
在 LeetCode 的题库里283. 移动零Move Zeroes属于那种“看一眼题目觉得自己会一写代码却被面试官反复追问”的典型题目。它排在热门 100 题里也是很多人刷题计划的第一批题。题面很短给你一个整数数组 nums把数组中所有的 0 移动到末尾非零元素保持原来的相对顺序并且必须在原数组上进行不能额外复制数组。我第一次做这题时第一反应是“再开一个数组把非零装进去末尾补零再复制回来”空气安静了三秒然后被一句“那需要多少额外空间”直接击中。今天就把这题从读题、原理、代码到坑位完整拆一遍。1. 题目到底在考什么读题与出题人意图1.1 题干核心信息拆解原题描述非常克制给定数组 nums编写一个函数将所有 0 移动到数组的末尾同时保持非零元素的相对顺序。注意这个函数必须直接修改原数组也就是传说中的 in-place。示例也只有一个[0,1,0,3,12] 经过处理后变成 [1,3,12,0,0]。说实话这道题放到 LeetCode 上难度只能算 easy但它在求职面试中的出现频率一点都不低。原因很简单它考察的知识点非常收敛就三个——数组遍历、索引控制、空间复杂度意识。没有复杂的算法套壳不依赖任何高级数据结构适合作为“先写一段再说”的开场题。先别急着写代码我们把出题人的意图拆成三个关键词移动、保持顺序、原地。移动不是排序所以不用考虑数组中其他值的相对大小0 就是单纯要被挪到尾部。保持顺序数组中原本的 1、3、12 这些非零值处理完后它们的先后顺序必须和原来一模一样。原地不允许新建一个数组来过渡空间复杂度被限定在 O(1)。如果一个候选人能把这三点准确翻译成“时间复杂度 O(n)、额外空间 O(1)、稳定性保持”那这题基本就拿到一半分了。1.2 为什么“原地”和“保持顺序”缺一不可先说“原地”。如果去掉这个限制解法就是傻瓜版本遍历一遍原数组把所有非零元素收集到一个新数组里末尾补上若干个 0再把新数组内容复制回来。这确实是能跑通的思路但额外空间是 O(n)。出题人之所以强调原地不是单纯想刁难你而是因为在真实工程环境里大数组的频繁复制代价极高。尤其是在 C、Go、嵌入式这些场景下一次大内存分配、cache miss、GC 压力都可能成为性能瓶颈。整理房间也是同一个道理要求你在一个房间里腾挪家具而不是把所有东西搬到走廊再搬回来——走廊空间往往根本不存在。再说“保持顺序”。这个约束其实是在逼你放弃一类“看似高效但会打乱序列”的解法。举个例子你可以用双指针从左右两端往中间扫描碰到左边是 0、右边非零就交换。这种方法确实能在 O(n) 时间、O(1) 空间内把 0 都放到末尾但它会破坏非零元素的相对顺序。比如 [0,2,1]用左右交换法可能变成 [1,2,0]2 和 1 的顺序就反了。所以“保持顺序”不是空话它决定了我们不能简单套用标准的 partition 思想。1.3 从“直觉解法”到“约束解”的思维切换很多初学者刷题有个习惯看题的第一眼就想“怎么最直接地实现”而不是“在给定约束下怎么高效实现”。这两种思维方式在简单题上差别不大但到了中难题就是天壤之别。以这题为例直觉解法是“复制一个数组”但约束把它否决了。于是你被迫去寻找一种只使用数组自身索引的操作方式。这时候你自然会想到能不能用一个指针记录“下一个应该放非零元素的位置”再用另一个指针从头往后扫描这个念头一出现其实你已经在无意识中推导出了双指针解法。我不建议新手上来就背模板更建议每次遇到这种“直觉被否决”的题目时停下来想一想为什么约束是这样哪个操作被禁止了有没有更轻量的替代方式这比记住标准答案重要得多。2. 双指针解法从思路到代码2.1 快慢指针的固定套路双指针有很多形态对撞指针、快慢指针、滑动窗口。这题用的是快慢指针也叫同向双指针。两个指针都从数组头部出发slow 表示“下一个非零元素应该被放置的位置”fast 负责向前扫描每一个元素。fast 每遇到一个非零元素就执行一次操作把这个元素放到 slow 指向的位置然后 slow 向后移动一步如果 fast 遇到的是 0就什么都不做继续向前走。仔细品一下这个逻辑slow 只在遇到非零元素时前进它天然指向所有已处理的非零元素的“尾部边界”fast 遍历完整数组后所有非零元素其实已经被紧凑地搬到了数组前部剩下的 tail 部分再统一置 0 即可。整个过程只遍历一次且没有任何额外数组分配。用 [0,1,0,3,12] 手动跑一遍你会更直观地感受到指针的移动fast当前元素slow操作000跳过0 无需处理110交换 nums[0] 和 nums[1]slow 变 1201跳过331交换 nums[1] 和 nums[3]slow 变 24122交换 nums[2] 和 nums[4]slow 变 3最终数组变成 [1,3,12,0,0]完全符合预期。2.2 写法A非零元素往前覆盖末尾统一补零这是最容易理解和实现的一种写法。思路是第一遍扫描把非零元素依次写到数组前面的连续位置第二遍从写指针位置开始把后面所有位置统一填充为 0。void moveZeroes(vectorint nums) { int write 0; // 下一个写入非零元素的位置 for (int read 0; read nums.size(); read) { if (nums[read] ! 0) { nums[write] nums[read]; } } // 剩余位置全部补零 while (write nums.size()) { nums[write] 0; } }仔细看看这个覆盖过程当 write 追上 read 时nums[write] 其实就是 nums[read] 自己相当于一次自我赋值没有任何问题当 write 落后于 read 时write 指向的位置是之前某个已经被扫描过的 0把它覆盖掉也不会丢信息因为那个 0 本来就不需要保留。这个写法的优点是代码短、逻辑直白、赋值次数也少。每个非零元素最多被写一次末尾的 0 再写一次整体写入量大约就是 O(n)。2.3 写法B发现非零就交换一步到位另一种写法是原地交换fast 遇到非零时直接把 nums[fast] 和 nums[slow] 交换然后 slow 加一。这样不需要第二轮补零因为 0 会在交换过程中自然“被动”地后移。void moveZeroes(vectorint nums) { int slow 0; for (int fast 0; fast nums.size(); fast) { if (nums[fast] ! 0) { swap(nums[slow], nums[fast]); slow; } } }一个值得注意的细节当数组里几乎没有 0 时slow 和 fast 会保持同步于是每次都在做“自己交换自己”。这没有功能性错误但有些追求极致的代码评审者会要求加一行判断if (slow ! fast) { swap(nums[slow], nums[fast]); }不过我个人建议面试时先不加这个优化因为它本质上属于微优化反而会把主逻辑绕复杂。面试官如果追问你再解释“这是为了避免自交换的额外操作”反而是一个加分项。其他语言也一样换汤不换药。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] nums[fast], nums[slow] slow 1为什么交换不会出问题核心不变量是slow 永远小于等于 fast。因为 slow 只在遇到非零时递进而 fast 每轮都会递进。所以即便发生交换被交换到后面的“旧值”也是 fast 已经扫描过的位置不会破坏还没有处理的数据。理解了这一步你就不会再问“这样覆盖会不会丢数据”了。3. 复杂度、稳定性与其他解法路线对比3.1 主流解法时间复杂度对比这道题的讨论区里解法五花八门但真正值得在面试中拿出手的其实就双指针的两种变体。我把常见路线放在一起做个对比帮你一眼看清各自的代价。解法时间复杂度额外空间是否保持顺序是否满足题意新数组收集再复制O(n)O(n)是否空间不达标边删 0 边 push_backO(n²)O(1)是是但性能差非零覆盖 末尾补零O(n)O(1)是是双指针交换O(n)O(1)是是左右对撞交换不稳定O(n)O(1)否否顺序会被破坏从表格可以看出“看起来也能过”的解法不少但“完全符合题意”的只有双指针的两种实现。面试官真正想听到的往往不只是“能跑”还有“为什么选这个”。3.2 为什么“边删边补零”容易写出 O(n²) 的解很多非科班或者刚学数据结构的朋友第一反应是找到 0把它删掉再在末尾补一个 0。听起来没毛病但别忘了数组的删除操作本身是昂贵的。以 C 的 vector 为例erase会把被删除位置之后的所有元素整体向前移动一位这个过程是 O(n) 的。你在 for 循环里每遇到一个 0 就 erase 一次最坏情况下比如数组全 0每删一次都要移动后面的元素总代价就是 O(n²)。更麻烦的是循环变量本身会变得非常蹩脚for (int i 0; i nums.size(); i) { if (nums[i] 0) { nums.erase(nums.begin() i); nums.push_back(0); --i; // 当前索引被替换成了新元素需要回退重新检查 } }这段代码能跑但阅读体验很差而且一旦数组很大性能会肉眼可见地崩。面试官看到这种实现大概率会追问一句“如果数组有 10 万个元素、其中 9 万个是 0你的做法总共移动了多少次”这一问就能把不稳定性暴露出来。3.3 稳定性和排序中的“稳定”是同一个概念“保持非零元素的相对顺序”本质上就是排序算法里的“稳定性”。你可能在学归并排序、快排的时候听过“稳定排序”这个词相同的元素在排序前后相对位置不变。这题里的稳定性是把非零元素当成一个整体要求它们的先后关系不能被破坏。为了体现稳定性有多重要我写一个不稳定的解法给你看void moveZeroesNoOrder(vectorint nums) { int left 0; int right nums.size() - 1; while (left right) { while (left right nums[left] ! 0) left; while (left right nums[right] 0) --right; if (left right) { swap(nums[left], nums[right--]); } } }对 [0,2,1] 来说这个解法会把 1 换到前面去输出变成 [1,2,0]。如果你不关心非零顺序这个答案还行但按原题要求它就是错的。所以当你声称自己会做这题时一定要能解释清楚为什么稳妥的方案是快慢指针而不是左右对撞交换——两个字稳定。4. 边界条件、测试用例与实战排雷4.1 边界用例清单这道题看似简单但边界条件并不少。我整理了一个自测清单每次写完后都拿这些用例跑一遍基本能覆盖绝大多数隐藏问题。输入期望输出需要验证的点[][]空数组不崩溃[0][0]单个 0[1][1]单个非零[0,0,1][1,0,0]前部连续 0[1,0,0][1,0,0]后部连续 0[1,2,3][1,2,3]没有 0原样[0,0,0][0,0,0]全部是 0[1,0,1][1,1,0]非零之间夹 0[0,1,0,3,12][1,3,12,0,0]官方标准用例其实边界用例的核心就一句话无论 0 出现在哪里无论有多少结果都应该是“非零紧凑在前、零紧凑在后、非零顺序不变”。4.2 我实际踩过的坑和排查方法我自己刷题和带人刷题时发现几个高频踩坑点这里直接列出来希望你少走弯路。第一个坑是 slow 指针忘记递增。很多人写出if (nums[fast] ! 0) swap(nums[slow], nums[fast])却忘了slow于是每一次非零都会覆盖同一个位置核心逻辑直接报废。这道题的指针递增是灵魂少一行都不行。第二个坑是把覆盖方向写反。比如写成nums[fast] nums[slow]这就会把扫描指针当前的值覆盖掉导致后续元素丢失。记住口诀快指针负责读慢指针负责写。读的是非零值写的是慢指针位置。第三个坑是 C 里用 int 保存nums.size()然后从数组尾部倒序遍历时遇到空数组会下标溢出。比如for (int i nums.size() - 1; i 0; --i)这种写法在nums.empty()时nums.size() - 1会变成非常大的无符号数进而直接越界访问。这题虽然主要用正向遍历但涉及双端交换的变体会经常踩到。第四个坑更隐蔽面试时为了炫技前后两半都用了复杂逻辑结果把自己绕晕。我见过有人在交换时把slow和fast混淆导致非零顺序错乱。面对这题最简单的方法反而是最稳妥的不要为了“看上去高级”而牺牲正确性。4.3 调试技巧肉眼模拟 打印中间结果排查数组问题我有一个很土但很有效的办法把每一步的数组状态打出来。void printArray(const vectorint nums) { for (int x : nums) cout x ; cout endl; } void moveZeroesDebug(vectorint nums) { int slow 0; for (int fast 0; fast nums.size(); fast) { if (nums[fast] ! 0) { swap(nums[slow], nums[fast]); slow; } cout fast fast , after: ; printArray(nums); } }这样跑一遍你能非常清楚地看到 0 是怎么一步步“漂”到后面去的。如果发现某一轮数组顺序不对劲那就是指针条件写错了。另外建议在本地把上面表格里的测试用例写成单元测试比如用assert校验结果。LeetCode 平台虽然会跑用例但我们刷题的目的不只是提交通过而是理解原理、在面试中能稳定复现。本地多测几个边界比反复提交赌人品强得多。5. 从“移动零”延伸出去的战场5.1 同模考题26 删除有序数组中的重复项、27 移除元素移动零不是孤立的一题。它和 LeetCode 27 移除元素、26 删除有序数组中的重复项本质上是同一个模子用一个慢指针维护“过滤后的数组长度”用一个快指针扫描全部元素。以 27 移除元素为例题目要求原地移除所有值等于 val 的元素并返回新长度。解法几乎可以直接平移int removeElement(vectorint nums, int val) { int slow 0; for (int fast 0; fast nums.size(); fast) { if (nums[fast] ! val) { nums[slow] nums[fast]; } } return slow; }你发现没有移动零就是val 0的移除元素区别只是移动零要求把 0 补到末尾而不是直接忽略长度。至于 26 题也是同样的快慢指针只是判断条件从“不等于 val”变成“不等于上一个已保留的元素”。所以把这三种题放一起对比着刷你会发现所谓“新题”其实是“旧思路换皮”。热门 100 题这个清单里还有很多类似的双指针或二分模块的题比如二分查找类型里有个“爱吃香蕉的狒狒”虽然和移动零不是同一个模块但都属于高频考区。我的建议是刷题不要孤立地背题号而是按“双指针、二分答案、单调栈、动态规划”这种模块去归纳这样才能把一道题的经验复制到一类题上。5.2 面试官可能追加的追问与正确姿势面试官很少只问“你会不会写这道题”更常见的是在你写出代码后抛几个变体问题。这里我整理了几个高概率追问以及推荐回答方向。如果把“保持非零元素的相对顺序”这个条件去掉怎么做这个问题考的是你对稳定性的理解。可以不使用快慢指针而是用左右对撞交换见 3.3 节。你需要主动指出它不稳定并说明为什么原题不允许这样做。如果数组特别大你优先选择哪种双指针写法这时候可以从赋值次数角度分析覆盖补零写法每个非零元素只赋值一次末尾零再赋值一次写操作总量可控而交换写法在 slow 和 fast 不相同时一次 swap 会产生两次赋值。工程上如果写操作代价高覆盖补零可能更优但这属于细节优化面试时点到为止即可。为什么 slow 指针不会超过 fast 指针从而把尚未扫描的数据覆盖掉这是我最喜欢追问的一个问题。正确回答是slow 只在遇到非零元素时自增而 fast 每次循环都会自增所以两者之间天然有这个不变量slow ≤ fast。因此 slow 指向的位置一定是 fast 已经路过的地方覆盖它不会破坏未来元素。这些追问不见得需要你在白板上完整写代码但至少要说清楚思路。能把“为什么要这么写”讲明白的候选人通常比那个闷头写代码五分钟然后说“好了”的人拿到的评价高一大截。5.3 在代码评审里是怎么“挑刺”的我的经验我在代码评审时看到这道题的实现一般会按三条标准快速检查是否原地、是否稳定、是否 O(n)。第一眼看是不是多开了数组。如果看到有人新建 vector 再复制我会问“为什么不用原地方案”。如果看到用stable_partition我会先肯定它能跑再问它内部会分配多少临时空间。标准库函数虽然安全但在这个简单场景下手动双指针通常是更可控的选择。第二眼看非零顺序有没有被破坏。很多人用partition一类的函数时会忽略稳定性的坑。原题已经明确要求保持顺序任何破坏顺序的实现无论跑得再快都不能算对。第三眼看自交换和边界。比如交换写法中swap(nums[slow], nums[fast])如果两个指针相等自交换有没有问题对 int 来说当然没有但如果换成一个自定义类型自赋值可能触发不必要的析构和拷贝生产代码里通常建议加保护。这道题虽然是纯算法题但我会顺带提醒候选人注意真实世界的对象语义。最后说点我自己的体会。刷题刷到后面我越来越觉得“AC 通过”只占三分剩下七分是理解深度。移动零这道题非常简单但如果你愿意多想一层“为什么快慢指针可行”你会发现它把数组原地操作、稳定性、复杂度分析这些基础概念串起来了。平时我做完一题都会写一段注释或者笔记把这个不变量记下来——比如这题的核心就是“slow ≤ fast所以覆盖是安全的”。下次遇到同类题这句笔记会替你省下大量的思考时间。