刷了几年算法题,我越来越认同一件事:真正能把一道“简单题”讲清楚的,往往不是刚入门的选手,而是踩过坑、背过题、又回过头来重新理解它的老手。“移动零”就是这么一道题。它在很多在线评测平台上都有收录,题目短到一句话就能说完:把数组里的所有 0 挪到末尾,同时保持非零元素的相对顺序。听起来像小学三年级就能做的整理书包任务,可就是这么一道题,我在面试别人时用过,在被别人面时遇到过,在给新人做 Code Review 时也反复拿出来讨论过。一次两次下来我才发现,这道题根本不是在考“你会不会移动 0”,而是在考你懂不懂原地操作、懂不懂双指针、懂不懂什么叫循环不变量。这篇文章我就把这道题从里到外拆开讲一遍,包括各种解法的思考路径、双指针为什么是正解、它背后链接的哪一大类算法思想,以及我实际面试中看到的典型翻车现场。
1. 题目到底在考什么
1.1 先还原一下题目本身
给你一个数组,比如[0,1,0,3,12],要求把 0 全部移到数组末尾,非零元素保持原来的相对顺序。结果应该是[1,3,12,0,0]。题目还有两个明确约束:必须原地操作,不能拷贝一份新数组;另外要尽量减少操作次数。
很多人看到“减少操作次数”这句话就懵了,因为这类题目在评测时只看最终数组对不对,很少真的去统计你交换了几次。但我可以明确告诉你,这行条件是出题人精心埋下的。如果你用“遇到 0 就往末尾推”这种粗暴思路,最坏情况下一个非零元素可能被来回移动好几次,数组长度一大,性能差距就显现出来了。真正的正解,是让每个非零元素最多移动一次。
再说“保持相对顺序”这件事,它直接排除了很多看起来很聪明的做法。比如有人会想:先拿两个指针,一个从头扫、一个从尾扫,看到 0 就和尾部非零交换。数组[0,1,0,3,12]如果这样做,很容易变成[12,1,3,0,0],虽然把 0 都赶到了末尾,但非零元素顺序全乱了,不符合要求。这两个细节叠加在一起,就把题目从“随便搞搞”变成了“有约束优化问题”。
1.2 潜在考点:一眼看穿的三种能力
我在面试中特别喜欢拿这道题做开场,因为它能快速区分出三种人。
第一种是没怎么刷过题的人,上来就开一个新数组,把非零复制进去,再在后面补 0。逻辑没问题,但一听到“原地操作”就卡住,说明对空间复杂度的意识还没建立。
第二种是刷过题但理解浅的人,知道要用双指针,代码也能 AC,但你问他“为什么这个循环里要交换而不是直接赋值?”“你的快指针和慢指针各自代表什么状态?”他会支支吾吾。这说明他只是背了模板,没有真正理解算法核心。
第三种是真正理解的人,他会先跟你讲清楚两个指针的语义,讲清楚每一步操作之后数组满足什么性质,甚至能主动告诉你这道题本质上是一种稳定分区操作,和快排里的 partition、排序数组去重、按奇偶排序都是同一套思想。这种人,后续系统设计题我也愿意多聊两句。
所以你看,一道简单题从来不简单,它考查的是底层思维模型是否扎实。接下来我从最朴素的解法开始,一步步推演到最优解。
2. 从暴力解法开始的思维热身
2.1 辅助数组方案为什么不算正解
最简单的做法是遍历一次数组,把非零元素按顺序放进一个新数组,然后统计 0 的个数,在新数组末尾补上相应数量的 0。这个方案很好理解,几乎不需要什么算法基础。
但它有两个硬伤。第一,额外开了一个数组,空间复杂度 O(n),不符合“原地操作”的约束。第二,即使你强行说“题目没说不让开数组”,这种解法也完全没有训练价值——真实工程中,几百万条数据的内存分配和拷贝损耗是实打实的。
这里我想多说一句:为什么“原地”要求这么常见?因为在实际系统中,数据往往存放在连续内存里,频繁创建大数组会带来内存碎片、GC 压力、缓存命中率下降等一系列问题。算法题里的“原地操作”不是书呆子式的自我设限,它背后对应的是真实系统中的资源约束。你把这个背景理解了,就不会觉得题目是在故意刁难人。
2.2 单指针原地挪动的“灵光一现”与陷阱
有些人会想:不用新数组也行,我维护一个pos指针,表示“下一个非零元素应该放的位置”。遍历数组,遇到非零元素就把它放到nums[pos],然后pos++。遍历结束后,把pos后面的位置全部写成 0。
这个思路方向是对的,而且已经非常接近最优解了。但很多人第一次写出来,会发现一个问题:如果你直接把nums[i]赋值给nums[pos],那nums[i]原来的位置怎么办?如果i不等于pos,你等于把当前元素复制走了,但当前位置还残留旧值。等循环结束再统一把后面位置补 0,看起来没问题,实际上可能会覆盖掉还没遍历到的非零元素,或者丢失非零元素前面的数据。
举个例子,数组[1,0,2]。pos 初始为 0,i=0 时非零,nums[0] 赋值给 nums[0],没问题;i=1 是零,跳过;i=2 非零,pos 是 1,nums[1] = nums[2],此时数组变成[1,2,2],虽然最后把 nums[2] 置 0 得到[1,2,0],但如果数组再复杂一点,比如[0,1,0,2,3],你稍微走几步就会发现这种“先覆盖、后清空”的策略会导致某个非零值在数组里出现两次或直接消失,核心原因是你没有把原来位置上的值及时清掉或交换出去。
所以,最稳妥的单指针写法其实是交换:遇到非零元素,和pos位置的元素交换。这个“交换”二字,就是通向双指针的临门一脚。
3. 双指针的核心:循环不变量
3.1 两个指针到底各管哪一段
把上一节的pos指针叫做slow,把当前遍历下标叫做fast,双指针结构就出来了。二者的分工是:
fast负责在前面探路,一路扫描整个数组,相当于“检查员”。slow表示下一个非零元素应该放置的位置,相当于“队列末尾”。
关键要理解的是,在每次循环结束后,数组一定处于这样一种状态:从下标 0 到slow-1的区域全部是非零元素,且它们的相对顺序和原数组一致;从slow到fast-1的区域全是扫过的 0;fast之后是尚未处理的区域。这个不变性质在每次迭代后都成立,术语叫“循环不变量”。
很多资料讲双指针只讲“一快一慢”,不讲这个不变量关系,导致学习者只知道照葫芦画瓢。但恰恰是这个不变量,保证了算法结束时数组的正确性:当fast走完整个数组后,前slow个位置就一定是所有非零元素按原顺序排列的结果,后面全是 0。整个过程中,非零元素之间没有相互跨越,相对顺序天然得到保留。
3.2 为什么是交换而不是覆盖
接着上面代码层面的关键决策:当fast遇到非零元素时,nums[slow]和nums[fast]执行交换,而不是简单执行nums[slow] = nums[fast]。原因要分两种情况讨论。
第一种,slow == fast。此时两者指向同一个位置,这个位置本身就是非零,交换等于原地不动,无事发生,这是最理想的情况。
第二种,slow < fast。这出现在前面已经碰到过若干个 0 的场景下,此时nums[slow]一定是一个已经扫描过的 0,把它和nums[fast](非零)交换,等于把 0 往后挪了一步以上,把非零元素放到了它应该在的位置。如果改成直接赋值,那么原来那个 0 仍然留在num[fast]位置,然后你把slow自增,后面再次遇到非零继续覆盖,看起来最后也能通过补 0 完成,但中间状态会非常难维护,也容易在边界条件下出错。交换操作的好处是它天然维护了“slow 到 fast 之间都是 0”的不变量,不需要额外清理操作。
注意:交换写法并不是唯一可行方案,先覆盖再统一补 0 也能 AC。但从代码语义清晰度和状态一致性角度,我强烈建议用交换。尤其你在面试现场写代码时,交换逻辑更好讲、更好推理,不容易在最后一步补 0 时漏掉边界。
3.3 用生活类比帮助理解
你想把这个双指针讲给完全不懂算法的人听,可以用一个“文具整理”的类比。
想象你办公桌上有一排文件格,每个格子里要么是文件夹(非零元素),要么是空盒子(零)。你从左边第一个格子开始检查,右手边拿一个“下一个该放文件夹的位置”标记。当你看到文件夹,就把它放到标记位置;看到空盒子,就继续往前走。标记位置只有在放进文件夹时才往后移动。整个过程走完,所有文件夹都被紧凑地排在最前面,而空盒子自然被推到了右边。关键点在于,标记位置永远只可能指向空盒子,或者指向当前正在检查的格子,绝不可能指向一个还没处理过的文件夹。这个性质一旦被破坏,算法就出问题了。
4. 完整实现与复杂度分析
4.1 一份可以直接抄的代码
Python 版本的实现非常简洁:
def move_zeroes(nums): slow = 0 for fast in range(len(nums)): if nums[fast] != 0: nums[slow], nums[fast] = nums[fast], nums[slow] slow += 1Java 版本同样直白:
public void moveZeroes(int[] nums) { int slow = 0; for (int fast = 0; fast < nums.length; fast++) { if (nums[fast] != 0) { int tmp = nums[slow]; nums[slow] = nums[fast]; nums[fast] = tmp; slow++; } } }C++ 版本,由于标准库自带swap,写起来更短:
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; } } }细心的读者会发现,当slow == fast时,这个交换是自我交换,白白做了一次无用操作。优化办法是加一层判断:
if (nums[fast] != 0) { if (slow != fast) { swap(nums[slow], nums[fast]); } ++slow; }这个优化能减少一些赋值操作,但并不会改变时间复杂度的量级。到底要不要加,取决于你对“尽量减少操作次数”这一约束的严格程度。实测下来,数组规模小的时候差别微乎其微,大规模数组上大约能省掉将近一半的交换操作。我个人建议:写进代码里也不影响清晰度,可以加上。
4.2 复杂度到底怎么算
时间复杂度是 O(n),因为fast从头到尾只遍历了一遍数组,slow最多也只会增加 n 次,两个指针都没有回溯。空间复杂度是 O(1),只用了几个变量。这种“一次遍历 + 常数空间”的组合,就是这道题的最优解。
有些资料会声称用了“双指针”所以复杂度是 O(n) + O(n),这实际上是把两个指针各自扫描了一次当成双重循环来理解了。真正的双指针并不代表双重循环,关键在于每个指针在整个过程中移动的总次数都是线性的。我见过有同学把快慢指针绕着弯子写成了嵌套循环,比如每次遇到 0 就把后面的元素全部左移一位,这样的代码虽然能过小样例,但最坏时间复杂度退化成 O(n²),绝对不符合题目预期。
4.3 顺着代码走一遍执行过程
拿初始数组[0, 1, 0, 3, 12]来手动模拟一遍。
初始状态:slow = 0,fast = 0。
- fast=0,nums[0]=0,跳过。
- fast=1,nums[1]=1,非零,交换 nums[0] 和 nums[1],数组变为
[1, 0, 0, 3, 12],slow 变为 1。 - fast=2,nums[2]=0,跳过。
- fast=3,nums[3]=3,非零,交换 nums[1] 和 nums[3],数组变为
[1, 3, 0, 0, 12],slow 变为 2。 - fast=4,nums[4]=12,非零,交换 nums[2] 和 nums[4],数组变为
[1, 3, 12, 0, 0],slow 变为 3。
最终数组正确。注意每次交换后,从 slow 到 fast 之间的区域确实都是 0,不变量始终成立。你如果在白板上画一遍这个过程,比背十遍代码都有用。
5. 从移动零延伸出的变形题
5.1 移除元素:连题号都极其相似
原题还有一个近亲:给定一个数组和一个目标值,原地删除所有等于目标值的元素,返回新长度。比如数组[3,2,2,3],删除 3,剩下[2,2],新长度是 2。
解法几乎一模一样:快指针扫描,遇到不等于目标值的元素就放到慢指针位置。唯一区别是,由于不要求最后部分必须填充什么值,所以可以直接赋值而不需要交换。移动零可以看作“移除 0”的变体,区别在于移除后的“坑位”必须由 0 来填补,因此需要多做一步交换或补零。
这个对比很有价值:它让你明白,什么时候该交换、什么时候该直接覆盖,取决于题目是否要求保留某种“填充值”。没有这个意识,很多人会在两道题之间来回切换时把自己搞晕。
5.2 删除排序数组中的重复项:同一个双指针模板
另一个经典题目是:给定一个有序数组,原地删除重复出现的元素,使每个元素只出现一次,返回新长度。解法同样是快慢指针,不同之处在于判断条件从“是否等于 0”变成了“是否和前一个不同”。
def remove_duplicates(nums): if not nums: return 0 slow = 0 for fast in range(1, len(nums)): if nums[fast] != nums[slow]: slow += 1 nums[slow] = nums[fast] return slow + 1这道题里使用直接赋值而非交换,因为slow位置之前的元素都是处理好的结果,后面的旧值并不需要保留。你会发现,这三道题:移动零、移除元素、删除重复项,本质上都是同向双指针的不同场景。理解了其中一个,另外两个就是改条件的事。
5.3 颜色分类:从两指针升级到三指针
再往深远走一步,移动零其实是一种“二分类稳定分区”:把数组分成“非零”和“零”两个区域。如果题目要求把数组分成“红、白、蓝”三个区域,比如颜色分类那道题,你就需要三个指针:一个维护红色区边界,一个维护蓝色区边界,一个用于扫描。思想完全一致,只是分区数量从二变成了三。
你甚至可以自己出一道变形题:给定一个数组,负数和正数分别放到左右两边,但负数之间、正数之间相对顺序不变。这就是稳定分区问题,用移动零的双指针思想稍加改造就能解决。如果要求不稳定分区,即不关心相对顺序,那就可以直接用快排里的 partition 思想,首尾双指针往中间逼近。
把这几道题放在一起练一遍,你会发现自己对数组操作的直觉突飞猛进。刷题最怕的就是“一题一题孤立地刷”,当你看见题与题之间的共同骨架时,才算真正开始掌握算法思维。
6. 面试中的踩坑与加分点
6.1 我见过的高频 Bug 清单
这道题代码短,反而给了面试官很多追问空间。我在模拟面试和实际评审中见过不少翻车现场,整理成一张速查表:
| 常见错误 | 错误示例 | 后果 |
|---|---|---|
| 忘记维护 slow 自增 | 遇到非零交换后漏写slow++ | 非零元素可能被后续元素覆盖 |
| 把 fast 和 slow 用反 | 快指针去接非零值,慢指针做扫描 | 数组越界或结果完全错误 |
判断条件写成nums[fast] == 0 | 把零元素往前面挪 | 非零顺序被打乱 |
循环范围写成len(nums)-1 | 最后一个元素没处理 | 末尾元素丢失或位置错误 |
| 用嵌套循环做移位 | 遇到 0 把后面所有元素左移 | 时间复杂度上升到 O(n²) |
| 试图新开数组后贴回原数组 | 空间复杂度 O(n) | 违反原地要求 |
我特别想强调一条:边界条件要用极端样例来验。至少在心里过一遍:空数组、只有一个元素、全是 0、没有 0、0 在开头、0 在结尾。这些情况代码都必须正确处理。很多同学写完后只拿题目示例验证,结果遇到[0]这种输入就漏了。
6.2 面试加分回答策略
技术能力相同的情况下,面试官看重的是你能不能把思路讲清楚。我给你一个建议话术顺序:
先说约束条件:题目要求原地操作、保持非零相对顺序、尽量减少操作次数,所以新数组方案不能接受,暴力移动会超时,我们需要一次遍历的原地算法。
再说指针语义:慢指针指向“下一个非零元素的插入位置”,快指针负责遍历寻找非零元素。
然后说为什么这样正确:关键在于每次迭代后,数组都保持“前 slow 个元素全部非零且有序,slow 到 fast 之间全是零”的循环不变量,所以快指针走完时结果必然正确。
最后补复杂度:时间复杂度 O(n),空间复杂度 O(1),每个非零元素最多被交换一次。
这套说辞不是在背答案,而是你真的理解了算法背后的证明思路。哪怕面试官中途打断你问“如果 slow 和 fast 相等怎么办”,你也知道答案:交换无实际效果,但逻辑依然成立。
6.3 从算法题到系统设计的思维迁移
如果面试官是个喜欢深挖的人,他可能会问:“这个思路在真实系统里有什么用?”
我会回答:任何需要保持稳定顺序的数据分区场景都在用类似思想。比如数据库对某一字段值为空的记录做重排、日志系统中把错误级别事件与非错误事件分离但保留原始时间顺序、流式数据处理中按某些特征把事件分桶。甚至操作系统内存管理里的某些压缩算法,也可以在抽象层面看到双指针原地重排的影子。
你不需要在面试中讲得这么宏大,但只要你能从一道数组题跳出来,提到“稳定分区”这个抽象概念,就已经比大多数候选人高出一个档次了。这也是“看似简单题”隐藏的面试价值:它能帮你展示思维广度。
7. 这道题后续还能怎么扩展
如果你已经把上面的内容消化掉,我建议你把这一题作为“模板母题”,做一次三连扩展训练。
第一练:把移动零改成移动任意目标值,代码怎么变?你会发现只需要把判断条件里的 0 换成目标参数。
第二练:把移动零改成保持零元素相对顺序不变(比如把所有非零移到后面,零保持原顺序放到前面),还能用同一个双指针吗?答案是可以,只需要把交换方向反过来。
第三练:引入额外条件,比如“正数、负数、零三区间分别聚集,且每个区间内顺序保持不变”,这就要用到类似颜色分类的三指针思路,但难度陡增,因为三路稳定分区不能简单用首尾交换完成。
进阶之后还能接触到一个概念叫“稳定性”。算法领域里,稳定排序、稳定分区都是重要话题。移动零这道题要求保持相对顺序,本质就是一种稳定性要求。你能从一道题里延伸出这么长的知识链,说明你的算法学习已经不是在“背题”,而是在“织网”。
我也建议你把这道题的题解写进自己的刷题笔记,不是抄代码,而是把两个指针各自代表什么、循环不变量是什么、为什么用交换、复杂度为什么是 O(n),用自己得话表述一遍。这个过程比刷十道重复题更有价值。
8. 一点个人的体会
刷题多年后再回看这道“简单题”,我最深的感受是:算法能力的分水岭不在你会不会做难题,而在你能不能把一个简单题讲出层次。移动零就好比武功里的扎马步,看起来枯燥,但马步扎得稳的人,后续学什么都快。遇到过很多同学,一道题 AC 完就急着往下一题冲,根本不再回头思考为什么。如果你也是这样,我强烈建议你换一个学习方式:挑二十道经典简单题,每道题都把它拆到这个粒度,用自己的话写一篇笔记,效果一定比盲目刷两百道来得扎实。
最后分享一个小技巧:面试前如果时间紧,不用题海战术,就把移动零、移除元素、删除重复项、颜色分类这四道题放在一起练。它们共享同一个双指针骨架,练熟之后你会形成肌肉记忆,遇到类似的数组分区题,动手前脑中就已经有了清晰的指针语义图。这道“简单题”带给你的算法思维升级,绝对比题目本身的难度值钱得多。