简介
KMP算法是一种字符串模式匹配算法, 不同的来源讲解方式也不一样,很容易混乱,在这里以一种特殊的方式来讲解KMP算法,希望大家不再被这个问题所困扰。
KMP 算法是 D.E.Knuth、J,H,Morris 和 V.R.Pratt 三位神人共同提出的,称之为 Knuth-Morria-Pratt 算法,简称 KMP 算法。该算法相对于 Brute-Force(暴力)算法有比较大的改进,主要是消除了主串指针的回溯, 从而使算法效率有了某种程度的提高。
什么是字符串的模式匹配?
给定两个串S=“s1s2s3 …sn”和T=“t1t2t3 …tn”,在主串S中寻找子串T的过程叫做模式匹配,T称为模式。
我们先从比较好理解的 暴力匹配(朴素模式匹配BF算法)开始,进而引出 KMP算法。
暴力匹配(朴素模式匹配BF)
规定i是主串S的下标,j是模式T的下标。现在假设现在主串S匹配到 i 位置,模式串T匹配到 j 位置。
- 如果当前字符匹配成功(即
S[i] = T[j]
),则i++; j++;
,继续匹配下一个字符; - 如果失配(即
S[i] != T[j]
),令i = i-(j-1),j = 0;
。相当于每次匹配失败时,i 回溯到本次失配起始字符的下一个字符,j 回溯到0。
int BF(char S[],char T[])
{
int i=0,j=0;
while(S[i] != '\0' && T[j] != '\0')
{
if(S[i] == T[j])
{
i++;
j++;
}
else
{
i=i-j+1;
j=0;
}
}
if (T[j]=='\0') return (i-j); //主串中存在该模式返回下标号
else return -1; //主串中不存在该模式
}
举个例子:
现在有主串 S:ababcabcacbab,模式串T:abcac。我们来看一下是如何匹配的。i从0开始,j也从0开始。
- 第一次匹配中,i从0开始,j从0开始。当i=2,j=2时匹配失败,此时i回溯到1,j回溯到0。
- 第二次匹配中,i从1开始,j从0开始。当i=1,j=0时匹配失败,此时i回溯到2,j回溯到0。
- 第三次匹配中,i从2开始,j从0开始。当i=6,j=4时匹配失败,此时i回溯到3,j回溯到0。
- 第四次匹配中,i从3开始,j从0开始。当i=3,j=0时匹配失败,此时i回溯到4,j回溯到0。
- 第五次匹配中,i从4开始,j从0开始。当i=4,j=0时匹配失败,此时i回溯到5,j回溯到0。
- 第六次匹配中,i从5开始,j从0开始。i=10,j=5,T中全部字符比较完,匹配成功,返回本次匹配起始位置下标i - j。(i=9和j=4的时候匹配成功,i和j会再加一次,所以i=10,j=5)
可见,如果 i 已经匹配了一段字符后出现了失配的情况,i 会重新往回回溯,j 又从0开始比较。这样浪费的大量的时间。
-
在第三次匹配结束后,我们可以发现:i=3和j=0,i=4和j=0以及i=5和j=0是不必进行的!
-
因为从第三次部分匹配过程中我们可以得出,主串中第3,4,5个字符必然是‘b’,‘c’,‘a’(即与模式串的第1,2,3个字符分别对应相等),而模式的首字符是‘a’,它分别与‘b’,‘c’不等,与‘a’相等。
如果将模式向右滑动3个字符继续进行i=6和j=1时的字符比较,很明显会加快进程。这样就引出了我们的KMP算法,不回溯i,加快匹配效率。
KMP 算法
上面说道 KMP 算法主要是通过消除主串指针的回溯来提高匹配的效率的,那么,它是怎么样来消除回溯的呢? 提取并运用了加速匹配的信息!
算法流程:
规定i是主串S的下标,j是模式T的下标。现在假设现在主串S匹配到 i 位置,模式串T匹配到 j 位置。
- 如果j = -1,则i++,j++,继续匹配下一个字符;
- 如果S[i] = T[j],则i++,j++,继续匹配下一个字符;
- 如果j != -1,且S[i] != P[j],则 i 不变,j = next[j]。此举意味着失配时,接下来模式串T要相对于主串S向右移动j - next [j] 位。
加速信息,即数组 next 的提取是整个 KMP 算法中最核心的部分,弄懂了 next 的求解方法,也就弄懂了 KMP 算法的十之七八了,但是不巧的是这部分代码恰恰是最不容易弄懂的……
KMP算法代码:
int KMP(int start,char S[],char T[])
{
int i = start, j = 0;
while(S[i]!='\0' && T[j]!='\0')
{
if(j==-1 || S[i]==T[j])
{
i++; //继续对下一个字符比较
j++; //模式串向右滑动
}
else j = next[j]; // 此语句是这段代码最反人类的地方
}
if (T[j] == '\0') return (i-j); // 匹配成功返回下标
else return -1; // 匹配失败返回-1
}
到这里,我们肯定是有一些疑问的:
- next是什么???它是怎么来的???
首先我们来解释一个名词:最长公共前后缀。假设有一个串P=“p0p1p2 …pj-1pj”。如果存在p0p1…pk-1pk = pj-kpj-k+1…pj-1pj,我们就说在P串中有一个最大长度为k+1的公共前后缀。
- 如何寻找前后缀???
- 找前缀时,要找除了最后一个字符的所有子串。
- 找后缀时,要找除了第一个字符的所有子串。
问题1、2实例:
现在有串P=abaabca,各个子串的最大公共前后缀长度如下表所示:
这样,公共前后缀最长长度就会和串P的每个字符产生一种对应关系:
这个表的含义是以当前子串的第一个字符、最后一个字符列出来的后缀、前缀,包括当前子串所拥有的公共前后缀最长长度。例如当c作为最后一个字符时,当前子串abaabc并没有公共前后缀。
接下来我们就用这个表来引出next数组,next 数组的值是除当前字符外(注意不包括当前字符)的公共前后缀最长长度,相当于把上表做一个变形,将表中公共前后缀最长长度全部右移一位,第一个值赋为-1。
例如c对应next值的意义是,c之前(不包括c)的子串abaab所拥有的公共前后缀最长长度为2,我们称next数组中的值为失效函数值,也就是c的失效函数值为2。(当然这是我们手动推得,我们后续会用编程思想来推得next数组)
我们手动推得了next数组,那就来体验一下KMP算法的流程:现在有主串S:ababcabcacbab,模式串T:abcac。i从0开始,j也从0开始。
根据上述方法可以知道,T的中每个字符对应的失效函数值为:
- 第一次匹配中,i从0开始,j从0开始。当i=2,j=2时匹配失败,此时i不动,next[j] = next[2] = 0,接下来模式串T要相对于主串S向右移动j - next [j] = 2 位,j回溯到0。
第二次匹配中,i从2开始,j从0开始。当i=6,j=4时匹配失败,此时i不动,next[j]=next[4]=1,接下来模式串T要相对于主串S向右移动j - next [j] = 3位,j回溯到1。
第三次匹配中,i从6开始,j从1开始。当i=10,j=5时匹配成功,返回i - j = 5。
我们根据next数组进行匹配,不失一般性的话,我们做如下总结:
当 pj 和 si 失配时,因为next[j]=k,相当于在不包含pj的模式串中有最大长度为k的相同前后缀,也就是 p0 p1 …pk-1 = pj-k pj-k+1 … pj-1,所以令j = next[j],让模式串右移j - next[j]位,使模式串 p0 p1 … pk-1 与主串 si-k si-k+1 … si-1 对齐,让 pk 和 si 继续匹配。
代码推导 next 数组:
void Getnext(int next[],string t)
{
int j = 0, k = -1;
next[0] = -1;
while(j < t.length()-1)
{
if(k == -1 || t[j] == t[k]){
j++, k++;
next[j] = k;
}
else k = next[k];
}
}
KMP的next数组告诉我们:当模式串中的某个字符跟主串中的某个字符失配时,模式串下一步应该跳到哪个位置。如模式串中在j 处的字符跟主串在i 处的字符匹配失配时,下一步用 next [j] 处的字符继续跟主串 i 处的字符匹配,相当于模式串向右移动 j - next[j] 位。
测试代码
int main(){
string s1 = "ababcabcacbab";
string s2 = "cabca";
int len = s2.size();
int next[len];
Getnext(next, s2);
for(int i = 0; i < len; i++) cout << next[i] << ' ';
cout << endl;
cout << KMP(next, s1, s2) << endl;
return 0;
}
代码生成图:
如有不同见解,欢迎留言讨论~~~