移动零这道题,放到整个算法题库里,难度并不高,但它是我面试候选人时最爱用的一道开场题。原因很简单:它表面考的是“把零放到数组末尾”,实际上考的是双指针的理解深度,以及对空间复杂度的尊重程度。很多人口头禅式地说“我用双指针”,但被追问一句“快慢指针各自维护的区间是什么”,立刻卡壳。这篇文章不是单纯给你贴一份能通过的代码,而是带着你从题目描述一路走到面试现场,把这道题的底层逻辑彻底讲明白。无论你是刚开始刷题的校招同学,还是有几年经验准备跳槽的工程师,这篇内容都能让你在遇到它时,讲出一个比普通答案更有层次的分析。
1. 题目本身不难,难的是想明白这三点
1.1 三个隐藏条件,决定了解法方向
力扣第283题的题目描述非常短:给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。
这里的信息密度其实被低估了。你能从这句话里提取出三个约束条件,才算真正读懂了题。第一,操作对象是数组,不是链表也不是字符串,意味着我们拥有连续内存、支持随机访问,可以通过下标直接定位任意位置,这为后续用指针方式遍历提供了基础。第二,必须是“原地操作”,也就是不能拷贝一份新数组再把结果粘贴回去,除非你被明确告知可以开辟额外空间,否则这道题默认的空间边界就是 O(1)。第三,非零元素的相对顺序不能变,这一点把很多看上去聪明的做法直接判了死刑。
我见过不少人拿到题目后的第一反应是:把数组里的零全部筛选出来删掉,再在末尾补上对应数量的零。这种思路和一开始就把“空间”当可再生资源的想法绑定在一起,一旦被追问“你的额外空间用在哪了”,就会露出破绽。副产出问题,效率也不在线。实际上面试官希望看到的,是你对一维数组上的移动操作有一套机制性的理解,而不是靠库函数去掩盖工程细节。
1.2 “原地操作”是新手遇到的第一个冲击
如果允许用额外数组,这道题几乎没有任何算法含量。你开一个同样长度的容器,先遍历一遍原数组把所有非零元素放进去,剩下的空位自动补零,整个逻辑一句话就能说完。但原地操作约束一加,问题就变成:如何在有限的内存里完成元素的搬迁,同时不丢失信息。
很多初学算法的读者会把“原地”理解为“不新建对象”,其实真正的标准比这更严格:除了几个临时变量、下标和指针之外,不得再使用与数组规模相关的额外空间。两个 int 型变量做计数器或者存指针没问题,但是新建一个长度为 n 的列表去存结果,空间复杂度就是 O(n),不管你是用这个列表承接最终结果还是只做中间状态,本质都是“超出约束”。
还有一个大家容易忽略的细节:原地操作并不等于不能用交换。恰恰相反,交换是在数组内部完成结构改变的最高效手段。它不新增容器、不破坏未扫描区域的原始信息,因为它本质上就是两份数据在两个已知位置上互相兑换。你去想一想“两杯水互换需要第三个杯子”那个例子,会发现在数组里做交换根本不需要第三块内存,这完全是数组支持随机访问带来的红利。这种“交换即缓存”的意识,是以后理解排序算法、链表反转乃至堆调整的基本功。
1.3 暴力解法到底错在哪里
假如第一步想的是“每遇到一个零,就和后面的非零元素交换”,这就落到了暴力的漩涡里。具体来说,外层循环扫到 0,内层循环再向右寻找第一个非零元素然后交换,最坏情况下数组形态是 [0,0,0,...,1] 这种极限压缩结构。外层 n 次、内层平均 n/2 次,复杂度直接到 O(n²),一旦数组长度上万,运行时间就会肉眼可见地膨胀。
更隐蔽的问题是,这种暴力法连“保持非零元素相对顺序”这一条都未必满足。你把一个靠后的非零元素交换到前面的零窟窿里,被挤走的这个零下一次又会挡在更靠前的元素前面,整个移动过程就像在玩滑块拼图,每一步都在局部调整,完全没有全局规划。时间复杂度失控,逻辑上也不优雅。
真正的双指针做法,是在一次遍历里顺手把结构整理完:不用反复回头找,不用反复交换,两个指针各司其职,一趟过后就完成移动。要理解这种看似轻巧的处理方式,你得先看清楚两个指针分别承担什么样的职责。
2. 双指针怎么设计:一个记住位置,一个负责扫描
2.1 双指针模型:慢指针管位置,快指针管发现
双指针解法在数组题里属于最常用的模型之一,但很多人对它只有模糊印象,不会设计。移动零题目的指针模型可以这样建立:一个指针叫 slow,一个指针叫 fast,都从数组头部出发,slow 用来指示“下一个非零元素应该存放的位置”,fast 用来扫描整个数组,寻找非零元素。
fast 每找到一个非零元素,就把这个元素交给 slow 指向的位置,然后 slow 前进一格。fast 的步伐永远快于或等于 slow,因为 fast 是主动扫描方,slow 只是被动接收方。这一动一静的分工,让整个算法有了清晰的节奏。
如果抽象成一句话,就是:把非零元素“按扫描顺序”逐个提取出来,顺序摆到数组前段。那些扫描过程中没被提取的零元素,自然就被留在了后段。这个思路和“把牌堆里所有红牌依次抽出放到左边”是一个道理——你不需要专门去管黑牌,因为抽出红牌的动作自身就完成了隔离。
2.2 为什么这样能保证非零元素的相对顺序
保证相对顺序的关键,在于 fast 指针是按从左到右的顺序扫描的,而 slow 指针接收元素的顺序也严格遵循这个扫描顺序。第一个被 fast 遇到的非零元素会被放到位置 0,第二个被遇到的放到位置 1,依此类推。这样排出来的序列,和原数组中非零元素的先后次序完全一致。
有人会担心:交换操作会不会破坏尚未扫描区域的顺序?答案是:不会。因为每次交换只发生在 slow 指向的“已处理区边界”和 fast 当前指向的元素之间。假如 slow 落后于 fast,fast 指向的位置还在未扫描区域,交换确实可能把一个零或者一个已经被处理过的元素交换到后面。一个被交换到后面的元素是什么?只可能是 slow 那一侧的旧值,也就是一个零或者一个此前已经处理过的非零元素。由于 slow 区域的元素都是排好的,这个被交换出去的零,会在后续扫描中被留在后面,重新成为一个“待留位置”的元素。
这类看似绕来绕去的分析,其实在解释“稳定性”的问题。双指针交换法的稳定性正是源于此:不是因为它用了什么特殊排序算法,而是因为它的扫描和写入两个过程都是顺序进行的。这也是为什么后续你可以用它来做稳定分区,而不是简单地做元素移动。
2.3 双指针算法的“不变量”思想
在算法设计里,一个非常重要的概念叫“循环不变量”。你可以在纸上画一条分界线,位于 slow 左侧的所有元素都是“已经排好的非零元素”,位于 slow 和 fast 之间的是“已经扫描过的零元素”,位于 fast 右侧的是“尚未扫描的元素”。每次循环结束,这条分界线的定义都保持不变,这就是不变量的力量。
只要不变量始终成立,正确性就有了可证明的依据。初始时 slow = 0,左侧区间为空,不变量自然成立。每轮循环如果遇到零,fast 前进但不改动任何元素;如果遇到非零元素,与 slow 位置交换,slow 前移,左侧区间从“长度 k”变成“长度 k+1”,并且第 k+1 个元素恰好来自 fast 此刻指的位置,不变量继续成立。整个循环结束后 fast 越过数组边界,未扫描区间为空,所有非零元素都在 slow 左侧,所有零自然都在 slow 右侧。这就是“用不变量证明算法正确”的完整链条。
这个思考方式的价值远超移动零这道题本身。在排序、搜索、滑动窗口、甚至图的遍历里,试图把每一步操作定义清楚,比记住一段模板代码重要得多。面试官问“为什么你这么写是对的”,最想听到的也正是这种基于不变量的论证,而不是“我跑过用例能过”。
3. 两种主流的实现方式,附完整代码
3.1 写法一:交换法,代码最短但最好理解
先说我最推荐的写法。思路就是上面设计的双指针模型,遇到非零元素直接和 slow 位置交换,然后 slow 前移。这里用 Python 实现:
def moveZeroes(nums): slow = 0 for fast in range(len(nums)): if nums[fast] != 0: nums[slow], nums[fast] = nums[fast], nums[slow] slow += 1 return nums如果你用 C++ 写,本质是一模一样的:
class Solution { public: void moveZeroes(vector<int>& nums) { int slow = 0; for (int fast = 0; fast < nums.size(); fast++) { if (nums[fast] != 0) { swap(nums[slow], nums[fast]); slow++; } } } };这个版本最核心的动作在swap这一行。当 slow 和 fast 指向相同元素时,交换本身没有任何变化,但代码依然正确,因为这表示当前元素本来就应该待在当前位置。当 slow 落后于 fast 时,交换能把 fast 的非零元素送进“已排好区域”的队尾,同时把 slow 位置的旧元素丢到后面去慢慢处理。整个过程只需要一次遍历,一次扫描内完成所有移动。
很多读者第一次看到nums[slow], nums[fast] = nums[fast], nums[slow]会担心:万一 slow 指向的元素是一个还没处理的非零元素,交换不会把它弄丢吗?这里有一个细节:在扫描过程中,slow 永远不可能越过 fast。换句话说,slow 指向的位置要么是 fast 已经扫过的位置,要么就是 fast 当前所在位置。从这个逻辑出发,slow 位置的值绝对不可能是“尚未扫描的非零元素”,所以交换是安全的。
3.2 写法二:覆盖补零法,更符合直觉
如果你觉得交换法还是有点绕,这里有一个更“实诚”的写法。先不关心零元素该怎么挪,先做一件事:把所有非零元素按顺序搬到数组前面,然后再把后面的位置统一补成零。这个思路在代码实现上分两步走。
def moveZeroes(nums): slow = 0 for fast in range(len(nums)): if nums[fast] != 0: nums[slow] = nums[fast] slow += 1 for i in range(slow, len(nums)): nums[i] = 0 return nums第一遍循环复制非零元素到前段,第二遍循环从 slow 开始一路补零。这个方法的优势是每一步都符合直觉:先整理非零,再补空缺。缺点是相比交换法,它必须做“赋值 + 补零”两次写操作,而交换法平均只有一次交换。
有一个隐藏的陷阱:如果不在第二遍循环补零,原数组中残留的旧值会被错误地保留在数组后面。举个例子,[1, 0, 2, 3],第一遍结束后数组变成 [1, 2, 3, 3],因为位置 3 的旧值仍然是 3。这一步若不处理,输出结果就会错误。因此补零循环不是可选项,而是第二遍必须执行的步骤。
3.3 两种写法的对比与应用建议
从正确性上来看,两种写法都能通过所有测试用例。区别主要在三个维度:执行效率、代码清晰度、以及面试展示力。
| 对比维度 | 交换法 | 覆盖补零法 |
|---|---|---|
| 遍历次数 | 一次循环内完成 | 一次复制循环 + 一次补零循环 |
| 写操作次数 | 最多 n 次交换 | 最多 2n 次赋值 |
| 代码量 | 更短,更精炼 | 逻辑更直白 |
| 对不变量演示 | 优秀,可以追述交换顺序 | 稍弱,分两步处理结构重排 |
| 面试表达友好度 | 高,能体现设计感 | 也不错,但容易被追问性能损耗 |
实际写代码的时候,我更推荐交换法,不光是代码量少,更因为它在展示“一次遍历完成分区”的算法思想时几乎没有多余步骤。如果你是初学者,可以先实现覆盖补零法把逻辑想通,再切换到交换法,两者互为印证。面试时我会建议你用交换法,因为面试官看到这个解法往往会顺势追问“那你如何证明非零元素相对顺序不变”,这样你就有机会把不变量讲出来,反而变成加分项。
4. 一步一步跑数组:从模拟过程到复杂度证明
4.1 用真实用例手推一遍交换过程
与其凭空解释怎么跑,不如直接拿一个带零的数组走一遍全过程。假设输入是[0, 1, 0, 3, 12],我们跟着 fast 和 slow 的移动轨迹一格一格看。
初始状态:slow = 0,fast = 0,数组[0, 1, 0, 3, 12]。
- fast = 0:
nums[0] == 0,跳过。数组不变。 - fast = 1:
nums[1] == 1,交换nums[0]和nums[1]→[1, 0, 0, 3, 12],slow = 1。 - fast = 2:
nums[2] == 0,跳过。数组不变,slow 仍为 1。 - fast = 3:
nums[3] == 3,交换nums[1]和nums[3]→[1, 3, 0, 0, 12],slow = 2。 - fast = 4:
nums[4] == 12,交换nums[2]和nums[4]→[1, 3, 12, 0, 0],slow = 3。
最终数组[1, 3, 12, 0, 0],非零元素相对顺序 1、3、12 被完整保留。
这个过程中最值得观察的是,每个非零元素都只被交换一次,且交换目标位置始终是 slow 指示的“下一个空位”。零元素虽然被来回跳转,但没有任何零跨越到 slow 左侧,因为 slow 左侧已经被处理好的非零元素占满了。最终 slow 的值是 3,代表有 3 个非零元素被安置到了正确位置;数组剩余的两个位置,自然被零填满。
4.2 时间复杂度为什么一定是 O(n),而不是 O(n²)
要判断一个算法是不是 O(n),关键看基本操作的次数和输入规模之间的关系。在交换法中,内层根本没有嵌套循环,每一次循环只处理一个 fast 位置,交换动作也最多执行 n-1 次。因此总操作次数是“遍历 n 次 + 交换 n 次以内”的量级,也就是 O(n)。
潜在的风险是,你把交换当成循环,那就另当别论了。有些初写者会这么写:每次发现一个零,就立刻往前移动非零元素,移动的过程又用一层循环把这一段元素整体前移。这样一来,每一层交换里都包含了一段元素搬运,整个算法的总操作次数就可能变成 n 的平方级。判断标准很简单:如果你在循环内部还有一个能跑满数组的循环,那你的复杂度大概率不是 O(n)。
空间复杂度也不难证明。整个算法只定义了两个整数指针 slow 和 fast,没有创建任何与数组长度相关的数据结构。无论输入数组多大,额外空间消耗都是常数,所以空间复杂度是 O(1)。这里我特别提醒一句,不要为了简化代码就在 Python 里用nums = [x for x in nums if x != 0] + [...]这种写法,虽然它看起来很短,但它构造了一个新数组,空间复杂度是 O(n),直接违背题目约束。
4.3 边界条件与测试用例设计清单
刷题时,边界条件往往是最后测试阶段最容易翻车的地方。对于移动零来说,至少要保证以下几类输入都能正确处理。
- 空数组:
[],直接返回空,不能崩溃。 - 全部为零:
[0, 0, 0],输出应该仍是[0, 0, 0],slow 始终停在 0。 - 全部非零:
[1, 2, 3],输出不变,且每个元素可能与自身交换一次。 - 单位长度数组:
[0]和[1],不能出现数组越界。 - 零分布在中间:
[1, 0, 2, 0, 3],要保证最终顺序是[1, 2, 3, 0, 0]。 - 零在前段连续出现:
[0, 0, 0, 1, 2],交换法要能正确处理 slow 长时间滞留的情况。
我在写代码时习惯先把这些用例写成一个检测列表,再去跑主方法。一个小技巧是:用比较assert而不是print去验证,不仅省事,还能在错误发生时直接定位具体是哪个用例出了问题。
5. 面试高频误区与追问:把简单题讲出层次感
5.1 四个常见误区,几乎所有人都会踩
第一个误区是“见到零就删除”。在 Python 里用remove,在 C++ 里用erase,看起来零被移除了,但数组长度也在动态变化,要么导致遍历越界,要么影响结尾处理。真实工程里还有另一种玩法:用std::remove配合erase,但这种函数式处理不容易在面试中展示你对底层设计的理解。
第二个误区是把“保持非零顺序”和“把零都放后面”分开思考,结果写出两个独立的循环:先往新数组里挑非零,再把新数组复制回去。这确实是人类最容易想的方案,但恰好在空间复杂度上破功。
第三个误区是交换方向写反。比如写成if nums[slow] == 0: swap(nums[slow], nums[fast]),虽然也能通过一部分用例,但它的行为等价于暴力搜索零的位置,反而破坏了 fast 指针作为扫描者的职责。双指针题要习惯于“fast 无脑右移,slow 按条件右移”的固定结构。
第四个误区更隐蔽:把交换法的交换目标理解为“和当前零交换”,然后用一个计数器去记忆零的个数,导致逻辑越写越复杂。实际上双指针并不关心零的个数,慢指针所在的边界本身就是零与非零区域的分界线,本质是一个“动态分区”的过程。如果你发现自己需要额外变量去记录零的数量,大概率是还没真正理解双指针的核心规律。
5.2 面试官的三连追问怎么接住
一道力扣简单题,面试官如果想深挖,至少有三个方向可以问。第一个追问是“为什么这种方法能保证非零元素相对顺序不变”。你可以从 fast 的扫描顺序入手,指出非零元素被提取的顺序和它们原本的顺序完全一致,而 slow 的写入又是顺序的,相当于一个稳定分区操作。
第二个追问是“如果题目改为把所有偶数放前面、奇数放后面,并且不要求相对顺序,你会怎么写”。这时候你可以先点出稳定分区和普通分区的区别,再给出一个前后双指针的写法:左指针向右找偶数,右指针向左找奇数,然后交换。这个变体其实也源于快排分区思想。
第三个追问是“能不能用递归实现”。这道题使用递归没有天然优势,但你可以借机说清楚递归会引入调用栈,空间复杂度从 O(1) 变为 O(n),而本题的重点正是空间复杂度约束,所以递归不是合理方向。一个干净利落的回答,反倒会让面试官觉得你对空间复杂度有全局认识。
5.3 从移动零看双指针在整个算法问题中的位置
很多人把双指针局限在“两个下标夹逼”或者“快慢指针”这类固定形状里,实际上双指针是一种非常通用的遍历策略。它的底层逻辑是:在多次扫描一个数组时,通过控制至少两个独立的游标位置,用局部信息代替全局重排,从而把 O(n²) 的暴力优化到 O(n)。
移动零最经典的一点在于,它完美示范了“快慢指针”的意义:快指针负责发现,慢指针负责落位。这个模式与去除有序数组中的重复项、合并两个有序数组、滑动窗口找最值这些题目,在抽象层面是一致的。把一道题吃透,本质上是在替一连串题目打基础。
所以我不建议初学者背这道题的解法,而是建议动手画一遍指针轨迹。画到第三次,你自然会发现:真正起决定作用的不是某个语法细节,而是那个“ slow 左侧已被处理、 fast 右侧等待处理、中间是已扫描零元素”的不变量。抓住了这一条,无论面试官如何改变数组内容、如何改变目标值,你都能在几分钟内写出同样的套路。
最后再分享一个我个人的实操习惯:在面试或刷题时,遇到数组原地移动类题目,先不要急着写代码,先在白板上画出两个指针,再用箭头标明每个指针在每一步之后的位置。这一步看起来浪费时间,却能在真正动手之前发现半数以上的边界错误。移动零这道题本身不难,能讲出细节的人却很少,希望这篇拆解能让你在下次遇到它时,不是背出答案,而是讲出完整的推导过程。