蓝桥杯备赛 Day14 字符串

字符串

KMP

1.KMP解决的问题:在一个已知字符串(主串)中查找子串的位置,也叫做串的模式匹配
朴素的模式匹配为主串和子串的一个字符不匹配,主串回溯到第二个字符,子串回溯到第一个字符,效率太低。
而KMP算法对子串求得一个nex数组,主串和子串的一个字符不匹配,主串字符不动,子串回溯到net[j],如下图所示
![[KMP.png]]
2.nex数组的构建
nex数组记录子串每一个字符位置处,以该字符为结尾的子子串的最大相等前缀和后缀的长度,例如:

id      0 1 2 3 4 5 6
p         a b a b a c  //字符串从1开始计数,
nex     0 0 0 1 2 1 0
解释:nex[0]=nex[1]=0,为赋初值,只有1个字符不能叫做前缀和后缀相等
nex[3]=1,因为前缀:a,后缀:a
nex[4]=2,因为前缀:ab,后缀:ab(不是ba,仍然是从前往后)
nex[5]=1,因为前缀:a,后缀:a

代码:

const int M=100;
char p[M];
int nex[M],m;
int main(){
	cin>>p+1; //从p[1]开始输入
	m=strlen(p+1) //获取从p[1]开始的字符串长度
	//初始化nex数组 
	nex[0]=nex[1]=0;
	//i从2开始,赋值nex[i],j从0开始,判断p[i]=?p[j+1] 
	for(int i=2,j=0;i<=m;i++){
		//不断匹配,如果这个前缀不行,就找这个前缀的前缀,即nex[j],直到j==0
		while(j && p[i]!=p[j+1])	j=nex[j];
		//此时要么j==0,要么p[i]==p[j+1]
		//匹配成功,满足前缀和后缀相等,j++ 
		if(p[i]==p[j+1])	j++;
		//赋值 
		nex[i]=j;
	}
	return 0;
}

图解:
![[nex数组.png]]
3,利用nex数组实现KMP算法
仍然是这幅图
![[KMP.png]]
代码:

const int M=100;
char s[M];
int n;
cin>>s+1;
n=strlen(s+1);
//KMP算法
	for(int i=1,j=0;i<=n;i++){
		//不匹配则更新j的位置,寻找前缀的前缀 
		while(j && s[i]!=p[j+1])	j=nex[j];
		//匹配则j++ 
		if(s[i]==p[j+1])	j++;
		// 成功匹配一次
		if(j==m){ 
			//根据题目而定
			int start=i-j+1; //匹配的主串的字符位置 
		}
	}

例子图解:
![[KMP2.png]]

斤斤计较的小Z

学习:
(1)字符串匹配模版题
(2)学会输入:

//让字符串从1开始计数 

  cin>>p+1;

  m=strlen(p+1);

  //默认情况下,cin 会以空白字符(包括空格、制表符、换行符)作为分隔符。

  cin>>s+1;

  n=strlen(s+1);

代码:

#include <bits/stdc++.h>

using namespace std;
const int N=1e6+10;
char s[N],p[N];
int n,m,nex[N],ans;

int main(){
	ios::sync_with_stdio(false),cin.tie(0),cout.tie(0);
	//让字符串从1开始计数 
	cin>>p+1;
	m=strlen(p+1);
	//默认情况下,cin 会以空白字符(包括空格、制表符、换行符)作为分隔符。
	cin>>s+1;
	n=strlen(s+1);
	//计算nex数组
	//初始化nex数组
	nex[0]=nex[1]=0;
	for(int i=2,j=0;i<=m;i++){
		//未匹配更新j
		while(j && p[i]!=p[j+1])	j=nex[j];
		//匹配成功
		if(p[i]==p[j+1])	j++;
		//更新nex[i] 
		nex[i]=j; 
	} 
	//KMP算法
	for(int i=1,j=0;i<=n;i++){
		//未匹配更新j
		while(j && s[i]!=p[j+1])	j=nex[j];
		//匹配成功j++
		if(s[i]==p[j+1])	j++;
		//完全匹配
		if(j==m){
			ans++;
			//从完全匹配的下一个字符开始新的一轮
			i=i-j+1; //之后有i++ 
			j=0;
		} 
	} 
	cout<<ans;
	return 0;
}
小明的字符串

