LeetCode 283移动零详解:双指针与三段区间思维
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 0fast 0。步骤fast指向当前值操作数组状态slow初始00等待扫描[0, 1, 0, 3, 12]0100是0跳过[0, 1, 0, 3, 12]0211非零与slow交换[1, 0, 0, 3, 12]1320是0跳过[1, 0, 0, 3, 12]1433非零与slow交换[1, 3, 0, 0, 12]25412非零与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, 23越过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(vectorint 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时和slow0交换所有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] ! 027移除元素移除所有等于val的元素nums[fast] ! val26删除有序数组中的重复项每个元素只保留一个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换成了变量val26 把比较对象从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——因为有多少个0slow就被“落下”多少个位置。第二个体会是关于“稳定性”的。我以前做算法题总觉得“稳定”这个词很学术好像只跟排序算法相关。直到在工作中处理一次日志清洗任务需要把标记为“异常”的记录批量移到文件末尾同时保留每条记录的时间顺序才真正明白稳定性的工程意义。LeetCode 283 的“保持非零元素的相对顺序”不是一句空话它是一个在实际系统里每天都会被提起的硬性要求。第三个体会是关于“空间换时间”的权衡。这道题最优解是O(1)空间但很多新手第一反应是开一个新数组把非零元素放进去再补零。这不算“错解”只是不符合题目约束。在现实中如果数组不大开额外数组确实是可接受的但如果数据量到了百万千万级别空间成本就会被放大到不可接受。学会在读题时自动识别“原地”两个字的分量是一个工程师向资深进阶的必修课。如果你刚接触这道题我的建议是不要急着提交答案。先拿纸笔把slow和fast每一步的位置、两个指针夹出来的区间画出来画完三个例子之后你再写代码会发现代码几乎是自然流出来的。双指针题的核心从来不在手而在眼——眼睛看得见区间手才能写得对代码。