☰
LeetCode 283 移动零:双指针原地算法与空间复杂度实战解析
2026/10/6 3:34:41 网站建设 项目流程

在 LeetCode 的题库里,283. 移动零(Move Zeroes)属于那种“看一眼题目觉得自己会,一写代码却被面试官反复追问”的典型题目。它排在热门 100 题里,也是很多人刷题计划的第一批题。题面很短:给你一个整数数组 nums,把数组中所有的 0 移动到末尾,非零元素保持原来的相对顺序,并且必须在原数组上进行,不能额外复制数组。我第一次做这题时,第一反应是“再开一个数组,把非零装进去,末尾补零再复制回来”,空气安静了三秒,然后被一句“那需要多少额外空间”直接击中。今天就把这题从读题、原理、代码到坑位完整拆一遍。

1. 题目到底在考什么:读题与出题人意图

1.1 题干核心信息拆解

原题描述非常克制:给定数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。注意这个函数必须直接修改原数组,也就是传说中的 in-place。示例也只有一个:[0,1,0,3,12] 经过处理后变成 [1,3,12,0,0]。

说实话,这道题放到 LeetCode 上难度只能算 easy,但它在求职面试中的出现频率一点都不低。原因很简单:它考察的知识点非常收敛,就三个——数组遍历、索引控制、空间复杂度意识。没有复杂的算法套壳,不依赖任何高级数据结构,适合作为“先写一段再说”的开场题。

先别急着写代码,我们把出题人的意图拆成三个关键词:移动、保持顺序、原地。

  • 移动:不是排序,所以不用考虑数组中其他值的相对大小,0 就是单纯要被挪到尾部。
  • 保持顺序:数组中原本的 1、3、12 这些非零值,处理完后它们的先后顺序必须和原来一模一样。
  • 原地:不允许新建一个数组来过渡,空间复杂度被限定在 O(1)。

如果一个候选人能把这三点准确翻译成“时间复杂度 O(n)、额外空间 O(1)、稳定性保持”,那这题基本就拿到一半分了。

1.2 为什么“原地”和“保持顺序”缺一不可

先说“原地”。如果去掉这个限制,解法就是傻瓜版本:遍历一遍原数组,把所有非零元素收集到一个新数组里,末尾补上若干个 0,再把新数组内容复制回来。这确实是能跑通的思路,但额外空间是 O(n)。

出题人之所以强调原地,不是单纯想刁难你,而是因为在真实工程环境里,大数组的频繁复制代价极高。尤其是在 C++、Go、嵌入式这些场景下,一次大内存分配、cache miss、GC 压力,都可能成为性能瓶颈。整理房间也是同一个道理:要求你在一个房间里腾挪家具,而不是把所有东西搬到走廊再搬回来——走廊空间往往根本不存在。

再说“保持顺序”。这个约束其实是在逼你放弃一类“看似高效但会打乱序列”的解法。举个例子,你可以用双指针从左右两端往中间扫描,碰到左边是 0、右边非零就交换。这种方法确实能在 O(n) 时间、O(1) 空间内把 0 都放到末尾,但它会破坏非零元素的相对顺序。比如 [0,2,1],用左右交换法可能变成 [1,2,0],2 和 1 的顺序就反了。所以“保持顺序”不是空话,它决定了我们不能简单套用标准的 partition 思想。

1.3 从“直觉解法”到“约束解”的思维切换

很多初学者刷题有个习惯:看题的第一眼就想“怎么最直接地实现”,而不是“在给定约束下怎么高效实现”。这两种思维方式在简单题上差别不大,但到了中难题,就是天壤之别。

以这题为例,直觉解法是“复制一个数组”,但约束把它否决了。于是你被迫去寻找一种只使用数组自身索引的操作方式。这时候你自然会想到:能不能用一个指针记录“下一个应该放非零元素的位置”,再用另一个指针从头往后扫描?这个念头一出现,其实你已经在无意识中推导出了双指针解法。

我不建议新手上来就背模板,更建议每次遇到这种“直觉被否决”的题目时,停下来想一想:为什么约束是这样?哪个操作被禁止了?有没有更轻量的替代方式?这比记住标准答案重要得多。

2. 双指针解法:从思路到代码

2.1 快慢指针的固定套路

双指针有很多形态:对撞指针、快慢指针、滑动窗口。这题用的是快慢指针,也叫同向双指针。

两个指针都从数组头部出发,slow 表示“下一个非零元素应该被放置的位置”,fast 负责向前扫描每一个元素。fast 每遇到一个非零元素,就执行一次操作:把这个元素放到 slow 指向的位置,然后 slow 向后移动一步;如果 fast 遇到的是 0,就什么都不做,继续向前走。

