移动零 LeetCode 283:双指针与原地数组操作的经典算法题解析

移动零 LeetCode 283:双指针与原地数组操作的经典算法题解析 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 0nums[0] 0非零条件不满足跳过。fast 1nums[1] 1与nums[0]交换数组变成[1, 0, 0, 3, 12]slow 1。fast 2nums[2] 0跳过。fast 3nums[3] 3与nums[1]交换数组变成[1, 3, 0, 0, 12]slow 2。fast 4nums[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 当成一个标本把它的三种解法、复杂度分析、边界条件、扩展变种全部整理到自己的笔记里。一道题吃透了比囫囵吞枣刷十道题更有用。面试官很多时候并不是要看你会不会解这一题而是看你面对一个看似简单的问题时能不能保持清晰的思路和严谨的态度。