扩展KMP
扩展KMP
所有字符串下标从1开始
有一天,你虐OJ的时候遇到了这道题
ExKMP
给定两个字符串 \(a,b\),你要求出两个数组:
- \(b\) 的 \(z\) 函数数组 \(z\),即 \(b\) 与 \(b\) 的每一个后缀的 LCP 长度。
- \(b\) 与 \(a\) 的每一个后缀的 LCP 长度数组 \(p\)。
对于一个长度为 \(n\) 的数组 \(a\),设其权值为 \(\operatorname{xor}_{i=1}^n i \times (a_i + 1)\)。
求 \(z,p\) 的权值。
对于 \(100\%\) 的数据,\(1 \le |a|,|b| \le 2 \times 10^7\),所有字符均为小写字母。
扩展KMP即为解决这类问题的方法,具体如下:
设 \(n,m\) 分别为 \(a,b\) 长度
Z函数
即题目中的 \(z\) 数组。
显然,\(z[1]=n\)。我们考虑递推求解 \(z\)。
设我们已经求解 \(1\sim i-1\),现在求解 \(i\),设 \(r=\max_{1< k<i}\lbrace k+z[k] -1\rbrace\),\(l\) 为 \(k+z[k]\) 取到最大值的 \(k\)。特别地,\(z[1]\) 不参与 \(l,r\) 的取值。初始:\(r=0,l=0\)。
- \(i\le r\)
此时呢,根据 \(z\) 的定义,有 \(a[1,z[l]]=a[l,r]\)。可以看作字符串 \(a[1,z[l]]\) 向后平移了 \(l-1\) 个单位长度。那么由于 \(i\le r\),则 \(a[i,r]\) 也是由 \(a[i-l+1,r-l+1]\) 平移过来的,此时 \(z[i]\ge z[i-l+1]\)
需要注意的是,最长扩展长度不能超过 \(r-i+1\),若 \(z[i-l+1]>r-i+1\),也应取 \(r-i+1\)。故 \(z[i]\ge \min\lbrace z[i-l+1],r-i+1\rbrace\)
。先令 \(z[i]=\min\lbrace z[i-l+1],r-i+1\rbrace\),然后再暴力向后进行扩展,类如:while(i+z[i]<=n&&a[i+z[i]]==a[z[i]+1])++z[i];。这样的话,若 \(z[i-l+1]\le r-i+1\),此时没有超过最大长度 \(r-i+1\),由 \(z\) 的极大性可知此循环不会执行。
- \(i>r\),直接暴力进行匹配。
每次匹配完之后需要更新 \(l,r\)。
由于内层的while执行多少次,就会使得 \(r\) 增加多少,最大增量是 \(n\),所以总时间复杂度是 \(O(n)\) 的
void Z(){
z[1]=n;
for(int i=2,l=0,r=0;i<=n;i++){
if(i<=r)z[i]=min(z[i-l+1],r-i+1);
while(i+z[i]<=n&&a[i+z[i]]==a[z[i]+1])++z[i];
if(r<i+z[i]-1)l=i,r=i+z[i]-1;
}
}
\(p\) 数组
类比计算 \(z\) 函数的过程和KMP算法中匹配字符串的过程,不难想到如下代码:
void ExKMP(){
Z();
for(int i=1,l=0,r=0;i<=m;i++){
if(i<=r)p[i]=min(z[i-l+1],r-i+1);
while(i+p[i]<=m&&b[i+p[i]]==a[p[i]+1])++p[i];
if(r<i+p[i]-1)l=i,r=i+p[i]-1;
}
}
正确性的证明:
\(l,r\) 含义类似,仅由 \(z\) 数组换为 \(p\) 数组而已。
在 \(i\le r\) 的时候,同样是 \(a,b\) 的共同匹配部分,将重复的部分用 \(z\) 函数求出距离,更新即可。
同样是暴力匹配,暴力更新
Complete template
char a[N],b[N];
int n,m,z[N],p[N];
void Z(){
z[1]=n;
for(int i=2,l=0,r=0;i<=n;i++){
if(i<=r)z[i]=min(z[i-l+1],r-i+1);
while(i+z[i]<=n&&a[i+z[i]]==a[z[i]+1])++z[i];
if(r<i+z[i]-1)l=i,r=i+z[i]-1;
}
}
void ExKMP(){
Z();
for(int i=1,l=0,r=0;i<=m;i++){
if(i<=r)p[i]=min(z[i-l+1],r-i+1);
while(i+p[i]<=m&&b[i+p[i]]==a[p[i]+1])++p[i];
if(r<i+p[i]-1)l=i,r=i+p[i]-1;
}
}
signed main(){
cin>>b+1>>a+1;
n=strlen(a+1),m=strlen(b+1);
ExKMP();
int ans=0;
for(int i=1;i<=n;i++)ans^=i*(z[i]+1);
cout<<ans<<"\n";ans=0;
for(int i=1;i<=m;i++)ans^=i*(p[i]+1);
cout<<ans<<"\n";
}
事实上,我们可以将两个串合并,并以一个特殊字符如&隔开,跑一遍 \(z\) 数组,前半部分是所求的 \(z\) 数组,后半部分是所求的 \(p\) 数组。
ExKMP算法是KMP算法的扩展,这意味着它可以做很多KMP可以做和不可以做的事情
应用
字符串匹配
给定串 \(A,B\),求 \(A\) 在 \(B\) 中每一次出现的位置。
解法1
上文的 \(p\) 数组中,如果出现 \(p[i]=len_A\) 表示出现,输出即可。
for(int i=1;i<=m;i++){
if(p[i]==n){
cout<<"Appear: "<<i<<"\n";
}
}
解法2
同样的,我们也可以将 \(A\) 复制一遍在后面并用一个特殊字符隔开,这样只需统计 \(z[i]=len_A\) 的个数了。
循环元问题
根据KMP的知识,我们知道长度为 \(x\) 的循环元存在的充要条件是:\([1,len-x+1]=[x+1,len]\) 且 \(len\equiv 0(\bmod x)\)。
考虑食用 \(z\) 函数进行求解。我们可以从后往前枚举 \(z[i]\),一旦满足 \(i+z[i]-1=len\) 的时候,\(z[i]\) 就有可能为一个循环元。
类比 KMP ,我们考虑如何判断 \([1,len-z[i]+1]=[z[i]+1,len]\) ,明显,我们可以使用 \(z[z[i]+1]\) 来表示 \([z[i]+1,len]\) 和 \([1,z[z[i]+1]]\)。根据循环元和 \(z\) 函数的定义,可以像KMP那般不断回跳求解(可以想象一个一个不断重叠)。
事实上,\([1,z[i]]\) 是一个最小的循环元,无论是否完整(即他是使得这段字符 \([i+z[i]-1]\) 用 \([1,z[i]]\)循环覆盖后多余字符最少的子串)。
所以为循环元的充要条件是:\(i+z[i]-1=len,z[z[i]+1]=i-1\)
最小循环元就倒序枚举 \(i\),第一个有解就输出。
Z();
for(int i=n;i;--i)
if(i+z[i]-1==n&&z[z[i]+1]==i-1){
cout<<n/z[i]<<"\n";break;
}
这种方法的本质上枚举最后一次循环位置,以 \([1,z[i]]\) 为循环节。
本质上是通过 \(z[i]+1\) 将循环节头转移到了串开头的位置。
还有一种方法,枚举的是循环节,来判断是否合法,会简洁一些。
也即枚举循环节 \([1,i]\) ,若 \(z[i]=len-i+1\) 即有解。
前后缀问题
也即:求 \(A\) 的所有既是前缀又是后缀的所有子串长度。
在KMP算法中,我们通过不断跳 \(next\)数组实现它,下面我们使用 \(z\) 函数来实现它。
既是前缀又是后缀的子串,换句话说就是:\(i+z[i]-1=len\)。
找到所有符合要求的串,输出即可。
for(int i=n;i;--i)
if(i+z[i]-1==n)
cout<<z[i]<<" ";
cout<<"\n";
回文串问题
对于每个字符串 \(S\) ,求出一个字符串 \(S^*\) , \(S^*\) 需要满足:
- \(S\) 为 \(S^*\) 的前缀;
- \(S^*\) 是一个回文字符串;
- \(|S^*|\) 应尽可能小;
这个问题很有意思。我们判断一个回文串有一个方法是将其翻转并拼在原串前,用特殊符号隔开,最后看 \(z\) 是否等于 \(len\)。
对于 \(S^*\),可以进行拆分,拆分为 \(X+Y+X'\) 的形式,其中 \(X'\) 为 \(X\) 翻转后的串,\(X+Y=S\).
问题等价于求出后缀中最长的回文串。
根据回文串的判断方式,我们设 \(T=S'+@+S\)。那么所求的回文串就放在了 \(T\) 的开头和末尾。因为回文串翻转之后仍然相等,问题转化为求两半中的相等子串。根据上文,显然有 \(i\in[|S|+1,2|S|+1],i+z[i]-1=2|S|+1\),则说明串 \([1,z[i]]=[i,2|S|+1]\)。从前往后扫一遍,第一个合法位置即为所求。
应用
NOIP2020 字符串匹配
说一下我做的时候的心路历程
容易发现 \(AB\) 是一个非完整循环节,且字符数只有 26,可以递推预处理 :
- \(t[i]\) 表示串 \([i,n]\) 出现奇数次的字符数量
- \(g[i,j]\)表示在前 \(i\) 个字符中出现奇数次字符数量为 \(j\) 的数的个数
- \(f[i,j]\)表示在前 \(i\) 个字符中出现奇数次字符数量 \(\le j\) 的数的个数
inline void init(){
cin>>s+1;
n=strlen(s+1);
for(re int i=n;i;--i){
t[i]=t[i+1];
cnt[s[i]-'a']++;
if(cnt[s[i]-'a']&1)t[i]++;
else t[i]--;
}
memset(cnt,0,sizeof cnt);
for(re int i=1;i<=n;++i){
a[i]=a[i-1];
cnt[s[i]-'a']++;
if(cnt[s[i]-'a']&1)a[i]++;
else a[i]--;
}
memset(cnt,0,sizeof cnt);
for(re int i=1;i<=n;i++){
for(re int j=0;j<26;++j)g[i][j]=g[i-1][j];
g[i][a[i]]++;
}
for(re int i=1;i<=n;i++){
f[i][0]=g[i][0];
for(re int j=1;j<26;j++)f[i][j]=f[i][j-1]+g[i][j];
}
}
然后考虑枚举 \(C\),每一步用KMP算法判断循环节,将其倍数后统计答案.
inline void solve(){
re long long ans=0;
for(re int i=n-1;i;--i){
ans+=f[i-1][t[i+1]];
if(i%(i-nxt[i])==0){
int x=i-nxt[i];
for(int j=1;j*x<i;j++){
int len=x*j;
if(i%len)continue;
ans+=f[len-1][t[i+1]];
}
}
}
cout<<ans<<"\n";
}
这时候你就会惊奇的发现,交上去只有 48pts。
考虑优化。这个做法在循环节么次都是1的情况下会爆成 \(O(n^2)\)
换一个角度呢?我可以枚举 \((AB)^i\),只要可以 \(O(1)\) 地统计 \((AB)^i\) 的答案,就可以在 \(O(\sum_{i=1}^n\frac{n}{i})\sim O(n\log n)\) 的时间复杂度内解决。
显然是可以的,仅需要判断其最多能够循环到哪个位置。这时候需要用上 \(z\) 函数了
设 \(AB=[1,i]\),显然有 \([i+1,2i]\) 是第二个循环节。这时候就可以以 \(i+1\) 为起点判断循环节了。
更详细地说,这个循环节一直循环到 \(i+1+z[i+1]-1=i+z[i+1]\)。需要注意不一定完整循环,且 \(|C|\ge 1\),所以循环终点应该与n-1取较小值。所以代码应该是:
inline void solve(){
re long long ans=0;
for(re int i=2;i<n;++i){
int ed=i+z[i+1];ed=min(ed,n-1);
for(int j=1;j*i<=ed;++j){
ans+=f[i-1][t[i*j+1]];
}
}
cout<<ans<<"\n";
}
多测不清空,抱灵两行泪
这个做法会被卡到92(洛谷),需要氧气。
参考文献:

浙公网安备 33010602011771号