KMP算法是一种改进的字符串匹配算法,由D.E.Knuth,J.H.Morris和V.R.Pratt提出的,因此人们称它为克努特—莫里斯—普拉特操作(简称KMP算法)。KMP算法的核心是利用匹配失败后的信息,尽量减少模式串与主串的匹配次数以达到快速匹配的目的。具体实现就是通过一个next()函数实现,函数本身包含了模式串的局部匹配信息。KMP算法的时间复杂度O(m+n)
一、暴力算法
int BF(char *chang,char *duan)
{
int c_strlen=strlen(chang);
int d_strlen=strlen(duan);
int c=0,d=0;
while(c<c_strlen && d<d_strlen){
if(chang[c]==duan[d]){
c++;
d++;
}
else{
c = c - d + 1; //这里要回到开始处,然后下一个字符继续开始匹配所以+1;
d = 0;
}
}
return d<d_strlen?-1:c-d;
}
其特点,使用两个指针,一个指向主串,一个指向子串。
如果匹配成功,两个指针往后走一位,继续匹配。
如果匹配失败,主串指针回溯到开始匹配的下一个字符,子串指针回溯到开始的字符。
直到字符串匹配成功,返回开始匹配处的主串下标。
这种算法,其时间复杂度是 O(m*n)。
二、KMP算法
使用BF算法的时候,会发现如果失配,两个指针都要回溯。KMP算法的思想,就是只回溯一个子串指针,主串指针不变。这样KMP算法的时间复杂度就是O(n+m)。
子串指针回溯:
既然知道KMP算法只回溯子串指针,那么如何回溯子串指针呢?这里通过例子讲解。
PS:KMP算法一种子串类型是不足以解释所有的情况,所有下面会使用多种类型的子串进行解析
上图,假设已经匹配到了"流"处,失配进行指针回溯,注意发现。子串部分字符串前面和后面是相同的。
既然前面部分相同,又和主串相匹配。那么这里使用KMP,就能让子串指针直接跳过,已经匹配过的相同的字符,直接从子串的第三个位置"楼"处匹配。
那么问题就来了,子串如何知道,要回溯到"楼"处匹配呢?
这里就需要计算Next[]数组。Next[]数组中存储的就是,子串对应下标位置失配后,指针回溯的位置
计算Next[]数组和原理:
计算Next[]数组,主要是计算子串一个下标位置,前面最长的相同字符串长度。其也叫真前缀和真后缀。
PS:注意这里判断真前后缀时是否相等时,都要以Str[0]处的字符开始,以Str[i-1]处的字符为结束。这里i是匹配失败的位置,也是Next[]的下标。
首先前两个字符是没有前缀和后缀的,所以Next[]数组,第一位和第二位都为固定为0。
以上面为例子,也就是说真前缀真后缀,都要以a开始b结束。然后其长度,就是Next[i]的值。那么如果子串在i下标处失配,子串指针就回退到Str[2]处,Str[2]就刚好是c。
知道其计算方法后,就可以直接计算出子串的Next[]数组。
特点:如果数组成增长趋势,那么后一个必定只比前一个大1。也就是说增长的话是+1。但是下降的话就不一定只减一个。用这个特点也可以帮助快速计算Next[]数组,和判断其计算的正确性。
根据上面两个例子,可以发现计算Next[]数组时,其真前缀和真后缀是可以重合的。 (但是不能完全重合!!)
介绍到这里起始可以写出KMP的查找子串的代码了,发现跟BF算法非常相似。只不过字符串失配的时候,子串指针回溯到对应下标的Next[]值,这个是KMP算法的核心。
int KMP(char *chang,char *duan) //duan:长串 duan:短串
{
int c_strlen=strlen(chang);
int d_strlen=strlen(duan);
int c=0,d=0;
while(c<c_strlen && d<d_strlen){
if(chang[c]==duan[d]){
c++;
d++;
}
else{
d = Next[d]; //失配,指针回退到对应Next[]下标元素。
}
}
return d<d_strlen?-1:c-d;
}
//问题代码
PS:代码有个小问题,如果刚开始就比较错误,也就是说 chang[0] != duan[0] 的时候,0下标处的Next[]还是0。会进入死循环。可以写个if()判断,如果刚开始就失配,让主串往后走一位。
int KMP(char *chang,char *duan)
{
int *next=getNext(duan);
int c_strlen=strlen(chang);
int d_strlen=strlen(duan);
int c=0,d=0;
while(c<c_strlen && d<d_strlen){
if(c=0 && chang[c]!=duan[d])
c++;
if(chang[c]==duan[d]){
c++;
d++;
}
else{
d = Next[d]; //失配,指针回退到对应Next[]下标元素,存储的位置。
}
}
return d<d_strlen?-1:c-d;
}
//修正
但是创建KMP算法的大佬用了更妙的写法。
他们把Next[]数组的第0项赋值为 -1。如果子串第一个字符就不匹配,那么d==-1,然后判断如果d是-1,就会进去匹配的分支,这样的话c++,主串指针往后走一个。d++后,子串指针还是指向了0位置。
会发现这样既保证了0下标位置失配后,下一次匹配,从主串的下一个字符继续匹配,又保证了子串回溯到了最开始的位置。
int KMP(char *chang,char *duan)
{
int *next=getNext(duan);
int c_strlen=strlen(chang);
int d_strlen=strlen(duan);
int c=0,d=0;
while(c<c_strlen && d<d_strlen){
if( d==-1 || chang[c]==duan[d] ){ //匹配
c++;
d++;
}
else{
d = Next[d]; //失配,指针回退到对应Next[]下标元素,存储的位置。
}
}
return d<d_strlen?-1:c-d;
}
//大佬写法
所以Next[]数组,第一项固定为-1,第二项固定为0。
计算Next[]数组,代码实现:
使用口算,会发现计算Next[]数组还是比较简单。但是怎么把它实现为代码呢?其实Next[]也是具有一定规律的。
观察这个子串,假设 i 下标 处失配,那么其指针回溯到Str[3]处。设其回溯后的下标为k。
因为Next[]里面存的是指针回溯的下标,那么就有Next[i]==k。
那么既然Next[i]==K,那就说明 i下标 处有个最大真前缀和最大真后缀相等。
这里就是"望 江 望"。因为这里的Next[i]就是根据"望 江 望"的长度得出来的。前面说过找真前缀真后缀时,都要以Str[0]字符开始,Str[i-1]字符结束。
那么就说明 Str[0]...Str[k-1]==Str[x]...Str[i-1]。(这里真前缀起始位置固定为0,真后缀位置设为未知数X)
所以得出,如果 Next[i]==k,那么 Str[0]...Str[k-1]==Str[x]...Str[i-1];
因为真前缀和真后缀的长度相等,所以 k-1-0==i-1-x (这里使用下标,就能算出来真前缀和真后缀的长度)
整理一下可以得出 k = i - x 移项得 x = i - k
根据 x = i - k 又可以得出 Str[0]...Str[k-1]==Str[i-k]...Str[i-1];(套入上面的公式)
再来看子串,因为这里 Str[i]==Str[k] ,所以i+1处的真前缀和真后缀就是 "望 江 望 江",也就是 Str[0]...Str[k]==Str[i-k]...Str[i];(这里这里仔细思考会发现,i+1处的Next[]值其实也是i的Next[]值加1。)
观察两个公式:
Next[i]==k; Str[0]...Str[k-1] == Str[i-k]...Str[i-1];
Str[i]==Str[k]; Str[0]...Str[k] == Str[i-k]...Str[i];(这个公式,比上面多1)
那么就可以得出,如果Str[i]==Str[k],那么:Next[i+1]=k+1;
那么如果 Str[i] != Str[k] 的情况呢?
不相等的话就,就让k回溯,回溯到下标位置2
发现还是,Str[k]!=Str[i];k继续回溯。直到回溯到 Str[i]==Str[k];让 Next[i+1]==k+1成立。
当然这里回溯到最开始后,还是没有找到Str[i]==Str[k]; 那么Next[i+1]==0;i下一个Next[]数组元素就是0了
假设这里k回溯到最开始后,Str[i]==Str[k],Next[i+1]==k+1成立,这里Next[i+1]==1。
到此步,所有的情况都考虑到了。可以写出计算Next[]数组的代码:
int *getNext(char *str)
{
int len=strlen(str);
int *next[]=(int *)calloc(len,sizeof(int));
next[0]=-1;
next[1]=0;
int i=2; //主串指针
int k=0; //回溯位置
while( i < len ){
if( k==-1 || str[i-1]==str[k] ){ //这里有多种写法
next[i]=k+1;
i++;
k++;
}
else{
k=next[k];
}
}
return next;
}
PS:因为这两Next[]数组里面,前两个数是固定的,所以直接从第三个开始计算。(其实会发现,第三项元素如果前面有真前缀和真后缀,其也是固定为1的,且第一个字符和第二个字符是相等的)
Next[]数组的另一种理解方法:
求Next[]数组,相当于子串自己匹配自己。
自己跟自己匹配,i 处匹配,所以说明前面也有一个是"望"。这样匹配,是不是就相当于 i+1找到一个真前缀和真后缀啊。那么i+1处的Next[]数组元素就是1。Next[i+1]==1。
继续往下匹配。
这里再次匹配的情况下,也是就是 str[i]==str[k] ,相当于真前缀和真后缀又加了一个相同的字符,真前缀和真后缀+1。因为Next[i] 除了是指针回溯的位置,也是代表的 i 处真前缀和真后缀长度度,所以 Next[i+1]==Next[i]+1 ;
但是又因为Next[]数组里面存的值是代表子串指针要回溯的下标位置,也就是Next[i]==k,那么也可以得出。Next[i+1]==k+1(套入上面公式)
那么一直计算比较,可以得出一部分的Next[]数组。然后如下图
到这里发生了失配。如果这里 i 匹配的话,Next[i+1]==5。但是这里失配,所以说Next[i+1]首先不可能等于5了。
因为相当于在做字符串匹配,上面是主串,下面是子串。那么发生失配,指针回溯。回溯到哪里呢,会发现!!这里回溯的就是子串对应下标Next[]存的值,Next[i]==4。回溯到下标位置为 4 的 "楼"处,也就是 k == Next[k];
发现还是失配str[i]!=str[k],指针继续回溯 ,k==Next[k];
失配进入if() 的失配分支,但是因为指针回溯了初始位置,因为Next[0]==-1,所以k==-1。会发现这里非常妙,k==-1,进入的是if()的匹配分支,然后i++,k++。主串、子串指针都往后走一位,Next[i]=k;k==0,Next[i]==0;
或者Next[i+1]=k+1;前面 i 和 k,加过了所以不用加。
代码:
int * getNext(char* str)
{
int s_len = strlen(str);
int *next = (int *) calloc (s_len,sizeof(int));
next[0] = -1;
int i=0,k=-1; //k是子串指针,i是主串指针。主串指针赋值-1,是为了从下一个位置开始匹配
while(i<s_len-1){ //因为都从0开始的话,自己匹配自己就全匹配完了,就计算不了数组元素了
if(k==-1||str[i]==str[k]){
i++;
k++;
Next[i]=k; //前面加过了不用加了
}
else{
k=Next[k];
}
}
return next;
}
PS:写法跟前面差不多一样。因为是相当于字符串匹配,又因为Next[]第一个元素是固定的不用计算。所以主串指针要比子串指针快一步。
相当于刚开始如上图这样比较,然后比一个字符,计算一个Next[]的值。当然第一个值为-1,第二个值为0,是固定的。
int * getNext(char* str)
{
int s_len = strlen(str);
int *next = (int *) calloc (s_len,sizeof(char));
next[0] = -1;
int i=0,k=-1; //k是主串指针,i是主串指针
while(i<s_len-1){
if(k==-1||str[i]==str[k]){
Next[i+1]=k+1; //为了方便理解也可以这样写。
i++;
k++;
}
else{
k=Next[k];
}
}
return next;
}
PS:
三、NextValue
其实Next[]还可以优化。先来观察一个子串。
假设在 i 处失配,指针回溯。会发现一个问题,回溯会回到下标6处,然后6下标的字符因为也是a,跟原来一样所以肯定也是不匹配的,就会继续回溯,5 4 3 2 1 0,会发现指针一直会回溯到0处。
然后会发现,前面不管前面哪个a发生失配,指针其实都会回溯到0处。那么其实可以跳过中间一步步回溯的步骤,使指针一步到位,一次回溯到最开始处。就是当Str[i]==Str[k](现在指针指向的字符,与回溯到的字符相等时),Next[i]==Next[k],这样就重新获得了一个NextValue[]数组。
如果Str[i]!=Str[k]的话,其还是Next[]的值。
再来看一个子串
如果 i 处失配,且 Str[3]==Str[0];所以Nextval[3]==Nextval[0];
如果 i 在5下标位置失配,因为Str[5]!=Str[2];所以Nextval[5]==2;
PS:实际计算NextValue[]数组的时候要注意要顺序计算,因为前面的数据覆盖了后面的数据时,再后面也要使用被覆盖的数据。什么意思呢?
这里顺序计算一个NextValue[],
按顺序以次往下计算。
这里会使用前面被覆盖为0的值,自己也被覆盖为0,而不是原来的1。所以要按照顺序计算。
使用Nextvalue[]和Next[]数组结果是一样的,Nextvalue[]是优化版本。其本质就是
代码:
#include <iostream>
#include<stdio.h>
int* getNext(char *str)
{
int len = strlen(str);
int* next = (int*)calloc(len, sizeof(int));
next[0] = -1;
int i = 0, k = -1;
while ( i < len-1) { //这里注意-1
if (k==-1||str[i]==str[k]) {
i++;
k++;
next[i] = (str[i] == str[k] ? next[k] : k);
}
else {
k = next[k];
}
}
return next;
}
int KMP(char* chang,char* duan)
{
int* next = getNext(duan);
int c_len = strlen(chang);
int d_len = strlen(duan);
int i = 0, k = 0;
while (i<c_len && k<d_len) {
if (k == -1 || chang[i] == duan[k]) {
i++;
k++;
}
else {
k = next[k];
}
}
return k < d_len ? -1 : i - k;
}
int main()
{
char str1[] = "wjl,wjn,wjlswjn,jlqg,jnqg";
char str2[] = "wjlswjn";
printf("在位置数组下标位置 %d 处找到字符串\n", KMP(str1,str2));
return 0;
}
PS:再次注意,如果next[i]=k,这写在i++,k++后面。如果写在前面用next[i+1]=k+1。
然后i<s_len-1,注意这里是-1。拿这里的代码来说,str2的长度为7,如果用的是i<s_len,计算6位置的时候,i++了一次,就成了next[7],数组过界了。如果这里使用i<s_len,i=5,i++了一次变成了6,next[6]就是计算最后一个元素了,刚好全算完。
测试:
PS:仔细观察getNext()函数和KMP()函数,会发现非常相似。侧面也说明了获取Next[]数组的过程,相当于子串自己跟自己做串匹配。
总结:
如果明白了KMP算法的话,其实就知道,其无非失配时,跳过前面已经匹配成功过的真前缀。那么就能知道,如果一个子串的重复字符越多,其算法使用效率越高,不然没有重复字符或者重复字符少,会退化成BF算法。