学习:
(1)求子串的最大匹配前缀,就是KMP算法里面改写第三部分即可,ans=max(ans,j)

字符串hash

1.本质是将字符串映射到数字,构造一个单射,使一个字符串映射到一个数字,从而比较数字来判断字符串是否相等,时间为O(1)
2.采用自然溢出方法,即**unsigned long long** 的自然溢出(相当于取模2^64-1),且定义一个进制base,一般取131(这两步都是为了构造单射,防止冲突)
3.构造hash数组

typedef unsigned long long ull;
const ull base=131;//base一般为一个质数
ull h[N],b[N]; //hash数组和base数组
//base数组初始化,为base的多少次方
b[0]=1;
//hash数组初始化
for(int i=1;i<=n;i++){
	b[i]=b[i-1]*base; //base数组
	h[i]=h[i-1]*base+s[i]-'a'+1; //类似于前缀和
}

4.获取一段字符串的哈希值

ull gethash(ull h[],int l,int r){
	return h[r]-h[l-1]*b[r-l+1];//类似于前缀和
}

如下图所示:
![[字符串hash.png]]
5.最后在主串种匹配子串就只需遍历主串然后用hash值判断即可

for(int i=1;i+m-1<=n;i++){ //子串长度为m,i最多到n-m+1(n-(n-m+1)+1=m)
	//完全匹配一次
	if(gethash(h1,i,i+m-1)==gethash(h2,1,m)){
		ans++;
	}
}
斤斤计较的小Z

学习:
(1)字符串hash模版题
代码:

#include <bits/stdc++.h>

using namespace std;
const int N=1e6+10;
char s1[N],s2[N];
int m,n,ans;
typedef unsigned long long ull;
const ull base=131;
ull h1[N],h2[N],b[N];
//1为子串,2为主串 

ull gethash(ull h[],int l,int r){
	return h[r]-h[l-1]*b[r-l+1];
}

int main(){
	ios::sync_with_stdio(false),cin.tie(0),cout.tie(0);
	cin>>s1+1;
	m=strlen(s1+1);
	cin>>s2+1;
	n=strlen(s2+1);
	//初始化hash数组
	b[0]=1;
	for(int i=1;i<=n;i++){
		b[i]=b[i-1]*base;
		h1[i]=h1[i-1]*base+(int)s1[i]; //后面是几无所谓,不是0就行
		h2[i]=h2[i-1]*base+(int)s2[i]; 
	} 
	//遍历主串 
	for(int i=1;i+m-1<=n;i++){
		//完全匹配一次 
		if(gethash(h1,1,m)==gethash(h2,i,i+m-1)){
			ans++;
		}
	}
	cout<<ans;
	return 0;
}

Manacher

理解

1.先要知道回文中心和回文半径,以及加入特殊字符处理:
![[回文中心和回文半径.png]]
如果回文字符串长度为偶数时,回文中心不能恰好落到某个数组下标处,为了统一操作,在每个字符中间添加一个特殊字符“#“,同时为了方便数组遍历,在首加上”^“字符,在尾加上”$"字符
![[回文中心和回文半径2.png]]
所以原来的索引都乘以2.倒序赋值,且数组开的大小也是2倍,代码实现

const int N=1e6+10; //2倍大小
char s[N];

cin>>s+1;
int n=srlen(s+1);
//倒序遍历,不会影响前面的
for(int i=2*n+1;i>0;i--){
	//奇数插入#
	if(i & 1) s[i]='#';//比i%2快
	//偶数为原数组字符
	else s[i]=s[i>>1];//比i/22快
}
//首尾字符
s[0]='^',s[2*n+2]='$';

