力扣283移动零:双指针+Java原地算法最优解
“看到题目里的‘移动零’三个字很多朋友第一反应就是把非零的往前挪零往后扔——思路是瞬间就有的但一落到代码里不是数组越界就是顺序乱了再不然就是超时了。这道力扣283题作为‘数组类双指针’的开门题我在刷题时前后用了三种写法才摸清门道也被它带出了后面一大串类似题型的解法套路。”“今天这篇文章我就把这套解法拆开了讲透为什么双指针是这道题的最优解Java代码为什么那样写中间有哪些面试官一看就亮的点以及我实际调试时踩过的几个坑。写完这题你顺带能把力扣26题、27题一起秒了。”1. 题目到底在考什么别急着写代码先看懂需求背后的意图题目要求很简单给你一个数组nums把所有的 0 移动到数组末尾同时保持非零元素的相对顺序。要求是原地操作不能复制数组然后直接返回数组即可。力扣的判题只看结果数组是否符合要求。很多第一次刷的同学看到这题觉得“这不就是两次遍历嘛”——第一次把非零的挑出来第二次把剩下的位置填零这样确实能过但不是题目想要的解法。注意题目描述里那两个关键约束必须在原数组上操作不能拷贝额外的数组。尽量减少操作次数。这两句话翻译过来就是空间上尽量省时间上尽量省。而双指针就是同时满足这两个约束的天然方案。所以这道题表面上是“移动零”实质上考的是原地修改数组时的下标管理能力。你在移动元素的过程中能不能保证不丢元素、不改变顺序、不多占用空间——这才是评委想看的。我建议你自己先想一分钟如果只有一个指针从前往后扫遇到 0 怎么办是不是很自然的想把后面的数字往前挪那挪完之后要不要填 0填在哪里填 0 的位置是不是会遮挡住还没扫描到的元素一旦开始纠结这些问题你就知道为什么需要第二个指针了。1.1 双指针的“双”到底是怎么分工的这道题里的双指针不是像“首尾夹逼”那样一个在前一个在后而是一快一慢方向一致。先记住一个口诀慢指针守位快指针探路。快指针我们叫i负责往前走逐个检查每个位置。慢指针我们叫j负责记录“下一个非零元素应该放置的位置”。这两个指针之间形成了一个“窗口”窗口里的内容是我们已经处理过、并且确定是零的那些元素。快指针跑得比慢指针快快指针扫过的区域是“已检查区”慢指针和快指针之间的区域是“零缓存区”慢指针之前是“已整理区”。这个视角一旦建立后面的代码写起来就非常顺。还有一个理解方式更直接你想象自己在整理一排书架要求把所有的空箱子挪到最右边但书之间的顺序不能变。快指针就是你从头到尾一本一本检查的目光慢指针则是你手里的“下一个放置位”标签——你看到非空的书就搬到标签指的位置然后把标签往后挪一格。1.2 为什么不能直接用冒泡式的相邻交换有的同学会想那我碰到相邻的“非零 零”就交换一路交换下去把零像冒泡一样慢慢冒到后面不是也能完成吗能完成但性能不行。最坏情况下一个非零元素要跟后面的零逐位交换很多次整体复杂度会退化到O(n²)。题目的数据规模虽然不大但“尽量减少操作次数”这道旨意评委是想看到一次遍历就搞定的解法。而且冒泡交换的代码写起来并不比双指针短逻辑却复杂得多——你要考虑交换完之后的指针移动要考虑连续多个零的情况写出来的代码很容易下标越界。别和 O(n²) 的解法较劲这道题的价值就在于训练你跳出“交换”思维转为“覆盖”思维。2. 双指针解法的完整推演从“零先攒着”到“末尾补零”双指针解法之所以优雅核心思路在于一个非常朴素的事实我们关心的是非零元素的相对顺序至于零放在哪只要最终都堆到末尾就行。所以我们可以分两步走把数组里所有的非零元素按顺序一股脑儿“压缩”到数组的前面去。数组剩余的部分全部填成 0。这两步用同一个循环就能完成因为快指针在扫描的过程中就已经完成了“压缩”慢指针停下来的位置就是需要填零的起点。2.1 核心代码逻辑逐行拆解先上代码这个版本我自认为是“最舒服”的没有多余的 swap没有土的判断public void moveZeroes(int[] nums) { // j 是慢指针永远指向下一个非零元素应该放的位置 int j 0; // i 是快指针扫描整个数组寻找非零元素 for (int i 0; i nums.length; i) { // 遇到非零元素把它赋值到 j 的位置 if (nums[i] ! 0) { nums[j] nums[i]; // 如果 i 和 j 位置不同说明 i 位置原本应该被置零 if (i ! j) { nums[i] 0; } // 非零元素放好后慢指针前进一位 j; } } }这段代码有一个很关键的细节我用的是“覆盖 清零”而不是“交换”。如果完全套用“swap交换”的模板if (nums[i] ! 0) { int tmp nums[i]; nums[i] nums[j]; nums[j] tmp; j; }二者效果一样但“覆盖 清零”的效率更高少了一次中间变量赋值的操作并且在理解上更贴合“快慢指针”的语义——快指针负责找慢指针负责存。那为什么还要加if (i ! j)这个判断因为当数组本身就没有 0比如[1, 2, 3, 4]或者非零元素顺序已经是紧凑排列i和j会一直相等。这种情况下如果直接nums[i] 0会把没扫描过的区域提前清零造成元素丢失。这个判断一旦漏掉[1, 2, 3, 4]跑一遍就变成[1, 2, 3, 0]直接判错。所以这个if (i ! j)不是性能优化是正确性保障一定要记住。2.2 模拟一遍完整执行过程看懂指针怎么跳舞拿力扣示例来手动跑一遍nums [0, 1, 0, 3, 12]初始状态j 0快指针i 0步骤i指向值操作数组状态执行后ji00跳过不做处理[0, 1, 0, 3, 12]0i11赋值给nums[0]且i≠j将nums[1]清零[1, 0, 0, 3, 12]1i20跳过[1, 0, 0, 3, 12]1i33赋值给nums[1]且i≠j将nums[3]清零[1, 3, 0, 0, 12]2i412赋值给nums[2]且i≠j将nums[4]清零[1, 3, 12, 0, 0]3最终结果[1, 3, 12, 0, 0]。完全正确。注意看 i4 那一步虽然 nums[4] 是 12但我们把它赋值到 nums[2] 后原位置立即清零保证不会残留重复数据。自己手跑一遍比看十遍讲解都有用强烈建议你在草稿纸上走一遍这个过程尤其是连续多个零的情况比如[0, 0, 1]、[1, 0, 0, 2]这类用例能检验你对边界条件掌握是否扎实。2.3 这个解法的时间复杂度和空间复杂度为什么是完美的时间复杂度O(n)快指针i从 0 扫到数组末尾只遍历一次每个元素只被访问一次。空间复杂度O(1)只用了两个整型变量作为指针没有额外数组或容器。有人说这就是这道题的“标答姿势”——在算法面试里O(n)时间 O(1)空间的组合就是你能给出的最优复杂度了。面试官拿到这个答案至少不会在复杂度上刁难你。还有人会问那这个解法的“稳定性”体现在哪里其实非零元素保持原有相对顺序就是这种“稳定性”的直接体现。如果题目改成“把零放前面”写法大同小异只是非零和零的身份对调了而已。3. Java 工程师写法里的那些细节不止是“能跑”更要“耐读”很多刷题的同学习惯把代码写得又短又炫比如一行流、函数式编程搞定。这在刷题娱乐时没问题但在面试或工程代码评审面前可读性 炫技。力扣283这道题的代码我最推荐的结构就是上面那版变量名清晰、流程直白、逻辑无跳跃。3.1 为什么快指针用 for慢指针用 int 声明在外部快指针i的生命周期就是整个循环过程用 for 循环天然合适慢指针j的值需要跨越循环保存下来循环结束时要用来定位填充零的起始位置所以声明在循环外面。这种“生命周期决定声明位置”的习惯是写任何算法代码时都应该遵循的。还有一个小细节命名上不要用 a、b、c 这种无意义缩写也不要叫 l、r虽然双指针常见 left/right但这里语义不是左右夹逼而是快慢。我见过很多同学把这里写成fast和slow这反而比i和j更容易让人理解。如果要提交到代码评审建议改成fast和slow如果是面试手写用i、j也没问题只要你先说明前面的指针是干嘛的、后面的指针是干嘛的。3.2 绕开“零在开头”和“零在结尾”的两种极端用例这道题的常规边界用例有两个数组以非零开头且中间没有零[1, 2, 3, 4]。这时代码里每次i j只做nums[j] nums[i]不做清零。如果不加判断数组会越改越错。数组以零结尾[1, 2, 0]。这时代码里快指针扫描到最后一个位置发现是零不做任何操作正确。但要注意慢指针j此时停在了 2 的位置说明数组前两位已经是非零后面一位不用填零因为本来就是零。还有一个极端情况数组长度为 0 或 1。nums.length 0时循环体不执行直接返回nums.length 1时只有一个元素不管是零还是非零都不用动。代码天然正确但作为工程习惯你可以在开头加上一个早退保护if (nums null || nums.length 1) { return; }这不是刷题的必备操作但能体现你写工程代码时的防御性思维——我加的每一行判断都是在避免不必要的后续计算。3.3 为什么这道题的变体全都离不开双指针力扣里有些题目看起来完全不相干但骨子里几乎是同一套思路力扣26题“删除有序数组中的重复项”同样是一个快指针扫描、一个慢指针记录下一个有效位置只是判定条件从“非零”变成“当前元素不等于前一个有效元素”。力扣27题“移除元素”给定一个 val把数组中所有等于该值的元素移除。快慢指针的组合一模一样只是判定条件变成“不等于 val”。力扣283题本身移动零本质上是“移除0元素”加上“末尾补零”两步。这几道题刷熟了你会形成一种肌肉记忆凡是“原地调整数组顺序”的题第一反应应该就是双指针。后面遇到更复杂的同类型题比如“把奇数移到偶数前面”剑指 Offer 21也逃不出这个框架。4. 编码时最容易翻车的 5 个现场和排查方案这部分是我实际写这道题时踩过的坑外加帮别人复盘时看到的常见问题整理成一份“排雷手册”。4.1 翻车现场一交换后导致原始数据丢失结果出现重复数字或凭空少数字症状运行结果里出现了两个 3却少了一个 12原因是直接套用“覆盖”逻辑但没有处理掉原位置的残留。排查思路在循环里加一行System.out.println(Arrays.toString(nums))观察每次赋值前后的数组变化。如果发现 i 和 j 指向同一个位置时原位置被错误的清零或者被覆盖多半是没有判断i ! j。我见过的一个高频错误写法是这样的for (int i 0; i nums.length; i) { if (nums[i] ! 0) { nums[j] nums[i]; } } for (; j nums.length; j) { nums[j] 0; }这个写法第一遍遍历只赋值、不清零第二遍从 j 的位置统一填零 —— 其实也是对的。但它需要循环两次操作次数比“边扫边清”多了一倍。如果数据量大的题这种写法排在后 10%面试官可能会提示你“简化操作”。所以我的建议是能一遍完成的坚决不写两遍。4.2 翻车现场二数组越界症状运行时报ArrayIndexOutOfBoundsException集中在nums[j] nums[i]这一行。排查思路想一想 j 在最极端的情况下会跑到哪里。如果数组全是零j 动都不动安全。如果数组全非零j 和 i 始终保持同步i 到末尾时 j 也到末尾安全。真正会越界的情况是你在某个地方多写了j或者在条件里写成了num[j]而忘记递增。这类问题用最笨的方法检查在 j 做自增之前打一条 print输出当前 i 和 j 的值。4.3 翻车现场三非零元素的相对顺序被破坏了症状输出结果是[1, 3, 12, 0, 0]但中间有个别元素顺序不对比如[1, 12, 3, 0, 0]。排查思路出现这种情况几乎都是因为你采用了“从后往前扫”的策略或者用了首尾指针相向交换。你是不是觉得“既然零要往后放那我从后往前走碰到非零就跟前面的零交换”这个思路在“保持顺序”的条件下是行不通的因为后往前扫会颠覆顺序。一个简单规则只要题目里出现“保持相对顺序”五个字就不要首尾交换老老实实用同向快慢指针。4.4 翻车现场四空指针 / 判题超时如果直接拿题目的空数组、只有一个零的数组去跑一般不会超时。但如果你的写法里用了Arrays.sort或Collections.sort复杂度就会带上 O(n log n)在数据量增大的时候超时风险明显上升。这题完全不需要排序如果你潜意识里用了排序说明题目解析还不够透彻——排序本身会破坏相对顺序和题意相悖。4.5 翻车现场五手忙脚乱临时用“辅助数组”有些同学看双指针一时半会想不通就转头写了个“新开一个数组把非零按顺序填进去再填零”的版本。这不叫错但不符合“原地修改”的限制。用一个辅助数组空间复杂度就变成了 O(n)面试的第一轮追问就扛不住。如果你真想用辅助数组先跑通用例来“验证逻辑”可以把它当作草稿纸但最终一定要落实到原地修改——这点我会在下文“课后扩展”里专门给出一条从辅助数组迁移到双指针的思路路径。5. 面试追问进阶如果面试官加码你要怎么接招力扣283是高频热题但光会标准解法的人太多了面试官如果想筛人通常会追加几个变种问题。下面是我经历或者听说的真实追问提前捋一遍面试不慌。5.1 “你能把非零元素的相对顺序打乱吗”这个问题背后是“这道题的题眼到底在哪”。答案是不能一旦打乱顺序这个解法就不成立而且更严格来讲题目明确要求保持顺序。如果面试官问你“如果不要求保持顺序呢”那解法会大幅简化变成首尾交换不断把末尾的非零与开头的零交换但那个方向就不是这题的本意了。由此你能感受到“保持非零元素的相对顺序”就是整道题的主心骨一切解法都要优先尊重这个约束。5.2 “如果数组里不光有 0还有其他需要‘过滤’的无效值呢”这其实是把力扣27题的思路嵌套进283里。你可以把“非零”抽象成一个谓词Predicate比如“不等于 target”双指针的框架依然适用。如果你平时写代码有抽象意识甚至可以写一个通用的“partition”函数。这块在面试中属于“举一反三”的加分项不做要求但想到了很出彩。5.3 “你能证明你的解法在每个元素上只操作了一次吗”证明思路快指针 i 遍历一次每个元素只会成为一次nums[i]慢指针 j 在非零时递增每个元素也只会被赋值一次或被清零一次。整体是每个元素常数次操作所以是 O(n)。这种证明能力在高压面试里很珍贵提前准备好能帮你建立起“信得过、讲得出”的形象。5.4 “这个解法在内存和 CPU 缓存上表现好在哪里”此题数组很小讨论缓存有点小题大做。但如果你能说出“顺序访问空间局部性好不会乱跳内存”这样的答案面试官会觉得你具备一定的系统级素养。对 Java 工程师来说能意识到“代码执行背后的内存访问模式”已经超越绝大多数把刷题仅仅当作“玄学背诵”的候选人。6. 从这道题延伸出去刷题的价值不止是“背答案”我知道很多人刷题是“背模板”见三数之和就直接三指针夹逼见二叉树的题就递归加一个回溯。这种靠记忆的刷法换一道新题就失效。但如果你把283题当成一块跳板顺着“同向双指针”这条线往下走会发现它干净利落地串起了一大批数组题而且每一道的解决思路都共享同一个骨架。这就是一种“结构思维”不是背代码而是识别代码背后的结构。刷完283接着把26题、27题、283题三连击练一遍你会发现自己的写题熟练度提高得特别快。之后再做80题“删除有序数组中的重复项 II”这类“允许保留两个重复项”的加码版也能很快拆解无非是把慢指针的“放置条件”从“遇到非零”改成“当前元素不比上上个有效元素大”其他结构不变。6.1 配套练习建议和刷题顺序我建议的顺序是先做27题“移除元素”熟悉快慢指针的基本框架只需要赋值覆盖不需要末尾补零。再做283题“移动零”赋值覆盖 末尾补零多一个填充步骤。再做26题“删除有序数组中的重复项”比较相邻元素慢指针条件略变。最后做80题“删除有序数组中的重复项 II”作为区间挑战。这样做的好处是每一道题都在上一道题的基础上只增加一个变量难度爬坡非常平稳。我辅导过不少零基础的朋友用这个顺序刷下来基本都能自己推导出解法而不是“背答案”。6.2 一道题的五个层次按照“刻意练习”的说法一道题可以分五层来吃透第一层能 AC。第二层能手写代码不需要编辑器提示。第三层能讲清楚思路含复杂度推导面试官追问时能应对。第四层能写出两个以上不同思路比如覆盖法 vs 交换法并对比它们的性能差异。第五层能联想到一类题并总结出共性模板。如果刷题时间是1小时我会建议这样分配AC 只需要20分钟剩下的40分钟用来走完后面四层。这比一小时连刷三道“一次性AC后面就忘”的题收益要高得多。这也是为什么我每次分享力扣题解都要花大篇幅讲“为什么”而不是只贴一段代码。7. 一个推荐版本可直接粘贴提交最后把我个人最常用的版本整理在这里包含防御式判空和单次遍历逻辑可以直接作为练习或面试手写的基准模板public void moveZeroes(int[] nums) { if (nums null || nums.length 1) { return; } int slow 0; // 慢指针指向已整理区末尾 for (int fast 0; fast nums.length; fast) { if (nums[fast] ! 0) { if (fast ! slow) { nums[slow] nums[fast]; nums[fast] 0; } slow; } } }如果你更习惯“先覆盖、末尾统一补零”的版本也可以但要注意整体操作数会稍多面试时讲复杂度不受影响只是实现习惯不同。我自己的习惯是上面这一版只遍历一次且数组本身没有零时零额外操作纯无零数组跑起来是零拷贝只试探不做无用功。另外一个小建议提交到力扣前养成自测用例的习惯。至少覆盖下面这五种情况就能最大程度规避手误全是零[0, 0, 0, 0]全是非零[1, 2, 3, 4]零在开头[0, 1, 0, 3, 12]零在末尾[1, 2, 0]只有一个元素[0]我在实际调试中发现覆盖了这五种用例后这道题基本不会再有隐藏bug。这个“五种用例自测法”不只是用于这一道题你可以把它推广到所有数组类的简单题上养成习惯后提交错误率会肉眼可见地降下来。双指针这层窗户纸捅破之后你会发现它不仅仅是算法题里的一个技巧更是日常代码里处理数组区间、流式数据处理时经常用到的一种思维方式——写多了你自然会有这种感觉某些位置上的数据是“作废”的某些是“有效”的指针就是你在这些状态之间的索引坐标。希望这篇详解对你有用后面再遇到同向双指针的题你会觉得熟悉又轻松。