字符串
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;
}