LeetCode 第 283 题「移动零」
字数 3228 2025-10-25 20:59:30

好的,我们这次来详细讲解 LeetCode 第 283 题「移动零」

这道题非常经典,是数组类问题中“双指针”技巧的一个绝佳入门练习。它不仅能考察你对数组的基本操作,更重要的是能让你理解如何通过一次遍历高效地重新排列数组元素。


第一步:理解题目

题目描述:

给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。

示例 1:

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

示例 2:

输入: nums = [0]
输出: [0]

说明:

  1. 必须在原数组上操作,不能拷贝额外的数组。
  2. 尽量减少操作次数(即尽量只遍历一次数组)。

题目核心要求分析:

  1. 功能要求:将所有 0 移到数组末尾。
  2. 质量要求:保持非零元素的原始相对顺序
  3. 空间要求原地修改,空间复杂度应为 O(1)。
  4. 效率要求:时间复杂度应尽可能低,理想情况是 O(n)。

第二步:从最直观的想法开始(可能会超时或不符合要求)

我们最容易想到的方法可能是“冒泡”或者“遇到0就与后面的非0交换”。但这种方法效率很低。

一个更直观但不符合“原地”要求的方法是:

  1. 创建一个和原数组一样长的新数组。
  2. 遍历原数组,把所有非零元素按顺序放入新数组。
  3. 然后把新数组剩下的位置全部填上0。
  4. 最后把新数组的值拷贝回原数组。

这个方法的缺点:

  • 违反了“不能拷贝额外数组”的要求,空间复杂度是 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,这是暂时的)。
  • 安置完成后,slowfast 都前进一步,准备处理下一个位置。
  • 结果: 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的值)。
  • slowfast 都前进一步。
  • 结果: slow = 2, fast = 4

第 6 步:fast 指向 12

  • nums[fast] 是 12,是非零资源!
  • 操作:执行 nums[slow] = nums[fast],即 nums[2] = nums[4]
  • 数组变成: [1, 3, 12, 3, 12]
  • slowfast 都前进一步。
  • 结果: slow = 3, fast = 5fast 已超出数组范围,循环结束)

最终处理:
现在,fast 已经遍历完了整个数组。我们已经成功地将所有非零元素 [1, 3, 12] 按顺序移动到了数组的前部,位置是 0, 1, 2
但是,从 slow 指针(索引3)开始到数组末尾的位置,还保留着原来的值 [3, 12]。这些位置现在应该被清零。

所以,我们需要一个额外的循环,将从 slow 到末尾的所有元素设置为 0。

  • nums[3] = 0
  • nums[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 指针左侧都是非零数,slowfast 之间都是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] 快速验证优化版:

  1. fast=0 (0): 不交换,slow=0, fast=1
  2. fast=1 (1): 交换 nums[0]和nums[1] -> [1,0,0,3,12], slow=1, fast=2
  3. fast=2 (0): 不交换,slow=1, fast=3
  4. fast=3 (3): 交换 nums[1]和nums[3] -> [1,3,0,0,12], slow=2, fast=4
  5. fast=4 (12): 交换 nums[2]和nums[4] -> [1,3,12,0,0], slow=3, fast=5 (结束)

可以看到,一次遍历后就直接得到了正确结果,无需额外的清零步骤。这是更优雅的实现。


第七步:核心要点回顾

  1. 问题本质:在不改变非零元素顺序的前提下,对数组进行分区(Partition)。
  2. 核心技巧双指针。一个指针(slow)用于维护“已处理好的非零序列”的边界,另一个指针(fast)用于探索未知区域。
  3. 关键理解slow 指针的左边是已经处理好的部分,它指向的位置是下一个非零元素的家。
  4. 两种实现
    • 赋值+清零:逻辑清晰,易于理解,需要两次遍历。
    • 交换:代码更简洁,一次遍历完成,是更优的解法。

这道题是理解双指针处理数组问题的基石,希望这个循序渐进的讲解能让你彻底掌握它!

好的,我们这次来详细讲解 LeetCode 第 283 题「移动零」 。 这道题非常经典,是数组类问题中“双指针”技巧的一个绝佳入门练习。它不仅能考察你对数组的基本操作,更重要的是能让你理解如何通过一次遍历高效地重新排列数组元素。 第一步:理解题目 题目描述: 给定一个数组 nums ,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。 示例 1: 示例 2: 说明: 必须在原数组上操作,不能拷贝额外的数组。 尽量减少操作次数(即尽量只遍历一次数组)。 题目核心要求分析: 功能要求 :将所有 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] = 0 nums[4] = 0 最终数组: [1, 3, 12, 0, 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已经被自然地被“挤”到了右侧。 优化后的代码: 用示例 [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 指针的左边是已经处理好的部分,它指向的位置是下一个非零元素的家。 两种实现 : 赋值+清零 :逻辑清晰,易于理解,需要两次遍历。 交换 :代码更简洁,一次遍历完成,是更优的解法。 这道题是理解双指针处理数组问题的基石,希望这个循序渐进的讲解能让你彻底掌握它!