
有的人觉得算法题就是应付面试的八股背一背模板就过去了。但“移动零”这道题恰恰是那种看起来人畜无害、实际上把“数组原地操作”和“双指针”两个核心思想全部串联起来的经典题目。我刷了这么多数组题回头再看这一道依然认为它是入门者理解指针思想的最佳教材之一。如果你正准备算法面试或者刚学完数组想找点实战练手这篇文章应该能帮你在一个小时内吃透这道题并且顺手把相关变体也解决了。题目本身不复杂给定一个数组nums把所有0移动到数组的末尾同时保持非零元素的相对顺序并且要求原地操作不能使用额外数组。比如[0,1,0,3,12]处理完应该是[1,3,12,0,0]。难就难在“原地”和“相对顺序”这两个硬约束直接卡掉了最简单的“拷贝非零元素再补零”的思路。1. 题目解析与核心考点1.1 题目到底在说什么先别急着写代码我们把题面拆开看它其实藏了三个隐含要求。第一数组是就地修改的。你不能新建一个result数组把非零元素塞进去然后再把result赋给nums。很多语言里数组是引用传递你甚至可以nums[:] result这种“伪装”原地但这本质上还是用了额外空间面试官一眼就能看出来。真正的原地操作意味着空间复杂度要控制在 O(1)。第二非零元素的相对顺序不能变。比如原始数组是[1, 0, 2, 0, 3]处理后的1、2、3的相对位置必须保持1 → 2 → 3的顺序不能变成[3, 2, 1, 0, 0]。这一点特别容易被新手忽略很多人一开始想到的“从后往前删除零再append”顺序可能对但删除操作本身就是 O(n)根本过不了。第三结果是就地移动不是排序。题目没有要求非零元素升序或降序只要求“零都到后面去”。这其实是在暗示你这是一个基于分区的操作而不是基于比较的排序。很多人会绕进“排序”的死胡同用快排去排一遍答案是能出来但复杂度完全不匹配。1.2 题目真正考的是什么这道题被归类为“经典”不是因为题目难而是因为它能一次性考察三个层面数组下标操作的熟练度写nums[i]的时候脑子里有没有偏移量的概念边界条件会不会处理指针/索引思维双指针是算法里非常常见的优化手段通过一个慢指针维护结果区间快指针负责探路很多中等甚至困难题都是从这种思路衍生出来的。空间复杂度的敏感度很多人写代码不关心内存能用O(n)就不想O(1)。这道题直接把“额外空间”这个口子堵死逼你在原数组上做文章。在面试场景下这道题往往是“热身题”。面试官看你写这道题的速度、边界处理、代码风格就能大致判断出你的基本功。我见过不少候选人思路是对的但代码写出来要么越界要么漏掉全零数组的情况要么while循环写成了死循环——所以它真的是一块很好的试金石。2. 思路对比从暴力到双指针2.1 方案一两次遍历覆盖法先来说一个最容易想到、也最容易写出来的方案先遍历数组把所有非零元素按顺序搬到前面剩下的位置全部补零。思路是这样的用一个指针j维护“下一个非零元素应该放的位置”第一趟遍历时遇到非零就把值赋给nums[j]然后j。当第一次遍历结束j的值就是非零元素的个数。第二步从j到数组末尾全部赋值为0。def moveZeroes(nums): j 0 # 第一遍非零元素全部往前挪 for i in range(len(nums)): if nums[i] ! 0: nums[j] nums[i] j 1 # 第二遍后面的位置全部补零 for i in range(j, len(nums)): nums[i] 0这个方案的时间复杂度是 O(n)空间复杂度是 O(1)完全符合题目要求。很多人可能会问这不是已经挺好了吗为什么还有第三种方案答案是覆盖法有一个缺点——它改变了非零位置之外的元素值。虽然在这里不影响最终结果但如果你想保留“每个位置元素的身份信息”或者想把零和某个特定位置的元素交换而不是简单地覆盖掉这种方法就不通用了。更重要的是双指针交换法只需要一次遍历代码更优雅也更能体现“指针思想”的精髓。2.2 方案二额外数组不符合要求但值得想一想如果题面没有“原地”两个字最简单的做法就是新建一个数组ans遍历原数组把所有非零元素放进去最后补零然后复制回原数组。这个方案思路很直白代码两三行就写完了def moveZeroes(nums): ans [x for x in nums if x ! 0] ans [0] * (len(nums) - len(ans)) nums[:] ans但问题在于它需要 O(n) 的额外空间。如果你在面试的时候第一反应是这种写法也没关系这叫“可行解”但是你必须主动告诉面试官这不符合空间要求然后继续优化成原地版本。很多人对着面试官闷头写这种代码写完了也不说优化那就直接掉进陷阱里了。话说回来额外数组方案并不是毫无价值。它给你提供了一个“正确性参照物”当你写完原地操作的双指针代码之后完全可以用这个简单版来验证结果是否正确。在实际工程里如果数组非常小、性能不是瓶颈牺牲空间换可读性是完全合理的。算法题是算法题工程是工程你要分清这两种场景。2.3 方案三双指针原地交换这就是这道题的“正主”了。核心思想其实很朴素用两个指针一个慢指针j指向“当前可放置非零元素的位置”一个快指针i遍历数组。每当快指针遇到非零元素就把它和慢指针指向的元素交换然后慢指针前进一位。等等这里有个关键点需要想清楚慢指针指向的到底是什么位置实际上在整个遍历过程中慢指针j始终指向“已经处理好区域的第一个零元素”。什么意思呢就是[0, j)这个区间是已经处理好的非零元素[j, i)中间的元素全是零或者初始状态下是原始未处理的元素。快指针i扫过的区域非零元素都已经挪到了前面零都被自然填满了中间。def moveZeroes(nums): j 0 for i in range(len(nums)): if nums[i] ! 0: nums[j], nums[i] nums[i], nums[j] j 1画个图理解数组[0, 1, 0, 3, 12]一开始j 0、i 0。i指向0不做操作。i 1时遇到1把nums[1]和nums[0]交换数组变成[1, 0, 0, 3, 12]j变成1。此时[0, 1)区间是[1]没问题。然后i 2遇到0跳过。i 3遇到3把nums[3]和nums[1]交换数组变成[1, 3, 0, 0, 12]j 2。最后i 4遇到12把nums[4]和nums[2]交换数组变成[1, 3, 12, 0, 0]。整个过程中每次交换都把当前扫描到的一个非零元素放到“最前面的空闲位置”而非零元素之间的相对顺序完全没有被打乱。这正是双指针方法的精妙之处。3. 双指针方案的完整实现3.1 双指针的两种经典写法针对这道题双指针其实有“交换版”和“覆盖版”两种写法。上面给的是交换版我再把覆盖版写出来对比一下# 覆盖版 def moveZeroes(nums): j 0 for i in range(len(nums)): if nums[i] ! 0: nums[j] nums[i] if i ! j: nums[i] 0 j 1两种写法的区别是什么覆盖版直接把非零值赋给nums[j]然后把原位置nums[i]置为0前提是i ! j。这本质上也是交换只是手动分成两步写。交换版用 Python 的多变量赋值一行搞定交换。其他语言例如 Java则用一个临时变量来交换。我个人的建议是优先掌握交换版。因为它更通用后面学到“三路快排”的思想你会发现也是靠交换来维护分区。而且交换版不需要额外判断i ! j代码更简洁。下面给出其他主流语言的实现方便不同技术栈的读者对照。3.2 代码实现Java 版public void moveZeroes(int[] nums) { int j 0; for (int i 0; i nums.length; i) { if (nums[i] ! 0) { // 交换 nums[i] 和 nums[j] int temp nums[i]; nums[i] nums[j]; nums[j] temp; j; } } }Java 没有 Python 那么优雅的交换语法所以用临时变量。注意在i大于j的时候nums[i]和nums[j]交换后nums[i]会被填上原来的nums[j]——而原来的nums[j]是零因为慢指针指向的是第一个零所以交换后相当于把零往后面挪了。这个逻辑很多人第一次看会绕跑一遍就能理解了。3.3 代码实现JavaScript 版function moveZeroes(nums) { let j 0; for (let i 0; i nums.length; i) { if (nums[i] ! 0) { [nums[j], nums[i]] [nums[i], nums[j]]; j; } } }JavaScript 的解构赋值和 Python 一样简洁。但注意一点解构赋值在部分老版本 JavaScript 引擎上性能有微小的额外开销如果你追求极致性能还是用一个temp变量比较稳。在面试场景里面试官更关注思路而不是这几个纳秒的差异。3.4 代码实现C 版class Solution { public: void moveZeroes(vectorint nums) { int j 0; for (int i 0; i nums.size(); i) { if (nums[i] ! 0) { swap(nums[i], nums[j]); j; } } } };C 直接用标准库的swap函数签名里使用引用传递确保修改的是原数组。这里强调一下vectorint nums中的不可省否则你只是修改了形参的副本调用结束后原数组纹丝不动。3.5 手动模拟一遍运行过程为了彻底讲透我把完整模拟过程写出来。以输入[0, 1, 0, 3, 12]为例按交换版执行步骤ij当前数组操作含义初始00[0, 1, 0, 3, 12]还没开始100[0, 1, 0, 3, 12]nums[0] 为0跳过210[1, 0, 0, 3, 12]nums[1]1非零交换 nums[0]与nums[1]j321[1, 0, 0, 3, 12]nums[2]为0跳过431[1, 3, 0, 0, 12]nums[3]3非零交换 nums[1]与nums[3]j542[1, 3, 12, 0, 0]nums[4]12非零交换 nums[2]与nums[4]j模拟完会发现一个规律每次交换快指针指向的非零元素都会被放到慢指针指向的位置而慢指针指向的位置在交换前一定是一个零因为零会被保留在中间区域。如果慢指针指向的不是零那说明之前的逻辑出了问题你可以拿这个规律来调试自己的代码。3.6 边界情况与特殊输入写数组操作题一定要养成检查边界条件的习惯。我总结了几组必测用例nums []空数组。j 0循环不执行直接返回代码不会报错。nums [0]只有一个零。i 0时跳过结束数组还是[0]符合预期。nums [1]只有一个非零。i 0时nums[0]和nums[0]自己交换结果不变j变成 1结束。这里要注意有交换操作时交换一个元素和它自身是没有问题的但如果你额外写判断if (i j) continue会减少一次无意义操作。nums [0, 0, 1]前面全是零。i 2时才遇到非零交换nums[0]和nums[2]结果[1, 0, 0]正确。nums [1, 2, 3, 0, 0]零已经在末尾。整个遍历过程只会出现“非零元素和自己交换”的情况最终数组不变正确。这些边界情况在面试时最好主动提出来能体现你的严谨性。如果你在面试中写完代码不妨自言自语一句“我检查一下空数组、全零、没有零这三种输入。”这句话本身就是加分项。4. 深入分析与变体扩展4.1 为什么“交换”优于“覆盖”回到前面提到的覆盖版和交换版很多人有个疑问既然结果一样为什么我说交换版更值得掌握关键在于“通用性”三个字。覆盖版的本质是“先腾位置再填充”。它把非零元素之前的零直接丢掉了相当于把数组的内容重写了一遍。如果题目只是“移动零”这完全没问题但如果题目变成“把数组中的所有偶数移动到前面同时保持相对顺序”覆盖法就需要先判断“哪些元素需要覆盖”逻辑会更绕。交换版的本质是“两两换位置”。任何分区类问题核心都是画一条分界线左边是符合要求的元素右边是尚未处理的元素每次遇到符合要求的元素就把它换到分界线左边。这种思路就是快速排序partition的基础逻辑。所以把交换版练熟了以后做很多数组分区题都能直接套。我记得有位前辈说过一句话印象很深“算法题背的是思路不是代码代码是思路的载体不是答案本身。”双指针交换这种思路才是这道题真正值钱的地方。4.2 变体一移动任意指定元素到末尾把“移动零”的零改成任意一个目标值target比如把数组中所有等于target的元素移到末尾同时保持其他元素的相对顺序。解法几乎一样只需把判断条件nums[i] ! 0改成nums[i] ! targetdef moveTargetToEnd(nums, target): j 0 for i in range(len(nums)): if nums[i] ! target: nums[j], nums[i] nums[i], nums[j] j 1这个变体在真实业务中有个典型场景对数组做重排时把某些“删除标记”的元素移到末尾以便之后统一截断处理。比如一个日志数组要把所有level debug的日志挪到尾部然后只处理前面的部分。理解了这道题这个业务需求你就顺手解决了。4.3 变体二把所有非零元素移动到前面保持顺序这个变体其实是“移动零”的对称版本。不使用任何排序算法把数组中的非零元素全部放在前面零自然落到后面。这跟原题完全一样只是描述方式不同。有些面试官会换一种问法来考察比如“将数组中的所有正数移到前面”、“将数组中所有非负数移到前面”核心解法不变。如果题目再加一个条件正数和负数分别放在两端中间是零这就变成“荷兰国旗问题”三路分区的简化版。三路分区的核心思想是维护三个指针把数组分成三段这种题也是高频题。“移动零”是你接触这类题目的第一级台阶。4.4 从这道题延伸出去快速排序分区思想快速排序的经典单边循环分区法Lomuto partition和这道题的结构非常相似。快排的 partition 通常是以最后一个元素为基准值pivot用慢指针i指向比基准值小的区域的边界快指针j遍历数组遇到小于基准值的元素就与i位置交换然后i。你看这和“移动零”的交换模板如出一辙只是判断条件从“非零”变成了“小于基准值”。所以在面试中我通常会建议候选人把“移动零”当作一个记忆锚点遇到分区类题目先想想能不能套双指针交换的框架。这个框架的适应范围远超你想象从数组重排到字符串处理都能用。5. 常见问题与易错点排查5.1 遍历方向出错导致相对顺序错乱有一种常见错误写法是从后往前遍历遇到零就把它“冒泡”到末尾。比如这样def wrongMoveZeroes(nums): n len(nums) for i in range(n - 1, -1, -1): if nums[i] 0: # 把零往后冒泡 for j in range(i, n - 1): nums[j], nums[j 1] nums[j 1], nums[j]这种写法最终结果可能是对的但复杂度退化成 O(n²)而且交换过程中大量重复操作在数组长的时候性能极差。虽然它保持了非零元素的相对顺序但完全失去了双指针方案的意义。我见过有些同学在面试时写这种冒泡式解法结果被面试官追问“复杂度是多少”时才发现问题。为什么会想从后往前遍历因为直觉上“零往末尾跑”像是从前往后推——但实际上双指针已经帮你完成了“从前往后扫描、从后往前填零”的逻辑你不需要自己去模拟冒泡过程。5.2 慢指针更新时机错误有些同学会把j 1写到if块外面导致每次循环都让j前进最后数组被交换得面目全非。正确逻辑是只有发生了非零元素的交换时慢指针才前进。为什么会写错主要是没想清楚j的语义。j不是简单的“当前遍历位置”而是“下一个非零元素的落点”。只有当你把一个非零元素放到j位置后j才有必要继续前进。如果把j 1写在if外等价于每次循环都把当前位置当作落点那等于没有分区。调试方法也简单每轮循环后打印i、j和当前数组看看j是否始终指向第一个零的位置。如果j指到了非零元素说明更新逻辑错了。5.3 交换时元素自身与自身交换当i j时执行nums[i], nums[j] nums[j], nums[i]是自我交换没有任何问题但会多一次赋值操作。有些追求极致性能的面试官会问你“能不能优化掉”这时你可以加一个判断if i ! j: nums[j], nums[i] nums[i], nums[j] j 1不过这个优化意义不大在数组不长的时候性能差异可以忽略。重点是你要知道这个操作的存在并且能解释它为什么无害——因为交换两个相同的位置结果不变。5.4 常见问题速查表为了方便你复习我把这道题常见的错误整理成一张表常见问题根本原因解决方案额外数组空间想走捷径想用过滤拼接严格使用双指针原地交换非零元素顺序错乱从后往前扫描并冒泡零从前往后扫描快慢指针交换慢指针更新时机不对j 1放在循环末尾只在nums[i] ! 0时j数组越界内层循环边界写到n内层边界用nums.length或n仔细检查没有考虑空数组/单元素缺乏边界测试习惯写出代码后先测试四组边界用例Java 形参修改无效忽略了引用传递确认函数签名使用int[]或ListInteger引用5.5 如何自测代码写完代码后我通常会给自己设计一组覆盖性强的测试用例[0, 1, 0, 3, 12]→ 普通场景[0, 0, 0]→ 全零[1, 2, 3]→ 无零[0]→ 单元素[]→ 空数组[1, 0, 2, 0, 3, 0, 4]→ 零与非零交替出现如果这些用例都能通过这道题的实现基本就稳了。在 LeetCode 上提交代码之前先自己在本地跑一遍这些用例能节省不少提交次数。LeetCode 的判定比较严格有的隐藏用例会直接暴露你的边界处理缺陷。关于测试还有一个技巧不要只盯着“输出对不对”还要看“操作过程是否原地”。你可以在代码里打印数组的内存地址确认地址始终没变这才叫真正的原地操作。结束语“移动零”这道题题目短小考察的维度却很密集——数组、指针、原地操作、复杂度分析一次性全部覆盖。我个人刷了数百道数组题之后回过头来看这道题确实是双指针思想的“第一课”。它让我真正理解了“慢指针维护结果区间快指针负责探索”这个套路后面做“移除元素”“压缩数组”甚至快排 partition 都顺了很多。最后再分享一个刷题习惯每个经典题做完之后我都会把它“改编”一下比如把零改成负数、把“移动到末尾”改成“移动到开头”然后自己写一遍解法。这种主动改题的方式比刷十道新题还能锻炼思维。如果你能在一刻钟内把本文的所有变体都默写出来这道经典题你就彻底毕业了。