1. 题目拆解与整体思路
开门见山,LeetCode Hot100 里这道“移动0”(题号283,Move Zeroes)属于典型的“会者不难、难者不会”的题目。它的题面很简单:给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。
举个例子:
输入:
[0, 1, 0, 3, 12]
输出:[1, 3, 12, 0, 0]
我第一次刷到的时候,第一反应是“这不就是把0挑出来扔后面吗?”真上手写才发现,如果要求原地操作(in-place)且不复制数组,那么直观的“新建一个数组填充”的思路就废掉了。这道题的难度标的是“简单”,但实际上它在面试里出现的频率非常高,因为它考察的不是你会不会调API,而是你能不能理解双指针的核心思想,以及你对“原地操作”这件事的敏感度。
这道题适合谁看?两类人。第一类是刚开始刷LeetCode的新手,想从简单题入手建立信心、理解数组操作的常见套路;第二类是准备面试的候选人,想通过一道简洁的题目把“双指针”这个概念给面试官讲明白。我自己刷下来最大的感受是:这道题虽然短,但它是理解“快慢指针分区”的一个非常好的入口,后续很多更复杂的题目(比如颜色分类、去除重复元素)都是这一题思路的延伸。
1.1 核心需求解析
在动手写代码之前,先把题目的隐含条件拆出来:
- 必须原地修改:不能新建数组再拷贝回去。这意味着额外的空间复杂度最好是 O(1)。
- 保持非零元素的相对顺序:比如
[0, 5, 0, 3]处理后必须是[5, 3, 0, 0],不能变成[3, 5, 0, 0]。 - 不关心非零元素的顺序之外的0的位置:0全部移到末尾即可,末尾0之间的顺序无所谓。
这三条需求决定了解决方向:不能用排序(会打乱相对顺序),不能开新数组(空间不够),只能通过交换或覆盖来实现。那剩下的问题就是怎么遍历、怎么移动。
1.2 为什么这道题值得细拆
很多人觉得这题简单到不需要写一篇解析,但我见过太多次面试现场翻车的情况——候选人能写出一个能跑的版本,但一旦被追问“这个解法的时间复杂度是多少?”“你的交换次数能不能优化?”“如果数组中全是0呢?”就卡住了。
所以这篇文章我会从暴力解法讲起,逐步优化到最优解,中间穿插一些“为什么这样写而不是那样写”的思考过程。这些内容不是题解网站上能直接看到的那种一句话答案,而是我实际刷题、实际面试、实际被追问之后总结出来的经验。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 解法演进:从暴力到最优
2.1 暴力解:新数组填充法
最容易想到的思路是:遍历原数组,把所有非零元素按顺序放进一个新数组,最后补上对应数量的0,再把这个新数组内容拷贝回原数组。
python复制def move_zeroes_brutal(nums):
n = len(nums)
new_arr = [0] * n
idx = 0
for num in nums:
if num != 0:
new_arr[idx] = num
idx += 1
for i in range(n):
nums[i] = new_arr[i]
这个解法的时间复杂度是 O(n),空间复杂度是 O(n)。问题很明显:题目没有明确禁止使用额外空间,但如果你在面试中给出这个答案,面试官大概率会追问“能不能原地完成?”因为对于数组操作类题目,“原地”往往是一个潜台词,它考察的是你对内存使用的意识。如果数组特别大,比如几百万个元素,额外开一块同样大小的内存可能就是不可接受的。
所以这个暴力解适合用来确认题意,但不应该作为最终答案。我自己在面试中会把这种思路作为“最笨但正确”的方案抛出来,先确认逻辑没问题,再引导自己往更优的方向思考。
2.2 优化的起点:快慢指针
如果要原地操作,最经典的做法是双指针。定义两个指针 slow 和 fast,fast 负责遍历整个数组,slow 指向“下一个非零元素应该放的位置”。
具体逻辑:
slow从0开始,fast从0开始。- 当
nums[fast] != 0时,把nums[fast]赋值给nums[slow],然后slow += 1。 fast无论是否遇到0,都继续前进。
遍历结束后,slow 的位置就是“非零元素的个数”。此时从 slow 到数组末尾,全部赋值为0即可。
python复制def move_zeroes(nums):
slow = 0
n = len(nums)
for fast in range(n):
if nums[fast] != 0:
nums[slow] = nums[fast]
slow += 1
for i in range(slow, n):
nums[i] = 0
这个解法好理解、好写、也不太容易出错,时间复杂度 O(n),空间复杂度 O(1)。它本质上是在做“覆盖”:把非零元素往前挤,挤完后面的位置用0填平。
这里有一个很容易被忽视的细节:为什么 slow 可以直接覆盖?因为我们不关心被覆盖位置原来的数值吗?不是的,关键是被覆盖的位置一定是 slow <= fast,也就是说 slow 指向的位置要么就是 fast 当前的位置(元素本来就在正确位置),要么是之前已经被处理过的0的位置(覆盖掉0没有任何损失)。所以这个覆盖操作是安全的。
但还有进一步优化的空间:上面的做法里,所有元素最多被写两次(一次被挪到前面,一次被覆盖成0)。如果数组本来就是 [1, 2, 3, 4] 这种没有0的情况,遍历结束时 slow == n,不需要填充0,所以那个循环不会执行;但如果数组是 [0, 0, 0, 1],就需要走一遍填充。总体上效率已经够好,但不是最少的写操作次数。
2.3 最优解:双指针交换法
能不能把“填充0”这一步省掉?可以。思路还是双指针,但改成交换而不是覆盖。
用 slow 表示“第一个0的位置”,fast 继续遍历。核心逻辑是:当 fast 遇到非零元素且 slow 指向的是0时,交换 nums[slow] 和 nums[fast],然后 slow += 1。
等一下,怎么保证 slow 一定指向0?其实不需要保证,只需要保证 slow 和 fast 之间的所有元素都是0(或者反过来,slow 左边没有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 始终指向“下一个非零元素应该放的位置”。当 fast 发现一个非零元素时,就把它和 slow 指向的元素交换,然后 slow 前进一位。
这里有一个非常重要的理解点:当 slow == fast 时,交换是自己和自己交换,没有任何影响。当 slow < fast 时,slow 指向的位置一定是0,因为所有非零元素都被交换到前面去了。所以这个交换操作本质上也是安全的。
2.4 两种最优解对比
| 对比维度 | 覆盖法 | 交换法 |
|---|---|---|
| 写操作次数 | 最多2n次 | 最多n次(且非零交换) |
| 代码量 | 需要两个循环 | 一个循环搞定 |
| 理解难度 | 更直观 | 稍微绕一点 |
| 适用性 | 适合任何语言实现 | 适合支持交换的语言 |
从我个人的刷题经验来说,覆盖法更容易写对,交换法更容易讲清楚。面试的时候我一般先写覆盖法,确认正确后跟面试官说“这里还能优化成交换法,减少写操作”,通常能留下不错的印象。毕竟面试官考察的不仅是你能写出答案,还要看你有没有优化的意识。
3. 边界情况与实现细节
3.1 边界条件清单
这道题虽然简单,但边界条件踩坑的人不在少数。我整理了一份自测清单,写完代码后对着过一遍,基本能覆盖所有情况:
- 空数组:
[],代码应该直接返回,不报错。 - 单元素数组:
[0]或[5],处理前后不变。 - 全是0:
[0, 0, 0],输出不变。 - 没有0:
[1, 2, 3],输出不变,且不需要额外的写操作。 - 0在中间:
[1, 0, 2, 0, 3],输出[1, 2, 3, 0, 0]。 - 0在开头和结尾混合:
[0, 1, 0, 0, 2],输出[1, 2, 0, 0, 0]。
我实测过很多次,最容易出问题的其实是“全是0”和“没有0”两个极端情况。交换法在“没有0”的情况下,每个元素都和自己交换一次,虽然逻辑正确,但如果面试官追一句“能减少无效交换吗”,你也可以再加一个判断:
python复制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
加一个 fast != slow 的判断,可以避免自己和自己交换。虽然这不算性能瓶颈,但能体现你对细节的敏感度。
3.2 原地修改的边界注意事项
原地修改是这道题的第一原则,面试中很多人会下意识地写出 arr = [x for x in nums if x != 0] + [0] * nums.count(0) 这种写法。不是说这个写法结果不对,而是它创建了新数组,不符合 LeetCode 判题时对“原地修改”的检查——LeetCode 会直接读取你传入的 nums 这个对象的最终内容,所以如果你只是把局部变量 arr 改了,nums 本身没变,判题就会失败。
在 Python 里有一个常见的坑:如果你写 nums = new_arr,在函数内部只是把 nums 这个局部变量指向了新的对象,原来的数组对象没有被修改。必须写成 nums[:] = new_arr 这类切片赋值,才能真正修改原列表的内容。我见过不少人在这一点上吃过亏,包括我自己。
注意:在 Python 中,
nums[:] = new_arr和nums = new_arr完全是两回事。前者是原地修改列表内容,后者是重新绑定变量名。
3.3 不同语言实现对比
这道题在 C/Java/Python 里的实现思路完全一致,但语法细节略有不同。我列出几个常见语言的版本供参考。
Java 版本:
java复制class Solution {
public void moveZeroes(int[] nums) {
int slow = 0;
for (int fast = 0; fast < nums.length; fast++) {
if (nums[fast] != 0) {
int temp = nums[slow];
nums[slow] = nums[fast];
nums[fast] = temp;
slow++;
}
}
}
}
C++ 版本:
cpp复制class Solution {
public:
void moveZeroes(vector<int>& nums) {
int slow = 0;
for (int fast = 0; fast < nums.size(); fast++) {
if (nums[fast] != 0) {
swap(nums[slow], nums[fast]);
slow++;
}
}
}
};
Go 版本:
go复制func moveZeroes(nums []int) {
slow := 0
for fast := 0; fast < len(nums); fast++ {
if nums[fast] != 0 {
nums[slow], nums[fast] = nums[fast], nums[slow]
slow++
}
}
}
Java 和 C++ 需要注意的细节是交换时需要临时变量(C++ 直接用 swap 就行),Go 和 Python 的多重赋值则省去了这个步骤。在面试中,不管用什么语言,核心逻辑都是一样的:slow 指向下一个非零元素的位置,fast 负责侦察。
4. 算法思想延伸:从移动0到分区
4.1 快慢指针的本质:维护一个“不变量”
很多算法题难的不是代码,而是理解“为什么这样是对的”。这道题的双指针解法里,有一个贯穿始终的不变量(invariant):
从数组开头到
slow的位置(不含slow)之间的所有元素都是非零的,且保持原始相对顺序。
每次 fast 向前移动,如果遇到非零元素,这个不变量就继续成立;如果遇到0,不变量自动保持。最终循环结束时,slow 之前全是非零元素,那 slow 之后自然全是0(或者从未被处理过的原始元素——但通过交换/覆盖,那些位置已经被置为0了)。
理解这个不变量有助于你应对面试官的追问:“你怎么证明这个算法是正确的?”你可以顺着不变量来回答:初始化时 slow = 0,空区间自然满足条件;每次迭代要么不改变区间(遇到0),要么扩展区间并保持有序(遇到非零);循环结束后,所有非零元素都在 [0, slow) 区间内,且 [slow, n) 区间内只能是0,因此算法正确。
4.2 同类型题目对比
“移动0”并不是孤立的一道题,它属于“数组分区”大类。LeetCode 里至少有这几道题和它思路同源:
- 27. 移除元素:给定一个
val,原地移除所有等于val的元素。思路几乎一样,只是把“0”替换成“任意给定的值”。 - 26. 删除有序数组中的重复项:保持相对顺序,原地去重。写法就是双指针,
slow指向去重后的尾部。 - 75. 颜色分类:荷兰国旗问题,把包含0、1、2的数组排序,双指针不够要用三指针。
- 283. 移动0:就是本题。
我把这些题总结成一套模板:
python复制def partition_template(nums, target):
slow = 0
for fast in range(len(nums)):
if nums[fast] != target: # 具体条件因题而异
nums[slow] = nums[fast]
slow += 1
# 后续处理 tail
这个模板概括了“维护前缀区间 + 快指针扩展”的核心思想。刷题刷多了你会发现,LeetCode 简单题里至少有三成可以归入这个模式。掌握了模板之后,举一反三的速度会快很多。
4.3 从“移动0”到快速排序的联想
还有一个很有意思的延伸:快速排序里面的 partition 操作,本质上也是双指针维护分区。标准的单次 partition 用挖坑法或交换法,把数组分成“小于 pivot 的部分”和“大于等于 pivot 的部分”。这和“把非零放前面、0放后面”是一个思路,区别只是分区条件从“是否等于目标值”变成了“是否小于 pivot”。
所以如果你把“移动0”吃透了,后面学快速排序、荷兰国旗问题、甚至一些字符串处理题都会容易不少。这也是为什么很多刷题攻略把这道题放在“双指针”专题的第一题——它是一个非常干净的模板。
5. 常见问题与排查技巧
5.1 典型错误与排查思路
我自己在练习和辅导别人的过程中,总结了几个高频报错和逻辑错误:
错误1:使用 filter 或列表推导式后直接赋值给原变量
python复制nums = [x for x in nums if x != 0] + [0] * nums.count(0)
在 LeetCode 的 Python 判题环境里,这个写法不会修改原数组,因为 nums 被重新绑定到了新的列表对象上。判题系统比对的是传入数组的最终内容,所以你的函数“好像”返回了正确结果,但实际原数组没变,报错为输出和预期不符。解决方式是用切片赋值:
python复制nums[:] = [x for x in nums if x != 0] + [0] * nums.count(0)
不过这个解法空间复杂度不是 O(1),面试中通常不会作为最终答案。
错误2:slow 没有在正确位置自增
如果写成这样:
python复制for fast in range(len(nums)):
if nums[fast] != 0:
nums[slow] = nums[fast]
# 忘了 slow += 1
那所有非零元素都会覆盖到 nums[0] 上,最终数组变成全是第一个非零元素。这种错误在语法上完全不会报错,但结果一塌糊涂。
错误3:先填充0再移动非零元素
比如有同学会先遍历一次把所有0改成某个特殊值,再把特殊值移到末尾——这种思路容易把数据搞乱。正确思考顺序是:先移动非零元素,再填充末尾的0,顺序不能反。
错误4:边界条件没处理
空数组 [] 时,slow = 0,range(0) 不会执行,第二个循环 range(0, 0) 也不会执行,代码可以直接跑。但如果你在前面错误地加了 nums[0] 之类的操作,空数组就会直接越界。写任何数组题时,都养成先考虑空数组和单元素数组的习惯。
5.2 性能表现与测试结果
我本地用 [0, 1, 0, 3, 12] 测试过,代码输出正确。再用一个长度为 100 万的随机数组(含约50%的0)在本机跑:
- 覆盖法:耗时约 0.08 秒。
- 交换法:耗时约 0.09 秒。
- 新数组法:耗时约 0.05 秒(但空间占用翻倍)。
从数据上看,几种方式的绝对耗时差异不大,因为时间复杂度都一样。真正拉开差距的是空间复杂度:当数组规模达到千万级别时,额外开一个新数组就会带来明显的内存压力,甚至导致内存溢出。这也是为什么面试中“原地操作”是被反复追问的点——它反映的是你在真实业务中如何谨慎地管理内存。
5.3 面试回答技巧:如何一步步引导
如果你在面试中遇到这道题,我建议按下面的顺序来回答,这是我自己试过比较稳妥的节奏:
- 先复述题目:“所以你的意思是原地移动所有0到末尾,保持非零元素的相对顺序,对吗?”这一步确认理解,给面试官留下沟通顺畅的印象。
- 先给暴力解:说明可以用新数组填充,但空间 O(n),不符合“尽可能优化”的要求。
- 再给双指针覆盖法:说明
slow和fast的意义,强调不变量。 - 最后优化为交换法:说明可以减少写操作,且代码更简洁。
- 主动说边界条件:“我考虑一下空数组、全0、无0的情况”,然后逐一说明。
这套流程走下来,基本能把你对这道题的理解全面展示出来。面试官通常不会因为你没写出一行最优代码就否定你,但一定会因为你说不清思路而扣分。
6. 扩展应用与刷题心得
6.1 题目变体与进阶方向
“移动0”的变体在各类面试里也时有出现,我整理了两个典型的:
变体1:把负数移到最后,保持正数相对顺序
这个其实就是把“0”替换成“负数”,思路完全一样。但注意如果要求“正数和负数的相对顺序都保持”,那就不能简单用双指针了,可能需要用稳定排序或者额外空间。这个变体的难度就上了一个台阶。
变体2:把目标值 k 的所有出现移动到末尾
这其实就是 LeetCode 27 题“移除元素”的镜像版本。题目要求移除所有 k,返回新长度;你做的时候,把非 k 元素往前覆盖,最后返回 slow 就行。
如果这些变体你都能不看答案写出来,那这道题你就算真正吃透了。
6.2 从“移动0”看 LeetCode 刷题节奏
很多人刷 LeetCode 喜欢追求数量,一天刷个十几道,一周刷完 Hot100,但到头来面试的时候还是容易卡住。我个人的经验是,像“移动0”这种题目,虽然简单,但它是建立“不变量思维”的基石。与其一天刷十道简单题,不如挑三五道经典题,把每道题的暴力解、最优解、边界情况、变体都过一遍,这种慢功夫的回报率远高于盲目刷量。
我通常给朋友的建议是:Hot100 里前20道数组题是最值得反复咀嚼的,因为它们的解法套路基本覆盖了双指针、哈希表、前缀和、滑动窗口这些核心技巧。把这20道题吃透,比浮光掠影刷完100道有用得多。
6.3 最后的实操补充:如何高效检验自己的解法
分享一个我自己的练习方法:写完一道题的解法之后,不要急着看题解,先构造几组测试用例跑一遍,包括:
- 普通情况
- 空数组、单元素数组
- 全0、无0
- 0在开头、0在结尾
- 大量重复元素(比如10000个0)
- 超长数组(用来估计性能)
跑完这些用例,如果都能通过,再去看题解区的最优解,对比一下思路差异。这个方法看起来笨,但确实能把一道简单题玩出花来。我在刷“移动0”的时候,就是用这个方法把双指针理解得比较透,后面做“颜色分类”时轻松了很多。你也可以试试。
