☰
Leetcode 283移动零:双指针原地算法,数组分区问题的最优解
2026/10/16 3:57:08 网站建设 项目流程

Leetcode 283『移动零』是我在算法入门阶段反复写过好几遍的一道题。先说结论:如果你刚开始刷题,这道题值得放进前二十题;如果你准备面试,这道题是数组双指针的经典敲门砖。它要解决的问题一句话就能说清——把一个数组里所有的 0 挪到末尾,同时保持非零数字原来的先后顺序。真正难的不是看懂题,而是能在不借助额外数组的前提下把这件事做干净,并且跟面试官把每一步为什么这样写讲明白。

我见过不少人把这道题背下来了,代码写得飞快,但被问到“slow 和 fast 各自代表什么”“为什么最后补零从 slow 开始”就卡住了。这篇笔记想把题目拆开揉碎,从最朴素的思路逐步演进出最优解,再把同类题串起来。不管你是刚接触算法的初学者,还是准备面试想补齐短板的人,都应该能从中拿到点东西。

1. 题目到底在考什么

1.1 把题面翻译成人话

原题描述很简洁:给定一个数组 nums,请把所有 0 移动到数组末尾,同时保持非零元素的相对顺序。

举个例子:

输入: [0, 1, 0, 3, 12] 输出: [1, 3, 12, 0, 0]

很多初学者看到这个输出会觉得是“排序把 0 放后面”,其实不对,因为非零元素之间的顺序完全没变:1 还是在 3 前面,3 还是在 12 前面。所以准确地说,这是一个“稳定分区”问题,按是否为 0 分成两类,并且让原顺序稳定保留。

题目描述里还有两个补充条件:必须在原数组上操作,不能拷贝额外的数组;尽量减少操作次数。这直接封死了最偷懒的写法,也决定了我们后面所有思路的走向。

1.2 隐含的三条硬性要求

我把这道题的隐含要求拆成三条,每条都会影响解法选择。

第一,原地修改。意思是不能新建一个数组,把非零元素装进去再把零补在后面,然后把新数组返回。你只能操作传入的那个数组对象本身。这一点在很多语言的函数传参里都有坑,Python 尤其容易踩,后面我会专门讲。

第二,保持相对顺序。这就是“稳定”的含义。有些新手会用排序来做,觉得“把 0 排到最后”就是答案,但排序会破坏原本非零元素之间的相对顺序,而且排序复杂度至少是 O(n log n),完全不是这题想要的东西。退一步说,即便你用稳定排序,也只是绕路,不是正解。

第三,尽量少的操作次数。最理想的状态是遍历一遍数组就完成全部工作。这意味着算法要尽量做到每个元素只处理有限次,整体复杂度控制在 O(n)。这也是面试官通常期待的标准解。

1.3 为什么这道题在面试里被反复拿出来

这道题难度不高,但出现的频率一直很高。原因是它把几个基本功揉在了一起:数组下标的运用、指针或者说索引的设计、原地算法的空间意识。

真实场景里这种需求也不少见。比如一份用户行为日志,事件类型为 0 表示无效事件,需要把有效事件全部挪到数组前段,同时保留事件的先后时间顺序;再比如一批任务列表,需要把已取消任务沉到末尾,但不能改变正常任务之间的优先级顺序。只要你写过后端批处理,大概率遇到过类似的分区需求。

所以这道题看起来是刷题题,实际上是一道披着算法外衣的工程题。它考察的不仅仅是你会不会写 for 循环,而是你有没有“在有限空间里通过调整索引完成任务”的直觉。

2. 新手最容易先想到的解法,为什么我会劝你别急着写

2.1 新建数组的做法,思路对但不合格

我刚刷这道题时的第一反应特别简单:把非零元素挑出来,记录有多少个 0,然后组装一个新数组。

def move_zeroes(nums): result = [x for x in nums if x != 0] result += [0] * (nums.count(0)) nums[:] = result

这个解法是正确的,跑示例也能过,但完全不达标题的硬性要求:它用了额外空间,而且 nums.count(0) 又额外遍历了一遍数组。假如输入有 10 万个元素,代码会在内存里多出一份近乎完整的数组,数据量翻倍时开销也翻倍。

如果题目没要求原地,这个解法完全能用;可面试场景下,面试官几乎必然追问一句“能不能不用额外空间”。所以它只能作为理解题意的第一步,不能作为最终答案。

2.2 从 O(n) 空间到 O(1) 空间,差别在哪

从 O(n) 空间降到 O(1) 空间,听起来只是复杂度标识变了,实际带来的约束差异非常大。

