☰
LeetCode 283 移动零:双指针法彻底拆解与分区思维迁移
2026/10/16 3:57:08 网站建设 项目流程

LeetCode 283这道「移动零」,应该是很多人刷数组题时遇到的第一道双指针。题目描述很简短:给定一个数组,把所有的0移动到末尾,同时保持非零元素的相对顺序,要求在原数组上操作。就是这么一道Easy难度的题,面试手撕环节出现的频率却高得离谱,而且翻车率远超想象。这篇文章不打算只讲一个标准答案,而是把这道题彻底拆开:为什么双指针是标准解、两种双指针写法有什么区别、边界情况怎么验证、以及它背后那个可以迁移到排序和分区问题上的思想模型。适合刚开始准备算法面试的入门选手,也适合想把基础题吃透的进阶玩家。

1. 题目解析与核心思路

1.1 这道题到底在考什么

先复述一下题目本身:给你一个数组nums,编写一个函数将所有0移动到数组的末尾,同时保持非零元素的相对顺序。示例是[0,1,0,3,12],处理后变成[1,3,12,0,0]。

读题要抓住三个关键约束。第一,必须是原地操作,这意味着你不能新建一个数组然后把非零元素放进去、最后再接上一串0。第二,非零元素的相对顺序不能变,这一点把很多看起来取巧的方案直接毙掉了。第三,数组里的元素不一定是正整数,也可能是负数,关键判断标准是“等不等于0”,而不是“大于0”。

我经常看到有人把这道题理解成“把非零元素挑出来放前面”,这个理解本身没问题,但容易忽略一个隐含条件:题目并没有要求0之间保持相对顺序,只要求非零元素的相对顺序。虽然常规解法往往会顺带保持0的相对顺序,但你在写代码时不能基于“0也必须保持顺序”这个假设去设计逻辑,否则思路容易绕进去。

这道题的核心考察点有两个层面。表层是双指针技巧的运用,慢指针记录“下一个非零元素该放的位置”,快指针负责扫描全数组。深层则是对“数组原地修改”这类问题的结构化理解:你把数组看成一段连续的存储空间,所有操作都只能在上面交换、覆盖,不能依赖额外存储,这本身就是对思维习惯的训练。

1.2 为什么双指针是唯一合理的路线

我第一次做这道题的时候,第一反应是:直接遍历,遇到0就删掉,然后在数组末尾append一个0,这不是很简单吗?在Python里确实写起来很爽,几行就完事。但稍微分析一下就会发现,Python列表的pop和insert操作在中间位置都是O(n)的,而你在遍历过程中可能遇到很多个0,最坏情况下就是O(n²)。这个复杂度在面试里没法交代。

还有一个常见思路是:先把数组排序,然后……排序能做吗?可以做,但是有一个致命问题:排序会打乱非零元素之间的相对顺序,这直接违反了题目的硬性约束。除非你用一个稳定排序,但稳定排序的复杂度至少是O(n log n),远远不如线性解法。

顺着约束条件往下推,你会发现双指针几乎是必然的选择。既然不能开额外数组、又要保持顺序、又要线性时间,那唯一的信息通路就是在原数组上用两个指针维护两个区间:指针左边的区间是已经处理好的非零区域,指针右边到快指针之间是待处理的区域。这个“区间维护”的思想,和快排的partition过程、滑动窗口的维护过程,本质上是同一套思维模型。这也是为什么这道题虽然简单,却经常被拿来当成考察候选人基本功的试金石。

2. 双指针解法一步步推演

2.1 方法一:先挪非零元素,再统一补零

第一种写法也是最容易理解的写法,思路可以拆成两个阶段。

第一阶段,用快指针fast遍历整个数组,用慢指针slow指向“下一个非零元素应该放置的位置”。每次遇到非零元素,就把它写到nums[slow],然后slow前进一位。第二阶段,从slow到数组末尾的位置,全部填入0。

def move_zeroes(nums): slow = 0 for fast in range(len(nums)): if nums[fast] != 0: nums[slow] = nums[fast] slow += 1 while slow < len(nums): nums[slow] = 0 slow += 1

