这里字符串下标默认从 1 开始。

前缀函数

一些概念

前缀:一个字符串通过从结尾删除字符得到的子串(可以不删)。
后缀:一个字符串通过从开头删除字符得到的子串(可以不删)。
真前缀:一个字符串通过从结尾删除字符得到的子串(必须删)。
真后缀:一个字符串通过从开头删除字符得到的子串(必须删)。

π \pi π

定义对于字符串 s s s π \pi π 数组, π i \pi_i πi 表示前缀 [ 1 , i ] [1,i] [1,i] 的最长真前缀(且有一个后缀与之相等)长度,这个真前缀称为 border

求法

暴力
先看一个 O ( N 3 ) O(N^3) O(N3) 的暴力:

for(int i=2;i<=n;i++){
		for(int j=i-1;j>=1;j--){
			if(s.substr(1,j)==s.substr(i-j+1,j)){
				p[i]=j;
				break;
			}
		}
	}

没有任何优化,纯暴力。

优化 1

注意到在最好的情况下, π i \pi_i πi 的值每次最多增加 1,所以当前最大的情况也就是 π i − 1 + 1 \pi_{i-1}+1 πi1+1

for(int i=2;i<=n;i++){
		for(int j=p[i-1]+1;j>=1;j--){
			if(s.substr(1,j)==s.substr(i-j+1,j)){
				p[i]=j;
				break;
			}
		}
	}

这样看似没有改变什么,但实际的时间复杂度是 O ( N 2 ) O(N^2) O(N2) 的了。

因为每次增加 1, π i \pi_i πi 的最大值也只会到达 n − 1 n-1 n1,而下限是 0,所以外面两层枚举的总时间复杂度是 O ( N ) O(N) O(N) 的。

优化 2

注意到 π i \pi_i πi 的定义,有 [ 1 , π i ] = [ i − π i + 1 , i ] [1,\pi_i]=[i-\pi_i+1,i] [1,πi]=[iπi+1,i]
在这里插入图片描述
上图中,蓝色部分就是 [ 1 , π i ] [1,\pi_i] [1,πi] [ i − π i + 1 , i ] [i-\pi_i+1,i] [iπi+1,i],右边的黄色部分就是新加入的 i + 1 i+1 i+1

若要使新的 π i + 1 = π i + 1 \pi_{i+1}=\pi_i+1 πi+1=πi+1,则需要两个黄色部分的字符相同。

但是如果不相同怎么办?

我们将目光转移到 π π i \pi_{\pi_i} ππi 上面。由于 [ 1 , π i ] = [ i − π i + 1 , i ] [1,\pi_i]=[i-\pi_i+1,i] [1,πi]=[iπi+1,i],还有 [ 1 , π π i ] = [ π i − π π i + 1 , π i ] [1,\pi_{\pi_i}]=[\pi_i-\pi_{\pi_i}+1,\pi_i] [1,ππi]=[πiππi+1,πi],所以一定有 [ 1 , π π i ] = [ i − π π i + 1 , i ] [1,\pi_{\pi_i}]=[i-\pi_{\pi_i}+1,i] [1,ππi]=[iππi+1,i],这时候还是可以将 [ 1 , π π i ] [1,\pi_{\pi_i}] [1,ππi] [ i − π π i + 1 , i ] [i-\pi_{\pi_i}+1,i] [iππi+1,i] 两个区间视为上图中的蓝色部分,此时若黄色部分的字符相同,就有 π i + 1 = π π i + 1 \pi_{i+1}=\pi_{\pi_i}+1 πi+1=ππi+1

那如果还是不同怎么办?

就继续看 π π π i \pi_{\pi_{\pi_i}} πππi,直到没有为止。

时间复杂度 O ( N ) O(N) O(N),可以像【优化 1】中的那样分析。

for(int i=2;i<=n;i++){
	int j=p[i-1];
	while(j&&s[j+1]!=s[i])j=p[j];
	if(s[j+1]==s[i])j++;
	p[i]=j;
}

那为什么这样一定是对的?

首先正确性是毋庸置疑的。

看到最优性。根据 π \pi π 的定义,其一定是最大的,所以 π π i \pi_{\pi_i} ππi 一定是 π i \pi_i πi 的所以满足条件的前缀中最大的一个, π π π i \pi_{\pi_{\pi_i}} πππi 以此类推。

既然是从最大的开始向更小的找,那么肯定没有遗漏。

字符串匹配

所以 KMP 到底是啥?

其实没有前缀函数的 O ( N ) O(N) O(N) 求法,KMP 啥都不是。

查询模式串 s 2 s_2 s2 在文本串 s 1 s_1 s1 出现的位置。

题目:P3375 【模板】KMP

方案一

既然前缀函数是求一个前缀的 border 长度,那么我们只需要让模式串称为那个固定的前缀,去匹配文本串中的子串。

那么怎么找文本串的子串呢?前缀的后缀就是一个子串。

