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

这篇总结所有的字符串都是以 0 为下标起点

Z函数(ExKMP)

对于一个字符串 S

我们规定一个函数 Z[i] 表示 SS[i...n1] 的 LCP(最长公共前缀)的长度。

S[0.....Z[i]1]S[i...i+Z[i]1] 相等

先说构造 Z 函数,再说 Z 函数的应用

首先考虑暴力的构造 时间复杂度 O(n2)

char s[N];
inline void GetZ(){
    int len=strlen(s);
    for(register int i=0;i<len;++i)
        while(i+z[i]-1<n&&s[z[i]]==s[i+z[i]]) z[i]++;
    
    for(register int i=0;i<len;++i)
        cout<<z[i]<<" ";
}

这就是一个根据定义的模拟,但是显然 O(n2) 的时间复杂度有些不太优秀,所以考虑优化:

扩展时的判断条件根据上面的代码,是:

while(i+z[i]-1<n&&s[z[i]]==s[i+z[i]]) z[i]++;

这一步是用枚举实现的,是 O(n) 的,那么如何对这一步进行优化呢?

对于枚举的优化:

这时考虑先考虑一下 Z 函数的性质:

从定义来说:这是满足S[0.....Z[i]1]S[i,i+Z[i]1] 相等的最长长度

性质1:那么对于一个区间[l,r]l[i,i+Z[i]1]r[i+Z[i]1],它一定与区间 [li,ri] 相等(定义),

那么考虑优化暴力的思路,即如何减少枚举:

如何减少枚举呢?大部分情况来说是从当前已知的情况去更新当前未知的情况,如果不行,再枚举

记录下i+Z[i]1 的最大值 r ,与这个最大值对应的 i,下面出现的 l,就是这个最大值对应的 i

如果对于当前的一个位置 i,如果 ir。那么根据性质 1 , S[i....r] 是与 S[il.....rl] 相等的

所以要么 i 这个位置与 Z[il] 一样,与 S 的LCP长度为 Z[il],要么它可以匹配完整个 ri+1,还可以继续往后匹配。

简单来说,就是Z[i]min(ri+1,Z[il])

那么如果此时 Z[il] 还满足 Z[il]<ri+1 也就是当前可以继承的范围并没有到达此时的边界 r ,我们选择直接继承。

if(Z[i-l]<r-i+1) Z[i]=Z[i-l];

根据上面的分析,如果不满足上面的这个条件话,证明它可以匹配完整个 ri+1,并且还能向后匹配

所以代码也就更简单了:

if(Z[i-l]>=r-i+1){
	Z[i]=r-i+1;
	while(i+Z[i]-1<len&&S[Z[i]]==S[i+Z[i]])	Z[i]++;
}

但是我们发现上面的两个程序本身是没有问题的,只是有一些情况没有考虑到:

1.比如当前的位置 i,如果已经 >r 了,那么上面的所有结论都不成立。这时就应该直接暴力匹配
2.我们的 r,表示的是当前匹配段最右边的端点值,而 l 是它所对应的 i 值,所以在暴力匹配后,应该更新 l,r 的值。

所以整个求 Z 函数的代码应该是这样的:

	int len=strlen(s);
	Z[0]=0;//其实根据定义这里也珂以赋值为 len。
	for(register int i=1;i<len;++i){
		if(i<=r&&Z[i-l]<r-i+1)	Z[i]=Z[i-l];
		else{
			Z[i]=max(0,r-i+1);
//因为可能有两种情况进来,一个是i>r,一个是Z[i-l]>=r-i+1,而两种情况对于Z[i]的赋值是不同的。所以这里直接一个max(0,r-i+1)概括两种情况
			while(i+Z[i]-1<len&&S[Z[i]]==S[i+Z[i]])	Z[i]++;
			if(r<i+Z[i]-1)	l=r,r=i+Z[i]-1;
		}
	}

为什么我们的循环要从1开始呢?

因为如果从0开始的话,r 会直接扩展完,而整个算法也会随之退化到 O(n2)

Z函数的应用:

1.字符串匹配

一个字符串算法少不了的就是字符串匹配了。

一道经典例题:

求一个字符串 A ,在另一个字符串 B 中的出现次数。

你先想了想 Z 函数,发现它储存的都是 B的后缀与 B 匹配的信息,基本无法应用到与 A 匹配上面。

那么如何将 BB 匹配的信息变成 BA 统计的信息呢?

答案十分 Naive

A 加在 B 的前面不久好了?

此时在新的字符串中 A 是这个串的前缀,那么此时匹配的就都是 A 了。

