资讯动态

LeetCode 283 移动零:双指针原地算法与复杂度优化实战

发布时间:2026/9/16 3:20:51 来源:尧图企业网站定制
刷 LeetCode 的人应该都体会过这种时刻一道题标着“简单”但自己写出来的解法又臭又长跑通之后一看题解别人三五行代码就搞定了。283. 移动零就是这样一道典型的“简单但不简单”的题。说它简单是因为题目本身一句话就能说清说不简单是因为它背后牵出的双指针思想几乎贯穿了整个 LeetCode 算法体系从数组操作到链表、字符串处理到处都有它的影子。这道题我最早是在准备面试的时候刷到的当时用了一个极其朴素的“新建数组”解法虽然能过但总感觉差点意思。后来把双指针的思路吃透了才发现这题其实是理解快慢指针、理解“原地操作”、理解时空权衡的绝佳入口。这篇文章就把我从暴力解法到最优解法的完整思考过程写出来包括代码实现、复杂度分析、边界条件以及从那之后我在其他题目里反复用到的“双指针通用套路”。不管你是刚开始刷题的新手还是准备春招秋招的应届生这篇应该都能给你一些参考。1. 题目到底在问什么先读懂需求再动手1.1 题干拆解与核心考点原题描述很简洁给定一个数组 nums编写一个函数将所有 0 移动到数组的末尾同时保持非零元素的相对顺序。第一遍读题很多人的第一反应是“就这”但仔细拆一下这题其实藏了三个硬性要求原地操作题目虽然没直接说“不能使用额外空间”但作为高频面试题它的潜台词就是这个。你不能 new 一个新数组然后把非零元素塞进去那样空间复杂度就是 O(n)面试官大概率会让你继续优化。保持非零元素的相对顺序这句话直接把“对撞指针”这类解法排除掉了。如果你用两个指针一头一尾往中间走遇到零就扔到后面确实能把零集中到末尾但非零元素的相对顺序会被打乱。这一条要求决定了这道题最合适的解法是“快慢指针”而不是“左右指针”。数组操作这意味着你要在内存连续的空间里做元素的移动和覆盖没法像链表那样简单地“摘除”和“拼接”节点。所以这题的核心考点其实有三个维度双指针思想、原地算法的空间意识、对元素顺序的敏感度。这三个维度恰好也是面试中数组类题目的底层能力学会这一题后面再遇到“移动元素”“去重”“压缩数组”这类题思路会顺很多。1.2 从“看着简单”到“写对”的距离我曾经拿这道题给一个刚学完 Java 基础的朋友试手他 10 分钟就写出了第一版public void moveZeroes(int[] nums) { int n nums.length; int index 0; int[] res new int[n]; for (int num : nums) { if (num ! 0) { res[index] num; } } while (index n) { res[index] 0; } System.arraycopy(res, 0, nums, 0, n); }这段代码完全正确LeetCode 也能通过时间复杂度 O(n) 其实已经很好了。网上有些讨论把这种做法说得一文不值我不太认同——能在几分钟内写出一个正确且时间上不差的解法这本身就是一种能力。先写出一个能跑的解法再去追求更优这才是我认可的刷题节奏。但问题在于这个解法有两个可以被追问的点第一它用了 O(n) 的额外空间如果数组很大比如几百万个元素内存开销就很明显第二它先复制到新数组再复制回来做了两次 O(n) 的拷贝虽然不影响复杂度量级但实际执行时间会翻倍。面试官如果问“能不能省掉额外空间”你就得回到双指针这条思路上来。1.3 适用场景与牵扯的知识储备这道题在 LeetCode 上属于“热门 100 题”之一热度一直很高原因是它非常适合作为双指针专题的入门题。LC 的整个题单里双指针题目分好几类快慢指针一个走得快一个走得慢典型如环形链表检测、删除有序数组重复项。左右对撞指针一个从左一个从右典型如两数之和 II、反转字符串、盛最多水的容器。滑动窗口本质上也是双指针只是两个指针同向移动维护一个窗口区间。283 移动零属于典型的快慢指针或者说同向双指针应用。理解它之后再去做 27. 移除元素、26. 删除有序数组中的重复项你会发现它们的骨架几乎一模一样差别只在于“移动零”要求把零放到末尾实际上就是“移除元素”的镜像操作不信的话可以把问题反过来想先把所有非零元素按顺序放到前面再在数组尾部补零。2. 双指针解法最优解背后的“为什么”2.1 快慢指针的直觉来源我更喜欢把这道题的快慢指针理解成“蚂蚁搬家”或者“整理书架”想象一个书架上有一排书其中几本是你不要的“零”你要做的不是把每本“零”书一本一本挪到最右边那是冒泡排序式的笨办法。更聪明的做法是把那些“非零”的好书一本一本往前挪填补到书架左侧一个个空位上最后把右边空出来的位置统一摆上“零”。这样每个元素最多被移动一次干净利落。对应到代码里就是两个指针慢指针 slow 指向当前已整理好的“非零区”的下一个位置快指针 fast 负责从头到尾扫描整个数组。每当 fast 遇到一个非零元素就把它赋值到 slow 指向的位置然后 slow 前进一步。扫描结束后slow 之前的区域全都是非零元素并且相对顺序没变接下来只要把 slow 到数组末尾的区域全部填成 0 就可以了。2.2 覆盖 vs 交换两种写法你选哪种这个解法在网上有两种常见实现第一种是“覆盖后补零”public void moveZeroes(int[] nums) { int slow 0; // 第一趟把非零元素往前覆盖 for (int fast 0; fast nums.length; fast) { if (nums[fast] ! 0) { nums[slow] nums[fast]; } } // 第二趟末尾补零 while (slow nums.length) { nums[slow] 0; } }第二种是“原地交换”public void moveZeroes(int[] nums) { int slow 0; for (int fast 0; fast nums.length; fast) { if (nums[fast] ! 0) { int temp nums[fast]; nums[fast] nums[slow]; nums[slow] temp; slow; } } }两种写法的时间复杂度都是 O(n)空间复杂度都是 O(1)。区别在于“覆盖后补零”的思路更直白但会改变数组中某些元素的“位置轨迹”。比如[1, 0, 2, 0, 3]第一步把 nums[0]1 放到 nums[0]第二步当 fast2 时把 nums[2]2 放到 nums[1]原数组中下标 2 位置的 2 就被覆盖了随后在第二趟把下标 3 和 4 补成 0。最后结果是[1, 2, 3, 0, 0]正确。“原地交换”更严谨因为它保持了“非零区”之外空间的语义slow 到 fast 之间的元素都是零一次遍历就完成全部工作连补零都不用所以少一趟循环。在代码简洁性上我更推荐第二种。虽然第一次接触时会觉得“交换”这个动作有点绕但写多了就会发现这种“慢指针维护结果区快指针扫描待处理区”的模式能直接迁移到很多题上。2.3 时间复杂度、空间复杂度与极端情况时间复杂度无论是覆盖还是交换fast 指针都只遍历数组一次所以是 O(n)。覆盖法还多了一趟补零的 while 循环这个循环最坏情况会跑 n 次全零数组但加起来是 O(2n) O(n)仍然属于线性复杂度。空间复杂度全程只用了常数级别的临时变量没有额外开辟数组所以是 O(1)。这也是这题最重要的考点之一。极端情况我也建议你想一下空数组[]两个循环都不会进入直接返回没问题。全零数组[0,0,0,0]fast 扫描时一个非零都遇不到slow 一直在 0 不动交换法等价于没有交换补零法最后 whole 数组重新填一遍零结果正确。全部非零数组[1,2,3,4,5]slow 和 fast 几乎是同步前进的交换法是原地和自己交换虽然多做了一些无用功但结果正确。这里有一个小优化点可以让slow ! fast时才执行交换不过对于 LeetCode 的运行环境来说这点判断带来的性能差异几乎可以忽略反而多写一个判断会让代码的语义不那么纯正我个人建议别加。零在开头[0,1,2]这是交换法最典型的场景。slow 一开始指向下标 0fast 到下标 1 时发现 1 是非零交换 nums[0] 和 nums[1]数组变成[1,0,2]slow 变成 1fast 到下标 2 时交换 nums[1] 和 nums[2]数组变成[1,2,0]完美。提示刷题时很多人会忽略边界条件的检验但面试官最爱的就是在这种“小地方”挖坑。提交之前建议先在脑内跑一遍空数组、单元素数组、全零数组、全非零数组这四类用例。3. 为什么不能用“遇零就往后挪”的暴力法3.1 朴素思路的白板推演很多人第一眼看到这题脑子里蹦出来的解法是从前往后遍历遇到一个 0就把它后面的所有元素整体前移一位然后在这个 0 的位置补到最后面。以[0, 1, 0, 3, 12]为例指针 i0 发现 nums[0] 是 0把后面[1, 0, 3, 12]整体前移一位并在末尾补一个 0数组变成[1, 0, 3, 12, 0]。指针 i1 发现 nums[1] 是 0再把后面[3, 12, 0]前移一位并末尾补 0数组变成[1, 3, 12, 0, 0]。最终结果也是对的但问题在于每遇到一个零就要做一次 O(n) 的搬运外层还要遍历一遍数组所以最坏情况的时间复杂度是 O(n²)。面试官脸上可能不露声色但心里已经在想“这哥们时间复杂度分析得不太行”。3.2 为什么它不符合面试期待有些朋友会问既然 LeetCode 上 O(n²) 也能通过毕竟 n 最大才 10⁴那为什么要写最优解我的看法是刷题不只是为了通过用例更是为了训练工程判断力。真实业务里一个方法可能被调用几万次每次传入的数组可能是百万级数据。O(n²) 和 O(n) 在这个量级下的差距不是百分之五十而是几百倍。你写代码时如果对复杂度没概念线上出了问题往往连从哪里排查都不知道。再从面试角度来说面试官问这道题极少是为了考你会不会“把零放末尾”——这个行为本身太简单了。他们真正想听的是你如何分析一个算法的时间复杂度和空间复杂度如何在约束中做出取舍。所以哪怕你一眼就看出了最优解法也要在讲思路时把“为什么 O(n²) 不行”提一句这能直接拉满面试官对你的印象分。3.3 暴力法唯一的好处作为“基线版本”不过暴力法也不是毫无价值。写代码也好做算法题也好一个很重要的策略就是“先写一个能跑的版本再去优化”。暴力法能帮你验证自己对题意的理解是否正确也能给你一个可以对比性能的基线。我刷题时的习惯是拿到中等及以上的题先不急着写最优解而是把暴力的思路用注释写在一旁然后问自己三个问题这个思路的时间复杂度是多少空间复杂度是多少哪一步是耗时的主要来源能不能去掉如果出现“重复计算”或者“多余搬运”能不能用指针、哈希表、前缀和等方式避免283 这题暴力法的耗时来源就是“反复搬运数组元素”而双指针解法恰恰通过“一次搬运到位”避开了这个瓶颈。这其实就是算法的本质——用更聪明的数据结构或指针设计减少不必要的计算。4. 实操过程与核心环节实现4.1 从思路到代码完整实现步骤我把双指针解法的完整推导过程拆成四步这样哪怕你之前完全没接触过双指针也可以照着这个流程写出来第一步定义两个指针。慢指针 slow 从 0 开始表示“下一个非零元素应该放置的位置”快指针 fast 也从 0 开始负责遍历整个数组。第二步快指针扫描。fast 从 0 遍历到数组末尾每次检查nums[fast] ! 0是否成立。如果成立说明这个元素应该被挪到前面去。第三步交换或覆盖。如果采用交换法就把 nums[slow] 和 nums[fast] 交换然后 slow 前进一步。这个交换隐含了一个信息nums[slow] 要么是 0要么和 nums[fast] 是同一个位置所以不会丢失任何非零元素。第四步确认结果。交换法不需要第二趟补零因为每次交换都会把 0 自然“甩”到后面去覆盖法则需要在最后统一补零。4.2 代码逐行讲解与易错点标注先用交换法的完整代码配上重点注释class Solution { public void moveZeroes(int[] nums) { // 慢指针指向当前已经处理好的非零区域的尾部 int slow 0; // 快指针从数组头扫到尾找非零元素 for (int fast 0; fast nums.length; fast) { if (nums[fast] ! 0) { // 将非零元素交换到 slow 指向的位置 int temp nums[fast]; nums[fast] nums[slow]; nums[slow] temp; // slow 后移维护非零区域的边界 slow; } } } }这里有几个初学者容易踩的坑忘记让 slow 前进这种情况下重复的交换只会把非零元素原地“弹”回去最后数组没任何变化。刷题时最怕这种“编译通过但逻辑错误”的 bug由于没有报错排查起来相当浪费时间。把 if 条件写成nums[fast] ! 0 slow ! fast逻辑上没问题能避免自己交换自己但会让代码更啰嗦。如果你追求极致的可读性建议保持简单。在交换法中又补零交换法本质是“每碰到一个非零就把它和前面的零交换一次”数组尾部自然堆积零。如果你画蛇添足再加一个循环补零可能会把原本正确的数组破坏掉。4.3 覆盖法的完整流程再来仔细看覆盖法的流程方便你在两种写法间自由切换。覆盖法的执行过程可以分为两个阶段阶段一压缩非零区。在遍历过程中把所有非零元素按顺序向前覆盖慢指针 slow 记录了非零区的边界。比如[0,1,0,3,12]fast 依次扫过 nums[1]1 时把它覆盖到 nums[0]扫过 nums[3]3 时覆盖到 nums[1]扫过 nums[4]12 时覆盖到 nums[2]此时数组变成[1, 3, 12, 3, 12]可以看到后面的元素暂时是脏的但没关系下一阶段会清掉它们。阶段二尾部补零。从 slow 开始到数组结束全部赋值为 0。上一步的[1,3,12,3,12]经过nums[3]0和nums[4]0之后变成了[1,3,12,0,0]。完整代码class Solution { public void moveZeroes(int[] nums) { int slow 0; for (int fast 0; fast nums.length; fast) { if (nums[fast] ! 0) { nums[slow] nums[fast]; slow; } } while (slow nums.length) { nums[slow] 0; slow; } } }这段代码的优点是特别容易理解面试现场你一边讲“先把非零的往前排再把后面补零”一边写这段思路非常顺畅。4.4 Java 版本特别提醒用 Java 刷这题时有几个语言层面的点顺便说一下方法的入参是数组引用你在方法内部做的所有修改都会直接影响原数组。不需要 return直接原地改即可。这和 C 里的指针传递、Python 里的列表引用是一个道理。nums.length是属性不是方法调用所以别写成nums.length()。这种笔误在面试手写代码时特别常见写完后如果时间充裕建议默读一遍代码检查这类低级问题。交换两个 int 值时用临时变量最稳妥。虽然可以用a ^ b; b ^ a; a ^ b;这种异或交换但在算法题中意义不大反而增加了理解成本没必要。注意如果你在 IDE 里跑这段代码想查看结果可以直接用Arrays.toString(nums)打印但 LeetCode 的判题系统只认数组内容本身不会管你怎么打印。5. 常见问题与排查技巧实录5.1 为什么用交换法时结果总是原封不动这是很多人第一次写完交换法后最容易遇到的问题。举个例子输入[1, 0, 2, 0, 3]fast0 时 nums[0]1不为零交换 nums[0] 和 nums[0]自己换自己slow 变 1fast1 时 nums[1]0跳过fast2 时 nums[2]2交换 nums[1] 和 nums[2]数组变成[1, 2, 0, 0, 3]fast4 时 nums[4]3交换 nums[2] 和 nums[4]数组变成[1, 2, 3, 0, 0]。结果完全正确。那为什么会“原封不动”最常见的原因是慢指针没有更新。只要在每次 if 里忘了写slow下一次交换就会把刚换过去的非零元素又换回来整个过程就像“原地踏步”。另一个可能的原因是用 foreach 遍历数组的同时尝试修改数组元素。用增强 for 循环时你拿到的num是数组元素的副本直接num 0是没用的必须用下标访问去改原数组。这是个特别隐蔽的错排查起来很费劲。5.2 数组越界到底是怎么发生的数组越界在这个解法中不太容易发生但如果你把 while 补零写成了while (slow nums.length)就会越界。原因是数组最大下标是 length-1当 slow 等于 length 时就应该停止。另外有一些写法会先统计零的个数再根据个数决定循环范围比如int zeroCount 0; for (int num : nums) { if (num 0) { zeroCount; } }这个思路没问题但如果你后面再去写“从后往前填充零”的循环边界条件就很容易记混。我的建议是逻辑越简单越不容易错尽量让 slow 自己维护边界而不是额外记一个 zeroCount。5.3 刷题自测用例清单我给自己整理了一套“数组题通用自测用例”每次写完这道题我都会在本地跑一遍用例输入期望输出测试意图常规混合[0, 1, 0, 3, 12][1, 3, 12, 0, 0]正常情况零在开头[0, 0, 1][1, 0, 0]连续零零在结尾[1, 2, 0, 0][1, 2, 0, 0]不需要移动的情况全零[0, 0, 0][0, 0, 0]极端边界全非零[1, 2, 3][1, 2, 3]不需要移动的情况单元素[0]/[1][0]/[1]最小输入空数组[][]边界情况你可以把这份清单当成模板套到几乎所有数组类问题上。写完之后跑一遍能覆盖绝大多数逻辑边界省去反复提交试错的麻烦。6. 举一反三双指针是同一套骨架6.1 变式一27. 移除元素原题要求原地移除所有数值等于 val 的元素返回移除后数组的新长度。这题和 283 简直是一个模子刻出来的区别只有两处283 移除的是固定的“0”27 移除的是参数传入的“val”。283 要求把零放到数组末尾27 只要求返回移除后的长度对末尾剩余元素没有要求。所以解法可以直接复用 283 的覆盖法思路连补零都不用做因为题目不关心 slow 之后的位置public int removeElement(int[] nums, int val) { int slow 0; for (int fast 0; fast nums.length; fast) { if (nums[fast] ! val) { nums[slow] nums[fast]; slow; } } return slow; }6.2 变式二26. 删除有序数组中的重复项原题要求给定一个有序数组原地删除重复出现的元素使每个元素只出现一次返回新长度。这题的快慢指针就更加精妙了快指针负责探路慢指针负责维护“已去重区域”的边界。因为是有序数组所以重复项是相邻的当 fast 发现nums[fast] ! nums[slow]时意味着遇到了新元素把它放到 slow1 的位置public int removeDuplicates(int[] nums) { int slow 0; for (int fast 1; fast nums.length; fast) { if (nums[fast] ! nums[slow]) { slow; nums[slow] nums[fast]; } } return slow 1; }这道题几乎是 283 的“精神续作”。你已经掌握了 283 的覆盖法思路再看这段代码应该能瞬间理解 slow 和 fast 各自的职责。6.3 变式三283 的“值移动”思路在链表中的应用双指针不仅能处理数组链表里的经典问题比如 876. 链表的中间结点、141. 环形链表用的都是快慢指针只不过一个走一步一个走两步。当你把数组题的指针抽象成“遍历位置”把链表题的指针抽象成“节点引用”会发现它们背后是同一套思维模型用一个快的指针去探索未知区域用一个慢的指针维护已经处理好的区域。这也是为什么我建议每个刷题的人都要专题化地练习。只做一道题你记住的是解法做一类题你掌握的才是方法。7. 面试现场与刷题心态的几点建议7.1 手撕代码时的表达节奏如果你在面试中遇到这道题我建议按照下面这个顺序表达能体现你的逻辑层次感先复述题目确认约束条件“所以我理解为需要原地操作并且要保持非零元素的相对顺序对吗”这一步很关键既能确认题意又能展示你的沟通意识。再说思路“我可以用双指针。慢指针维护已经处理好的区间快指针去遍历整个数组遇到非零元素就交换到前面来。这样每个元素最多被移动一次时间复杂度 O(n)空间复杂度 O(1)。”这段话 30 秒不到但面试官能立刻明白你是有备而来。然后写代码边写边讲写int slow 0;时说“这个指针代表非零区的边界”写交换逻辑时说“每次交换就把一个非零元素放到了正确的位置同时把 0 甩到了后面”。最后主动做复杂度分析“一次遍历O(n)。只有常数空间O(1)。不过有一个极端情况是全部非零这时交换两个相同位置的元素属于无效操作但不会影响正确性。”主动提极端情况是加分项因为它展示了你对代码边界条件的敏感度。7.2 从刷题到面试的迁移心态我知道很多人在刷题阶段会很焦虑觉得自己连“简单题”都写得磕磕绊绊。但以我带过的人和我自己的经历来看这非常正常。我刚接触算法题时283 这种题也要查半天题解甚至看了题解都反应不过来为什么慢指针不往回走。后来我调整了方法不去背题解而是把每个标签下的经典题按类型刷两三道然后自己总结一套“骨架”。比如双指针的骨架就是“一个慢指针维护结果区一个快指针扫描原数组”在这个骨架之上不同题只是改变了比较条件、移动时机和收尾动作。一旦形成这种抽象能力你遇到新题时就不会慌而是会想“这是在哪个骨架上加了一点变化”。7.3 刷题记录的一个小技巧最后分享一个我个人的习惯每刷完一道题都会在题解末尾写一段“这题教会了我什么”。283 这题我当时写的是“覆盖法和交换法在空间上等价但交换法的语义更严谨快慢指针中慢指针的位置很重要它所谓的“慢”不是走得慢而是它只在满足特定条件时才前进。”这个习惯听起来很简单但坚持半年之后你回看自己的刷题笔记会发现自己对算法的理解有一根非常清晰的成长线。这不仅对面试有帮助对你读源码、做架构设计时也有潜移默化的影响。用一道简单题练熟一套底层方法这买卖无论如何都划算。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价