小学生都能看懂的KMP算法原理及实现(简单版)
说明
在此版本中,我们仅对KMP算法的Next数组和简单的匹配做讲解
对于NextVal数组不做讨论
什么是KMP算法
KMP算法是一种改进的字符串匹配算法,由D.E.Knuth,J.H.Morris和V.R.Pratt提出的,因此人们称它为克努特—莫里斯—普拉特操作(简称KMP算法)。KMP算法的核心是利用匹配失败后的信息,尽量减少模式串与主串的匹配次数以达到快速匹配的目的。具体实现就是通过一个next()函数实现,函数本身包含了模式串的局部匹配信息。KMP算法的时间复杂度O(m+n)
摘录自百度百科:kmp算法
从暴力思考
要理解KMP算法,我们首先有必要再回顾一下字符串匹配的朴素做法:
如下图,我们算法内部是这样实现的:
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/4bb74784171819f52d323b74848fb849.gif)
那么,我们很容易知道,这种朴素字符串匹配的时间复杂度在最坏情况下可以达到 O ( n m ) O(nm) O(nm)
这显然是不够的高效的
那么我们思考,这种不高效产生的原因是什么
从上图可以看出,我们的模式串每次在发生于主串不匹配是,我们的模式串都只向后移动了一位,并且匹配的指针回到了模式串的开头位置,正是这个指针回到开头位置重新匹配这一操作,使得我们的时间复杂度大大上升
那么是否存在一种操作,可以让我们每次模式串在与主串不匹配后,指针不回到开头,而是模式串按照一定的方式移动,并且这种移动时间复杂度较小,那不就优化了算法吗?
于是KMP算法应运而生
KMP算法
算法原理
那么KMP是如何消除匹配指针回溯的呢?
我们先以一张动图来形象的观察以下整个匹配过程:
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/8e9595883cccc2a8579b4dcb05fd3600.gif)
原理很简单,我们先令匹配串和模式串分别为 S主 和 S模
我们把匹配指针所在位置拟定为 i ,那么如果我们下一次在不移动这个指针,就意味着我们再这个指针前面的某一部分就必须要和我们的模式串完全匹配,那么我们自然而然就想到了前缀和后缀的关系,也就是说,我们的要让我们的匹配指针再次走到这里的一个充要条件是我们主串在匹配指针前的一个后缀要和我们模式串的一个前缀相等(在之后我们称之为公共前后缀)(关于充要性的证明十分简单,这里不做过多赘述),又由贪心原则,我们当前的选取应该为后面保留的可能性尽可能多,也就说我们在计算公共前后缀时,我们舍弃的部分要尽可能少,保留的部分尽可能多,也就是我们的前后缀要尽可能长,下方我们将这种最长的前后缀称为最长公共前后缀
关于选取最长公共前后缀能保证其更小公共前后缀一定能够被选取到的证明(不重要,感兴趣可以看一下):
我们令 S1 的最长公共前后缀是 S2
S2 的最长公共前后缀是 S3
那么我们一定可以证明以下两句话:
在 S2 作为 S1 的前缀出现时,S3 同样也能作为 S1 的前缀出现
在 S2 作为 S1 的后缀出现时,S3 同样也能作为 S1 的后缀出现
也就是说,S2 是 S1 的公共前后缀,S3 是 S2 的公共前后缀时,S3 也必定是 S1 的公共前后缀
也就证明了这种前后缀关系存在一种传递性
那么我们在模式串中 [ 0 , i − 1 ] [ 0 , i - 1 ] [0,i−1] 的区间内找到一个前缀 S‘ ,使得其刚好是我们主串 [ l , l + i − 1 ] [ l , l + i - 1 ] [l,l+i−1] 的一个后缀 S",且 S ′ ≠ S 模 S'\neq S_模 S′=S模 ,那么我们可以很轻易的证明以下事实:
- 对于当前模式串和匹配串,我们恒有 S 模 [ 0 , i − l ] = S 主 [ l , l + i − 1 ] S_{模_{[0,i-l]}}=S_{主_{[l,l+i-1]}} S模[0,i−l]=S主[l,l+i−1] , S ′ = S ′ ′ S'=S'' S′=S′′
- 对于当前的主串 [ l , l + i − 1 ] [l,l+i-1] [l,l+i−1] 的后缀S”,在我们的模式串中同样能够找到相同的后缀 S’’’
于是,我们现在对于主串和模式串的研究都可以放到我们的模式串上面来
那么我们在不匹配只就能够快速跳过前面已知的能够匹配的部分,也就消除了指针的回溯过程
为了实现上面的设想,我们需要引进一个数组来记录当前模式串在某个位置的最长公共前后缀的长度,来方便我们找到在某个位置失去匹配后能够再次匹配所需保留的最长长度
这个数组就被我们称为再次数组(next)
Next数组的构建
我们对于一个长度为 12 的串 S = [ A , B , A , B , A , A , A , B , A , B , A , B ] S=[A,B,A,B,A,A,A,B,A,B,A,B] S=[A,B,A,B,A,A,A,B,A,B,A,B]
首先,我们令 N e x t [ 0 ] = 0 Next[0]=0 Next[0]=0,表示我们指针在第一位时的最大公共前后缀长度(显然,只有一个字符时,我们的非自身最长公共前后缀时不存在的)
对于 N e x t [ i ] Next[i] Next[i] ,我们令 p r e = N e x t [ i − 1 ] pre=Next[i-1] pre=Next[i−1] ,然后我们验证 S [ N e x t [ p r e ] + 1 ] S[Next[pre]+1] S[Next[pre]+1] 与 S [ i ] S[i] S[i] 是否相等,如果相等, N e x t [ i ] = N e x t [ p r e ] + 1 Next[i]=Next[pre]+1 Next[i]=Next[pre]+1 ,否者 p r e = N e x t [ P r e ] pre=Next[Pre] pre=Next[Pre] 直到 p r e = 0 pre=0 pre=0
于是我们就能够得到上述串的Next数组 [ 0 , 0 , 1 , 2 , 3 , 1 , 1 , 2 , 3 , 4 , 5 , 4 ] [0,0,1,2,3,1,1,2,3,4,5,4] [0,0,1,2,3,1,1,2,3,4,5,4]
具体匹配方式如下:
![请添加图片描述](https://i-blog.csdnimg.cn/blog_migrate/53c0350106c5dead475c15a97a7981b1.png)
![请添加图片描述](https://i-blog.csdnimg.cn/blog_migrate/31ef1270a5380f8c5f53420dcbc01ed4.png)
![请添加图片描述](https://i-blog.csdnimg.cn/blog_migrate/d29c5d30325a5d31a9620add2f899a26.png)
![请添加图片描述](https://i-blog.csdnimg.cn/blog_migrate/ec482e7c9555d94456b8dbd6df53a1a8.png)
Next数组的代码实现
有了以上篇幅的叙述,代码实现已经不复杂了
void getnext() //对于一个字符串S求其对应的Next数组
{
int len = strlen(s + 1); //有些选手用S[0]作存长度
NEXT[1] = 0;
for (int i = 2, pre = 0; i <= len; i++) //从第二位开始求
{
pre = NEXT[i - 1];
while (pre > 0 && s[i] != s[pre + 1])
pre = NEXT[pre];
if (s[i] == s[pre + 1]) //当且仅当在当前公共前后缀的情况下,后一位字符相等,pre才+1
pre++; //否者只能说明pre已经到了0,且依然不等
NEXT[i] = pre;
}
}
当然,我们这个代码的效率还可以进一步优化
void getnext() //对于一个字符串S求其对应的Next数组
{
int len = strlen(s + 1); //有些选手用S[0]作存长度
NEXT[1] = 0;
for (int i = 2, pre = 0; i <= len; i++) //从第二位开始求
{
//pre = NEXT[i - 1]; //我们的pre直接从上一次继承,因为上一次求出的是满足上一次的最长公共前后缀,所以没必要再全部遍历一次
while (pre > 0 && s[i] != s[pre + 1])
pre = NEXT[pre];
if (s[i] == s[pre + 1]) //当且仅当在当前公共前后缀的情况下,后一位字符相等,pre才+1
pre++; //否者只能说明pre已经到了0,且依然不等
NEXT[i] = pre;
}
}
用Next数组匹配
匹配算法和我们求Next数组的思路几乎完全一样(因为上面算法原理已经证明了主串和模式串的关系,忘了可以回去看一下),这里就不多赘述
void check()
{
int len1 = strlen(S + 1); //主串
int len2 = strlen(s + 1); //模式串
for (int i = 1, j = 0; i <= len1; i++)
{
//由于定义相似,所以代码结构也差不多
while (j > 0 && (j == len2 || S[i] != s[j + 1]))
j = NEXT[j];
if (S[i] == s[j + 1])
j++;
//这里输出的就是我们的模式串在整个主串中出现的位置
if (j == len2)
printf("%d\n", i - len2 + 1);
}
}
例题(Luogu P3375)
就是上面的两个代码片拼起来
#include <bits/stdc++.h>
using namespace std;
char s[1000010];
char S[1000010];
int NEXT[1000010];
void getnext() //对于一个字符串S求其对应的Next数组
{
int len = strlen(s + 1); //有些选手用S[0]作存长度
NEXT[1] = 0;
for (int i = 2, pre = 0; i <= len; i++) //从第二位开始求
{
while (pre > 0 && s[i] != s[pre + 1])
pre = NEXT[pre];
if (s[i] == s[pre + 1]) //当且仅当在当前公共前后缀的情况下,后一位字符相等,pre才+1
pre++; //否者只能说明pre已经到了0,且依然不等
NEXT[i] = pre;
}
}
void solve()
{
int len1 = strlen(S + 1); //主串
int len2 = strlen(s + 1); //模式串
for (int i = 1, j = 0; i <= len1; i++)
{
//由于定义相似,所以代码结构也差不多
while (j > 0 && (j == len2 || S[i] != s[j + 1]))
j = NEXT[j];
if (S[i] == s[j + 1])
j++;
//这里输出的就是我们的模式串在整个主串中出现的位置
if (j == len2)
printf("%d\n", i - len2 + 1);
}
for (int i = 1; i <= len2; i++)
printf("%d ", NEXT[i]);
}
int main()
{
scanf("%s", S + 1);
scanf("%s", s + 1);
getnext();
solve();
return 0;
}