当然这样是有问题的,比如位置 i 的后缀已经可以把 A 全部匹配完了,他还是会和自己匹配,那么此时的信息根本无法用到与 A 的匹配中去。

所以我们还需要在 AB 之间加上一个特殊符号 '#',从而保证匹配长度不会超过 lenA

那么统计出现次数时只需要统计在 B 串的范围内,有多少个位置满足Z[i]=lenA 的就行了。

有了上面字符串匹配的知识,你就可以 A掉一些简单的模板题了!

题目:
P5410 【模板】扩展 KMP(Z 函数)
CF126B Password
UVA12604 Caesar Cipher

2.判断循环节

几个概念:

对字符串 S0>p|S|,若 S[i]=S[i+p] 对所有 i[0,|S|p1] 成立,则称 pS周期

对字符串 S0r<|S|,若 S 长度为 r 的前缀 和 长度为 r 的后缀相等,则称长度为r 的前缀为 Sborder

注意,周期不等价于循环节!

如果一个长度为 k 的周期是循环节,那么一定满足 len%k=0

题目

求一个字符串 A 的最短循环节。

对于一个长度为 k 的循环节,一定满足S[0......k1]=S[lenk.....len1]

如果转化为 Z 函数的话,就是 i+Z[i]==len 就是 i 的后缀为 S 的一个Border,有一个长度为 Z[i]border 等价于有一个长度为 lenZ[i] 的周期。(证明略过)

那么我们可以 O(n) 的扫,如果当前i+Z[i]==len 那么判断 len%(lenZ[i]) 是否等于 0 。因为满足 i+Z[i]=lenlenZ[i] 是递减的(因为 i 枚举时递增。)所以第一个满足上述条件的 lenZ[i] 就是最大的循环节,要找最小的可以直接倒叙枚举,然后第一个直接退出。

例题:
UVA455 周期串 Periodic Strings

(因为我太弱了,所以我没有找到更多的循环节例题 )

3.判断回文

只要你理解了 Z 函数在字符串匹配的应用。如果要判断一个串 S 是否为回文,只需要将它的反串 S 拼在 S 前面,然后中间加上一个 '#' ,直接匹配,最后判断 Z[len] 是否等于 len 就好了。(这里的 len 是指单个字符串的长度,不是拼在一起的长度)

例题:
UVA11475 Extend to Palindrome

题意:

就是加最少的字母,使得原串变为一个回文串。

设当前的字符串为 SS 一定可以被分成两部分 AB

其中B是一个回文串(也可以是一个空串),A 是一个普通的字符串。

放一个图方便理解吧:

A 的反串为 A

而且 A+B+A 一定是一个回文串(想一想为什么)

那么我们加上的字符串就是 A

因为|A| = |A|,|A|=|S||B|

因为|S|一定,为了让|A|更小,所以需要找到最大的|B|

也就是找出 S 的后缀中最长的回文串。

这个利用 Z 函数很容易解决

我们将 S 的反串 S 拼在 S 的前面,那么一个后缀回文串左端点 i 一定满足 Z[i]=这个后缀回文串的 len ,也就是i+Z[i]= 整个字符串的 len,即i+Z[i]=lenS

记住,我们找的是最长的后缀回文串,也就是 |B|max

但答案需要的是|A|,并且还要将 S[0~|A|-1]倒过来输出

最后输出就可以了。

Code:

#include<bits/stdc++.h>
using namespace std;
const int N=2e5+3;
char s[N];
int len,z[N],siz;
inline void GetS(){
	z[0]=siz+1;
	for(int i=1,l=0,r=0;i<=siz;++i){
		if(i<=r&&z[i-l]<r-i+1)	z[i]=z[i-l];
		else{
			z[i]=max(0,r-i+1);
			while(i+z[i]<=siz&&s[z[i]]==s[i+z[i]]) ++z[i];
			if(i+z[i]-1>r)	l=i,r=i+z[i]-1;	
		}
	}
	return;
}
int main(){
	while(scanf("%s",s)!=EOF){
		len=strlen(s);siz=2*len;
		s[len]='#';
		for(register int i=len+1;i<=siz;++i)	s[i]=s[i-len-1];
		reverse(s,s+len);
		GetS();int maxn=0;
		for(register int i=siz;i>len;--i){if(z[i]==siz-i+1){maxn=z[i];}	}	
		maxn=len-maxn;
		for(register int i=len+1;i<=siz;++i)	cout<<s[i];
		reverse(s+len+1,s+len+1+maxn);
		for(register int i=len+1;i<=len+maxn;++i)	cout<<s[i];
		putchar('\n');
	}
	return 0;
}

