力扣HOT100刷题记录-双指针-题解-283.移动零

4 阅读6分钟

移动零(Move Zeroes)

题目描述

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

要求:

  • 必须在原数组上操作,不能拷贝额外的数组
  • 尽量减少操作次数

示例:

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

输出: [1,3,12,0,0]

解题思路分析

首先考虑暴力解法,很容易想到

方法一:暴力法

遍历数组,遇到0时将其与后面的非零元素交换,直到所有0都移到末尾。

时间复杂度:O(n²)

空间复杂度:O(1)

#include<iostream>
#include<vector>

using namespace std;

int main(){

    int nums[5] = {0,1,0,3,12};

    //暴力解法

    for(int i = 0; i < 5; i++){
        if(nums[i] == 0){
             for(int j = i+1; j < 5; j++){
                if(nums[j] != 0){
                    swap(nums[i],nums[j]);
                    break;
                }
             }
        }
    }
    
    //输出
    for(int i=0;i<nums.size();i++){
        cout<<nums[i]<<" ";
    }
}

发现暴力解法是符合双指针优化条件的,使用双指针模板即可解决问题

方法二:双指针法

使用快慢指针,快指针遍历所有元素,慢指针记录非零元素应该放置的位置。

时间复杂度:O(n)

空间复杂度:O(1)

#include<iostream>
#include<vector>

using namespace std;

int main()
{
    vector<int> nums = {0,1,0,3,12};
    //双指针做法

    int slow=0;//慢指针,指向第一个零的位置(如果有)
    int fast=0;//快指针,指向当前位置

    while(fast<nums.size()){//遍历数组
        if(nums[fast]!=0){//快指针的元素不为零
            swap(nums[slow],nums[fast]);//快指针的元素和慢指针的元素交换位置
            slow++;//慢指针后移
        }
        fast++;//快指针后移
    }
    
    //输出
    for(int i=0;i<nums.size();i++){
        cout<<nums[i]<<" ";
    }
}

方法三:其实也可以理解成这道题双指针的另一种写法,非适用性模板思路

用k来记录0的个数

时间复杂度:O(n)

空间复杂度:O(1)

#include<iostream>
#include<vector>

using namespace std;

int main(){
    vector<int> nums = {0,1,0,3,12};
    
    int k=0;//有几个不是0的数
    for(int i=0;i<nums.size();i++){
        if(nums[i]!=0){
            nums[k]=nums[i];
            k++;
        }
    }
    
    for(int j=k;j<nums.size();j++){
        nums[j]=0;
    }
    
    //输出
    for(int i=0;i<nums.size();i++){
        cout<<nums[i]<<" ";
    }
}

双指针算法通用模板

1. 双指针优化条件

当遇到暴力解法形式为:

for(int i = 0; i < n; i++){
    for(int j = i+1; j < n; j++){
        // ...
    }
}

且内层循环存在重复计算时,大多可以通过双指针优化,将时间复杂度从 O(n²) 降低到 O(n)。

2. 通用双指针模板

for (int i = 0, j = 0; i < n; i ++ )
{
    while (j < i && check(i, j)) j ++ ;

    // 具体问题的逻辑
}

模板说明:

  • i 指针:通常作为快指针,遍历整个序列
  • j 指针:通常作为慢指针,维护满足条件的区间左边界
  • check(i, j) :判断当前区间 [j, i] 是否满足特定条件
  • while 循环:调整 j 指针的位置,直到区间满足条件

3. 常见问题分类

(1) 对于一个序列,用两个指针维护一段区间
  • 典型问题:最长连续不重复子序列、最小覆盖子串、滑动窗口最大值
  • 应用:本题"移动零"属于此类,用快慢指针维护非零元素区间
(2) 对于两个序列,维护某种次序
  • 典型问题:归并排序中合并两个有序序列、判断子序列
  • 应用:两个指针分别遍历两个序列,按特定顺序处理

4. 移动零问题的模板应用

在移动零问题中,我们可以将双指针模板具体化为:

for (int fast = 0, slow = 0; fast < n; fast ++ )
{
    // 相当于 check 条件:如果当前元素非零,需要交换
    if (nums[fast] != 0) {
        // 调整 slow 指针:先交换,后移动
        swap(nums[slow], nums[fast]);
        slow++;
    }
    // 不需要 while 循环调整,因为 slow 指针的移动是条件性的
}

核心思想与关键问题解答

1. 为什么慢指针能指向第一个零的位置(方法二)?

关键原因:

  • 指针移动规则:快指针每次循环都移动,慢指针只在交换非零元素后移动
  • 遇到零时:慢指针不动,零自然留在慢指针位置
  • 遇到非零时:交换后慢指针右移,非零元素被"搬运"到前面
  • 数学归纳:循环过程中,慢指针左边都是已处理的非零元素,慢指针位置指向第一个零(如果有)

直观理解:

数组分区:[非零区域] | [零区域] | [未处理区域]
                slow       fast
  • slow左边:已处理完成的非零元素
  • slow位置:指向第一个零(或未处理区域起点)
  • fast右边:还未处理的元素

2. 双指针优化的本质

结合双指针优化的通用思想,我们可以从更抽象的角度理解本题的双指针设计:

双指针优化的本质:

  • 消除重复计算:窗口滑动时,大部分计算可以复用上次的结果。在本题中,我们通过一次遍历完成非零元素的搬运和零的定位,避免了暴力法中重复交换的过程。
  • 利用单调性:指针只向右移动,不会向左回退。本题中,快指针 fast始终向前扫描,慢指针 slow也只会在遇到非零元素时向前推进,保证了整体时间复杂度控制在 O(n)。
  • 空间换时间:虽然我们使用了两个指针,但只用了常数级额外空间,通过巧妙的指针移动策略,将原本 O(n²) 的时间复杂度优化至 O(n)。

适用双指针优化的判断标准:

  1. 问题具有“窗口”特性:本题可以看作是在数组中维护一个“非零区域”的窗口,快指针不断扩展窗口,慢指针收缩窗口。
  2. 指针移动具有单调性:两个指针都只向右滑动,不会向左回退,这保证了算法的效率。
  3. 存在重叠子区间:在处理数组时,前后区间存在大量重复元素,双指针通过跳过这些重复部分来缩短查询时间。

3. 双指针优化的使用情形

在求解某段时间区间内的数量时,前一区间和后一区间存在大量重复部分。我们要做的就是利用这部分来缩短查询时间。

判断方法(针对本题):
  • 窗口特性:本题可以视为一个“滑动窗口”,窗口内维护非零元素,窗口外为零元素。
  • 单调性:指针只向右移动,不会回退,符合双指针优化的基本条件。
  • 重复计算:通过慢指针记录已处理非零元素的位置,避免了对已处理区域的重复扫描。
典型应用场景:
  • 滑动窗口最大值:在数组中寻找满足某种条件的连续子区间。
  • 求子数组的和、最大、最小值:如和为连续正整数序列、最长不重复子串等。
  • 处理数组中的连续子序列:如本题,将所有0移动到末尾,保持非零元素相对顺序。

算法流程详解

模拟执行过程(nums = [0,1,0,3,12])

步骤fastnums[fast]slow操作数组状态说明
初始0-0-[0,1,0,3,12]初始化
1000跳过[0,1,0,3,12]遇到0,只移动fast
2110交换[1,0,0,3,12]非0,交换后slow++
3201跳过[1,0,0,3,12]遇到0,只移动fast
4331交换[1,3,0,0,12]非0,交换后slow++
54122交换[1,3,12,0,0]非0,交换后slow++

最终输出:[1,3,12,0,0]