#9990. 202/8/4/WWX笔记(尺取法)

202/8/4/WWX笔记(尺取法)

尺取法课堂笔记(精简版)

一、今日重点

今天主要学习了:

  1. 字符串贪心的两个特殊边界;
  2. 尺取法,也叫双指针或滑动窗口;
  3. 环形数组展开;
  4. 用计数数组维护窗口中的不同数字。

对应题目:4226、1091、4535、4872、4877、4536、4988、4878、4870


二、字符串贪心订正

1. 魔咒升级

每次操作:

  1. 找到最靠前的非 z 字符;
  2. 从这里开始,一直加到下一个 z 之前;
  3. 如果已经全部是 z,立即结束。
while(k--){
	int p=-1;
	for(int i=0;i<s.size();i++){
		if(s[i]!='z'){
			p=i;
			break;
		}
	}
	if(p==-1) break;//全部是z,不能继续操作
	for(int i=p;i<s.size()&&s[i]!='z';i++) s[i]++;
}

易错点:如果没有找到非 z 字符,p 还是 -1,不能访问 s[-1]

2. 最小字典序

记录每个字母最后出现的位置时,要先初始化:

int last[26];
for(int i=0;i<26;i++) last[i]=-1;

如果无法通过交换变小:

  • 有相同字符:交换两个相同字符,字符串不变;
  • 所有字符都不同:交换最后两个字符。

最后两个字符的下标是:

s.size()-2
s.size()-1

s[s.size()] 已经越界。


三、尺取法是什么

尺取法用两个指针维护一段连续区间:

int l=1,r=1;
  • l:区间左端点;
  • r:区间右端点;
  • sum:当前区间 [l,r] 的和。

基本动作:

r向右:加入新元素,区间变大。
l向右:删除左边元素,区间变小。

数组元素都是正数时:

r右移,区间和只会变大。
l右移,区间和只会变小。

因此两个指针都只向右移动,总复杂度是 O(n)


四、模板1:和至少为m,长度最短

对应题目:4535 最短子序列

做法:

  1. 右指针加入数字;
  2. sum>=m 时,记录答案并移动左指针;
  3. 尽量缩短区间。
#include<bits/stdc++.h>
using namespace std;
const int N=1e6+10;
int n,m,a[N];
int main(){
	cin>>n>>m;
	for(int i=1;i<=n;i++) cin>>a[i];
	int l=1,ans=1e9;
	long long sum=0;
	for(int r=1;r<=n;r++){
		sum+=a[r];
		while(sum>=m){
			ans=min(ans,r-l+1);
			sum-=a[l++];//继续缩短区间
		}
	}
	if(ans==1e9) cout<<0;
	else cout<<ans;
	return 0;
}

本题无解输出 0


五、模板2:和不超过m,长度最长

对应题目:

  • 4872 松果
  • 4877 蛋糕
  • 4536 买票

做法:

  1. 右指针加入数字;
  2. 如果 sum>m,不断移动左指针;
  3. 当前区间合法后,更新最大长度。
#include<bits/stdc++.h>
using namespace std;
const int N=1e6+10;
int n,m,a[N];
int main(){
	cin>>n>>m;
	for(int i=1;i<=n;i++) cin>>a[i];
	int l=1,ans=0;
	long long sum=0;
	for(int r=1;r<=n;r++){
		sum+=a[r];
		while(sum>m&&l<=r){
			sum-=a[l++];//太重了,缩小窗口
		}
		ans=max(ans,r-l+1);
	}
	cout<<ans;
	return 0;
}

一句话记忆:

太大就缩左边,合法就更新最长答案。

六、模板3:连续区间和恰好为C

对应题目:4878 求和为C

数组元素都是正整数,所以:

  • sum>C:移动左指针;
  • sum==C:答案加一;
  • 然后继续让右指针加入新元素。
