但凡刷过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]:
fast=0,元素是0,跳过;fast=1,元素是1,写入nums[0]=1,slow=1;fast=2,元素是0,跳过;fast=3,元素是3,写入nums[1]=3,slow=2;fast=4,元素是12,写入nums[2]=12,slow=3;- 第二轮从下标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]:
left=0,right=0,跳过;right=1,元素1非0,交换nums[0]和nums[1],数组变[1, 0, 0, 3, 12],left=1;right=2,元素0,跳过;right=3,元素3非0,交换nums[1]和nums[3],数组变[1, 3, 0, 0, 12],left=2;right=4,元素12非0,交换nums[2]和nums[4],数组变[1, 3, 12, 0, 0],left=3。
你发现没有,交换法天然地把0“挤”到了数组后部,不需要第二遍补零,而且同样稳定。两种解法的时间、空间复杂度完全一致,区别只是代码风格。我个人的习惯是面试里先写覆盖法,因为它的思路更直白,边界条件也更少;如果面试官要求“尽量少操作次数”,那用交换法更合适,毕竟它避免了第二次遍历。
两种方案对比如下:
| 对比维度 | 快慢指针覆盖法 | 零游标交换法 |
|---|---|---|
| 遍历次数 | 两遍(筛选 + 补零) | 一遍 |
| 交换次数 | 仅写入,无交换 | 每次遇到非零都会交换 |
| 代码理解难度 | 更容易 | 稍微需要想一下 left 的语义 |
| 空间占用 | O(1) | O(1) |
| 时间占用 | O(n) | O(n) |
3. 代码实现与边界处理:改对这三个坑就能一次AC
3.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]传入后 fast=0 时写入nums[0]=5,slow=1,第二遍从下标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 # 然后从 left+1 开始找非零交换这个思路本质上回到暴力解了,它先把第一个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] != val;283 的保留条件是nums[fast] != 0,本质是27在 val=0 时的特例,额外多了补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题的时候回头看,会发现很多难题都是从这几个简单骨架上长出来的,那时候再来刷中等难度的数组题,思路会明显清晰很多。