2.回文半径p数组(记录以某个字符为中心向两侧拓展的最大回文串半径(不包括自己)),如下图所示
![[回文半径.png]]
暴力解法就是以这个字符为中心向两侧拓展(中心拓展法),直到不再是回文字符串为止,从而求得最长回文子串,代码:

int ans=0;
for(int i=1;i<=2*n+1;i++){
	(i-j)-(i+j)
	int j=0;//能拓展长度
	while( (i-j-1)>=1 && (i+j+1)<=2*n+1 && s[i-j-1]==s[i+j+1]) j++;
	ans=max(ans,j);
}

而Manacher算法如下图蘑菇图所示:
![[蘑菇图.png]]
例如:
(1)第一个c字符位置处,左侧的蘑菇都在c这个大蘑菇的左边界之内,那么右侧三个小蘑菇可以直接得出来(充分利用回文串的对称性)(优化1)
(2)对于a字符,左侧的c字符的左边界超过a字符蘑菇的左边界,从而不能直接对称到右侧去,但是能保证右侧c字符的回文字符串的长度至少是1(即到a字符蘑菇的由边界)(优化2),然后再向两侧拓展
利用两个变量:R(当前最长回文串的右边界),C(当前最长回文串的回文中心位置)
代码实现:

int p[N];//回文半径长度,等价于替代中心拓展法里面的变量j
int c=0,r=0; //都从0开始
for(int i=1;i<=2*n+1;i++){
	//如果i<r,说明可以利用对称性求出一个至少的长度
	if(i<=r) p[i]=min(r-i,p[2*c-i]); //2*c-i为对称过去
	//对于上面(2)中的a字符还要中心拓展,而(1)中的c字符下面的循环直接退出了,直接优化调
	while(s[i-p[i]-1]==s[i+p[i]+1]) p[i]++;
	//更新r和c(只有经历中心拓展的才有可能超过,r单调递增)
	if(i+p[i]>r){
		r=i+p[i];
		c=i;
	}
}
# P1659 [国家集训队] 拉拉队排练

学习:
1.这题用Manacher算法求出数组p,即以每个字符为回文中心的回文半径,然后要理解题意,首先回文字符不能是添加的字符’#',其次回文半径要是奇数,然后一个一个回文半径可以一直提供到1(例如abbba,回文半径为5,然后还能提供回文半径3的bbb,p数组是最大回文半径)
2.存储前k个大的,可以数组排序,也可以使用优先队列维护,注意:优先队列变成小顶堆(队列top元素是最小的,但是用的是greater<int>,理解为数组是降序排列,但是要出队列取数组最后一个元素,即最小的),还要指明底层容器类型,例如vector<int>,变成priority_queue<int,vector<int>,greater<int>>
3.上面这样做后面会超时,先暂时不考虑
代码:

#include <bits/stdc++.h>

using namespace std;
const int N=2e6+10,mod=19930726;
char s[N];
int n,k,p[N];

int main(){
	ios::sync_with_stdio(false),cin.tie(0),cout.tie(0);
	cin>>n>>k>>s+1;
	for(int i=2*n+1;i>0;i--){
		if(i&1)	s[i]='#';
		else s[i]=s[i>>1];
	}
	s[0]='^',s[2*n+2]='$';
	int c=0,r=0;
	for(int i=1;i<=2*n+1;i++){
		if(i<=r)	p[i]=min(r-i,p[2*c-i]);
		while(s[i-p[i]-1]==s[i+p[i]+1])	p[i]++;
		if(i+p[i]>r){
			r=i+p[i];
			c=i;
		}
	}
	priority_queue<int,vector<int>,greater<int>> q;
	for(int i=1;i<=2*n+1;i++){
		//i为偶数 ,p[i]为奇数 
		if(!(i&1) && p[i]&1){
			while(p[i]>0){
				q.push(p[i]);
				//维护前k个 
				if(q.size()>k){
					q.pop(); //删掉最小的 
				}
				p[i]-=2;
			}
		}	
	}
	if(q.size()<k){
		cout<<-1;
		return 0;
	}
	int ans=1;
	while(!q.empty()){
		ans=(ans*q.top())%mod;
		q.pop();
	}
	cout<<ans;
	return 0;
}
反异或01串

