本文按照此结构展开:1. 问题描述
2. 朴素模式匹配算法
3. KMP模式匹配算法
4. KMP算法的改进
5. KMP算法优化总结
6. 参考文献
1. 问题描述
在实际应用中,我们常会遇到这样的问题:判断一个较长的字符串(主串)中是否存在一个较短的字符串(子串),若存在,则返回匹配成功的索引。这就是字符串的模式匹配,也称为子串定位操作。
解决这类问题主要有两种方法:
- 朴素的模式匹配算法
- KMP模式匹配算法
下面我将详细介绍。
2. 朴素的模式匹配算法
假设主串为"BBCABCDABABCDABDE",子串为"ABCDABD",则匹配步骤为:
第1步:(红色表示不匹配)
B B C A B C D A B A B C D A B D E
A B C D A B D
主串"BBCABCDABABCDABDE"的第一个字符与模式串"ABCDABD"的第一个字符不匹配,继续向后找,知道找到主串中与模式串第一个字符匹配的字符,2-4步。
第2步:
B B C A B C D A B A B C D A B D E
A B C D A B D
第3步:
B B C A B C D A B A B C D A B D E
A B C D A B D
第4步:
B B C A B C D A B A B C D A B D E
A B C D A B D
找到主串中与模式串第一个字符匹配的字符,继续向后与模式串后面的字符进行匹配,发现'A'与'D'匹配失败。
第5步:
B B C A B C D A B A B C D A B D E
A B C D A B D
主串回溯到与模式串第一个字符匹配的位置,继续执行1-4步骤,直到遍历完成或者匹配成功。
......
也就是说,以主串中的每个字符,作为子串的开头进行匹配,直到遍历完成或者匹配成功。
存在问题:效率低。
时间复杂度:假设S1长度为n,S2长度为m,
则最好情况为:S1="abcdefgoogle",S2="google",时间复杂度:O(n+m);
最坏情况为:若S1="00000000000000000000001",S2="00000000001",时间复杂度:O((n-m+1)*m)
3. KMP模式匹配算法
KMP通过避免重复的比较,提高了算法效率。
观察朴素字符串匹配的第5步,其实我们已经发现问题所在了:模式串"ABCDABD"中"ABCDAB"已经与主串"BBCABCDABABCDABDE"中的字符匹配成功,当'A'与'D'匹配失败时,我们可以看到下一个进行比较的位置最好应该如下图所示:
B B C A B C D A B A B C D A B D E
A B C D A B D
原因:已经比较过的不需要重复比较.
那么怎么做到呢?
先思考一种比较简单的情况:假设主串为"ABCDEFHIJ"模式串为"ABCDEFG"成功匹配的字符为"ABCDEF",'G'与'H'不匹配。那么,很直观,下一步最好的比较位置是:用模式串的字符'A'与主串的'H'比较,就是说,模式串的第一个字符直接与第一个不匹配的主串字符重新进行比较。
然而,在上面的例子中,我们用'A'和'C'进行比较。
原因是:在模式串中有重复的字符(串)。
所以,当匹配失败时,下一个匹配的最好位置与模式串的字符内容有很大的关系。接下来,我们来寻找此规律。
3.1 寻找最长前缀后缀
3.2 基于最大长度表的匹配
通过观察,可以发现,匹配失败后,下一个比较的最佳位置是(这里,因为主串不回溯,所以主串用来比较的字符就是匹配失败的那个字符,所以最佳位置是指:模式串在比较失败后,下一个比较的最佳位置):
下一个比较的模式串下标=[已匹配成功子串的最大公共前后缀]
在上面的例子中,"ABCDABD"中"ABCDAB"匹配成功,'A'与'D'匹配失败,
B B C A B C D A B A B C D A B D E
A B C D A B D
下一次正确匹配位置:
B B C A B C D A B A B C D A B D E
A B C D A B D
此时,模式串的最佳比较位置应该为[2].
由此,我们可以得到,若模式串的j,j>0,与主串的i,匹配失败,我们用maxCommon[j]表示模式串[0,...,j]的最大前后缀,主串为S1,模式串为S2,则下一个比较位置为
S1[i] S2[maxCommon[j-1]].
那么,为什么根据最长相同真前后缀的长度就可以实现在不匹配情况下的跳转呢?举个代表性的例子:假如i = 6
时不匹配,此时我们是知道其位置前的字符串为ABCDAB
,仔细观察这个字符串,首尾都有一个AB
,既然在i = 6
处的D不匹配,我们为何不直接把i = 2
处的C拿过来继续比较呢,因为都有一个AB
啊,而这个AB
就是ABCDAB
的最长相同真前后缀,其长度2正好是跳转的下标位置。
有的读者可能存在疑问,若在i = 5
时匹配失败,按照我讲解的思路,此时应该把i = 1
处的字符拿过来继续比较,但是这两个位置的字符是一样的啊,都是B
,既然一样,拿过来比较不就是无用功了么?其实不是我讲解的有问题,也不是这个算法有问题,而是这个算法还未优化,关于这个问题在下面会详细说明,不过建议读者不要在这里纠结,跳过这个,下面你自然会恍然大悟。
3.3 next数组的求解
有上面,我们得到,S1[i] S2[maxCommon[j-1]]这样一个对应关系。
更为方便的,用next[j]数组表示模式串j位置匹配失败时,下一个匹配的位置,则:next[j] = maxCommon[j-1],j>0,当j=0时,也就是说,第一个字符都不相等,此时,应该用模式串的第一个字符与主串的下一个字符进行比较。为了代码处理方便,我们使得next[0]=-1;
所以:next[j] = maxCommon[j-1],j>0
next[0] = -1;
可得:next数组为:
i | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
模式串 | A | B | C | D | A | B | D |
最大前后缀公共长度 | 0 | 0 | 0 | 0 | 1 | 2 | 0 |
next[i] | -1 | 0 | 0 | 0 | 0 | 1 | 2 |
3.4 代码实现(CPP)
#define KMP_CPP_H #ifdef KMP_CPP_H #include<iostream> #include<vector> using namespace std; bool GetNext(string matchStr, vector<int >& next) { int nsize = matchStr.size(); //str.size() str.length(),str.length()为STL之前的函数. if (nsize != next.size()) return false; int i = 0, j = -1; next[0] = -1; while (i < nsize-1)//注意这里要-1,否则next数组越界 { cout << "i=" << i << " j=" << j << " next[i]=" << next[i] << endl; if (j == -1 || matchStr[i] == matchStr[j]) { i++; j++; next[i] = j; cout << "i=" << i << " j=" << j << " next[i]=" << next[i] << endl; } else j = next[j]; cout << endl; } return true; } int KMP(string majorStr, string matchStr) { int majorStrSize = majorStr.size(), matchStrSize = matchStr.size(); if (majorStrSize < matchStrSize) return -1; vector<int> next(matchStrSize,0); if (!GetNext(matchStr, next)) { cout << "GetNext is error" << endl; return -1; } int i = 0, j = 0; while (i < majorStrSize && j < matchStrSize) { if (j == -1 || majorStr[i] == matchStr[j]) { i++; j++; } else j = next[j]; } if (j == matchStrSize) return i - j; return -1; } int main(int argc, char* argv) { cout << KMP("BBCABCDABABCDABDE", "ABCDABD") << endl; getchar(); return 0; } #endif/*KMP_CPP_H*/
这里,得到next数组的代码比较难以理解,所以,将过程输出,方便理解:
4. KMP算法的改进
按照上面求next数组的方法,得到next数组为:
i | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
模式串 | A | B | C | D | A | B | D |
next[i] | -1 | 0 | 0 | 0 | 0 | 1 | 2 |
则,若当i=5时匹配失败,则下一次应该是i=1处的字符进行匹配,但是,这两个位置的字符是相同的,所以这次比较没有必要。
故,KMP算法还有可以改进之处:
当next[i]=next[next[i]]时,继续递归。
改进后得到next数组的函数如下:
bool GetTrueNext(string matchStr, vector<int >& next) { int nsize = matchStr.size(); //str.size() str.length()没区别 if (nsize != next.size()) return false; cout << matchStr << endl;//#include<string> int i = 0, j = -1; next[0] = -1; while (i < nsize - 1)//注意这里要-1,否则next数组越界 { cout << "i=" << i << " j=" << j << " next[i]=" << next[i] << endl; if (j == -1 || matchStr[i] == matchStr[j]) { i++; j++; //next[i] = j; if(matchStr[i] != matchStr[j]) next[i] = j; else next[i] = next[j]; cout << "i=" << i << " j=" << j << " next[i]=" << next[i] << endl; } else j = next[j]; cout << endl; } return true; }
next数组的求解过程:
优化前后对比:
i | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
模式串 | A | B | C | D | A | B | D |
优化前next[i] | -1 | 0 | 0 | 0 | 0 | 1 | 2 |
优化后next[i] | -1 | 0 | 0 | 0 | -1 | 0 | 2 |
5. KMP算法优化总结
KMP算法严格来说分为KMP算法(未优化版)和KMP算法(优化版),所以建议读者在表述KMP算法时,最好告知你的版本,因为两者在某些情况下区别很大,这里简单说下。
KMP算法(未优化版): next数组表示最长的相同真前后缀的长度,我们不仅可以利用next来解决模式串的匹配问题,也可以用来解决类似字符串重复问题等等,这类问题大家可以在各大OJ找到,这里不作过多表述。
KMP算法(优化版): 根据代码很容易知道(名称也改为了nextval),优化后的next仅仅表示相同真前后缀的长度,但不一定是最长(称其为“最优相同真前后缀”更为恰当)。此时我们利用优化后的next可以在模式串匹配问题中以更快的速度得到我们的答案(相较于未优化版),但是上述所说的字符串重复问题,优化版本则束手无策。
所以,该采用哪个版本,取决于你在现实中遇到的实际问题。
6. 参考文献
大话数据结构
http://wiki.jikexueyuan.com/project/kmp-algorithm/define.html
https://segmentfault.com/a/1190000008575379