Java详解LeetCode 热题 100(04):LeetCode 283. 移动零(Move Zeroes)详解

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. 理解题目

这道题目要求我们完成以下任务:

  1. 将数组中所有的0移动到数组的末尾
  2. 保持其他非零元素的相对顺序不变
  3. 必须在原数组上进行操作,不能创建新数组
  4. 尽量减少操作次数

关键点:

  • 原地操作,不能使用额外数组
  • 非零元素的相对顺序不能改变
  • 操作次数越少越好

3. 解法一:暴力法(两层循环)

3.1 思路

最直观的方法是使用两层循环:

  1. 外层循环遍历数组
  2. 当遇到0时,使用内层循环将后面的所有元素都前移一位
  3. 然后在数组末尾补上0
  4. 由于移动元素后,当前位置可能还是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 代码详解

  1. 遍历数组的每个元素,索引为i
  2. 当遇到值为0的元素时:
    • i+1开始寻找下一个非0元素,索引为j
    • 如果找到了非0元素(j < n),将其与当前位置的0交换
    • 如果后面没有非0元素了(j >= n),说明所有0已经在末尾,可以直接结束循环
  3. 交换后,当前位置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 思路

双指针法是解决此问题的最优解之一:

  1. 使用两个指针:slow指向当前应该填入非0元素的位置,fast用于遍历数组
  2. fast指针遇到非0元素时,将其移动到slow指针位置,然后slow指针前进一步
  3. 当数组遍历完成后,将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 代码详解

  1. 初始化slow指针为0,表示下一个非0元素应该放置的位置
  2. 使用fast指针遍历数组:
    • 当遇到非0元素时,将其移动到slow指针位置,然后slow指针后移一位
    • 当遇到0时,仅将fast指针后移
  3. 遍历完成后,所有非0元素已经按顺序移到了数组前部
  4. slow指针之后的所有元素赋值为0,完成0的移动

4.4 复杂度分析

  • 时间复杂度:O(n),其中 n 是数组的长度。只需要遍历数组一次,然后再遍历一次slow到数组末尾的元素。
  • 空间复杂度:O(1),只使用了常数级别的额外空间。

4.5 适用场景

双指针法是解决此问题的最优解,适用于所有情况,特别是大型数组。

5. 解法三:双指针优化版(减少写操作)

5.1 思路

上述双指针法可以进一步优化,减少不必要的写操作:

  1. 只有当fastslow指针不同时,才进行元素交换
  2. fastslow指向同一个元素时,不需要任何操作

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 代码详解

  1. 初始化slow指针为0
  2. 使用fast指针遍历数组:
    • 当遇到非0元素时,检查slowfast是否指向同一位置
    • 如果指向不同位置,将fast指向的元素移到slow位置,并将fast位置设为0
    • 无论是否交换,slow指针都要后移一位
  3. 这种方法的优势在于不需要在遍历结束后填充0,因为在移动过程中已经将原位置填充为0

5.4 复杂度分析

  • 时间复杂度:O(n),其中 n 是数组的长度。只需要遍历数组一次。
  • 空间复杂度:O(1),只使用了常数级别的额外空间。

5.5 优化说明

这种优化避免了在原地移动非0元素后再填充0的步骤,但在某些情况下可能增加写操作:

  • 当数组前面没有0时(如[1,2,3,0,0]),此方法会执行不必要的写操作
  • 当数组中0很少时,第一种双指针法可能更高效

6. 解法四:交换法

6.1 思路

另一种优化思路是使用交换操作,而不是赋值:

  1. 使用nonZeroPos记录下一个非0元素应该放置的位置
  2. 遍历数组,当遇到非0元素时,将其与nonZeroPos位置的元素交换
  3. 交换后,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 代码详解

  1. 初始化nonZeroPos为0,表示非0元素应该放置的位置
  2. 遍历数组,当遇到非0元素时:
    • 将当前元素与nonZeroPos位置的元素交换
    • nonZeroPos加1
  3. 由于交换操作,遍历完成后,所有非0元素都在数组前部,所有0都在数组后部

