详解双指针算法(二)之一般类型的双指针

详解双指针算法(二)之一般类型的双指针


前言

  • 上一期我主要讲了快慢双指针,对于找节点或解决带环问题具有不可替代的作用
  • 这一期我主要讲解一般类型的双指针算法,对于某些问题,一般类型的双指针算法通常可以减少一个量级的时间复杂度;例如:从 O(N^3) 降到 O(N^2),从 O(N^2) 降到 O(N)
  • 一般类型的双指针算法通常都可以通过暴力解法优化而来

一、一般类型双指针简介


  1. 概念

一般类型的双指针一般根据指针指向的对象来决定指针的走向,双指针可能同向也可能反向,视情况而定


  1. 使用场景

一般类型的双指针可以用来解决如下问题:

  • 数组划分
  • 遍历数组找答案(相比暴力解法,时间复杂度降低一个维度)


二、数组划分问题


  1. 简介

对于数组划分问题,一般定义两个前后指针 ,cur 来遍历数组,prev 来执行相应操作,当 cur 遍历完数组,数组完成划分


  1. 经典例题
  • 移动零

题目来源
在这里插入图片描述


解析:该题目是经典的数组划分,题目要求将数组划分成非零和零两个部分,并保持非零元素的相对顺序不变

刚接触双指针算法的小伙伴一般都不会想到双指针的解法,或者知道要用双指针解法,但是没有代码思路

在这里插入图片描述


但是没有关系,我会带大家从一般的方法优化成双指针算法

一般解法:暴力解法 / 新开数组

  • 暴力解法:将零挪动到数组最后,其余元素往前挪动,时间复杂度 O(N^2) ,空间复杂度 O(1)
  • 新开数组:以时间换空间,一个指针遍历原数组,非零元素放数组前半部分,零元素放数组后半部分,时间复杂度 O(N) ,空间复杂度 O(N)

时间复杂度和空间复杂度难道只能优化其一吗???

一般算法才做选择,我双指针算法全要!!!
在这里插入图片描述


该题的双指针算法可以由新开数组的解法优化而来,先看新开数组的代码思路

  • cur 遍历原数组,begin 指向新开数组的开始,end 指向新开数组的结尾
  • cur 若指向零元素,写入新数组下标为 end 的位置,end 向前移动一位
  • cur 若指向非零元素,写入新数组下标为 begin 的位置,begin 向后移动一位
  • cur 遍历完数组,新数组则完成数组的划分
    过程演示:
    在这里插入图片描述

怎么由异地操作优化为原地对数组进行操作,是本题的关键

  • 由异地对数组操作来看,cur 指针仅起到遍历数组的作用,故原地操作还需要定义一个指针 prev 来执行相应的操作,使得 prev 位置之前(包含 prev 位置)为非零元素
  • 在 cur 移动期间,控制逻辑一直保持住 prev 位置及之前都为非零元素,即保证了:当 cur 遍历完数组,数组在 prev 处被分割为非零元素和零元素
  • 故在 cur 遍历数组期间,数组被划分成了三个部分
    • [ 0 , prev ] ,已处理部分的非零区间
    • [ prev + 1 , cur ] ,已处理部分的零区间
    • [ cur + 1 , size - 1 (数组结尾)] ,未处理部分
  • 观察以上区间,要让未处理区间的非零元素在已处理区间的零元素之前,就要让 prev 后移一位(即指向零区间的第一位),再与 cur 指向的非零元素交换
  • cur 继续向后遍历
    过程演示:
    在这里插入图片描述

代码实现:

void moveZeroes(int* nums, int numsSize)
{
    int cur = 0 , prev = -1;
    while(cur < numsSize)
    {
        if(nums[cur])			// cur 指向非零元素,让 prev 后移一位,再交换
        {
            int tmp = nums[cur];
            nums[cur] = nums[++prev];
            nums[prev] = tmp;
        }
        cur++;		// cur指针遍历数组,不管怎样每次都向后移动一位
    }
}
// 数组开头若是非零元素,即自己与自己交换

时间复杂度O(N) ,空间复杂度 O(1)

  • 快排的单趟排序

快排的单趟排序:选定一个 key 值,单趟排序后,使 key 左边元素都小于 key ,右边元素都大于 key,即 key 已经排序完毕;然后采取分治再次对 key 的左右区间进行单趟排序,如此递归,便是整个快速排序了

细心的小伙伴应该发现了,快排的单趟排序也是对数组进行划分,故也能使用双指针算法