走一遍示例[0, 1, 0, 3, 12]:

  • 初始slow = 0,fast = 0,元素为0,跳过。
  • fast = 1,元素为1,非零,nums[0] = 1,slow变成1。
  • fast = 2,元素为0,跳过。
  • fast = 3,元素为3,非零,nums[1] = 3,slow变成2。
  • fast = 4,元素为12,非零,nums[2] = 12,slow变成3。
  • 遍历结束,从slow = 3开始补0,得到[1, 3, 12, 0, 0]。

这个方法的关键点在于:第二阶段补0是必须的。因为第一阶段的覆盖操作只是把非零元素挪到了前面,但数组末尾那些位置上的旧值还留在原地,你不清掉,就残留着之前的值。很多新手第一次写这题,只写了第一个循环就提交,结果发现原来的数据没被完全覆盖,输出完全不对。

2.2 方法二:原地交换,过程中直接消除零

第二种写法和第一种思路不同:遍历过程中直接把零交换到后面,不需要最后再补一轮0。这里的关键是控制交换时机。

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 = 0,fast = 0,元素为0,跳过。
  • fast = 1,元素为1,非零,交换nums[0]和nums[1],数组变成[1, 0, 0, 3, 12],slow变成1。
  • fast = 2,元素为0,跳过。
  • fast = 3,元素为3,非零,交换nums[1]和nums[3],数组变成[1, 3, 0, 0, 12],slow变成2。
  • fast = 4,元素为12,非零,交换nums[2]和nums[4],数组变成[1, 3, 12, 0, 0],slow变成3。

结束。这个写法的精妙之处在于:slow指针在每一步之后指向的,永远是当前区间内第一个0的位置。遇到非零元素,就拿它去和那个0交换,这样既把非零元素挪到了正确的位置,又把0顺势推到了后面。整个过程一气呵成,不需要补零阶段。

2.3 两种写法的取舍与细节优化

第一种写法的优点在于逻辑最简单,两段式的处理非常直白,适合在面试一开始快速说清思路。缺点是多了第二遍遍历,虽然总体仍是O(n),但常数稍大,而且第一阶段的覆盖操作会临时破坏数组的部分内容,如果面试官要求你“每一步都不能丢失信息”,那么交换法更容易自圆其说。

第二种写法的优点是不需要补零阶段,一次遍历就完成,而且每一步的状态都满足“非零元素已经在正确区间里”这个不变式,语义更清晰。缺点是理解门槛稍高,需要想清楚slow指针指向的到底是什么、什么时候交换是安全的。

我自己在实际写代码时,更推荐第二种写法作最终提交版本。但面试建议两个都掌握,先讲思想最简单的第一个版本,再顺势提一句“还可以用交换法把补零阶段优化掉”,这样能展示你对复杂度细节有意识。

再补充一个优化细节:交换法里,当slow == fast时,交换是和自己交换,属于无意义操作。数组元素多的时候影响不大,但可以加一个判断来避免,也有利于表达“这两个指针重合时不需要动作”这个语义。

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

加了slow != fast的判断之后,遇到非零元素且两个指针重合时,只做一次自增,性能上对极端场景(比如全是非零元素的数组)有一点微小的提升。

3. 复杂度分析与边界情况

3.1 时间复杂度与空间复杂度怎么算

两种解法的时间复杂度都是O(n)。推导过程很简单:第一个方法里,第一个循环遍历了n个元素,第二个循环从slow开始填充0,最坏情况下是n次,所以总操作次数不超过2n,系数是常数,时间复杂度仍然是O(n)。第二个方法只有一个循环,遍历n个元素,每个元素最多被交换一次,同样是O(n)。

空间复杂度是O(1),因为两种解法都是在原数组上操作,额外变量只有两个索引指针,不随输入数组规模增长而增长。这是面试官最看重的点:原地修改、常数空间,意味着这个方法在最严格的内存约束下依然可行。

