moveZeroes 这道题,我第一次见是在一次电话面试里。面试官问得很随意:给你一个整数数组,把所有 0 移到末尾,保持非零元素的相对顺序不变,要求原地操作。我心想这还不简单,结果一紧张写了个遍历加 splice 加 push 的写法,被追问了一句“时间复杂度是多少”,当场就有点冒汗。后来刷题刷得多了,才发现这道题几乎是双指针类问题里最经典、最入门、也最考验基本功的一道,LeetCode 编号 283,热度常年居高不下。
如果你正准备面试,或者刚开始刷算法,这道题一定值得吃透。它表面上只是“移动零”,实际上覆盖了数组原地修改、指针语义、稳定性、复杂度分析这些高频考点。更妙的是,它有好几种层层递进的解法,可以从暴力一路优化到 O(n) 时间、O(1) 空间的最优解,非常适合用来建立“先暴力、再优化”的解题思维。
这篇文章我把自己的刷题过程、面试中被追问过的细节、写代码时踩过的坑全部整理出来,尽量讲透,而不是只贴一个答案。
1. 题目解构:移动零到底在考什么
1.1 一句话说清题目要求
题目本身非常短:给定一个数组nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。重点是“原地操作”,也就是说不能新建一个数组再拷贝回去,必须在原数组上完成修改。
举个例子:输入[0, 1, 0, 3, 12],输出应该是[1, 3, 12, 0, 0]。非零元素 1、3、12 的顺序不能变,两个 0 被挪到了最后面。
题目表面上的约束就这几条,但越简单的题越能看出功底。很多人在 LeetCode 上能把代码跑通,但面试时一被追问就露馅,原因在于没有真正理解每一步指针移动背后的含义。
1.2 为什么这道题被面试官偏爱
这道题在面试中出现频率极高,不是因为它难,而是因为它“门槛低、上限高”。门槛低是指题目描述任何人都能看懂,不需要什么高深的数学背景;上限高是指它可以从多个角度切入,衍生出不少值得讨论的话题。
我记得有一次面试官在我写完代码之后,连续追问了好几个问题:为什么用两个指针而不是一个?能不能用交换代替覆盖?如果要求保持 0 的相对顺序怎么办?如果数组里不是 0,而是某个重复出现的值呢?每一个追问都指向一个更本质的问题:你有没有真正理解这段代码,还是只是背下来了。
另外,这道题和快速排序里的 partition 思想关系非常紧密。快排的 partition 本质上就是选一个基准值,把小于基准的放左边、大于基准的放右边,而移动零正是“以 0 为基准,把所有非零元素放到左边”的特例。理解了这一点,以后遇到荷兰国旗问题、奇偶分离问题,都能迅速迁移。
1.3 常见错误思路:不要一上来就想着删元素
我见过不少新手的第一反应是:遍历数组,遇到 0 就删掉,然后在末尾补一个 0。思路本身没错,但在 JavaScript 里如果写成splice,时间复杂度直接变成 O(n²),因为splice删除元素会触发后续所有元素的前移,每删一次都是 O(n) 的操作。
还有一次我帮同事 review 代码,看到他写的是用filter过滤出非零元素,再拼上相应数量的 0,然后逐个赋值回原数组。这样确实能通过测试,空间复杂度也不低,而且违背了“原地修改”的考察意图。面试官想看的是你能不能在一个循环里完成这件事,而不是用语言自带的高级函数绕过问题。
2. 解法演进:从暴力到双指针的完整脉络
2.1 暴力解法:能跑,但远远不够
先说我最早写出来的那个版本,思路很直接:遍历数组,每遇到一个 0,就把它从当前位置“删除”,然后在数组末尾 push 一个 0。用 JavaScript 写大概长这样:
function moveZeroes(nums) { for (let i = 0; i < nums.length; i++) { if (nums[i] === 0) { nums.splice(i, 1); nums.push(0); } } }上面这个写法其实还有隐藏 bug。splice会改变数组长度和索引,删掉一个 0 之后,后面的元素会往前移,此时循环里的i++会跳过下一个本该检查的元素。也就是说,连续出现两个 0 的时候,第二个 0 可能没被处理到。正确做法的处理方式更繁琐,这里不展开。更重要的是,这个解法的时间复杂度是 O(n²),空间上是 O(1),勉强能跑,但完全不符合面试期望。
另一种常见的暴力思路是:遍历数组,遇到 0,就和它后面的第一个非零元素交换位置。例如[0, 1, 0, 3],遇到索引 0 的 0,找到后面第一个非零元素 1,交换得到[1, 0, 0, 3];继续遍历会遇到新的 0,再次往后找非零元素。这个方法也是 O(n²),因为每次找“后面的第一个非零元素”都需要内层循环。
暴力的意义不在于提交,而在于帮助建立直觉:我们最终的目标就是“避免反复搬运元素”,让每个非零元素最多被移动一次。
2.2 双指针覆盖法:最优解的第一种写法
用两个指针,一个慢指针slow表示“下一个非零元素应该放的位置”,一个快指针fast遍历整个数组。快指针每遇到一个非零元素,就把它写到slow指向的位置,然后slow前进一步。遍历结束后,把slow之后的所有位置都填成 0。
用 JavaScript 写:
function moveZeroes(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++; } }这个写法的精妙之处在于,快指针负责“找”,慢指针负责“放”。整个过程每个元素最多被读一次、写一次,最后再统一填零,总的时间复杂度是 O(n),空间复杂度是 O(1)。
需要注意的是,这个方法是“覆盖”而不是“交换”。当fast和slow指向同一个位置时,nums[slow] = nums[fast]相当于自赋值,没有副作用。当它们不同时,fast位置的原值会被slow位置覆盖,但因为最后会把尾部统一置零,所以我们并不担心丢掉什么。
我建议第一次学这道题的人先用覆盖法理解指针语义,再去看交换法。
2.3 交换法:快排 partition 思想的优雅版本
交换法的写法更贴近快速排序的分区逻辑:慢指针slow始终指向当前已处理区间中第一个为 0 的位置,快指针遍历数组,遇到非零元素就和slow交换,然后slow前进一位。
function moveZeroes(nums) { let slow = 0; for (let fast = 0; fast < nums.length; fast++) { if (nums[fast] !== 0) { [nums[slow], nums[fast]] = [nums[fast], nums[slow]]; slow++; } } }举例走一遍:[0, 1, 0, 3, 12]。
fast = 0,nums[0] = 0,非零条件不满足,跳过。fast = 1,nums[1] = 1,与nums[0]交换,数组变成[1, 0, 0, 3, 12],slow = 1。fast = 2,nums[2] = 0,跳过。fast = 3,nums[3] = 3,与nums[1]交换,数组变成[1, 3, 0, 0, 12],slow = 2。fast = 4,nums[4] = 12,与nums[2]交换,数组变成[1, 3, 12, 0, 0],slow = 3。
整个过程很像冒泡,但实际上每个非零元素只被交换一次,0 被“滚”到了数组尾部。这个方法最大的优点是天然保持非零元素和 0 的相对顺序,而且不需要最后再补一轮置零操作,代码更短。
面试时我通常先写覆盖法(更直观),然后主动提一句“其实也可以用交换法,思路和快排的 partition 一样”,往往能带来加分效果。
2.4 滚雪球法:一个有趣但不够主流的思路
还有一种写法在讨论区偶尔能看到,思路是记录连续 0 的个数,遇到非零元素时直接跨越这段“雪球”交换。代码大概是:
function moveZeroes(nums) { let snowballSize = 0; for (let i = 0; i < nums.length; i++) { if (nums[i] === 0) { snowballSize++; } else if (snowballSize > 0) { nums[i - snowballSize] = nums[i]; nums[i] = 0; } } }这个写法的本质还是双指针,只是用snowballSize这个变量隐式记录慢指针的位置。我面试时不太推荐主动写这个版本,因为i - snowballSize这个索引关系比较绕,面试官需要花时间理解。除非你能把它讲得很清楚,否则不如老老实实写双指针。
3. 核心实现与代码细节:多语言对照与边界处理
3.1 Python 和 Java 的写法要点
这道题在 LeetCode 上支持多种语言,虽然逻辑一样,但写法和注意事项略有不同。
Python 版本,注意函数签名要求在None类型上原地修改:
def move_zeroes(nums: list[int]) -> None: slow = 0 for fast in range(len(nums)): if nums[fast] != 0: nums[slow], nums[fast] = nums[fast], nums[slow] slow += 1Python 的多重赋值交换元素非常优雅,但要注意nums[slow], nums[fast] = nums[fast], nums[slow]是先计算右侧的,再批量赋值,所以即使slow和fast相等也没问题。
Java 版本:
public void moveZeroes(int[] nums) { int slow = 0; for (int fast = 0; fast < nums.length; fast++) { if (nums[fast] != 0) { int temp = nums[slow]; nums[slow] = nums[fast]; nums[fast] = temp; slow++; } } }Java 没有元组交换,必须用临时变量,多写三行。还有一个细节:LeetCode 的moveZeroes方法没有返回值,正确做法是直接修改入参数组,不要新建数组再返回。
3.2 边界条件与特殊情况全梳理
写这道题的时候,最容易翻车的地方不是主逻辑,而是边界条件。我把常见情况整理成一张表,方便自查:
| 输入情况 | 预期行为 | 注意事项 |
|---|---|---|
空数组[] | 不做任何操作 | 循环不会进入,注意索引访问越界 |
只有 1 个元素[0]或[1] | 原样返回 | 交换时slow和fast指向相同位置 |
所有元素都是 0[0, 0, 0] | 原样返回 | slow始终保持 0,无交换发生 |
没有 0[1, 2, 3] | 原样返回 | 每个元素都自交换一次,不影响结果 |
0 在开头[0, 1, 2] | [1, 2, 0] | 第一个非零元素与 0 交换 |
0 在结尾[1, 2, 0] | 原样返回 | 快指针遍历完所有 0 后没有交换 |
我在写覆盖法时还遇到过一个细节:第二个循环里while (slow < nums.length)其实也可以写成for (let i = slow; i < nums.length; i++),两种写法都一样。但要注意别把条件写成slow <= nums.length,那会越界。
3.3 复杂度分析:为什么 O(n) 就是最优
时间上,双指针法只遍历数组一次,每个元素最多被访问两次(一次被快指针读,一次被慢指针写),所以时间复杂度是 O(n)。空间上,只用了slow和fast两个额外变量,不随输入规模增长,所以空间复杂度是 O(1)。
很多同学会问:能不能做到比 O(n) 更快?答案是不能。因为数组中的每个元素都至少需要被检查一次,才知道它是不是 0,这本身就是 O(n) 的下界。至于空间,如果允许额外开一个数组,那当然更简单,但题目明确要求原地操作,O(1) 就是最优。
面试官有时候会追问:覆盖法和交换法那个更好?从结果上看都是 O(n) + O(1),但从操作次数上,交换法每个非零元素和 0 交换需要三次赋值操作,而覆盖法只需要一次赋值,最后统一填零的时候每个尾部位置再赋一次值。整体来说覆盖法的赋值次数更少,常数项更优,但两者的差距在实际运行中几乎可以忽略。我倾向于在代码可读性上做选择。
4. 实操心得与常见坑:这些细节没人提醒过你
4.1 我在面试和刷题中踩过的真实的坑
第一个坑就是for循环内用splice导致跳过元素。这个前面提过,连续 0 的场景下,删除一个 0 后,下一个 0 的位置前移了,但循环索引已经加一,于是跳过了它。最后测试用例[0, 0, 1]直接失败。
第二个坑是用forEach配合索引删除。JavaScript 的forEach不允许在遍历时修改数组长度,否则会出现非常诡异的行为。而且你拿不到原始索引,删除元素后索引错乱得更厉害。我的建议是:凡是涉及数组内容修改的算法题,优先用最原始的for循环,方便控制索引。
第三个坑是忘记函数签名或返回值要求。有一次我在白板上写 Java 解法,写完给面试官讲思路,面试官提醒我“题目要求原地修改,不需要返回新数组”,我才发现自己的方法签名多了return nums。这种细节平时刷题看不出来,因为 LeetCode 只检测最终数组状态,但在真正的面试里,规范和严谨程度同样影响评价。
第四个坑是忽略稳定性。如果题面改成“把所有的 1 移到末尾,但 0 要保持原顺序”,那就是另一个问题了。移动零本身不要求保持 0 的顺序,因为所有 0 都相同,看不出区别。但面试官很可能会借这个点扩展提问。
4.2 变种题与扩展思路
我强烈建议刷完 moveZeroes 后立刻做两道变种题,巩固理解:
第一道是 LeetCode 27,移除元素。给定一个数组和一个值val,原地移除所有等于val的元素,返回新长度。解法几乎一模一样,只是把nums[fast] !== 0换成nums[fast] !== val。
第二道是奇偶分离。给定一个数组,把奇数放在前面、偶数放在后面,保持相对顺序。这时候不能简单选 0 作为基准,而是要判断奇偶性。如果你理解了 partition 的本质是从“选基准、分两边”的角度思考,这两道题都迎刃而解。
再扩展一点,就是快速排序里的 partition 函数。快速排序的一次 partition 其实就是选一个 pivot,把小于等于 pivot 的元素挪到左边,大于的挪到右边。moveZeroes 可以看成 pivot = 0 且只看是否等于的场景。理解了这一层,后面看荷兰国旗问题(三向切分)会顺畅很多。
4.3 如何验证你的解法是正确的
写完代码别急着提交,先在草稿纸上手动跑一遍最复杂的测试用例。我自己习惯用一个“0 夹在中间”的用例,比如[1, 0, 2, 0, 3, 0, 4],因为这种情况下指针的移动最能暴露问题。
另一个验证技巧是用随机测试。写一个简单的暴力解法作为基准,再随机生成大量包含 0 和非零元素的数组,用断言对比两种解法的结果。我之前刷题时用过这个办法,对于检测边界 bug 非常有效。
还有一个小技巧:在代码里临时打印slow和fast每一步的变化,跑几个用例后就能直观感受到指针是怎么移动的。理解透了再删掉打印语句。
5. 从移动零提炼通用套路:双指针解题方法论
5.1 怎么快速识别一道题能不能用双指针
经过大量刷题,我自己总结出双指针题型的几个识别特征:
- 数组是线性结构,要求在原地修改。
- 需要把满足某条件的元素统一挪到某个区域。
- 要求保持某种顺序(通常是相对顺序)。
- 暴力解需要两层循环,且内层循环做的事情非常单一。
如果一道题命中以上 2 到 3 条,大概率可以用双指针优化。移动零同时命中了全部 4 条,所以它才会被当作双指针的入门第一课。
常见的双指针模型大概有三类:快慢指针、左右对撞指针、滑动窗口。移动零属于快慢指针,一个负责探测,一个负责记录位置。第三个模型的典型代表是“最长无重复子串”,和本题解法结构完全不同,但总纲都是“用两个指针维护一个区间”。
5.2 一套可以直接套用的模板
根据我自己的经验,快慢指针处理数组原地修改类问题,有一个万能模板:
初始化慢指针 slow = 0 for (fast = 0; fast < nums.length; fast++) { if (满足某种条件) { 执行某些操作(赋值或交换) slow++ } } // 可能需要后续处理,比如填零、截断长度等这里的核心是必须想清楚“slow指向的是已经满足条件区间的下一个位置,还是当前已处理区间的边界”。不同的定义会导致完全不同的代码。
以移动零为例,定义slow为“下一个非零元素应该放的位置”,代码就是覆盖法;定义slow为“第一个 0 的位置”,代码就是交换法。两者殊途同归,但理解层面略有差别。
我建议刷题时不要只背代码,而是每道题都问自己三个问题:slow代表什么?fast代表什么?什么条件下推进slow?把这三个问题答清楚,才算真正掌握这道题。
5.3 面试中的表达话术与节奏建议
这道题如果在面试中出现,建议按下面的节奏回答:
第一,先复述题目,确认关键约束,比如“原地操作”“保持非零元素相对顺序”这两点。
第二,快速给出暴力思路和复杂度。可以说“最直观的做法是遇到 0 就删除并在末尾补零,但这样是 O(n²),显然不够好”。
第三,自然过渡到双指针。解释清楚两个指针的含义,然后边说边写代码。写代码的时候不要沉默,可以同步描述每一步在做什么。
第四,跑一个简单的例子验证,比如[0, 1, 0, 3, 12]。
第五,主动分析复杂度,说“时间复杂度 O(n),空间复杂度 O(1)”。
这一套走下来,既展示了思维过程,又体现了代码能力。我在几次模拟面试中按这个节奏走,反馈都不错。
6. 写在最后的经验之谈
这道题我前前后后写过不下二十遍,一开始总是想找“更聪明”的解法,比如用正则替换,或者用sort自定义比较函数。后来才意识到,这类基础题考察的从来不是花哨技巧,而是你能不能把一个简单的逻辑用最干净的方式写出来,并且讲清楚为什么这么写是对的。
如果你正在准备面试,我建议把 moveZeroes 当成一个标本,把它的三种解法、复杂度分析、边界条件、扩展变种全部整理到自己的笔记里。一道题吃透了,比囫囵吞枣刷十道题更有用。面试官很多时候并不是要看你会不会解这一题,而是看你面对一个看似简单的问题时,能不能保持清晰的思路和严谨的态度。