O(1) 空间意味着除了若干个变量,你不能和输入规模挂钩地申请内存。数组元素个数越多,你越不能复制一份同样大小的数据。这在真实系统里很关键:内存是有限资源,尤其在嵌入式、游戏客户端、高并发服务端里,一次多余拷贝可能直接影响延迟和吞吐。

这里要特别提醒 Python 初学者一个坑:函数内部写nums = result和写nums[:] = result效果完全不同。nums = result只是让函数内部的局部变量指向了新的列表,函数外部的原数组没有任何变化;nums[:] = result才是把原列表对象的内容整体替换成 result 的内容。如果你在本地测试时直接在全局作用域写,可能看不出区别,一旦封装成函数就出问题。

2.3 决定用双指针前,先想清楚这几件事

在我自己把解法最终优化到双指针之前,我先问了自己几个问题:

第一,0 到底需不需要被逐个搬运到末尾?其实不需要。我们只要把非零元素全部前移,剩下的位置统一填 0 就可以了。0 本身没有“顺序”需要保持,所以它们内部怎么排列都无所谓。

第二,能不能让每个元素只被读取一次?如果能做到,那就已经是 O(n)。能不能每个非零元素只被写入一次?如果能做到,那操作次数就非常少。

第三,用什么手段记录“下一个非零元素该放的位置”?这个位置会随着扫描不断推进,天然适合用一个变量来维护。

想清楚这三点,双指针的思路就不难自己推导出来:一个指针负责向前扫描、寻找非零元素,另一个指针负责标记写入位置。

3. 双指针覆盖法:从最朴素的实现开始

3.1 双指针的灵魂:快慢指针各管一摊

双指针在很多数组题里是万能钥匙,但理解它不能只记住模板,要记住两个指针各自的职责。

在这道题里,我习惯把两个指针叫slow和fast。

  • slow:下一个非零元素应该放置的位置,也是当前前段有效区的右边界。
  • fast:当前正在扫描的数组位置,负责一路往右寻找非零元素。

打个比方:你在整理一排书架,fast是你正在检查的书,slow是接下来要把有效书放过去的空格。每遇到一本不该淘汰的书,你就把它放到slow指向的空位,然后slow往后挪一格;遇到 0 就直接跳过,继续往后检查。

3.2 核心代码逐行分析

这是覆盖法最朴素的 Python 实现:

def move_zeroes(nums): 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

逐行看。

slow = 0表示当前没有任何非零元素被安置,所以第一个非零元素应该放到下标 0。

for fast in range(len(nums))让快指针从 0 开始扫描到数组末尾。每一次循环,fast就是“当前读取的位置”。

进入循环后,如果nums[fast] == 0,说明当前元素是应该被沉底的零,直接跳过,不做任何操作。slow不动,后面遇到非零元素时仍然可以写到这个空位。

如果nums[fast] != 0,就执行nums[slow] = nums[fast],把非零元素往前搬运到slow指向的位置。然后slow += 1,表示“下一个空位”已经向右移动。

第一个循环结束时,所有非零元素都已经按原顺序放到了数组前段,slow恰好等于非零元素的个数。接下来第二个循环从slow开始到数组末尾,全部填 0。

这里的核心思想是:先做“非零前移”,再做“末尾补零”。零元素不需要真的和谁交换位置,最后统一覆盖即可。

3.3 复杂度为什么是 O(n) 和 O(1)

第一个循环最多执行 n 次,第二个循环最多执行 n 次,合起来最多 2n 次操作。常数 2 不影响复杂度级别,所以时间复杂度是 O(n)。

空间上,除了slow和fast两个整数变量,没有额外申请和数组规模有关的存储,所以空间复杂度是 O(1)。

对比一下新建数组法:那个做法除了最终结果数组外,还涉及 Python 内部列表推导的临时结构,空间是 O(n)。在数据规模很大时,这两种做法对内存的压力完全不同。

3.4 覆盖法的坑:什么时候需要补零

覆盖法最容易被忽略的一点是:第二个循环到底应该从哪个下标开始补零。

很多人写成for i in range(fast, len(nums)),这是一个典型错误。因为fast在循环结束时指向的是数组最后一个位置,并不代表“有效区的右边界”。真正代表有效区右边界的是slow,只有它知道有多少个非零元素被放到了前面。

什么时候第二个循环实际上什么都不用做?答案是原数组里没有 0 的时候。比如输入[1, 2, 3],第一个循环会把每个元素写到自己位置上,slow走到 3,range(slow, len(nums))是空区间,补零操作自然跳过。

