算法 - KMP算法原理顿悟有感
中国开国七十一年三月十二日下午,解衣欲睡,雨声入耳,悟以往,思来日,惧无以为生计,遂至哔哩哔哩寻KMP算术,观后遂顿悟,以作文记之。
KMP?
KMP算法是在某一字符串中寻找给定子串的算法,比起逐一比较的方法,KMP算法快了不少。
KMP核心思想
比较过程如图所示:
此处的疑点:为什么有时候模式串一次往后移那么多?为什么有时候模式串又只往后移一格?到底是怎么个模式串移动法?
这就是KMP的关键。
答案:当发现模式串的第i个字符匹配错误时,将模式串向后移动N个位置。此处,N = 模式串第i个字符前的子串中的长度 - 模式串第i个字符前的子串最长且小于子串本身长度的公共前后缀的长度。
一句话总结:让i之前的公共前缀移动到公共后缀的位置上。
由此可知,KMP算法的移动主要和模式串有关,和待查找的字符串关系不大。
举个栗子
假设模式串为 ABABAAABABAA
假设模式串与带比较字符串的第1位比较不匹配:则模式串前移1位(1号位与主串下一位比较)
假设模式串与带比较字符串的第2位比较不匹配:二号位前的子串为A,公共前后缀长度是0,则模式串向前移1位(1号位与主串当前位比较)
假设模式串与带比较字符串的第3位比较不匹配:三号位前的子串为AB,公共前后缀长度是0,则模式串向前移1位(1号位与主串当前位比较)
假设模式串与带比较字符串的第4位比较不匹配:三号位前的子串为ABA,公共前后缀长度是1,则模式串向前移2位(2号位与主串当前位比较)
到这为之,如图所示:
我们得到一个规律,如果当前子串最大公共前后缀长度为N,那我们就移动模式串使N+1号位与主串当前位比较。
那么以此类推:
我们把第一句话标记为0,当我们看到0时,将1号位与主串下一位比较。
并且我们将后面的每一句话的第一个数字取出,结合上面的数组下标,将这些数字放在一个数组中,这样一来,根据数组所提供的信息,我们在模式串上任何一个位置匹配失败,就知道下一步该怎么做了:
这个数组就是传说中的next数组。
上点代码
循序渐进,若想知道KMP算法的代码实现怎么写,我们先要知道天真的傻瓜式比较法的代码怎么写。
//too simple, sometimes naive
//假设字符串位置从1开始
int naive(String str, String substr)
{
int i = 1, j = 1, k = i; //i:主串游标,j:模式串游标,k:位置记录器
while (i<=str.length && j<=substr.length){ //i、j都落在各自字符串的范围内
if(str.ch[i] == substr.ch[j]){
i++;
j++;
}
else {
j = 1;
i = ++k; //寻找初始比较位置的下一个位置
}
}
if (j>substr.length) return k;
else return -1;
}
现在用KMP的思想做一点修改:
int KMP(String str, String substr, int next[])
{
int i = 1, j = 1; //KMP不需要回溯,不需要k
while (i<=str.length && j<=substr.length){
if(j == 0 || str.ch[i] == substr.ch[j]){ //此处注意j=0的处理
i++;
j++;
}
else {
j = next[j]; //i不需要回溯,j有next数组指导往哪走
}
}
if (j>substr.length) return i-substr.length; //计算首字符存在的位置
else return -1;
}
而关键在于:next数组怎么获得?
next数组
KMP所做的事的聪明之处在于:把之前工作的结果合理利用起来,减少重复劳动。
求解next数组需要继承这一思想。
假设一段模式串如下图所示:
P为模式串中的字符,P的下标代表每一个字符的位置,模式串的长度为m
现在我们把模式串复制一份,并凸显出1到t位置上的字符,也就是左端长度为t的子串
我们将上面Pj-t+1到Pj的子串与下面P1到Pt的子串对应起来,假设红色部分完全匹配,黄色部分暂时不知道,则next[j] = t。
现在我们需要求next[j+1]的值。
(1)若Pj == Pt
那么很容易求得next[j] = t + 1 = next[j] + 1
(2) 若Pj 和 Pt不相等
当Pj 不等于 Pt时,这时的情况似曾相识:主串某个位置与模式串某个位置发生不匹配的现象。
假如我们把上面的字符串称为假主串,下面的称为假模式串:
这不就是熟悉的KMP吗!
在我们已知next[j]求next[j+1]的情况下时,我们可以使用next[j]之前所有的next数组
所以当若Pj 和 Pt不相等时,则循环将t赋值为next[t],直到t=0或者满足(1)为止,当t = 0时,next[j+1] = 1。
总结:
我们发现,这种求法天生适合翻译成代码:
void getNext(string substr, int next[])
{
int t = 0, j = 1;
next[1] = 0;
while (j<substr.length){
if(t == 0 || substr.ch[j] == substr.ch[t]){
next[j+1] = t + 1;
t++; j++;
}
else
t = next[t];
}
}
在求完next数组之后,为了理解接下来的工作,以及不忘初心,我们必须回顾及总结next数组的含义到底是什么。
next[5] = 3意味着5这个位置之前的子串的公共前后缀长度为2。
一句话:next[i] = j意味着i这个位置之前的模式串有j-1个字符是能够与主串匹配的。
改进上面的KMP算法
这里就让人头疼了,本来上面那个就不怎么好理解,还要改进?
在求解next[j]时,上图其实做了很多重复的工作。
因为1到4上的字符串相等,因此next[5]直接赋值为0即可。
所以针对KMP算法的改进主要集中在求解next数组的改进上,我们把改进之后的数组称为nextval数组。
nextval
nextval的优化思路:求解nextval[j]时,移过来比较的字符必须与比较过不符合要求的Pj不相同。
如上图所示:其中P代表模式串中的字符,我们若要求nextval[j],需不停将j赋值为next[j],再把这些位置上的字符与Pj进行比较,如果它们与Pj相等,那么意味着Pj与主串中的字符未匹配,则这些位置上的字符来到这也没用。若其中有一个字符与Pj不等,那nextval[j]则为对应位置(上图Pa中的)。
求解nextval数组的一般方法
- 当j等于1时,nextval[j]赋值为0,作为特殊标记
- 当j大于1时:
- 若Pj不等于Pnext[j],则nextval[j]等于next[j]
- 若Pj等于Pnext[j],则nextval[j]等于nextval[next[j]]
上代码
void getNextval(string substr, int nextval[])
{
int t = 0, j = 1;
nextval[1] = 0; //这句很明显要加上
while (j<substr.length){
if(t == 0 || substr.ch[j] == substr.ch[t]){
//计算nextval值
if(substr.ch[j+1] != substr.ch[t+1]])
nextval[j+1] = t + 1;
else
nextval[j+1] = nextval[t + 1];
t++; j++;
}
else
t = nextval[t]; //用nextval代替next数组
}
}