仔细品一下这个逻辑:slow 只在遇到非零元素时前进,它天然指向所有已处理的非零元素的“尾部边界”;fast 遍历完整数组后,所有非零元素其实已经被紧凑地搬到了数组前部,剩下的 tail 部分再统一置 0 即可。整个过程只遍历一次,且没有任何额外数组分配。

用 [0,1,0,3,12] 手动跑一遍,你会更直观地感受到指针的移动:

fast当前元素slow操作
000跳过,0 无需处理
110交换 nums[0] 和 nums[1],slow 变 1
201跳过
331交换 nums[1] 和 nums[3],slow 变 2
4122交换 nums[2] 和 nums[4],slow 变 3

最终数组变成 [1,3,12,0,0],完全符合预期。

2.2 写法A:非零元素往前覆盖,末尾统一补零

这是最容易理解和实现的一种写法。思路是:第一遍扫描,把非零元素依次写到数组前面的连续位置;第二遍,从写指针位置开始,把后面所有位置统一填充为 0。

void moveZeroes(vector<int>& nums) { int write = 0; // 下一个写入非零元素的位置 for (int read = 0; read < nums.size(); ++read) { if (nums[read] != 0) { nums[write++] = nums[read]; } } // 剩余位置全部补零 while (write < nums.size()) { nums[write++] = 0; } }

仔细看看这个覆盖过程:当 write 追上 read 时,nums[write] 其实就是 nums[read] 自己,相当于一次自我赋值,没有任何问题;当 write 落后于 read 时,write 指向的位置是之前某个已经被扫描过的 0,把它覆盖掉也不会丢信息,因为那个 0 本来就不需要保留。

这个写法的优点是代码短、逻辑直白、赋值次数也少。每个非零元素最多被写一次,末尾的 0 再写一次,整体写入量大约就是 O(n)。

2.3 写法B:发现非零就交换,一步到位

另一种写法是原地交换:fast 遇到非零时,直接把 nums[fast] 和 nums[slow] 交换,然后 slow 加一。这样不需要第二轮补零,因为 0 会在交换过程中自然“被动”地后移。

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; } } }

一个值得注意的细节:当数组里几乎没有 0 时,slow 和 fast 会保持同步,于是每次都在做“自己交换自己”。这没有功能性错误,但有些追求极致的代码评审者会要求加一行判断:

if (slow != fast) { swap(nums[slow], nums[fast]); }

不过我个人建议面试时先不加这个优化,因为它本质上属于微优化,反而会把主逻辑绕复杂。面试官如果追问,你再解释“这是为了避免自交换的额外操作”,反而是一个加分项。

其他语言也一样,换汤不换药。Python 版本的写法非常接近:

class Solution: def moveZeroes(self, nums: list[int]) -> None: slow = 0 for fast in range(len(nums)): if nums[fast] != 0: nums[slow], nums[fast] = nums[fast], nums[slow] slow += 1

为什么交换不会出问题?核心不变量是:slow 永远小于等于 fast。因为 slow 只在遇到非零时递进,而 fast 每轮都会递进。所以即便发生交换,被交换到后面的“旧值”也是 fast 已经扫描过的位置,不会破坏还没有处理的数据。理解了这一步,你就不会再问“这样覆盖会不会丢数据”了。

3. 复杂度、稳定性与其他解法路线对比

3.1 主流解法时间复杂度对比

这道题的讨论区里,解法五花八门,但真正值得在面试中拿出手的其实就双指针的两种变体。我把常见路线放在一起做个对比,帮你一眼看清各自的代价。

解法时间复杂度额外空间是否保持顺序是否满足题意
新数组收集再复制O(n)O(n)是否,空间不达标
边删 0 边 push_backO(n²)O(1)是是,但性能差
非零覆盖 + 末尾补零O(n)O(1)是是
双指针交换O(n)O(1)是是
左右对撞交换(不稳定)O(n)O(1)否否,顺序会被破坏

从表格可以看出,“看起来也能过”的解法不少,但“完全符合题意”的只有双指针的两种实现。面试官真正想听到的,往往不只是“能跑”,还有“为什么选这个”。

3.2 为什么“边删边补零”容易写出 O(n²) 的解

很多非科班或者刚学数据结构的朋友,第一反应是:找到 0,把它删掉,再在末尾补一个 0。听起来没毛病,但别忘了数组的删除操作本身是昂贵的。

以 C++ 的 vector 为例,erase会把被删除位置之后的所有元素整体向前移动一位,这个过程是 O(n) 的。你在 for 循环里每遇到一个 0 就 erase 一次,最坏情况下(比如数组全 0),每删一次都要移动后面的元素,总代价就是 O(n²)。

