文章目录
1. 题目描述
给定一个数组 nums
,编写一个函数将所有 0
移动到数组的末尾,同时保持非零元素的相对顺序。
请注意,必须在不复制数组的情况下原地对数组进行操作。
示例 1:
输入: nums = [0,1,0,3,12]
输出: [1,3,12,0,0]
示例 2:
输入: nums = [0]
输出: [0]
提示:
- 1 <= nums.length <= 10^4
- -2^31 <= nums[i] <= 2^31 - 1
进阶:
- 尽量减少操作次数
2. 理解题目
这道题目要求我们完成以下任务:
- 将数组中所有的0移动到数组的末尾
- 保持其他非零元素的相对顺序不变
- 必须在原数组上进行操作,不能创建新数组
- 尽量减少操作次数
关键点:
- 原地操作,不能使用额外数组
- 非零元素的相对顺序不能改变
- 操作次数越少越好
3. 解法一:暴力法(两层循环)
3.1 思路
最直观的方法是使用两层循环:
- 外层循环遍历数组
- 当遇到0时,使用内层循环将后面的所有元素都前移一位
- 然后在数组末尾补上0
- 由于移动元素后,当前位置可能还是0,需要再次检查
3.2 Java代码实现
public class Solution {
public void moveZeroes(int[] nums) {
int n = nums.length;
// 遍历数组
for (int i = 0; i < n; i++) {
// 如果当前元素是0
if (nums[i] == 0) {
// 找到下一个非0元素
int j = i + 1;
while (j < n && nums[j] == 0) {
j++;
}
// 如果找到了非0元素,交换
if (j < n) {
// 交换元素
nums[i] = nums[j];
nums[j] = 0;
} else {
// 如果后面没有非0元素了,直接结束
break;
}
}
}
}
}
3.3 代码详解
- 遍历数组的每个元素,索引为
i
- 当遇到值为0的元素时:
- 从
i+1
开始寻找下一个非0元素,索引为j
- 如果找到了非0元素(
j < n
),将其与当前位置的0交换 - 如果后面没有非0元素了(
j >= n
),说明所有0已经在末尾,可以直接结束循环
- 从
- 交换后,当前位置
i
变成了非0元素,继续处理下一个位置
3.4 复杂度分析
- 时间复杂度:O(n²),其中 n 是数组的长度。
- 最坏情况下,数组中除了最后一个元素是非0外,其余都是0,此时内层循环会执行 n-1, n-2, …, 1 次,总操作次数为 n(n-1)/2
- 空间复杂度:O(1),只使用了常数级别的额外空间。
3.5 适用场景
暴力法简单直观,但效率较低,主要适合小规模数据和教学目的。对于大型数组,此方法不够高效。
4. 解法二:双指针法
4.1 思路
双指针法是解决此问题的最优解之一:
- 使用两个指针:
slow
指向当前应该填入非0元素的位置,fast
用于遍历数组 - 当
fast
指针遇到非0元素时,将其移动到slow
指针位置,然后slow
指针前进一步 - 当数组遍历完成后,将
slow
指针之后的所有元素都设为0
这种方法的核心思想是:先将所有非0元素按顺序移到数组前面,然后再将剩余位置填充为0。
4.2 Java代码实现
public class Solution {
public void moveZeroes(int[] nums) {
if (nums == null || nums.length <= 1) {
return;
}
int slow = 0; // 指向应该放置非0元素的位置
// 第一步:将所有非0元素移到数组前面
for (int fast = 0; fast < nums.length; fast++) {
if (nums[fast] != 0) {
nums[slow] = nums[fast];
slow++;
}
}
// 第二步:将剩余位置填充为0
while (slow < nums.length) {
nums[slow] = 0;
slow++;
}
}
}
4.3 代码详解
- 初始化
slow
指针为0,表示下一个非0元素应该放置的位置 - 使用
fast
指针遍历数组:- 当遇到非0元素时,将其移动到
slow
指针位置,然后slow
指针后移一位 - 当遇到0时,仅将
fast
指针后移
- 当遇到非0元素时,将其移动到
- 遍历完成后,所有非0元素已经按顺序移到了数组前部
- 将
slow
指针之后的所有元素赋值为0,完成0的移动
4.4 复杂度分析
- 时间复杂度:O(n),其中 n 是数组的长度。只需要遍历数组一次,然后再遍历一次
slow
到数组末尾的元素。 - 空间复杂度:O(1),只使用了常数级别的额外空间。
4.5 适用场景
双指针法是解决此问题的最优解,适用于所有情况,特别是大型数组。
5. 解法三:双指针优化版(减少写操作)
5.1 思路
上述双指针法可以进一步优化,减少不必要的写操作:
- 只有当
fast
和slow
指针不同时,才进行元素交换 - 当
fast
和slow
指向同一个元素时,不需要任何操作
5.2 Java代码实现
public class Solution {
public void moveZeroes(int[] nums) {
if (nums == null || nums.length <= 1) {
return;
}
int slow = 0; // 指向已处理序列的末尾
for (int fast = 0; fast < nums.length; fast++) {
if (nums[fast] != 0) {
// 只有当slow和fast不同时才交换
if (slow != fast) {
nums[slow] = nums[fast];
nums[fast] = 0;
}
slow++;
}
}
}
}
5.3 代码详解
- 初始化
slow
指针为0 - 使用
fast
指针遍历数组:- 当遇到非0元素时,检查
slow
和fast
是否指向同一位置 - 如果指向不同位置,将
fast
指向的元素移到slow
位置,并将fast
位置设为0 - 无论是否交换,
slow
指针都要后移一位
- 当遇到非0元素时,检查
- 这种方法的优势在于不需要在遍历结束后填充0,因为在移动过程中已经将原位置填充为0
5.4 复杂度分析
- 时间复杂度:O(n),其中 n 是数组的长度。只需要遍历数组一次。
- 空间复杂度:O(1),只使用了常数级别的额外空间。
5.5 优化说明
这种优化避免了在原地移动非0元素后再填充0的步骤,但在某些情况下可能增加写操作:
- 当数组前面没有0时(如[1,2,3,0,0]),此方法会执行不必要的写操作
- 当数组中0很少时,第一种双指针法可能更高效
6. 解法四:交换法
6.1 思路
另一种优化思路是使用交换操作,而不是赋值:
- 使用
nonZeroPos
记录下一个非0元素应该放置的位置 - 遍历数组,当遇到非0元素时,将其与
nonZeroPos
位置的元素交换 - 交换后,
nonZeroPos
向后移动一位
这种方法的优势是保持了数组的"填充性质",即对于任意不等于0的元素,在处理完之后要么位于其原始位置,要么位于某个曾经存放0的位置。
6.2 Java代码实现
public class Solution {
public void moveZeroes(int[] nums) {
if (nums == null || nums.length <= 1) {
return;
}
int nonZeroPos = 0;
for (int i = 0; i < nums.length; i++) {
if (nums[i] != 0) {
// 交换元素
int temp = nums[nonZeroPos];
nums[nonZeroPos] = nums[i];
nums[i] = temp;
nonZeroPos++;
}
}
}
}
6.3 代码详解
- 初始化
nonZeroPos
为0,表示非0元素应该放置的位置 - 遍历数组,当遇到非0元素时:
- 将当前元素与
nonZeroPos
位置的元素交换 - 将
nonZeroPos
加1
- 将当前元素与
- 由于交换操作,遍历完成后,所有非0元素都在数组前部,所有0都在数组后部
6.4 复杂度分析
- 时间复杂度:O(n),其中 n 是数组的长度。只需要遍历数组一次。
- 空间复杂度:O(1),只使用了常数级别的额外空间。
6.5 适用场景
交换法适用于所有情况,但存在一个潜在问题:
- 当数组已经按要求排序(如所有非0元素在前,所有0在后)时,仍会执行不必要的交换操作
7. 解法五:快慢指针交换优化
7.1 思路
我们可以结合前面几种方法的优势,进一步优化算法:
- 使用快慢指针,
slow
指向已处理好的非0序列的末尾 - 只在
slow
指向0且fast
指向非0元素时才进行交换 - 这样可以避免不必要的交换操作
7.2 Java代码实现
public class Solution {
public void moveZeroes(int[] nums) {
if (nums == null || nums.length <= 1) {
return;
}
int slow = 0;
int fast = 0;
while (fast < nums.length) {
if (nums[fast] != 0) {
// 只有当slow指向0时才需要交换
if (nums[slow] == 0) {
// 交换元素
nums[slow] = nums[fast];
nums[fast] = 0;
}
slow++;
}
fast++;
}
}
}
7.3 代码详解
- 初始化
slow
和fast
都为0 - 使用
fast
指针遍历数组:- 当遇到非0元素时,检查
slow
位置是否为0 - 如果
slow
位置为0,交换slow
和fast
位置的元素 - 无论是否交换,
slow
都向后移动一位 fast
总是向后移动一位
- 当遇到非0元素时,检查
- 这种方法的优势在于只有在必要时才执行交换操作
7.4 复杂度分析
- 时间复杂度:O(n),其中 n 是数组的长度。只需要遍历数组一次。
- 空间复杂度:O(1),只使用了常数级别的额外空间。
7.5 适用场景
这种优化适用于各种情况,尤其是当数组中的0比较少或已经部分有序时,可以减少不必要的操作。
8. 特殊情况和边界处理
在实现解决方案时,我们需要考虑以下特殊情况:
-
空数组或只有一个元素的数组:这种情况下不需要任何操作,直接返回。
if (nums == null || nums.length <= 1) { return; }
-
没有0的数组:例如[1,2,3,4,5],不需要移动任何元素。
- 在双指针法中,
slow
会一直跟随fast
,不会产生任何移动 - 在交换法中,所有交换都是自己和自己交换,不会改变数组
- 在双指针法中,
-
全是0的数组:例如[0,0,0,0],不需要移动任何元素。
- 在双指针法中,
slow
会一直保持0,不会移动 - 在交换法中,不会进行任何交换
- 在双指针法中,
-
0在数组前面,非0在后面:例如[0,0,0,1,2],需要将所有非0元素移动到前面。
- 这种情况适合使用交换法,可以有效减少操作次数
9. 性能优化与改进
9.1 减少不必要的操作
在解法五中,我们已经通过检查slow
位置是否为0来减少不必要的交换。可以进一步优化:
public void moveZeroes(int[] nums) {
if (nums == null || nums.length <= 1) {
return;
}
int slow = 0;
// 首先找到第一个0的位置
while (slow < nums.length && nums[slow] != 0) {
slow++;
}
// 从第一个0的位置开始,只交换必要的元素
for (int fast = slow + 1; fast < nums.length; fast++) {
if (nums[fast] != 0) {
nums[slow] = nums[fast];
nums[fast] = 0;
slow++;
}
}
}
9.2 使用System.arraycopy优化
对于大型数组,可以考虑使用System.arraycopy
进行批量移动,减少单个元素操作:
public void moveZeroes(int[] nums) {
if (nums == null || nums.length <= 1) {
return;
}
int count = 0; // 计算0的个数
int[] nonZeros = new int[nums.length]; // 临时存储非0元素
int index = 0;
// 收集所有非0元素
for (int num : nums) {
if (num != 0) {
nonZeros[index++] = num;
} else {
count++;
}
}
// 将非0元素复制回原数组
System.arraycopy(nonZeros, 0, nums, 0, index);
// 填充0
Arrays.fill(nums, nums.length - count, nums.length, 0);
}
注意:这种方法使用了额外的空间,不符合原地操作的要求,仅作为思路参考。
10. 完整的 Java 解决方案
以下是最优解决方案的完整实现,兼顾了性能和代码简洁性:
class Solution {
public void moveZeroes(int[] nums) {
if (nums == null || nums.length <= 1) {
return;
}
// 使用双指针
int insertPos = 0;
// 第一次遍历:将所有非0元素向前移动
for (int i = 0; i < nums.length; i++) {
if (nums[i] != 0) {
nums[insertPos++] = nums[i];
}
}
// 第二次遍历:将剩余位置填充为0
while (insertPos < nums.length) {
nums[insertPos++] = 0;
}
}
}
11. 实际应用与扩展
11.1 应用场景
移动零的问题在实际编程中有多种应用场景:
- 数据预处理:在机器学习和数据分析中,常需要将缺失值(用0表示)移到数据末尾
- 图像处理:在图像处理中,可能需要将背景像素(值为0)与前景像素分离
- 游戏开发:在游戏中处理无效对象(表示为0)时,需要将它们移至数组末尾以便批量删除
- 内存管理:在某些内存管理算法中,需要将空闲块(标记为0)集中到一起以减少碎片
11.2 扩展问题
-
移动特定值:将任意给定值(而不仅仅是0)移动到数组末尾
public void moveElement(int[] nums, int val) { int insertPos = 0; for (int i = 0; i < nums.length; i++) { if (nums[i] != val) { nums[insertPos++] = nums[i]; } } while (insertPos < nums.length) { nums[insertPos++] = val; } }
-
移动多个值:将多个不同的值移动到数组末尾
public void moveElements(int[] nums, int[] vals) { Set<Integer> valSet = new HashSet<>(); for (int val : vals) { valSet.add(val); } int insertPos = 0; for (int i = 0; i < nums.length; i++) { if (!valSet.contains(nums[i])) { nums[insertPos++] = nums[i]; } } // 按原顺序填充要移动的值 int[] countMap = new int[vals.length]; for (int num : nums) { for (int i = 0; i < vals.length; i++) { if (num == vals[i]) { countMap[i]++; } } } for (int i = 0; i < vals.length; i++) { for (int j = 0; j < countMap[i]; j++) { nums[insertPos++] = vals[i]; } } }
-
根据条件移动:将满足特定条件的元素移动到数组末尾
public void moveElementsIf(int[] nums, Predicate<Integer> condition) { int insertPos = 0; for (int i = 0; i < nums.length; i++) { if (!condition.test(nums[i])) { nums[insertPos++] = nums[i]; } } // 收集满足条件的元素 List<Integer> elementsToMove = new ArrayList<>(); for (int num : nums) { if (condition.test(num)) { elementsToMove.add(num); } } // 填充满足条件的元素 for (int element : elementsToMove) { nums[insertPos++] = element; } }
12. 常见问题与解答
12.1 为什么不使用简单的排序算法?
排序算法(如快速排序)虽然可以将所有0移到数组的一端,但无法保证非0元素的相对顺序不变,而题目要求保持非0元素的相对顺序。
12.2 双指针法为什么比暴力法更高效?
暴力法在遇到每个0时都需要移动后面的所有元素,导致很多元素被多次移动。而双指针法只需要遍历数组一次,每个元素最多被移动一次,大大减少了操作次数。
12.3 如何处理大规模数据?
对于非常大的数组:
- 使用双指针法,时间复杂度为O(n)
- 避免创建新的数组,以节省内存
- 考虑并行处理(虽然这道题不太适合并行)
12.4 如何测试解决方案的正确性?
可以使用以下测试用例:
- 普通数组:[0,1,0,3,12] → [1,3,12,0,0]
- 无零数组:[1,2,3,4,5] → [1,2,3,4,5]
- 全是零:[0,0,0,0,0] → [0,0,0,0,0]
- 零在开头:[0,0,1,2,3] → [1,2,0,0,0]
- 零在结尾:[1,2,3,0,0] → [1,2,3,0,0]
- 零在中间:[1,0,2,0,3] → [1,2,0,0,0]
- 单个元素:[0]或[1] → [0]或[1]
13. 测试用例
为了验证解决方案的正确性,以下是一些测试用例:
public class MoveZeroesTest {
public static void main(String[] args) {
Solution solution = new Solution();
// 测试用例1:标准测试
int[] nums1 = {0, 1, 0, 3, 12};
testAndPrint(solution, nums1, "测试用例1");
// 测试用例2:没有零
int[] nums2 = {1, 2, 3, 4, 5};
testAndPrint(solution, nums2, "测试用例2");
// 测试用例3:全是零
int[] nums3 = {0, 0, 0, 0, 0};
testAndPrint(solution, nums3, "测试用例3");
// 测试用例4:零在开头
int[] nums4 = {0, 0, 1, 2, 3};
testAndPrint(solution, nums4, "测试用例4");
// 测试用例5:零在结尾
int[] nums5 = {1, 2, 3, 0, 0};
testAndPrint(solution, nums5, "测试用例5");
// 测试用例6:零在中间
int[] nums6 = {1, 0, 2, 0, 3};
testAndPrint(solution, nums6, "测试用例6");
// 测试用例7:单个元素
int[] nums7 = {0};
testAndPrint(solution, nums7, "测试用例7");
int[] nums8 = {1};
testAndPrint(solution, nums8, "测试用例8");
}
private static void testAndPrint(Solution solution, int[] nums, String caseName) {
System.out.println(caseName + ":");
System.out.println("输入: " + Arrays.toString(nums));
solution.moveZeroes(nums);
System.out.println("输出: " + Arrays.toString(nums));
System.out.println();
}
}
14. 总结与技巧
14.1 解题要点
- 理解题目要求:移动零到数组末尾,保持其他元素相对顺序不变
- 原地操作:不使用额外数组,控制空间复杂度为O(1)
- 优化时间复杂度:从暴力法的O(n²)优化到双指针法的O(n)
- 减少操作次数:通过避免不必要的交换,进一步优化算法
- 处理边界情况:考虑空数组、单元素数组、无零数组等特殊情况
14.2 常用技巧
- 双指针技巧:使用快慢指针处理数组的移动、删除、合并等操作
- 就地交换:在需要保持稳定性时使用交换而非覆盖
- 条件筛选:在遍历过程中有选择地处理元素
- 减少冗余操作:通过检查条件避免不必要的赋值或交换
- 分步处理:将复杂问题分解为多个简单步骤(如先移动非零元素,再填充零)
14.3 面试技巧
在面试中遇到此类问题时:
- 先讨论简单解法(如暴力法),表明理解问题
- 分析简单解法的缺点(时间复杂度高、操作次数多)
- 提出双指针法等优化方案
- 分析优化方案的复杂度和优势
- 考虑并讨论特殊情况和边界条件
- 如果有时间,讨论如何进一步减少操作次数