283. 移动零#
生活中的算法#
你有没有整理过房间?常常会发现一些要丢掉的东西,但又不想立刻处理。这时候,我们通常会先把这些东西推到角落,把有用的东西集中在一起。等整理完后,再统一处理角落里的那些要丢弃的物品。
这就很像我们今天要讲的”移动零”问题:把数组中的零移到末尾,同时保持其他元素的相对顺序不变。就像整理房间时,我们把不要的东西(零)移到角落,同时保持其他物品(非零元素)的摆放顺序不变。
问题描述#
题目目标#
给定一个数组 nums,请将所有 0 移动到数组末尾,同时保持非零元素的相对顺序不变。要求必须在原数组上原地操作,不能额外创建同规模数组。
示例 1#
输入: nums = [0,1,0,3,12]
输出: [1,3,12,0,0]
数组变化示意:
原数组: [0, 1, 0, 3, 12]
提取非 0: [1, 3, 12]
补齐 0: [1, 3, 12, 0, 0]text说明: 所有非零元素仍按原顺序排列,两个 0 被移动到了数组末尾。
补充说明#
- 这是一个原地修改问题,返回结果体现在原数组中。
- 关键是同时满足“非零元素相对顺序不变”和“额外空间尽量少”。
最直观的解法:两次遍历法#
最容易想到的方法就是:先把所有非零元素按顺序排在数组前面,然后把剩下的位置都填上0。就像整理房间时,先把要留下的东西整理好,然后剩下的空间就是要清理的区域。
具体步骤是这样的:
- 用一个指针记录当前应该放置非零元素的位置
- 遍历数组,遇到非零元素就放到这个位置,并移动指针
- 最后,从指针位置到数组末尾都填充0
让我们用一个例子来模拟这个过程:
原数组:[0,1,0,3,12]
第一次遍历(移动非零元素):
pos = 0, 遇到0,跳过
pos = 0, 遇到1,放入pos位置:[1,1,0,3,12],pos++
pos = 1, 遇到0,跳过
pos = 1, 遇到3,放入pos位置:[1,3,0,3,12],pos++
pos = 2, 遇到12,放入pos位置:[1,3,12,3,12],pos++
第二次遍历(填充0):
从pos=3开始填充0:[1,3,12,0,0]plaintext这种思路可以用Java代码这样实现:
public void moveZeroes(int[] nums) {
// pos 表示“下一个非零元素应该放到哪个下标”
int pos = 0;
// 第一次遍历:按原顺序收集所有非零元素,紧凑地放到数组前面
// num 表示当前遍历到的元素值
for (int num : nums) {
// 只有非零元素才需要向前放置
if (num != 0) {
// 把当前非零元素放到 pos 指向的位置
nums[pos] = num;
// 放置完成后,pos 后移一位,准备放下一个非零元素
pos++;
}
}
// 第二次遍历:把剩余位置全部补成 0
// 这些位置原本是被“挤走”的元素区域,题目要求最终都为 0
while (pos < nums.length) {
// 将当前位置设为 0
nums[pos] = 0;
// 继续处理下一个位置
pos++;
}
}java优化解法:单次遍历法#
仔细观察可以发现,我们其实可以用一次遍历就完成任务。关键是用两个指针:一个指向当前应该放置非零元素的位置,另一个用来遍历数组。当遇到非零元素时,把它和前面的零交换位置。
单次遍历法的原理#
- 用左指针记录下一个非零元素应该放置的位置
- 用右指针遍历数组
- 当右指针遇到非零元素时,将其与左指针指向的位置交换
- 左指针只有在处理非零元素时才移动
算法步骤(伪代码)#
- 初始化左指针left = 0
- 遍历数组,右指针right从0到末尾:
- 如果遇到非零元素
- 交换left和right位置的元素
- left指针右移
- 完成后,所有零都在数组末尾
示例运行#
让我们用示例数组[0,1,0,3,12]模拟运行过程:
初始状态:[0,1,0,3,12],left=0,right=0
right=0:
- 遇到0,不操作
right=1:
- 遇到1,与left交换:[1,0,0,3,12]
- left移动到1
right=2:
- 遇到0,不操作
right=3:
- 遇到3,与left交换:[1,3,0,0,12]
- left移动到2
right=4:
- 遇到12,与left交换:[1,3,12,0,0]
- left移动到3
结束plaintextJava代码实现#
public void moveZeroes(int[] nums) {
// left 表示下一个非零元素应该放置的位置(慢指针)
int left = 0;
// right 负责从左到右扫描数组(快指针)
for (int right = 0; right < nums.length; right++) {
// 只有遇到非零元素才需要处理
if (nums[right] != 0) {
// 当 left == right 时,说明当前元素本来就在正确位置,无需交换
// 当 left != right 时,说明 right 指向非零、left 指向待填位置(通常是 0),需要交换
if (left != right) {
// temp 暂存 left 位置的值,避免覆盖
int temp = nums[left];
// 把 right 的非零值放到前面的有效区域
nums[left] = nums[right];
// 把原 left 的值放到 right,完成交换
nums[right] = temp;
}
// 一个非零元素归位后,left 后移,指向下一个待放置位置
left++;
}
}
}java两次遍历vs单次遍历#
让我们比较这两种解法:
两次遍历法的时间复杂度是O(n),需要遍历两次数组。它的优点是逻辑简单清晰,容易理解和实现。
单次遍历法的时间复杂度也是O(n),但只需要遍历一次数组。它通过巧妙的指针操作,一次遍历就完成了任务。虽然实现稍微复杂一些,但在实际运行时更高效。
两种方法的空间复杂度都是O(1),因为都是在原数组上进行操作。
题目模式总结#
这道题体现了一个重要的数组操作模式:双指针技巧。
这种技巧在数组操作中经常出现,比如:
- 删除数组中的重复元素
- 合并两个有序数组
- 判断是否是回文数组
解决这类问题的通用思路是:
- 确定两个指针的用途(比如一个用于记录位置,一个用于遍历)
- 明确指针移动的条件
- 考虑元素交换或移动的时机
小结#
通过这道题,我们不仅学会了如何高效地移动数组中的零元素,更重要的是掌握了双指针这一重要的编程技巧。这种技巧在处理数组问题时特别有用,能帮助我们写出更高效的代码。
记住,有时候看似简单的问题,通过巧妙的算法设计,能让解决方案变得更加优雅高效!