开头就直接说事。LeetCode Hot100 刷题的人应该都有印象,排在最前面的几道题里,有一道看起来“人畜无害”的数组题——移动零,题号 283。不少新手看到这道题以为是在考“你会不会删元素”,真正写起来才发现,面试官要的是你能不能在一次遍历、原地操作的限制下,把代码写干净、把思路讲清楚。这道题确实是 Hot100 里从“会写代码”过渡到“会写算法”的一道门坎题。
这篇文章我不打算只贴一段 AC 代码就跑。我会把这道题从暴力想到最优、从易错点到面试话术、从原题到相关变形,完整拆开讲一遍。后半段还会把我自己刷这道题时踩过的坑、排查过的超时问题、以及怎么把它和快排 partition、移除元素、有序数组去重串成一条线一起吃透,一并写出来。无论你是刚开始刷 LeetCode、准备面试,还是单纯想把数组操作写得更稳,这篇都适合你。
1. 先搞清楚这道题到底在考什么
1.1 题目原意与三个隐藏约束
先看原题:给定一个数组 nums,写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。注意几个关键词:“原地”、“移动到末尾”、“保持非零元素的相对顺序”。
“原地”意味着不能新建一个数组再拷贝回去,空间复杂度最好压到 O(1)。“移动到末尾”不是“删除 0”再在末尾补 0——这个说法虽然贴近结果,但写代码时容易跑偏。“保持非零元素的相对顺序”是整道题的题眼,它直接决定了不能用简单的排序或交换来蒙混过关。
举个例子:输入 [0, 1, 0, 3, 12],正确输出是 [1, 3, 12, 0, 0]。1、3、12 的先后顺序完全不动。如果你用倒序处理或者从后往前交换,一不留神就会变成 [3, 1, 12, 0, 0] 这种错误答案。
这三个约束合在一起,基本就是在引导你往“双指针”方向走。数组原地操作的进阶技巧里,双指针是最常用的一套武器,这道题就是它最朴素、最经典的应用场景。
1.2 为什么 Hot100 要把它排到第 2 位
Hot100 的顺序不是随便排的。前几道题往往承担着“让新手在低挫败感下掌握高频考点”的任务。移动零恰好能满足这个定位:题目本身不难懂,不做任何优化也能写出一个能跑的解法,但想达到 O(n) 时间 + O(1) 空间的“最优解”,就必须真正理解双指针的工作机制。
更重要的是,这道题的思维模型会反复出现在后面的题里。比如“移除元素”、“删除有序数组中的重复项”、“按奇偶排序数组”,甚至快排里的 partition 思想,本质上都是同一套东西:一个指针负责遍历,另一个指针负责标记边界,把满足条件的元素“搬”到边界内。把移动零吃透,后面再遇到这类题会轻松很多。
1.3 常见误读:“移动 0”不等于“删掉 0”
我见过不少人在讨论区把这道题理解成“先删 0 再补 0”。这个方向没错,但如果你用 Python 的 remove 或者在数组中间 insert,就会掉进性能陷阱。remove 每次都要从开头找到第一个 0,然后把后面所有元素前移一位,多个 0 就要重复扫描多次,最坏情况退化成 O(n^2)。这在大数组上会直接超时。
另一个常见误读是“用排序解决”。如果按 0 和非 0 排个序,看起来把 0 放到了末尾,但排序无法保证非零元素的相对顺序稳定,除非你写的是稳定排序且比较函数只区分“是否为 0”。理论上做得到,但复杂度也不是 O(n),属于典型的“用高射炮打蚊子”,面试官听了会皱眉。
判断你是否真正理解了这道题,最简单的自测方式就是:能不能只用一个循环解决?能不能在只申请常量级额外空间的条件下,处理 [0, 0, 0, 0]、[1, 2, 3, 4]、[0] 这些边界样例?
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从暴力解法到双指针的思维路径
2.1 第一反应:新数组法,为什么不可行
很多人的第一反应是新建一个数组,把非零元素按顺序放进去,后面补 0,再拷贝回原数组。思路清晰、代码简单,几分钟就能写出来。这在笔试题里可以拿部分分,但面试时基本会继续追问:“能不能不用额外数组?”
这里有一个容易被忽略的点:题目要求原地操作,本质上是卡空间复杂度。如果你开了一个等长的新数组,空间复杂度是 O(n),哪怕逻辑完全正确,也不是这道题想考察的最优解。所以做题时不用急着否定这个方案,它反而是最好的“铺垫”——你可以先写出这个版本,然后主动说“但这个方案用了 O(n) 空间,我想能不能优化到 O(1)”。这种从无脑解到最优解的进阶路径,恰恰是面试官想看到的思考过程。
2.2 原地操作的暴力思路:每次遇到 0 就整体前移
既然不能用新数组,那就原地搬。一个容易想到的暴力方案是:从前往后遍历,遇到一个 0,就把后面所有元素往前挪一位,然后在数组末尾补一个 0。
这么做确实原地,但代价很大。假设数组长度为 n,最坏情况(比如前半段全是 0),遇到第一个 0 要挪动 n-1 个元素,遇到第二个 0 又要挪动 n-2 个元素,累加起来是 O(n^2) 的复杂度。LeetCode 的数据量一大,这个解法就会超时。
我最早刷这道题时也写过这种版本,本地测试小数组没问题,一提交就红了一大片。后来才意识到,问题的根源在于“每遇到一个 0 都去挪整个后半段”,重复劳动太多。正确的思路不是“把 0 往右推”,而是“把非零往左搬”。
2.3 双指针的核心思路:读指针和写指针
想象一下有两个指针,一个叫 fast,一个叫 slow。fast 像一只眼睛,从数组开头一路看到结尾,负责判断每个位置上的元素值;slow 像一只手,只负责记录“下一个非零元素应该放到哪个位置”。
fast 每次往前走一步,如果发现当前位置不是 0,就把这个值写到 slow 指向的位置,然后 slow 也往前走一步。如果发现是 0,fast 继续往前走,slow 原地不动。
这个过程结束后,所有非零元素都被按顺序搬到了数组前部,slow 正好停在最后一个非零元素的后一位。最后一步,把 slow 之后的所有位置统一置 0,就完成了。
这里面的关键点在于:slow 永远不会超过 fast。fast 走前面,slow 跟在后面,当 fast 指向非零且不等于 slow 时,说明中间至少隔了一个 0,这时候把 fast 的值赋给 slow 是安全的,因为 slow 位置上的旧值要么是 0、要么已经被搬走过。
用一个生活中的例子来类比:把数组想象成一条传送带,上面有苹果(非零)和空箱子(0)。你要做的是把所有苹果按顺序堆到传送带前端,空箱子自然被挤到最后。fast 就是传送带上的扫描头,slow 就是苹果堆当前的堆放位置。这么一想,整个过程就非常顺了。
3. 双指针的三种实现版本与取舍
3.1 先移动再补零:最容易理解的版本
这是我最推荐新手掌握的写法。它把问题拆成两步:第一步,把所有非零元素按顺序放到前面;第二步,把剩余位置全部填成 0。Python 代码如下:
python复制def moveZeroes(nums):
slow = 0
for fast in range(len(nums)):
if nums[fast] != 0:
nums[slow] = nums[fast]
slow += 1
while slow < len(nums):
nums[slow] = 0
slow += 1
这个版本的好处是逻辑极其清晰,slow 的语义非常明确:它始终指向“下一个非零元素该放的位置”。面试时讲这个版本,面试官通常不需要追问就能理解。
需要注意一个细节:如果 fast == slow,说明当前位置本来就在正确的位置上,此时 nums[slow] = nums[fast] 是自我赋值,没有任何副作用。这个操作不会导致数据丢失,因为 slow 没有超前于 fast 的可能。
3.2 直接交换:代码更精简的版本
第二种思路是在遍历过程中直接把非零元素和 slow 位置的元素交换,不需要最后再补 0。代码如下:
python复制def moveZeroes(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 交换到后面去,然后 slow 前进一位。
这个版本在面试中很受欢迎,因为它天然做到“一次遍历完成所有操作”,而且代码更短。但要提醒一点:当 fast == slow 时,交换操作会和自己交换,虽然结果正确,但会产生一次无意义的赋值操作。对大多数场景来说这点开销可以忽略,但在追求极致的代码里,可以在交换前加一个 if fast != slow 的判断。
3.3 零计数法:另一种思维角度
除了双指针,还有一种计数法也能达到 O(n) 时间、O(1) 空间。基本思路是:遍历时记录已经遇到的 0 的个数,遇到非零元素就把它往前移动“0 的个数”个位置。等遍历结束,从倒数第 count 个位置开始,把最后 count 个位置全部置 0。
python复制def moveZeroes(nums):
count = 0
for i in range(len(nums)):
if nums[i] == 0:
count += 1
else:
nums[i - count] = nums[i]
for i in range(len(nums) - count, len(nums)):
nums[i] = 0
这种写法代码量也不大,思路直观,适合笔试时快速写出来。它在面试中的价值在于体现你对同一道题的多角度思考。但相比之下,双指针的语义更通用,因为它可以直接迁移到“移除元素”、“去重”等其他题目上,所以我个人还是更推荐优先掌握双指针。
3.4 三种版本的复杂度与适用场景对比
为了让你一眼看清差异,我把三个版本的特性整理成了一个表格:
| 版本 | 时间复杂度 | 空间复杂度 | 代码量 | 适用场景 |
|---|---|---|---|---|
| 先移动再补零 | O(n) | O(1) | 中等 | 面试首选,思路最清晰,讲解成本最低 |
| 直接交换 | O(n) | O(1) | 少 | 写题最快,刷题比赛首选,代码最精炼 |
| 零计数法 | O(n) | O(1) | 少 | 适合理解另一种思维路径,但不建议作为主答案 |
三个版本的时间复杂度一样,常数级别的差异在实际 LeetCode 测试中几乎感知不到。真正决定选哪个的,是你要不要向面试官展示一个清晰的逻辑链路。如果面试官只要求你写出来、讲明白,我建议用“先移动再补零”;如果你想显得代码功力更老练,可以用“直接交换”。
4. 边界条件与高频易错点
4.1 四类典型边界输入
不管用哪个版本,代码写完后都要跑一遍边界数据,否则很容易带着 bug 就提交了。
第一类:全零数组,比如 [0, 0, 0, 0]。双指针遍历时,每个元素都是 0,slow 一直停在开头,第一个版本的补零循环会把整个数组都填成 0,结果正确;第二个版本完全不会进入交换逻辑,结果也正确。
第二类:不含零的数组,比如 [1, 2, 3, 4]。fast 每走一步都遇到非零,slow 会紧跟着 fast,最终 slow == len(nums),补零循环不会执行。正确结果就是原数组。
第三类:数组长度只有 1,无论是 [0] 还是 [5]。循环只跑一次,结果都不会变。这类测试最容易让人自信满满地提交,然后发现边界没问题,主逻辑反而出了问题。
第四类:混合负数和非零大数,比如 [-1, 0, 2, -3, 0]。判断条件 nums[fast] != 0 对负数同样成立,负数会和非零正数一样被保留在前面。千万不要把条件写成 nums[fast] > 0,那会把负数错误地当成 0 搬走。
4.2 最容易翻车的坏习惯:边遍历边修改数组
这道题用 Python 写的时候,一个非常经典的错误是在 for 循环里直接删除元素:
python复制for i in nums:
if i == 0:
nums.remove(0)
这段代码在 [0, 1, 0, 2] 这类输入上会得到奇怪的结果。原因是 remove 修改了数组长度,而 for 循环内部是根据下标索引推进的,跳过了一些元素。这类问题的排查往往比自己想象中耗时,而且根本无法通过简单的测试用例暴露。
正确做法永远是用“写指针”来覆盖,而不是在遍历时删除或插入。这也是为什么双指针写法天然安全的原因——它从不改变数组长度,只是原地覆盖和交换。
4.3 “保持相对顺序”究竟难在哪
很多人不理解,为什么这道题强调“保持非零元素的相对顺序”。其实这句话是在限制你使用快速排序式的交换逻辑。
比如,如果允许打乱顺序,那可以一头一尾双指针:从左边找到第一个 0,从右边找到第一个非零,然后交换。整个过程非常快,但结果是 [12, 3, 1, 0, 0] 这种乱序输出,在本题里就是错误答案。
这组对比能帮你更深刻理解:做题时,不仅要“把 0 放后面”,还要“稳定地把 0 放后面”。稳定二字,就是快慢双指针之所以成立的根本原因。
我的建议是,每次写完解法后,手动拿 [0, 1, 0, 3, 12] 和 [0, 0, 1] 这两个用例走一遍,确认非零元素的相对顺序没有变,再提交。
5. 面试中的考察点和说题技巧
5.1 面试官为什么爱考这道题
这道题之所以高频,不是因为它难,而是因为它能在一道题里同时考察三项基本功:数组下标操作、边界条件控制、原地算法意识。很多候选人写业务代码时习惯依赖框架 API,一到了手写数组操作就暴露出对“指针”“下标”理解不透彻的问题。
面试官一般会在你写完基础版之后,追加几个问题:你能不看额外空间吗?你能只遍历一遍吗?如果让你移动的不是 0,而是某个给定的 target,你该改哪一行?如果改成把偶数放前面、奇数放后面,你还能用同样的模板吗?
如果你能顺着这几问透,说明你对“partition 思想”的迁移能力是过关的,这比死记硬背十道题更有说服力。
5.2 答题的推荐节奏:暴力先稳住,再主动优化
我推荐的面试答题节奏是:先花 10 秒确认题目要求和边界条件,然后直接说“我先说一个最直觉的解法,新建数组,把非零元素按顺序放进去,再补零”。说完之后马上接一句:“但这个方案空间是 O(n),如果限制原地操作,我可以改成双指针,一次遍历完成,空间 O(1)。”
这样做有两个好处:第一,证明你的思路是逐步演进的,而不是背答案;第二,给面试官一个清晰的信号——你不仅会写,还知道每个方案的代价在哪里。
如果你一上来就写最优解,面试官反而可能怀疑你是背过的,会继续往深挖。适当展示“暴力到最优”的路径,反而能让面试官更放心地让你通过。
5.3 手撕代码时的几个细节
写代码时,有几点会直接影响面试官对你的印象:
第一,变量命名要有语义。用 slow、fast 比用 i、j 好得多,因为前者直接表达了双指针各自的职责。第二,写双指针版本时,最好同时说一句“slow 指向的是当前第一个 0 的位置”,这样面试官会确认你理解这个指针的维护逻辑,而不只是背模板。第三,写完不要急着说“好了”,而是在代码旁边口头跑一个例子,比如用 [0, 1, 0, 3, 12] 走一遍,确认结果是 [1, 3, 12, 0, 0]。这种主动验证动作在面试中是很大的加分项。
5.4 相关变形题:移动任意值、奇偶排序
理解了移动零之后,有几道变形题几乎不用再费脑子就能秒掉。
第一道:给定一个数组和一个目标值 target,把所有等于 target 的元素移动到数组末尾,保持其他元素的相对顺序。做法就是把 nums[fast] != 0 改成 nums[fast] != target,其他完全不动。
第二道:把偶数放到数组前部,奇数放到数组后部,保持相对顺序。判断条件改成 nums[fast] % 2 == 0 即可。这个变体经常出现在一些公司的一面题里,如果移动零掌握得牢,这题就是送分题。
第三道:LeetCode 283 的反向版本,把某个元素移动到最前面。思路相同,只需要把快慢指针的维护逻辑反过来,先找到目标值出现的位置,再搬动即可。
6. 这个模板能打通多少题
6.1 移除元素、去重、移动零:其实是同一套模板
LeetCode 里有一组题,被我称为“同源三兄弟”:第 27 题 移除元素、第 26 题 删除有序数组中的重复项、第 283 题 移动零。它们的核心模板完全一致,都是快慢指针,只是判断条件和赋值策略略有差异。
移除元素要求把不等于 val 的元素留在前面,后面剩下什么无所谓,因为题目只关注前 k 个。删除有序数组中的重复项要求把不重复的元素依次放到数组前部,然后返回新长度。移动零要求把非零元素留在前面,其余位置统一补 0。
把这三道题放在一起刷,你会发现它们的双指针代码结构惊人地相似。一旦你从移动零里提炼出“快指针遍历 + 慢指针写入”的通用模式,另外两道题就是改一两个条件的事。我的经验是,这种“以点带面”的刷法,比一道一道孤立地刷效率高得多。
6.2 从移动零到快排 partition 的思维跳跃
快排的核心 partition 是:选一个 pivot,把小于 pivot 的放左边,大于 pivot 的放右边。这本质上也是一个“移动元素到指定边界”的问题,只不过 pivot 是动态选的,判断条件是大小比较而非是否等于 0。
移动零里,我们可以把 pivot 看成 0,把“不等于 0”的元素往左搬。这和快排里的“把小于 pivot 的元素往左搬”几乎是同一套动作。差别在于快排 partition 不要求稳定,所以可以用左右交换的方式;而移动零要求稳定,所以只能用快慢指针的方式。
如果你能想明白这一层,以后再看到“荷兰国旗问题”、“按颜色分类”、“移动特定值”这类题目,就会有天然的熟悉感。这正是刷 Hot100 想要达到的效果——不是背答案,而是建立思维模型。
6.3 从数组到字符串:双指针的迁移能力
双指针不只在数组里好用,在字符串场景里一样能打。比如“反转字符串中的元音字母”,“验证回文串”,“最长回文子串”等题目里,双指针都是核心武器。移动零练出来的“一个指针维护边界,一个指针负责扫描”的感觉,可以直接迁移到这些场景中。
而且,一旦你习惯了这种写法,你会发现大多数“原地操作 + 相对顺序”类题目的代码都很短。这个短不是靠压缩代码,而是因为思路足够清晰,不需要额外的辅助结构。
7. 实测踩坑记录与常见问题排查
7.1 本地能跑通,提交却超时
这是我刚开始刷 LeetCode 时最常遇到的状况。小数组测试时性能差异看不出来,但一旦输入规模到几万甚至十万,O(n^2) 的暴力解法就会暴露出问题。
有一次我写了一个版本:每次遇到 0,就用 Python 的 pop(0) 把它删掉,再在末尾 append(0)。本地跑 [0, 1, 0, 3, 12] 完全正确,自我感觉良好,结果提交后超时。原因就是 pop(0) 在列表开头删除元素,需要把后面所有元素都往前挪一位,整个数组有多少个 0 就要挪多少次,复杂度直接拉满。
排查方法很简单:把数据量拉大,比如生成一个长度为 10 万的数组,看运行时间是否暴涨。如果明显卡顿,大概率就是算法复杂度出了问题,不是代码 bug。
7.2 Python 里 remove 和 insert 的隐藏开销
在 Python 中,list.remove(value) 看起来只要一行代码,但内部实现要先线性查找目标值,再把后面的元素整体前移。list.insert(0, x) 同理,也要整体后移。这些操作在数组中都是 O(n) 的,叠加在循环里就是 O(n^2)。
因此,在写这道题时,尽量避开一切“在数组中间或开头插入/删除”的写法。最安全的方式就是下标赋值或交换。如果你测试中发现某个版本特别慢,先检查是不是用了 remove、insert、pop(0) 这类操作。
7.3 多语言实现时容易踩的坑
这道题我用 Python、Java、C++ 都写过,每个语言都有一些细微的坑值得注意。
Java 版本里,基本类型数组不能像 Python 那样直接平行赋值做交换,必须借助临时变量。int[] 和 List<Integer> 的处理方式也不同,如果不小心用了 ArrayList 的 remove,同样会有 O(n^2) 的问题。
C++ 版本里,迭代器遍历时修改 vector 元素是安全的,但如果用到 erase,会导致迭代器失效。另外,C++ 的交换操作也有许多写法差异,std::swap 是最方便的方式,但如果你自己手写交换,要注意复制开销,对对象类型尤其明显。
Python 版本则要小心一个比较隐蔽的点:for i in range(len(nums)) 中,如果循环体内修改 nums 的长度会导致越界或漏检。只要不增删元素,只在原位置赋值或交换,就完全安全。
7.4 调试时容易误判的一个细节
这道题的一个特点是:函数直接在原数组上修改,不返回新数组。如果你在本地调试时写成这样就会踩坑:
python复制nums = [0, 1, 0, 3, 12]
print(moveZeroes(nums))
函数没有返回值,print 会打印 None,让你误以为代码没生效。正确做法是打印修改后的 nums,或者让函数返回 None,然后单独打印原数组。
这个小细节在初学阶段特别容易让人抓狂。你可能会反复检查代码逻辑,结果只是调试方式不对。后来我养成了一个习惯:凡是原地修改的函数,都在调用之后单独打印原数组,而不是用返回值。
7.5 我的一些个人体会
刷题刷到后面,我发现 Hot100 里真正难的不是那些一眼看不懂的难题,反而是像移动零这种“一看就会,一写就错”的基础题。它会反复提醒你:算法能力的核心不是记住某道题的答案,而是理解每个指针为什么这样移动、每步操作的时间代价是多少。
我个人更推荐大家用“先移动再补零”的版本来理解原理,再用“直接交换”的版本来熟练手速。两个版本都写一遍,你才算真正掌握了这道题。面试前如果时间紧,只看交换版本就够了,但前提是你能在 30 秒内说清楚 slow 和 fast 各自的意义。
最后再分享一个小技巧:刷完移动零之后,建议立刻刷 27 题(移除元素)和 26 题(删除有序数组中的重复项)。三道题放在一起对比着写,你会发现它们就是同一个模板的三副面孔。刷完这三道,再去看快速排序 partition,又会有一层新的理解。这就是 Hot100 真正的价值所在——每一道题都不是孤岛,而是一整片大陆的入口。