解析:

  • 先选定 key 值,这里将数组的起始位置即 0 下标处的元素定为 key
  • 要让 key 左边小于 key ,key 右边都大于 key,即对区间 [ 1 , size - 1 (数组结尾)] 进行划分,定义 cur 和 prev 前后双指针
  • 待处理区间 [ 1 , size - 1 ] 又被 prev 和 cur 划分成三个部分
    • [ 1 , prev ],已处理部分且元素都小于 key 的区间
    • [ prev + 1 , cur ],已处理部分且元素都大于 key 的区间
    • [cur + 1 , size - 1 ],未处理部分
  • 与移动零例题的双指针一样,在 cur 遍历数组的过程中,控制逻辑保证 prev 位置及之前的元素都小于 key(不包含 0 位置),即保证了:当 cur 遍历完数组,数组 [ 1 , size-1 ] 在 prev 位置被划分成小于 key 与大于 key 两部分
  • 观察以上区间,要让未处理区间小于 key 的值在已处理区间大于 key 的值之前,就要让 prev 后移一位(即指向值大于 key 区间的第一位),再与 cur 指向的值小于 key 的元素交换
  • cur 遍历完数组后,[ 1 , prev ] 元素都小于 0 下标的 key ,故交换 prev 和 0 位置的元素,使 key 值来划分数组,单趟快排就结束了

过程演示:
在这里插入图片描述


代码实现:

void Swap(int *e1, int *e2)
{
    int tmp = *e1;
    *e1 = *e2;
    *e2 = tmp;
}

int PartSort(int *arr, int left, int right)
{
    int prev = left;
    int cur = left + 1;		// cur 从 key 值后一位向后遍历
    int key_pos = left;		// key 值下标
    while (cur <= right)
    {
        // cur 后移找比 key 小的值
        // cur 刚遍历若都是小于 key 的值,prev 向后移动一位与 cur 交换即自己与自己交换
        // 故加上 ++prev != cur 的小优化,避免自己与自己交换,移动零例题也可以加上,不过加不加都不影响最后的结果
        if (arr[cur] < arr[key_pos] && ++prev != cur)
            Swap(&arr[cur], &arr[prev]);
            
        cur++;		// 不管怎样,cur 都向后移动
    }
    
    // cur 遍历完数组,将 prev 指向的元素与 key 交换
    Swap(&arr[prev], &arr[key_pos]);
    
    // 返回值的作用方便后续的单趟排序,涉及整个快速排序的控制,就不在双指针算法中细讲了
    key_pos = prev;
    return key_pos;
}

int main()
{
    int arr[] = {6, 1, 2, 7, 9, 3, 4, 5, 10, 8};
    int sz = sizeof(arr) / sizeof(int);
    PartSort(arr, 0, sz - 1);			// 第一趟快排排序整个数组区间
    for (size_t i = 0; i < sz; i++)
        printf("%d ", arr[i]);
    return 0;
}
有了移动零例题中双指针算法的铺垫,是不是发现了快速排序并没有那么难了,快排的核心就在于单趟排序!!!



三、遍历数组找答案问题


  1. 简介

遍历数组找答案都可以使用暴力解法,套上两层或三层的 for 循环,时间复杂度一般在 O(N^2) 或 O(N^3)


想使用暴力解法通过 LeetCode 提交的小伙伴,不出意外的话,都会超出时间限制;一般解决此类问题,双指针算是首选方法,要用两层 for 循环的,双指针一层就够了,要用三层 for 循环的,双指针两层就够了,时间复杂度下降了一个维度


接下来我会通过例题,从暴力解法一步步优化成双指针解法,让编程不再玄学,而是有迹可循


  1. 经典例题
  • 和为 s 的两个数字

题目来源在这里插入图片描述


解析:该题目是经典的遍历数组找答案,题目要求在数组中找两个数,使这两个数相加等于给定的数 s;这道题的暴力解法很简单,固定一个数的下标为 i ,再固定另一个数的下标为 j ,用两层 for 循环穷举,找到和为 s 的两个数,这样的时间复杂度为 O(N^2)

但有没有小伙伴发现,这道题还有一个条件:数组递增排序
注意!!!看到数组有序你必须下意识想到两种算法:双指针和二分算法
此题使用对碰指针只需遍历一遍数组,时间复杂度为 O(N)

思路:

  • 定义两个 left 和 right 指针分别指向数组的起始和结尾
    在这里插入图片描述
  • left 指向 10,right 指向 60,此时两数之和为 70 大于 40;这时还有必要固定 60 改变另一个数吗???没有必要,因为数组递增,让 left 右移只会让两数之和越来越大;言外之意:60 肯定不会是答案的两个数之一,故让 right 左移
    在这里插入图片描述
  • left 指向 10 ,right 指向 31 ,此时两数之和为 41 小于 56;这时还有必要固定 10 改变另一个数吗???没有必要,因为数组递增,让 right 右移只会让两数之和越来越小;言外之意:10 肯定不会是答案的两个数之一,故让 left 右移
  • 逻辑:
    • left 指向的数 + right 指向的数如果大于 target ,right 左移,使两数之和减小,逼近 target
    • left 指向的数 + right 指向的数如果大于 target ,left 右移,使两数之和增大,逼近 target
    • left 指向的数 + right 指向的数如果等于 target ,返回结果

