ARTICLE DETAIL

资讯详情

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

LeetCode 283移动零全解析:双指针原地操作数组的通用套路

LeetCode 283移动零全解析:双指针原地操作数组的通用套路 但凡刷过LeetCode热门100题列表的人大概率都见过283这道题。移动零题号283题目短得不能再短给定一个数组把0全部移到末尾同时保持非零元素的相对顺序。就这么一道标着Easy的题面试里出现的频率却高得离谱尤其是一二线大厂的电面手撕环节经常把它当作双指针的入门题来考。今天这篇就把这道题彻底说透——从题目里那几个容易被忽略的限制条件开始到暴力解、双指针覆盖法、交换法三种思路的推导过程再到提交时最容易翻车的细节最后把它和27移除元素、26删除有序数组重复项、80删除重复项II串成一条线你会发现这类题本质上就一句话用双指针在原地完成“筛选写入”。写这篇文章的起因是最近一期的LeetCode周赛430又有人在类似题上卡了壳群里聊起来才发现很多朋友对这类基础题的解法理解还停留在“背代码”的阶段所以我决定从283开始把这一族题的底层逻辑系统梳理一遍。无论你是刚刷题的小白还是面试前临阵磨枪的老手这篇都值得收着慢慢看。1. 这题到底在考什么三个容易忽略的隐藏条件1.1 题目描述里藏着的不只是“把0挪走”原题描述很简短给定一个数组 nums编写一个函数将所有 0 移动到数组的末尾同时保持非零元素的相对顺序。示例是[0,1,0,3,12]期望输出[1,3,12,0,0]。很多人第一眼看过去觉得这就是个“把0挑出来扔最后”的题目但真正决定这题难度的不是“移动零”这个动作而是题目末尾那段不起眼的补充说明必须在原数组上操作不能拷贝额外的数组尽量减少操作次数。这句话翻译过来有两层硬性要求空间上必须做到O(1)额外空间不允许你 new 一个新数组出来把非零元素挨个放进去再在末尾补零。操作上要“尽量少”也就是说你不能用delete、remove、splice这类会引发数组元素整体迁移的操作更不能反复挪动元素做无意义的交换。这两条直接堵死了大多数人第一反应里的“偷懒方案”也恰好说明了这道题真正想考的东西——你对数组原地操作的理解以及双指针技巧的熟练度。1.2 为什么面试官这么爱考这道“简单题”283明明是一道 Easy 题但它出现在热门100题里也高频出现在面试手撕环节原因有几个第一它考察的是抽象能力。你能不能从“移动零”这个具体场景里提炼出“把满足某类条件的元素筛选到前面把不满足的放到后面”这个通用模型。这个模型在后续刷题中会反复出现比如快速排序的 partition、移除元素、删除重复项全是同一个骨架。第二它能快速区分“背题”和“真会”。如果你只是背过代码换一个类似的题可能就懵了如果你真理解双指针的移动逻辑270道后面的一系列题都能顺藤摸瓜解出来。面试官现场让你写解法的时候你有没有认真处理边界条件是不是一上来就写remove(0)这些细节都能直接看出代码功底。第三它有一个很容易被追问的扩展点如果面试官把0换成负数、把“移到末尾”改成“移到开头”你能不能照样写出来。这是同一个 partition 思想在不同场景下的迁移。1.3 先想清楚两个“为什么”动笔之前先问自己两个问题想明白了码就好写了。第一个为什么不能直接统计非零元素个数然后把非零放到前面再把后面填0这个思路其实是对的但实现时很容易写成开新数组。如果你真的在原数组上做两遍循环——第一遍把非零元素往前挪第二遍把剩下的位置补0——这就已经是标准解法了只不过这一步一定要控制好下标。第二个非零元素的相对顺序为什么必须保持不变因为这道题本质上是要求“稳定”的。数组里的元素不只是值还有它原本的位置信息。如果不需要保持相对顺序那直接首尾指针交换就够了类似快排的非稳定分区但题目明确要求稳定所以你的指针移动方式必须是单向的不能从两头夹逼。这个点很多人没意识到后面我会具体对比。2. 从暴力解到最优解双指针是怎么一步步逼出来的2.1 第一直觉开个新数组为什么不行我们先把最直观的思路写出来遍历原数组把所有非零元素依次拷贝到一个新数组里再把0补满最后把新数组内容搬回去。def move_zeroes_extra_array(nums): n len(nums) tmp [0] * n idx 0 for x in nums: if x ! 0: tmp[idx] x idx 1 for i in range(n): nums[i] tmp[i]这个方案逻辑完全正确也能通过示例但问题在于空间复杂度是O(n)不符合题目“不能拷贝额外的数组”的硬性要求。你要是在面试里这么写面试官大概率会追问一句“能不能不用额外空间把它做掉”然后你就得当场优化。有人可能会想new 一个数组不就是 O(n) 空间吗反正数组本来就是 O(n)这里要注意题目的“额外空间”指的是除了输入数组本身之外占用的空间你 new 的tmp就是额外的O(n)。这就像搬家时你明明可以直接把家具在房间里挪位置却偏要租一个仓库临时存放空间成本完全不同。所以这个解法不是“错误”而是不满足题目给出的约束条件在 LeetCode 上会被判空间扣分或者直接视为不合规解法。2.2 冒泡式交换能跑但很痛的暴力解不开新数组那就在原地挪呗。很多人会想到两层循环外层遍历每一个位置如果当前位置是0就在它后面找第一个非零元素然后交换过来。def move_zeroes_bubble(nums): n len(nums) for i in range(n): if nums[i] 0: for j in range(i 1, n): if nums[j] ! 0: nums[i], nums[j] nums[j], nums[i] break这个思路很直观但代价很大。想象一下数组是全[0, 1, 0, 1, 0, 1, ...]这种交替结构每找到一个0都要扫到后面去找非零元素最坏情况下时间复杂度是O(n²)。如果数组长度是 10 万这个暴力解基本就卡死在超时边缘了。我坦白说这种解法我以前也交过结果 LeetCode 给了一个很长的测试用例直接超时那是我第一次意识到有时候“能做出来”和“能通过”之间差着一个复杂度分析的距离。这种暴力解虽然空间是 O(1)时间却完全不合格而 283 这个题一眼就能看出 O(n) 的最优解所以面试里几乎默认要求你一次到位。2.3 正解一快慢指针覆盖法最符合直觉的写法快慢指针覆盖法的核心思路是用一个慢指针slow表示“已经处理好的非零区间的边界”用一个快指针fast遍历整个数组。快指针每遇到一个非零元素就把它写到slow指向的位置然后slow前进一位。遍历结束后slow之后的坑位全部填上0。def move_zeroes_cover(nums): slow 0 for fast in range(len(nums)): if nums[fast] ! 0: nums[slow] nums[fast] slow 1 for i in range(slow, len(nums)): nums[i] 0这个写法理解起来很简单第一遍循环做“筛选写入”把非零元素按顺序全部压缩到数组前部第二遍循环做“清扫补零”把剩余位置全部置0。举个例子[0, 1, 0, 3, 12]fast0元素是0跳过fast1元素是1写入nums[0]1slow1fast2元素是0跳过fast3元素是3写入nums[1]3slow2fast4元素是12写入nums[2]12slow3第二轮从下标3开始补0数组变成[1, 3, 12, 0, 0]。这个解法的时间复杂度是O(n)空间是O(1)而且完美保持了非零元素的相对顺序因为写入顺序就是遍历顺序。它唯一的缺点是做了两次循环第一次写非零第二次补零但这完全在可接受范围内。2.4 正解二零游标交换法一次循环更干净如果你觉得补零那一步有点“多此一举”还有一种更优雅的写法——用一个变量记录当前“最靠前的0的位置”然后遍历数组遇到非零元素就跟这个位置的0交换。def move_zeroes_swap(nums): left 0 for right in range(len(nums)): if nums[right] ! 0: nums[left], nums[right] nums[right], nums[left] left 1这里left始终指向当前区间内第一个0的位置初始为0。当nums[right]不是0时说明这个元素应该被放到前面去那就跟left位置的0交换。交换后nums[left]变成了非零元素left后移一位。再走一遍[0, 1, 0, 3, 12]left0right0跳过right1元素1非0交换nums[0]和nums[1]数组变[1, 0, 0, 3, 12]left1right2元素0跳过right3元素3非0交换nums[1]和nums[3]数组变[1, 3, 0, 0, 12]left2right4元素12非0交换nums[2]和nums[4]数组变[1, 3, 12, 0, 0]left3。你发现没有交换法天然地把0“挤”到了数组后部不需要第二遍补零而且同样稳定。两种解法的时间、空间复杂度完全一致区别只是代码风格。我个人的习惯是面试里先写覆盖法因为它的思路更直白边界条件也更少如果面试官要求“尽量少操作次数”那用交换法更合适毕竟它避免了第二次遍历。两种方案对比如下对比维度快慢指针覆盖法零游标交换法遍历次数两遍筛选 补零一遍交换次数仅写入无交换每次遇到非零都会交换代码理解难度更容易稍微需要想一下 left 的语义空间占用O(1)O(1)时间占用O(n)O(n)3. 代码实现与边界处理改对这三个坑就能一次AC3.1 多语言模板直接抄先给三份最常用的语言参考代码。Python、Java、JavaScript基本覆盖了面试和日常刷题的主力场景。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] slow 1 for i in range(slow, len(nums)): nums[i] 0Java 版本交换法class Solution { public void moveZeroes(int[] nums) { int left 0; for (int right 0; right nums.length; right) { if (nums[right] ! 0) { int tmp nums[left]; nums[left] nums[right]; nums[right] tmp; left; } } } }JavaScript 版本覆盖法var moveZeroes function(nums) { let slow 0; for (let fast 0; fast nums.length; fast) { if (nums[fast] ! 0) { nums[slow] nums[fast]; slow; } } for (let i slow; i nums.length; i) { nums[i] 0; } };这三份代码功能等价你可以根据自己的主力语言选一个背熟但更重要的是理解每一行在干什么而不是照抄。3.2 边界条件空数组、全零数组、无零数组刷题多年我自己的总结是提交前先在心里过三个特殊用例基本能避开 90% 的边界错误。这道题的三个特殊用例是第一空数组或长度为1的数组。[]直接不进入循环代码天然安全[0]传入后 fast 扫描一遍不触发任何写入slow 保持0第二遍把nums[0]置0结果还是[0]正确[5]传入后 fast0 时写入nums[0]5slow1第二遍从下标1开始不执行结果[5]正确。所以这种题不需要写if len(nums) 1: return这种特判但写了也没毛病只是不够优雅。第二全零数组比如[0, 0, 0, 0]。覆盖法里 fast 扫描完slow 始终是0第二遍从下标0开始把四个位置全部置0结果还是全零正确。交换法里 left 也始终是0所有元素都是0所以 never 触发交换正确。第三无零数组比如[1, 2, 3, 4]。覆盖法里 fast 每步都写入slow 最终等于数组长度4第二遍从下标4开始不执行原数组不变。交换法里 left 和 right 同步前进每次都自己和自己交换虽然浪费了一点操作但结果正确。3.3 千万别用 remove、del、splice 这类危险操作这道题最大的陷阱之一就是误用语言自带“删除元素”的API。比如 Python 里有人会写for x in nums: if x 0: nums.remove(0) nums.append(0)看起来逻辑没毛病把0挑出来删掉再在末尾补一个0。但这里有两个致命问题第一remove内部是线性查找并删除删除后所有后续元素都要往前挪一位每删一个0就是 O(n) 的开销。假如数组里有 k 个0总开销就是 O(k·n)最坏 O(n²)。第二边遍历边修改列表长度极容易出现“跳过一个元素”的bug。比如[0, 1, 0, 3]你在 for 循环里遍历时删除了当前位置的0后面的元素整体前移但循环下标已经往后走了导致某些元素根本没被检查。你用列表解析新建一个数组再覆盖回去倒是能正确解决但空间又不满足要求了。JavaScript 的splice和 Java 里ArrayList.remove同理都是 O(n) 的删除操作在算法题里是禁忌。我见过不少人面试时一紧张就写出这种代码面试官本来对你印象不错看一眼这个操作直接开始叹气——因为这说明你对基本数据结构的复杂度不够敏感。3.4 覆盖法为什么最后一定要补零有些朋友写覆盖法时只做第一遍筛选忘了第二遍补零提交后输出结果就成了[1, 3, 12, 3, 12]这种后来的元素残留。原因很简单你把非零元素往前搬的时候是把后面的值覆盖到前面的坑里但数组长度没变那些已经被搬走的原位置的旧值还留在那里。要是不把它们清零它们就会继续占据数组尾部导致结果错误。我习惯用一个生活化类比帮助记忆覆盖法就像整理书架你把所有想留的书往左边集中摆放挪完之后右边空出来的格子不会自己变干净你得拿抹布把空位擦一遍。补零就是这个“擦格子”的动作。交换法为什么不需要补零因为它每次都是“书和空格子互换”空格子跟着指针一路被挤到最右边天然就在尾部不需要额外清理。4. 提交记录复盘最容易挂的三种情况4.1 常见错误一覆盖后数组末尾残留旧值这个问题在上一节其实已经提到但值得单独复盘一个真实案例。我第一次提交这题时写的是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] slow 1结果输入[0, 1, 0, 3, 12]时输出是[1, 3, 12, 3, 12]。当时我还愣了几秒仔细一推才发现slow最终停在2但数组下标3和4的位置还是原来的3和12根本没被碰过。这个错误特别容易犯因为你肉眼看到“前面已经排好了”就会下意识觉得“后面已经空了”。排查方法很简单在纸上把 slow 的每一次变化画出来你就知道哪些位置是“无人管理区”必须手动清零。4.2 常见错误二交换法里 left 的语义没想清楚交换法的核心是left永远指向“当前最靠前的0”但如果你没有真正理解这句话很容易写出反向逻辑。比如有人会写成left 0 for right in range(len(nums)): if nums[right] 0: left right break # 然后从 left1 开始找非零交换这个思路本质上回到暴力解了它先把第一个0找出来然后在它后面找非零交换但交换之后 left 并没有维护“第一个0的位置”只是固定在了旧的位置。比如[0, 1, 0, 3]第一次交换后数组变成[1, 0, 0, 3]left 还停在0已经失效了后面再遇到非零时无法保证和“第一个0”交换非零元素的相对顺序就可能被打乱。正确理解是left不会停留在某一个具体位置它会随着交换不断向后推进永远指向已处理区间中第一个0。每次交换都是把那个0和当前的非零元素互相换位相当于把0“平移”到了当前遍历点的位置而不是固定编号。4.3 常见错误三误判操作次数导致复杂度退化还有一种情况是代码写对了但你不小心写了多余的循环。比如有人为了“减少操作次数”在交换法里加了这样的优化if left ! right: nums[left], nums[right] nums[right], nums[left] left 1这个判断没什么问题能避免自己和自己交换但如果你在left right时没有left 1那就出问题了——非零元素不会被登记到已处理区间后续会出乱子。另一个更隐蔽的写法是把if nums[right] ! 0写成if nums[right] 0然后把0和后面的交换。这个方向一反过来0确实被往后弄了但如果你用的是覆盖法把0覆盖到前面再补非零不对这样会破坏非零元素顺序。总之指针移动的触发条件必须和非零元素绑定不能反着来。4.4 实测对比覆盖法和交换法谁更快我知道很多人关心这个问题特意在 LeetCode 上分别提交了覆盖法和交换法。以官方评测数据来看两者的执行耗时都在 10ms 以内差距完全可以忽略。原因是这题的时间复杂度已经到 O(n) 的下限输入规模再大也只会影响常数倍而 LeetCode 的测试数据量并不足以让那一点点交换开销产生可感知的差异。不过有一个在实际工程场景里值得注意的点如果数组特别大比如上百万元素并且零出现的频率很低覆盖法的写入次数等于非零元素个数交换法则还会额外产生很多“自己和自己交换”的无意义操作。你可以用if left ! right把这种自我交换跳过去理论上有微小收益但在刷题场景下我不建议为了这种细节让代码多一圈判断除非面试官明确要求“尽可能减少操作次数”。5. 一招吃遍“移除元素”家族27、26、80、283通用套路5.1 家族图谱先看这一串题的关系283 不是孤立的。LeetCode 上有整整一族题都基于同一个双指针覆盖思想27. 移除元素给定一个值 val原地移除所有等于 val 的元素返回新长度。这是 283 最直接的变体区别只在于把“移除0”泛化成“移除任意值”。26. 删除有序数组中的重复项原地删除有序数组中的重复元素让每个元素只出现一次返回新长度。非零/非val的条件换成了“和上一个保留元素不同”。80. 删除有序数组中的重复项 II允许每个元素最多出现两次其余逻辑不变。保留条件再放宽一档。283. 移动零把0移到末尾本质上就是先“移除0”把非零往前搬再在尾部补0返回类型是 void 而已。如果只看代码骨架这四个题可以统一成一个模板slow 初始位置 for fast in range(初始位置, len(nums)): if 满足保留条件(nums[fast]): nums[slow] nums[fast] slow 1 # 后续按题目要求处理剩余位补0、截断、返回slow等区别只有一个“保留条件”怎么写。27 的保留条件是nums[fast] ! val283 的保留条件是nums[fast] ! 0本质是27在 val0 时的特例额外多了补0操作26 的保留条件是fast 0 or nums[fast] ! nums[slow - 1]80 的保留条件是slow 2 or nums[fast] ! nums[slow - 2]。5.2 快慢指针的本质把数组看成一个“录取区”我用一个更容易记的模型来理解这族题快慢指针其实就是在一个数组内部维护了“录取区”和“待检区”两个逻辑分区。[0, slow)是已经录取的非零/非重复元素区这个区域里的元素是最终结果的一部分[slow, fast)是“已经被扫描过但不合格”的区域相当于候选区外面的缓冲区[fast, n)是还没被检查的待检区。快指针负责巡逻慢指针负责给录取区划边界。这个模型最好用的地方在于你不需要纠结“数据怎么搬”只需要问自己一个问题当前 fast 指向的元素是否符合录取标准符合就写入录取区末尾然后录取区扩大一格不符合就继续巡逻。这种思维方式可以迁移到很多看似无关的题里比如把负数移到正数前面、把奇数放到偶数前面、把满足某些复杂条件的行先筛选出来等等。5.3 面试官进阶追问这类题还能怎么变形掌握283之后建议你也准备一下这几个常见追问防止面试时被突然扩展第一个追问如果要求把0移到开头而不是末尾思路完全对称。要么把非零元素往后搬从右往左填充要么把 left 初始化为数组末尾从右向左扫描遇到非零就往前交换。本质上还是同一个双指针只是方向变了。第二个追问如果要求把数组按奇偶排序奇数在前偶数在后且不要求稳定可以用首尾双指针左边找偶数、右边找奇数交换。这个就退化成单指针从两边逼近的 partition 思想了和 283 相比少了“稳定性”要求所以可以更高效。第三个追问如果要求稳定分区的方案但现在的数据不是0而是某个需要特殊处理的标记稳定分区stable partition在 C 标准库里是有专门算法的底层思路比普通快排的 partition 更复杂通常会需要额外空间。283 之所以能用简单的双指针搞定正是因为0是重复的、没有内部顺序可言一旦换成带标识的对象要保持相对顺序开销就会上升。面试官如果抛这个延伸题我建议你先主动说出来“因为0完全相同所以不需要保持0之间的相对顺序一旦换成有差异的数据这就变成稳定分区问题了”这一句话就能让面试官觉得你理解到位。第四个追问假设数组里有负数要求先排负数再排正数0夹中间这就变成了“三色旗”问题Dutch national flag problem的变体需要三个指针是另一道经典题。但它的基础仍然是双指针思想283 学扎实了再上手会顺畅很多。5.4 刷题路线建议283之后接着刷什么如果你想顺着这条线把“数组原地操作”这一块彻底吃透我建议按这个顺序刷先刷27. 移除元素练手 val 参数化理解返回值slow本身就是新数组长度这个点。再刷26. 删除有序数组中的重复项体会保留条件变成“和上一个不同”时的写法变化。接着刷80. 删除有序数组中的重复项 II把慢指针初始位置从0改成2保留条件变成nums[fast] ! nums[slow - 2]你会发现套路几乎没变。然后可以做75. 颜色分类三色旗感受多指针的扩展。最后回来把 283 用覆盖法和交换法各写一遍做到闭着眼都能写对。我刷题群里很多朋友喜欢把 LeetCode 热门100题来回刷两遍但我觉得像 283 这种基础题关键是刷完以后把同一族的题串起来复盘而不是重复提交同一道题。串起来以后你会突然发现这些题不是“一堆题”而是“一个套路”。再分享一个我自己的小习惯每做一个新题我会在笔记里记下“它和哪道题是同一族”比如 283 旁边我会写 “related: 27, 26, 80, 75”。这个习惯没啥高科技含量但对建立知识网络特别有用。等到你刷到第50题的时候回头看会发现很多难题都是从这几个简单骨架上长出来的那时候再来刷中等难度的数组题思路会明显清晰很多。
返回列表