ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

移动零双指针解法:原地稳定分区与算法优化解析

移动零双指针解法:原地稳定分区与算法优化解析 1. 一道Easy题为什么值得认真对待LeetCode Hot100 里的第 283 题「移动零」标签写着 Easy双指针解法也就十行代码。但我刷了这么多题之后想说这道 Easy 题是典型的看起来简单写干净很难——群里经常有人交上来一份能通过但很别扭的解法面试时被追问两句就露馅。题目本身很直白给定一个数组nums编写一个函数将所有0移动到数组的末尾同时保持非零元素的相对顺序并且必须在原数组上操作不能拷贝额外的数组。示例是[0,1,0,3,12]变成[1,3,12,0,0]。看似只是一个把零放到后面的动作但它同时踩中了三个考点原地操作、线性时间复杂度、稳定性。能做到其中两条的人不少三条全部做到且代码干净的人其实不多。我在刷题时有个习惯拿到一道题先不看题解把第一版能跑的代码写出来然后再问自己三个问题——能不能原地完成时间复杂度能不能降到 O(n)元素的相对顺序有没有被破坏这套流程对于 283 这种简单题尤其有效因为简单题往往不是考你能不能做出来而是考你能不能在约束条件下做到最干净。1.1 常见低效解法能跑但别满足于能跑先说第一种错误倾向用两层循环从前往后遇到 0再往后找一个非零元素来交换。这种写法其实就是手动冒泡最坏情况下每个 0 都要往后扫一遍复杂度是 O(n²)。遇到[0,0,0,...,1]这种极端输入耗时直接起飞。LeetCode 的测试数据对这种写法往往还能放过但面试官一眼就能看出问题。第二种倾向是借助额外数组或集合来删除0比如把非零元素先收集到新列表再统一补零拼回去。这的确能得到正确结果但只要题目明确要求不能拷贝额外的数组这种写法就直接不合格。有些语言里remove操作表面是一行代码底层是 O(n) 的搬迁加移位循环用下来整体成本更高而且边遍历边删还容易踩下标错乱的坑。第三种倾向更隐蔽用类似快速排序分区的方式从数组两端同时向中间扫描左边找零、右边找非零然后交换。这种写法确实能做到 O(n) 和原地但它有一个致命问题——会破坏非零元素的相对顺序。拿[1, 0, 2, 0, 3, 4]举例左侧指针在 0 的位置停下右侧指针从末尾找到非零元素 4 后交换数组会变成[1, 4, 2, 0, 3, 0]4 跑到了 2 的前面。题目明确要求保持非零元素的相对顺序所以直接套用快排分区思路是不行的。1.2 这道题的本质稳定的原地分区把上面这些错误解法排除掉之后你会发现 283 的本质其实是一个稳定分区问题把满足某种条件的元素非零放到数组前部把不满足条件的元素零挪到数组后部同时保持满足条件元素之间的原有次序。听起来很像排序里的 partition但普通 partition 不要求稳定所以可以随意交换。而稳定分区要求每个非零元素在移动之后它们彼此之间的先后关系仍然和原数组一致。这个约束直接决定了算法设计方向必须从左到右按顺序处理非零元素不能跳跃式交换。理解了这一点再看双指针解法就很顺理成章了。2. 双指针解法快慢指针各自的职责283 的标准解法是双指针但双指针这个词在 LeetCode 里其实覆盖了好几类完全不同的玩法有同向移动的快慢指针有从两端往中间走的左右夹逼还有维护可变区间的滑动窗口。283 属于第一类也是最入门、最容易被误解的一类。快慢指针的核心思想很朴素用两个指针同时从数组头部出发一个负责探路一个负责定位。快指针的任务是逐个扫描数组元素把看见的非零值报告出来慢指针的任务是维护一个边界这个边界左边已经全是非零元素边界位置就是下一个非零值该放的地方。2.1 状态定义与循环不变量写代码之前先定义清楚状态这是避免出 bug 最重要的一步。我习惯这样描述slow指向下一个非零元素应该放置的位置同时也表示当前已经处理好的非零元素个数。fast从 0 遍历到数组末尾负责检查每个位置上的值。循环不变量是在每一轮循环结束时nums[0..slow-1]中已经按原顺序放好了所有已经遇到过的非零元素nums[slow..fast-1]中这些位置要么是待处理的原始值要么是零但slow永远指向下一个空位。这个不变量写出来之后代码的正确性就很好论证了fast 扫描完整个数组后所有非零元素一定都在nums[0..slow-1]中按原顺序排好剩下的nums[slow..n-1]自然就是零的位置。2.2 手动模拟一遍执行过程拿标准示例[0, 1, 0, 3, 12]来模拟交换法的执行过程你会看到零是如何被一步步挤到后面的初始状态slow0, fast0。nums[0]是 0fast 直接前进。fast1看到nums[1]1这是第一个非零元素应该放到位置 0。交换nums[0]和nums[1]数组变成[1, 0, 0, 3, 12]slow前进到 1。fast2nums[2]是 0跳过。fast3看到nums[3]3放到slow1的位置。交换后数组变成[1, 3, 0, 0, 12]slow变成 2。fast4看到nums[4]12放到slow2的位置。交换后数组变成[1, 3, 12, 0, 0]slow变成 3。整个过程里非零元素 1、3、12 先后被安放到前部彼此之间的先后顺序一秒都没乱。注意一个细节fast每遇到一个非零元素slow才前进一次所以slow永远领先于已经处理干净的区域而fast负责把前方尚未检查的区域扫干净。两者配合恰好做到一遍扫描完成所有移动。2.3 为什么稳定性天然成立很多人不理解为什么快慢指针这样交换就能保住顺序而左右夹逼就不行原因在于交换发生的方向。快慢指针中慢指针只前进不后退快指针也一直向前两个指针都是单向运动。快指针遇到非零元素时该元素在未被处理区中的顺序是相对靠前的它被放到slow指向的位置时slow-1位置的元素一定是之前已经放好的更靠前的非零元素。换句话说每一个非零元素都是按它们在原数组中的出现顺序依次被写入前部的不会出现后面的元素跳到前面元素前面去的现象。而左右夹逼是双向运动右指针从数组末尾往左找非零元素这个元素在原数组中往往是靠后的但它被交换到了数组前部直接插入到早先放好的非零元素之前稳定性自然就碎了。所以判断一种指针写法是否适合 283最简单的问题是它是否保证非零元素从左到右依次落位保证就是稳定不保证就是不稳定。3. 交换法与覆盖后清零两种主流写法的取舍明确了快慢指针的状态定义之后实现层面还有两条路线交换法swap和覆盖后清零法overwrite。这两种写法都能在线性时间和常数空间内完成但代码风格和常数性能略有差异。3.1 写法A交换法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 1交换法的思路是slow指向的位置就是当前第一个零的位置或者尚未写入非零的位置遇到非零元素就直接和这个位置交换。零元素会随着交换逐步向数组尾部迁移数组的非零区也一步步向右扩张。这里有一个很实用的小优化当fast slow时交换是自己和自己换完全没有意义但会白白多做两次数组读写。可以把交换条件收紧def move_zeroes(nums): slow 0 for fast in range(len(nums)): if nums[fast] ! 0: if fast ! slow: nums[slow], nums[fast] nums[fast], nums[slow] slow 1加了if fast ! slow之后在非零元素不需要移动的测试数据比如全非零数组上代码会退化为纯扫描操作次数大幅下降。3.2 写法B覆盖后清零法def move_zeroes(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。很多初学者第一次看到覆盖法会觉得这样不就把没扫描的值丢了吗确实在执行过程中某些非零值会被临时覆盖掉比如示例里的[1, 0, 0, 3, 12]第一步nums[0]1会覆盖原来的nums[0]但此时原来的nums[1]0虽然暂时还在原处在后面统一清零阶段会被处理而所有非零值都已经在slow的推进中被安全地复制到了前部。最终结果是正确的。3.3 两种写法的复杂度与适用场景从时间复杂度看两者都是 O(n)。但如果较真常数项它们各有胜负交换法对每个非零元素做一次交换即两次赋值。如果数组里非零元素很多、零很少总赋值次数约等于2 × N_nonzero相当高效。覆盖法第一阶段对每个非零元素做一次赋值第二阶段对每个零位置做一次赋值总赋值次数约等于N_total N_zero。如果数组中零很多、非零很少覆盖法的总赋值次数接近于N_total几乎达到赋值次数下限。用表格来对比会更直观维度交换法覆盖后清零法额外空间O(1)O(1)时间复杂度O(n)O(n)赋值次数约2 × 非零个数约数组长度 零的个数非零元素多时更优一般零元素多时一般更优代码可读性直观零逐渐被挤到尾部两段逻辑需要理解覆盖语义实际刷题时除非面试官明确追问如何尽量少地移动元素否则两种写法都可以接受。我个人更推荐交换法因为它的每一步操作都能从数组状态上直接看出来调试和讲解都更方便。但如果要求尽量减少操作次数覆盖法是更接近理论最优的选择。4. 边界条件与测试用例设计把代码打回原形很多题不是思路不对而是边界条件考虑不周。283 这道题看似简单边界条件其实相当多。我在本地写验证脚本时至少会覆盖下面这些场景输入期望输出覆盖点[][]空数组循环直接不执行[0][0]单元素且是零[1][1]单元素且非零[0, 0, 0][0, 0, 0]全零快指针永远跳过分支[1, 2, 3][1, 2, 3]全非零slow 和 fast 同步前进[0, 1, 0, 3, 12][1, 3, 12, 0, 0]标准示例[1, 0, 2, 0, 3, 4][1, 2, 3, 4, 0, 0]零和非零交替出现[0, -1, 0, -2, 0][-1, -2, 0, 0, 0]负数与零混合验证判断逻辑不依赖数值正负我自己写过一个通用的验证脚本把用例直接塞进去批量跑def check(nums, expected): move_zeroes(nums) assert nums expected, fcase failed: got {nums}, want {expected} check([], []) check([0], [0]) check([1], [1]) check([0, 0, 0], [0, 0, 0]) check([1, 2, 3], [1, 2, 3]) check([0, 1, 0, 3, 12], [1, 3, 12, 0, 0]) check([1, 0, 2, 0, 3, 4], [1, 2, 3, 4, 0, 0]) check([0, -1, 0, -2, 0], [-1, -2, 0, 0, 0]) print(all passed)这段脚本的作用不只是验证正确性更重要的是逼自己把输入类型想全。你会注意到我在设计用例时会刻意把零放到开头、中间、结尾还会混入负数和全零数组。这样一个用例集走下来大多数实现上的隐性 bug 都会暴露出来。4.1 最容易踩的指针初始化与循环边界283 的代码本身很短但短代码更容易藏边界 bug。最常见的问题是慢指针的位置定义和循环结束条件不一致。如果你把slow定义为当前已处理的非零元素个数那第一次遇到非零元素时它应该等于 0必须在交换/赋值之后再加一。但如果你把slow定义为第一个零的位置那它初始可能是 0也可能要在遇到第一个零之后才开始生效两种定义下的具体代码略微不同一旦混用就会出错。另一个容易出错的地方是覆盖法的清零循环。有些人会用for i in range(len(nums) - slow):这种写法看起来没问题但如果你把slow的位置算错了一位清零范围就会差一个元素导致开头或结尾残留错误的 0。最稳妥的做法是清零循环直接从slow开始到len(nums)结束语义与慢指针的定义严格一致。还有个更隐蔽的坑如果你的解法需要先找到第一个零的位置再开始处理比如写成while slow n and nums[slow] ! 0: slow 1那么遇到全非零数组时slow会一路走到数组末尾后面的循环必须处理好slow n的情况否则就会数组越界。这种写法不是不行但需要多加一层判断不如直接用慢指针同时兼任计数器和位置标记的写法来得干净。4.2 如何快速验证原地修改与稳定性验证原地操作有个笨但有效的办法在函数执行前后打印数组的内存地址或者直接检查函数是否返回了新的列表。Python 里可以通过观察列表对象是否变化来判断nums [0, 1, 0, 3, 12] print(id(nums)) move_zeroes(nums) print(id(nums))如果两次id(nums)相同说明确实是在原数组上操作没有偷偷 new 一个新列表。稳定性则可以通过用例的期望输出直接验证——交替出现的用例已经能拦住左右夹逼那种破坏顺序的写法。很多新手刷题时只跑题目的官方示例就提交这是很危险的习惯。一个官方示例只能证明这条路径能走通完全谈不上边界条件正确。面试时你如果能主动说出我考虑了全零、全非零、交替出现这三种极端情况会比闷头写代码给面试官留下更深的印象。5. 从283延伸到双指针题族283 不是一道孤立的题。Hot100 里跟它思路几乎同源的有好几道把它们放在一起刷你才能真正体会到双指针是一种思维模式而不是某个套路代码。5.1 同类题目26、27、7526. 删除有序数组中的重复项同样是快慢指针快指针负责扫描慢指针维护去重区间的末尾。区别在于 283 是把非零元素往前放、后面补零而 26 是在原地去重后只返回新长度数组末尾是什么样题目不关心。两道题的慢指针位置定义非常相似。27. 移除元素给定一个值val要求移除所有等于该值的元素并返回新长度。这题的代码几乎和 283 一模一样只是把nums[fast] ! 0换成了nums[fast] ! val且不需要把val本身挪到尾部因为题目只要求返回前k个元素的有效内容。75. 颜色分类这题是 283 的强化版数组元素只有 0、1、2 三种颜色要求排成 0、1、2 的顺序。它需要三个指针或者左中右三个边界本质是把数组分成三段并保持每段的内部顺序属于多指针分区的更复杂形态。如果你把 283 的稳定分区思想吃透了再看 75 的荷兰国旗问题会轻松很多。把这几道题放在一起对照你会发现它们共享同一个骨架慢指针维护一个已处理区域的边界快指针负责遍历发现有价值的元素。变来变去换的只是判断条件和边界位置的维护策略。5.2 双指针的另外两种形态左右夹逼与滑动窗口我在前面说过双指针在 LeetCode 里是个大筐。除了 283 这类快慢指针还有两种常见形态值得你单独梳理左右夹逼相向双指针比如 167. 两数之和 II、11. 盛最多水的容器、977. 有序数组的平方。这类题的指针一个在左端、一个在右端根据当前和/面积/平方大小决定移动哪一侧核心逻辑是每一步排除掉一个不可能包含最优解的区域。滑动窗口同向但窗口可变比如 3. 无重复字符的最长子串、209. 长度最小的子数组。左右指针都向前移动中间夹着一个不断伸缩的窗口用于维护某种连续的约束条件比如子串无重复、子数组和大于等于目标值。所以当你看到双指针标签时首先要分清是快慢指针、左右夹逼还是滑动窗口。283 属于第一种它的核心特征是两个指针速度不同、方向相同。5.3 练习顺序与刷题建议如果你准备系统刷双指针我建议的顺序是先从 283 入门掌握同向快慢指针的区间维护思路再做 26 和 27 巩固然后挑战 75 感受多指针分区最后转向 167 和 11切到左右夹逼的思维模式。这个顺序能让指针移动的方向从单一变为多元每道题带来的认知增量都很大。有一个刷题技巧我很推荐每做完一道题强迫自己写一段思路复盘用一句话概括这道题的指针移动规则。比如 283 是快指针找非零慢指针定位落点26 是快指针找新值慢指针维护去重末尾。这种概括能帮你快速区分不同题目之间的细微差别而不是把所有双指针题目都背成同一套模板。6. 面试追问与工程联想这道题的真正价值283 在面试中经常被当作热身题但热身不代表面试官会轻易放过你。我见过不少候选人在白板上写出交换法之后被接下来几个追问问得卡壳。6.1 面试官拿到283之后常见的追问序列第一个追问通常是如果不用原地限制你会怎么做这时候你要能快速说出新建一个数组第一遍收集非零元素第二遍补零的方案同时指出它违反了题目的空间约束。说出的目的不是证明你会走捷径而是证明你知道什么时候可以用额外空间、什么时候不行。第二个追问能尽量少移动元素吗这就回到我在第三章提到的交换法与覆盖法的常数对比。非零元素多时交换法好零元素多时覆盖法好能把这两者的赋值次数差异讲清楚面试官基本就满意了。第三个追问如果要求移动的是负数呢答案很简单把判断条件从nums[fast] ! 0改成nums[fast] 0或其他规则即可但重点是你有没有意识到移动零只是移动满足某类条件的元素的特例。能把这个抽象说出来说明你不是背题而是真的理解了。第四个追问如果不需要保持相对顺序能不能用更少操作这时你可以提到两端夹逼交换的思路它确实能减少某些情况下的赋值次数但代价是失去稳定性。结合 2.3 节的分析你能现场演示[1, 0, 2, 0, 3, 4]是如何被它搅乱顺序的这个追问就算彻底过关了。6.2 稳定分区在工程里的样子离开刷题场景283 背后的稳定原地分区思想在工程中随处可见。举一个我实际遇到过的例子在广告投放系统的曝光记录里需要把已经下线的广告计划对应的记录统一移到数组尾部同时保证仍然在线的广告记录保持原有先后顺序方便后续按时间戳做增量同步。这就是一个典型的稳定分区需求算法层面和 283 完全同构只是判断条件从是否非零变成了计划是否在线。类似的场景还有订单列表需要把异常状态的订单挪到末尾但保留正常订单的顺序日志系统需要把某种级别的日志归档到尾部甚至数据库在整理碎片时也需要在保留主键顺序的前提下把死元组压缩到页尾。这些需求都可以用同向双指针的思想来解决。所以不要觉得 283 只是一道面试题它实际上教给你的是如何在空间受限的情况下稳定地重排一批数据。6.3 我的刷题体会简单题的价值在够不够干净我自己刷 Hot100 到第 283 题的时候已经是刷了几百道题的老手了但我仍然会刻意要求自己把这题写到位。原因很简单简单题最容易暴露代码习惯。你是不是喜欢用硬编码的辅助数组你的循环边界是否依赖调试而非推理遇到极端输入时会不会下意识忽视这些习惯性弱点在难题里容易被复杂逻辑掩盖在 283 这种十行代码的题里则会原形毕露。我在实际刷题中还有一个习惯做完 283 之后尝试用至少三种不同方式实现它——交换法、覆盖法、以及先找第一个零再双指针移动的变体——然后用标准用例和极端用例分别跑一遍。这个过程帮我建立了对同向双指针的肌肉记忆。之后遇到任何需要稳定分区的题我的第一反应都是快慢指针而不是去硬套其他模板。最后再分享一个小技巧刷题时把慢指针的语义用注释写在代码上方比如# slow: next position to place a non-zero element。这行注释看起来多余但写下来之后你的循环不变量就有了锚点调试时能少掉一半脑力。这个习惯从 283 开始建立后面刷 75、刷 287 时都会一直受益。
返回列表