扩展KMP

扩展KMP

所有字符串下标从1开始

有一天,你虐OJ的时候遇到了这道题

ExKMP
给定两个字符串 a,b,你要求出两个数组:

  • bz 函数数组 z,即 bb 的每一个后缀的 LCP 长度。
  • ba 的每一个后缀的 LCP 长度数组 p

对于一个长度为 n 的数组 a,设其权值为 xori=1ni×(ai+1)

z,p 的权值。
对于 100% 的数据,1|a|,|b|2×107,所有字符均为小写字母。

扩展KMP即为解决这类问题的方法,具体如下:

n,m 分别为 a,b 长度

Z函数

即题目中的 z 数组。

显然,z[1]=n。我们考虑递推求解 z

设我们已经求解 1i1,现在求解 i,设 r=max1<k<i{k+z[k]1}lk+z[k] 取到最大值的 k。特别地,z[1] 不参与 l,r 的取值。初始:r=0,l=0

  1. ir

此时呢,根据 z 的定义,有 a[1,z[l]]=a[l,r]。可以看作字符串 a[1,z[l]] 向后平移了 l1 个单位长度。那么由于 ir,则 a[i,r] 也是由 a[il+1,rl+1] 平移过来的,此时 z[i]z[il+1]

需要注意的是,最长扩展长度不能超过 ri+1,若 z[il+1]>ri+1,也应取 ri+1。故 z[i]min{z[il+1],ri+1}

。先令 z[i]=min{z[il+1],ri+1},然后再暴力向后进行扩展,类如:while(i+z[i]<=n&&a[i+z[i]]==a[z[i]+1])++z[i];。这样的话,若 z[il+1]ri+1,此时没有超过最大长度 ri+1,由 z 的极大性可知此循环不会执行。

  1. 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 数组而已。

ir 的时候,同样是 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,求 AB 中每一次出现的位置。

解法1

上文的 p 数组中,如果出现 p[i]=lenA 表示出现,输出即可。

	for(int i=1;i<=m;i++){
		if(p[i]==n){
			cout<<"Appear: "<<i<<"\n";
		}
	}

解法2

同样的,我们也可以将 A 复制一遍在后面并用一个特殊字符隔开,这样只需统计 z[i]=lenA 的个数了。

循环元问题

根据KMP的知识,我们知道长度为 x 的循环元存在的充要条件是:[1,lenx+1]=[x+1,len]len0(modx)

考虑食用 z 函数进行求解。我们可以从后往前枚举 z[i],一旦满足 i+z[i]1=len 的时候,z[i] 就有可能为一个循环元。

类比 KMP ,我们考虑如何判断 [1,lenz[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]=i1

最小循环元就倒序枚举 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]=leni+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 ,求出一个字符串 SS 需要满足:

  1. SS 的前缀;
  2. S 是一个回文字符串;
  3. |S| 应尽可能小;

这个问题很有意思。我们判断一个回文串有一个方法是将其翻转并拼在原串前,用特殊符号隔开,最后看 z 是否等于 len

对于 S,可以进行拆分,拆分为 X+Y+X 的形式,其中 XX 翻转后的串,X+Y=S.

问题等价于求出后缀中最长的回文串。

根据回文串的判断方式,我们设 T=S+@+S。那么所求的回文串就放在了 T 的开头和末尾。因为回文串翻转之后仍然相等,问题转化为求两半中的相等子串。根据上文,显然有 i[|S|+1,2|S|+1],i+z[i]1=2|S|+1,则说明串 [1,z[i]]=[i,2|S|+1]。从前往后扫一遍,第一个合法位置即为所求。

应用

NOIP2020 字符串匹配

说一下我做的时候的心路历程

容易发现 AB 是一个非完整循环节,且字符数只有 26,可以递推预处理 :

  1. t[i] 表示串 [i,n] 出现奇数次的字符数量
  2. g[i,j]表示在前 i 个字符中出现奇数次字符数量为 j 的数的个数
  3. f[i,j]表示在前 i 个字符中出现奇数次字符数量 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(n2)

换一个角度呢?我可以枚举 (AB)i,只要可以 O(1) 地统计 (AB)i 的答案,就可以在 O(i=1nni)O(nlogn) 的时间复杂度内解决。

显然是可以的,仅需要判断其最多能够循环到哪个位置。这时候需要用上 z 函数了

AB=[1,i],显然有 [i+1,2i] 是第二个循环节。这时候就可以以 i+1 为起点判断循环节了。

更详细地说,这个循环节一直循环到 i+1+z[i+1]1=i+z[i+1]。需要注意不一定完整循环,且 |C|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(洛谷),需要氧气。
参考文献

Z函数(扩展KMP)&前缀函数的总结~

洛谷P5410题解区

Oi Wiki

posted @   spdarkle  阅读(31)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!
点击右上角即可分享
微信分享提示