
每天一题这个习惯我从开始刷题起就一直保留着。身边不少朋友问过“移动零这么简单到底有什么可讲的”其实越简单的题越藏得住东西。移动零在 LeetCode 上是第 283 题题目要求把数组里所有的 0 移动到数组末尾同时保持非零元素的相对顺序。听上去像送分题但它把“原地操作”“双指针”“空间复杂度 O(1)”这三个基础考点完美地揉在了一起既适合新手建立数组操作的基本功也经常被面试官拿来考察老手能不能写出无懈可击的边界处理。这篇文章我打算把这道题完整拆一遍从暴力解法为什么过不了到双指针两种写法的完整推导再到多语言实现和常见翻车现场一次性把“移动零”吃透。1. 移动零这道题到底在考什么1.1 先读懂题目里的三个限制先看原题描述给定一个数组nums编写一个函数将所有0移动到数组的末尾同时保持非零元素的相对顺序。请注意必须在不复制数组的情况下原地对数组进行操作。很多第一次刷题的人只记住了“把 0 挪到后面”这半句忽略了后半句。实际上题目短短两三句话里至少藏了三个考点保持非零元素的相对顺序这句话给操作加了一个很强的约束。你不能用排序、不能随意换位原来在后面的非零元素操作后依然要在后面原来的第一个非零元素操作后依然是第一个非零元素。这本质上是一个“稳定性”要求。不复制数组、原地操作这是空间复杂度约束。也就是说你最多只能用几个临时变量不能开一个新的数组去接收结果。换句话说额外空间必须是 O(1)。尽量减少操作次数这句话一般不会在题目描述里直接写明但在 LeetCode 的进阶说明里出现过。它是在引导你往一次遍历、最少赋值的方向思考。这三个条件组合起来才算完整定义了一道题。只看一个条件解法可能五花八门三个条件一起看基本就把“创建新数组法”“随便排序法”“逐步挪动法”全部否掉了。1.2 为什么“原地”是核心考点“原地操作”在数据结构题里的地位很特殊。它考察的不只是你会不会遍历数组而是你有没有理解“引用传递”“内存布局”和“额外空间”的真实含义。我习惯用一个生活化的类比整理书架的时候书架上的书就是数组元素0 可以想象成空盒子。题目要求你把空盒子全部推到书架最右侧同时保持书的原有相对顺序。如果允许你抱着一摞书放到地上再重新摆回去那太简单了大不了先复制一份清单再排一次。但“原地操作”的意思是你最多只能在手边腾一个临时位置剩下的移动都得在书架本身上完成。这个约束在真实工程里非常重要。处理大数组、大对象时每拷贝一次数据都要消耗内存和 CPU 时间。尤其在内存受限的嵌入式设备上、在大规模日志处理管道中、在频繁触发垃圾回收的语言环境里一次多余拷贝的代价可能比算法本身的复杂度还高。所以“原地操作”不是面试官故意刁难而是工程里的真实诉求。1.3 这种题在实际项目中能干什么你可能觉得“移动零”这种题就是纯刷题用但在实际开发里我确实遇到过类似的场景。举个例子表格组件需要把状态为“已完成”的数据行移到列表底部同时保持其它行的相对顺序不变。如果数据量不大直接filter concat就行const moved [...rows.filter(r r.status ! done), ...rows.filter(r r.status done)];但这会新建两个数组旧数组还在内存瞬间翻倍。在数据量达到几十万条的前端表格里这种写法会导致明显的卡顿和 GC 压力。这时候原地移动的思路就派得上用场了一次遍历把满足条件的元素移动到数组前段再统一处理剩余位置。再比如后端内存受限的场景一个线程池共享一个超大缓冲区里面存了很多待处理记录需要把无效记录统一挪到末尾并覆盖掉。这些需求本质上是“移动零”的工业版。理解这道题等于理解了一类真实问题的基础解法。2. 从暴力解法到原地解法的思维转折2.1 大多数人第一反应是什么我第一次做这道题的时候脑子里浮现的第一种写法简单粗暴开一个新数组先遍历原数组把非零元素依次丢进去再补上若干个 0最后把新数组的元素复制回原数组。function moveZeroes(nums) { const result []; for (const num of nums) { if (num ! 0) result.push(num); } while (result.length nums.length) { result.push(0); } for (let i 0; i nums.length; i) { nums[i] result[i]; } }这段代码在功能上完全正确传入[0, 1, 0, 3, 12]返回的也是[1, 3, 12, 0, 0]。而且它保持了非零元素的相对顺序。所以很多人提交之后会得到一个“通过”的结果。但面试官下一句永远是“你能不能不开新数组就在原数组上操作”2.2 一眼看穿暴力解法的硬伤暴力解法的硬伤不在于正确性而在于空间复杂度。上面这段代码使用了两个额外的东西一个result数组以及常量级的循环变量。result的长度和原数组一样所以额外空间是 O(n)。题目要求“不复制数组”这个解法在最开始就已经复制了一份明显不符合要求。面试官之所以会追问就是因为它过度使用了空间。另一个隐藏问题是“写操作次数”。暴力法里有两次复制一次是往result里 push一次是把result复制回nums。这意味着每个元素至少被写两次大数组场景下耗时翻倍。虽然时间复杂度都是 O(n)但常数项差异很大。我在实际面试时见过有人回答“反正时间都是 O(n)空间 O(n) 也没什么吧”。这个认知是危险的。如果面试题明确要求原地操作你还在用 O(n) 空间那就等于没读题。换个场景假如数组大小是 100MB你再复制一份内存直接翻倍很多服务器都扛不住。所以“原地”不是形式上的规则而是为了满足现实条件。2.3 双指针思路是如何一步步浮出水面的当你决定不用额外数组问题就变成了怎么在原数组内部把非零元素“压实”到前面可以这样想维护一个专门的“写入位置”从数组下标 0 开始。遍历数组时只要遇到非零元素就把它写到这个位置然后写入位置往后挪一格。如果遇到 0什么都不做继续往后看。这样遍历完一遍之后所有非零元素已经被按顺序放到了数组最前面但它们原来在后面的位置上可能还有一些残留的非零值需要被清成 0。这就是“覆盖”的思想用一个慢指针代表“下一个非零元素应该放的位置”用一个快指针代表“当前扫描到的位置”。一快一慢在同一个数组上移动这就是双指针的基础形态。再进一步想能不能不等到最后统一清 0而是在遍历过程中就把 0 往后“换”出去可以。既然慢指针指向的位置在放完非零元素之后就应该让位给 0那么遇到快指针指向非零元素时直接交换两个指针位置的元素就好了。这样一轮循环下来非零元素全在前面0 自然被交换到了后面。从“开新数组”到“覆盖再补零”再到“交换”这是一个典型的思维递进过程。每一步都比上一步少用一点空间、少做一点写操作而这就是这道题真正想让你体会的东西。3. 两种双指针写法手把手推导一遍3.1 两次遍历法先压实非零再统一补零两次遍历法可以理解为“覆盖 清零”两阶段。先看完整代码function moveZeroes(nums) { let write 0; for (let i 0; i nums.length; i) { if (nums[i] ! 0) { nums[write] nums[i]; write; } } while (write nums.length) { nums[write] 0; write; } }我们用[0, 1, 0, 3, 12]手推一遍全过程。初始时write 0。i 0nums[0] 0跳过什么都不做。i 1nums[1] 1把nums[1]赋值给nums[0]。数组变成[1, 1, 0, 3, 12]write变成 1。i 2nums[2] 0跳过。i 3nums[3] 3把nums[3]赋值给nums[1]。数组变成[1, 3, 0, 3, 12]write变成 2。i 4nums[4] 12把nums[4]赋值给nums[2]。数组变成[1, 3, 12, 3, 12]write变成 3。第一次遍历结束时非零元素已经被“压实”到前三个位置但数组末尾还残留着旧值。第二次遍历从下标write 3开始把剩余位置全部写成 0数组变成[1, 3, 12, 0, 0]。注意一个细节在第一次遍历里我们是在“覆盖”而不是“交换”。所以nums[3]被复制到nums[1]之后原来在nums[3]位置的 3 还存在直到第二次清理才被覆盖成 0。这正是为什么需要第二次补零步骤。这种写法的好处是逻辑非常清晰慢指针write一旦停下它右边的区域就是要统一处理的位置。坏处是每个非零元素可能被赋值两次零本身也要被再赋一次值写操作次数略多。3.2 一轮遍历法用交换把零顶到后面一轮遍历法更优雅也是官方推荐的高频写法。代码是这样的function moveZeroes(nums) { let slow 0; for (let fast 0; fast nums.length; fast) { if (nums[fast] ! 0) { [nums[slow], nums[fast]] [nums[fast], nums[slow]]; slow; } } }还是用[0, 1, 0, 3, 12]推演。初始slow 0。fast 0nums[0] 0跳过。fast 1nums[1] 1交换nums[0]和nums[1]数组变成[1, 0, 0, 3, 12]slow变成 1。fast 2nums[2] 0跳过。fast 3nums[3] 3交换nums[1]和nums[3]数组变成[1, 3, 0, 0, 12]slow变成 2。fast 4nums[4] 12交换nums[2]和nums[4]数组变成[1, 3, 12, 0, 0]slow变成 3。一次遍历结束结果是正确的。这个写法巧妙在slow左边始终是已经排好的非零区slow自身指向的位置要么是 0要么和fast指向同一个位置。因为如果slow指向的位置不是 0那说明前面的非零元素还没有填满前段但根据算法逻辑只要有非零元素被处理过slow就会递增所以slow左边的空位都会是非零值而slow指向的位置只有在“跳过零”的时候才会留在原地此时它大概率是一个 0。交换出去之后fast位置会被换上这个 00 就自然往后移动了。这也解释了为什么这个写法不会乱序因为每次遇到非零元素都是把当前看到的非零元素放到slow这个位置而slow是逐步递增的。第一个看到的非零元素放在下标 0第二个放在下标 1第三个放在下标 2天然保持相对顺序。3.3 两种写法的细节对比与复杂度账从复杂度的角度两种写法的时间都是 O(n)空间都是 O(1)只用了常数级别的临时变量。但“写操作次数”不一样这是很多人容易忽略的点。对比维度两次遍历法覆盖补零一轮遍历法交换遍历次数2 次1 次写操作次数约 n zeroCount 次赋值每次交换 3 次赋值共约 3 × nonZeroCount 次是否保持相对顺序是是代码直观程度容易理解适合新手需要多想一步更适合追求简洁极端情况下开销全零数组也要全部补零全零数组几乎零开销如果面试官追问“哪种更优”可以分情况讨论。数组里非零元素特别多时交换法可能做很多次无意义的交换比如数组全是[1, 2, 3, 4]每次slow fast交换的是同一个元素。更精细的写法可以加一句判断if (nums[fast] ! 0) { if (slow ! fast) { [nums[slow], nums[fast]] [nums[fast], nums[slow]]; } slow; }但加了判断后代码会丑一点收益也不大所以在实际刷题时我更倾向于不加。不过面试时主动提一句“这里可以做个小优化避免同位置交换”会是一个加分项。4. 代码实现多语言版本与边界测试4.1 JavaScript 实现与易错点JavaScript 版本的实现我在上一节已经给过了这里重点说易错点。第一如果你在浏览器控制台直接执行moveZeroes([0, 1, 0, 3, 12])不会看到返回值。因为题目要求在原数组上修改函数不需要 return直接修改入参就行。很多人习惯return result这不算错但要明确这是原地修改函数返回值可选。第二交换元素时很多人会写nums[slow] nums[fast]; nums[fast] nums[slow];这样写完全错误因为第二行会把第一行刚覆盖的值又赋回去。所以必须引入临时变量或者使用数组解构const temp nums[slow]; nums[slow] nums[fast]; nums[fast] temp;[nums[slow], nums[fast]] [nums[fast], nums[slow]];数组解构写法的好处是“同时取值”不会因为先后顺序出错代码也简洁很多。但要注意它的本质是创建了一个临时数组[nums[fast], nums[slow]]这在性能敏感的代码里可能有一点额外开销。不过在实际项目中这个开销可以忽略不计刷题时也完全能接受。4.2 Python、Java、C 各给一版Python 版本和 JavaScript 语法很像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 1Python 的多元赋值等价于 JS 的解构赋值右边先整体求值再赋值所以交换是安全的。如果写成nums[slow] nums[fast] nums[fast] nums[slow]同样会出错。Python 新手在这里翻车的概率极高我自己也曾经写过这种代码然后对着结果怀疑人生。Java 版本public void moveZeroes(int[] nums) { int slow 0; for (int fast 0; fast nums.length; fast) { if (nums[fast] ! 0) { int temp nums[slow]; nums[slow] nums[fast]; nums[fast] temp; slow; } } }Java 没有解构赋值只能用临时变量。另外注意方法签名是void直接修改传入的数组引用调用方会看到变化。C 版本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; } } }C 的关键点是参数必须传引用vectorint。如果不加传入的是整个向量的一份拷贝函数内部改得再开心外面一点变化都没有。这是 C 和 Java/JavaScript 最不同的地方。4.3 边界用例与极端数组的手推过程写完代码一定要把边界用例过一遍否则面试时就容易翻车。我整理了几个高频边界例子。空数组[]slow 0fast循环不会执行结果还是空数组正确。全零数组[0, 0, 0]交换法里fast每次看到的都是 0跳过数组不变正确。两次遍历法里第一次循环不赋值第二次循环会把所有位置写成 0看起来多余但结果正确。没有零的数组[1, 2, 3]每次fast都遇到非零每次都交换但slow fast交换的是自身数组不变正确。只有一个元素的数组[0]或[5]循环只有一次。前者跳过后者交换自身结果正确。开头和结尾都是零比如[0, 1, 2, 0]一次遍历后应该变成[1, 2, 0, 0]。可以用快速手推验算一遍。我自己还会额外测一个“密集零”的用例比如[0, 0, 1, 0, 0, 2, 0]。这样的用例能检验算法在多个连续零之间跳转时是否依然保持非零元素顺序、是否会把零全部挤到后面。实测交换法处理这个用例时slow在遇到 1 和 2 时才移动中间跳过的都是 0输出为[1, 2, 0, 0, 0, 0, 0]符合预期。5. 常见问题与排查技巧实录5.1 处理完发现数组长度没变却没补零这是两次遍历法最容易踩的坑。很多人写完第一段循环就以为结束了结果[0, 1, 0, 3, 12]变成[1, 3, 12, 3, 12]。原因很简单把非零元素复制到前面之后数组末尾仍然残留着旧值必须再写一段循环把write之后的位置全部清成 0。排查这类问题有个技巧在循环结束的位置打印整个数组看看。我第一次踩坑时花了十分钟才反应过来因为打印出来一看前面都对就是最后多出来的旧值让我疑惑。后来我总结出一个习惯把“覆盖”和“清零”分成两个阶段想逻辑就顺了。如果使用的是交换法则不存在这个坑因为交换过程中 0 已经被换到后面去了。所以从“不容易漏步骤”这个角度我更推荐交换法。5.2 JavaScript 里重新赋值形参为什么没用这是一个特别经典的 JavaScript 陷阱。有人会写出这样的解法function moveZeroes(nums) { nums nums.filter(x x ! 0).concat(nums.filter(x x 0)); }执行完函数外面的数组一点变化都没有。原因是 JavaScript 的函数参数是“按值传递的引用”nums在函数内部只是一个指向原数组的引用变量当你对它重新赋值时只是让这个局部变量指向了新数组原数组并没有被修改。正确的思路只有一种要么通过下标修改nums[i] ...要么调用splice、reverse、sort这类会修改原数组的方法。否则你在函数内部构建的“新数组”都带不出去。这个坑在我面试候选人的时候经常拿出来问问十个人至少有两个人会踩中。所以做这道题时时刻记住“必须原地修改传入的数组对象本身”。5.3 被追问“最少操作次数”怎么答这题有一句进阶要求是“尽量减少操作次数”。面试官可能会问你你的解法到底做了多少次写操作。以交换法为例每交换一次需要 3 次赋值temp、a、b假设非零元素个数是m最多交换m次所以最多3m次写操作。数组长度为n空间复杂度仍是 O(1)。以两次遍历法为例第一阶段最多写m次第二阶段补零写n - m次合计最多n次赋值。从这个角度看如果要求“尽量减少写操作次数”两次遍历法反而比交换法写得更少。因为交换法一次交换就要写 3 次覆盖法一次赋值只写 1 次。这是个很有意思的结论。面试官如果追问“哪种更好”你可以从两个维度回答如果目标是减少写操作覆盖法更优如果目标是减少遍历次数交换法更优。实际工程中写操作和遍历次数的权重取决于硬件特性通常减少写操作对缓存更友好但在面试里说清楚两者的取舍就已经很加分了。5.4 其他容易翻车的代码风格细节还有几个小细节都是我实际写代码时踩过或看别人踩过的。第一不要用for...of遍历数组的同时做splice修改。比如有人想“跳过本次删除的 0”结果splice改变了数组长度for...of却依然按原长度迭代导致下标错位或漏处理。正确做法是用传统的for循环配合下标或者在循环里维护自己的索引。第二不要试图用sort解决这道题。sort确实可以把 0 排到后面但要保证非零元素的相对顺序必须依赖稳定排序。而不同语言、不同引擎的排序稳定性并不一致比如某些环境下默认排序不稳定结果就可能出错。为了一个简单需求去依赖不可控的排序实现不是好做法。第三要注意函数命名和服务端语言类型的差异。在 Java 里刷题时方法名要严格保持moveZeroes在 Python 里一般用move_zeroes。虽然大小写不影响算法但面试笔试系统往往对方法名有硬性要求写错直接编译不过。这一点看似低级实际最容易在紧张的时候翻车。6. 从移动零延伸出去一鱼多吃的数组套路6.1 同一个双指针模板能解决哪些题移动零的核心套路可以抽象成一句话用一个“慢指针”表示结果区边界用一个“快指针”扫描原始数组满足条件的元素就写入/交换到慢指针位置。这个模板直接可以套到好几道 LeetCode 经典题上。比如第 27 题“移除元素”删除数组中所有值等于val的元素返回新长度。用同样思路function removeElement(nums, val) { let slow 0; for (let fast 0; fast nums.length; fast) { if (nums[fast] ! val) { nums[slow] nums[fast]; slow; } } return slow; }再比如第 26 题“删除有序数组中的重复项”数组已排序要求原地删除重复元素使每个元素只出现一次并返回新长度。注意这里比较的是相邻元素function removeDuplicates(nums) { let slow 0; for (let fast 1; fast nums.length; fast) { if (nums[fast] ! nums[slow]) { slow; nums[slow] nums[fast]; } } return slow 1; }还有第 80 题“删除有序数组中的重复项 II”允许每个元素最多保留两个只是把条件从“和前一个不相同”改成“和再前一个不相同”。你会发现只要掌握了“快慢指针维护结果区”的思想这些题完全是一通百通。6.2 变体思考移动其他元素、移动方向换一下如果把“移动零”做一个小改动变成“把所有非零元素移到前面把零移到后面顺序反过来”也就是从右往左扫描怎么改很简单把循环改成从尾部开始慢指针也从尾部出发遇到非零就交换。思路完全对称。如果题目变成“把负数移动到末尾正数保持在前面同时保持顺序”此时数组有三类元素零的模型就不够用了。你需要类似快排分区思想的三指针解法本质上还是在原地操作但条件更复杂。这也是一个很好的扩展训练方向。还有一个变体要求在移动零的同时把非零元素按降序排列。这时候双指针只是辅助核心你要引入排序思路不能仅靠一次遍历完成。这种变体题目看起来很吓人但其实是在考察你能不能把“分区”和“排序”两个目标拆开处理。我在实际面试中会建议大家刷完移动零之后顺手把 27、26、80 这三道题连着刷一遍。因为它们共享同一个模板连刷几道之后数组原地操作的肌肉记忆就形成了。结尾刷题的意义从来不是背答案。移动零这道题我前前后后写了不下几十遍写过 C 版本、Python 版本、JavaScript 版本还在真实业务里遇到过“把状态为 0 的行挪到表格末尾”的需求。每次重新读一遍题意都会发现一些新细节。如果这篇文章能帮你在面试或日常开发里省下几分钟那它就没白写。最后分享一个小技巧遇到数组原地操作的题先问自己三个问题——能不能用 O(1) 空间能不能只用一次遍历边界条件到底是什么这三个问题想清楚了一半的数组题就都好办了。