资讯详情

双指针经典题:力扣283移动零的原地解法与Java实现

📅 2026/10/7 16:51:35 | 华诺云谱 👁 阅读
双指针经典题:力扣283移动零的原地解法与Java实现
刷算法题的朋友对力扣283“移动零”肯定不陌生——给定一个数组nums把数组里所有0移动到末尾同时保持非零元素的相对顺序不变并且只能在原数组上操作。题目看着简单但面试里出现频率极高因为它在最简单的外衣下藏着两个重要考点一个是“原地操作”的约束另一个就是“双指针”这类线性扫描技巧。我最早做这题时脑子里只有“边遍历边删除零再末尾补零”这种笨办法代码写得又臭又长后来把双指针思路彻底想通了发现这个题其实是一类题型的敲门砖。今天就把我踩过的坑、总结的套路一次性讲清楚适合刚接触双指针的新手也适合准备Java面试的朋友作为复习笔记。1. 题目解构移动零到底在考什么1.1 需求再翻译一下原题描述很短但信息密度很高输入一个数组nums例如[0,1,0,3,12]要求把数组“原地”改成[1,3,12,0,0]所有非零元素要靠前所有零元素靠后非零元素之间的相对顺序不能变。“原地”这两个字是核心约束意思是你不能new一个新数组然后遍历填进去。很多新手第一反应是“那我先把非零挑出来再拼上0不就行了”——能跑但面试官会追问“空间复杂度呢”如果用了额外数组空间复杂度就是O(n)题目明确要求原地操作本质上是希望你在O(1)的额外空间里完成。“保持相对顺序”这个条件也值得圈出来。如果题目不要求相对顺序那我们可以用更暴力的左右双指针方式把零和非零互相交换但一旦要求“非零元素保持原有顺序”指针移动的策略就要变了。力扣283正是用这个条件把你逼到“同向双指针”这条路上来。1.2 为什么这题是“双指针”经典双指针不是一个具体的算法而是一类处理数组/链表问题的技巧。最常见的有两种碰撞指针左右指针一个从左边走一个从右边走向中间相遇常用于有序数组、回文判断、两数之和等场景快慢指针同向指针两个指针都从左侧出发一个走得快遍历全数组一个走得慢指向下一个待覆盖的位置常用于原地修改、去重、链表圈检测等场景。移动零就属于典型的快慢指针场景。慢指针划定“已处理区域”的边界快指针负责遍历所有元素找到可用的非零元素往前“放”。整个过程只需一趟扫描时间O(n)空间O(1)简洁得几乎让人怀疑——怎么这么简单但真的自己写的时候经常栽在指针初始化和赋值顺序上。2. 双指针算法核心思路两个指针怎么配合2.1 慢指针与快指针各自的责任我习惯把这题的双指针理解为“两个工作人员在整理一排货架”慢指针left指向“下一个非零元素应该放的位置”快指针right负责把整个货架从头到尾检查一遍看看有没有“非零货品”需要挪到前面来。初始时left 0right 0。然后right在for循环里从0遍历到n-1如果nums[right] ! 0说明找到了一个非零元素我们要把它放到left位置如果left right说明当前元素本来就在正确位置上不需要动如果left ! right就把nums[right]的值赋给nums[left]然后left前进一位如果nums[right] 0什么都不做right继续往下走。这个过程中left走过的区间是“已经排好序的非零区”left和right之间的区间是“已经被扫描但还没处理/或者全是零的区域”right之后是“尚未扫描的区域”。整个数组被分割成三个部分这个认知模型非常重要很多变体题比如删除排序数组中的重复项、移除元素都能套同一套模型。2.2 为什么这个解法不会丢失元素先看一个最容易被质疑的点“直接把nums[right]赋给nums[left]会不会覆盖掉原来在left位置上的非零元素”这就要看left指针的性质了。因为left一定小于等于right而left之前的所有位置都已经被前面扫描到的非零元素填好了left指向的位置要么本来就是非零元素自己left right的情况要么是已经被“掏空”的零位置。为什么是零位置因为我们前面把所有非零元素都往左挪了所以只要某个位置曾经被挪走元素它留下的坑就会被赋成0或者被标记为无效而left指向的正是这样一个“空闲位置”所以覆盖它不会影响未处理的数据。这也就解释了为什么很多人写的版本里会有“赋值后再把原位置清零”这一步。实际上用覆盖法时最后需要把数组尾部补零而用交换法时left和right交换后right位置自然变成了0。两种写法殊途同归但覆盖法的赋值操作更少更适合Java这种需要手动管理数组的语言。3. Java代码实现与细节剖析3.1 标准双指针写法覆盖补零我日常最推荐的是这一版逻辑清晰不容易出错public void moveZeroes(int[] nums) { if (nums null || nums.length 0) { return; } int left 0; // 下一个非零元素要放置的位置 for (int right 0; right nums.length; right) { if (nums[right] ! 0) { // 如果两个指针不同就需要把右边非零值覆盖到左边 if (left ! right) { nums[left] nums[right]; } left; } } // 此时 left 及之后的位置都应该是 0 while (left nums.length) { nums[left] 0; left; } }这段代码有三处关键细节判空if (nums null || nums.length 0)一定要放在最前面。力扣的测试用例不会传null但面试手写时防御性编程是加分项至少说明你想到了边界。left ! right的检查这一步不是必须的即使不检查直接赋值执行结果也正确自己赋给自己无害。但加上这个判断可以避免无意义的自赋值在极端情况下比如数组几乎没有零能省下大量内存写操作。尾部补零遍历完成后left的左侧包括left本身都是非零元素从left到最后才算“原数组剩余的坑”这些坑必须全部填0。这个while循环很容易被漏掉漏掉之后的结果就是数组长度不变但后面残留了旧值或重复值。3.2 另一种写法交换法既然题目要求原地用交换也能实现而且省掉了“补零”那一步public void moveZeroes(int[] nums) { int left 0; for (int right 0; right nums.length; right) { if (nums[right] ! 0) { int tmp nums[left]; nums[left] nums[right]; nums[right] tmp; left; } } }交换法本质上和覆盖法是同一个思想快指针找到非零元素与慢指针所在位置交换慢指针后移。当left right时交换是自己交换自己其实也可以加个判断跳过但大多数情况没有必要的开销。交换法更容易被面试官理解因为它直观展现了“把零往后抛”的动作但涉及三次赋值操作覆盖法只做一次赋值从右往左末尾再统一补零。就我个人经验覆盖法的性能在Java里会稍胜一筹尤其数组很长且零很多时可以减少大量内存写入。但交换法的可读性更好。如果面试时没有特别说明我通常先用交换法讲思路再补充覆盖法的优化版本。3.3 为什么不用List或System.arraycopy有些朋友喜欢把数组转成ListInteger然后用remove删除零最后add到末尾。这在功能上没问题但有几层额外的代价自动装箱int[]转成ListInteger每个元素都会装箱成Integer对象产生大量GC压力删除元素是O(n)的移动删除k个零就是O(n·k)额外使用了O(n)的容器空间。力扣判题时虽然数据规模不大可能超时但面试官看到这种写法基本会直接问“能不能O(1)空间”然后话题就往双指针带了。所以既然这题训练的就是双指针就别绕开核心考点。4. 复杂度分析与面试追问4.1 时间和空间复杂度怎么算时间快指针right遍历整个数组一次慢指针left最多也遍历一次两个循环叠加在一起整体是O(n)。即使覆盖法后面还有补零的while循环补零的次数等于零元素的个数不会超过n所以总时间依然是O(n)。空间只额外使用了两个int变量O(1)。为什么这题的复杂度讨论几乎是送分题因为它只有一层for循环没有嵌套循环也没有递归。但面试官可能会接着问“如果数组特别大放不下内存只能用流式处理你会怎么改”这种问题属于大数据场景的扩展可以在流式处理时用类似“原地压缩”的思想逐个读取非零元素写入输出区但这已经属于分布式、外部排序的范畴了力扣上一般不会深入。4.2 面试官爱追问的变体移动零不是独立题目它的变体很多面试官特别喜欢在此基础上“加码”变体1把指定值k移动到最后如果数组里有多个等于k的元素要求把不等于k的元素保持原顺序移到前面等于k的移到后面。解法完全一样快指针找不等于k的元素慢指针维护放置位置。这其实就是力扣27“移除元素”的翻版区别只是移除后要不要把末尾填成k。变体2把所有奇数移到前面偶数移到后面这个通常不要求保持顺序所以可以用碰撞指针左指针找偶数右指针找奇数找到后交换直到左右指针相遇。时间复杂度O(n)空间O(1)但非零元素的相对顺序会改变不适合原题。变体3字符串压缩去重比如把字符串里连续重复的字符压缩成“字符次数”或者去掉所有空格这些问题的本质都是“同向双指针维护有效区”。我在做这类题时只要看到“原地修改”“保持相对顺序”“去除/移动某类元素”这些关键字第一反应就是尝试套移动零的模板。4.3 复杂度对比表这里把常见解法摆在一起做个对照方便大家看明白为什么双指针值得写解法时间复杂度空间复杂度是否原地是否稳定保持顺序适用场景新建数组拷贝O(n)O(n)否是不要求原地时代码简单List配合remove/addO(nk·n)O(n)否是数量小时可接受覆盖补零双指针O(n)O(1)是是推荐通用性强左右碰撞交换O(n)O(1)是否不要求相对顺序时看完表就明白双指针是用O(1)空间换时间还保留了稳定性这也是它成为“标准答案”的根本原因。5. 实操过程手写一遍从零到AC5.1 在力扣实时提交时我踩过的坑第一次写这题我用了“新数组统计零”的思路很快就AC了因为力扣判题组不会管你是否用了额外空间但看了官方题解的“进阶”提示才意识到自己没get到点。后来改成双指针我连续提交了三次才做到一次通过问题都出在一些不起眼的细节上。第一个坑只判断了nums.length 0没判断nums null。结果在本地测试传null时抛了NullPointerException。虽然力扣测试不会传null但养成习惯总没错。第二个坑交换法里忘了在left right时跳过交换导致自交换时tmp完全多余性能不是问题难看是真的。后来我加了个条件if (right left) { 交换 }。第三个坑覆盖法里末尾补零时我用的是Arrays.fill(nums, left, nums.length, 0)结果导了java.util.Arrays还记错了参数顺序刷题平台的代码区可没有IDE提示手写时极易出错。所以我建议新手老老实实写while循环补零虽然代码看起来多一点但不会因为IDE不提示而出糗。5.2 断点调试观察指针变化如果你在IDE里逐步走一遍观察指针变化会非常直观。拿[0,1,0,3,12]举例用交换法初始left0, right0nums[0]0rightright1nums[1]1交换nums[0]和nums[1]数组变成[1,0,0,3,12]left1right2nums[2]0跳过left不动right3nums[3]3交换nums[1]和nums[3]分别是0和3数组变成[1,3,0,0,12]left2right4nums[4]12交换nums[2]和nums[4]分别是0和12数组变成[1,3,12,0,0]left3循环结束得到正确答案。注意看每次交换后left左侧全是非零left到right之间全是零right右侧全是未检查——这就是“三区间”的直观体现。我之前写这篇博文时把这三段区间画成[非零区 | 零区 | 未知区]一下子就把同向双指针的本质讲明白了。5.3 压测极端用例除了示例我用这几个用例来验证自己的代码[0,0,0]全程没有非零元素快指针一直跳过最后补零循环把整个数组设成0。结果是[0,0,0]正确。[1,2,3]快指针每次发现非零且leftright交换法每次都自交换覆盖法则不赋值直接left。结果不变正确。[]直接返回正确。[0]single zero和全零类似正确。[0,1]第一个是零第二个非零交换后[1,0]正确。能把这五个用例跑过基本就稳了。尤其在“全非零”数组里覆盖法的效率优势最明显——它一次赋值都没有只是把left从0挪到末尾。6. 常见问题与排查技巧实录6.1 为什么我的结果末尾不是零这是最常见的bug。如果你用的是“只赋值、不补零”的版本遍历完成后left之后的位置还是原来的旧值不会自动变成零。比如数组[2,0,3]只赋值不补零处理结果是[2,3,3]末尾的3是重复的零并没有被移过来。解决办法就是在遍历结束后显式把从left到数组末尾的所有元素赋零。记住覆盖法必须补零交换法不需要这两者别混用。我曾经把覆盖法的左指针写成int left 1然后尾补零从left开始结果第一个元素没有被检查全错。左指针必须从0起步因为它指向的是第一个非零元素应该去的位置。6.2 快慢指针顺序写反会怎样如果循环里写的是“当nums[right]0时把nums[right]赋给nums[left]”那你就变成把零往左堆了执行结果会得到所有零在前面非零在后面和题目要求正好相反。这个错误很反直觉因为很多人在潜意识里把“移动零”理解成“抓住零往右扔”但双指针的解法的本质是“非零往左挪”。我建议你记住一句话快指针永远在找非零元素慢指针永远在指空位。6.3 关于Java中传参的误区有朋友会问“Java方法参数是值传递我在moveZeroes里修改nums外面拿到的是修改后的数组吗”这个问题的答案是“能”。因为nums本身是个引用虽然引用是值传递但引用指向的是堆上同一个数组对象。你在方法里执行nums[index] value修改的是这个数组对象的内部状态调用方当然能看到。如果你在方法里重新执行nums new int[...]那确实不会影响外部变量——但在做题时我们不这么干因为这样会丢失原有数据。有个更隐蔽的坑力扣的判题系统不会检查你返回什么它只检查你传入的数组是否变成了预期结果。所以方法返回类型是void你千万别画蛇添足写个返回值万一写成了public int[] moveZeroes(...)且返回了新数组就违背了“原地”要求。6.4 从“AC”到“最优”的个人体会我想多说一句力扣283虽然简单但它背后代表的是“原地快慢指针”这一类思想。我记得自己从这道题里悟出的一个重要经验是——“避免重复搬运”。新数组法虽然直观但每个非零元素被拷贝了两次一次拷到新数组一次补零到末尾双指针法每个非零元素最多被移动一次。这个思想在操作系统、数据库、文件压缩等领域都有变体比如日志文件的compact也是扫描一遍把有效的记录挪到前面废弃的部分留到末尾等回收。理解了这种精神就不难理解为什么大厂面试总爱拿这种题作为“热身”——它考察的不是你会不会写那段for循环而是你有没有“空间敏感”的工程意识。6.5 一道配套练习题看完这篇解析你可以立刻去力扣做“27. 移除元素”给定数组nums和一个值val原地移除所有等于val的元素返回新长度。解法与283几乎一样只是把“非零”换成“不等于val”并且不需要补零。做完这题再顺手做“26. 删除有序数组中的重复项”你会发现鸭不用会双指针模板的肌肉记忆就形成了。我在刷这些题时会在笔记里写同一套伪代码left 0 for right in range(n): if 满足保留条件: 写入/交换到left位置 left之后遇到“移动字母”“移动负数/正数”等变形题就会条件反射式地把这个模板套上去效率高到我自己都意外。7. 扩展思考这题对Java工程师的特殊意义7.1 高频Java面试八股相关性移动零这类题近年来频繁出现在Java岗面试中尤其是笔试环节。为什么Java面试官偏爱这种题因为Java程序员日常处理数组、列表的机会非常多而且ArrayList底层就是数组remove操作的本质也是元素搬移。如果能理解双指针的搬移思想那么你在理解ArrayList的add、remove、trimToSize时会有一层更深的认识。比如ArrayList.remove(int index)的实现它会调用System.arraycopy把index后面的元素整体前移一位然后把最后一个位置置空。这和覆盖法“把非零元素往左挪”的思路一模一样。所以做这道题的时候你其实是在模拟一个简化的ArrayList内部机制只不过自己写for循环比arraycopy更底层更可控。7.2 从题目延伸到工程实践我在工作中真正用到这个思路是处理一个“用户日志去重”的需求有一个数组保存了当天的用户ID部分ID为0代表匿名用户需要把匿名用户全部排到数组尾部并且后续系统只处理前面非匿名的ID。当时第一时间想到的就是移动零的解法直接写了个双指针搞定没有new新数组也没有用Stream过滤再拼接因为日志数组可能有上百万长度每次请求都new一个大数组对GC压力很大。还有一个场景是数据库的分页参数清洗一个前端传来的数组里可能有非法占位符0我们需要把合法参数左移然后在程序里只遍历有效长度。这种“原地整理缩短有效区”的应用几乎就是力扣283的翻版。所以别小看这道题它的核心思想真的会跟着你好几年。7.3 写在最后的小技巧如果你准备在面试中把这个题答出彩我建议你按这个顺序表达先说最直观的“新数组”方案并主动承认空间复杂度是O(n)再引出“原地双指针”方案强调快慢指针的含义写上代码后主动用边界用例全零、全非零、空数组过一遍最后提一句复杂度O(n)/O(1)并说一下如果题目不要求保持相对顺序还可以用左右交换实现。这一套下来面试官基本就给你打上“有思路、有工程意识、基础扎实”的标签了。我当年面试时就是靠这个题从“待定”聊到了“通过”后来复盘发现其实不是这个题多难而是我把“为什么用双指针”讲透彻了面试官觉得我不是背题是真懂。对我来说力扣283不只是刷题列表里一个绿色对勾它是理解数组原地修改的一把钥匙。希望你也能通过这篇解析真的把这道题吃透以后遇到任何“移动某类元素”的题目都能条件反射地迈出同向双指针的第一步。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