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 0fast 0元素为0跳过。fast 1元素为1非零nums[0] 1slow变成1。fast 2元素为0跳过。fast 3元素为3非零nums[1] 3slow变成2。fast 4元素为12非零nums[2] 12slow变成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 0fast 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”这会让面试官觉得你对细节有把控。写完代码不要马上说“好了”而是主动跑一个例子展示数组每一步的变化再提一下边界情况的处理。这么做的好处是即使代码有一点小瑕疵面试官也会因为你的流程完整而给出正向评价。我自己带过不少做面试准备的朋友发现这道题能一遍完美写出来的人往往对双指针的理解已经到位了而写错的人通常不是不会写代码而是没有把“慢指针到底指向什么”这个问题想透。所以这篇文章的最后我还是要强调那句话写任何双指针题之前先明确两个指针各自的意义再动手。指针的含义清晰了代码自然就顺了。