好的,我们这次来详细讲解 LeetCode 第 283 题「移动零」。
这道题非常经典,是数组类问题中“双指针”技巧的一个绝佳入门练习。它不仅能考察你对数组的基本操作,更重要的是能让你理解如何通过一次遍历高效地重新排列数组元素。
第一步:理解题目
题目描述:
给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。
示例 1:
输入: nums = [0,1,0,3,12]
输出: [1,3,12,0,0]
示例 2:
输入: nums = [0]
输出: [0]
说明:
- 必须在原数组上操作,不能拷贝额外的数组。
- 尽量减少操作次数(即尽量只遍历一次数组)。
题目核心要求分析:
- 功能要求:将所有
0移到数组末尾。 - 质量要求:保持非零元素的原始相对顺序。
- 空间要求:原地修改,空间复杂度应为 O(1)。
- 效率要求:时间复杂度应尽可能低,理想情况是 O(n)。
第二步:从最直观的想法开始(可能会超时或不符合要求)
我们最容易想到的方法可能是“冒泡”或者“遇到0就与后面的非0交换”。但这种方法效率很低。
一个更直观但不符合“原地”要求的方法是:
- 创建一个和原数组一样长的新数组。
- 遍历原数组,把所有非零元素按顺序放入新数组。
- 然后把新数组剩下的位置全部填上0。
- 最后把新数组的值拷贝回原数组。
这个方法的缺点:
- 违反了“不能拷贝额外数组”的要求,空间复杂度是 O(n)。
- 我们追求的是原地修改的解决方案。
第三步:引入核心思想——双指针
为了满足原地修改和 O(n) 时间复杂度的要求,我们需要一种更聪明的方法。这里就要用到双指针技巧。
我们可以定义两个指针,通常我们称它们为:
slow指针(慢指针):它指向下一个非零元素应该被放置的位置。在slow指针之前的所有元素都是已经处理好的非零元素。fast指针(快指针):它作为扫描器,用来遍历整个数组,寻找非零元素。
两个指针的明确分工:
fast(侦察兵):负责探索新区域,发现非零资源。slow(建设者):负责在已探索的安全区内,按顺序安置这些非零资源。
初始状态:
两个指针都从数组的起始位置(索引 0)开始。
第四步:详细拆解双指针的操作步骤
让我们用示例 nums = [0, 1, 0, 3, 12] 来一步步模拟这个过程。
第 1 步:初始化
slow = 0, fast = 0
数组: [0, 1, 0, 3, 12]
s/f
第 2 步:fast 指向 0
nums[fast]是 0,这不是我们需要的“资源”。- 操作:我们不做任何交换或赋值,只让
fast前进一步,继续寻找。 slow保持不动,因为当前位置还没有安置好非零元素。- 结果:
slow = 0,fast = 1
第 3 步:fast 指向 1
nums[fast]是 1,这是一个非零资源!- 操作:我们需要把这个资源安置到
slow所指的位置。所以,执行nums[slow] = nums[fast],即nums[0] = nums[1]。 - 现在数组变成:
[1, 1, 0, 3, 12](注意,此时有两个1,这是暂时的)。 - 安置完成后,
slow和fast都前进一步,准备处理下一个位置。 - 结果:
slow = 1,fast = 2
第 4 步:fast 指向 0
nums[fast]是 0,不是资源,忽略。- 操作:只让
fast前进。 slow保持不动,因为我们要保证slow指向的位置是下一个非零元素的安放点。- 结果:
slow = 1,fast = 3
第 5 步:fast 指向 3
nums[fast]是 3,是非零资源!- 操作:执行
nums[slow] = nums[fast],即nums[1] = nums[3]。 - 数组变成:
[1, 3, 0, 3, 12](注意索引1和3的值)。 slow和fast都前进一步。- 结果:
slow = 2,fast = 4
第 6 步:fast 指向 12
nums[fast]是 12,是非零资源!- 操作:执行
nums[slow] = nums[fast],即nums[2] = nums[4]。 - 数组变成:
[1, 3, 12, 3, 12]。 slow和fast都前进一步。- 结果:
slow = 3,fast = 5(fast已超出数组范围,循环结束)
最终处理:
现在,fast 已经遍历完了整个数组。我们已经成功地将所有非零元素 [1, 3, 12] 按顺序移动到了数组的前部,位置是 0, 1, 2。
但是,从 slow 指针(索引3)开始到数组末尾的位置,还保留着原来的值 [3, 12]。这些位置现在应该被清零。
所以,我们需要一个额外的循环,将从 slow 到末尾的所有元素设置为 0。
nums[3] = 0nums[4] = 0- 最终数组:
[1, 3, 12, 0, 0]✅
第五步:总结算法并写出代码
根据上面的步骤,我们可以清晰地写出代码:
class Solution:
def moveZeroes(self, nums: List[int]) -> None:
"""
Do not return anything, modify nums in-place instead.
"""
# 步骤1: 初始化慢指针
slow = 0
# 步骤2: 快指针遍历整个数组
for fast in range(len(nums)):
# 如果快指针指向的元素非零
if nums[fast] != 0:
# 将该非零元素安置到慢指针的位置
nums[slow] = nums[fast]
# 慢指针前进一步
slow += 1
# 步骤3: 将慢指针之后的所有位置置为零
for i in range(slow, len(nums)):
nums[i] = 0
复杂度分析:
- 时间复杂度:O(n)。我们使用
fast指针遍历了整个数组一次 (n 步),然后使用一个循环将剩余部分清零(最多 n 步),所以总时间是 O(2n) = O(n)。 - 空间复杂度:O(1)。我们只使用了常数级别的额外空间(
slow,fast,i等变量)。
第六步:优化(一次遍历,减少操作)
上面的方法需要两次遍历。我们能否在第一次遍历时,在移动非零元素的同时,直接处理好0的位置,从而避免第二次遍历呢?
答案是肯定的,我们可以使用交换(swap) 来代替赋值+清零。
优化思路:
- 当
fast指针遇到非零元素时,我们将其与slow指针所指的元素进行交换。 - 交换后,
slow位置得到了非零元素,fast位置得到了slow位置原来的值(可能是0,也可能是其他值,但无关紧要)。 - 这样,在遍历过程中,
slow指针左侧都是非零数,slow与fast之间都是0(如果有的话)。当遍历结束时,所有0已经被自然地被“挤”到了右侧。
优化后的代码:
class Solution:
def moveZeroes(self, nums: List[int]) -> None:
slow = 0
for fast in range(len(nums)):
if nums[fast] != 0:
# 交换 slow 和 fast 指向的元素
nums[slow], nums[fast] = nums[fast], nums[slow]
slow += 1
用示例 [0,1,0,3,12] 快速验证优化版:
- fast=0 (0): 不交换,slow=0, fast=1
- fast=1 (1): 交换 nums[0]和nums[1] ->
[1,0,0,3,12], slow=1, fast=2 - fast=2 (0): 不交换,slow=1, fast=3
- fast=3 (3): 交换 nums[1]和nums[3] ->
[1,3,0,0,12], slow=2, fast=4 - fast=4 (12): 交换 nums[2]和nums[4] ->
[1,3,12,0,0], slow=3, fast=5 (结束)
可以看到,一次遍历后就直接得到了正确结果,无需额外的清零步骤。这是更优雅的实现。
第七步:核心要点回顾
- 问题本质:在不改变非零元素顺序的前提下,对数组进行分区(Partition)。
- 核心技巧:双指针。一个指针(
slow)用于维护“已处理好的非零序列”的边界,另一个指针(fast)用于探索未知区域。 - 关键理解:
slow指针的左边是已经处理好的部分,它指向的位置是下一个非零元素的家。 - 两种实现:
- 赋值+清零:逻辑清晰,易于理解,需要两次遍历。
- 交换:代码更简洁,一次遍历完成,是更优的解法。
这道题是理解双指针处理数组问题的基石,希望这个循序渐进的讲解能让你彻底掌握它!