这里我多说一句关于复杂度分析的理解方式。很多初学者喜欢背“双指针是O(n)”这个结论,却不理解为什么。关键是弄清楚“每个元素被访问多少次”。在交换法里,快指针每个元素访问一次,慢指针偶尔访问一下,但慢指针的移动次数不会超过n,所以总和是每个元素常数级别的访问。只要你把“总操作次数”和“输入规模”之间的关系想清楚,复杂度的结论就不是背出来的,而是推导出来的。

3.2 必须过一遍的边界测试用例

写代码时觉得没问题,一提交就报错,多半是边界情况没有覆盖到。移动零这道题的边界情况值得单独列一张检查清单。

场景输入示例期望输出关键观察点
空数组[][]循环不会执行,不应报错
单元素非零[5][5]单个元素不需要移动
单元素零[0][0]0原地不动
全部是零[0, 0, 0][0, 0, 0]无操作
全部非零[1, 2, 3][1, 2, 3]不动
零在末尾[1, 2, 0][1, 2, 0]已经满足条件
零在开头[0, 1, 2][1, 2, 0]需要整体前移
负数和零混排[-1, 0, -2, 0][-1, -2, 0, 0]负数也是非零,要保留
连续多个零[0, 0, 1][1, 0, 0]多个零要连续推到最后

手动过一遍这些用例能发现很多实现问题。比如在覆盖法里,如果第二阶段的补0循环写成for i in range(slow + 1, len(nums))或者range(slow, len(nums) - 1),就会在边界上差一位。所以每次写完代码,建议先用这几个用例在脑子里模拟一遍,不要急着提交。

特别是在负数的处理上,很多人因为题目名是“移动零”,想当然地以为判断条件是“大于0就前移”,这就把问题做成了“把正数前移”,结果负数留在了后面,完全偏离题意。判断条件只有一个:nums[fast] != 0。

4. 常见错误与排查技巧

4.1 新手最容易踩的三个坑

第一个坑是试图用删除再追加的方式解决。Python写起来很诱人:

def move_zeroes(nums): for num in nums: if num == 0: nums.remove(0) nums.append(0)

这段代码的问题很多。首先remove本身是O(n)的,在遍历里嵌套一个O(n)的删除操作,总复杂度是O(n²)。其次,遍历过程中修改列表长度会造成索引错位,结果很容易出现元素被跳过或者越界。这种写法在本地小数组上跑跑好像没问题,一提交大数据集就超时。

第二个坑是交换逻辑写反。有的朋友记住了交换,但写成了“遇到0就往后交换”,结果就是把非零元素和下一个元素反复交换,反倒打乱了顺序。核心规则是:把非零元素往slow指向的位置交换,而不是把0往后面随便抛。要在每一步都问自己一个问题:slow现在指向的位置,是不是当前已经处理好的非零区间的下一个空位?

第三个坑是处理完非零元素后忘了处理末尾残留的旧值。覆盖法最容易犯,把前面的元素挪完之后,以为大功告成,结果输出数组最后还带着原来的旧值。比如[2, 1, 0]经过第一阶段变成[2, 1, 0],看起来没变化,但你不补0的话,[2, 1, 0]本身就是正确答案,这掩盖了问题。换一个例子[0, 2, 1]第一阶段变成[2, 1, 1],末尾的旧值1还残留着,不补0就错了。

4.2 快速定位问题的排查思路

如果写的解法提交报错,我建议按照下面这个顺序排查。

第一步,确认返回值。很多刚学编程的人会在函数里return一个新数组,这等于没有原地修改。LeetCode这类题目通常希望你直接在原数组上修改,函数可以不返回任何值,或者返回None,判题系统会直接检查传入的数组对象。如果你创建了新数组返回,你把nums指向新对象,但系统检查的还是原来的那个对象,结果就是“看起来没问题,提交不通过”。

第二步,加入打印信息跟踪指针位置。在循环里打印slow、fast和当前数组状态,跑几个例子,看看slow是否确实指向了合理的位置。比如交换法里,每轮交换后,slow指向的都应该是当前区间里第一个0的位置。如果不是,说明你的更新逻辑有问题。

