一、简介
KMP(Knuth-Morris-Pratt)算法是一种用于字符串匹配的高效算法,其主要目的是在给定文本串(主串)和模式串(要匹配的子串)的情况下,找出模式串在文本串中的所有匹配位置。
二、原理分析
经常性的,我们会在文本中查找指定字符串的位置,下意识想到的匹配算法可能是将待查找字符串和文本一一对比,如果不相等就继续从待查找字符串的第一个字符串继续对比,如下图1,图2所示。
图1
图2
可以看到,通过暴力对比算法对比时,一旦对比失败,不但待查找字符串的索引归0,文本的索引也需要回退,从上图可以看到,其实有很多比对是重复工作,那么KMP算法是怎么工作的呢?
首先我们需要分析文本和待查找字符串比对过程,假设待查找字符串是"ahaf",当比对到"aha"和文本都相同,"f"和文本对应位置不同时,如图1所示,这时候待查找字符串的索引其实可以先回退到索引1,如图3所示,然后继续对比待查找字符串在当前索引下与文本的是否相同,如果相同,文本索引向后移动,待查找字符串索引向后移动,继续比对;如果不相同,则待查找字符串的索引继续回退,重复前面步骤,直至归0;这样一方面避免了文本索引的回退,另一方面最大化利用了之前的比对结果。那么现在有两个问题,一个问题是为什么这样做文本索引不需要回退,另一个问题是怎么知道待查找字符串应该回退到哪个位置?当我们待查找字符串索引回退时就是变向的文本索引回退,可以这样理解,如果待查找字符串是"abcdef"这样的前后缀子字符串不可能相同的形式文本索引回退没有意义,待查找字符串索引回退到0继续比对即可,只有待查找字符串的前后缀子字符串有相同时才有回退的意义,比如待查找字符串"abcabd",不过我们可以通过待查找字符串索引回退而不是直接归0来替代文本索引的回退。那么现在的关键是要确认当比对失败时待查找字符串应该回退到哪个位置。
图3
假设待查找字符串"ahaf",现在我们需要生成一个数组next,这个数组的长度和待查找字符串的长度相同,分别对应待查找字符串的每个字符,数组记录的值是字符串索引应该回退的位置。现在我们可以以推演的方式为待查找字符串"ahaf"尝试性的生成一个这样的数组。
首先next[0] = 0,因为第一个字符退无可退;
然后next[1] = 0,因为第一个字符和第二个字符不相同;
其次next[2] = 1,因为第一个字符和第三个字符相同;
最后next[3] = 0,因为第四个字符和第二个字符不相同且第四个字符和第一个字符也不相同,如果第四个字符和第二个字符相同则next[3] = 2,如果第四个字符和第二个字符不相同但第四个字符和第一个字符相同则next[3] = 1。
如果上面的推演没有看明白,那么再看一个待查找字符串"abcabd"的推演。
next[0] = 0,因为第一个字符退无可退;
next[1] = 0,因为第一个字符和第二个字符不相同;
next[2] = 0,因为第一个字符和第三个字符不相同;
next[3] = 1,因为第一个字符和第四个字符相同;
next[4] = 2,因为第二个字符和第五个字符相同且第一个字符和第四个字符相同;
next[5] = 0,因为第三个字符和第六个字符不相同且第一个字符和第六个字符不相同。
如果还没有看明白,再看一个待查找字符串"abacabad"的推演。
next[0] = 0,因为第一个字符退无可退;
next[1] = 0,因为第一个字符和第二个字符不相同;
next[2] = 1,因为第一个字符和第三个字符相同;
next[3] = 0,因为第二个字符和第四个字符不相同且第一个字符和第四个字符也不相同;
next[4] = 1,因为第一个字符和第五个字符相同;
next[5] = 2,因为第二个字符和第六个字符相同且第一个字符和第五个字符相同;
next[6] = 3,因为第三个字符和第七个字符相同且第二个字符和第六个字符相同且第一个字符和第五个字符相同;
next[7] = 0,因为第四个字符和第八个字符不相同,所以回退,这个值比较特殊,一般是已经成功匹配了一个待查找字符串后继续向后匹配时会用到这个值。
现在我们看C++首先生成这个数组的方法。
void get_next(int* next, string pattern) {
auto len = pattern.length();
int end_char = 1, equal_count = 0;
next[0] = 0;
for (; end_char < len; ++end_char) {
while (equal_count > 0 && pattern[end_char] != pattern[equal_count]) {
//这句代码是最难理解的,也是KMP算法的精髓,实在想不到通俗易懂的解释
//建议结合画图、调试、推演来理解
equal_count = next[equal_count - 1];
}
if (pattern[end_char] == pattern[equal_count]) {
equal_count++;
}
next[end_char] = equal_count;
}
}
看不懂的建议让AI加注释,如果还不清楚,一步步调试进行确认。
拿到next数组后,进行文本比对的思路就清晰了,文本和待查找字符串相同则待查找字符串索引前移,文本索引前移,如果不相同,则调整待查找字符串索引,通过next数组回退到指定位置。
下面给出完整的代码,有些细节没有详细说出来,建议AI生成注释理解,节省时间。
void get_next(int* next, string pattern) {
auto len = pattern.length();
int end_char = 1, equal_count = 0;
next[0] = 0;
for (; end_char < len; ++end_char) {
while (equal_count > 0 && pattern[end_char] != pattern[equal_count]) {
equal_count = next[equal_count - 1];
}
if (pattern[end_char] == pattern[equal_count]) {
equal_count++;
}
next[end_char] = equal_count;
}
}
int* kmp_algorithm(string text, string pattern) {
auto pattern_len = pattern.length();
auto text_len = text.length();
int next[pattern_len];
int* results = new int[text_len];
for (int i = 0; i < text_len; ++i) {
results[i] = -1;
}
get_next(next, pattern);
for (int i = 0; i < pattern_len; ++i) {
cout << next[i] << " ";
}
cout << endl;
int text_index = 0, pattern_index = 0;
for (; text_index < text_len; ++text_index) {
while ((text[text_index] != pattern[pattern_index]) && (pattern_index > 0)) {
pattern_index = next[pattern_index - 1];
}
if (text[text_index] == pattern[pattern_index]) {
pattern_index++;
}
if (pattern_index == pattern_len) {
for (int i = 0; i < text_len; ++i) {
if (results[i] == -1) {
results[i] = (int)(text_index - pattern_len + 1);
break;
}
}
}
}
return results;
}
void test_kmp_algorithm() {
string text = "ahaaahaabacabafheabacabaabfdrs";
string pattern = "abacaba";
int* index = kmp_algorithm(text, pattern);
for (int i = 0; i < text.length(); ++i) {
if (i == 0 && index[i] == -1) {
cout << "Not Found!";
break;
}
if (index[i] != -1) {
cout << "第" << i + 1 << "个位置在索引" << index[i] << endl;
}
}
delete[] index;
}