LeetCode 283这道题,我拿来当双指针开蒙题讲过很多次了。题目本身短得不能再短:给定一个数组,把所有0移动到末尾,同时保持非零元素的相对顺序,要求原地操作。难度标着Easy,但我见过不少人刷了三五年题,回头再做这道题,能写对代码,却说不出指针为什么这样走、两个指针之间夹的那段区间到底代表什么。这篇文章想把这道题彻底讲透——包括标题里那句“把数组当成三段区间”到底是什么意思,“原地”和“稳定”这两个约束又是怎么倒逼出最优解的。适合刚接触双指针的初学者,也适合想把这一个题扩成一类题的面试党。
1. 移动零到底在考什么:题干三句话全是考点
1.1 题目拆解:不只是“把0丢到后面”这么简单
完整的原题长这样:给定一个数组nums,编写一个函数,将所有0移动到数组的末尾,同时保持非零元素的相对顺序。注意题目通常还会带一句“必须在原数组上操作,不能拷贝额外数组”。
第一眼看过去,很多人会觉得这题简单得不像话——遍历一遍,遇到0就放到最后呗。但真动手写就会发现,问题的麻烦之处在于“挪动”不是一个自然操作。数组不像链表,你没法轻松地把一个元素摘出来塞到尾部,所有移动都伴随着覆盖和交换。如果你真的按“遇到0往后放”的思路去写,大概率会写出一个时间复杂度看着没问题、实际结果却错误的版本。
三句话里藏着三个考点:
- “移动到末尾”——要求最终数组形态是“非零区 + 零区”两段,非零在前面,零在后面。
- “保持非零元素的相对顺序”——这句话在计算机里有个专业说法叫稳定性。原来在前面出现的非零元素,处理后仍然在前;原来在后面出现的,处理后仍然在后。这个约束直接否定了很多“取巧”的交换策略。
- “原地操作”——不允许用额外的数组临时存储。辅助数组解法虽然好想,但空间复杂度是O(n),不满足题意。
当一个题目同时出现“原地”和“稳定”两个关键词时,双指针基本就是标准答案了。
1.2 “原地”和“稳定”在真实工程里意味着什么
我以前带过的一个实习生问过我一个很有意思的问题:力扣上这种数组题,动不动就要求原地操作,现实中谁会这么用?其实恰恰相反,现实中几乎都是这种场景。
举个最贴近的例子:你有一个按时间排序的事件数组,每条事件里有一个字段是“是否已处理”。现在要求把所有未处理的事件排到前面、已处理的沉底,同时保持时间顺序不变。你能新建一个数组再倒一遍吗?能,但如果这个数组有几千万条记录,额外开一份内存的开销就不是“几块钱”的问题了。更常见的场景是嵌入式系统或游戏引擎里的内存池整理:可用内存块要尽量靠前,碎片要集中到后面,而且内存块的顺序不能乱。这种场景对空间极其敏感,原地操作是刚需,稳定性也是刚需。
所以LeetCode 283表面上是一道教学题,实际考察的是你对“数组内部区间划分”的理解深度。理解了它,后面做快速排序的partition、做磁盘整理、做有序数组去重,都能顺着同一套思维模型推下去。
2. 三段区间思维:核心不是“移动”,而是“分区”
2.1 三区间划分:非零区、零区、待处理区
标题里说“把数组当成三段区间”,这是理解这道题最关键的一步。大多数初学者看到双指针,脑子里只有“两个指针一前一后走”的模糊画面,完全不知道两个指针夹出来的区域代表什么。而这道题恰恰是“指针之间的区域”信息量最大。
定义两个指针:慢指针slow,快指针fast。两个指针把数组逻辑上切成了三段:
- 区间
[0, slow):已经处理好的非零区。这里的元素全部非零,而且保持原始顺序。 - 区间
[slow, fast):已经处理好的零区。这里的元素全部是0,相当于被“暂时存放”在这里,等待最终被交换到尾部。 - 区间
[fast, n):还没扫描到的待处理区。
一次遍历的过程中,fast不断向右扫描,看到非零元素就往“非零区”末尾送,看到0就留在“零区”。到fast走完整个数组,待处理区消失,数组只剩前两段——“非零区”和“零区”,任务完成。
这个三区间模型,和磁盘分区的概念异曲同工。想象一个磁盘存储空间:已经占用且有用的文件放在最前面,已删除待回收的块挤在中间,还没写入的空白区域在最后。你做碎片整理时,本质上就是在不断调整三段空间的边界。
2.2 循环不变量:让指针之间的关系始终成立
“三区间划分”光嘴上说说没用,关键是它在代码的每一步迭代之后都保持成立。这就是算法设计里常说的“循环不变量”(loop invariant)。
具体到这道题,循环不变量是:
slow始终指向下一个非零元素应该放置的位置。[0, slow)内全是非零元素,且它们之间的相对顺序和原数组一致。[slow, fast)内全是0。fast扫描过的区域里,非零元素都已经通过交换被放进了[0, slow)。
为什么这个不变量重要?因为只要它成立,算法的正确性就几乎不用怀疑——每次迭代只是把一个新的非零元素“接”到非零区的尾部,同时把边界向前推一格。你不需要在每次循环里思考整个数组的状态,只需要盯住局部的一组关系。
我经常跟读者说,做题不要背代码,要背这种“状态描述”。面试官问你对算法的理解时,你把不变量说出来,比报出“时间复杂度O(n)”有说服力得多。
3. 双指针跑起来:一次遍历内完成分区的完整过程
3.1 从具体例子看两个指针的每一步分工
理论讲完了,必须落到例子上。用经典的测试用例nums = [0, 1, 0, 3, 12]来走一遍完整流程。
初始状态:slow = 0,fast = 0。
| 步骤 | fast指向 | 当前值 | 操作 | 数组状态 | slow |
|---|---|---|---|---|---|
| 初始 | 0 | 0 | 等待扫描 | [0, 1, 0, 3, 12] | 0 |
| 1 | 0 | 0 | 是0,跳过 | [0, 1, 0, 3, 12] | 0 |
| 2 | 1 | 1 | 非零,与slow交换 | [1, 0, 0, 3, 12] | 1 |
| 3 | 2 | 0 | 是0,跳过 | [1, 0, 0, 3, 12] | 1 |
| 4 | 3 | 3 | 非零,与slow交换 | [1, 3, 0, 0, 12] | 2 |
| 5 | 4 | 12 | 非零,与slow交换 | [1, 3, 12, 0, 0] | 3 |
最终结果[1, 3, 12, 0, 0],全部正确。
注意看步骤2、4、5:每次遇到非零元素,代码不是简单地把这个元素“赋值”到前面,而是和slow指向的零区第一个元素做交换。这样做的精妙之处在于,那个位置的0并不是凭空消失,它被“挤”到了原来非零元素待过的地方。因为非零元素现在要被挪到前面去,它原本占用的位置自然就成了新的零区成员——而那里恰好是零区扩展的必然方向。
如果你用覆盖赋值而不是交换,结果虽然也对,但“零区”这个概念就没这么优雅了。交换操作是让两段区间边界清晰移动的关键。
3.2 为什么非零元素的相对顺序不会乱
这是题目最核心的约束,也是很多人容易忽略的地方。
我见过一种错误的写法,思路是“一头一尾双指针”:左指针从头往右找0,右指针从尾往左找非零,找到就交换。这种写法代码短,速度也快,如果能忽略稳定性要求,它完全可行。但问题恰恰在于它破坏了稳定性。
用[1, 0, 2, 0, 3]验证一下错误的对撞交换:
- 左指针指向索引1的0,右指针指向索引4的3,交换 →
[1, 3, 2, 0, 0] - 此时非零元素的顺序从原来的
1, 2, 3变成了1, 3, 2,3越过2跑到了前面。
这个结果严格来说是违反题意的。而快慢指针的做法为什么不会乱?因为fast是从左往右依次扫描的,每次遇到非零元素时,它一定是在“所有已经在非零区的元素之后”发现的新元素。把它放到slow指向的位置,实际上就是放到非零区的尾部。“先扫到的先放,后扫到的后放”,天然就是一个按原顺序排列的过程。
稳定性的保证不是靠代码里写了什么特殊逻辑,而是靠“从左到右扫描 + 往前放”这个动作本身。
3.3 对比:为什么“覆盖版本”也能保住顺序
除了交换版本,网上还有另一种高频写法——快指针扫描,遇到非零元素就覆盖到slow位置,slow++;等fast跑完,再把slow到数组末尾全部置0。我把这种方式叫覆盖版。
交换版本和覆盖版本跑同一组数据时,结果几乎一样,但实际操作行为和适用场景有细微差别。覆盖版在最后需要单独做一轮“清零”,所以严格来说它是“先压缩再补零”,而不是“边扫描边分区”。在面试中,我更推荐交换版,因为它天然地把三区间维护住了,不需要额外考虑“补零”这一步,也不容易漏边界。
但覆盖版也不是没有价值——它更好地展示了“压缩”这个思想。如果你以后做字符串压缩、日志清理这类问题时,覆盖版的思路会更直接。
4. 代码落地:主流语言实现与一个容易被忽略的优化点
4.1 交换版与覆盖版的代码对照
先给标准交换版,Python实现:
class Solution: def moveZeroes(self, nums: List[int]) -> None: """ Do not return anything, modify nums in-place instead. """ slow = 0 for fast in range(len(nums)): if nums[fast] != 0: nums[slow], nums[fast] = nums[fast], nums[slow] slow += 1C++ 版本同理:
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 版本需要手动处理交换的中间变量:
class Solution { public void moveZeroes(int[] nums) { int slow = 0; for (int fast = 0; fast < nums.length; fast++) { if (nums[fast] != 0) { int tmp = nums[slow]; nums[slow] = nums[fast]; nums[fast] = tmp; slow++; } } } }覆盖版的 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] = 0两种实现的时间复杂度都是O(n)。但覆盖版有一点更直观:它把“整理非零区”和“填充零区”拆成两个阶段,思路更容易向面试官讲清楚。交换版则更简洁,不需要二次遍历。
4.2 优化细节:当 fast 等于 slow 时没必要交换
在交换版里有一个很容易忽略的性能点:如果nums[fast]是非零,且fast == slow,说明这个元素本来就已经在非零区的末尾了,交换自己和自己纯属白费操作。
比如数组前面几个元素全是非零:[1, 2, 3, 0, 0]。扫描前三个元素时,fast和slow是同步走的,每次都执行交换,但交换的两个位置其实是同一个位置。这在数据量小的时候无所谓,但如果你在嵌入式环境或者性能敏感场景里,避免无效写操作是有意义的。
优化版只是在交换前加了一个判断:
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这样写之后,数组前段全是非零数据时,算法只做了n次遍历,一次交换都没有。极端情况下(全非零数组),时间复杂度仍为O(n),但实际运行时间明显更短。
4.3 复杂度与方案对比
把几种常见解法的复杂度摊开来看,能更清楚地理解为什么双指针是这道题的“最优解”:
| 解法 | 时间复杂度 | 空间复杂度 | 稳定性 | 备注 |
|---|---|---|---|---|
| 辅助数组 | O(n) | O(n) | 稳定 | 不符合题目原地要求 |
| 双指针交换 | O(n) | O(1) | 稳定 | 推荐,边扫描边分区 |
| 双指针覆盖+补零 | O(n) | O(1) | 稳定 | 直观,但分两个阶段 |
| 一头一尾对撞交换 | O(n) | O(1) | 不稳定 | 不满足题意 |
5. 常见的翻车现场与边界测试:代码之外的硬功夫
5.1 面试追问:能不能打乱顺序?不能,除非改题
很多读者在我文章下面留言,说自己面试这道题时“明明写对了,面试官还追问”,然后一脸委屈。追问通常集中在两个方向上。
第一个追问是“为什么不能用一头一尾交换法”。这个问题本质是考稳定性的理解。如果你直接说“因为交换会导致非零顺序变化”,面试官会觉得你理解了本质;如果你只会背代码,到这里就容易卡壳。
第二个追问是“如果题目允许打乱非零元素的顺序,你会怎么改”。这时候答案就变了——直接用对撞交换法,一个while循环搞定,时间复杂度同样是O(n)但写起来更短。能在一道题里分辨两种不同约束下的最优解,是面试官很看重的灵活度。
5.2 边界测试:空数组、全零、零在头尾都要过
我刷题有个习惯:无论题目多简单,至少想四个边界用例再提交。这道题的边界用例集中在以下四种:
- 空数组
[]:循环体一次都不执行,直接返回,天然正确。 - 全零数组
[0, 0, 0, 0]:fast扫完所有元素,if条件一次都不触发,slow始终为0,数组不变,结果正确。 - 全非零数组
[1, 2, 3, 4]:每个fast都命中非零条件,但带优化判断时fast == slow,一次交换也不发生。不带优化判断时会做4次自我交换,结果依然正确。 - 零在头部
[0, 0, 1, 2]:前面的0全部跳过,遇到第一个非零1时和slow=0交换,所有0被稳定推后,结果是[1, 2, 0, 0],正确。 - 零在尾部
[1, 2, 0, 0]:尾部0本来就不影响结果,扫描到0时跳过,到末尾结束,结果不变,正确。
这四类用例能覆盖绝大多数边界条件。建议刷题时把这组用例放在一套测试脚本里,每次写完直接跑。
5.3 语言层面的细节坑
这个部分提两个我实际见过的问题。
第一个是 Java 的交换陷阱:Java 对基本类型数组没有内置的 swap 函数,如果你直接写nums[slow] = nums[fast]; nums[fast] = nums[slow];,第二个赋值会把同一个值再写回去,等于没交换。必须引入临时变量。
第二个是 Python 的并行赋值问题:nums[slow], nums[fast] = nums[fast], nums[slow]这种写法虽然方便,但要注意等号两边的求值顺序。Python 会先计算出右边的两个值,再分别赋值给左边,所以在同一行完成交换是正确的,不用担心覆盖问题。C++ 用swap也没有坑。唯一麻烦的是 C 语言,没有内置 swap,需要自己写宏或函数。
6. 一题打通一串:283其实是“双指针家族”的原型题
6.1 双指针家族:283和27、26、80的关联
很多人刷题是孤立地刷,做完283就去做下一个题,完全没意识到283是一个“母题”。其实力扣上有四道题,框架几乎完全一致,只是快指针的判断条件换了一下:
| 题号 | 题目 | 核心需求 | 快指针判断 |
|---|---|---|---|
| 283 | 移动零 | 非零在前,0在后 | nums[fast] != 0 |
| 27 | 移除元素 | 移除所有等于val的元素 | nums[fast] != val |
| 26 | 删除有序数组中的重复项 | 每个元素只保留一个 | nums[fast] != nums[slow - 1] |
| 80 | 删除有序数组中的重复项 II | 每个元素最多保留两个 | nums[fast] != nums[slow - 2] |
以 27 题“移除元素”为例,题目要求把数组中所有等于val的元素移除,保持其他元素的相对顺序。判断条件从nums[fast] != 0换成nums[fast] != val,代码其余部分几乎一字不改:
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再看 26 题“删除有序数组中的重复项”。数组是有序的,重复元素一定是连续出现的。快指针看到一个新元素时,只需要判断它和“已经整理好的区间”最后一个元素是否相同——不同就收进来,相同就跳过:
class Solution: def removeDuplicates(self, nums: List[int]) -> int: slow = 0 for fast in range(len(nums)): if fast == 0 or nums[fast] != nums[slow - 1]: nums[slow] = nums[fast] slow += 1 return slow如果你把 283 和 27、26 连起来刷,会发现这个过程像在“升级打怪”:283 是最简单的不等条件,27 把固定的0换成了变量val,26 把比较对象从val换成了“前一个已保留元素”。差距全在if条件里,指针框架一点没变。
80题稍微复杂一点,要求每个元素最多保留两个。快慢指针的思路仍然是一致的,只是判断条件升级为nums[fast] != nums[slow - 2]。之所以这样判断,是因为slow-2是“已保留区间的倒数第二个位置”,如果快指针的值和它相等,说明当前元素已经是第三次出现了,必须跳过。
从283一路做到80,你会彻底理解这类题的套路:慢指针是写入指针,快指针是扫描指针,if条件决定“什么值得写入”。这个框架的普适性远超你的想象。
6.2 从283到快速排序partition:一个思想的跳板
再往深一层看,283的三区间划分思想和快速排序的核心 partition 操作是同源的。
快排的 partition 通常会选择数组里某个元素作为基准(pivot),然后把数组划分成两段:小于基准的放左边,大于等于基准的放右边。实现时也正是用了一个逐步推进的指针,把不仅满足条件的元素逐个“换”到左段末尾。经典的双向 partition 写法有各种变体,但核心思路仍然是“两个指针 + 区间维护”。
一旦你从283中理解了“slow和fast夹出的中间段是什么”,再去看快排的 partition 代码,就不会觉得它神秘了。很多初学者觉得 partition 难,难的不是代码,而是脑子里没有区间模型。283恰好提供了最小巧的区间模型训练机会。
这就是为什么我一直建议初学者把283当成“母题”反复揣摩——它不是让你背答案,而是让你建立一种看待数组的视角:数组不是一个线性序列,而是一段可以在指针移动中被动态切分的空间。这个视角建立起来之后,再做任何与数组划分、快速排序、有序合并相关的题目,都会有质的提升。
7. 再聊点实操体会:关于这题我自己的几个“顿悟”时刻
写下这篇文章的时候,我又把这题从头做了一遍。做了这么多遍,依然能发现新的东西,这是经典题的价值所在。
第一个体会是“慢指针不慢,快指针不赶”。我第一次学双指针时,总以为两个指针是一前一后你追我赶,一个负责找,一个负责等。后来才意识到,在这个模型里slow和fast都在前进,只是前进的节奏不同。fast每轮都走,slow只在遇到非零时走一步。它们之间的距离,恰好就是“已扫描区域里的零的数量”。这个观察非常直观地解释了为什么循环结束时数组后面一定全是0——因为有多少个0,slow就被“落下”多少个位置。
第二个体会是关于“稳定性”的。我以前做算法题,总觉得“稳定”这个词很学术,好像只跟排序算法相关。直到在工作中处理一次日志清洗任务,需要把标记为“异常”的记录批量移到文件末尾,同时保留每条记录的时间顺序,才真正明白稳定性的工程意义。LeetCode 283 的“保持非零元素的相对顺序”不是一句空话,它是一个在实际系统里每天都会被提起的硬性要求。
第三个体会是关于“空间换时间”的权衡。这道题最优解是O(1)空间,但很多新手第一反应是开一个新数组,把非零元素放进去,再补零。这不算“错解”,只是不符合题目约束。在现实中,如果数组不大,开额外数组确实是可接受的;但如果数据量到了百万千万级别,空间成本就会被放大到不可接受。学会在读题时自动识别“原地”两个字的分量,是一个工程师向资深进阶的必修课。
如果你刚接触这道题,我的建议是:不要急着提交答案。先拿纸笔把slow和fast每一步的位置、两个指针夹出来的区间画出来,画完三个例子之后,你再写代码,会发现代码几乎是自然流出来的。双指针题的核心从来不在手,而在眼——眼睛看得见区间,手才能写得对代码。