学习:
1.分析题目:题目关键点在于 s′ = s ⊕ rev(s)
由异或性质可知,若s[i]==s[n-i-1],则s'[i]=0=s'[n-i-1],反之,若s[i]!=s[n-i-1],则s'[i]=1=s[n-i-1],则可以得到结论:s'一定为一个回文串
2.s'为一个回文串,若s'偶数长度,根据回文串的对称性和上述结论,可以使s'一半的1由s的0生成,这是最多能缩减的量,而s'奇数长度,s必定也为奇数长度,rev(s)也为奇数长度,s和rev(s)中间的数必定相等,s’中间的数必定是0,所以也是缩减一半的1,所以这题就转换为求含1最多的回文子字符串的1的数量,然后用总的1的数量-求得的1的数量/2,而回文子字符串求法联想到Manacher算法,1的数量是一个区间值求和,联想到前缀和算法
代码:

#include <bits/stdc++.h>

using namespace std;
const int N=2e6+10;//2倍
char s[N];
int p[N],pre[N]; 

int main(){
	ios::sync_with_stdio(false),cin.tie(0),cout.tie(0);
	cin>>s+1;
	int n=strlen(s+1);
	int m=2*n+1;
	//构建#字符数组
	for(int i=m;i>=1;i--){
		if(i&1) s[i]='#';
		else s[i]=s[i>>1];
	} 
	s[0]='^',s[m+1]='$';
	//前缀和
	for(int i=1;i<=m;i++){
		pre[i]=pre[i-1]+(s[i]=='1');
	} 
	//Manacher算法
	int c=0,r=0;
	for(int i=1;i<=m;i++){
		if(i<=r)	p[i]=min(r-i,p[2*c-i]);
		while(s[i-p[i]-1]==s[i+p[i]+1])	p[i]++;
		if(i+p[i]>r){
			r=i+p[i];
			c=i;
		}
	} 
	//计算含1最多的回文子字符串1的数量
	int cnt=-1;
	for(int i=1;i<=m;i++){
		int t=pre[i+p[i]]-pre[i-p[i]-1];
		cnt=max(cnt,t);
	} 
	//结果
	cout<<pre[m]-cnt/2; 
	return 0;
}

字典树初步

解决问题

1.输入字符串s1-sn,要查找一个字符串t是否在其中出现过,朴素查找就是一个一个遍历查找,但是对于多次查询会超时,而字典树就是建立个树状结构实现快速查询字符串t是否出现过,如下图所示
![[字典树.png]]
2.字典树用下面的代码实现:

int nex[N][27]; //nex[i][j]记录从当前结点i出发加上j字符所到达的结点id,例如上图nex[3]['c'-'a']=4,nex[3]['d'-'a']=5
int cnt[N];//记录当前结点的字符串数量,例如cnt[4]=1,表示输入中有一个字符串'abc'
int idx=2; //下一个结点索引,用于动态开点

3.输入一个字符串更新字典树的insert函数

void insert(char s[]){
	int len=strlen(s+1);
	int x=1;//从第1个结点开始
	for(int i=1;i<=len;i++){
		char t=s[i]-'a';
		//当前结点没遇到过此字符则开新结点
		if(!nex[x][t]) nex[x][t]=id++;
		//更新遍历结点
		x=nex[x][t];
	}
	//更新cnt数组
	cnt[x]++;
}

注意:是nex[x][t],不是nex[i][t]
4.输出字符串t在字典树中出现的次数check函数(即遍历完字符串t返回cnt数组)

