题目描述
给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。
注意:
-
必须在原数组上操作,不能拷贝额外数组
-
尽量减少操作次数
示例分析
示例 1
输入: nums = [0,1,0,3,12]
输出: [1,3,12,0,0]
解释:
-
所有非零元素按原来的顺序保留下来
-
所有
0被统一移动到数组末尾
示例 2
输入: nums = [0]
输出: [0]
只有一个元素,而且本身就是 0,所以结果不变。
解题思路
这道题的核心要求有两个:
-
把所有
0移动到末尾 -
非零元素的相对顺序不能变
很多同学第一次看到这题,可能会想到“遇到 0 就和后面的非零元素交换”。 这种做法虽然能做出来,但写起来麻烦,而且交换次数也可能比较多。
你给出的这份代码思路非常清晰,本质上是一个双指针覆盖写入的方法:
-
先把所有非零元素按顺序搬到数组前面
-
再把后面剩余的位置统一补成
0
这样既满足“原地修改”,也保证了非零元素顺序不变。
代码
class Solution {
public void moveZeroes(int[] nums) {
if(nums==null) {
return;
}
int j = 0;
for(int i=0;i<nums.length;++i) {
if(nums[i]!=0) {
nums[j++] = nums[i];
}
}
for(int i=j;i<nums.length;++i) {
nums[i] = 0;
}
}
}
双指针分别表示什么
这份代码里有两个关键下标:
-
i:负责遍历整个数组,寻找每一个非零元素 -
j:负责指向下一个应该放置非零元素的位置
你可以把它理解成:
-
i是“读指针” -
j是“写指针”
i 从左到右扫描数组。 每当发现一个非零元素,就把它写到 j 的位置,然后 j 往后移动一格。
这样第一轮结束后,数组前面就会按顺序放好所有非零元素。
为什么这样能保持相对顺序
这是这道题最关键的一点。
因为我们是按照原数组从左到右的顺序遍历的:
for(int i = 0; i < nums.length; ++i)
只要遇到非零元素,就立刻按当前顺序写到前面去。
所以写入前面的非零元素顺序,和它们在原数组中出现的顺序完全一致。
例如:
[0,1,0,3,12]
扫描顺序是:
-
先遇到
1 -
再遇到
3 -
最后遇到
12
所以前面被写成的结果一定是:
[1,3,12,...]
顺序不会乱。
第一轮循环在做什么
代码
int j = 0;
for(int i=0;i<nums.length;++i) {
if(nums[i]!=0) {
nums[j++] = nums[i];
}
}
含义
这段代码的目标是:
把所有非零元素依次压缩到数组前面。
举个例子
假设:
nums = [0,1,0,3,12]
初始:
j = 0
i = 0
nums[0] = 0
跳过。
i = 1
nums[1] = 1
是非零元素,写到 nums[j]:
nums[0] = 1
j = 1
数组变成:
[1,1,0,3,12]
这里虽然看起来中间有些值被覆盖得有点“乱”,但没关系,因为我们后面还会统一补零。
i = 2
nums[2] = 0
跳过。
i = 3
nums[3] = 3
写到 nums[1]:
nums[1] = 3
j = 2
数组变成:
[1,3,0,3,12]
i = 4
nums[4] = 12
写到 nums[2]:
nums[2] = 12
j = 3
数组变成:
[1,3,12,3,12]
第一轮结束后,数组前 j 个位置已经是所有非零元素:
[1,3,12, ... ]
这时候 j = 3,表示已经放好了 3 个非零元素。
第二轮循环在做什么
代码
for(int i=j;i<nums.length;++i) {
nums[i] = 0;
}
第一轮只是把非零元素移到了前面,但数组后半部分还残留着旧值。 所以第二轮的任务就是:
把剩余位置全部填成
0。
继续上面的例子:
第一轮后数组是:
[1,3,12,3,12]
现在从 j = 3 开始,把后面全部设为 0:
-
nums[3] = 0 -
nums[4] = 0
最终得到:
[1,3,12,0,0]
这就是正确答案。
为什么这题算原地修改
题目要求不能额外开一个新数组。
你这份代码里只使用了:
-
一个读指针
i -
一个写指针
j
并没有申请额外数组,所以满足题目的“原地操作”要求。
为什么不需要交换
很多人第一次做这题会写成“遇到非零元素就和前面的零交换”。
但你这份代码其实更简洁,因为它不是交换,而是“覆盖写入”。
这样做有两个好处:
-
逻辑更清楚
-
操作次数通常更少
因为每个非零元素最多只写一次,最后再统一补零,不需要来回交换。
代码逐段解析
1. 判空
if(nums==null) {
return;
}
如果数组为 null,直接返回,防止空指针异常。
2. 初始化写指针
int j = 0;
j 表示下一个非零元素应该写入的位置。
3. 第一轮:收集所有非零元素
for(int i=0;i<nums.length;++i) {
if(nums[i]!=0) {
nums[j++] = nums[i];
}
}
这一轮结束后:
-
nums[0...j-1]都是原数组中的非零元素 -
且顺序与原数组一致
4. 第二轮:补零
for(int i=j;i<nums.length;++i) {
nums[i] = 0;
}
把剩下的所有位置填成 0。
再看一个例子
假设:
nums = [4,0,5,0,0,3,0,1]
第一轮扫描后,前面会变成:
[4,5,3,1,...]
此时 j = 4。
第二轮把后面全部补零:
[4,5,3,1,0,0,0,0]
这就完成了“非零元素前移,零移到末尾”。
复杂度分析
设数组长度为 n。
时间复杂度
O(n)
虽然有两轮循环,但总的仍然是线性复杂度。
-
第一轮遍历一次数组
-
第二轮最多再遍历一次数组
所以时间复杂度是 O(n)。
空间复杂度
O(1)
只使用了常数个额外变量,没有使用额外数组。
和交换写法对比
这道题还有一种常见写法,也是双指针:
-
j指向当前应该放非零元素的位置 -
当
nums[i] != 0时,交换nums[i]和nums[j]
例如:
if (nums[i] != 0) {
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
j++;
}
交换写法的优点
-
一边遍历一边整理结果
-
最终不需要再单独补零
当前覆盖写法的优点
-
更直观
-
代码更简洁
-
更容易理解“先压缩非零元素,再统一补零”的思路
所以你这份代码非常适合作为这题的基础标准解法。
总结
这道题的核心可以概括成两步:
-
用一个写指针
j,把所有非零元素按顺序放到数组前面 -
再把剩余位置全部补成
0
也就是说:
前面放非零,后面补零。
双指针,一个负责扫描,一个负责写入,把非零元素稳定前移,最后统一补零。
这是一道非常经典的数组双指针题,思路简单但很有代表性,值得牢牢记住。










