ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

LeetCode 283 移动零:双指针原地重排数组的经典解法

LeetCode 283 移动零:双指针原地重排数组的经典解法 刷 LeetCode 的人多半绕不开 Hot 100 这份题单283 移动零又是这份题单里比较特别的一道难度标着 easy但很多人第一次写的时候脑子里冒出来的都是“开个新数组把非零元素塞进去后面补零”。这个思路确实对可面试官只要补一句“能不能原地操作空间复杂度 O(1)”不少人就卡住了。移动零这道题本质上是把“双指针”这个高频思想用一个非常朴素的外壳包装了起来。这篇文章我想从题目分析、双指针解法、同类题目对比、常见坑和面试追问几个角度把这道题彻底讲透顺便聊聊怎么把一道 easy 题的价值挖到最大。1. 题目先看懂移动零到底在考什么1.1 题目描述与本质题目要求很简单给你一个数组nums写一个函数把所有的0移动到数组的末尾同时保持非零元素的相对顺序。比如输入[0,1,0,3,12]输出应该是[1,3,12,0,0]。题目下面通常还有两条硬性要求必须原地操作不能拷贝额外数组尽量减少操作次数。为什么强调“保持非零元素的相对顺序”因为这一点直接决定了解法的走向。如果允许乱序那可以头尾双指针一左一右交换把非零丢前面、零丢后面遍历一遍就结束。但一旦要求保持相对顺序头尾交换的思路就不灵了因为交换会打乱非零元素原本的顺序。所以这道题真正想考察的是在“稳定性”约束下怎么用指针完成原地重排。拆开来看移动零做的事情其实可以理解为两步第一步把数组里所有的非零元素挑出来按原顺序放到数组前面第二步把剩下的位置全部填成 0。双指针解法本质上就是这两步的融合版通过一个慢指针标记“已经排好的位置”一个快指针扫描“还没看过的元素”一次遍历就完成整个重排。1.2 为什么是 easy 却不简单我见过不少人在这道题上翻车翻车的原因往往不是题目难而是第一反应“太简单”直接上手写了个脆弱的解法。最常见的三种错误第一种新开一个数组从头扫一遍把非零元素放进去再补零返回。这个写法逻辑完全正确但空间复杂度是 O(n)不符合原地要求。第二种在 JavaScript 里用splice删掉零再push到末尾。问题在于splice本身就是 O(n) 的删除操作外层再套一个循环最坏情况复杂度直接 O(n²)而且遍历时索引会变经常出现漏删。第三种看到“移动”就想着两两相邻交换像冒泡一样把零往后挪。这个思路不是不能用但最坏情况下每个零都要冒泡到末尾同样退化到 O(n²)。这三种解法各有各的问题但它们都有一个共同点没有意识到数组原地重排的场景下双指针几乎是标配手段。慢指针是“写指针”快指针是“读指针”读指针负责寻找符合条件的元素写指针负责记录放置位置两个指针一配合就能在单次遍历里完成筛选和落位的双重任务。1.3 双指针的直觉用生活化的场景来理解双指针就好比整理一排书架你从左往右看把每一本不是空位的书往左靠同时用手标记“下一个空位在哪”。慢指针是那只手永远指向下一个可以放书的位置快指针是你的眼睛一格格往右扫。眼睛看到不是空位的书就搬到手边然后手往右挪一格。扫完之后手右边剩下的全部位置自然就是空位。这个直觉一旦建立起来代码就非常直观了。而且你会发现双指针解法不只适用于这道题后面做到 27 移除元素、26 删除有序数组中的重复项逻辑都是一模一样的套路只是判断条件不同而已。这也是我建议所有刷题的人把 283 当作双指针入门第一题的原因它没有复杂的数学推导没有特殊的数据结构纯粹是“指针怎么移动”的问题。2. 双指针解法一快慢指针一次遍历2.1 指针语义与执行过程这道题最经典的写法是快慢指针也叫双指针一次遍历。定义两个变量slow代表慢指针指向当前可以放置非零元素的位置fast代表快指针遍历整个数组。快指针每遇到一个非零元素就把它和slow指向的位置进行交换然后slow右移一格。当fast走完全部数组前面slow个位置就都是非零元素剩下的自然全是零。用[0,1,0,3,12]来模拟一遍步骤fast 指向slow 指向数组状态操作初始00[0,1,0,3,12]无fast0值00[0,1,0,3,12]不交换slow 不动fast1值10[1,0,0,3,12]交换 slow 和 fastslow1fast2值01[1,0,0,3,12]不交换slow 不动fast3值31[1,3,0,0,12]交换 slow 和 fastslow2fast4值122[1,3,12,0,0]交换 slow 和 fastslow3这个过程里slow始终指着“已经处理好的非零序列的下一个位置”所以每次把非零元素交换到slow的位置都不会破坏前面已经排好的部分。同时因为快指针是从左往右扫的非零元素被发现的顺序天然就是它们在原数组中的相对顺序稳定性自然得到保证。2.2 代码实现Python 版本def moveZeroes(nums: list[int]) - None: slow 0 for fast in range(len(nums)): if nums[fast] ! 0: nums[slow], nums[fast] nums[fast], nums[slow] slow 1C 版本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 版本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; } } }三个语言的逻辑完全一样区别只在交换操作的语法。时间复杂度 O(n)空间复杂度 O(1)操作次数最坏情况下是 n 次交换最好情况下可以少很多比如数组全是非零元素时每个元素都会和自己交换。2.3 交换 versus 覆盖两种写法的取舍快慢指针还有一个常见的变种不交换而是覆盖。遇到非零元素时直接把nums[fast]赋值给nums[slow]然后slow遍历结束后从slow开始到数组末尾统一填零。代码如下def moveZeroes(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交换和覆盖都能 AC但两者的语义有些微差别。交换是“把非零元素和零换个位置”整个过程数组里始终有零在向后移动数据不会丢失覆盖则是“先集中搬运非零元素最后统一清场”。从性能上看覆盖方案在数组非零元素较多时节省了很多次无意义的自我交换写入次数也更少。比如数组本来就是[1,2,3,4,5]交换方案会让每个元素和自己交换一次覆盖方案则只做 5 次赋值加 0 次补零。但我在实际写题时更倾向于交换写法原因有两个。第一交换写法更通用后面做 27 移除元素时如果没有要求返回新长度而是要求原地移除交换写法可以直接改条件复用第二交换写法在一次遍历里完成了所有操作不需要额外再来一个补零循环代码结构上更“干净”一些。面试的时候如果面试官问有没有优化空间可以抛出覆盖方案来展示你对写入次数的敏感度这是一个不错的加分点。3. 双指针解法二先搬运再补零3.1 算法描述第二种解法其实是第一种解法的“分步版”思路更直白第一遍遍历把数组里所有非零元素按顺序搬到前面用一个慢指针记录下一个写入位置第二遍遍历从慢指针的位置开始把数组剩余位置全部写成 0。这个方案比交换方案更容易理解更适合作为向新手讲解时的第一步。它的正确性可以从“数量守恒”的角度证明第一遍结束时慢指针slow的值就是非零元素的个数既然数组总长度不变那么剩余len(nums) - slow个位置必然全部是零把这些位置统一赋值成 0 即可。还是拿[0,1,0,3,12]举例。第一遍遍历fast0值是0跳过fast1值是1写入nums[0]数组变为[1,1,0,3,12]slow 变为1fast2值是0跳过fast3值是3写入nums[1]数组变为[1,3,0,3,12]slow 变为2fast4值是12写入nums[2]数组变为[1,3,12,3,12]slow 变为3。第二遍从下标3开始到末尾全部赋值为0最终得到[1,3,12,0,0]。细心的读者会发现在第一遍过程中原数组里的部分元素被覆盖后原位置的值可能还残留着比如上面的 3 和 12 都被复制了一份在数组后面。所以第二遍的补零是绝对必要的不能省。3.2 代码实现与复杂度C 版本void moveZeroes(vectorint nums) { int slow 0; for (int fast 0; fast nums.size(); fast) { if (nums[fast] ! 0) { nums[slow] nums[fast]; } } for (int i slow; i nums.size(); i) { nums[i] 0; } }Java 版本public void moveZeroes(int[] nums) { int slow 0; for (int fast 0; fast nums.length; fast) { if (nums[fast] ! 0) { nums[slow] nums[fast]; } } for (int i slow; i nums.length; i) { nums[i] 0; } }时间复杂度仍然是 O(n)因为第一遍和第二遍各遍历一次总体是 2n 次操作常数项可以忽略空间复杂度 O(1)。相比交换方案覆盖方案在“非零元素特别多”的数组上明显更快因为它避免了大量的自交换但在最坏情况数组全是零下两个方案都只需要一轮扫描覆盖方案甚至因为少了 swap 的操作而略快一点。3.3 两个方案到底选哪个我自己的习惯是刷题阶段两个方案都要能写出来面试阶段可以先用覆盖方案讲思路再用交换方案写代码。为什么这样安排因为覆盖方案更容易解释清楚“非零元素往前搬后面补零”这个朴素逻辑面试官不需要花时间理解交换的指针跳转过程而交换方案更优雅代码更短而且一次遍历的处理方式更能体现对双指针的理解深度。但有一个点需要特别提醒如果题目要求“不能改变非零元素的相对顺序”那么覆盖方案和交换方案都没有问题但如果题目改成“把某个目标值移动到末尾并且不能覆盖其他元素”覆盖方案就不适用了必须用交换方案。因为覆盖方案在搬运过程中会临时覆盖掉尚未扫描到的元素虽然最后会补零但过程上并不安全。所以在做变式题时交换方案通常是更稳妥的底座。4. 从 283 到一类题双指针的适用边界4.1 双指针为什么高效双指针的高效来自于“一次遍历同时完成多个任务”。在单指针思路下你可能需要先遍历一遍统计某种信息再遍历一遍执行操作而双指针让“读”和“写”同时发生慢指针记录写位置快指针负责读元素两个指针步调一致地向后移动整个过程数组最多被扫描一遍。这也是所有 O(n) 原地重排算法的底层逻辑。双指针还有一个隐藏优势它天然维护了稳定性。快指针是从左到右扫描的发现非零元素的顺序就是它们在原数组中的顺序慢指针每次落位也都是从左到右依次进行的。两个指针的移动方向一致就保证了“先到先放”的稳定性。4.2 同套路题目对比26、27 和 283LeetCode 里有好几道题几乎是一个模子刻出来的放在一起对比着刷效率非常高。27 移除元素给定一个值val原地移除所有等于val的元素返回移除后数组的新长度。核心逻辑和 283 一模一样只是把“判断是否为 0”换成了“判断是否等于 val”并且因为题目只要求返回长度不需要补零所以用覆盖方案写更简洁def removeElement(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 slow26 删除有序数组中的重复项给定有序数组原地删除重复元素使每个元素只出现一次返回新长度。这里快指针同样是从左往右扫描但判断条件变成了“当前元素和上一个保留元素是否不同”def removeDuplicates(nums: list[int]) - int: slow 0 for fast in range(1, len(nums)): if nums[fast] ! nums[slow]: slow 1 nums[slow] nums[fast] return slow 1对比三者的指针逻辑题目快指针条件慢指针含义操作283 移动零nums[fast] ! 0非零序列的末尾交换27 移除元素nums[fast] ! val非 val 序列的末尾覆盖26 去重nums[fast] ! nums[slow]去重序列的末尾覆盖这三个题建议一起刷你会发现所谓“套路”其实就是一套快指针找目标慢指针落位条件决定你选中哪些元素。把 283 吃透了27 和 26 基本就是改一行条件的事。4.3 各语言实现要点这道题本身逻辑不难但不同语言的写法有不少细节差异我在刷题群里见过不少人栽在小细节上。Python 要注意交换列表元素不能写a, b b, a这种简单解包在列表里其实也可以写nums[slow], nums[fast] nums[fast], nums[slow]但千万别写for fast in nums然后边遍历边修改数组那个坑太深C 记得引入vector和algorithmstd::swap可以直接用Java 没有内置 swap需要手写临时变量JavaScript 可以用解构赋值[nums[slow], nums[fast]] [nums[fast], nums[slow]]但要注意 LeetCode 的 Node 版本对解构交换是支持的不需要额外处理。Go 版本也是手写交换func moveZeroes(nums []int) { slow : 0 for fast : 0; fast len(nums); fast { if nums[fast] ! 0 { nums[slow], nums[fast] nums[fast], nums[slow] slow } } }核心逻辑跨语言完全一致区别只在语法糖所以语言切换成本很低。5. 实战中容易踩的坑与面试追问5.1 边界条件与测试用例写完代码第一时间不是提交而是用边界用例自测。这道题的边界条件不算刁钻但覆盖面很广空数组[]、全零数组[0,0,0]、全非零数组[1,2,3,4]、单个元素[0]或[1]、零在开头、零在末尾、零分散在各处。我自己的自测清单[]期望[][0]期望[0][0,0,0]期望[0,0,0][1,2,3]期望[1,2,3][0,1,0,3,12]期望[1,3,12,0,0][1,0,0,0,2]期望[1,2,0,0,0]这些用例覆盖了空、全零、全非零、零分散、零在中间等主要情况。如果这些都能通过这道题的正确性基本就有保证了。5.2 稳定性问题为什么相对顺序不会乱很多初学双指针的人会疑惑交换的时候万一慢指针指向的内容被换到后面会不会打乱已经排好的顺序答案是不会。关键在于慢指针的定义。slow永远指向的是“下一个可以放置非零元素的位置”也就是说slow之前的元素都已经处理完毕且必然都是非零元素。当快指针发现一个非零元素时它被交换到slow的位置这个操作只影响slow指向的那个位置。而slow位置上要么是 0要么是已经被扫描过的非零元素如果之前没有 0 出现slow 可能等于 fast此时是自我交换。无论是哪种情况这个位置都不属于“已经排好的前半段”的末尾所以交换不会破坏已处理部分的顺序。举个例子数组是[1,0,2]初始 slow0fast0nums[0]1非零交换nums[0]和nums[0]自我交换slow1。fast1值是0跳过。fast2值是2交换nums[1]和nums[2]得到[1,2,0]。可以看到即使在 fast2 时把nums[1]的 0 换到了后面前面的[1,2]顺序依然是对的因为slow1之前的位置就是“已排好”的区域。5.3 面试追问变式这道题作为 easy真正的价值在面试时的扩展提问。我整理了几个高频追问如果要把所有零移到开头同时保持非零元素相对顺序怎么做答案是反向双指针从右往左扫描快指针从末尾往前找非零元素慢指针也从末尾记录放置位置其余逻辑完全对称。如果要把指定值val移动到末尾不限0怎么做把判断条件从nums[fast] ! 0改成nums[fast] ! val即可其余逻辑完全不变。如果数组里的元素不止0和非0两类而是要求把负数放到最前面、正数放到最后面、零放在中间而且每类内部保持相对顺序怎么做这个问题复杂度会上升需要用到三次翻转或者多轮稳定分区一般不会在 easy 题里出现但可以作为延伸话题展示你的思考深度。如果要求最小化写入次数怎么做这就是前面提到的覆盖方案 vs 交换方案之争需要比较两种方案在不同输入下的写入次数。回答这些追问的关键不是背答案而是理解双指针的“指针语义”。只要知道快指针代表“筛选标准”慢指针代表“放置位置”大部分变式都能通过改判断条件来套用。5.4 常见错误速查表错误现象原因修复方法非零元素顺序乱了交换时把nums[slow]和nums[fast]写反或者 slow 没有及时后移确认慢指针只记录写入位置交换后 slow结果数组前面多了零判断条件写成了nums[fast] 0才操作改成! 0数组越界循环内访问了fast1或slow1快慢指针都从 0 开始用for range(len(nums))死循环交换后忘记 slowslow 始终停在原地每次交换后必须 slow 1结果还是原数组直接nums new_list而不是修改原数组引用在函数原地修改nums不要重新赋值覆盖方案残留旧值覆盖后没有补零第二遍循环把slow到末尾全部赋 0这些坑我基本都踩过尤其是“忘记 slow”和“覆盖后没补零”几乎每个刚开始刷双指针的人都会遇到。写代码的时候把指针更新和循环结束条件当成一个整体来检查能少走很多弯路。6. 我的刷题体会我在刷 Hot 100 的时候策略是先把手感养起来前 30 道题尽量挑 easy 和 middle 的数组题283 就是其中之一。这道题我前后刷了三遍第一遍用覆盖方案第二遍改成交换方案第三遍是刷到 26、27 之后再回来对比总结才发现这三道题其实是一家人。真正让我觉得 283 有价值的地方不是它本身有多难而是它提供了一个分析框架看到“原地 保持相对顺序 移动某一类元素”这三个关键词就应该立刻想到双指针。这个框架在后续刷 75 颜色分类、80 删除有序数组中的重复项 II、左右两边收缩的各类滑动窗口题里都能反复用到。如果在面试中遇到这道题我会建议按这个顺序表达先说明暴力解法新数组和它的空间问题再提出双指针解法明确两个指针各自的语义写代码前举一个小例子口头模拟一遍指针移动最后主动提到覆盖和交换两种方案的区别以及扩展到移除任意元素。这套表达练习几遍之后会非常熟练也能帮你在面试官眼里建立一个“这人不只会背答案是真的理解”的印象。最近我把 283、27、26 这三道题的题解放在一起整理成了一份双指针入门笔记每条题解都只保留指针语义、代码和复杂度分析三部分。两个月后再回看这份笔记比自己当初的零散题解清晰太多。如果你也在刷 Hot 100我的建议是不要为了 AC 刷题把一道 easy 题从暴力到优化、从写法到变式全部想清楚收获比盲目刷十道新题要大得多。
返回列表