ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

移动零LeetCode 283:双指针法从暴力到最优全解析

移动零LeetCode 283:双指针法从暴力到最优全解析 刷算法题的时候大部分人第一道“双指针”入门题都会遇到这个给你一个数组把所有 0 移到末尾同时保持非零元素的相对顺序。这就是经典的移动零LeetCode 283。题目本身不难甚至一眼就能想到暴力解法但它背后藏着的双指针法是后面一连串进阶题的地基。这篇文章我不打算只贴一个答案而是把这道题从暴力到最优、从代码到边界、从套路到扩展完整拆一遍顺便把我实际刷题时踩过的坑和总结的调试方法一并写出来。无论你是刚准备刷题的新手还是想在面试前把双指针框架梳理一遍的老手这篇都值得看完。1. 题目剖析移动零到底在考什么1.1 原题描述与隐含条件题目要求很简洁给定一个数组nums编写一个函数将所有0移动到数组的末尾同时保持非零元素的相对顺序。举例输入: [0, 1, 0, 3, 12] 输出: [1, 3, 12, 0, 0]注意原题说的是“就地移动”也就是要求在原数组上修改不能另开一个数组复制。这一点非常关键直接把很多“取巧”解法挡在门外。除了“就地”之外还有两个隐含条件需要仔细品味保持非零元素的相对顺序。这意味着不能简单地把所有零挑出来放末尾也不能用排序去处理因为排序会打乱非零元素的原有相对位置。不要求保持零元素的相对顺序。零都是一样的谁在前谁在后无所谓。这两个条件共同决定了标准的双指针解法为什么是最优选择。1.2 先看暴力解法能走多远很多人第一次见到这道题会这样想先遍历一遍数组数出有多少个非零元素然后把所有非零元素按顺序放到前面最后把数组后面补成 0。这个思路本身没错但它需要两次甚至三次遍历最关键的是它违背了“就地”的约束——你至少要开一个临时数组来存放非零元素空间复杂度是 O(n)。如果题目只是要求结果正确那这样的解法勉强能过但在面试场景下面试官一定会追问“能不能在 O(1) 空间内完成”。一旦被问到这个问题你就必须切换到双指针思路。其实暴力解法也不是完全没用。它给了我们一个很好的直觉最终结果可以被看作“非零前缀 零后缀”。我们要做的事情就是把数组重排成这种形态同时保持前缀内部的相对顺序不变。双指针法本质上就是用一个指针维护这个非零前缀的边界另一个指针负责扫描全数组。2. 双指针策略从暴力到优雅2.1 双指针的两种常见形态双指针并不是一个“标准算法”而是一类思想。在数组、链表、字符串相关问题里常见的双指针形态大致有两类相向指针两个指针一个从左往右走一个从右往左走比如两数之和 II、回文字符串判断。同向指针快慢指针两个指针都从同一方向出发一个走得更快一个走得更慢比如链表找环、原地去重。移动零这道题属于典型的同向双指针也就是快慢指针。为什么不用相向指针因为相向指针在交换元素时很容易破坏相对顺序。比如你让左边的指针找 0、右边的指针找非零然后交换这看起来可行但交换后非零元素的相对顺序可能被打乱。移动零要求相对顺序不变所以同向双指针是更自然的选择。2.2 核心思想快指针探路慢指针写位置我把这两个指针分别叫fast和slow这样比较好记。fast遍历整个数组负责“找”非零元素。slow指向当前应该放置下一个非零元素的位置也就是“非零前缀”的下一个坑位。整个过程可以用一句话概括快指针每遇到一个非零元素就把它放到慢指针指向的位置然后慢指针后移一步。因为慢指针只在写入非零元素后才会移动所以它前面的位置永远都是已经排好的非零元素慢指针后面的位置则暂时保留原始值等待后续被覆盖。这个思路的本质是“把非零元素往前搬运”。你可能会有疑问搬运的过程中会不会把还没遍历到的元素覆盖掉这正是关键点。由于快指针始终跑在慢指针的前面快指针所指向的元素才是“当前正在看”的元素慢指针指向的位置要么已经被处理过要么就是快指针已经越过的位置。换句话说覆盖掉的值一定是之前已经遍历过的非零元素被复制到前面之后的“残留值”就算丢了也不影响最终结果。想清楚这一点才能彻底理解这个算法的正确性。2.3 为什么能做到 O(1) 空间很多算法题在空间复杂度上要求很严格。移动零的“就地”要求意味着我们只能使用常数级别的额外空间也就是 O(1)。快慢指针法天然满足这个要求。整个过程只引入两个指针变量不需要任何辅助数组。所有操作都在原数组上进行通过覆盖或交换来完成重排。相比暴力法的 O(n) 空间双指针法在空间开销上有压倒性优势。这也是它在面试里能获得最佳评价的原因。3. 手把手实现三种写法与细节对比3.1 经典覆盖法先写后补零最直观的双指针写法是“覆盖”也就是把非零元素依次往前移最后把数组末尾补零。def moveZeroes(nums: list[int]) - None: slow 0 for fast in range(len(nums)): if nums[fast] ! 0: nums[slow] nums[fast] slow 1 while slow len(nums): nums[slow] 0 slow 1这个写法非常清晰第一遍循环遍历数组遇到非零元素就复制到slow位置slow自增。第二遍循环把从slow到数组末尾的所有位置都置为 0。举例推演一下[0, 1, 0, 3, 12]遍历位置fast 指向的值slow 位置操作后数组000不写入slow 不变110nums[0]1数组变 [1,1,0,3,12]slow1201不写入slow 不变331nums[1]3数组变 [1,3,0,3,12]slow24122nums[2]12数组变 [1,3,12,3,12]slow3循环结束后从slow3开始补零最终得到[1, 3, 12, 0, 0]。你可能会觉得中间过程中数组里有重复元素比如出现了两个 3这是正常的。因为“覆盖”并不是“交换”只要最终结果正确中间态无所谓。3.2 优雅交换法一次遍历原地交换覆盖法需要两个循环交换法则只需要一个循环逻辑也更优雅让慢指针永远指向第一个 0 的位置一旦快指针遇到非零元素就和慢指针指向的 0 交换。def moveZeroes(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这个写法在 LeetCode 上非常常见。它的巧妙之处在于慢指针指向的是已经处理好的非零序列的末尾同时也指向第一个待处理位置的元素。如果这个位置恰好也是非零元素那么交换就变成了自己和自己交换不影响正确性。举个例子输入[1, 2, 0, 3]fast0nums[0]1非零交换 nums[0] 和 nums[0]slow1。fast1nums[1]2非零交换 nums[1] 和 nums[1]slow2。fast2nums[2]0跳过。fast3nums[3]3非零交换 nums[2] 和 nums[3]数组变[1,2,3,0]slow3。可以看到交换法天然把 0 保留在中间态不需要最后补零而且整个过程只遍历了一次。这是我最推荐面试时写的版本因为代码短、逻辑完整还能直接体现“原地操作”的意图。3.3 边界细节非零元素与零元素的交错不管用覆盖法还是交换法有一点要注意slow 和 fast 的初始值都是 0。这不是偶然而是因为数组的起始位置还没有非零前缀第一个坑位就是索引 0。还有个小细节交换法里如果数组中没有 0比如[1, 2, 3]那么每次都是nums[slow] nums[fast]自己和自己交换性能上稍微有一点点浪费。但实际上这种无意义的交换在时间复杂度上仍然是 O(n)而且现代编译器或解释器对这类操作的开销可以忽略。如果你实在介意可以加一个判断if nums[fast] ! 0 and nums[slow] 0: nums[slow], nums[fast] nums[fast], nums[slow] slow 1但这样会让代码多一层判断反而影响可读性。我个人在实际刷题时不推荐加这个判断保持代码干净更重要。3.4 多语言实现参考面试时不一定总用 PythonJava 和 C 也很常见。这里把交换法翻译成 Java 版本class Solution { 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 版本class Solution { public: void moveZeroes(vectorint nums) { for (int slow 0, fast 0; fast nums.size(); fast) { if (nums[fast] ! 0) { swap(nums[slow], nums[fast]); slow; } } } };如果你在 LeetCode 上做题会发现官方题解和多数高赞答案都长这样。语言差异只在细节核心思想完全一致。4. 复杂度与边界条件别让简单题绊倒你4.1 时间复杂度和空间复杂度推导先说时间复杂度。无论覆盖法还是交换法fast指针都从数组头遍历到尾一共走了 n 步slow指针每一步最多只后移一次最终也是 O(n)。覆盖法虽然多了一个“补零”的循环但那个循环从slow开始最多也执行 n 次。所以总的时间复杂度都是 O(n)。空间复杂度方面除了几个指针变量没有引入额外数组所以是 O(1)。这里要强调一个问题O(n) 的时间复杂度意味着什么它意味着这个算法是“单趟扫描”的每个元素最多被访问常数次。相比暴力法可能需要两层循环 O(n²)双指针法是质的提升。在面试中如果你能主动说出“由于每个元素最多被处理一次所以时间复杂度为 O(n)”会是一个很好的加分点。4.2 特殊输入与边界测试在实际面试中面试官很喜欢考察边界用例。我总结了一份移动零的边界清单空数组[]循环直接跳过结果不变。单元素数组[0]或[5]slow 和 fast 都在 0 位置如果是 0 跳过如果是非零自己交换。结果都正确。全零数组[0, 0, 0]fast 全程找不到非零元素slow 保持 0交换法不执行任何交换数组不变。无非零数组[0,0,0,0]同上。无非零元素但有 0 穿插比如[1, 0, 2, 0, 3]快慢指针需要多次交换重点检查相对顺序是否仍为 1、2、3。所有非零元素都在末尾比如[0, 0, 1, 2]快指针要走到后面才能遇到非零元素慢指针一直停在开头等待。这些边界测试不需要都写进代码但心里要有数。我调试的时候会用一组小数组在纸上模拟尤其是那种“零与非零交替出现”的情况最容易暴露指针移动的错误。4.3 一个容易忽略的约束相对顺序很多初学者会想能不能用“从后往前遍历把 0 往后推”的方式比如每次遇到 0 就把它和后面的元素依次交换像冒泡一样把 0 沉底。# 直观但低效的写法 for i in range(len(nums) - 1, -1, -1): if nums[i] 0: for j in range(i, len(nums) - 1): nums[j], nums[j 1] nums[j 1], nums[j]这种写法虽然能保持相对顺序但内层循环会把每个 0 都向后“冒泡”一遍遇到连续 0 时可能重复移动大量元素最坏情况下时间复杂度达到 O(n²)。它其实是一种“单指针 交换”的笨办法不是双指针法。面试时如果你写了这种解法大概率会被追问“能否优化”。所以这里有一个很实用的判断标准如果你发现解法中出现了嵌套循环而题目本身只需要一次扫描大概率不是最优解。5. 常见错误与调试心得老手也踩过的坑5.1 错误一遍历方向搞反有人会写成从数组末尾向前遍历然后把非零元素放到后面。这样会导致非零元素的相对顺序反转。比如[1, 2, 3]会变成[3, 2, 1]直接不符合要求。记住移动零要求“非零元素相对顺序不变”所以非零元素只能从前往后排列。任何从后往前填充非零元素的解法都需要额外维护倒序得不偿失。5.2 错误二覆盖法忘记补零覆盖法的另一个常见 bug 是只做了第一遍“前移非零元素”忘记把后面的位置清零。这样返回的数组后半段残留旧值。比如[0, 1, 0, 3]只做覆盖会变成[1, 3, 0, 3]中间的 0 变成 3 了。所以覆盖法的第二步补零千万别漏。相比之下交换法因为每次遇到非零元素都把它和 0 交换等于同时在移动 0不太会出现残留问题。这也是我更推荐交换法的原因之一。5.3 错误三把返回结果当作新数组LeetCode 上这道题的函数签名是void moveZeroes(int[] nums)Java或者要求原地修改然后返回NonePython而不是返回一个新数组。很多新手刷题时习惯把结果存到新列表里返回然后报错“输出和预期不符”。这不是算法问题而是没有理解“原地修改”意味着调用者拿到的还是原来的那个数组对象。Python 里尤其容易犯错# 错误示范 def moveZeroes(nums): result [x for x in nums if x ! 0] result.extend([0] * (len(nums) - len(result))) return result这样写虽然能通过部分测试但在 LeetCode 上会直接报错因为题目要求就地修改输入数组。正确的方式是直接在nums上操作不要返回任何值。5.4 调试技巧用状态打印验证指针移动如果你在本地写代码调试双指针题最有效的方法就是在每次循环结束打印三个变量slow、fast、当前数组。比如def moveZeroes(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 print(ffast{fast}, slow{slow}, nums{nums})输入[0, 1, 0, 3, 12]中间状态会非常直观。我自己在刷题初期每道双指针题都强制自己打印一轮中间状态这样能快速纠正指针移动的节奏感。等熟练之后就不需要打印了直接在脑子里走一遍就行。5.5 测试用例设计思路刷算法题时不要只看题目给的示例。我一般会自己造几组特殊输入全零[0, 0, 0]无零[1, 2, 3, 4]零在开头[0, 1, 2]零在末尾[1, 2, 0]零零交替[0, 1, 0, 2, 0, 3]负数加零[-1, 0, -2, 0]重复非零[1, 1, 0, 1]每组输入都要确认两件事所有非零元素是否都在前面、相对顺序是否保持不变。用这套用例基本可以覆盖所有边界。6. 双指针的通用框架一道题吃透一类题6.1 从移动零提炼双指针套路移动零不是孤立题目。我刷完这道题后最大的收获是提炼出了一套“原地数组重排”的通用框架适用场景需要按某种条件把数组中的元素分成两类并保持某一类的相对顺序。通用步骤定义slow指针指向“已处理区域”的下一个位置。定义fast指针遍历整个数组。当fast指向的元素满足“应该放到前面”的条件时把它写入或交换到slow位置slow后移。遍历结束后根据题目要求处理后缀区域。这个框架可以用在很多地方移除元素给定val原地删除所有等于val的元素返回新长度。有序数组去重原地删除重复元素使每个元素只出现一次。移动特定值把某个值都移到数组末尾相当于“移动零”的变体。奇偶排序把所有奇数排在偶数前面或反过来。颜色分类三指针处理荷兰旗问题。6.2 实战扩展一移除指定元素LeetCode 27 题“移除元素”和移动零非常像。题目要求原地移除所有等于val的元素返回新数组的长度不要求保持“零”在末尾只要求前面的元素保留相对顺序。def removeElement(nums: list[int], val: int) - int: slow 0 for fast in range(len(nums)): if nums[fast] ! val: nums[slow] nums[fast] slow 1 return slow是不是几乎一模一样区别只在于移动零是“移除 0 但把 0 留在末尾”而移除元素是“移除 val 直接忽略末尾内容”。只要理解了移动零的覆盖法这个题就是换个条件的事。6.3 实战扩展二有序数组去重LeetCode 26 题“删除有序数组中的重复项”要求原地去重并返回新长度。由于数组是有序的相等的元素一定相邻。快指针寻找与当前已处理区域末尾不同的元素慢指针维护去重后的边界。def removeDuplicates(nums: list[int]) - int: slow 0 for fast in range(1, len(nums)): if nums[fast] ! nums[slow]: slow 1 nums[slow] nums[fast] return slow 1 if nums else 0注意这里slow初始从 0 开始fast从 1 开始因为第一个元素天然不需要去重。这道题也是快慢指针的标准应用。你做完移动零之后再刷这两道题会发现套路是完全互通的。6.4 实战扩展三奇偶分离双指针的另一面如果题目要求把奇数移到前面、偶数移到后面而且不要求保持相对顺序那么可以用相向指针def sortArrayByParity(nums: list[int]) - list[int]: left, right 0, len(nums) - 1 while left right: while left right and nums[left] % 2 0: left 1 while left right and nums[right] % 2 1: right - 1 nums[left], nums[right] nums[right], nums[left] return nums同样涉及两个指针但方向相反。这提醒我们双指针不是一种固定写法而是要根据约束条件是否要求稳定、是否要求一次遍历、是否要求原地来选择指针的移动方向。移动零是“从左到右、稳定、原地”的典型奇偶分离如果是 LeetCode 905则不要求稳定所以可以左右交换。6.5 什么时候用双指针什么时候不能用作为一个刷题老手我的经验判断标准是当题目涉及“在数组/字符串中需要按某种规则移动或比较元素且时间复杂度要求线性”时大概率可以用双指针。但要注意不是所有数组题都适合双指针。比如要求“统计子数组个数”且子数组有单调性通常用滑动窗口也是双指针的变体如果要求“找出所有满足组合的三元组”双指针也常需要配排序。移动零是双指针最简单的一个入口但绝不是终点。7. 写在最后的个人经验移动零这道题我前前后后刷了不下五遍。第一遍用暴力法第二遍记住了代码第三遍才开始真正理解慢指针的含义。后来我在给朋友讲题的时候发现很多人卡住的点不是“快指针怎么走”而是“慢指针到底指向什么”。其实慢指针永远指向“下一个非零元素应该放的位置”它的语义不会变。我个人的一个小技巧是把快指针当成“观察员”把慢指针当成“施工队长”。观察员不断向前报告遇到什么元素如果遇到非零就喊一声“这里有货”施工队长就在当前位置接货然后往前走一步。其他时候队长原地等待。这个比喻虽然简单但真的能帮你记住整个过程的逻辑。如果你刚开始刷题建议把移动零和移除元素、有序数组去重这三道题放在同一天做。它们用的是一套思路做完之后你会对双指针有质的理解。而且这三道题在面试中出现频率都很高属于“性价比极高”的题目。最后再分享一个实测有效的小技巧理解双指针之后做题时先在纸上把数组和两个指针的位置画出来每走一步就更新一次指针位置。画着画着很多“为什么”就通了。等你能在不看代码的情况下用纸笔完整推演[0, 1, 0, 3, 12]的交换过程这道题就算真正吃透了。
返回列表