很多人在刷题的时候愿意跳过这种标着 Easy 的题,觉得“移动零”不就是把 0 挪到后面吗,有什么好写的。但我自己在面试别人、复盘候选人白板代码的过程中发现,LeetCode 283 这道题恰恰是能把“看起来会写”和“真的会写”区分开的那类题。改元素位置本身不难,难的是在 O(1) 额外空间的约束下,让遍历次数、赋值次数和结果稳定性都达标。283“移动零”看起来是给新手练手的入门题,实际上它是数组双指针类题目里最典型的样板,把这题吃透,后面很多“原地修改数组”的题都能一通百通。
这篇文章我不打算只贴一份代码然后说“完成”,而是想完整走一遍我的思考链路:题目到底在考什么、双指针是怎么从暴力解里演化出来的、覆盖写法和交换写法在真实面试里有什么差别、边界用例可能把哪些看似正确的答案打回原形,以及 283 的思路还能延伸到 27、26、75 这些题目上。不管你是刚开始刷题的新手,还是准备面试想把这个模式讲得更清楚的开发者,都应该能从里面拿到一点能直接用的东西。
1. 这道题的真考点:不是“移动”,是“原地”
1.1 先把题目要求拆开看
题面说得很简单:给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。示例也很直观:
code复制输入: [0,1,0,3,12]
输出: [1,3,12,0,0]
如果只看这个输入输出,很容易产生一种错觉:这题不就是把非零元素挑出来放到前面,再把后面填零吗?没错,思路确实是这样,但这句话里藏着两个必须同时满足的硬条件,缺一个都是在给面试官送分:
- 必须在原数组上操作,不能拷贝额外的数组;
- 非零元素原来的相对顺序不能变。
第一点就是“原地操作”约束,它把很多偷懒的解法直接划掉了。第二点通常被忽略,但它决定了双指针到底是“从前往后”还是“从后往前”,也决定了你写出来的代码会不会在某个隐藏用例上倒下。
1.2 “原地”到底意味着什么
很多初学者看到“原地操作”的第一反应是:哦,那我用 temp 变量交换不就行了。这个理解对,但是没有触及本质。“原地”约束的核心是额外空间复杂度必须做到 O(1),也就是说,不管数组有多大,我用到的额外存储只能是固定数量的变量,不能随着输入规模增长。
如果把限制稍微放松一点,这道题可以写得很舒服:
python复制def move_zeroes_with_extra_space(nums):
result = [x for x in nums if x != 0]
result += [0] * (len(nums) - len(result))
return result
逻辑完全正确,结果也对,但面试官只要追问一句“额外空间是多少”,这版代码就当场淘汰了。因为 result 的大小随着 nums 增长,空间复杂度是 O(n),跟“原地”根本不沾边。
所以 283 真正考察的不是“你会不会把 0 移到末尾”,而是“你愿不愿意放弃额外数组,用两个指针在同一个数组里完成读写分离”。这是所有数组类算法题里最基础、最通用的一层能力。
1.3 保持相对顺序:稳定性是隐藏门槛
再想深一层:为什么题目非要强调保持相对顺序?因为如果不用保持顺序,解法可以从双指针退化成“两头换”:
python复制def move_zeroes_unstable(nums):
left, right = 0, len(nums) - 1
while left < right:
while left < right and nums[left] != 0:
left += 1
while left < right and nums[right] == 0:
right -= 1
nums[left], nums[right] = nums[right], nums[left]
这个解法也能把 0 全部挪到后面,但它会改变非零元素的相对位置。示例 [0,1,0,3,12] 如果走这种交换,结果很可能变成 [12,3,1,0,0]。题目明确要求保持顺序,本质上是在约束你必须用“稳定”的方式处理元素。这跟排序里的“稳定排序”是同一个概念,理解这一点之后,你就能明白为什么后续延伸题里有的要求“保持原顺序”,有的不要求,解法会因此完全不同。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 双指针的核心思路:从暴力解一步步推出来
2.1 如果你真的想暴力做,会是什么样
先把最简单、最不聪明但一定正确的做法写出来:遍历两遍,第一遍数一下非零元素有几个,第二遍创建一个新数组,把非零元素依次放进开头,最后补零。这个方案前面已经提过,空间复杂度 O(n),复杂度高不高?高。但它给了我们一个非常重要的启示:结果数组中,从左到右依次放的是原数组里的非零元素,顺序还保持原样。也就是说,输出数组其实可以理解为“把原数组的非零元素按原来的顺序压缩到最前面”。
这个“压缩”动作,就是双指针的雏形。你之所以需要压缩,不是因为元素多了或者少了,而是因为在原数组上操作时,你不想因为“删除一个元素”而让后面的元素全部往前挪。数组的删除操作成本是 O(n),如果每个 0 都触发一次整体搬移,整道题的复杂度直接变成 O(n²),数组稍微大一点就肉眼可见地卡顿。
2.2 双指针的演化过程
既然不能拷贝数组,又不想每次删除都搬移,那就只剩下一条路:用一个指针负责“读”,另一个指针负责“写”。
读指针,通常叫 i 或者 fast,从数组头扫到数组尾,它的任务很简单:判断当前元素是不是非零元素。写指针,通常叫 j 或者 slow,指向“下一个非零元素应该放到哪个位置”。当读指针扫到一个非零元素时,就把它写到写指针指向的位置,然后写指针前进一格;如果读指针扫到的是 0,什么都不做,继续往前扫。
还是拿 [0,1,0,3,12] 走一遍:
- 初始状态:
i和j都指向索引 0; i=0,读到的值是 0,跳过,j不动;i=1,读到的值是 1,不是 0,把它写到nums[0],j变成 1;i=2,读到的值是 0,跳过;i=3,读到的值是 3,不是 0,写到nums[1],j变成 2;i=4,读到的值是 12,不是 0,写到nums[2],j变成 3。
此时数组的前半段已经变成了 [1,3,12,...],后半段还是残留下来的旧值 [0,0] 或者 [0,0] 的一部分。因为题目要求所有 0 都在末尾,所以写指针 j 之后的所有位置,必须全部变成 0。
这个思路特别像什么呢?像整理一排列车车厢。你负责从车头走到车尾,把每节有人的车厢里的人都往前面空出来的车厢引导,走到最后,前面是整齐的队伍,后面空出来的车厢自然就是空的。整个过程没有把整列车拆掉重装,只是让人在车厢之间移动,O(n) 搞定。
2.3 为什么非零元素的相对顺序天然稳定
这是整个算法里最值得停下来想的一个点。因为读指针永远是从左往右扫,先碰到的非零元素会先被写到前面,后碰到的非零元素会被写到更靠后的位置。写指针的推进顺序和读指针的扫描顺序完全一致,这就保证了“先出现”的永远在“后出现”的前面。
反过来,如果读指针从右往左扫,或者你先处理后面的 0 再处理前面的非零,顺序就会颠倒。所以“稳定”不是靠额外的逻辑保证的,而是靠指针的方向保证的。面试的时候,如果面试官问“为什么这个解法稳定”,你只需要答这一句:指针扫描和写入的方向一致,第一次出现的元素永远先被写入。
3. 两种实现风格:覆盖法和交换法
3.1 覆盖后补零:最容易写对的第一版
思路理清楚以后,写代码就很直接了。第一种实现方式,也是我强烈建议新手第一版先写的,是“覆盖后补零”。
python复制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
整个过程就是:先用 slow 指针把所有非零元素往前铺,铺完之后,slow 后面剩下的位置统一补 0。
这段代码有几个细节值得注意:
slow不一定等于“已处理的非零元素个数”。它确实等于已写入的非零元素个数,而且因为只对非零元素递增,所以最后slow的值就是非零元素的个数。- 第二段循环的起始位置是
slow,不是slow + 1。写错这里的人不在少数。因为slow在写完最后一个非零元素之后先加了 1,此时它指向的位置已经是第一个该填 0 的坑位,所以要从这里开始补零。 - 如果数组里没有 0,比如
[1,2,3],第一段循环会把每个元素复制到自己身上,第二段循环完全不执行。结果没变,只是做了几次无效的自赋值,这不影响正确性,但在极端性能敏感的场合可以加一次判断避免。
3.2 原地交换:更省事但需要留神的写法
第二种实现方式,不额外补零,而是在遍历过程中直接把 0 和非零元素交换位置。
python复制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 在遇到 0 之前,它指向的位置和 fast 指向的位置是同一个,交换等于自交换;一旦 slow 停在了某个 0 的位置上,fast 继续往后扫描,再碰到非零元素时,交换就会把这个 0 挪到后面去,同时把非零元素挪到前面来。
还是用 [0,1,0,3,12] 走一遍:
slow=0, fast=0,值 0 跳过;slow=0, fast=1,值 1,交换nums[0]和nums[1],数组变成[1,0,0,3,12],slow=1;slow=1, fast=2,值 0 跳过;slow=1, fast=3,值 3,交换nums[1]和nums[3],数组变成[1,3,0,0,12],slow=2;slow=2, fast=4,值 12,交换nums[2]和nums[4],数组变成[1,3,12,0,0],slow=3。
和覆盖法的结果完全一致,但省掉了第二段补零循环。代价是每次交换涉及三次赋值,而覆盖法的“赋值”只有一次。在数据量不大的情况下,运行时间几乎没有差异;在数据量极大的极端场景,覆盖法的赋值次数反而更少。
3.3 两种写法怎么选
先上结论:面试中两种写法都能用,但覆盖法的“可讲解性”更强,交换法的“可读性”更短。我自己更倾向于让候选人先用覆盖法把思路讲清楚,因为它的每一步都对应“把非零元素挪到前方”这个直觉,边界情况也更少。
交换法有一个坑需要额外踩:当 slow 和 fast 指向同一个位置时,代码会执行一次交换自己和自己的操作,这在逻辑上无害,但如果你用基准测试去对比,会发现全非零数组上它比覆盖法慢一点点。想要规避也很简单,加一个判断:
python复制def move_zeroes(nums):
slow = 0
for fast in range(len(nums)):
if nums[fast] != 0:
if slow != fast:
nums[slow], nums[fast] = nums[fast], nums[slow]
slow += 1
但加了这个判断之后,代码变得没那么清爽,而且这个判断本身也消耗时间。所以我一般建议:能用覆盖法就把问题解决,不用为了“优雅”强行上交换法。只有当你明确知道要处理的是超大数组,并且希望尽量减少赋值次数时,再认真考虑交换法的优化方向。
4. 用边界用例把代码怼一遍:几个容易翻车的地方
4.1 最容易出错的几种输入
代码写出来之后,真正的考验不是示例用例,而是边界用例。我见过太多人在 LeetCode 上通过之后,一改写成 Java 或 C++ 就挂在边界用例上。下面这几组输入,建议实现之后全部跑一遍。
第一组是空数组 []。这两种实现都完全无视这个情况:for 循环一次都不执行,直接结束,结果还是空数组。这一组如果写挂了,说明对循环结构的理解还有问题。
第二组是只有一个元素:[0]。覆盖法的第一段循环会跳过,第二段循环把 nums[0] 重新写成 0,结果 [0]。交换法的 slow=0,fast=0,值 0 跳过,结果 [0]。都没问题。但如果有人把补零的循环写成 for i in range(slow, len(nums) + 1),就直接越界了,这是写补零循环时最容易犯的错。
第三组是全是零:[0,0,0]。覆盖法第一段循环完全不执行,第二段循环从 slow=0 开始,把三个位置依次写成 0,结果不变。交换法三个位置全部跳过,结果也不变。这一组主要验证的是:读指针在最后阶段会不会因为 j 已经走到数组末尾而出现二次处理。
第四组是完全没有零:[1,2,3,4,5]。覆盖法会逐个自赋值,交换法会逐个自交换。结果都不变。这里要关注的是:有没有多余操作影响性能。
第五组是零都在开头:[0,0,1,2]。覆盖法执行 nums[0]=1, nums[1]=2,然后从位置 2 开始补零,结果 [1,2,0,0]。交换法执行 nums[0]和nums[2]交换 -> [1,0,0,2],然后 nums[1]和nums[3]交换 -> [1,2,0,0]。结果一致。
4.2 两种写法在同一个用例上的结果差异
交换法的结果变化过程是“渐进的”,也就是说,在执行过程中,数组的中间状态看起来像是“1 和 0 在交错移动”。覆盖法在第一段循环执行完之后,数组中间状态是“前面是非零元素,后面是残留的原值”,只有执行完第二段循环才是最终结果。
这两者在单步调试的时候差别很大。如果你用覆盖法,第二段循环执行前,数组的后面一段可能残留着之前的值,这些值有可能是 0,也有可能不是 0。比如输入 [1,0,2,0,3],第一段循环执行完之后,数组变成 [1,2,3,0,3],最后一个位置还残留着原来的 3,第二段循环把位置 3 和 4 都写成 0,才得到 [1,2,3,0,0]。如果面试官追问“为什么最后那两个位置一定是 0”,你应该说:因为非零元素一共就 3 个,数组长度为 5,剩余 2 个位置必须放 0,所以统一覆盖。
4.3 复杂度验证
时间复杂度方面,无论覆盖法还是交换法,每个元素最多被读指针扫描一次,非零元素最多被写指针处理一次,整体上是 O(n)。覆盖法的第二段补零循环在最坏情况下(比如数组全是 0,或者非零元素数量很少)会执行接近 n 次赋值,但依然是 O(n),不改变数量级。
空间复杂度方面,两个版本都只用到了 slow 和 fast 两个整数变量,不管输入数组是 10 个元素还是 1 亿个元素,额外空间都是常数级的 O(1)。这就是“原地操作”最标准的体现。
我在实际写这种题时,还会额外关注一个点:“交换的写法是否真的节省了写入次数”。以 [0,1,0,3,12] 为例,覆盖法写入非零元素 3 次 + 补零 2 次 = 5 次赋值;交换法执行了 3 次交换,每次交换涉及 3 次赋值 = 9 次赋值。在数据量大的时候,交换法并不比覆盖法快。所以遇到“对性能极度敏感”的面试官,你能主动抛出这个对比,会显得你真的理解代码背后的成本,而不是只背了一个答案。
4.4 一个容易被忽略的变体:如果不用保持相对顺序
我在文章开头提过“如果不用保持顺序”,这里再展开一下。面试官如果把这个题目改一版:只要求把 0 放末尾,不要求非零元素顺序,那解法就完全不同了。你可以用左右对撞指针:左指针找 0,右指针找非零,找到后交换。这样能减少交换次数,但会打乱非零元素顺序。
这个变体的存在,提醒我们思考一个问题:为什么原题要求保持顺序?因为在实际业务场景里,元素往往不只是数字,而是一整条记录或对象的集合。你按某个字段把记录排序后,如果还要再按另一个维度分组,稳定性就是必须的。比如电商订单列表按金额排好序后,再做一次“让 0 金额订单沉底”的分组,如果分组不稳定,之前金额的排序就被破坏了。算法题的约束,很多时候不是面试官拍脑袋设计的,而是从真实场景里抽象出来的。
5. 从 283 看一类题:后面这些题目用的也是同一套思路
5.1 同类题的双指针套路
如果你把 283 吃透,会发现自己已经掌握了一个非常有用的工具:快慢指针,或者说读写双指针。这个工具在数组原地操作这个类别里特别常用,最典型的几个延伸:
LeetCode 26“删除有序数组中的重复项”就是同一个套路。要求原地删除重复元素,使得每个元素只出现一次,返回新长度。解法是用快指针扫描数组,慢指针记录下一个不重复元素该放的位置。每扫到一个和上一个不重复的元素,就写到慢指针位置。这不就是 283 的“非零元素”换成了“非重复元素”吗?逻辑框架完全一致,甚至连补零都不需要,因为题目只要求返回长度,不要求数组后段是什么。
LeetCode 27“移除元素”也是同一个套路。给定一个值 val,要求原地移除所有等于 val 的元素。解法依然是快慢指针:快指针扫描,遇到不等于 val 的元素就写到慢指针位置,慢指针加一,最后返回慢指针的值作为新数组长度。这就是 283 的“0”换成了“任意指定的 val”。
LeetCode 75“颜色分类”,也就是荷兰国旗问题,稍微进阶一点,用三个指针来区分 0、1、2 三个区域。它的核心仍然逃不开往边界写元素的思路,只是从“两类元素”变成了“三类元素”。如果你能理解 283 这个“两类元素”的稳定划分,那么三个指针的版本会更容易看进去,因为你知道了指针本质上是“区域的边界”。
5.2 什么时候该用这套思路
判断一道题是不是这类“双指针原地处理”题,我有一个简单有效的口诀:一维数组 + 原地操作 + 元素需要按某种规则分组或保留一部分,优先考虑快慢指针。
这三个条件不一定全都在题目里直接写出“原地”两个字。只要看到类似“不能使用额外数组空间”“不要返回新数组”“在原数组上修改”这样的描述,大概率就是这套思路。而如果题目允许你新建数组,那问题往往会变成“模拟、统计、排序”而不是“指针操作”,难度会直线下降。
不过也要提醒一句,不是所有数组题都用快慢指针。如果题目要求“找出连续子数组最大和”,适合用滑动窗口;如果要求“两数之和”,适合用哈希表;如果要求“旋转数组”,需要的是反转技巧。先判断题目类型再选工具,比拿到题就下意识写双指针要稳妥得多。
5.3 面试中怎么把自己的思路讲清楚
很多候选人代码写对了,但面试官问“为什么这么解”的时候答得支支吾吾,最后被扣分。这是我的经验之谈:面试官看 283 这类题,真正想确认的是你有没有把“为什么不复制数组”“为什么需要两个指针”“为什么顺序不会乱”这三个问题想明白。
我的建议是,按照下面的顺序讲:
先说复杂度的底线:不能用额外数组,所以不能想当然地做“取非零、补零”的拷贝方案;再说双指针的职责分工:快指针负责扫描,慢指针负责标记可写位置;最后用一个小例子展示指针移动过程,最好边说边在纸上把数组的状态画出来。这样下来,面试官基本能确认你不是背答案,而是真的理解了这个模型。
我个人在练这一系列题目时,还会强迫自己用至少两种语言各写一遍。因为不同语言对数组操作的语法不同,但逻辑一致。用 Python 写完之后再用 JavaScript 写一遍,会发现思维的肌肉记忆比单纯看题解强很多。
好了,这道题的内容就到这儿。如果你刚刷到 283,花了半小时没解出来也不要紧,按这篇文章的思路,先理清“为什么不能复制数组”和“为什么快指针在前、慢指针在后”,再动手写代码,会比直接背题解牢固得多。后面再碰到任何“原地操作数组”的题目,就试着套一下这套双指针框架,我相信你会回来感谢从 283 开始建立的那个直觉。
