一、KMP算法
1.1 概述
KMP(D.E.Knuth,J.H.Morris,V.R.Pratt)算法,由D.E.Knuth,J.H.Morris和V.R.Pratt同时发现,称之为克努特–莫里斯–普拉特算法,简称KMP算法。
一般匹配字符串时,我们从目标字符串str(假设长度为n)的第一个下标选取和ptr长度(长度为m)一样的子字符串进行比较,如果一样,就返回开始处的下标值,不一样,选取str下一个下标,同样选取长度为n的字符串进行比较,直到str的末尾(实际比较时,下标移动到n-m)。这样的时间复杂度是O(n*m)。KMP算法可以实现复杂度为O(m+n);
KMP算法的关键是利用匹配失败后的信息,在已经匹配的模式串子串中,找出最长的相同的前缀和后缀,前后缀的匹配可以理解为对称匹配,然后移动使它们重叠,尽量减少模式串与主串的匹配次数以达到快速匹配的目的。
KMP的核心思想就是比过的字符串就不比了,为了实现这一句话,KMP在模式串的基础上,加入了next表来进行实现。next表的值的每一个数字含义是:当前字符之前的子字符串片段,从前向后数与从后往前数,能够相等的子字符串的最大长度加一。next()函数本身包含了模式串的局部匹配信息。
1.1.1 前缀和后缀
如:abababac
前缀为:a,ab,aba,abab,ababa,ababab; 含头不含尾。
后缀为:c,ac,bac,abac,babac,ababac,bababac; 含尾不含头。
1.1.2 子串
如:abababac
子串为 a,ab,aba,abab,ababa,ababab,abababac;
从左到右。
1.2 算法
KMP的算法流程:
假设现在文本串S匹配到 i 位置,模式串P匹配到 j 位置: 假设现在文本串S匹配到 i 位置,模式串P匹配到 j 位置:
如果j = -1,或者当前字符匹配成功(即S[i] == P[j]),都令i++,j++,继续匹配下一个字符;
如果j != -1,且当前字符匹配失败(即S[i] != P[j]),则令 i 不变,j = next[j]。此举意味着失配时,模式串P相对于文本串S向右移动了j - next [j] 位。
换言之,当匹配失败时,模式串向右移动的位数为:失配字符所在位置 - 失配字符对应的next 值,即移动的实际位数为:j - next[j],且此值大于等于1。
很快,你也会意识到next 数组各值的含义:代表当前字符之前的字符串中,有多大长度的相同前缀后缀。例如如果next [j] = k,代表j 之前的字符串中有最大长度为k 的相同前缀后缀。
此也意味着在某个字符失配时,该字符对应的next 值会告诉你下一步匹配中,模式串应该跳到哪个位置(跳到next [j] 的位置)。如果next [j] 等于0或-1,则跳到模式串的开头字符,若next [j] = k 且 k > 0,代表下次匹配跳到j之前的某个字符,而不是跳到开头,且具体跳过了k 个字符。
1.2.1 next数组
首先是讲一讲next表当中各个数字的含义和作用,例子当中的next表的值为0001230,其中每一个数字的含义是:当前字符之前的子字符串片段,从前向后数与从后往前数,能够相等的子字符串的最大长度加一。
那接下来还是用几个例子来说明:对于字符串abcab的子字符串abca,从前往后数,可以得到a,ab,abc,从后往前数,可以得到a,ca,bca,最大的相等的子字符串就是a,长度为1,因此abcab的next值就应该为1+1=2,其实一眼也可以看出来,从前往后数最大也就有一个a和后面的a相等,就可以得到next值为2;再举一个例子,abc的子字符串ab,从前往后数有a,从后往前数有b,就是一个相等的都没有,那么就是它的next值就是0+1=1。重新说一遍之前的话:当前字符之前的子字符串片段,从前向后数与从后往前数,能够相等的子字符串的最大长度加一。这样的话,next表的每一个数值的含义与计算方法就已经明确了,接下来就是它的作用。
重复地再说一次,next表的作用是“比过了不比了”,那么他每一个数字的作用就是记录了实现这一目标的方法。与传统的暴力解法不一样,当KMP遭遇到对比不一致的情况时,KMP不是简单地向后移动一格继续对比,而是将向后移动(j-next[j])的距离。依然以一个例子来说明,目标字符串是abadbcaaad,模式串为abcabcd,暴力解法中,对比到目标串的第三个字符时发现不一样,于是又从目标串的第二位开始对比。这里就是可以思考的地方了:既然目标字符串的aba已经和模式串的abc进行过对比了,那我们是不是就可以不比了呢?答案是肯定的,而不比了的含义就是,可以直接将模式串abcabcd移动到目标串的第四位’d’的位置(3-next[3]=3,移动3格)开始匹配对比。不再一个一个的进行对比,能够一次性地跨越已经比过的位置,但是并不是所有的目标串都是可以直接跳过所有已经对比的子字符串的,还需要考虑下面的这种情况:一个新的例子,目标串为abcabcabcd,模式串为abcabcd,首次对比中,对比到目标串的第7个字符’a’时发现了不一样,但是这里的移动如果直接移动到目标串第八位的位置’b’开始比较,明显就会错过abc(abcabcd)中括号括起来的部分,所以这里的移动是移动到目标串的第四个字符’a’的位置(7-next[7]=3,移动3格)进行对比。
转换成代码表示,则是:
void cal_next(char *str, int *next, int len)
{
next[0] = -1; //next[0]初始化为-1,-1表示不存在相同的最大前缀和最大后缀
int k = -1; //k初始化为-1
for (int q = 1; q <= len-1; q++)
{
while (k > -1 && str[k + 1] != str[q]) //while()循环一直检查,如果下一个不同,那么k就变成next[k],注意next[k]是小于k的,无论k取任何值。
{
k = next[k]; //往前回溯
}
if (str[k + 1] == str[q]) //如果相同,k++
{
k = k + 1;
}
next[q] = k; //这个是把算的k的值(就是相同的最大前缀和最大后缀长)赋给next[q]
}
}
理解
next数组中储存的是这个字符串前缀和后缀中相同字符串的最长长度。比如abcdefgabc,前缀和后缀相同的是abc,长度是3。
next[i]储存的是string中前i+1位字符串前缀和后缀的最长长度。如abadefg,next[2]存的是aba这个字符串前缀和后缀的最长长度。
但是这里为了和代码相对应,将-1定义为相同长度为0,0定义为相同长度为1,……依次类推;
(1)这里用一个比较明显的字符串abababac来做例子,先创建一个和字符串长度相同的数组next。第一位设为-1。
(2)所以向后移一位开始比较;
循环条件:next[0] = -1;k = -1;q = 1; 判断条件:str[0] != str[1] ;next[1] = -1;
(3)a和b不同,next第二位写-1 ;
循环条件:next[0] = -1;k = -1;q = 1; 判断条件:str[0] != str[1] ;next[1] = -1;
(4)再向后移一位 ;
(5)a和a相同,next数组存前一个k=-1加1等于0;
循环条件:next[1] = -1;k = -1;q = 2; 判断条件:str[0] == str[2] ; k = -1 + 1 = 0 ; next[2] = 0;
(6)再比较下一位,相同就比前一个加一;
循环条件:next[2] = 0;k = 0;q = 3; 判断条件:str[1] == str[3] ; k = 0 + 1 = 1; next[3] = 1;
循环条件:next[3] = 1;k = 1;q = 4; 判断条件:str[2] == str[4] ; k = 1 + 1 = 2; next[4] = 2;
循环条件:next[4] = 2;k = 2;q = 5; 判断条件:str[3] == str[5] ; k = 2 + 1 = 3; next[5] = 3;
循环条件:next[5] = 3;k = 3;q = 6; 判断条件:str[4] == str[6] ; k = 3 + 1 = 4; next[6] = 4;
(7)到第7位时,c和b不再相等,这时就用到了回溯;先看下一次比较时,应该移动到哪里。
代码:
while (k > -1 && str[k + 1] != str[q] )
{
k = next[k]; //往前回溯
}
循环条件:next[6] = 4;k = 4;q = 7;
判断条件:
{1、str[5] != str[7] ; k = next[4] = 2 ;
2、str[3] != str[7] ;k = next[2] = 0 ;
3、str[1] != str[7] ; k = next[0] = -1;
}
k = -1;退出while循环;
(8)原因要再回来看上一次比较,上一次比较相同长度为5,ababa。
(9)看这5个字符串相同的前后缀的长度,即next[4]中储存的值:2再加1,因为这5个字符串就是str的前五个字符串!
(10)所以k先回溯到2,再比较下一个字符是否相同,这里是比较c和b,不同,再回溯,这次前面剩下aba三个字符,k=next[2]=0,即前后缀还有一个字符相同,所以应该后移到下图位置。
(11)再比较c和b,还不同,再回溯,k=next[0]=-1,然后next[7]=-1,这样就求出了next数组了。
总结:本身next[k]存储的就是前k+1个字符串内的最长前后缀,回溯的意义就是说此时我的第k+1个字符不一样,但是我前k个还是一样的,所以只要寻找到和我前k的字符的尾巴最长的处在开头的字符串就ok了,那么如何快速找到呢,因为我们知道开头的k个字符和末尾的k个字符是一样的,所以只要找开头的k个字符中最长的前后缀也就是next[k]内的保存值,这样反复的回溯直到遇到-1或者匹配成功就结束!
1.2.2 KMP函数
int KMP(char *str, int slen, char *ptr, int plen)
{
int *next = new int[plen];
cal_next(ptr, next, plen); //计算next数组
int k = -1;
for (int i = 0; i < slen; i++)
{
while ( k >-1 && ptr[k + 1] != str[i] ) //ptr和str不匹配,且k>-1(表示ptr和str有部分匹配)
k = next[k]; //往前回溯
if (ptr[k + 1] == str[i]) k = k + 1;
if (k == plen-1) //说明k移动到ptr的最末端
{
return i-plen+1; //返回相应的位置
}
}
return -1;
}
(1)ptr字符串abababac来做例子,按照字符串求出数组next的值为-1,-1,0,1,2,3,4,-1。
(2)str字符串abacabababac来做例子,与ptr字符串abababac进行比较;
(3)比较过程:
(4)从str[0]开始和ptr[0]比较:
循环条件:k = -1 ; i = 0 ; 判断条件:ptr[0] == str[0] ; k = 0 ;
(5)接下来str[1]和ptr[1]比较:
循环条件:k = 0 ; i = 1 ; 判断条件:ptr[1] == str[1] ; k = 1 ;
(6)接下来str[2]和ptr[2]比较:
循环条件:k = 1 ; i = 2 ; 判断条件:ptr[2] == str[2] ; k = 2 ;
(7)接下来str[3]和ptr[3]比较:
循环条件:k = 2 ; i = 3 ; 判断条件:ptr[3] != str[3] //进行回溯;{ 1、k = next[2] ; 2、k = next[0] = -1; }
(8)接下来str[4]和ptr[0]比较:
1、循环条件:k = -1; i = 4 ; 判断条件:ptr[0] == str[4] ; k = 0;
2、循环条件:k = 0 ; i = 5 ; 判断条件:ptr[1] == str[5] ; k = 1;
3、循环条件:k = 1 ; i = 6 ; 判断条件:ptr[2] == str[6] ; k = 2;
4、循环条件:k = 2 ; i = 7 ; 判断条件:ptr[3] == str[7] ; k = 3;
6、循环条件:k = -1; i = 8 ; 判断条件:ptr[4] == str[8] ; k = 4;
7、循环条件:k = 0 ; i = 9 ; 判断条件:ptr[5] == str[9] ; k = 5;
8、循环条件:k = 1; i = 10 ; 判断条件:ptr[6] == str[10] ; k = 6;
9、循环条件:k = 2; i = 11; 判断条件:ptr[7] == str[11] ; k = 7;