今天数据结构学习了两个字符串匹配算法,BF算法和KMP算法。
课本讲的过于笼统,不能理解KMP算法,因此在网上找些资料后整理笔记如下。
字符串匹配算法就是在文本串中匹配模式串。
BF算法
BF(brute force)算法即暴力算法。从第一个字符开始匹配,每当匹配失败时,模式串向右滑动一位。最后返回第一次找到的下标,若没找到则返回-1。
ABABC
ABC
匹配失败,模式串向右滑动一位。
A BABC
ABC
代码实现如下:
int BF(char T[], char P[]) {
int i = 0, j = 0;
while (T[i] != '\0' && P[j] != '\0') {
if (T[i] == P[j]) {
i++; j++;
}
else {
// i - j即本次匹配的最初位置
// 再+1 即模式串向右滑动一位后对应的文本串的下标
i = i - j + 1;
j = 0;
}
}
if(P[j] == '\0')
return (i - j);
else
return -1;
}
假设文本串长度为m,模式串长度为n。
虽然BF算法在最快情况下(第一次就匹配成功)的时间复杂度仅为
O
(
m
)
O(m)
O(m),但最差情况需要
O
(
n
∗
m
)
O(n*m)
O(n∗m)。因此KMP算法加以改进。
KMP算法
KMP算法的改进之处在于每次回溯时都不止向右滑动1位,而允许一次滑动多位。
首先考虑一个特殊情况:模式串由不重复的字符组成
此时每次匹配失败都不需要回溯到下一个位置(即模式串不需要一位一位地向右滑动)。而是直接向右滑动k个字符,k是已经匹配的字符数。
因为
- 模式串后续字符必不相等
- 文本串已经与模式串匹配了k个字符
所以文本串已经匹配的后续字符与模式串的首字符必不匹配。例如:
ABCDEABCDEF
ABCDEF
成功匹配了5个字符(ABCDE),模式串向右滑动5位
ABCDEABCDEF
ABCDEF
一般情况怎么办?向左回溯!
但是不能希望模式串永远是由不重复的字符组成的,如果模式串中有重复字符怎么办呢?例如:
ABCDABCDABDE
ABCDABD
先当作没有重复的字符处理,成功匹配了6个字符(ABCDAB),模式串向右滑动6位
ABCDABCDABDE
ABCDABD
显然这样可能会错过正确答案,因为本可以与模式串首字符匹配的字符(AB)被跳过,因此需要向左滑动以回溯。但不像BF那样回溯到下一个字符,而是回溯到下一个模式串首字符出现的位置。如上面的字符需要回溯2位(前缀AB和后缀AB重复,其长度为2),所以总体向右滑动6-2=4位
ABCD ABCDABDE
ABCDABD
回溯多少?next数组!
这样的滑动方式需要记录模式串中每一段字符具有相同前缀和后缀的长度用来回溯.
模式串的各个子串 | 前缀 | 后缀 | 最大相同前后缀长度 |
---|---|---|---|
A | 无 | 无 | 0 |
AB | A | B | 0 |
ABC | A,AB | C,BC | 0 |
ABCD | A,AB,ABC | D,CD,BCD | 0 |
ABCDA | A,AB,ABC… | A,DA,CDA… | 1 |
ABCDAB | A,AB,ABC… | B,AB,DAB… | 2 |
ABCDABD | … | … | 0 |
综上所述,失配时,模式串向右滑动的位数为:已匹配字符数-已匹配字符串的最大公共元素长度。
因为已匹配的字符串一定是模式串的子串,所以回溯多少是只与模式串子串有关的量,也即只与失配时的字符在模式串中的序号有关。
用next数组来存储失配字符对应的应该回溯多少的值。
对于ABCDABD,其next数组为
模式串 | A | B | C | D | A | B | D |
---|---|---|---|---|---|---|---|
next | -1 | 0 | 0 | 0 | 0 | 1 | 2 |
可以发现next数组就是把最大公共元素长度数组整体向右移动一位,然后给next的第一个元素赋值为-1。
用P表示模式串,next[j] == k的本质表示模式串的字符P[j]之前的子串中,有长度为k的最长相同前后缀。因此next数组所包含的信息就是在P[j]的位置失配时,下一步应该去哪?在P[j]失配时,根据先滑动再回溯的思想,即向右滑动j - next[j]个位数。之所以给next的第一个元素赋值为-1,是因为如果第一个元素就失配,仍需要向右滑动一位,此时j - next[j] = 0 - (-1) = 1。
此时向右滑动的位数变为:已匹配字符数-失配字符对应的next值。
求next数组
使用递归的方法求next数组。
首先假设已知next[0~j],且next[j] == k,如何求next[j + 1]?
例如:对于模式串ABCDABCE,j = 5。
模式串 | A | B | C | D | A | B | D | E |
---|---|---|---|---|---|---|---|---|
next | -1 | 0 | 0 | 0 | 0 | 1 | ? | \ |
已知next[5] == k(即1),这表示在P[5]失配时,之前已匹配的子串中有相同前后缀,其最大长度为k(即本例的P[0](A)和P[4](A))。因为k也表示了相同前缀的长度,因此P[k]就是该相同前缀的下一个字符(本例的P[1](B))。并且P[j]表示该相同后缀的下一个字符(P[5](B))。因此显而易见:
- 如果
P[j] == P[k]
,那么next[j + 1] == next[j] + 1
。
此时考虑第二种情况,即P[j] != P[k]。例如以下模式串:
令α = AB(黄色表示),β = αCα(绿色表示),γ = βDβ(红色表示)。则该字符串为γEγCF。
已知 j = 23,next[23](即倒数第二位,C) == 11(γ的长度)。
此时P[j](E) != P[k](C),也即最大前缀的下一个字符和后缀的下一个字符不同。因此就需要退而求其次寻找小一点的最大相同前后缀长度。而这个过程中由于后缀是不变的,因此以后缀为参考去寻找有无稍小一点的前缀加上其下一个字符等于当前的后缀。
对于本例来说,最大的后缀γC已经没有相同前缀了。那么现在以βC为后缀去寻找下一个稍小一点的前缀,然而这个前缀是βD。那么再以αC去寻找,bingo!这时前缀刚好也是αC,因此next[24] == 3。
而如果到最后也没有找到(即k一直寻找到next[0],也即k == -1),那么相同前后缀的长度就是0了。
总结下来就是:
- 如果
P[j] != P[k]
,那么令k = next[k]
再去判断P[j]和P[k]是否相等(寻找小一点的相同前缀)。当k == -1
时,next[j + 1] == 0
代码如下:
int* getnext(char P[]) {
int j = 0; //模式串的“索引”
int k = -1; //k = next[j]
int* next = new int[strlen(P)];
next[0] = -1;
//根据已知的前j-1位推的第j位
while (j < strlen(P) - 1) {
if (k == -1 || P[j] == P[k])
next[++j] = ++k; //此时满足next[j+1] == next[j] + 1
else
k = next[k];
}
return next;
}
优化next数组
此时的next数组还有一个小问题。即不该出现p[j] == p[next[j]]
。理由是当p[j]与t[i]失配时,下次匹配必然是p[next[j]]与t[i]匹配。而如果p[j] == p[next[j]],那么本次匹配则一定会失败,因此这将会是一次冗余的匹配。
考虑下例:
ABACABAB
ABAB
第一次失配后变为:
ABACABAB
ABAB
那么本次匹配也一定会失配。
优化的方法就是在出现p[j + 1] == p[next[j] + 1]
的情况时,让下一次的匹配从p[next[j]]与t[i]变为p[next[next[j]]]与t[i]。
即不是next[j + 1] = next[j] + 1
,而是next[j + 1] = next[next[j] + 1]
。
代码如下:
while (j < strlen(P) - 1) {
if (k == -1 || p[j] == p[k]) {
j++;
k++;
Next[j] = (P[j] != P[k]) ? k : Next[k];
}
else {
k = next[k];
}
}
完整的KMP算法代码如下:
int KmpSearch(char* t, char* p) {
int i = 0;
int j = 0;
int tLen = strlen(t);
int pLen = strlen(p);
while (i < tLen && j < pLen) {
// 如果j == -1,或者当前字符匹配成功(即T[i] == P[j]),都令i++,j++
if (j == -1 || t[i] == p[j]) {
i++;
j++;
}
else {
// 如果j != -1,且当前字符匹配失败(即T[i] != P[j]),则令 i 不变,j = next[j]
j = next[j];
}
}
if (j == pLen)
return i - j;
else
return -1;
}
由于最坏的情况也只遍历一次文本串,再加上确定next数组得遍历一次模式串,因此KMP算法的时间复杂度是 O ( n + m ) O(n+m) O(n+m)。
以上关于KMP思路的总结,参考了从头到尾彻底理解KMP这篇文章。
关于如何确定next数组的部分也参考了这篇文章。