int check(char s[]){
	int len=strlen(s+1);
	int x=1; //开始遍历结点
	for(int i=1;i<=len;i++){
		char t=s[i]-'a';
		x=nex[x][t]; //没有结点则为0,cnt也为0,表示没有出现过
	}
	return cnt[x];
}
前缀判定

学习:
1.只要判断字符串t是否出现在s1-sn的前缀即可,可以修改cnt[x]的位置,或者直接以x来判断
代码:

#include <bits/stdc++.h>

using namespace std;
const int N=2e6+10;
int nex[N][27];
int cnt[N];
int idx=2,n,m;

void insert(char s[]){
	int len=strlen(s+1);
	int x=1;
	for(int i=1;i<=len;i++){
		int t=s[i]-'a';
		if(!nex[x][t])	nex[x][t]=idx++;	
		x=nex[x][t];
		cnt[x]++;
	}
}

bool check(char s[]){
	int len=strlen(s+1);
	int x=1;
	for(int i=1;i<=len;i++){
		int t=s[i]-'a';
		x=nex[x][t];
	}
	if(cnt[x])	return true;
	return false;
}

int main(){
	ios::sync_with_stdio(false),cin.tie(0),cout.tie(0);
	cin>>n>>m;
	while(n--){
		char s[N];
		cin>>s+1;
		insert(s);
	}
	while(m--){
		char s[N];
		cin>>s+1;
		cout<<(check(s)?'Y':'N')<<"\n";
	}
	return 0; 
}

真题

2023填充

学习:
(1)上面写着动态规划,实际上跟动态规划没有关系,就是判断是否是一个两个字符的子串,是的话就i++跳过第i+1个数即可
代码:

#include <bits/stdc++.h>

using namespace std;
typedef long long ll;
string s; 

int main(){
	ios::sync_with_stdio(false),cin.tie(0),cout.tie(0);
	getline(cin,s);
	ll ans=0;
	for(int i=0;i<s.size()-1;i++){
		if(s[i]==s[i+1] || s[i]=='?' || s[i+1]=='?'){
			ans++;
			i++;//跳过i+1即可 
		}
	}
	cout<<ans;
	return 0;
}
2024回文字符串

学习:
(1)像这种输出"Yes"和"No"的,写个函数返回bool值来判断输出"Yes“还是"No"比较好
(2)此题用vector来获取非规定字符的位置,从而根据索引的到第一个和最后一个非规定字符的位置,然后分别向内扩散判断和向外扩散判断(用vector第一反应是否为空)
代码:

#include <bits/stdc++.h>

using namespace std;
int t;
bool solve(char s[]){
	int len=strlen(s);//不能写s.size() 
	//获取s中非l,q,b的字符位置
	vector<int> v;
	for(int i=0;i<len;i++){
		if(s[i]!='l' && s[i]!='q' && s[i]!='b'){
			v.emplace_back(i);
		}
	} 
	//必须先判断,如果v为空,肯定可以,镜像翻转即可
	if(v.size()==0)	return true; 
	//找到第一个和最后一个非l,q,b位置
	int l1=v[0],l2=v[0],r1=v[v.size()-1],r2=v[v.size()-1];
	//向内和向外扩散判断
	//向内必须是回文字符串
	while(l1<=r1 && s[l1]==s[r1]){
		l1++,r1--;
	} 
	//如果不是,则直接false
	if(l1<=r1)	return false;
	//向内扩散判断
	while(l2>=0 && r2<len && s[l2]==s[r2]){
		l2--,r2++;
	} 
	//左边必须走完才行
	if(l2>=0)	return false;
	return true; 
}

int main(){
	ios::sync_with_stdio(false),cin.tie(0),cout.tie(0);
	cin>>t;
	while(t--){
		char s[1000010];//不能写1e6+10 
		cin>>s;
		if(solve(s))	cout<<"Yes\n";
		else cout<<"No\n";
	}
	return 0;
}