6.4 复杂度分析

  • 时间复杂度:O(n),其中 n 是数组的长度。只需要遍历数组一次。
  • 空间复杂度:O(1),只使用了常数级别的额外空间。

6.5 适用场景

交换法适用于所有情况,但存在一个潜在问题:

  • 当数组已经按要求排序(如所有非0元素在前,所有0在后)时,仍会执行不必要的交换操作

7. 解法五:快慢指针交换优化

7.1 思路

我们可以结合前面几种方法的优势,进一步优化算法:

  1. 使用快慢指针,slow指向已处理好的非0序列的末尾
  2. 只在slow指向0且fast指向非0元素时才进行交换
  3. 这样可以避免不必要的交换操作

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 代码详解

  1. 初始化slowfast都为0
  2. 使用fast指针遍历数组:
    • 当遇到非0元素时,检查slow位置是否为0
    • 如果slow位置为0,交换slowfast位置的元素
    • 无论是否交换,slow都向后移动一位
    • fast总是向后移动一位
  3. 这种方法的优势在于只有在必要时才执行交换操作

7.4 复杂度分析

  • 时间复杂度:O(n),其中 n 是数组的长度。只需要遍历数组一次。
  • 空间复杂度:O(1),只使用了常数级别的额外空间。

7.5 适用场景

这种优化适用于各种情况,尤其是当数组中的0比较少或已经部分有序时,可以减少不必要的操作。

8. 特殊情况和边界处理

在实现解决方案时,我们需要考虑以下特殊情况:

  1. 空数组或只有一个元素的数组:这种情况下不需要任何操作,直接返回。

    if (nums == null || nums.length <= 1) {
        return;
    }
    
  2. 没有0的数组:例如[1,2,3,4,5],不需要移动任何元素。

    • 在双指针法中,slow会一直跟随fast,不会产生任何移动
    • 在交换法中,所有交换都是自己和自己交换,不会改变数组
  3. 全是0的数组:例如[0,0,0,0],不需要移动任何元素。

    • 在双指针法中,slow会一直保持0,不会移动
    • 在交换法中,不会进行任何交换
  4. 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 应用场景

移动零的问题在实际编程中有多种应用场景:

  1. 数据预处理:在机器学习和数据分析中,常需要将缺失值(用0表示)移到数据末尾
  2. 图像处理:在图像处理中,可能需要将背景像素(值为0)与前景像素分离
  3. 游戏开发:在游戏中处理无效对象(表示为0)时,需要将它们移至数组末尾以便批量删除
  4. 内存管理:在某些内存管理算法中,需要将空闲块(标记为0)集中到一起以减少碎片

11.2 扩展问题

  1. 移动特定值:将任意给定值(而不仅仅是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;
        }
    }
    
  2. 移动多个值:将多个不同的值移动到数组末尾

    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];
            }
        }
    }
    
  3. 根据条件移动:将满足特定条件的元素移动到数组末尾

    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 解题要点

  1. 理解题目要求:移动零到数组末尾,保持其他元素相对顺序不变
  2. 原地操作:不使用额外数组,控制空间复杂度为O(1)
  3. 优化时间复杂度:从暴力法的O(n²)优化到双指针法的O(n)
  4. 减少操作次数:通过避免不必要的交换,进一步优化算法
  5. 处理边界情况:考虑空数组、单元素数组、无零数组等特殊情况

14.2 常用技巧

  1. 双指针技巧:使用快慢指针处理数组的移动、删除、合并等操作
  2. 就地交换:在需要保持稳定性时使用交换而非覆盖
  3. 条件筛选:在遍历过程中有选择地处理元素
  4. 减少冗余操作:通过检查条件避免不必要的赋值或交换
  5. 分步处理:将复杂问题分解为多个简单步骤(如先移动非零元素,再填充零)

14.3 面试技巧

在面试中遇到此类问题时:

  1. 先讨论简单解法(如暴力法),表明理解问题
  2. 分析简单解法的缺点(时间复杂度高、操作次数多)
  3. 提出双指针法等优化方案
  4. 分析优化方案的复杂度和优势
  5. 考虑并讨论特殊情况和边界条件
  6. 如果有时间,讨论如何进一步减少操作次数

15. 参考资料

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

全栈凯哥

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值