详解双指针算法(二)之一般类型的双指针
前言
- 上一期我主要讲了快慢双指针,对于找节点或解决带环问题具有不可替代的作用
- 这一期我主要讲解一般类型的双指针算法,对于某些问题,一般类型的双指针算法通常可以减少一个量级的时间复杂度;例如:从 O(N^3) 降到 O(N^2),从 O(N^2) 降到 O(N)
- 一般类型的双指针算法通常都可以通过暴力解法优化而来
一、一般类型双指针简介
- 概念
一般类型的双指针一般根据指针指向的对象来决定指针的走向,双指针可能同向也可能反向,视情况而定
- 使用场景
一般类型的双指针可以用来解决如下问题:
数组划分
遍历数组找答案(相比暴力解法,时间复杂度降低一个维度)
二、数组划分问题
- 简介
对于数组划分问题,一般定义两个前后指针 ,cur 来遍历数组,prev 来执行相应操作,当 cur 遍历完数组,数组完成划分
- 经典例题
- 移动零
题目来源
解析:该题目是经典的数组划分,题目要求将数组划分成非零和零两个部分,并保持非零元素的相对顺序不变
刚接触双指针算法的小伙伴一般都不会想到双指针的解法,或者知道要用双指针解法,但是没有代码思路
但是没有关系,我会带大家从一般的方法优化成双指针算法
一般解法:暴力解法 / 新开数组
- 暴力解法:将零挪动到数组最后,其余元素往前挪动,时间复杂度 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; }
有了移动零例题中双指针算法的铺垫,是不是发现了快速排序并没有那么难了,快排的核心就在于单趟排序!!!
三、遍历数组找答案问题
- 简介
遍历数组找答案都可以使用暴力解法,套上两层或三层的 for 循环,时间复杂度一般在 O(N^2) 或 O(N^3)
想使用暴力解法通过 LeetCode 提交的小伙伴,不出意外的话,都会超出时间限制;一般解决此类问题,双指针算是首选方法,要用两层 for 循环的,双指针一层就够了,要用三层 for 循环的,双指针两层就够了,时间复杂度下降了一个维度
接下来我会通过例题,从暴力解法一步步优化成双指针解法,让编程不再玄学,而是有迹可循
- 经典例题
- 和为 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; } };
相关例题:
- 有效三角形的个数:该题也采用固定一数后采用双指针的思路,比起本题简单些