[算法学习笔记] 浅谈双指针
概述
双指针是一种非常巧妙的优化算法,双指针最直观的使用就是维护一段区间的信息,特别是一段具有单调性的区间,对于新增和删除元素都很方便处理的信息,比如正数的区间和或者积等。
双指针顾名思义,用两个指针维护区间信息,下文将通过讲解一些例题来了解线性双指针的常见用法。
双指针维护线性表
乘积小于 K 的子数组
Description
给定一个长度为 \(n\) 的数组 \(a\) 和 常量 \(k\),求子数组内所有元素的乘积严格小于 \(k\) 的连续子数组的数目。
首先考虑暴力做法,枚举左端点 \(i\),然后枚举右端点 \(j(j>i)\)。判断是否符合题意,如果不符合退出内层循环,枚举下一个 \(i\)。时间复杂度 \(O(n^2)\)。
注意到一个比较显然的性质,若区间 \([L,R]\) 符合题意,则区间 \([L+1,R],[L+2,R],[L+3,R]\dots [R-1,R]\) 都符合题意。因此我们对于 \(\forall R\),只需要找到符合题意得最大 \(L\),即可计算出以 \(R\) 为右端点的答案。显然为 \(R-L+1\)。
接下来考虑细节,左端点不动,不断移动右端点直到不符合题意,此时计算出以当前 \(R\) 为右端点的子数组。然后移动左端点,同时移动右端点。直到移动完毕。
容易发现,随着左端点的右移,右端点同步右移,满足单调性。
实现
class Solution {
public:
int numSubarrayProductLessThanK(vector<int>& nums, int k) {
int sum = 0,ans = 1;
int n = nums.size();
for(int l=0,r=0;r <n;r++)
{
ans *= nums[r];
while(l<=r && ans >= k)
{
ans /= nums[l];
l++;
}
if(ans < k) sum += r-l+1;
}
return sum;
}
};
最长连续不重复子序列
Description
给定一个长度为 \(n\) 的数列,求它的最长连续不重复子序列。
本题我们可以用两个指针动态维护。
具体地,定义左右指针 \(L,R\),初始化 \(L=R=1\),若右指针移动不会造成重复,移动右指针即可。若右指针移动会造成重复,此时,以原左端点以及重复数据 \(k\) 左边的端点作为左端点的答案都不会更优,故移动左端点到重复数据 \(k\) 处。
对于判定重复,容易的办法是开一个桶,记录当前数字是否出现。如果数据较大需要哈希一下。
模板代码
#include <bits/stdc++.h>
using namespace std;
const int N = 100010;
int n;
int a[N];
int vis[N];
int maxn = -1;
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>n;
for(int i=1;i<=n;i++) cin>>a[i];
for(int l=1,r=1;r<=n;r++)
{
maxn = r-l+1;
if(vis[a[r]] && vis[a[r]] >= l && vis[a[r]] <= r)
{
l = vis[a[r]];
}
else vis[a[r]] = r;
}
cout<<maxn<<endl;
return 0;
}
拓展一下,如果是问不重复子序列数量呢?
这样就结合了上面两道题的内容,对于以 \(R\) 为右端点的最大 \(L\),有 \(R-L+1\) 个不重复子序列。判定合法和上题相同。代码不再赘述。
判定子序列 / 字串
双指针的匹配特性判定子序列,字串非常好用。这里的子序列/字串并不要求连续,只要求顺序一样,这也就满足了指针单调移动性,可以用双指针解决。
具体地,两个指针分别指向需判定字串,原串。若当前指针指向的二者内容相同,同步右移,判定下一个字符。若不同,只移动原串的指针。判定有解/无解只需要判断 所需判定字串指针是否移到头即可。
代码
#include <bits/stdc++.h>
using namespace std;
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
string str,str1;
cin>>str>>str1;
int p1 = 0,p2 = 0;
int len1 = str.size(),len2 = str1.size();
while(p1 < len1 && p2 < len2)
{
if(str[p1] == str1[p2])
{
p1 ++ ;
p2 ++;
}
else p1 ++;
}
cout<<p2<<endl;
if(p2 == len2) cout<<"Yes"<<endl;
else cout<<"No"<<endl;
return 0;
}
二路归并
二路归并 · 双指针 是一种优化思想。它可以在 \(O(n)\) 的复杂度下把两个长度为 \(n\) 的有序数组合并为一个有序数组。
它的具体处理方法如下:
定义两个长度为 \(n\) 的升序数组 \(a,b\)。,合并完后长度为 \(2n\) 的数组 \(c\),初始化两个指针 \(x=y=1\)(这里数组下标从 \(1\) 开始)
-
如果 \(a_x < b_y\),则 \(c_{cnt++}=a_x\),同时 \(x++\)。
-
如果 \(a_x > b_y\),则 \(c_{cnt++}=b_y\),同时 \(y++\)
正确性显然,由于我们确保 \(a,b\) 数组升序,故如果 \(a_x < b_y\) ,则 \(b\) 数组从 \(y\) 以后的数一定大于 \(a_x\),所以按照顺序取 \(a_x\),同时移动 \(x++\)。
对于 \(a_x > b_y\) 同理。
由此可见,对于把一个长度为 \(n\) 的数组排序,我们可以分治,然后不断二路归并。这就是归并排序。
双指针思想应用广泛,接下来我们给出几个例题。
例题 · Luogu P1309 瑞士轮
经典永流传之瑞士轮。后面好多题目都用到了类似于瑞士轮的思想。
我们发现每轮比赛都需要进行结构体排序,本题的复杂度显然无法接受。
注意到每轮比赛后胜利和失败是唯一的,不妨记录每轮比赛后胜利和失败的人,由于我们一开始确保数组有序,所以胜利和失败的人分数一定是单调递增的,可以二路归并。
实现
#include <iostream>
#include <cstdio>
#include <algorithm>
#define N 10010
using namespace std;
int n, m, k;
int s[N], w[N], q[N], q0[N], q1[N];
bool cmp(int a, int b)
{
if (s[a] != s[b])
return s[a] > s[b];
return a < b;
}
int main()
{
scanf("%d%d%d", &n, &m, &k);
for (int i = 0; i < n * 2; i++)
scanf("%d", &s[i]);
for (int i = 0; i < n * 2; i++)
scanf("%d", &w[i]);
for (int i = 0; i < n * 2; i++)
q[i] = i;
sort(q, q + n * 2, cmp);
while (m--)
{
int t0 = 0, t1 = 0;
for (int i = 0; i < n * 2; i += 2)
{
int a = q[i], b = q[i + 1];
if (w[a] < w[b])
{
s[b]++;
q0[t0++] = a;
q1[t1++] = b;
}
else
{
s[a]++;
q0[t0++] = b;
q1[t1++] = a;
}
}
int i = 0, j = 0, t = 0;
while (i < t0 && j < t1)
if (cmp(q0[i], q1[j]))
q[t++] = q0[i++];
else
q[t++] = q1[j++];
while (i < t0)
q[t++] = q0[i++];
while (j < t1)
q[t++] = q1[j++];
}
printf("%d\n", q[k - 1] + 1);
}
归并排序
上文提到,二路归并可以在 \(O(n)\) 的时间复杂度内合并两个有序数组。归并排序就用到了这个原理。
归并排序基于分治,也就是“分而治之”。具体地,不断将一个数组从中间拆分,直到拆到不能再拆了为止,开始回溯。回溯的之后将拆分的两部分进行二路归并。这样回溯上去能确保归并的两部分一定是有序的。这里运用了分治的思想。就是不断将子任务拆分,分成互相独立的子任务,然后将子任务的结果合并。
这里引用一张图,侵删。
(图片源自 https://blog.csdn.net/weixin_44686373/article/details/107619466)
至于每次归并好的部分,直接赋值即可。赋回原数组,使归并好的部分能直接被下一次归并利用。如上图。
提供归并排序模板,如下
归并排序
#include <bits/stdc++.h>
using namespace std;
const int N = 100010;
int n;
int a[N];
int cnt = 0;
void merge(int l,int r)
{
// cout<<l<<" "<<r<<endl;
int b[N];
cnt = 0;
int mid = (l+r) / 2;
int p1 = l,p2 = mid+1 ;
while(p1 <= mid && p2 <= r)
{
if(a[p1] < a[p2])
{
b[++cnt] = a[p1];
p1 ++;
}
else
{
b[++cnt] = a[p2];
p2 ++;
}
}
while(p1 <= mid) b[++cnt] = a[p1++]; //归并后如果还有一个数组有剩下的,扔到后面去即可,下面同理。
while(p2 <= r) b[++cnt] = a[p2++];
cnt = 0;
for(int i=l;i<=r;i++) a[i] = b[++cnt]; //赋回原数组
}
void sortt(int l,int r)
{
if(l < r)
{
int mid = (l+r) / 2;
sortt(l,mid);
sortt(mid+1,r);
merge(l,r); //左右部分处理完后进行合并
}
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>n;
for(int i=1;i<=n;i++) cin>>a[i];
sortt(1,n);
for(int i=1;i<=n;i++) cout<<a[i] <<" ";
cout<<endl;
return 0;
}
归并排序其实还可以求逆序对。
首先考虑暴力做法,求 \(\sum \limits_{i=1}^n\sum\limits_{j=1}^{i-1}[a_i<a_j]\) 即可。时间复杂度 \(O(n^2)\)。
注意到对于数组排序,每次交换 \(a_i,a_j\) 就意味着 \(a_i,a_j\) 为逆序对,我们想到了冒泡排序,在冒泡排序的过程中,每次交换统计答案 \(+1\) 即为逆序对数量。但是复杂度还是 \(n^2\) 的。
但这给我们启发,有没有一种时间复杂度比较低的排序算法,同时又很容易统计逆序对呢?
归并排序就满足我们的需求。
想一想,对于归并排序,每次交换和其他排序一样,都意味着产生了新的逆序对。但是它更好统计。定义 \(p_1,p_2\) 分别为两个数组的指针。若需要交换,即 \(a_{p_2} < a_{p_1}\),则 \(a_{p_2}\) 和 \(a_{p_1},a_{p_1+1},a_{p_1+2} \dots a_{mid}\) 都构成逆序对。因为数组是有序的。显然不是 \((p_2-p_1+1)\)。用 \(6,7,8,9,1,2,3,4\) 这组数据就很容易说明问题。
一定不会重复,因为每次统计后就给它交换,让他顺序正确了。
这里也运用了分治的思想,我们统计每一个子区间内的逆序对数量就能求总的数量。
归并排序求逆序对
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N = 1000010;
int n;
int a[N];
int cnt = 0;
int ans = 0;
void merge(int l,int r)
{
int b[N];
cnt = 0;
int mid = (l+r) / 2;
int p1 = l,p2 = mid+1 ;
while(p1 <= mid && p2 <= r)
{
if(a[p1] <= a[p2]) // 一定是小于等于。如果是小于对归并排序结果无影响,但是会影响答案统计,原因显然。
{
b[++cnt] = a[p1];
// ans ++;
p1 ++;
}
else
{
b[++cnt] = a[p2];
p2 ++;
ans += (mid+1-p1); //如果交换,统计答案
}
}
while(p1 <= mid) b[++cnt] = a[p1++];
while(p2 <= r) b[++cnt] = a[p2++];
cnt = 0;
for(int i=l;i<=r;i++) a[i] = b[++cnt];
}
void sortt(int l,int r)
{
if(l < r)
{
int mid = (l+r) / 2;
sortt(l,mid);
sortt(mid+1,r);
merge(l,r);
}
}
signed main()
{
// freopen("input.txt","r",stdin);
// freopen("output.txt","w",stdout);
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>n;
for(int i=1;i<=n;i++) cin>>a[i];
sortt(1,n);
cout<<ans<<endl;
//for(int i=1;i<=n;i++) cout<<a[i] <<" ";
//cout<<endl;
return 0;
}
然后求逆序对还有一个板题 P5149 会议座位
map 映射一下直接求逆序对即可。
本文作者:SXqwq,转载请注明原文链接:https://www.cnblogs.com/SXqwq/p/17721157.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!