1. 项目题干与考点拆解
1.1 题目到底在说什么
LeetCode hot100 第4题“移动零”,原题编号其实是283,题目描述非常短:给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。
举个例子,输入[0, 1, 0, 3, 12],输出应该是[1, 3, 12, 0, 0]。注意两个要求:必须在原数组上操作,不能拷贝额外的数组;非零元素的相对顺序不能变。
这道题看着简单,但它几乎是所有刷题人入双指针的门槛。hot100 把它排在这么靠前的位置,不是因为它难,而是因为它能一次性考察你对数组操作、指针思维、复杂度分析这三个基本功的掌握程度。网上说的“指针解法”,核心就是快慢双指针,这也是面试官最希望看到的答案。
1.2 为什么这道题“简单但不轻易满分”
我见过很多人上来就写两层循环冒泡式地把 0 往后挪,或者新建一个数组把非零挑出来再补 0。这两种做法都能跑通,但都不符合出题人的预期。原因在于:第一种时间复杂度 O(n²),虽然数据量小的时候看不出问题,但面试官会追问“能不能一次遍历”;第二种直接开辟了新数组,空间复杂度 O(n),违背了题目里“原地操作”的潜台词。
这道题真正要考察的其实就两件事:能不能想到用指针把“非零元素”和“零元素”分区,以及在交换或覆写的时候能不能保证稳定性。后面我会把三条路线全部写出来,对比完你自然就明白为什么双指针是正解。
2. 从最笨的解到最优解,思路是怎么长出来的
2.1 方案一:额外数组 + 两次遍历,最直觉但最“贵”
如果第一次刷题,没读过题里的“原地”限制,最容易想到的思路是:开一个新数组,先把所有非零元素按顺序塞进去,再把剩下的位置补 0,最后把新数组内容拷回原数组。
def move_zeroes_extra(nums): n = len(nums) result = [0] * n idx = 0 for num in nums: if num != 0: result[idx] = num idx += 1 # 将结果写回原数组 for i in range(n): nums[i] = result[i]时间复杂度 O(n),空间复杂度 O(n)。这个方案能 AC(Accepted),但它有两个明显问题:空间浪费和没必要的一趟写回。如果面试官追问“能不能不用额外空间”,那就得回到原地操作上了。
这里有个容易忽略的小点:题目说的是“函数将所有 0 移动到数组末尾”,并没有允许你返回新数组。所以哪怕你在函数内部构造了额外数组,最后也必须拷贝回原数组。这一点想清楚,就理解了为什么“原地”是硬约束。
2.2 方案二:前后双指针交换,一种直觉上的补丁
既然要把 0 往后放,那最自然的想法是“前后各一个指针”:左指针找 0,右指针找非零,找到就交换。这很像快排的 partition 思路。
def move_zeroes_swap(nums): left, right = 0, len(nums) - 1 while left < right: # 左指针找0 while left < right and nums[left] != 0: left += 1 # 右指针找非0 while left < right and nums[right] == 0: right -= 1 # 交换 nums[left], nums[right] = right, left等等,我故意写了一个 bug 在上面,你能看出来吗?nums[left], nums[right] = right, left把值写错了,应该写成nums[left], nums[right] = nums[right], nums[left]。这个笔误其实就是前后指针方案的第一个坑:交换的是数组元素的值,不是指针本身。
更严重的坑是:这种前后交换会把非零元素的相对顺序打乱。比如[1, 0, 0, 2],左指针在 index 1 找到 0,右指针从末尾找到 index 3 的非零 2,交换后变成[1, 2, 0, 0],看起来没问题。但如果数组是[1, 0, 2, 0, 3],左指针 index 1 和右指针 index 4 交换得[1, 3, 2, 0, 0],非零元素的相对顺序从 1、2、3 变成了 1、3、2,直接违背题意。
所以前后交换方案虽然时间复杂度是 O(n),但它破坏了稳定性。除非题目明确说“不要求保持相对顺序”,否则不要用。
2.3 方案三:快慢指针原地覆写,稳定且零额外空间
正解是快慢双指针,网上更多叫“快慢指针 + 覆写”,思路分成两步:
第一步,用快指针 fast 遍历数组,每遇到一个非零元素,就把它写到慢指针 slow 指向的位置,然后 slow 加一。这相当于把所有非零元素“挤”到数组前面,且因为 fast 是从头到尾顺序遍历,所以非零元素的相对顺序天然不变。
第二步,从 slow 开始到数组末尾,全部填充 0。
def move_zeroes(nums): slow = 0 n = len(nums) # 第一次遍历:把所有非零元素往前覆写 for fast in range(n): if nums[fast] != 0: nums[slow] = nums[fast] slow += 1 # 第二次遍历:末尾补0 while slow < n: nums[slow] = 0 slow += 1时间复杂度 O(n),空间复杂度 O(1),一次遍历完成覆写,不改变非零元素的相对顺序,而且完全原地操作。这就是那道题的标准答案。
有人可能会问:既然第二步还要再遍历一次,能不能优化成一次遍历?可以,那就是“遍历时直接交换”的写法,我在下一节单独讲,因为细节更值得花篇幅。
3. 核心代码实现与指针细节实战
3.1 C++ 实现以及数组下标与指针的辨析
先给出 LeetCode 上最常见的 C++ 写法,也就是单指针赋值的版本:
class Solution { public: void moveZeroes(vector<int>& nums) { int slow = 0; int n = nums.size(); for (int fast = 0; fast < n; ++fast) { if (nums[fast] != 0) { nums[slow++] = nums[fast]; } } while (slow < n) { nums[slow++] = 0; } } };很多初学者对nums[slow++] = nums[fast]这句很困惑。它其实等价于:
nums[slow] = nums[fast]; slow = slow + 1;这个写法背后的逻辑是把slow当成“下一个非零元素应该放的位置”。在 C++ 语境下,vector<int>的下标访问本质上走的是迭代器或指针运算,nums[slow]等价于*(nums.begin() + slow),所以在逻辑上,slow就是一个指针的概念——它指向当前待写入的位置。
你要是非要用真正的指针来写,在 C 风格数组下可以这样:
void moveZeroes(int* nums, int numsSize) { int* slow = nums; int* fast = nums; for (; fast < nums + numsSize; ++fast) { if (*fast != 0) { *slow = *fast; ++slow; } } while (slow < nums + numsSize) { *slow = 0; ++slow; } }注意这里slow和fast都是真正的指针变量,fast < nums + numsSize是尾后指针比较,*slow = *fast是指针解引用赋值。这个版本能帮你更直观地理解“指针移动”到底移动的是什么:不是数组元素,而是指向数组某个位置的地址。
我在实际教别人的时候发现,很多人分不清“指针变量”和“指针的值”。在这个例子中,slow这个指针变量的值是一个地址,*slow才是这个地址上存放的值。移动指针是改地址,赋值是改值,两码事。
3.2 一次遍历的交换版本:可读性与性能的平衡
LeetCode 热评区和题解里还有另一种一次遍历写法,核心思想是:快指针遇到非零元素,就和慢指针交换:
class Solution { public: void moveZeroes(vector<int>& nums) { int slow = 0; for (int fast = 0; fast < nums.size(); ++fast) { if (nums[fast] != 0) { swap(nums[slow], nums[fast]); ++slow; } } } };这样确实只需要一次遍历,而且不需要补 0 的那一轮循环。我实测下来,性能差异可以忽略不计,因为第二次循环本身就是 O(n) 里的一次普通遍历,两个版本时间复杂度完全一样,都是 O(n)。但交换版本有个隐藏问题:当slow == fast时,交换的是同一个元素,做了无用功。
更优雅的写法是加一个判断:
if (nums[fast] != 0) { if (slow != fast) { swap(nums[slow], nums[fast]); } ++slow; }这个判断能减少大量自我交换。数据量小的时候无感,但数组有几万个元素且绝大多数非零时,能省下不少无意义的写操作。我个人更推荐初学阶段用“先覆写再补零”的两段式版本,逻辑更清晰;面试时如果追求代码简洁,再写交换版本也不迟。
3.3 三种语言的快速对照,方便直接抄作业
Java 版本:
class Solution { public void moveZeroes(int[] nums) { int slow = 0; for (int fast = 0; fast < nums.length; fast++) { if (nums[fast] != 0) { nums[slow] = nums[fast]; slow++; } } while (slow < nums.length) { nums[slow] = 0; slow++; } } }Python 版本:
class Solution: def moveZeroes(self, nums: List[int]) -> None: slow = 0 for fast in range(len(nums)): if nums[fast] != 0: nums[slow] = nums[fast] slow += 1 for i in range(slow, len(nums)): nums[i] = 0JavaScript 版本:
var moveZeroes = function(nums) { let slow = 0; for (let fast = 0; fast < nums.length; fast++) { if (nums[fast] !== 0) { nums[slow] = nums[fast]; slow++; } } while (slow < nums.length) { nums[slow] = 0; slow++; } };提醒一点,Python 里如果你写成nums[:] = sorted(nums, key=lambda x: x == 0)也能过,但那是在炫技,面试官不一定会认可。老老实实双指针才是正道。
4. 边界条件、测试用例与常见 BUG 排查实录
4.1 边界情况速查表
这道题虽然简单,但边界条件一旦漏掉,很容易写出“看起来对,跑起来错”的代码。我整理了一张测试用例表,建议刷题时逐条过:
| 测试用例 | 期望输出 | 容易犯的错 |
|---|---|---|
空数组[] | [] | 无,但要注意慢指针循环条件别越界 |
单元素[0] | [0] | 如果写交换逻辑,可能出现自己和自己交换 |
单元素[1] | [1] | 同上 |
全零[0,0,0] | [0,0,0] | 非零遍历不执行,补零循环要把整个数组填满 |
无零[1,2,3] | [1,2,3] | 覆写版本中 slow 跑到末尾,补零循环不执行 |
零在前[0,0,1,2] | [1,2,0,0] | 覆写版本没问题,但交换版本容易把顺序弄乱 |
零在中间[1,0,2,0,3] | [1,2,3,0,0] | 前后双指针交换会破坏 1,2,3 的顺序 |
交替出现[0,1,0,1,0] | [1,1,0,0,0] | 相对顺序要保持,两个 1 的顺序不能变 |
我自己刷题时最讨厌全零和无零这两种极端输入,因为它们最容易暴露“慢指针没有推进”或“补零循环没执行”的问题。
4.2 三个我实际踩过的坑
第一个坑:把 slow 写成局部变量却在函数外面用。C++ 版本里如果写成int slow;而不初始化,slow 的值是未定义的,遍历时直接数组越界。很多刷题平台不给太多调试信息,报错还是“AddressSanitizer: heap-buffer-overflow”,特别难排查。解决办法是每次声明指针(下标)变量都要初始化,int slow = 0写死。
第二个坑:交换时把下标和值搞混。我见过有人写出swap(slow, fast),结果数组元素根本没变,因为交换的是两个局部整数变量,不是数组里的值。这实际上是 C++ 里最常见的“传值不传引用”困惑:swap的两个参数必须是可以修改的左值,nums[slow]是可以修改的,而裸的slow只是一个局部变量副本。如果非要自己写交换,请这样写:
int tmp = nums[slow]; nums[slow] = nums[fast]; nums[fast] = tmp;第三个坑:对nums.size()反复调用导致的边界判断混乱。vector<int>::size()返回的是无符号整数size_t,如果你用for (int i = 0; i < nums.size() - 1; i++),当nums为空时,nums.size() - 1会变成很大的正数(无符号溢出),循环直接飞出去。这道题一般不会踩到,但如果你把代码改成从末尾倒序遍历时就要非常小心。
4.3 如何用测试用例快速定位“不稳定”的写法
如果你用的是“前后双指针交换”那种不稳定方案,我建议你专门准备一个用例:[1, 2, 0, 3, 0, 4]。跑完后如果输出是[1, 2, 4, 3, 0, 0],说明 3 和 4 的相对顺序被破坏;正确输出应该是[1, 2, 3, 4, 0, 0]。
判断“顺序是否被破坏”有个直觉方法:把所有非零元素按原顺序单独抄出来,看和最终结果里的非零部分是否一致。如果一致,说明你的算法是稳定的。数组移动零,是否稳定往往决定了算法能不能通过面试官后续的追问。
5. 从移动零延伸到更广的指针思维
5.1 快慢指针模式的一鱼多吃
只做这一道题其实“吃不饱”,因为快慢指针是一整套方法论。LeetCode 27 题“移除元素”几乎就是把移动零的判定条件从nums[fast] != 0改成nums[fast] != val,代码结构一模一样。LeetCode 26 题“删除有序数组中的重复项”也是快慢指针,只不过赋值条件变成了nums[fast] != nums[slow - 1]。
我建议你把这个模式总结成四步口诀:快指针负责“找”,慢指针负责“存”,找到符合条件的元素就往前存,最后慢指针的位置就是新数组的长度或末尾。只要看透了这一点,后面做“移动石头”“移动负数到数组末尾”“把奇偶数分区”一类题目,都是同一套模板。
5.2 面试时怎么讲才能把 30 行代码讲出亮点
这道题在面试里被视为“热身题”,但正是热身题最容易看出候选人的表达功底。我比较推荐的讲解顺序是:
先说明白题目两个硬约束:原地操作 + 保持相对顺序。然后说“我可以用快慢指针解决”,不要直接甩代码。先画一个例子:[0, 1, 0, 3, 12],slow 指向 0,fast 从 0 往后扫,遇到第一个非零元素 1 时,把它写到 slow 的位置,slow 后移。然后再解释为什么这样不会乱序:因为 fast 是按顺序遍历的,先遇到谁就先写谁,天然有序。最后说复杂度 O(n)、O(1)。
面试官通常会追问两个问题:如果元素不是 0 而是“负数”,你怎么办?如果要求把所有 0 移到开头呢?这两个追问都不难,只要把判断条件从!= 0改成< 0或者== val,把补零改成补目标值,思路完全一样。真正想加分的话,可以主动提一句“这个解法是稳定的,这一点对保持相对顺序很关键”,几乎所有面试官都会眼睛一亮。
5.3 C++ 指针细节的后续延伸
如果你是因为这道题被“指针”两个字吸引进来的,那我要说明一下:LeetCode 刷题里的“指针解法”更多是思想层面的指针,也就是用下标模拟指针的思路;但理解真正的 C/C++ 指针,反过来会强化你对这个算法的理解。
比如nums[fast] != 0这句话,在 C/C++ 里等价于*(nums + fast) != 0。这里的nums在传递时会退化成指向首元素的指针,nums + fast是指针运算,*(nums + fast)才是数组元素。所以“移动零”的每一步操作,本质都是在说:通过指针运算找到某个位置的元素,然后把它挪到另一个指针指向的位置。
我见过很多同学看完这道题去啃“指针数组”“函数指针”“指向指针的指针”,结果越啃越乱。我的建议是:先把双指针的“思想版”练熟,再回头把 C 语言的指针语法补上。你会有一种“原来当时的内层机制是这回事”的顿悟感。等你能轻松区分“指针的值”和“指针指向的值”之后,再看智能指针的实现也会顺手很多。
5.4 这道题的更多变体,值得自己动手练一遍
移动零不止 LeetCode 283 这一道题,它作为套路题有很多变形:
- 把 0 移到数组开头,其他保持顺序。做法是把快慢指针从末尾往前遍历,或者先逆序再处理。
- 把负数移到左边,正数移到右边,且各自保持相对顺序。这时候需要两次移动,一次负数到前,一次正数归位。
- 在链表里“移动零”,用指针去删除和插入节点,考察点和数组完全不同。
- 把某个特征值
x全部移除,这就是 LeetCode 27 的变体。
我自己刷这些变体时有一个习惯:每做完一题,就在原题下面写一行“变换条件”,比如“把 0 改成 val”“把数组改链表”“把顺序改成不要求稳定”。下次再遇到新题,先扫一眼注释,就能快速匹配模板。
6. 最后的经验总结
这道题本身不复杂,但我带过不少人刷它,发现一个共同现象:代码写对很容易,但讲清楚为什么用双指针却很难。如果你也是第一次接触这类题,我建议你哪怕已经 AC 了,也再花十分钟做一件事:把快慢指针每一步的 slow 和 fast 位置画在一张纸上。画完你会真正理解什么叫“慢指针记录结果区间的末尾,快指针遍历整个输入区间”。
我个人的体会是,移动零这道题最大的价值不在于“会不会”,而在于它把“指针”从 C 语言语法层面上升到了“算法设计思想”层面。以前你写int *p = &a可能只是背语法;做完这道题你会发现,很多数组的原地操作都可以用指针移动来建模。这种思维上的转变,比多背二十道题的答案有用得多。
如果你正要刷 LeetCode hot100,建议按顺序刷,因为第 4 题之后很快会遇到更多双指针题,比如“盛最多水的容器”“三数之和”“接雨水”,移动零就是最开始那个最容易上手的引子。把它彻底吃透,后面能省不少力。