所以我们将两个字符串拼接起来:
s = s 2 + " # " + s 1 s=s_2+"\#"+s_1 s=s2+"#"+s1
中间的 # \# # 是为了防止匹配的长度超过模式串。

这时候我们遍历过程中,就会找到文本串 s 1 s_1 s1 的每个前缀,并且通过 π i \pi_i πi 数组得到其与模式串的前缀相同的后缀。

π i \pi_i πi 的值等于模式串 s 1 s_1 s1 的长度时,就说明匹配成功了。

#include<bits/stdc++.h>
using namespace std;
const int N=2e6+5;//字符串要拼接,要开两倍空间
int m;
int p[N];
signed main(){
	string s1,s2;
	cin>>s1;
	cin>>s2;
	m=s2.length();
	string s=" "+s2+"#"+s1;
	for(int i=2;i<s.size();i++){
		int j=p[i-1];
		while(j&&s[j+1]!=s[i])j=p[j];
		if(s[j+1]==s[i])j++;
		p[i]=j;
	}
	for(int i=m+2;i<s.size();i++){
		if(p[i]==m){
			cout<<i-2*m<<'\n';
		}
	}
	for(int i=1;i<=m;i++)cout<<p[i]<<' ';
}

方案二

直接比较。

先得求出模式串 s 2 s_2 s2 对应的 π \pi π 数组。

用两个指针 i i i j j j 分别在文本串 s 1 s_1 s1 和模式串 s 2 s_2 s2 中标记匹配位置。

s 1 i = = s 2 j + 1 {s_1}_i=={s_2}_{j+1} s1i==s2j+1,那么两个指针各自加一即可。

若不相等,则需要将指针 j j j 回移。

在这里插入图片描述
与求前缀函数的【优化 2】类似,蓝色部分即为模式串 s 2 s_2 s2 [ 1 , j ] [1,j] [1,j] 的 border。

当匹配不上时,为了防止时间爆炸,要用上之前已经匹配过的地方。所以将 j j j 变为 π j \pi_j πj。由于两端蓝色部分相同,所以不会影响前面的匹配。同理,若还是不匹配,则转到 π π j \pi_{\pi_j} ππj

#include<bits/stdc++.h>
using namespace std;
const int N=2e6+5;
int n,m;
int p[N];
signed main(){
	string s1,s2;
	cin>>s1;
	cin>>s2;
	n=s1.length(),m=s2.length();
	s1=" "+s1;
	s2=" "+s2;
	for(int i=2;i<=m;i++){
		int j=p[i-1];
		while(j&&s2[j+1]!=s2[i])j=p[j];
		if(s2[j+1]==s2[i])j++;
		p[i]=j;
	}
	int j=0;
	for(int i=1;i<=n;i++){
		while(j&&s2[j+1]!=s1[i])j=p[j];
		if(s2[j+1]==s1[i])j++;
		if(j==m){
			cout<<i-m+1<<'\n';
			j=p[j];
		}
	}
	for(int i=1;i<=m;i++)cout<<p[i]<<' ';
}

其他应用

字符串的周期

字符串 s s s 的周期 t t t 通过重复若干次可以得到一个字符串 S S S,使得 s s s S S S 的前缀。

第一种情况:border 未重叠
在这里插入图片描述
此时的周期很明显就是长度为 n − π n n-\pi_n nπn [ 1 , n − π n ] [1,n-\pi_n] [1,nπn] [ π n + 1 , n ] [\pi_n+1,n] [πn+1,n] 了。
第二种情况:border 重叠
在这里插入图片描述
上图中两个黑框框就是两个 border。根据 border 的定义,有两个橙色部分,两个蓝色部分相同。

但是这俩玩意本质上是一个字符串里面的,所以又有两条竖着的线连起来的部分相同。

根据 border 的定义,又有斜着的线连接的部分相同。

……

以此类推。

所以,最终橙色部分/蓝色部分就是字符串的一个周期,即 [ 1 , n − π n ] [1,n-\pi_n] [1,nπn] [ π n + 1 , n ] [\pi_n+1,n] [πn+1,n]

并且这是最短的周期,因为 π n \pi_n πn 还可以找到 π π n \pi_{\pi_n} ππn,会获得一个更长的周期。

以此类推。

字符串的压缩/循环节

循环节就是重复若干遍刚好为原字符串的子串。

那这就是个有条件的周期。

那么就是周期长度刚好是 n n n 的某个因数。

所以若 n ≡ 0 ( m o d    n − π n ) ) n\equiv0(\mod n-\pi_n)) n0(modnπn)) [ 1 , n − π n ] [1,n-\pi_n] [1,nπn] 就是循环节(或 [ π n + 1 , n ] [\pi_n+1,n] [πn+1,n])。

n ≢ 0 ( m o d    n − π n ) ) n\not\equiv0(\mod n-\pi_n)) n0(modnπn)),则循环节长度就是 n n n

为什么不继续尝试 n − π π n n-\pi_{\pi_n} nππn?因为 n − π n n-\pi_n nπn 是最小周期的长度,其他的周期都比他长。

Logo

一站式 AI 云服务平台

更多推荐