更麻烦的是循环变量本身会变得非常蹩脚:

for (int i = 0; i < nums.size(); ++i) { if (nums[i] == 0) { nums.erase(nums.begin() + i); nums.push_back(0); --i; // 当前索引被替换成了新元素,需要回退重新检查 } }

这段代码能跑,但阅读体验很差,而且一旦数组很大,性能会肉眼可见地崩。面试官看到这种实现,大概率会追问一句:“如果数组有 10 万个元素、其中 9 万个是 0,你的做法总共移动了多少次?”这一问就能把不稳定性暴露出来。

3.3 稳定性:和排序中的“稳定”是同一个概念

“保持非零元素的相对顺序”本质上就是排序算法里的“稳定性”。你可能在学归并排序、快排的时候听过“稳定排序”这个词:相同的元素在排序前后相对位置不变。这题里的稳定性,是把非零元素当成一个整体,要求它们的先后关系不能被破坏。

为了体现稳定性有多重要,我写一个不稳定的解法给你看:

void moveZeroesNoOrder(vector<int>& nums) { int left = 0; int right = nums.size() - 1; while (left < right) { while (left < right && nums[left] != 0) ++left; while (left < right && nums[right] == 0) --right; if (left < right) { swap(nums[left++], nums[right--]); } } }

对 [0,2,1] 来说,这个解法会把 1 换到前面去,输出变成 [1,2,0]。如果你不关心非零顺序,这个答案还行;但按原题要求,它就是错的。所以当你声称自己会做这题时,一定要能解释清楚:为什么稳妥的方案是快慢指针,而不是左右对撞交换——两个字,稳定。

4. 边界条件、测试用例与实战排雷

4.1 边界用例清单

这道题看似简单,但边界条件并不少。我整理了一个自测清单,每次写完后都拿这些用例跑一遍,基本能覆盖绝大多数隐藏问题。

输入期望输出需要验证的点
[][]空数组不崩溃
[0][0]单个 0
[1][1]单个非零
[0,0,1][1,0,0]前部连续 0
[1,0,0][1,0,0]后部连续 0
[1,2,3][1,2,3]没有 0,原样
[0,0,0][0,0,0]全部是 0
[1,0,1][1,1,0]非零之间夹 0
[0,1,0,3,12][1,3,12,0,0]官方标准用例

其实边界用例的核心就一句话:无论 0 出现在哪里,无论有多少,结果都应该是“非零紧凑在前、零紧凑在后、非零顺序不变”。

4.2 我实际踩过的坑和排查方法

我自己刷题和带人刷题时,发现几个高频踩坑点,这里直接列出来,希望你少走弯路。

第一个坑是 slow 指针忘记递增。很多人写出if (nums[fast] != 0) swap(nums[slow], nums[fast]),却忘了++slow,于是每一次非零都会覆盖同一个位置,核心逻辑直接报废。这道题的指针递增是灵魂,少一行都不行。

第二个坑是把覆盖方向写反。比如写成nums[fast] = nums[slow],这就会把扫描指针当前的值覆盖掉,导致后续元素丢失。记住口诀:快指针负责读,慢指针负责写。读的是非零值,写的是慢指针位置。

第三个坑是 C++ 里用 int 保存nums.size(),然后从数组尾部倒序遍历时,遇到空数组会下标溢出。比如for (int i = nums.size() - 1; i >= 0; --i)这种写法,在nums.empty()时,nums.size() - 1会变成非常大的无符号数,进而直接越界访问。这题虽然主要用正向遍历,但涉及双端交换的变体会经常踩到。

第四个坑更隐蔽:面试时为了炫技,前后两半都用了复杂逻辑,结果把自己绕晕。我见过有人在交换时把slow和fast混淆,导致非零顺序错乱。面对这题,最简单的方法反而是最稳妥的,不要为了“看上去高级”而牺牲正确性。

4.3 调试技巧:肉眼模拟 + 打印中间结果

排查数组问题,我有一个很土但很有效的办法:把每一步的数组状态打出来。

void printArray(const vector<int>& nums) { for (int x : nums) cout << x << " "; cout << endl; } void moveZeroesDebug(vector<int>& nums) { int slow = 0; for (int fast = 0; fast < nums.size(); ++fast) { if (nums[fast] != 0) { swap(nums[slow], nums[fast]); ++slow; } cout << "fast=" << fast << ", after: "; printArray(nums); } }

这样跑一遍,你能非常清楚地看到 0 是怎么一步步“漂”到后面去的。如果发现某一轮数组顺序不对劲,那就是指针条件写错了。

另外建议在本地把上面表格里的测试用例写成单元测试,比如用assert校验结果。LeetCode 平台虽然会跑用例,但我们刷题的目的不只是提交通过,而是理解原理、在面试中能稳定复现。本地多测几个边界,比反复提交赌人品强得多。

5. 从“移动零”延伸出去的战场

5.1 同模考题:26 删除有序数组中的重复项、27 移除元素

移动零不是孤立的一题。它和 LeetCode 27 移除元素、26 删除有序数组中的重复项,本质上是同一个模子:用一个慢指针维护“过滤后的数组长度”,用一个快指针扫描全部元素。

以 27 移除元素为例,题目要求原地移除所有值等于 val 的元素,并返回新长度。解法几乎可以直接平移:

int removeElement(vector<int>& nums, int val) { int slow = 0; for (int fast = 0; fast < nums.size(); ++fast) { if (nums[fast] != val) { nums[slow++] = nums[fast]; } } return slow; }

你发现没有,移动零就是val = 0的移除元素,区别只是移动零要求把 0 补到末尾,而不是直接忽略长度。至于 26 题,也是同样的快慢指针,只是判断条件从“不等于 val”变成“不等于上一个已保留的元素”。所以,把这三种题放一起对比着刷,你会发现所谓“新题”其实是“旧思路换皮”。

热门 100 题这个清单里,还有很多类似的双指针或二分模块的题,比如二分查找类型里有个“爱吃香蕉的狒狒”,虽然和移动零不是同一个模块,但都属于高频考区。我的建议是:刷题不要孤立地背题号,而是按“双指针、二分答案、单调栈、动态规划”这种模块去归纳,这样才能把一道题的经验复制到一类题上。

5.2 面试官可能追加的追问与正确姿势

面试官很少只问“你会不会写这道题”,更常见的是在你写出代码后抛几个变体问题。这里我整理了几个高概率追问,以及推荐回答方向。

如果把“保持非零元素的相对顺序”这个条件去掉,怎么做?这个问题考的是你对稳定性的理解。可以不使用快慢指针,而是用左右对撞交换,见 3.3 节。你需要主动指出它不稳定,并说明为什么原题不允许这样做。

如果数组特别大,你优先选择哪种双指针写法?这时候可以从赋值次数角度分析:覆盖补零写法,每个非零元素只赋值一次,末尾零再赋值一次,写操作总量可控;而交换写法在 slow 和 fast 不相同时,一次 swap 会产生两次赋值。工程上如果写操作代价高,覆盖补零可能更优,但这属于细节优化,面试时点到为止即可。

为什么 slow 指针不会超过 fast 指针,从而把尚未扫描的数据覆盖掉?这是我最喜欢追问的一个问题。正确回答是:slow 只在遇到非零元素时自增,而 fast 每次循环都会自增,所以两者之间天然有这个不变量:slow ≤ fast。因此 slow 指向的位置一定是 fast 已经路过的地方,覆盖它不会破坏未来元素。

这些追问不见得需要你在白板上完整写代码,但至少要说清楚思路。能把“为什么要这么写”讲明白的候选人,通常比那个闷头写代码五分钟然后说“好了”的人,拿到的评价高一大截。

5.3 在代码评审里是怎么“挑刺”的(我的经验)

我在代码评审时看到这道题的实现,一般会按三条标准快速检查:是否原地、是否稳定、是否 O(n)。

第一眼看是不是多开了数组。如果看到有人新建 vector 再复制,我会问“为什么不用原地方案”。如果看到用stable_partition,我会先肯定它能跑,再问它内部会分配多少临时空间。标准库函数虽然安全,但在这个简单场景下,手动双指针通常是更可控的选择。

第二眼看非零顺序有没有被破坏。很多人用partition一类的函数时,会忽略稳定性的坑。原题已经明确要求保持顺序,任何破坏顺序的实现,无论跑得再快,都不能算对。

第三眼看自交换和边界。比如交换写法中swap(nums[slow], nums[fast]),如果两个指针相等,自交换有没有问题?对 int 来说当然没有,但如果换成一个自定义类型,自赋值可能触发不必要的析构和拷贝,生产代码里通常建议加保护。这道题虽然是纯算法题,但我会顺带提醒候选人注意真实世界的对象语义。

最后说点我自己的体会。刷题刷到后面,我越来越觉得“AC 通过”只占三分,剩下七分是理解深度。移动零这道题非常简单,但如果你愿意多想一层“为什么快慢指针可行”,你会发现它把数组原地操作、稳定性、复杂度分析这些基础概念串起来了。平时我做完一题,都会写一段注释或者笔记,把这个不变量记下来——比如这题的核心就是“slow ≤ fast,所以覆盖是安全的”。下次遇到同类题,这句笔记会替你省下大量的思考时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询