换句话说,补零的区间长度正好等于 0 的个数。slow到数组末尾的距离,就是数组中 0 的数量,所以从slow开始补零一定是准确的。

4. 扩展与进化:把“覆盖”升级成“交换”的经典实现

4.1 为什么最终代码可以短到三五行

覆盖法的思路很清晰,但代码里有两个循环。还有一种写法更简洁,用一次遍历加交换完成,很多热门题解就是这种:

def move_zeroes(nums): j = 0 for i in range(len(nums)): if nums[i] != 0: nums[j], nums[i] = nums[i], nums[j] j += 1

核心是:j维护“第一个 0 的位置”,i负责向后扫描。遇到非零元素,就把它和j指向的 0 交换,然后j前进一位。

这样做的好处是省掉了最后补零的循环。交换本身会把 0 自动带到后面,不需要再单独写第二个循环。代码短一截,逻辑也更紧凑。

我第一次看到这个写法的时候,第一反应是“这么简单还能对吗”。后来在草稿纸上推演了几轮才彻底放心,它背后其实和覆盖法是同一套思想,只是把“先前移后补零”变成了“边扫描边交换”。

4.2 相对顺序是怎么保住的

为什么交换不会打乱非零元素的顺序?因为i是严格从左往右扫描的,每次遇到一个新的非零元素,它总是被放到j当前指向的位置。而j的位置只会被非零元素占据,且每写一个就右移一格,所以非零元素的写入顺序和扫描顺序完全一致,前面出现的非零元素永远不可能被后面出现的非零元素越过。

你可以这样理解:这个交换不是随意的交换,它本质上是把“遇到的非零元素”逐个放到前段连续空位上,和覆盖法一模一样。只不过覆盖法把被覆盖位置的旧值先忽略了,最后补零;交换法则把旧值(通常是 0)换到后面去,顺手完成了填零。

这里要留意一个特殊情况:当数组里没有 0 时,i和j同步移动,每次交换都是元素和它自己交换,数组最终不变。所以这个写法天然兼容“无 0”和“全 0”的边界情况。

4.3 几个边界用例的手推过程

我用示例数组手推一遍,你就更能感受到指针是怎么走的。

输入[0, 1, 0, 3, 12],初始j = 0。

  • i = 0,nums[0]是 0,跳过,j仍是 0。
  • i = 1,nums[1]是 1,交换nums[0]和nums[1],数组变成[1, 0, 0, 3, 12],j = 1。
  • i = 2,nums[2]是 0,跳过。
  • i = 3,nums[3]是 3,交换nums[1]和nums[3],数组变成[1, 3, 0, 0, 12],j = 2。
  • i = 4,nums[4]是 12,交换nums[2]和nums[4],数组变成[1, 3, 12, 0, 0],j = 3。

再看一个边上例子[0, 0, 1],初始j = 0。

  • i = 0,跳过。
  • i = 1,跳过。
  • i = 2,遇到 1,交换nums[0]和nums[2],数组变成[1, 0, 0]。

一次交换就完成了任务。

再看无零情况[1, 2, 3],i每次都与j相等,和自己交换,数组从头到尾不变。这说明交换法的额外操作在无零时只是一些无效自交换,但不影响结果,也不会报错。

5. 同类题目串讲:这套思路还能打哪些题

5.1 Leetcode 27 移除元素,是同一个骨架

做完移动零以后,我建议你马上去看“移除元素”这题。题目描述是:给定数组nums和目标值val,原地移除所有数值等于val的元素,返回移除后数组的新长度。

最标准的解法长这样:

def remove_element(nums, val): slow = 0 for fast in range(len(nums)): if nums[fast] != val: nums[slow] = nums[fast] slow += 1 return slow

和移动零的覆盖法几乎逐字相同。区别只有两点:判断条件从!= 0变成了!= val;不需要补零,因为题目只要求返回新长度,对数组末尾的值没有要求。

很多人会这样记:这道题背后是同一个“筛选保留元素并前移”的模板。你要保留什么,就把保留条件写在if里,然后用slow维护写入位置,fast负责全量扫描。

5.2 Leetcode 26 删除有序数组中的重复项,也是同一个骨架

再往深走一步,看“删除有序数组中的重复项”这题:给定一个有序数组,原地删除重复出现的元素,使每个元素只出现一次,返回新长度。

解法还是快慢指针:

def remove_duplicates(nums): 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