第三步,用随机化测试。写一个小的测试脚本,生成随机数组,用你的函数处理,再自己写一个简单的校验函数判断结果是否符合题意。这个做法特别适合在本地调试时快速发现隐性bug,比你手动填十几个例子高效得多。

import random def check(nums): # 找到第一个0的位置 first_zero = None for i, v in enumerate(nums): if v == 0: first_zero = i break if first_zero is None: return True # 第一个0之后全部应该是0 return all(v == 0 for v in nums[first_zero:]) for _ in range(1000): arr = [random.randint(-5, 5) for _ in range(random.randint(0, 20))] original = arr[:] move_zeroes(arr) if not check(arr): print("failed on", original, "->", arr) break

这套组合拳打下来,绝大多数问题都能在提交前被揪出来。

5. 从移动零延伸出去:一类题的通用模型

5.1 相邻考题:移除元素与去重

移动零并不是孤立的一道题,它和另外两道高频题共享同一个解题模型。第一道是移除元素,给定一个数组和一个目标值,要求原地删除所有等于目标值的元素,返回新长度。第二道是删除有序数组中的重复项,要求原地删除重复元素,让每个元素只出现一次。

这两道题的核心模式几乎一模一样:用快指针扫描,用慢指针记录下一个该写入的位置。在移除元素里,判断条件变成了“不等于目标值就写入”;在去重题里,判断条件变成了“当前元素不等于上一个保留的元素就写入”。你可以把这三道题放在同一天刷完,代码模板几乎是同一套,背诵成本很低。

我自己的刷题习惯是把同类型的题归档到一起,专门对比它们的差异点。移动零是在“写完后补0”,移除元素只需要返回新长度,不需要关心数组尾部的脏数据;去重题也是只要保证前一段有序不重复,后面的内容不影响结果。这些差异看起来小,但非常影响编码细节,值得单独写笔记,比盲目刷题效果好得多。

5.2 分区思想在排序里的应用

移动零的交换法,本质上是一个分区操作:把所有满足“非零”条件的元素分到数组左边,不满足的分到右边。这个模式放到更广的领域,就是快速排序里partition的核心思想。快排每一轮做的事情,其实是选定一个基准值,然后把小于等于基准的元素放到左边,大于基准的放到右边——这不就是“按条件把数组分成两半”吗?

理解了移动零里的交换法,你再看快排的partition代码,会觉得相当亲切。在很多语言的标准库里,也有类似于partition的函数,把满足条件的元素放在前面,不满足的放后面。甚至在一些更复杂的算法题里,比如荷兰旗问题(三色排序)、数组按奇偶排序,也都是同一套思想的变体。

所以我建议读者不要只把移动零当成一道面试题背答案,而是把它当作理解“数组分区”这个更一般概念的一个切口。当你需要把数组按照某个条件分成前后两段时,先想想能不能用双指针原地完成。大多数情况下可以,而且只需要O(n)时间和O(1)空间。

5.3 面试时的表达模板

再补充一个面试场景的表达模板。这道题如果是在面试中遇到,通常面试官希望看到的是你先说清楚思路,再写代码,最后做测试。思路部分可以这样组织:先说明约束(原地、保持顺序),再指出双指针维护的区间含义,然后讲快指针扫描、慢指针记录写入位置,最后说明为什么这个方案满足复杂度要求。

写代码的时候可以顺便提一句“我倾向于用交换法,因为它在过程中天然满足不变式,也不需要在末尾补0”,这会让面试官觉得你对细节有把控。写完代码不要马上说“好了”,而是主动跑一个例子,展示数组每一步的变化,再提一下边界情况的处理。这么做的好处是,即使代码有一点小瑕疵,面试官也会因为你的流程完整而给出正向评价。

我自己带过不少做面试准备的朋友,发现这道题能一遍完美写出来的人,往往对双指针的理解已经到位了;而写错的人,通常不是不会写代码,而是没有把“慢指针到底指向什么”这个问题想透。所以这篇文章的最后,我还是要强调那句话:写任何双指针题之前,先明确两个指针各自的意义,再动手。指针的含义清晰了,代码自然就顺了。

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

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

立即咨询