写在前面
KMP算法是在做LeetCode算法题的时候遇到的。遇到了就学习一下,目前为止,可以用使用这个算法解决的问题是:LeetCode-459. Repeated Substring Pattern。但是目前(截止2018年3月7日)我还没明白这道题中的解法二是如何使用KMP算法的。所以,我也想单独学习一下KMP算法。
2018年3月26日补充: LeetCode-686. Repeated String Match
首先要说明一下我学习KMP算法的参考博客分别是:KMP算法到底在干什么、KMP算法最浅显理解——一看就明白KMP算法——从入门到懵逼到了解、字符串匹配的KMP算法。
这几篇博客都是非常优秀的文章,这篇博客我是用我自己的理解来整理以上各博客内容,加上我自己的一些文字理解,来讲述我理解的KMP算法。如果我道行不够,可以多参考其他优秀的博客。
问题描述
字符串匹配,现在有两个字符串,一个我称为目标字符串,另一个称为模板字符串,我们的任务就是在目标字符串中找到一个子字符串,该子字符串与模板字符串完全相同,如果能找到就返回这个子字符串的第一个字符出现的位置。如果不能就返回-1。
具体实例:
char *str = "bacbababadababacambabacaddababacasdsd";
char *ptr = "ababaca";
str就是目标字符串,ptr就是模板字符串,在str中下标10,26处包含ptr。如下图所示:
解决方法——暴力算法
这个问题对于我们来说并不困难。我们最直接的想法就是用一个双重循环来暴力遍历目标字符串,具体来说:
假设现在文本串S匹配到 i 位置,模式串P匹配到 j 位置,则有:
- 如果当前字符匹配成功(即S[i] == P[j]),则i++,j++,继续匹配下一个字符。
- 如果失配(即S[i]! = P[j]),令i = i - (j - 1),j = 0。相当于每次匹配失败时,i 回溯,j 被置为0。
暴力算法的代码实现就不啰嗦了。显而易见,该算法的时间复杂度是:O(n*m)。
优化暴力算法
暴力算法时间复杂度比较高,那么我们如何简化呢?我们发现暴力算法太不“智能”了。因为每次匹配失败,就要从目标字符串的下一个字符重新开始匹配模板字符串的第一个字符。这个过程会发生很多的“不必要的匹配”。
如图,如果当前目标串与模式串在D处发生失配,传统方法是从模式串的开头位置重新移动,直到开头位置能够找到匹配的字符然后重新开始下一个匹配流程。但我们注意到在D发生失配之前的AB是能够被开头的AB正常匹配,那中间那些测试就是冗余的做法。
能这样做的原因是我们考虑了模板字符串的自身特性。发现模板字符串中有两个“AB”重复出现。所以,在发生匹配失败的时候,我们不用着急,不用那么暴力,直接将模板字符串重新移动到开头第一个字符处。而可以先移动到有重复字符串出现的地方。
说到这里,我们发现了优化暴力算法的一个思路。考虑模板字符串自身特性。充分考虑模板字符串自身子字符串的重复特性,达到减少每次匹配失败时候模板字符串移动数量,与减少目标字符串字符遍历次数。
关于后面一个减少还可以看一篇博客中的动图:
暴力算法:可注意暴力算法是要遍历目标字符串中每个字符的。
KMP算法:可注意匹配时选择性跳过了第二个b字符。
KMP算法概述
在前面的铺垫之后,我觉得我们可以顺利获得KMP算法的主要思路:
假设现在文本串S匹配到 i 位置,模式串P匹配到 j 位置,则有:
- 如果当前字符匹配成功(即S[i] == P[j]),则i++,j++,继续匹配下一个字符。
- 如果失配(即S[i]! = P[j]),令i = i - (j - 1),j = 0。相当于每次匹配失败时,i 回溯,j 不被置为0,而是根据模板字符串自身的重复特性来确定j被置为多少。
那么现在我们应该主要了解了KMP算法到底做了什么。接下来还有很多没有做,最主要的就是,之前一直在说模板字符串自身的重复特性,这个重复特性该如何量化?
部分匹配表Next()
首先要明白一点,我们一直说模板字符串自身重复特性其实具体来说,是在匹配失败的时候。来看个例子。
在这一个例子中,目标字符串与模板字符串匹配到模板字符串是D字符的时候停止。那么这时候,前面的“ABCDAB”是匹配好的。由于这时候我们匹配失败,我们需要一定模板字符串,那么这时候我们需要考虑“ABCDAB”的重复特性。其实就是两个“AB”。
这里就是我们要找的重复特性。所以,我们这时候可以将模板字符串的第一个AB移动到目标字符串的第二个AB处。
所以,我在这里想说的重复特性其实是模板字符串最后一个字符之前的子字符串的重复特性。而由于在匹配之前,我们不会知道是哪个字符发生匹配失败,当然模板字符串的每个字符都有可能匹配失败,所以,模板字符串的每个字符的前面的子字符串都要考虑重复特性。
总结一下,至此我们应该明白,我们需要一个数组类似的结构来储存模板字符串中每个字符之前的子字符串的重复特性。
接下来考虑何为重复特性。重复特性其实说白了就是字符串中相同的部分。主观感觉上有一前一后之分。所以在这里,引进“前缀”与“后缀”两个概念。概念不用文字啰嗦,看图:
(前缀不能包括最后一个字符,后缀不能包括第一个字符)
这个图就是bread的前缀后缀。不难发现,前缀与后缀构成了两个集合。我们找两个集合中重复的最大长度的公共字符串。将这个最长的公共字符串的长度作为“重复”的量化指标。那么就这个例子中,该值为0。
再来一例:“ABCDAB”,前缀与后缀集合有:
前缀 | 后缀 | 前缀后缀最长匹配 |
---|---|---|
A | BCDAB | 0 |
AB | CDAB | 0 |
ABC | DAB | 0 |
ABCD | AB | 0 |
ABCDA | B | 0 |
所以,“ABCDAB”字符串的前缀后缀集合中最长重复元素是“AB”,其长度为2,故该字符串的“重复”就是2。
结合以上两点,我们可以用一个数组next()来储存“重复”,next(i)表示模板字符串第i个字符前的子字符串的“重复”。有:
Pattern | A | B | C | D | A | B | D |
---|---|---|---|---|---|---|---|
next | 0 | 0 | 0 | 0 | 1 | 2 | 0 |
上表就是字符串“ABCDABD”的nxet()数组。之前“ABCDAB”就是next(5)。
使用Next()
上面主要介绍了Next()数组如何得来。但是该如何使用呢?在此之前还可以理解一下这个前缀后缀能给我们带来什么?
还是上面的“ABCDABD”匹配,在“D”的地方匹配失败。这时候D前的子字符串是“ABCDAB”,这时候我们差Next()数组,可得到一个2。2是什么意思?我们观察可以知道,“ABCDAB”中,重复的就是“AB”,其长度为2。这和前缀后缀有什么关系么?
关系就是,其实模板字符串与目标字符串匹配虽然失败,但是失败的地方前6个字符,“ABCDAB”匹配成功。而这个字符串的所有前缀后缀集合中公共的最长字符串就是“AB”,所以啊,由于要移动模板字符串,那么目标字符串自然不能移动。那么,我们就把模板字符串移动一下,使模板字符串的前缀与目标字符串的后缀契合,不就正好了么。
所以,我们也知道,模板字符串该移动多少个字符串。那就是移动使模板字符串的前缀与目标字符串的后缀契合。公式为:
移动位数 = 已匹配的字符数 - 对应的部分匹配值
但是,这里算的是移动位数,其实在真正编程过程过程中,移动太麻烦,不如直接将指针指向移动后要到达的地方,这里其实就是next()数组对应的值。为什么?
虽然说当前匹配值和当前移动位数已经没什么关系了,它却影响了下一个位置的移动位数,所以我们要将它和下一个位置关联起来。如此一来,每个当前位置的移动位数都与前面的匹配值相关,而下一个位置的移动位数又与当前匹配值相关……我们自然而然就明白了,我只要将table表中的部分匹配值都右移一位,就可以得到next表了。
注意这里的往右移一位。这样,D直接对应2。很方便。
Pattern | A | B | C | D | A | B | D |
---|---|---|---|---|---|---|---|
next | -1 | 0 | 0 | 0 | 0 | 1 | 2 |
因为next()数组的值是虽然是前缀与后缀公共最长子字符串的长度,说白了,也是前缀的长度,而前缀是从字符串第一个字符开始的。所以,next()就是所要的。比如之前的”ABCDAB”,对应的next()为2。所以,j直接为2。对应C。“AB”直接对应好呢。
完整的KMP算法
KMP算法:一重循环
1、初始化 i = 0, j = 0
2、开始循环:
如果source[i] == target[j],则 i++,j++;
如果 j == len(target),返回 i - j
否则 j = next[j],如果此时 j == -1,则 j = 0, i++。
直至 i == len(source)
时间复杂度为 O(m)。
代码实现Next()数组
人工计算Next()数组的方法足够麻烦了,写成代码可想而知,更加麻烦,其实可以利用已知的Next(j)来计算Next(j+1)。
next(j)=k,说明:p[0] p[1], …, p[k-1] = p[j-k] p[j-k+1], …, p[j-1],p是字符串。那么现在要计算next(j+1):
如果p[j]==p[k],那么自然next(j+1)=next(j)+1;
如果不等,那么说明p[0] p[1], …, p[k-1],p[k] 与 p[j-k] p[j-k+1], …, p[j-1],p[j]不匹配,要继续匹配,只能看p[k]的最大前后缀字符串了,其长度是:next(k)。如果p[next(k)]==p[j],那么next(j+1)=next(k);如此循环,一直不等,则为0。固有:
1、k = next[j]
2、开始循环:
如果 k = -1,则 next[j+1] = 0,算法结束
target[j] 是否等于 target[k],如果等于,则 next[j+1] = k + 1,算法结束
否则令 k = next[k],继续循环。
代码如下:
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])//如果下一个不同,那么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]
}
}
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的最末端
{
//cout << "在位置" << i-plen+1<< endl;
//k = -1;//重新初始化,寻找下一个
//i = i - plen + 1;//i定位到该位置,外层for循环i++可以继续找下一个(这里默认存在两个匹配字符串可以部分重叠),感谢评论中同学指出错误。
return i-plen+1;//返回相应的位置
}
}
return -1;
}