4.完美子串?

对于一个串 S,如果一个串既是它的前缀又是它的后缀,那么他就是 S 的完美子串。用 Z 函数来说,就是 i 如果满足 i+Z[i]==leni 开头的后缀为完美子串。

一些变式

1.求完美子串的出现次数:

首先注意到,每一个完美子串的长度都不相同,这就意味这我们不需要判断一个完美子串与另一个完美子串是否本质相同。

而且大的完美子串中一定包含小的完美子串,这也就启发我们可以利用 桶+后缀和 的思想来统计出现次数。

那么如何判断某一个子串可以包含某一个大的完美子串( k )呢?很显然,只需要这个点 iZ[i]lenk 就行了(因为每一个完美子串也是一个前缀。)

例题:
CF126B Password
CF432D Prefixes and Suffixes


//Z 函数蒟蒻会的就这么点了。。。觉得好的点个赞呗~(赞在文章底部作者栏的右边)

前缀函数

好吧其实前缀函数和 KMPnext 数组没什么大区别,只不过一个是下标一个是长度罢了。

给定一个长度为 len 的字符串 S , 其前缀函数被定义为一个长度为 n 的数组 π。其中π[i] 的定义为:

1.如果 i 的前缀 S[0...i] 有一对相等的真前缀与真后缀,即 S[0.....k1]=S[ik+1.....i] 那么 π[i] 就是这个相等的真前缀的长度,也就是 π[i]=k

2.如果有不止一对相等的,那么 π[i] 就是其中最长的那一对的长度;

3.如果没有相等的,那么 π[i]=0

简单来说 π[i] 表示的也就是以 i 为右端点的前缀最长的 border 长度( border 的定义看上面)

特别的,我们规定 π[0]=0

如果直接暴力计算前缀函数的话:

Code:

inline void Getpi(){
	string s;cin>>s;int len=s.size();
	for(register int i=1;i<len;++i){
		for(register int j=i;j>=0;--j){
			if(s.substr(0,j)==s.substr(i-j+1,j)){
				pi[i]=j;
				break;
			}
		}
	}
	return;
}

显然上面的算法是 O(n3) 的,不够优秀

考虑优化

优化构造前缀函数

优化1:相邻的两个前缀函数值最多增加 1。

这个显然,如果已经求出了当前的 π[i] 需要求出一个尽量大的 π[i+1] 时。

S[i+1]=S[π[i]] 的(下标从 0 开始),此时的 π[i+1]=pi[i]+1;

所以从 ii+1 时,前缀函数值只可能增加 1, 或者维持不变,或者减少。

此时可以将整个代码优化成这样:

inline void Getpi(){
	string s;cin>>s;int len=s.size();
	for(register int i=1;i<n;++i){
		for(register int j=pi[i-1]+1;j>=0;--j){
			if(s.substr(0,j)==s.substr(i-j+1)){
				pi[i]=j;
				break;
			}	
		}
	}
	return;
}

这个时候,因为起始点变为了 π[i1]+1 所以只有在最好的情况下才会在这个枚举上限上 +1 ,所以最多的情况时会进行 n1+n2+2n3 次比较

所以这个时候整个算法时间复杂度已经是 O(n2) 了。但还是不够优秀

优化2:可以通过不断地跳前缀函数来获取一个合法的匹配长度

在优化1中,我讨论了最优情况下的转移,那么这时理所当然的就该来优化S[π[i]]!=S[i+1] 时的匹配了

我们在 S[π[i]]!=S[i+1] 时,根据 π 函数的最优性,我们应该找到第二长的长度 j 使得 S[0....j1]==S[ij+1.....i] 这样我们才能继续用 S[i+1]=S[j] 时的拓展。

而当我们观察了一下可以发现:

S[0.....π[i]1]=S[iπ[i]+1....i] 所以第二长 j ,也就等价于[0,π[i]1] 这个区间中的最长 border 的长度 ,在一想,这不就是 π[pi[i]1] 嘛?(因为 π 函数,代表的一定是这个区间最长的 border 的长度)

所以这时我们只需要不停地跳 π 函数,就可以得到当前的 π[i+1] 了。

Code:

inline void Getpi(){
	string s;cin>>s;int len=s.size();
	//因为下标从0开始,所以下标其实是长度-1,所以格式与上文可能有些不符合,但是理解了就对了!
	for(register int i=1;i<len;++i){
		int j=pi[i-1];
		while(j&&S[i]!=S[j])	j=pi[j-1];
		if(S[i]==S[j]) ++j;
		pi[i]=j;
	}
	return;
}

