移动零:从笨办法到双指针,一次遍历的极致优雅

4 阅读8分钟

移动零:从笨办法到双指针,一次遍历的极致优雅

一道堪称“双指针”教科书级别的题目,看起来人人会做,但你能写出最优解吗?

🌱 开篇:一道简单题,为什么值得深挖?

先来看题目描述(LeetCode 283. 移动零):

给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。必须原地操作,不能拷贝额外的数组。

示例:

输入: [0,1,0,3,12]
输出: [1,3,12,0,0]

老实说,第一次见到这道题,我脑子里蹦出的第一个想法是:新建一个数组不就完事儿了吗?
但题目要求原地操作,这就有点意思了。在面试和刷题中,“简单题”往往是面试官观察你编码习惯、优化思维和细节把控能力的最好窗口

今天这篇文章,我们就从最笨的解法出发,一步步迭代到双指针的最优实现,不仅写出代码,更要理解背后的优化思想。看完你会发现,原来一个“移动零”就能把双指针的精髓讲得明明白白。


🧱 解法一:额外数组法(青铜)

思路最简单:遍历原数组,把非零元素依次放进一个新数组,然后补零,最后再复制回原数组。

def moveZeroes(nums):
    temp = []
    for num in nums:
        if num != 0:
            temp.append(num)
    # 补零
    while len(temp) < len(nums):
        temp.append(0)
    # 拷贝回去
    for i in range(len(nums)):
        nums[i] = temp[i]
  • 时间复杂度:O(n) —— 遍历几次都是线性。
  • 空间复杂度:O(n) —— 多开了一个等长数组。

显然,违反了“原地操作”的要求。题目明确说了不能拷贝额外数组,所以这只能算是我们的思维起点,面试写出这个大概率过不了。
但它让我们想清楚了一个核心任务:把非零元素按顺序搬到前面,后面再补零。 能不能就在原数组上做这件事?


🥈 解法二:双指针·两次遍历(白银)

既然不能开新数组,我们就用一个指针 idx 来标记下一个非零元素应该放置的位置
第一次遍历:把所有非零元素按顺序放到 idx 的位置,然后 idx 后移。
第二次处理:从 idx 到数组末尾全部赋值为 0

def moveZeroes(nums):
    idx = 0
    # 第一次遍历:放置所有非零元素
    for i in range(len(nums)):
        if nums[i] != 0:
            nums[idx] = nums[i]
            idx += 1
    # 第二次遍历:剩余位置补零
    for i in range(idx, len(nums)):
        nums[i] = 0

我们拿示例 [0,1,0,3,12] 走一遍:

初始: idx=0
i=0, nums[0]=0 → 跳过
i=1, nums[1]=1 → nums[0]=1, idx=1
i=2, nums[2]=0 → 跳过
i=3, nums[3]=3 → nums[1]=3, idx=2
i=4, nums[4]=12 → nums[2]=12, idx=3
此时数组变为 [1,3,12,3,12] ,最后从 idx=3 开始补零:
nums[3]=0, nums[4]=0 → [1,3,12,0,0]
  • 时间复杂度:O(n),每个元素访问常数次。
  • 空间复杂度:O(1),完美满足原地要求。

这个解法已经可以完美通过 LeetCode 了,代码清晰、无冗余。
但仔细想想:能不能只遍历一次就搞定? 我们第二次遍历纯粹是在补零,如果能在移动非零元素的同时把零“推”到后面,岂不更优雅?


🥇 解法三:双指针·一次遍历交换(黄金)

这里要引入经典的快慢指针思想:

  • 慢指针 slow:指向当前已经处理好的非零序列的下一个位置,也就是第一个 0 所在的位置(或者待写入的位置)。
  • 快指针 fast:遍历整个数组,负责发现非零元素

每当 fast 遇到一个非零元素,我们就交换 nums[slow]nums[fast],然后 slow 后移一位。这样非零元素就被逐步交换到前面,而零则被交换到后面。

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

再走一遍示例 [0,1,0,3,12]

初始 slow=0
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 → 跳过
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
结束。

整个过程一气呵成,一次遍历,原地完成
而且这个模板非常容易记忆:快指针找非零,慢指针等交换。

💡 一个小优化

slow == fast 时,其实我们不需要交换,因为元素在正确的位置上。加上判断可以减少不必要的操作:

def moveZeroes(nums):
    slow = 0
    for fast in range(len(nums)):
        if nums[fast] != 0:
            if slow != fast:
                nums[slow], nums[fast] = nums[fast], nums[slow]
            slow += 1

虽然时间复杂度仍是 O(n),但常数级别的写操作次数在某些极端情况下会减少。


🔬 深入分析:两种双指针方法的操作次数对比

很多同学会问:解法二和解法三都是 O(n) 时间、O(1) 空间,到底哪个更好?
这就涉及到对“写操作”次数的较真了,也是面试官喜欢追问的细节。

1. 两次遍历法(先搬后补零)

  • 非零元素:每个非零元素赋值一次nums[idx] = nums[i])。
  • 补零阶段:从 idx 到末尾,每个位置赋值一次。
  • 总写操作次数非零个数 + 零的个数 = n 次。

实际上,如果数组前部有大量零,非零元素会被搬运一次,零也会被重新赋值为零一次。操作次数稳定在 n 左右

2. 一次遍历交换法

  • 每次交换涉及三次赋值操作(如果使用 Python 的 a, b = b, a 底层也是创建临时变量交换)。
  • 最坏情况:前面全部是零,后面全部是非零,那么每个非零元素都需要和 slow 交换一次,共 非零个数 次交换。
    总赋值次数 = 3 × 非零个数,最大可能接近 3n。
  • 最好情况:数组已经有序(非零全在前),此时 slow == fast,我们优化后不做交换,赋值次数为 0(不算遍历索引操作)。

结论

  • 如果只看时间复杂度,两者都是 O(n)。
  • 如果关注写操作次数,两次遍历法通常更优,尤其是当零很多、数组很大时,它只有一次赋值操作,而交换法可能有三次。
  • 但交换法的代码更加紧凑、一次遍历,思路的通用性更强(很多题目都需要用交换完成原地移动)。

在真实面试中,能讲出这两种实现的差异和操作次数分析,是很大的加分项。


🚀 举一反三:从移动零到双指针万能模板

移动零的双指针思想,其实是解决**“数组分区”**问题的一把利刃。
核心思路都是:慢指针维护“已处理区域”的边界,快指针负责探查未处理区域。

同类变种题

  1. 移除元素(LeetCode 27)
    把指定值 val 移到末尾,但不要求保持非 val 元素的相对顺序
    → 可以用左右双指针碰撞,将右边的非 val 元素换到左边的 val 位置。
    如果要求保持顺序,则用快慢指针(同移动零)。

  2. 删除有序数组中的重复项(LeetCode 26)
    慢指针指向最后一个不重复元素的位置,快指针找下一个不同的元素,找到后慢指针前移并赋值。

  3. 删除有序数组中的重复项 II(LeetCode 80)
    允许最多出现两次,依然可用快慢指针,加一个计数变量。

你会发现,移动零刚好是这类问题里最简洁的模型,因为它只有两类值:零和非零,且要保持非零的相对顺序。掌握它,再复杂的数组分区问题都能迎刃而解。


🧠 还有更骚的操作吗?

有些同学可能会问:能不能用左右指针夹逼?
比如左指针找零,右指针找非零,然后交换?
但这样会打乱非零元素的相对顺序!题目严格要求保持顺序,所以左右指针并不适用。除非题目放宽条件,不然就要守住快慢指针的方案。

另外,如果数组中不只有零,还有负数、其他特殊值,要求将零移到最后,负数移到前面,但保持各自相对顺序,怎么办?
这就升级为“荷兰国旗问题”的变种,需要用三指针(low, mid, high)来解决。刷题之路永无止境呀~


✨ 总结:一个“移动零”,万行代码基

从青铜到王者的这段旅程,我们经历了:

解法核心思路时间空间特点
额外数组新建数组收集非零再补零O(n)O(n)违反原地要求
双指针两次遍历先搬非零,再补零O(n)O(1)操作数少,思维直接
双指针一次交换快慢指针边找边换O(n)O(1)代码简洁,一次遍历

双指针的核心心法只有一句话:

慢指针守住“已完成区域”的边界,快指针在前面探路,把符合条件的元素交换或赋值到慢指针的位置。

下次再看到数组重排、去重、移除元素,不妨先想想能不能用快慢指针优雅解决。


如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、关注~
在评论区留下你的想法,或者聊聊你遇到过的“看似简单实则暗藏杀机”的面试题,我们一起探讨。🧑‍💻

刷题不止,思考不息,我们下一题见!