过程演示:
在这里插入图片描述


代码实现

class Solution 
{
public:
    vector<int> twoSum(vector<int>& nums, int target) 
    {
        size_t left = 0,right = nums.size()-1;
        while(left < right)
        {
            if(nums[left] + nums[right] < target)
                left++;
            else if(nums[left] + nums[right] > target)
                right--;
            else
                return {nums[left],nums[right]};
        }
        // 没找到结果,理论上代码不会运行到这
        return {-1,-1};
    }
};

相关例题:

  • 三数之和

题目来源
在这里插入图片描述


解析:该题为两数之和的升级版,如果采用暴力解法,需要套上三层 for 循环穷举出所有的情况,还要对重复的三元组去重,例如 [ -1 , 0 ,1 ] 和 [ 0 , -1 , 1 ] 为重复的三元组,需要使用 set 容器去重,该解法的时间复杂度为 O(N^3)

在用 set 去重时,我们还需要对三元组进行排序以方便比较,例如将 [ -1 , 0 ,1 ] 和 [ 0 , -1 , 1 ] 都排序成 [ -1 , 0 , 1 ]
再仔细想想两数之和,我们也是在数组排序好的情况下才将暴力解法优化为双指针算法
基于上面两种思考,不难想到如果想用双指针算法,要先将数组进行排序!!!

思路:

  • 先将数组进行升序排序
  • 想办法将三数之和转化成多趟两数之和
    • 两数之和需要找到两个数之和等于目标值 target ,故我们先要固定一个数 ,这里我们从数组结尾处固定,下标用 end 表示
    • 三数之和想要为零,在固定一数之后,target 值就是固定数的相反数,即转化为在 end 右边区间找两数之和等于 target在这里插入图片描述
  • 将该题转化为多趟两数之和后,就要解决 不漏 + 去重
    • 不漏:在上图 end 固定到 5 时,在 [ left , right ] 区间找两数之和为 -5 ,在找到 -6 和 1 之后,中间区间可能还有另外两数之和也为 -5 ,例如 -4 和 -1,故单趟遍历找到和为 target 的两个数后,不能像两数之和那题一样直接结束此趟遍历,要继续缩小区间,直至 left 等于 right
    • 去重:以上逻辑虽然时间复杂度降为 O(N^2) ,但找出的结果仍然会包含重复的三元组,我们要想办法进行去重
      • 单趟内去重:如下图,当 left 和 right 找到 -4 和 -1 的和为 -5 时,该如何缩小区间呢???
        当 left 右移一位后还是指向 -4 ,right 只有找到 -1 才能使两数之和为 -5 ,故该三元组与上一组找的重复
        言外之意:当 left 与right 找到结果后,left 与 right 在缩小区间时要跳过与之前相同的元素
        在这里插入图片描述
      • 趟与趟之间去重:如下图,当 end 指向 5 这一趟遍历结束后,end 应该如何移动???有必要只向前移动一位继续指向 5 吗???
        没有必要,因为固定一个数为 5 的三元组在上一趟遍历中已经找到所有情况了,继续固定 5 遍历后找到的三元组与前一次找到的三元组是重复的
        言外之意:一趟遍历结束后,end 向前移动要跳过与之前相同的元素
        在这里插入图片描述
  • 细节控制:在跳过重复元素时避免越界
  • 小优化:end 指向元素如果小于零,left 和 right 指向元素肯定也小于零,三数之和必定小于零,无需再进循环找与 end 匹配的两数

过程演示:
在这里插入图片描述


代码实现:

class Solution
{
public:
    vector<vector<int>> threeSum(vector<int> &nums)
    {
        vector<vector<int>> vv;
        
        sort(nums.begin(), nums.end());		// 对数组进行升序排序
        int end = nums.size() - 1; 			// end 先固定一数
        
        // 外层循环条件 + 小优化
        while (end >= 2 && nums[end]>=0)	
        {
            int left = 0, right = end - 1, target = -nums[end];
            while (left < right)
            {
                if (nums[left] + nums[right]  < target)
                    left++;
                else if (nums[left] + nums[right] > target)
                    right--;
                else
                {
                    vv.push_back({nums[left], nums[right], nums[end]});
                    left++;
                    right--;
                    
                    // 单趟去重 + 避免越界
                    while(left < right && nums[left]==nums[left-1])
                    	left++;
                    while(left < right && nums[right]==nums[right+1])
                    	right--;
                }
            }
            end--;
            // 趟与趟间去重 + 避免越界
            while(end>=2 && nums[end]==nums[end+1])
            end--;
        }
        return vv;
    }
};

相关例题:

评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值