算法 - 双指针算法

双指针算法是我目前接触到的最优雅的算法了。(之前我觉得dp是最优雅的hhh)
双指针算法在快排和归并排序中都有用到,主要适用于两种情况:

  • 对于一个序列,用两个指针维护一段区间
  • 对于两个序列,维护某种次序,比如归并排序中合并两个有序序列的操作

1 是什么

双指针其实就是数组的两个下标,双指针算法解决问题时就是扫一遍数组就可以出结果。

2 优势

双指针算法可以将时间复杂度为O(n2)的暴力做法优化为O(n)做法。
优化方法因题而异,但大多都具有某种单调性,也就是这种性质使指针不会回退,两个指针各扫一遍就可以出结果。暴力做法是O(n2)也就是因为第二个指针一直在重新扫。

常见的暴力做法:

for(int i = 0; i < n; i ++)
	for(int j = 0; j < n; j ++)
		/*
		具体做法
		*/

优化后的做法:(不唯一
表面上看也是for + while,但是 i 和 j 分别只扫过一遍数组,所以时间复杂度是O(2n)即O(n)。

for(int i = 0; j = 0; i < n; i ++)
	while(j < i && check(j, i)) j ++;
	/*
	具体做法
	*/

3 应用

开头提到的两个应用简单来说就是:

  • 两个指针扫描两个序列
  • 两个指针扫描一个序列

具体的扫法也是因题而异,比如两个指针扫一个序列,可以都从头开始扫,也可以一个从头开始一个从尾开始。

  • 首先要确定好指针的起点以及扫描方向,明确每个指针的意义
  • 其次要找到单调性的关系。(也就是两个指针的联系)

我目前的思维模式是:
让指针1先扫,然后每动一次判断指针2能不能动,指针2不能动则指针1继续扫,直到满足某个条件指针2开始动,动到不满足这个条件,然后继续指针1,指针2……最后的结果是两个指针都扫过了一遍数组(不管什么方向)。可以参考上面的优化后的做法代码,但不唯一。

给四道例题:

3.1 输出单词

题目:输入一行字符串,字符串由单词和一个空格组成,输出各个单词。
题解:这道题是最简单的双指针算法。指针1指向单词首部,指针2指向单词尾部。
定义两个指针指向最初,指针1不动,指针2往后扫,扫到空格时说明指针2找到尾部,输出两个指针中间的单词,然后移动指针1指向下一个单词的首部,循环。

#include<iostream>
#include<string.h>

using namespace std;

int main(){
    string s;
    getline(cin, s);  //因为puts不能再用,且cin读string时以空格为分隔符也不能用
    //cout << s;
    //cout << s.size() << endl;
    
    for(int i = 0, j = 0; i < s.size(); i ++){
        if(s[i] == ' '){
            for(int k = j; k < i; k ++) cout << s[k];
            cout << endl;
            j = i + 1;
        }
    }
    return 0;
}

3.2 最长连续不重复子序列

题目:AcWing 799
题解:指针i作用:右边界,指针j:左边界。
指针i和j指向最初,然后i往后扫作为子序列的右边界。i每扫一个,便检查[j, i]是否有重复,如果有重复的话那么这段区间作废,j就移到下一个区间的开始作为新的左边界。[j, i]维护的始终是当前i对应的最长连续不重复子序列。
eg.标黄色的为[j, i]区间。即当前的最长连续不重复子序列。

  • 1(i)(j) 2 2 3 4
  • 1(j) 2(i) 2 3 4
  • 1(j) 2 2(i) 3 4 发现有重复
  • 1 2 2(i)(j) 3 4
  • 1 2 2(j) 3(i) 4
  • 1 2 2(j) 3 4(i)

技巧:
怎样判断[j, i]内有重复呢?
给一个动态计数器S[N]。i往后扫一个则S[a[i]] ++, j往后扫一个则S[a[j]] - -。每一次都判断S[a[i]] > 1,如果成立的话说明有重复,则j往后移动直到不重复。
为什么是动态变化的呢,因为如果一开始在读入时将计数器初始化那么将无法反映出扫的过程中在当前位置是否有重复。

for(int i = 0, j = 0; i < n; i ++){
	S[a[i]] ++;
	while(S[a[i]] > 1){
		S[a[j]] --;
		j ++;
	}

	res = max(res, i - j + 1);
}

在上面的算法中,i和j分别只扫了一遍数组便可以得出结果。
完整代码:

#include<iostream>

using namespace std;

const int N = 100010;

int a[N], S[N];

int main(){
    int n;
    int res = 0;
    scanf("%d", &n);
    for(int i = 0; i < n; i ++) scanf("%d", &a[i]);

    for(int i = 0, j = 0; i < n; i ++){
        //check(j, i)
        S[a[i]] ++;
        while(S[a[i]] > 1){
            S[a[j]] --;
            j ++;
        }

        //具体逻辑
        res = max(res, i - j + 1);
    }
    cout << res;
    return 0;
}

3.3 数组元素的目标和

题目:AcWing 800
题解:这道题卡了很久,就是因为下意识地以为两个指针只能从头往尾扫,忽略了指针也可以从尾往头扫。
eg.找到6是a数组和b数组的哪两个下标的元素的和,保证了a和b数组的单调递增和结果的唯一性。
1 2 4 7
3 4 6 8 9
答案是1 1
最初的思路是让两个指针都从a和b的下标0开始,想要a[i] + b[j] = x。a先动,如果a[i] + b[j] < x, 那么应该是a扫还是b扫呢?如果a往后扫,那b指针可能会出现回退现象,因为a在增大的时候b可以减少,从而使a[i] + b[j] = x。这种违背了初衷。eg.想找到6,i指到4时j才开始动,但明显答案是i指向2的时候,这会儿就回退了,思路错误。

换一种思路:
假设指针从a的下标0开始,b的下标尾开始。想要a[i] + b[j] = x,对每一个a[i],我们找到刚好使a[i] + b[j] <= x的那个b[j],这样我们在往后扫a数组的时候,每一次a[i]的b[j]一定在上一个b[j]的左边(或不变),这样就可以两个数组只扫一遍然后出结果,且无回退出现。

  • 1(i) 2 4 7
  • 3 4 6 8 9(j)

  • 1(i) 2 4 7
  • 3 4(j) 6 8 9

  • 1 2(i) 4 7
  • 3 4(j) 6 8 9

=============
2021/8/7 补充:
这道题我后来又想了下,3.1、3.2、3.4都是很容易能看出是两个指针从头到尾扫,这道题有点绕,就是怎样能get到b数组应该是从尾到头扫呢?
本质:找单调性(两个指针的联系)
分析:a[i] + b[j] = x,假定 i 是从头到尾扫,也就是 a[i] 递增。a[i] + b[i]不变,a[i]递增和b[j]产生联系的情况就是b[j]递减,所以b一定是逆序扫。

=============
2021/8/10 补充:
做了一道题,满脑子双指针,碰壁了。(可以参考数据结构的单调栈那道题)
然后就开始思考这道题为什么可以用双指针而那道题不可以。先看看这道题的暴力做法:

for(int i = 0; i < n; i ++)
	for(int j = 0; j < m; j ++)
		if(a[i] + b[j] == x){
			/*
			...
			*/
		}

暴力之后双指针怎么扫在上一条补充时已经思考过。
单调栈那道题是:

for(int i = 0; i < n; i ++)
	for(int j = i - 1; j >= 0; j --)
		if(a[i] > b[j]){
			/*
			...
			*/
		}

其他条件不说,最关键的是无序,实在找不出单调性,所以放弃双指针。
并且还得出了一条经验,那就是从暴力写法中是看不出这道题能不能用双指针来做的。(手动微笑,毕竟我觉得这道题可太能双指针了),

=============

#include<iostream>

using namespace std;

const int N = 100010;
int a[N], b[N];

int main(){
    int n, m, x;
    cin >> n >> m >> x;
    for(int i = 0; i < n; i ++) scanf("%d", &a[i]);
    for(int i = 0; i < m; i ++) scanf("%d", &b[i]);

    for(int i = 0, j = m - 1; i < n; i ++){
        while(j >= 0 && a[i] + b[j] > x) j --;
        if(a[i] + b[j] == x){
            printf("%d %d", i, j);
            break;
        }
    }
    return 0;
}

3.4 判断子序列

题目:AcWing 2816
题解:两个指针从头到尾扫就可以,我最初的bug是如果匹配成功,j忘了++。
这道题就比较简单了,但是我写的代码没有y总写的优雅,不知廉耻地都贴上来
我的:

#include<iostream>

using namespace std;

const int N = 100010;
int a[N], b[N];

int main(){
    int n, m;
    cin >> n >> m;
    for(int i = 0; i < n; i ++) scanf("%d", &a[i]);
    for(int i = 0; i < m; i ++) scanf("%d", &b[i]);

    int flag = 0;
    for(int i = 0, j = 0; i < n; i ++){
        if(j >= m){
            flag = 1;
            break;
        }

        while(b[j] != a[i]){
            j ++;
            if(j >= m){
                flag = 1;
                break;
            }
        }
        if(flag == 1) break;

        j ++; //指向下一次开始的地方,因为怕出界所以加了个判断条件
    }



    if(flag) cout << "No";
    else cout << "Yes";

    return 0;
}

y总的

//y总的做法,其实思路完全一致但他写的更优雅
while(i < n && j < m){
    if(a[i] == b[j]) i ++;
    j ++;
}
if(i == n) puts("Yes");
else puts("No");

双指针还需要多多练习啊……

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值