LeetCode 283. Move Zeroes,中文一般叫“移动零”,是很多刷题清单里排名特别靠前的一道 Easy。我见过不少刚开始刷题的读者把它当作“热手题”五分钟写完,结果一提交先红了一个 case;也见过一些已经刷了三五百题的人,在模拟面试里被要求讲讲为什么快慢指针能保证非零元素的相对顺序,讲着讲着就开始含糊。这题表面上是把数组里的零挪到末尾,实际上考的是三样东西:是否理解原地修改的约束、是否知道什么是稳定划分、以及能不能在给出的接口约束下写出最少操作的线性解法。
如果你正准备面试,或者刚进入 LeetCode 热门 100 题的刷题节奏,这篇题解适合你。我会从最容易翻车的约束开始,把解法怎么从“新开数组”一步步收敛到“双指针单次遍历”讲透,再给你三套可以直接落到编辑器里的实现,最后补一批容易血的边界用例和面试追问。这题不难,但值得认真对待。
1. 这道 Easy 题,为什么经常在面试里翻车
先说题目本身:给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。
举个例子:
code复制输入: [0,1,0,3,12]
输出: [1,3,12,0,0]
看起来是不是毫无压力?但请注意,题目还带了两条很关键的硬约束:
- 必须在原数组上操作,不能拷贝额外的数组。
- 尽量减少操作次数。
第一眼看到“把 0 移到最后”,很多人的反应是:新开一个数组,先遍历一遍把非零放进去,再补零。这思路完全正确,逻辑上也挑不出毛病,但它违反了“不能拷贝额外数组”这条约束。LeetCode 会检查最终 nums 的引用是否仍然指向原来的数组,如果你只是返回一个新数组,等于没有完成这个函数要求的原地语义。
我见过更典型的错误写法是这样的:遍历数组,遇到 0 就 pop 掉,然后再 append(0)。运行结果可能碰巧是对的,但复杂度完全失控。pop(0) 或者 remove(0) 在数组中间删除元素时,需要把后面所有元素统一往前搬,单次操作就是 O(n),外层再套一个 O(n) 的遍历,整体接近 O(n²)。如果数组足够长,后果一眼就能看出来。
还有一类错误,属于“思路对但实现细节没想清楚”。有人会尝试用一头一尾的双指针:从左边找到 0,从右边找到非零,然后交换。这个方案在不要求保持顺序时很有效,可一旦题目明确要求非零元素相对顺序不变,它就会让结果错得离谱。举个具体例子:
code复制输入: [0,1,0,3,12]
左指针指向 0,右指针指向最后一个元素 12
交换后: [12,1,0,3,0]
原来的非零顺序是 1、3、12,现在变成了 12、1、3。这个错误说明,左右夹逼交换的思路破坏了稳定性。
所以我说这题“翻车率”很高。它题面简单,但如果你没有形成“原地修改 = 写指针 / 读指针”这种稳定的思考路径,很容易写出观感不错但经不起深挖的答案。接下来我们逐个拆解这些约束。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 审题关键:原地修改和相对顺序到底卡住了多少解法
先聊“原地修改”。数组题目里的“原地”和“不能拷贝额外数组”意味着你的空间复杂度应该是 O(1),也就是只能使用有限的几个额外变量。像下面这种写法在很多语言里都有坑:
python复制nums = [x for x in nums if x != 0]
nums += [0] * (n - len(nums))
在 Python 的函数内部执行这串代码,并不会修改调用方传入的那个数组对象,而是把局部变量 nums 重新绑定到了一个新建的列表上。函数结束以后,外部数组还是老样子。 如果你非要用 Python 的列表推导式,至少也得写成 nums[:] = ... 这种切片赋值,它才会真正覆盖原数组的元素。但这类写法绕不开额外分配新列表的问题,严格说空间复杂度不是 O(1),和本题希望练习的原地能力也不吻合。
再聊“保持非零元素的相对顺序”。什么叫相对顺序?就是原来在左边的非零元素,处理完之后仍然在左边;原来在右边的,仍然在右边。以 [0,1,0,3,12] 为例,处理后的非零序列必须是 [1,3,12],不能是 [12,1,3] 或 [3,1,12]。
这条约束直接否决了所有“从后往前整理”或“首尾对调”的粗暴方案。前面说的左右双指针交换,就是典型的反例。正确做法必须保留一种“按原顺序逐个搬运”的性质,这也正是快慢指针能胜出的原因。
还有一个容易被忽略的点:题目说“尽量减少操作次数”。这里的“操作次数”通常指对数组元素的读写和交换。你不能说:既然要移到末尾,那就把所有非零元素整体往后移动一位,一直移动到底。那样确实也能完成,但元素被重复移动多次,操作次数明显偏高。线性扫描 + 每个非零元素最多被写一次,才是理想状态。
总结一下,审题阶段要形成三个结论:
- 不能用额外数组,所以要在原数组上手动维护位置。
- 必须保持稳定顺序,所以不能用破坏顺序的交换策略。
- 操作次数要尽量少,所以优先设计一次遍历的算法。
有了这三条边界,再去看各种解法,你会很清楚为什么某些答案在评论区被反复指出“不符合题意”。
3. 读写指针推演:从“想在草稿纸垫一遍”到原地完成
假设你现在没有“不能拷贝”这个限制,你会怎么做?正常人都会在草稿纸上先画一个新的数组位子,从左到右读原数组,看到非零就放进新数组的下一个空位。读到末尾,再把剩下的空位全填上 0。
这个直觉本身非常好,因为它是稳定的。新数组从前往后填充,天然保持原顺序。 现在我们唯一的困难是:新数组不允许存在,那能不能把原数组的“前半段”当作这个新数组来用?
答案是可以,只需要两个指针:
fast:负责从头到尾遍历数组,相当于“读指针”,判断当前元素是不是 0。slow:负责记录下一个非零元素应该放入的位置,相当于“写指针”。
当 fast 遇到非零时,就把这个值写到 slow 指向的位置,然后 slow 前进一格。因为 slow 的移动速度最多和 fast 一样快,所以不会覆盖还没处理过的元素。这就是那些“多开一个数组”方案在思想上的完整落地。
为了把读写指针的行为看清楚,我们手动走一遍 [0,1,0,3,12]。假如采用“先搬运非零、最后补零”的两阶段实现,过程如下:
初始状态:
code复制slow = 0
数组: [0, 1, 0, 3, 12]
第一轮遍历:
code复制fast = 0, nums[fast] = 0,是零,跳过
fast = 1, nums[fast] = 1,非零
把 nums[fast] 写到 nums[slow],即 nums[0] = 1
slow 变为 1
数组现在: [1, 1, 0, 3, 12]
fast = 2, nums[fast] = 0,是零,跳过
fast = 3, nums[fast] = 3,非零
把 nums[fast] 写到 nums[slow],即 nums[1] = 3
slow 变为 2
数组现在: [1, 3, 0, 3, 12]
fast = 4, nums[fast] = 12,非零
把 nums[fast] 写到 nums[slow],即 nums[2] = 12
slow 变为 3
数组现在: [1, 3, 12, 3, 12]
第一轮结束后,slow = 3,说明数组的前 3 个位置已经放好了所有非零元素,顺序是 1、3、12。但此时数组末尾还残留着两个值为 3 和 12 的元素。为什么会这样?因为“搬移”和“删除”是两码事。我们只是把非零元素复制到了前面,并没有清空原来的位置,数组长度也没有缩短。所以第二阶段必须把从 slow 开始到数组末尾的所有位置统一置为 0。
code复制从 slow = 3 开始,把数组末尾两个元素改为 0
数组最终: [1, 3, 12, 0, 0]
这里有一个初学者经常困惑的中间状态:在搬运过程中,数组同一时刻出现了两份 3 和两份 12。这正是因为原位置没有被立即覆盖。很多错误解法的根源,就是忘记了最后的补零阶段,导致返回结果里带有多余的旧值。
现在把两阶段进一步优化成一次遍历的交换版本。核心思路是:不要让非零元素“复制”到前面,而是让它和 slow 指向的 0 做交换。因为每次遇到非零,slow 指向的位置要么就是当前 fast 自己,要么就是一个已经确认的 0;把 0 和非零交换,等于把 0 扔到后面去,同时把非零稳定放到前面。
走一遍同一组数据:
code复制初始: slow = 0
fast = 0, num = 0,跳过
fast = 1, num = 1,非零
交换 nums[0] 和 nums[1],得到 [1, 0, 0, 3, 12]
slow = 1
fast = 2, num = 0,跳过
fast = 3, num = 3,非零
交换 nums[1] 和 nums[3],得到 [1, 3, 0, 0, 12]
slow = 2
fast = 4, num = 12,非零
交换 nums[2] 和 nums[4],得到 [1, 3, 12, 0, 0]
slow = 3
注意一个细节:为什么 fast=3 时,nums[slow] 也就是 nums[1] 一定是 0?因为在 fast 到达这里之前的某一次遍历中,原本的 1 已经被移动到下标 0,下标 1 的位置被交换过来的 0 占住了。你仔细体会一下:slow 并不会因为遇到 0 而前进,它始终指向第一个还待处理的 0 位,而 fast 不断往前扫描,碰到非零就把它和这个 0 位交换。两个指针之间的区间,本质上就是已经“沉淀下来”的 0。
这也解释了交换法为什么不需要最后的补零阶段:0 并没有消失,它只是被交换到了后面,已经待在它应该待的位置上。整个过程一遍遍历完成。
4. 三种能过提交的实现,各自的取舍
下面给出三套可以直接提交的实现。我会用 Python 来写,并在最后补充 C++/Java 的注意事项。第一套是两阶段搬移清零,第二套是单次遍历交换,第三套是更巧妙的零块滚动写法。
4.1 两阶段搬移清零:最容易理解,适合作为思维起点
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
# 第二趟:把剩余位置全部补 0
for i in range(slow, len(nums)):
nums[i] = 0
复杂度是 O(n),空间复杂度 O(1)。代码逻辑非常直白:第一趟负责搬移,第二趟负责清理。缺点是要遍历数组两遍,并且搬移非零时,数组后半段的旧值会短暂残留。如果面试官要求只遍历一遍,你需要使用第二种写指针交换法。
4.2 单次遍历交换:面试最推荐的写法
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] = nums[fast], nums[slow]
slow += 1
这套代码最简洁,也是我认为最适合作为最终答案的。它只遍历一遍,没有一个多余的第二阶段循环,也不需要担心“复制后忘记清空原有位置”的坑。你只需要向面试官解释清楚一个点:slow 始终指向当前第一个可以放非零的位置,而该位置在当前场景下一定是一个 0,除非 slow == fast。
在完全没有 0 的数组上,slow 和 fast 永远同步前进,交换的是同一个位置,相当于自己和自己交换。如果对“无意义的自交换”比较敏感,可以加一个判断:
python复制class Solution:
def moveZeroes(self, nums: List[int]) -> None:
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
加上 if fast != slow 后,全非零数组的处理过程就完全不会触发任何实际交换。但要注意,这个判断不是必须的,Python 里自己和自己交换也不会出错,语义完全正确。加了它只是为了减少理论上的写操作次数,对大多数场景来说,性能差异可以忽略不计。
4.3 零块滚动:思路有趣,但适合作为聊资而非首选
第三种方案用到一个计数器 zero_count,记录已经扫过的连续零块大小。每次遇到非零元素,就把它和前面零块的第一个零进行交换。
python复制class Solution:
def moveZeroes(self, nums: List[int]) -> None:
zero_count = 0
for i in range(len(nums)):
if nums[i] == 0:
zero_count += 1
elif zero_count > 0:
nums[i - zero_count], nums[i] = nums[i], nums[i - zero_count]
比如 [0, 0, 1, 2]:
- 先扫到两个 0,
zero_count = 2 - 遇到 1 时,把
nums[0]和nums[2]交换,得到[1, 0, 0, 2] - 遇到 2 时,把
nums[1]和nums[3]交换,得到[1, 2, 0, 0]
这个写法本质上是交换法的一种变体,但变量语义比较绕。在面试里,如果要用它作为首选,很容易把自己绕进去。相较之下,slow/fast 的表达方式更通用,也更容易扩展到第 27 题、第 26 题这类同族问题,所以我通常不建议在正式回答里用零块滚动。
把三种写法整理成一张对照表:
| 写法 | 遍历次数 | 是否存在自交换 | 是否需第二轮清零 | 代码可读性 |
|---|---|---|---|---|
| 两阶段搬移清零 | 2 次 | 否 | 是 | 直观 |
| 单次遍历交换 | 1 次 | 可能 | 否 | 最推荐 |
| 零块滚动 | 1 次 | 否 | 否 | 较绕 |
4.4 C++ 与 Java 提交时的同构写法
很多读者会认为算法语言不重要,但在这道题上,语言差异会体现在“数组是否作为引用传入”上。C++ 和 Java 中要确保函数签名是原地修改:
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;
}
}
}
};
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++ 里如果不用 vector,而是传入一个裸数组指针,必须同时把数组长度传进去;Java 里数组本身就是对象引用,函数内部对数组元素的修改天然对外可见。Python 这边最需要注意的是类型注解:如果本地运行且需要写 nums: List[int],记得 from typing import List,否则会报 NameError。LeetCode 平台自带该导入,不用管,但在本地写测试脚本时容易踩。
5. 边界用例、易错位置和面试官最爱的追问
代码能过示例不代表能过所有测试用例。我在刷题时会习惯性给自己列一组边界用例,这题尤其值得测以下几种情况:
| 用例 | 期望结果 | 覆盖场景 |
|---|---|---|
[] |
[] |
空数组 |
[0] |
[0] |
单元素且为 0 |
[1] |
[1] |
单元素且非零 |
[0, 0, 1] |
[1, 0, 0] |
连续多个 0 在开头 |
[1, 0, 0] |
[1, 0, 0] |
连续多个 0 在末尾 |
[0, 1, 0, 3, 12] |
[1, 3, 12, 0, 0] |
标准交错场景 |
[1, 2, 3] |
[1, 2, 3] |
没有 0 |
[0, 0, 0] |
[0, 0, 0] |
全为 0 |
这些用例看起来很简单,但它们能暴露很多实现细节问题。比如两阶段搬移清零版本,如果在第二阶段把循环范围写成 range(len(nums) - slow),那补零的次数就是错的;如果写成 range(slow + 1, len(nums)),又可能漏掉最后一个位置。最容易出问题的其实是索引区间,所以建议统一写成 for i in range(slow, len(nums)),slow 到末尾的闭开区间正好是需要补零的位置。
交换法也要注意一种极端情况:数组全为非零时,slow 和 fast 同步前进,交换操作其实发生在同一位置。加不加 if fast != slow 都能过测试,但如果你面对的是一个内存受限的嵌入式面试,能解释清楚“这里存在自交换,可以通过条件判断跳过”,会显得更有大局观。
关于复杂度证明,需要讲清楚:
- 时间复杂度:两阶段法是 O(n) + O(n) = O(2n),常数 2 在大 O 记号下仍然写作 O(n)。单次遍历交换法是严格的一趟 O(n)。两者的增长趋势一样,但如果题目极端强调“最少操作次数”,交换法在多数场景下写操作更少。
- 空间复杂度:全程只使用
slow、fast这类有限变量,没有分配与数组长度相关的存储,所以是 O(1)。
面试里还有一个高频追问方向:如果题目改一下,不要求保持原顺序,只要求把所有零放到末尾,答案能不能快?可以。那时候就可以用首尾双指针,遇到 0 就从尾部换一个非零过来,能够做到每个元素最多交换一次,而且不需要维护局部顺序。之所以 283 不推荐这种写法,就是因为稳定性是硬需求。
再往前一步,很多面试官会借这道题考察“稳定分区”这个更大的概念。你只要意识到,moveZeroes 本质上就是“把所有满足某个条件(不等于 0)的元素稳定地移动到数组前端,其他元素排在后面”,就很容易联想到 partition 的思路。这也是为什么有人会把这道题和第 27 题“移除元素”联系起来。第 27 题的解法几乎同构:
python复制class Solution:
def removeElement(self, nums: List[int], val: int) -> int:
slow = 0
for fast in range(len(nums)):
if nums[fast] != val:
nums[slow] = nums[fast]
slow += 1
return slow
区别只在于:第 27 题不需要把后面的元素补成 0,因为函数只要求返回处理后的长度,多余位置的值已经无所谓。用一道题练会这一类读指针/写指针模型,你会发现自己后续刷删除有序数组重复项、甚至面试手写快排单边循环,都会轻松很多。
还有一个真实案例分享。我看过有候选人在现场写 Python,用了这样的实现:
python复制class Solution:
def moveZeroes(self, nums):
while 0 in nums:
nums.remove(0)
nums.append(0)
这段代码在元素局部零散的短数组上能跑出正确结果,但面试官往往立刻会追问它的复杂度。while 0 in nums 本身是 O(n),remove(0) 又要在数组内部做移动,叠加起来是 O(n²) 甚至更多次数的数组迁移。候选人如果之前没有思考过数组删除操作的代价,很容易在这一步卡壳。所以我是真心建议,刷这题时不要满足于“结果能过”,要把实现的复杂度行为讲清楚。
最后分享一个小小的练习建议:找一张纸,把 [0, 1, 0, 3, 12] 用两阶段法完整走一遍,重点观察中间过程里为什么会出现重复值,再观察补零之后为什么恢复正确。走完这个过程,你对“原地修改数组到底意味着什么”的理解会比直接背诵代码深刻得多。我自己在带人刷题时发现,能把中间状态讲清楚的人,后面遇到第 27 题、第 26 题几乎不需要额外解释,因为读写指针的直觉已经完全建立了。