发现:我们枚举的 i 最多让 j 增加 n,而我们每次的跳至少会让 j1,所以无论 j 减小多少次,总的次数也不会超过 O(n)

所以此时构造的时间复杂度就为 O(n)

前缀函数的应用~

1.经典字符串匹配

求一个字符串 A ,在另一个字符串 B 中的出现次数。

在前面 Z 函数匹配字符串的启发下,很快就能想到:还是将 A 拼到 B 前面,中间加上一个特殊字符 '#' 。

因为有一个 ‘#‘ 在中间,所以所有的 π[i] 一定是 lenA 的。同样的想法:那么如何判断 AB中出现过呢?

既然 π[i] 表示的是以 i 为右端点的前缀长度,这个时候 A 为整个串的前缀,那么对于一个位置 i,当 π[i]==lenA 时,代表着 S[ilenA+1......i]A 相同 。

学会了这个你就可以 A 下面的例题了!

例题:
P3375 【模板】KMP字符串匹配
CF126B Password
UVA12604 Caesar Cipher

一道字符串匹配的变式吧。。:

P6080 [USACO05DEC]Cow Patterns G

在很多普通的字符串匹配中,π 函数表示的是前缀中最长的 border ,也就是前缀中前后缀相等的最长长度。

但在这道题中,很明显,无法用相等来表示。

首先,将模式串(K )和数字串(N)拼起来,中间插入一个特殊符号 “#”。

根据题意:我们应该将 π 函数中的“相等”看做大小关系相同,于是π[i] 就表示当前 S[0~i] 中前后缀大小关系最长的长度,因为有个特殊符号 “#” ,所以所有的 π[i]K,而满足“坏蛋团体”区间的右端点,一定满足 π[r]=K

那么这时问题就出在了如何判断大小关系相同了。

如果说当前 S[0~j1]S[ij,i1] 大小关系相同。

那么对于 ji 这两个位置,(首先匹配时这个 j ,一定是K的)

如果说 [0,j1] 中 比j 大的数与[ij,i1]中比 i 大的数的个数相等

而且 [0,j1] 中 和j 相等的数与[ij,i1]中和 i 相等的数的个数相等

又因为两个区间长度是一样的,那么区间中大于 j ,与大于 i 的数的个数也是相等的。

那么这[0,j][ij,i]两个区间的大小关系相等。

如此我们只需要用一个桶的前缀和,就可以在 O(S) 的复杂度中求出区间中比它小的与相等的数的个数了。

Warning : 最后需要的是左端点,但利用 π 函数判断的话,符合条件的是右端点.

Code:

与它相似的一道题:CF471D MUH and Cube Walls

2.判断循环节:

Z 函数差不多,整个前缀函数判断循环节也是通过不断地判断合法的 border 来确定周期长度,从而确定循环节长度的。

但是其实有一个定理(最长循环串长度=总长度-最长相同前后缀长度(前提是这个长度合法,不合法则不存在合法的循环节))

但是由于 Z 函数的定义,所以 Z 函数并不能像前缀函数这样 O(1) 求出最长循环节。

证明用的反证法。。这里就不放了。。。有需要的可以找我。。。

3.一个字符串中本质不同的子串个数

给定一个长度为 n 的字符串 S ,我们希望计算它的本质不同子串的数目。

我们将用一种在 S 的末尾添加一个字符后重新计算该数目的方法。

k 为当前 S 的本质不同子串的数量。我们添加一个新的字符 cS 中。现然会有一些写的子串以 c 结尾并且之前没有出现过,我们需要对这些字符串基数。

构造一个字符串 T+S+c 将它反转得到 T。现在我们的任务变成了计算有多少个 T 的前缀没有在 T 中的其他地方出现过,如果我们计算了 T 的前缀函数的最大值 πmax,那么最长的没有在 S 中的前缀的长度就为 πmax。那么自然,所有更短的前缀也会出现

所以,当添加了一个新字符后出现的新字符串为 |S|+1πmax

所以对于每次加入的字符,我们可以 O(n) 的算出新出现的子串的数量,所以最终复杂度就为 O(n2)

这一段抄的老师的讲义。。。(因为我描述不到这么详细,我太弱了)

posted @   NuoCarter  阅读(2256)  评论(0编辑  收藏  举报
编辑推荐:
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 使用C#创建一个MCP客户端
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现
点击右上角即可分享
微信分享提示