近期在刷题时又刷到KMP算法的题目,只不过第三次见面仍然不会写,每次都要重新写一遍,本次就写个博客记录一下吧
KMP算法的思想就是当出现字符串不匹配时,可以记录一部分之前已经匹配的文本内容,利用这些信息避免从头再去做匹配,降低时间复杂度。
前缀表即代码中出现的next数组,其作用是用来记录模式串(短串)与主串(长串)不匹配的时候,模式串应该从哪里开始重新匹配,因此是用来实现回退操作的。
例如在文本串aabaabaafa中查找模式串aabaaf
那么在第一次寻找时两者从头开始一一比较,当比较到第六个字符发现字符b和字符f不一致,那么如果是暴力解法就将模式串往后移动一个位置然后继续从头开始比较。但是KMP算法是找到了模式串第三个字符b然后继续与文本串的字符比较,就不用从头开始,因为这个时候前面两个aa是相同的。
那么前缀表是如何记录才能够实现这种跳转呢?其实前缀表记录的就是最长的相等前后缀。即记录下标i之前(包括i)的字符串中有多大长度的相同前缀后缀
这句首先要理解什么是前后缀:
那么前缀表中记录的最长相等前后缀,就是要求前后缀相等且具有最大长度,例如字符串abcab的最长相等前后缀为2,字符串aaa的最长相等前后缀为2。
回到刚才的例子:文本串:aabaabaafa中查找模式串:aabaaf,第一次找到的不匹配的位置为索引为5的位置,也就是在模式串中指向f,此时有最长相等前后缀aa,如下图:
那么为什么可以直接将模式串的指针移动到索引为2的b呢?
因为最长相等前后缀为aa,那么直接将前缀的aa移动到后缀的aa的位置,即字符串的第一个字符对应到第四个字符,肯定aa这个前后缀串的字符是可以匹配上的,接下来只需要往后匹配即可
那么有没有可能不是最长前后缀匹配,而是例如将模式串的第一个字符匹配到文本串的第四个字符之前的位置呢?这是不可能的,否则最长相等前后缀就不可能只有aa,可以尝试将模式串往后移动直到第一个字符对应上文本串的第三个字符b,可以发现这个时候是匹配不上的。
以上面那个模式串为例:
从头开始:
因此可以看出,模式串与前缀表对应位置的数组表示的是:下标i之前(包括i)的字符串中,有多大长度的相同前缀后缀
因此找到不匹配的位置时,就要查看不匹配位置的前一个字符的索引在前缀表中的数值是多少,然后模式串进行匹配的指针就移动到该数值对应的索引位置,例如上面匹配到f发现不匹配,而前一个字符对应在前缀表中数值为2,因此移动到索引为2的b继续匹配。
那么为什么要前一个字符的前缀表的数值呢?因为我们在当前这个位置不匹配(即文本串中索引为5的位置),我们要找出模式串中哪一个字符应该来与当前这个不匹配的字符进行比较,因此不能考虑现在这个字符的前后缀,而应该找前面一个字符串对应的最长相同前后缀。
next数组实际上和前缀表意义相同,只不过很多实现都是将前缀表的每一个元素减一之后作为next数组。但这并不涉及到KMP算法的原理,只是为了是在写代码的时候方便,当然也可以用前缀表来实现。其实这只是不同的实现方式,前缀表其含义更加清晰容易理解一点,而next数组相对而言在代码中比较难理解其具体含义,不过很多人都是用next数组来实现KMP算法。
先定义一个函数getNext来构建next数组,如下:
void getNext(int* next, const string& s){
int j = -1;
next[0] = j; // 完成初始化工作
for(int i = 1; i < s.size(); i++) { // 注意i从1开始
while (j >= 0 && s[i] != s[j + 1]) { // 前后缀不相同了
j = next[j]; // 向前回退
}
if (s[i] == s[j + 1]) { // 找到相同的前后缀
j++;
}
next[i] = j; // 将j(前缀的长度)赋给next[i]
}
}
该函数完成的功能就是计算模式串s的前缀表,具体的代码解释如下:
首先是初始化,定义指针 i i i和 j j j,其中 j j j指向当前前缀末尾的位置,而 i i i指向后缀末尾的位置。那么 j j j初始化为-1,是为了对应前文说前缀表统一减一作为next的操作,而next[i]表示i(包括i)之前最长相等的前后缀长度,因此初始化next[0]=j,因此单独一个元素没有前后缀而言。这里其实可以将其作为一个定义的操作,而不是强行去理解为什么这么设置。
处理前后缀不相同的情况:第一个寻找前后缀的子串应该是s[0]和s[1]组成的子串,那么初始化应该i=1,那么就是s[i]和s[j+1]进行比较。如果s[i]与s[j+1]不相同了,说明当前s[i]这个字符要回退,那么回退到哪里呢?回退到s[i-1]对应的相等前后缀的前缀的下一个,可以看下图来理解:
当 i = 12 , j + 1 = 8 i=12,j+1=8 i=12,j+1=8时此时发现不相等,我们可以发现s[i]前面的aabc和从头开始的aabc相等,因此 j + 1 j+1 j+1就要回退到第一个aabc 的下一个,来和 s [ i ] s[i] s[i]比较,如果相等还是可以继续续上相等前后缀的,那么也可以看到第一次回归 j + 1 j+1 j+1时并不相等,那么就再回退一次,因此就回退到开头,从头开始比较了。那么下一个问题就是我们知道应该回退到哪里了,缩印在哪里呢?其实回退位置的索引就是在next[j]里,因为我们要找在 j + 1 j+1 j+1前一个位置的公共前缀的长度。
处理前后缀相同的情况:前后缀都相同了,那么肯定是接着往下寻找,继续匹配前后缀,因此要 j + + j++ j++,同时还要将当前 j j j(也就是前缀的长度)赋予给next[i]作为记录
有了next数组,就可以同样定义两个指针i和j来依次比较两个串的内容了,不过这里同样是比较s[i]和t[j+1],因为next数组中记录的起始位置为-1,因此要匹配。
如果比较相同,那么同样i和j一起往后移动,如果比较不相等那么就利用前述的方法进行回退即可。同时还要注意判断j是否已经走到了模式串的末尾,来判断比较过程是否应结束。因此完整的代码如下:
class Solution {
public:
void getNext(int* next, const string& s){
int j = -1;
next[0] = j;
for(int i = 1; i < s.size(); i ++){
while( j >= 0 && s[i] != s[j+1]){
j = next[j];
}
if(s[i] == s[j+1]){
j++;
}
next[i] = j;
}
}
int strStr(string haystack, string needle) {
if(needle.size() == 0){
return 0;
}
int next[needle.size()];
getNext(next,needle);
int j = -1;
for(int i = 0; i = 0 && haystack[i] != needle[j + 1]){
j = next[j];
}
if(haystack[i] == needle[j + 1]){
j++;
}
if( j == (needle.size() - 1)){
return( i - needle.size() + 1);
}
}
return -1;
}
};
如果不要前缀表统一减一也是可以实现的,而且个人感觉其代码更加容易理解。
class Solution {
public:
void getNext(int* next, const string& s){
int j = 0;//由于不减一了那么初始化为0
next[0] = j;//这里同理
for(int i = 1; i < s.size(); i++){ // 这里i同样是从1开始的
while( j > 0 && s[i] != s[j]){
j = next[j - 1];//因为这里是j-1,因此j要大于0
}
if (s[i] == s[j]){
j++;
}
next[i] = j;
}
}
int strStr(string haystack, string needle) {
if(needle.size() == 0){
return 0;
}
int next[needle.size()];
getNext(next,needle);
int j = 0;
for(int i = 0; i < haystack.size(); i++){
while( j > 0 && haystack[i] != needle[j]){
j = next[ j - 1];
}
if(haystack[i] == needle[j]){
j++;
}
if( j == needle.size()){
//说明达到满足条件了
return (i - needle.size() + 1);
}
}
return -1;
}
};