#include<bits/stdc++.h>
using namespace std;
const int N=1e6+10;
int n,a[N];
long long c;
int main(){
	cin>>n>>c;
	for(int i=1;i<=n;i++) cin>>a[i];
	int l=1,cnt=0;
	long long sum=0;
	for(int r=1;r<=n;r++){
		sum+=a[r];
		while(sum>c&&l<=r) sum-=a[l++];
		if(sum==c) cnt++;
	}
	cout<<cnt;
	return 0;
}

易错点:找到 sum==c 后,循环必须继续前进,不能停在原地形成死循环。


七、模板4:包含全部m种数字的最短区间

对应题目:4870 射箭

使用:

cnt[x]:数字x在窗口中出现了几次
have:窗口中有多少种不同数字

加入一个第一次出现的数字:

if(cnt[a[r]]==0) have++;
cnt[a[r]]++;

删除一个最后一次出现的数字:

cnt[a[l]]--;
if(cnt[a[l]]==0) have--;

完整模板:

#include<bits/stdc++.h>
using namespace std;
const int N=1e6+10;
int n,m,a[N],cnt[2010];
int main(){
	cin>>n>>m;
	for(int i=1;i<=n;i++) cin>>a[i];
	int l=1,have=0,ans=1e9;
	for(int r=1;r<=n;r++){
		if(cnt[a[r]]==0) have++;
		cnt[a[r]]++;
		while(have==m){
			ans=min(ans,r-l+1);
			cnt[a[l]]--;
			if(cnt[a[l]]==0) have--;
			l++;
		}
	}
	if(ans==1e9) cout<<-1;
	else cout<<ans;
	return 0;
}

本题无解输出 -1


八、环形数组:回转寿司

环形数组可以复制一遍:

for(int i=1;i<=n;i++){
	cin>>a[i];
	a[n+i]=a[i];
}

这样原来的环形连续区间,就变成了长度 2n 数组中的普通连续区间。

但最多只能吃原来的 n 盘,所以窗口还要满足:

r-l+1<=n
#include<bits/stdc++.h>
using namespace std;
const int N=2e6+10;
long long a[N],m,sum;
int n;
int main(){
	cin>>n>>m;
	for(int i=1;i<=n;i++){
		cin>>a[i];
		a[n+i]=a[i];
	}
	int l=1,ans=0;
	for(int r=1;r<=2*n;r++){
		sum+=a[r];
		while((sum>m||r-l+1>n)&&l<=r){
			sum-=a[l++];
		}
		ans=max(ans,r-l+1);
	}
	cout<<ans;
	return 0;
}

这套写法不需要单独判断“所有寿司能否全部吃完”,边界更简单。


九、今天的易错点

1. 0下标前缀和访问s[-1]

错误:

s[i]=s[i-1]+a[i];//i=0时会访问s[-1]

建议本章统一使用1下标,或者直接在窗口中维护 sum

2. 循环写成l<=n、r<=n

r==n 后再执行 r++,可能访问 a[n+1]

推荐使用:

for(int r=1;r<=n;r++)

3. 数组大小写成1e12

const long long N=1e12+10;//错误,数组不可能开这么大

根据题目范围开数组,例如:

const int N=2e6+10;

4. sum使用int

n 很大时,连续区间和可能超过 int,应使用:

long long sum=0;

5. 环形数组没有限制窗口长度

复制数组后,窗口不能超过原来的 n 个元素。

6. 无解输出记混

4535 最短子序列:无解输出0。
4870 射箭:无解输出-1。

7. 最小字典序交换越界

最后一个字符是 s[s.size()-1],不存在 s[s.size()]

8. 找到答案后指针不移动

while 循环中每一轮必须保证 lr 至少有一个发生变化,否则会死循环。


十、一分钟复习

和太大:移动左指针。
需要扩大区间:移动右指针。
求最长:窗口合法时更新答案。
求最短:窗口满足条件时不断缩小。
环形数组:复制一遍,同时限制长度不超过n。
统计不同种类:使用cnt数组和have变量。

写完尺取法后检查:

1. l和r的初值对不对?
2. sum是否使用long long?
3. 每轮有没有移动指针?
4. 是否会访问数组外面?
5. 无解时应该输出什么?