LeetCode算法题详解 283:移动零

目录

  • [1. 问题描述](#1. 问题描述)
  • 2.问题分析
    • [2.1 理解题目](#2.1 理解题目)
    • [2.2 关键挑战](#2.2 关键挑战)
    • [2.3 核心洞察](#2.3 核心洞察)
  • 3.算法设计与实现
    • [3.1 双指针填充法](#3.1 双指针填充法)
    • [3.2 双指针交换法](#3.2 双指针交换法)
    • [3.3 计数移位法](#3.3 计数移位法)
    • [3.4 双指针优化法](#3.4 双指针优化法)
    • [3.5 使用额外空间](#3.5 使用额外空间)
  • 4.复杂度对比分析
    • [4.1 复杂度对比](#4.1 复杂度对比)
    • [4.2 性能分析](#4.2 性能分析)
    • [4.3 选择建议](#4.3 选择建议)
  • 5.边界情况处理
    • [5.1 常见边界情况](#5.1 常见边界情况)
    • [5.2 边界测试用例](#5.2 边界测试用例)
    • [5.3 边界处理技巧](#5.3 边界处理技巧)
  • 6.扩展与变体
    • [6.1 将特定值移动到末尾](#6.1 将特定值移动到末尾)
    • [6.2 将零移动到开头](#6.2 将零移动到开头)
    • [6.3 按照自定义规则排序](#6.3 按照自定义规则排序)
    • [6.4 删除数组中的重复项](#6.4 删除数组中的重复项)
    • [6.5 颜色分类](#6.5 颜色分类)
  • 7.总结
    • [7.1 核心知识点总结](#7.1 核心知识点总结)
    • [7.2 算法思维提升](#7.2 算法思维提升)
    • [7.3 实际应用场景](#7.3 实际应用场景)

1. 问题描述

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

请注意,必须在不复制数组的情况下原地对数组进行操作。

示例 1

复制代码
输入: nums = [0,1,0,3,12]
输出: [1,3,12,0,0]
解释: 将所有的0移动到末尾,保持非零元素的相对顺序。

示例 2

复制代码
输入: nums = [0]
输出: [0]
解释: 数组只有一个元素0,无需移动。

约束条件

  • 1 <= nums.length <= 10^4
  • -2^31 <= nums[i] <= 2^31 - 1

进阶要求

你能尽量减少完成的操作次数吗?

2.问题分析

2.1 理解题目

本题要求将一个数组中的所有零元素移动到数组的末尾,同时保持非零元素的相对顺序不变。关键要求如下:

  1. 原地操作:不能使用额外的数组空间,必须直接在原数组上修改。
  2. 保持顺序:非零元素的相对顺序不能改变。
  3. 零元素后移:所有零元素必须移动到数组末尾。
  4. 最小化操作:尽可能减少操作次数(赋值、交换等操作)。

2.2 关键挑战

  1. 原地操作限制:不能使用额外的数组存储结果,增加了算法设计的难度。
  2. 顺序保持要求:需要在移动过程中保持非零元素的原始顺序。
  3. 操作次数优化:在保证正确性的前提下,尽量减少数组元素的赋值或交换操作。
  4. 边界条件处理:需要考虑数组为空、全零、全非零等特殊情况。

2.3 核心洞察

通过对问题的深入分析,我们可以得出以下几个关键洞察:

  1. 双指针思想:使用两个指针可以高效地在一次遍历中完成操作,一个指针用于遍历数组,另一个指针指向下一个非零元素应该放置的位置。
  2. 零元素处理策略
    • 方法一:先移动非零元素,再填充零(需要两次遍历)
    • 方法二:边遍历边交换(一次遍历完成)
  3. 操作次数优化
    • 尽量减少不必要的赋值操作
    • 对于零元素较多的数组,交换法可能增加操作次数
    • 对于非零元素较多的数组,填充法可能更优

破题关键

  1. 快慢指针法 :使用慢指针slow记录下一个非零元素应该放置的位置,快指针fast遍历数组。当fast遇到非零元素时,将其复制到slow位置,然后两个指针都向前移动。
  2. 交换法:同样是双指针,但遇到非零元素时与慢指针交换,这样可以避免最后填充零的操作。
  3. 计数法:先统计零的个数,然后将非零元素按顺序前移,最后在末尾填充零。
  4. 优化策略:根据数组特点选择合适的方法,减少不必要的操作次数。

3.算法设计与实现

3.1 双指针填充法

核心思想

使用两个指针,快指针遍历整个数组,慢指针记录下一个非零元素应该放置的位置。遍历结束后,将慢指针之后的所有位置填充为零。

算法思路

  1. 初始化指针 :设置慢指针slow = 0,指向下一个非零元素应该放置的位置
  2. 遍历数组 :快指针fast从0开始遍历数组
    • 如果nums[fast] != 0,将nums[fast]赋值给nums[slow],然后slow++
    • 如果nums[fast] == 0,继续遍历
  3. 填充零 :遍历结束后,将slow到数组末尾的所有元素设置为0

时间复杂度分析

  • 遍历数组一次:O(n)
  • 填充零一次:O(k),其中k为零的个数
  • 总时间复杂度:O(n)

空间复杂度分析

  • 只使用了常数级别的额外空间:O(1)

代码实现

java 复制代码
public class MoveZeroesFill {
    public void moveZeroes(int[] nums) {
        if (nums == null || nums.length == 0) {
            return;
        }
        
        // 慢指针,记录下一个非零元素应该放置的位置
        int slow = 0;
        
        // 第一步:将所有的非零元素移到前面
        for (int fast = 0; fast < nums.length; fast++) {
            if (nums[fast] != 0) {
                nums[slow] = nums[fast];
                slow++;
            }
        }
        
        // 第二步:将剩余位置填充为零
        for (int i = slow; i < nums.length; i++) {
            nums[i] = 0;
        }
    }
}

3.2 双指针交换法

核心思想

使用两个指针,快指针遍历数组,慢指针指向下一个非零元素应该放置的位置。当快指针遇到非零元素时,与慢指针指向的元素交换位置。

算法思路

  1. 初始化指针 :设置慢指针slow = 0
  2. 遍历数组 :快指针fast从0开始遍历数组
    • 如果nums[fast] != 0,交换nums[fast]nums[slow],然后slow++
    • 如果nums[fast] == 0,继续遍历
  3. 无需额外操作:遍历结束后,所有非零元素已经在前面,零元素自然在末尾

时间复杂度分析

  • 遍历数组一次:O(n)
  • 交换操作次数等于非零元素个数
  • 总时间复杂度:O(n)

空间复杂度分析

  • 只使用了常数级别的额外空间:O(1)

代码实现

java 复制代码
public class MoveZeroesSwap {
    public void moveZeroes(int[] nums) {
        if (nums == null || nums.length == 0) {
            return;
        }
        
        // 慢指针,记录下一个非零元素应该放置的位置
        int slow = 0;
        
        for (int fast = 0; fast < nums.length; fast++) {
            // 当快指针遇到非零元素时,与慢指针交换
            if (nums[fast] != 0) {
                // 交换元素
                int temp = nums[slow];
                nums[slow] = nums[fast];
                nums[fast] = temp;
                
                // 慢指针向前移动
                slow++;
            }
        }
    }
}

3.3 计数移位法

核心思想

先统计数组中零的个数,然后将非零元素按顺序前移,最后在数组末尾填充相应数量的零。

算法思路

  1. 统计零的个数:遍历数组,统计零元素的数量
  2. 前移非零元素
    • 使用一个指针index记录当前应该放置非零元素的位置
    • 遍历数组,将非零元素依次放到index位置,然后index++
  3. 填充零:在数组末尾填充统计得到的零的个数

时间复杂度分析

  • 统计零的个数:O(n)
  • 前移非零元素:O(n)
  • 填充零:O(k),其中k为零的个数
  • 总时间复杂度:O(n)

空间复杂度分析

  • 只使用了常数级别的额外空间:O(1)

代码实现

java 复制代码
public class MoveZeroesCount {
    public void moveZeroes(int[] nums) {
        if (nums == null || nums.length == 0) {
            return;
        }
        
        // 统计零的个数
        int zeroCount = 0;
        for (int num : nums) {
            if (num == 0) {
                zeroCount++;
            }
        }
        
        // 前移非零元素
        int index = 0;
        for (int num : nums) {
            if (num != 0) {
                nums[index] = num;
                index++;
            }
        }
        
        // 填充零
        for (int i = index; i < nums.length; i++) {
            nums[i] = 0;
        }
    }
}

3.4 双指针优化法

核心思想

在双指针填充法的基础上进行优化,避免不必要的赋值操作。当快指针和慢指针不相等时才进行赋值操作。

算法思路

  1. 初始化指针 :设置慢指针slow = 0
  2. 遍历数组 :快指针fast从0开始遍历数组
    • 如果nums[fast] != 0
      • 如果fast != slow,将nums[fast]赋值给nums[slow],然后将nums[fast]设为0(可选)
      • slow++
    • 如果nums[fast] == 0,继续遍历
  3. 无需额外填充:在遍历过程中,如果进行了赋值操作,可以将原位置设为零,但需要注意不要破坏未处理的元素

时间复杂度分析

  • 遍历数组一次:O(n)
  • 赋值操作次数等于非零元素个数
  • 总时间复杂度:O(n)

空间复杂度分析

  • 只使用了常数级别的额外空间:O(1)

代码实现

java 复制代码
public class MoveZeroesOptimized {
    public void moveZeroes(int[] nums) {
        if (nums == null || nums.length == 0) {
            return;
        }
        
        // 慢指针,记录下一个非零元素应该放置的位置
        int slow = 0;
        
        for (int fast = 0; fast < nums.length; fast++) {
            if (nums[fast] != 0) {
                // 只有当快慢指针不相等时才需要赋值
                if (fast != slow) {
                    nums[slow] = nums[fast];
                    nums[fast] = 0; // 将原位置设为0
                }
                slow++;
            }
        }
    }
}

另一种优化版本(避免不必要的零赋值)

java 复制代码
public class MoveZeroesOptimized2 {
    public void moveZeroes(int[] nums) {
        if (nums == null || nums.length == 0) return;
        
        int slow = 0;
        for (int fast = 0; fast < nums.length; fast++) {
            if (nums[fast] != 0) {
                if (fast != slow) {
                    nums[slow] = nums[fast];
                    // 不立即将nums[fast]设为0,最后统一处理
                }
                slow++;
            }
        }
        
        // 将剩余位置设为0
        for (int i = slow; i < nums.length; i++) {
            nums[i] = 0;
        }
    }
}

3.5 使用额外空间

核心思想

创建一个新数组,将原数组中的非零元素按顺序放入新数组,然后在末尾补零。这种方法不符合题目原地操作的要求,但可以作为对比思路。

算法思路

  1. 创建新数组:创建一个与原数组相同大小的新数组
  2. 复制非零元素:遍历原数组,将非零元素按顺序放入新数组
  3. 填充零:将新数组剩余位置填充为零
  4. 复制回原数组:将新数组的内容复制回原数组(这一步是为了满足函数签名要求)

时间复杂度分析

  • 遍历原数组一次:O(n)
  • 复制回原数组一次:O(n)
  • 总时间复杂度:O(n)

空间复杂度分析

  • 需要额外创建一个数组:O(n)

代码实现

java 复制代码
public class MoveZeroesExtraSpace {
    public void moveZeroes(int[] nums) {
        if (nums == null || nums.length == 0) {
            return;
        }
        
        // 创建新数组
        int[] result = new int[nums.length];
        int index = 0;
        
        // 复制非零元素
        for (int num : nums) {
            if (num != 0) {
                result[index] = num;
                index++;
            }
        }
        
        // 剩余位置已经是0(Java数组默认初始化为0)
        // 复制回原数组
        System.arraycopy(result, 0, nums, 0, nums.length);
    }
}

4.复杂度对比分析

4.1 复杂度对比

方法 时间复杂度 空间复杂度 赋值操作次数 交换操作次数 优点 缺点
双指针填充法 O(n) O(1) n + k 0 思路清晰,代码简单 需要两次遍历,零多时效率低
双指针交换法 O(n) O(1) 0 m(非零元素数) 一次遍历完成 交换操作可能较多
计数移位法 O(n) O(1) n + k 0 逻辑简单,易于理解 需要两次遍历
双指针优化法 O(n) O(1) m + k 0 操作次数较少 代码稍复杂
使用额外空间 O(n) O(n) 2n 0 思路最简单 不符合原地要求

4.2 性能分析

  1. 操作次数分析

    • 赋值操作:将元素从一个位置复制到另一个位置
    • 交换操作:交换两个元素的位置
    • 在实际硬件中,赋值操作通常比交换操作更快,因为交换需要三次赋值
  2. 最佳情况

    • 数组中没有零:双指针优化法只需n次检查,0次赋值
    • 数组中全是零:双指针填充法只需n次检查,0次赋值(因为slow始终为0)
  3. 最坏情况

    • 非零元素和零元素交替出现:双指针交换法需要最多的交换操作

4.3 选择建议

  • 通用场景:双指针交换法,代码简洁,性能良好
  • 零元素较多:双指针填充法,避免不必要的交换
  • 非零元素较多:双指针优化法,减少赋值操作
  • 教学场景:计数移位法,逻辑清晰易于理解

5.边界情况处理

5.1 常见边界情况

  1. 空数组或null输入:直接返回
  2. 单元素数组:[0]或[1]都应保持不变
  3. 全零数组:数组已经是目标状态
  4. 全非零数组:数组无需任何修改
  5. 零在开头:需要将零移到末尾
  6. 零在末尾:数组已经是目标状态
  7. 零在中间:需要将零移到末尾,保持非零元素顺序

5.2 边界测试用例

java 复制代码
// 测试各种边界情况
int[][] testCases = {
    {},                    // 空数组
    {0},                   // 单个零
    {1},                   // 单个非零
    {0, 0, 0},             // 全零
    {1, 2, 3},             // 全非零
    {0, 1, 2, 3},          // 零在开头
    {1, 2, 3, 0},          // 零在末尾
    {1, 0, 2, 0, 3, 0},    // 零在中间
    {0, 1, 0, 3, 12},      // 示例用例
    {1, 0, 0, 0, 2, 0, 3}, // 多个连续零
};

5.3 边界处理技巧

  1. 空数组检查 :方法开头检查nums == null || nums.length == 0
  2. 单元素数组:无需特殊处理,算法自然正确处理
  3. 全零数组:慢指针始终为0,遍历后填充零的操作会覆盖所有位置(但实际可以优化)
  4. 全非零数组:快慢指针同步移动,无需任何赋值或交换操作

6.扩展与变体

6.1 将特定值移动到末尾

将数组中的所有特定值(如-1)移动到末尾,保持其他元素的相对顺序。

java 复制代码
public class MoveSpecificValue {
    public void moveValue(int[] nums, int target) {
        if (nums == null || nums.length == 0) {
            return;
        }
        
        int slow = 0;
        for (int fast = 0; fast < nums.length; fast++) {
            if (nums[fast] != target) {
                // 交换元素
                int temp = nums[slow];
                nums[slow] = nums[fast];
                nums[fast] = temp;
                slow++;
            }
        }
    }
}

6.2 将零移动到开头

将所有的零移动到数组开头,保持非零元素的相对顺序。

java 复制代码
public class MoveZeroesToFront {
    public void moveZeroesToFront(int[] nums) {
        if (nums == null || nums.length == 0) {
            return;
        }
        
        // 从后向前遍历,将非零元素移到后面
        int slow = nums.length - 1;
        for (int fast = nums.length - 1; fast >= 0; fast--) {
            if (nums[fast] != 0) {
                // 交换元素
                int temp = nums[slow];
                nums[slow] = nums[fast];
                nums[fast] = temp;
                slow--;
            }
        }
    }
}

6.3 按照自定义规则排序

将所有偶数移动到数组前面,奇数移动到后面,保持各自的相对顺序。

java 复制代码
public class MoveEvenToFront {
    public void moveEvenToFront(int[] nums) {
        if (nums == null || nums.length == 0) {
            return;
        }
        
        // 方法1:使用额外空间
        int[] result = new int[nums.length];
        int evenIndex = 0;
        int oddIndex = nums.length - 1;
        
        // 从后向前填充奇数和从前向后填充偶数
        for (int i = 0, j = nums.length - 1; i <= j;) {
            if (nums[i] % 2 == 0) {
                result[evenIndex++] = nums[i];
                i++;
            } else {
                result[oddIndex--] = nums[i];
                i++;
            }
            
            if (i <= j) {
                if (nums[j] % 2 == 0) {
                    result[evenIndex++] = nums[j];
                    j--;
                } else {
                    result[oddIndex--] = nums[j];
                    j--;
                }
            }
        }
        
        System.arraycopy(result, 0, nums, 0, nums.length);
    }
}

6.4 删除数组中的重复项

删除排序数组中的重复项,使每个元素只出现一次,并返回新的长度。

java 复制代码
public class RemoveDuplicates {
    public int removeDuplicates(int[] nums) {
        if (nums == null || nums.length == 0) {
            return 0;
        }
        
        int slow = 1; // 慢指针,指向下一个唯一元素应该放置的位置
        for (int fast = 1; fast < nums.length; fast++) {
            if (nums[fast] != nums[fast - 1]) {
                nums[slow] = nums[fast];
                slow++;
            }
        }
        
        return slow; // 返回新数组的长度
    }
}

6.5 颜色分类

将包含0、1、2的数组按0、1、2的顺序排序。

java 复制代码
public class SortColors {
    public void sortColors(int[] nums) {
        if (nums == null || nums.length == 0) {
            return;
        }
        
        // 三指针法
        int left = 0;      // 指向0的右边界
        int right = nums.length - 1; // 指向2的左边界
        int current = 0;   // 当前遍历指针
        
        while (current <= right) {
            if (nums[current] == 0) {
                // 交换到左边
                swap(nums, left, current);
                left++;
                current++;
            } else if (nums[current] == 2) {
                // 交换到右边
                swap(nums, current, right);
                right--;
                // 注意:这里current不增加,因为从右边交换过来的元素还未检查
            } else {
                // nums[current] == 1,保持不变
                current++;
            }
        }
    }
    
    private void swap(int[] nums, int i, int j) {
        int temp = nums[i];
        nums[i] = nums[j];
        nums[j] = temp;
    }
}

7.总结

7.1 核心知识点总结

  1. 双指针技巧:快慢指针是处理数组原地修改问题的核心技巧
  2. 原地操作思想:在不使用额外空间的情况下修改数组
  3. 操作次数优化:根据具体情况选择合适的策略减少操作次数
  4. 边界条件处理:考虑各种极端情况确保算法健壮性

7.2 算法思维提升

  1. 问题转化能力:将"移动零"问题转化为"重新排列非零元素"问题
  2. 指针运用能力:熟练运用快慢指针解决数组操作问题
  3. 优化思维能力:思考如何减少不必要的赋值和交换操作
  4. 代码简洁性:用最简洁的代码实现功能

7.3 实际应用场景

  1. 数据清洗:在处理数据时,将无效值(如0、null等)移动到末尾
  2. 内存优化:在内存有限的系统中,原地操作可以节省内存
  3. 实时系统:减少数据拷贝,提高系统响应速度
  4. 数据库优化:类似操作可用于数据库记录的重排
相关推荐
过河卒_zh156676617 小时前
喜讯:第十五批生成合成类算法备案备案号公布
人工智能·算法·aigc·生成式人工智能·算法备案
cpp_250117 小时前
B3927 [GESP202312 四级] 小杨的字典
数据结构·c++·算法·题解·洛谷
踩坑记录17 小时前
leetcode hot100 最长连续子序列 哈希表 medium
leetcode
Cx330❀17 小时前
《C++ 递归、搜索与回溯》第2-3题:合并两个有序链表,反转链表
开发语言·数据结构·c++·算法·链表·面试
AI科技星17 小时前
电磁耦合常数Z‘的第一性原理推导与严格验证:张祥前统一场论的几何基石
服务器·人工智能·线性代数·算法·矩阵
AI科技星17 小时前
电场起源的几何革命:变化的引力场产生电场方程的第一性原理推导、验证与统一性意义
开发语言·人工智能·线性代数·算法·机器学习·数学建模
中國龍在廣州17 小时前
“物理AI”吹响号角
大数据·人工智能·深度学习·算法·机器人·机器人学习
꧁Q༒ོγ꧂17 小时前
算法详解(二)--算法思想基础
java·数据结构·算法
꧁Q༒ོγ꧂17 小时前
算法详解(一)--算法系列开篇:什么是算法?
开发语言·c++·算法