LeetCode 283 移动零:双指针与原地算法的经典剖析
LeetCode Hot 100 这个列表我是从第 1 题开始顺着刷的。刷到第 2 题“移动0”的时候说实话心里有点不以为然——一个标成“简单”的数组题能有啥好写的真正开始认真对待它是后来用它去面试别人才发现这个看似平平无奇的小题能在一瞬间区分出“背过答案的人”和“真正理解算法的人”。所以这篇就专门聊聊移动0LeetCode 283这道题它为什么值得做有哪些看似正确其实有问题的解法双指针的正解是怎么一步步“长”出来的以及它背后串联起的同型题目。1. 为什么一道“简单题”能稳在 Hot100 前几排1.1 题目比你想的多三个约束先看原题描述给定一个数组nums编写一个函数将所有0移动到数组的末尾同时保持非零元素的相对顺序。 注意必须在原数组上操作不能拷贝额外数组。尽量减少操作次数。示例输入是[0,1,0,3,12]期望输出是[1,3,12,0,0]。如果你第一次刷这道题可能觉得它就是个“数组里挑 0 往后扔”的问题。但真正动笔写代码时题目里那三句话才是关键“保持非零元素的相对顺序”意味着你不能用“把 0 和末尾元素交换”这种取巧做法那样会把非零元素的顺序打乱。“必须在原数组上操作不能拷贝额外数组”直接堵死了“新建一个数组把非零填进去后面补 0”的思路。“尽量减少操作次数”是在暗示你不仅要解出来还要想最优的移动策略。这三句话单独看都不难但合在一起就把一道“简单题”变成了一个考察点很密集的题。在实际工程里“原地修改”对应的是内存敏感场景“保持相对顺序”对应的是稳定性要求——排序算法里经常聊的“稳定排序”说的就是同值元素在排序前后的相对位置不变。所以这道题的问法其实很贴近真实需求不是凭空刁难。1.2 为什么简单题反而区分度高我在面试里见过不少候选人提到“移动0”第一反应是“这题我背过。”然后开始默写双指针。但你再追问一句“为什么 swap 的时候 slow 位置一定是 0”很多人就卡住了。这就是 Hot100 这类列表有意思的地方。真正的高频题不一定是难题它更像一块试金石你会不会证明自己算法的正确性能不能分析清楚边界条件有没有主动跟面试官沟通你的解法——这些软实力全藏在一道“简单题”里。换个角度说如果你能把这题的每个细节都讲透后面遇到同类型的“双指针”题目基本可以平移思路只是条件包装得更复杂而已。2. 先别急着上双指针暴力解和典型错解挨个拆很多刷题攻略会直接甩双指针代码但我更建议你先看几个“错误答案”。因为只有知道错在哪才能真正理解正解的每一步在防什么。2.1 新数组法答案对但直接违规最容易想到的思路是def move_zeroes_copy(nums): result [] zero_count 0 for num in nums: if num 0: zero_count 1 else: result.append(num) result.extend([0] * zero_count) nums[:] result # 如果直接 result 返回根本过不了题目的检查这段代码逻辑上完全正确也能得到正确答案。但它创建了一个新的列表result额外空间是 O(n)。题目白纸黑字写了“不能拷贝额外数组”所以这个解法在面试里只能作为“第一步想到的方案”提出来然后自己否定它需要 O(n) 空间不符合要求。这种“先给一个能想到的解法再指出它的不足”的习惯在面试里很加分。它说明你在有意识地权衡而不是背题。但单独作为提交答案它不合格。2.2 冒泡式前移能过例子却顶不住长数组第二个思路是遇到 0就把后面的所有元素往前挪一位然后在数组末尾补一个 0。代码长这样def move_zeroes_shift(nums): n len(nums) i 0 while i n: if nums[i] 0: for j in range(i, n - 1): nums[j] nums[j 1] nums[n - 1] 0 else: i 1这个解法是“原地”的空间 O(1)相对顺序也没变。问题出在时间上最坏情况下比如数组是[0, 0, 0, ..., 0, 1]前面每一个 0 都要带着后面所有元素整体前移一次累加起来的移动次数是 n-1 n-2 ... 1也就是 O(n^2)。n 小的时候还能撑住LeetCode 后面的大数组测试用例直接超时。另外这个写法还有个容易出错的细节当发生移位后当前 i 位置变成了原来 i1 位置的值如果它仍然是 0就只能让 while 循环再处理一遍不能顺手 i 1。很多人写成 for 循环就踩坑最后要么漏处理要么死循环。这不只是效率问题坑是真的多。2.3 两种“看似合理”的错解思路再看两个很有迷惑性的做法。第一个是“从前往后遇到 0 就把它和数组末尾的元素交换”比如def move_zeroes_wrong(nums): n len(nums) last n - 1 i 0 while i last: if nums[i] 0: nums[i], nums[last] nums[last], nums[i] last - 1 else: i 1用[0, 1, 2, 0, 3]走一遍i0 时把 0 和最后一个元素 3 交换得到[3, 1, 2, 0, 0]结果非零元素的顺序是 3、1、2而原顺序是 1、2、3。顺序被破坏了。原因很直接你把“窗口扫描”和“末尾交换”组合在一起时前面的非零元素会被后面的非零元素顶到前面去稳定性完全丢失。第二个错解是边遍历边删 0 再 append常见于 Pythondef move_zeroes_pop(nums): n len(nums) for i in range(n): if nums[i] 0: nums.append(nums.pop(i))看起来一举两得把 0 弹出来然后追加到尾部。问题在于pop会改变数组长度和索引位置。以[0, 0, 1]为例i0 时 pop 掉第一个 0数组变成[0, 1]尾部 append 一个 0 变成[0, 1, 0]i1 时访问的是nums[1]此时是 1跳过最终结果是[0, 1, 0]第一个 0 根本没被移走。如果你换成for num in nums:的写法还会隐藏掉更多问题因为你既拿不到准确的索引又在遍历过程中修改了列表本身。这些错解最大的价值在于提醒你数组原地操作的题目核心难点就是“在覆盖/交换/删除之间保住还没处理的数据”。理解这个矛盾双指针的解法就顺理成章了。3. 双指针法把“处理0”翻译成“安置非零”3.1 slow 慢指针到底在维护什么双指针解法很多最常见的框架是一个慢指针slow表示“下一个非零元素应该放置的位置”一个快指针fast负责扫描整个数组。先想清楚一个问题我们真的需要专门处理 0 吗不用。如果你把所有非零元素按顺序搬到前面剩下的位置自然都是 0。所以这道题的本质不是“把 0 移出去”而是“把非零按顺序填到前面”。慢指针slow的意义就是维护一个边界[0, slow)这个区间里全是被处理好的非零元素。快指针fast一路往后扫每次发现一个非零元素就把它放到slow指向的位置然后slow前进一位。这个过程中0 不需要专门“搬到后面”因为最终slow之后的位置我们会统一清零或通过交换把 0 放过去。3.2 覆盖版 vs 交换版一份代码的变化过程基于上面的思想最直白的写法是“覆盖 末尾补 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。时间复杂度 O(n)空间 O(1)。但细想一下“覆盖”意味着nums[fast]原来的位置会留下一个“残留值”需要靠第二轮循环清理。能不能在一次遍历里顺便把清理工作也做了能用交换代替赋值def move_zeroes_swap(nums): slow 0 for fast in range(len(nums)): if nums[fast] ! 0: nums[slow], nums[fast] nums[fast], nums[slow] slow 1这次不再需要第二轮循环了。每当fast遇到非零元素就把它和slow位置的值交换slow再前进。因为[slow, fast)之间的元素早已被扫描过而且它们只能是 0这个下面再证明所以交换的结果一定是非零去了前面0 被换到了后面。两版代码的性能差距很小交换版少跑一个循环理论上每条指令也更少但覆盖版更好理解。不同的人适合不同的版本关键是逻辑一致。3.3 为什么“交换时 slow 位置一定是 0”——证明不变量很多题解会直接说“swap 就行”但面试官最爱追问的一句就是你凭什么保证交换后不会破坏已经排好的部分我们需要维护一个不变量扫描任意时刻满足[0, slow)区间内全是非零元素[slow, fast)区间内全是 0[fast, n)区间是尚未扫描的元素。初始时slow 0, fast 0三个区间都成立。下面看循环中的情况。如果nums[fast] 0说明扫描区遇到一个 0它应当属于[slow, fast)这个 0 区所以直接fast 1同时不变量保持。如果nums[fast] ! 0根据不变量nums[slow]一定是 0因为 slow 到 fast 之间全是 0而 slow 现在指向这个 0 区间的开头交换后非零去了 slow0 去了 fastslow 加一[0, slow)仍然全非零[slow, fast)重新变成空或全 0不变量继续成立。这就是为什么交换是安全的。用大白话说fast 比 slow 快fast 把前面的“坑”0 的位置都看到过一遍了slow 指向的那个位置早就被确认是 0拿它去换一个非零元素怎么换都不会伤害到已排序区域。4. 边界边界再边界测试用例与状态走查双指针代码写出来只有几行但边界条件才是拿分的点。我刷题和面试时都习惯在写代码之前先列一组测试用例覆盖常见的极端情况。4.1 测试用例清单设计输入预期输出覆盖点[][]空数组[0][0]单元素且为零[1][1]单元素非零[0,0,0][0,0,0]全零数组[1,2,3][1,2,3]全非零数组[0,1,0,3,12][1,3,12,0,0]标准混合用例[1,0,0,2,0,3][1,2,3,0,0,0]多个连续 0 夹在非零中间[0,0,1,2][1,2,0,0]0 全在开头这些用例里最容易让人翻车的是“全非零数组”和“0 全在开头”。全非零时slow和fast会一路同步交换的双方是同一个元素属于“自己换自己”代码不会报错但如果你用的是覆盖版第二轮循环也不会执行结果正确。0 全在开头时前几个位置都是 0slow一直不动直到遇到第一个非零元素然后它会把 0 区的第一个 0 换走这个行为符合预期。4.2 一组状态追踪表以交换版跑一遍标准输入[0,1,0,3,12]fastnums[fast]动作slow数组状态00跳过0[0, 1, 0, 3, 12]11交换 nums[0]、nums[1]1[1, 0, 0, 3, 12]20跳过1[1, 0, 0, 3, 12]33交换 nums[1]、nums[3]2[1, 3, 0, 0, 12]412交换 nums[2]、nums[4]3[1, 3, 12, 0, 0]手动走一遍表你会直观地看到slow始终指向 0 区间的开头fast每次遇到非零就把它“拽”到前面0 则被推后。这个走查过程在面试时非常推荐做给面试官看比空口讲“双指针”有说服力得多。4.3 Python 原地修改的两个小坑我自己的提交记录里踩过两个 Python 特有的坑。第一个坑在函数内写nums ...不会修改原数组。LeetCode 的检查逻辑是看你传入的nums是否被原地修改如果你写成def move_zeroes(nums): nums [num for num in nums if num ! 0] [0] * nums.count(0)函数结束后外面的nums还是原来的数组。必须写成nums[:] ...或者逐位赋值才能真的改到原数组上。这个点刷题时不注意后面做任何“原地修改”类题目都会吃亏。第二个坑遍历时拿不到索引。新手写for num in nums:然后用nums.index(num)去拿索引一旦数组里有重复元素index返回的位置是错的。更好的做法是从头到尾都用range遍历或者在交换时只依赖索引不依赖值。5. 移动0并不是孤立的一道题双指针变题串讲Hot100 里有一类题看着不重样底层全是“快慢指针 原地覆盖”。把这套框架想明白你可以用几乎同一个思路顺手解决好几道题。5.1 同一套模板解决 26、27、80先看第 27 题“移除元素”给定一个数组nums和一个值val原地移除所有等于val的元素并返回移除后数组的新长度。表面上是“移除元素”其实和移动 0 一模一样只是把“0”换成了参数val而且不需要把val放到末尾只需要把非 val 元素堆到前面。def remove_element(nums, val): slow 0 for fast in range(len(nums)): if nums[fast] ! val: nums[slow] nums[fast] slow 1 return slow你再回头看一眼移动 0 的覆盖版是不是只是把条件从nums[fast] ! 0换成了nums[fast] ! val完全同构。再看第 26 题“删除有序数组中的重复项”数组已经排好序要求把重复元素去掉返回新长度。还是在维护一个 slow 边界只是覆盖条件变成“遇到和前面已保留的最后一项不同的数”。def remove_duplicates(nums): if not nums: return 0 slow 1 for fast in range(1, len(nums)): if nums[fast] ! nums[slow - 1]: nums[slow] nums[fast] slow 1 return slow第 80 题“删除有序数组中的重复项 II”是 26 的加强版每个元素最多出现两次。判断条件从“和上一个不同”变成“和上上一个不同”因为如果nums[slow - 2] nums[fast]说明这个数已经出现两次了不能再保留def remove_duplicates_two(nums): slow 0 for fast in nums: if slow 2 or nums[slow - 2] ! fast: nums[slow] fast slow 1 return slow三个题是一个模子刻出来的slow维护“已完成区域”fast负责“往前探索”探索到符合条件的数据就放进已完成区域。你把这套模板理解到能不假思索写出来后面再做类似题目第一反应就不会是暴力解了。5.2 荷兰国旗问题三指针扩展当条件从一个“目标值”变成“三个区间”时双指针会升级成三指针LeetCode 第 75 题“颜色分类”就是典型。它要求把数组中的 0、1、2 排列成 0 在前、1 在中、2 在后。常见解法是维护left表示 0 区的右边界right表示 2 区的左边界再用i扫描中间def sort_colors(nums): left, right 0, len(nums) - 1 i 0 while i right: if nums[i] 0: nums[left], nums[i] nums[i], nums[left] left 1 i 1 elif nums[i] 2: nums[right], nums[i] nums[i], nums[right] right - 1 else: i 1你会发现它的核心思想还是“边界划分 交换”。区别只是维护的边界从一个变成了两个。这种思维方式从移动 0 一路延伸过来一点都不突兀。5.3 编程题之外的落地场景别觉得这些题只在面试里有意义。实际工作里“把某种元素沉底且保持其他元素顺序”的需求到处都是前端表格里把禁用的行排到最后同时保持可用行的原有顺序处理表单提交时把空字符串字段统一放到对象尾部方便后端解析日志系统里把特定 level 的日志聚合到末尾便于观察异常和正常日志的分界线数据库查询里ORDER BY对 nullable 字段做“NULL 最后”的排序本质也是这个逻辑。理解原地操作和稳定性这两个概念能帮你在工程里写更节省内存、更可控的数组处理代码。这也是为什么面试官喜欢拿这种“简单题”当切口它背后能聊的东西实在太多。6. 面试怎么聊这道题才不算白刷6.1 从“背答案”到“有节奏地回答”如果你只在本地默默写完代码这道题的价值你只用到了 20%。面试场景里同样的代码沟通节奏不同效果完全不同。我建议的顺序是先复述题目确认数组是否可能为空、元素是否为整数等边界然后说“我能想到最直接的是新数组法但空间不符合要求”接着转“如果不用额外空间我可以让非零元素依次覆盖到前面最后统一补 0这样是 O(n) 时间、O(1) 空间”如果有余力再优化成一次遍历的交换版。整个过程不是“直接甩最优解”而是让面试官看到你的思考链条。有一次我面一个候选人他一上来就写出了交换版。我问“你觉得覆盖版和交换版哪个更好”他想了一会儿说“交换版代码短一些其实覆盖版更直观。如果从实际执行来看覆盖版要跑两个循环交换版一个循环但两者都是线性复杂度差距不大。”这个回答我很满意因为他没有背答案而是真的在比较两种实现。6.2 时间复杂度论证别只说“一次遍历”很多人被追问“为什么时间复杂度是 O(n)”时只会说“因为只遍历了一次数组”。这个说法不算错但不够严谨。两个循环的覆盖版明明是两次遍历为什么也说 O(n)因为 O(n) 描述的是操作次数随输入规模增长的速率两个循环总共是 2n 次操作和 n 成正比所以还是 O(n)。交换版是一个循环 n 次操作两者都是 O(n)但常数项不同。如果你能主动补充一句“两个循环加起来是 O(n)交换版虽然也是 O(n)但常数更小”面试官会觉得你有复杂度量级的敏感度。这比背下“双指针 O(1) 空间”这种结论有用得多。6.3 用这道题考人的收获坦白说我自己用过这道题当面试起手式。几次下来我发现同一个“简单题”能看出三层差距第一层直接写新数组法或者背出双指针但讲不清原理——停留在“知道答案”的阶段。第二层能写出双指针并分析复杂度但在边界细节上要靠提醒——说明有算法基础但不够扎实。第三层从题目约束出发先给暴力解再逐步优化主动说明不变量并设计了几组边界用例——这是真正理解了问题。大多数人都卡在第二层。所以我一直建议刷 Hot100 的时候不要只追求“AC 数量”把一道简单题往深了挖收获比囫囵刷十道题都大。最后分享一个我个人常用的刷题手法遇到这种“简单题”别急着看题解区先自己把暴力解写出来再问自己三个问题——我的解法空间超了吗非零元素的相对顺序变了吗交换或覆盖的时候有没有可能丢数据想明白这三件事再去看双指针解法你会觉得它不是背下来的公式而是从约束条件里自然长出来的工具。移动0只是 Hot100 这条长路的第二站把这一站的每个细节磨透后面再遇到数组类的题目会顺很多。
