Upd on 2023.5.14:重述本篇文章部分内容,使得文章更加易懂。
前言
在我们今天的学习之前,常见的字符串算法除了 STL 之外就是哈希。但哈希有一定的错误概率,如果想要 100 % 100\% 100% 正确,就要双哈希,非常麻烦。那么,有没有一些算法,保证正确性的同时还拥有优秀的复杂度呢?
这就是今天的主角:KMP 算法。
本文默认字符串下标从 1 1 1 开始。
算法讲解
KMP 是由三位大佬: D . E . K n u t h \mathcal{D.E.Knuth} D.E.Knuth, J . H . M o r r i s \mathcal{J.H.Morris} J.H.Morris, V . R . P r a t t \mathcal{V.R.Pratt} V.R.Pratt,取他们名字首字母,就是 K , M , P K,M,P K,M,P。
原理
KMP 常用于解决单串匹配问题。即给定两个字符串 s s s 和 t t t,问 t t t 在 s s s 中的出现情况(出现次数或出现位置等)。
我们将 s s s 称为文本串, t t t 称为模式串。
一般的解法是对于 s s s 的每个位置都放一个 t t t 上去一位一位匹配。这种算法效率底下的原因是因为某些位置可能判断过很多次。
而 KMP 的优越性在于,每次匹配失败后,不会重头再来,而是根据之前匹配的“经验”跳到下一个可能可以成功匹配的地方,从而减少冗余信息的计算。
我们以下面的例子介绍:
现在文本串的第 5 5 5 位失配(匹配失败)了。KMP 会将模式串对齐至文本串的第 3 3 3 位。继续匹配,变成这样:
为什么他会跳到第 3 3 3 位?
与很多人理解的不同,它跳到第 3 3 3 位的原因是因为模式串本身的字符结构,而不是文本串。因为现在是第 5 5 5 位失配,证明前 4 4 4 位都已匹配成功。
而模式串的一二位与三四位是相等的,文本串的三四位又与模式串三四位是匹配的,所以直接挪到文本串第三位,可以保证一二位相等。
换言之,如果我们能知道 t 1 ∼ i t_{1\sim i} t1∼i 的最后若干位与前面若干位相同,那么在 i i i 失配之后就可以直接跳过这么多位,从而大大增加效率。
用公式表示,我们对每一个 i i i,求一个 k m p i kmp_i kmpi,使得 t 1 ∼ k m p i t_{1\sim kmp_i} t1∼kmpi 和 t i − k m p i + 1 ∼ i t_{i-kmp_i+1\sim i} ti−kmpi+1∼i 这两段是相同的,即前后缀相同的长度。
但是这个方法不够完美。比如在 mmmmmmmmmmmmmmmmh
中查找 mmmmmms
,你会发现每一次都是到最后一位才失配,又要跳回第一位重新匹配,比较恶心人。
所以我们要求的是最长长度而不是任意长度。
那么,问题是,如何求 k m p kmp kmp 数组?
考虑使用一种类似递推的方式。对于一个位置 i i i,如果 t i = t k m p i − 1 + 1 t_{i}=t_{kmp_{i-1}+1} ti=tkmpi−1+1,那么我们发现一定有 k m p i = k m p i − 1 + 1 kmp_i=kmp_{i-1}+1 kmpi=kmpi−1+1,如图:
其实就是在 k m p i − 1 kmp_{i-1} kmpi−1 的基础上加上了上图中两个蓝色的部分。
那如果不相同呢?我们还要往前跳,那又要跳到哪里呢?
不能随便跳,因为 t i = t k m p i − 1 + 1 t_{i}=t_{kmp_{i-1}+1} ti=tkmpi−1+1 的前提是 t i − 1 = t k m p i − 1 t_{i-1}=t_{kmp_{i-1}} ti−1=tkmpi−1。所以我们考虑再次将这个递推式进化,变成: t i − 1 = t k m p i − 1 = t k m p k m p i − 1 t_{i-1}=t_{kmp_{i-1}}=t_{kmp_{kmp_{i-1}}} ti−1=tkmpi−1=tkmpkmpi−1。那我们就可以重复上述的过程,不停的判断是否相等,如果不相等就继续嵌套。
如图,匹配失败后,红色框往它的 k m p kmp kmp 值走了。走完发现这回相等了,于是 k m p i = k m p k m p i − 1 + 1 kmp_i=kmp_{kmp_{i-1}}+1 kmpi=kmpkmpi−1+1,即蓝色部分。
这样的时间复杂度是对的,至于怎么分析,要用到均摊,作者比较菜,故省略。
那么边界是多少呢?显然 k m p 0 = 0 kmp_0=0 kmp0=0。问题是 k m p 1 kmp_1 kmp1 是多少?按定义来说,前后缀长度相同的长度应该是 1 1 1 呀!
但是这样就有问题了。比如有一个 i i i,它的 k m p kmp kmp 跳着跳着来到了 1 1 1。此时应该比较的是 t i t_i ti 和 t k m p 1 + 1 = t 2 t_{kmp_1+1}=t_{2} tkmp1+1=t2。如果不相等,又跳回到 k m p k m p 1 = k m p 1 = 1 kmp_{kmp_1}=kmp_{1}=1 kmpkmp1=kmp1=1,你就会发现它出不来了。
有人说,加个特判不行吗?不好意思,不行。你并不知道你是不是第一次进
k
m
p
1
kmp_1
kmp1。如果你一到
1
1
1 就 break
那将无法匹配第一位,从而导致
k
m
p
i
kmp_i
kmpi 全部变成
0
0
0。
所以,为了方便,我们将 k m p 1 = 0 kmp_1=0 kmp1=0,这样一到 0 0 0 我们就停手,可以保证能够正确地求出 k m p kmp kmp 数组。
void init(){
kmp[0] = kmp[1] = 0;
for(int i=2;i<=m;i++){
int j = kmp[i - 1];
while(j > 0 && t[i] != t[j + 1])
j = kmp[j];
if(t[i] == t[j + 1])
kmp[i] = j + 1;
}
}
那么,现在就是用 t t t 匹配 s s s 了。其实这个过程也是相当相似的,只是我们把 t t t 与 t t t 自己匹配换成了 t t t 与 s s s 匹配,所以代码也很容易:
int now = 0;//当前匹配到什么位置
for(int i=1;i<=n;i++){
while(now > 0 && s[i] != t[now + 1])
now = kmp[now];
if(s[i] == t[now + 1])
++now;
if(now == m)//匹配成功了
printf("%d\n", i - m + 1);
}
组合起来再输出 k m p kmp kmp 数组就可以过掉 P3375 啦!