KMP-前缀函数

这里字符串下标默认从 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) 的暴力:

cpp 复制代码
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 πi−1+1。

cpp 复制代码
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 n−1,而下限是 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】中的那样分析。

cpp 复制代码
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 的长度时,就说明匹配成功了。

cpp 复制代码
#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。

cpp 复制代码
#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)) n≡0(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)) n≡0(modn−πn)),则循环节长度就是 n n n。

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

相关推荐
feng_you_ying_li1 小时前
Linux 之线程封装,线程的同步与互斥,互斥锁的介绍
linux·c++·算法
HZ·湘怡2 小时前
二叉树 2 堆
算法
wabs6669 小时前
关于贪心算法的思考
算法·贪心算法
社交怪人10 小时前
【判断大小】信息学奥赛一本通C语言解法(题号1043)
算法
Snasph10 小时前
GNU Make 用户手册(中文版)
服务器·算法·gnu
江澎涌10 小时前
拆解与 AI 的一次对话
人工智能·算法·程序员
sheeta199811 小时前
LeetCode 每日一题笔记 日期:2026.06.02 题目:3635. 最早完成陆地和水上游乐设施的时间 II
笔记·算法·leetcode
Lsk_Smion11 小时前
力扣实训 _ [102].层序遍历--前序--后续_递归与非递归的实现
数据结构·算法·leetcode
小欣加油12 小时前
leetcode3751 范围内总波动值I
java·数据结构·c++·算法·leetcode