目录
- [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 理解题目
本题要求将一个数组中的所有零元素移动到数组的末尾,同时保持非零元素的相对顺序不变。关键要求如下:
- 原地操作:不能使用额外的数组空间,必须直接在原数组上修改。
- 保持顺序:非零元素的相对顺序不能改变。
- 零元素后移:所有零元素必须移动到数组末尾。
- 最小化操作:尽可能减少操作次数(赋值、交换等操作)。
2.2 关键挑战
- 原地操作限制:不能使用额外的数组存储结果,增加了算法设计的难度。
- 顺序保持要求:需要在移动过程中保持非零元素的原始顺序。
- 操作次数优化:在保证正确性的前提下,尽量减少数组元素的赋值或交换操作。
- 边界条件处理:需要考虑数组为空、全零、全非零等特殊情况。
2.3 核心洞察
通过对问题的深入分析,我们可以得出以下几个关键洞察:
- 双指针思想:使用两个指针可以高效地在一次遍历中完成操作,一个指针用于遍历数组,另一个指针指向下一个非零元素应该放置的位置。
- 零元素处理策略 :
- 方法一:先移动非零元素,再填充零(需要两次遍历)
- 方法二:边遍历边交换(一次遍历完成)
- 操作次数优化 :
- 尽量减少不必要的赋值操作
- 对于零元素较多的数组,交换法可能增加操作次数
- 对于非零元素较多的数组,填充法可能更优
破题关键
- 快慢指针法 :使用慢指针
slow记录下一个非零元素应该放置的位置,快指针fast遍历数组。当fast遇到非零元素时,将其复制到slow位置,然后两个指针都向前移动。 - 交换法:同样是双指针,但遇到非零元素时与慢指针交换,这样可以避免最后填充零的操作。
- 计数法:先统计零的个数,然后将非零元素按顺序前移,最后在末尾填充零。
- 优化策略:根据数组特点选择合适的方法,减少不必要的操作次数。
3.算法设计与实现
3.1 双指针填充法
核心思想
使用两个指针,快指针遍历整个数组,慢指针记录下一个非零元素应该放置的位置。遍历结束后,将慢指针之后的所有位置填充为零。
算法思路
- 初始化指针 :设置慢指针
slow = 0,指向下一个非零元素应该放置的位置 - 遍历数组 :快指针
fast从0开始遍历数组- 如果
nums[fast] != 0,将nums[fast]赋值给nums[slow],然后slow++ - 如果
nums[fast] == 0,继续遍历
- 如果
- 填充零 :遍历结束后,将
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 双指针交换法
核心思想
使用两个指针,快指针遍历数组,慢指针指向下一个非零元素应该放置的位置。当快指针遇到非零元素时,与慢指针指向的元素交换位置。
算法思路
- 初始化指针 :设置慢指针
slow = 0 - 遍历数组 :快指针
fast从0开始遍历数组- 如果
nums[fast] != 0,交换nums[fast]和nums[slow],然后slow++ - 如果
nums[fast] == 0,继续遍历
- 如果
- 无需额外操作:遍历结束后,所有非零元素已经在前面,零元素自然在末尾
时间复杂度分析
- 遍历数组一次: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 计数移位法
核心思想
先统计数组中零的个数,然后将非零元素按顺序前移,最后在数组末尾填充相应数量的零。
算法思路
- 统计零的个数:遍历数组,统计零元素的数量
- 前移非零元素 :
- 使用一个指针
index记录当前应该放置非零元素的位置 - 遍历数组,将非零元素依次放到
index位置,然后index++
- 使用一个指针
- 填充零:在数组末尾填充统计得到的零的个数
时间复杂度分析
- 统计零的个数: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 双指针优化法
核心思想
在双指针填充法的基础上进行优化,避免不必要的赋值操作。当快指针和慢指针不相等时才进行赋值操作。
算法思路
- 初始化指针 :设置慢指针
slow = 0 - 遍历数组 :快指针
fast从0开始遍历数组- 如果
nums[fast] != 0- 如果
fast != slow,将nums[fast]赋值给nums[slow],然后将nums[fast]设为0(可选) slow++
- 如果
- 如果
nums[fast] == 0,继续遍历
- 如果
- 无需额外填充:在遍历过程中,如果进行了赋值操作,可以将原位置设为零,但需要注意不要破坏未处理的元素
时间复杂度分析
- 遍历数组一次: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 使用额外空间
核心思想
创建一个新数组,将原数组中的非零元素按顺序放入新数组,然后在末尾补零。这种方法不符合题目原地操作的要求,但可以作为对比思路。
算法思路
- 创建新数组:创建一个与原数组相同大小的新数组
- 复制非零元素:遍历原数组,将非零元素按顺序放入新数组
- 填充零:将新数组剩余位置填充为零
- 复制回原数组:将新数组的内容复制回原数组(这一步是为了满足函数签名要求)
时间复杂度分析
- 遍历原数组一次: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 性能分析
-
操作次数分析:
- 赋值操作:将元素从一个位置复制到另一个位置
- 交换操作:交换两个元素的位置
- 在实际硬件中,赋值操作通常比交换操作更快,因为交换需要三次赋值
-
最佳情况:
- 数组中没有零:双指针优化法只需n次检查,0次赋值
- 数组中全是零:双指针填充法只需n次检查,0次赋值(因为slow始终为0)
-
最坏情况:
- 非零元素和零元素交替出现:双指针交换法需要最多的交换操作
4.3 选择建议
- 通用场景:双指针交换法,代码简洁,性能良好
- 零元素较多:双指针填充法,避免不必要的交换
- 非零元素较多:双指针优化法,减少赋值操作
- 教学场景:计数移位法,逻辑清晰易于理解
5.边界情况处理
5.1 常见边界情况
- 空数组或null输入:直接返回
- 单元素数组:[0]或[1]都应保持不变
- 全零数组:数组已经是目标状态
- 全非零数组:数组无需任何修改
- 零在开头:需要将零移到末尾
- 零在末尾:数组已经是目标状态
- 零在中间:需要将零移到末尾,保持非零元素顺序
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 边界处理技巧
- 空数组检查 :方法开头检查
nums == null || nums.length == 0 - 单元素数组:无需特殊处理,算法自然正确处理
- 全零数组:慢指针始终为0,遍历后填充零的操作会覆盖所有位置(但实际可以优化)
- 全非零数组:快慢指针同步移动,无需任何赋值或交换操作
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 核心知识点总结
- 双指针技巧:快慢指针是处理数组原地修改问题的核心技巧
- 原地操作思想:在不使用额外空间的情况下修改数组
- 操作次数优化:根据具体情况选择合适的策略减少操作次数
- 边界条件处理:考虑各种极端情况确保算法健壮性
7.2 算法思维提升
- 问题转化能力:将"移动零"问题转化为"重新排列非零元素"问题
- 指针运用能力:熟练运用快慢指针解决数组操作问题
- 优化思维能力:思考如何减少不必要的赋值和交换操作
- 代码简洁性:用最简洁的代码实现功能
7.3 实际应用场景
- 数据清洗:在处理数据时,将无效值(如0、null等)移动到末尾
- 内存优化:在内存有限的系统中,原地操作可以节省内存
- 实时系统:减少数据拷贝,提高系统响应速度
- 数据库优化:类似操作可用于数据库记录的重排