这里的变化是:保留条件从“不等于某个值”变成了“不等于前一个被保留的值”。因为数组有序,重复元素必然相邻,所以每次只需要跟nums[slow - 1]比较,就能判断当前元素是不是一个新的、应该保留的值。

这三道题放在一起看,你会发现核心模板是一致的:一个指针向后扫描,一个指针维护写入位置。变的是保留条件。移动零保留非零,移除元素保留非 val,去重保留首次出现的新值。

5.3 再扩大一个视野:同向双指针和对撞双指针是两回事

经常看到有人把双指针所有题目混在一起记,这是新手比较容易绕晕的地方。其实双指针至少可以分为两大类。

一类是同向双指针,也就是这一篇讲的两个指针都从左往右移动,比如移动零、移除元素、去重。这类问题通常要求保持元素间的原始相对顺序,或者处理单调有序数组的扫描。

另一类是对撞双指针,两个指针一个从左边开始往右走,一个从右边开始往左走,比如有序数组的 Two Sum、盛最多水的容器。这类问题通常依赖数组的某种有序性,在两端向中间逼近答案。

我自己的体会是:看到“保持相对顺序”或者“原地筛选”这几个字,优先想同向双指针;看到“有序数组”并且要求找满足条件的两个元素,优先想对撞指针。把这两大类分开记忆,比单独背某道题的题解要高效得多。

6. 常见错误与调试实录

6.1 我见过的高频错误

这道题代码量少,但出错率并不低。我帮人 review 代码时,反复看到下面几类问题。

第一,补零起点写错。覆盖法第二个循环写成for i in range(fast, len(nums)),结果把还没扫描完的后续元素给清掉了。记住:slow才是有效区边界,fast只是最后停在了数组的最后一个位置,它的值不能代表非零元素个数。

第二,交换法的j更新逻辑混乱。比如在nums[i] != 0时只写了j += 1,忘记交换,导致非零值被丢弃。正确的记录是:只要发现非零,就执行交换,然后j += 1;遇到零时什么都不做。

第三,Python 特有的传参问题。函数内部写了nums = [x for x in nums if x != 0] + [0] * (nums.count(0)),看起来是在修改,其实只是改了函数局部变量。外部数组纹丝不动,测试永远失败。处理办法是用切片赋值nums[:] = ...,或者走真正的双指针原地修改。

6.2 必跑的测试用例清单

我很喜欢在写完数组题后跑一遍边界用例,比直接提交省时间。这道题我建议至少跑这些:

[] -> [] [0] -> [0] [1] -> [1] [0, 0, 0] -> [0, 0, 0] [1, 2, 3] -> [1, 2, 3] [0, 1, 0, 3, 12] -> [1, 3, 12, 0, 0] [1, 0, 2, 0, 3, 0] -> [1, 2, 3, 0, 0, 0] [3, 0, 2, 0, 1] -> [3, 2, 1, 0, 0]

其中空数组、单元素、全零、无零这四类最容易暴露边界问题。我实际调试时通常会写一个小脚本,把上面的用例存成列表,然后用assert批量断言,这样每次改完都能秒级确认没有引入新问题。

6.3 写这道题时的几个好习惯

写双指针题,我现在养成了几个固定习惯,对刷其他题也适用。

第一,写代码前先在注释里写清指针含义。哪怕只是两句,比如“slow 表示下一个写入位置;fast 表示扫描位置”。不要觉得这多余,它能帮你避免写着写着就把变量含义搞混。

第二,命名用slow和fast,尽量不要用i和j。i和j太抽象,双方交流时还要额外解释;slow和fast一看就知道谁是写指针、谁是读指针。

第三,写完后口头给自己讲一遍边界情况。我会指着代码说“如果没有 0,slow 会跟 fast 同步走到末尾,补零区间为空;如果全是 0,slow 一直停在 0,最后整个数组原地补零”。能讲清楚这两句话,这道题才算真的懂了。

第四,从覆盖法理解,但提交前用交换法再巩固一遍。两段代码花不了几分钟,但能让你对“先前移后补零”和“边扫描边交换”两种实现都有手感。面试时你甚至可以说出两种方案,并解释为什么交换法代码更短。

这道题我刷了很多遍,每次重刷都有一点新的收获。如果只能留下一句话,我会说:写双指针之前,先在注释里写清楚每个指针的意义,代码只是把注释翻译成语法而已。后面你还会遇到 Leetcode 27、26、80 这些同源题目,都可以回到这个骨架来对照。希望这篇笔记能帮你把移动零彻底吃透,也把双指针的思维基础打牢固。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询