背景知识回顾
之前的文章中讲了字符串匹配问题中的单模匹配问题,即从一段文本串中找到另一个字符串是否出现过。母串(文本串)是指要从哪个字符串中查找,模式串指的是要查找哪个字符。之后介绍了暴力匹配算法,即从文本串中逐位向后和模式串比较。
本文介绍KMP匹配算法。
KMP算法理论介绍
1. 模式串逐位平移比较能否改为跳位比较
假设母串和模式串的比较遇到如下情况:
将母串用S表示,模式串用T表示。
母串比较到了x位置,模式串比较到了索引为12的位置y, 此时发现失配。
正常来说可以将模式串向后平移一位,重新进行逐位比较。
而模式串向后平移一位,文本串的x位置之前仍能匹配成功,应该等价于 T [ 0 : 10 ] = = T [ 1 : 11 ] T[0 : 10] == T[1 : 11] T[0:10]==T[1:11]。其中T为模式串。
同理,将模式串向后平移两位,文本串的x位置之前仍能匹配成功,应该等价于 T [ 0 : 9 ] = = T [ 2 : 11 ] T[0 : 9] == T[2 : 11] T[0:9]==T[2:11]。
将模式串向后平移三位,文本串的x位置之前仍能匹配成功,应该等价于
T
[
0
:
8
]
=
=
T
[
3
:
11
]
T[0 : 8] == T[3 : 11]
T[0:8]==T[3:11]。
…
将模式串向后平移 k k k位,文本串的x位置之前仍能匹配成功,应该等价于 T [ 0 : ( 11 − k ) ] = = T [ k : 11 ] T[0 : (11-k)] == T[k : 11] T[0:(11−k)]==T[k:11]。
而对于 T [ 0 : ( 11 − k ) ] = = T [ k : 11 ] T[0 : (11-k)] == T[k : 11] T[0:(11−k)]==T[k:11]是否成立,在比较之前就是可以预先计算好的。例如上图的例子 k k k最小等于7时, T [ 0 : 4 ] = = T [ 7 : 11 ] T[0 : 4] == T[7 : 11] T[0:4]==T[7:11] 才是成立的。这就意味着如果向后将模式串向后平移1位,2位,…, 6位都是不可能匹配成功的。
平移 k = 7 k=7 k=7 位以后,只需要看文本串此时的位置 x x x 和模式串平移后对应的位置 b b b 是否相等即可。
如果 x x x和 b b b相等,那么就让x的下一位和b的下一位继续比较,直到不匹配或者模式串都匹配完;
如果 x x x和 b b b不相等,那么此时模式串 T T T的 0 0 0到 4 4 4位 ( a d a d e ) (adade) (adade)就代替了之前的 T [ 0 : 11 ] T[0:11] T[0:11], 即在 T [ 0 : 4 ] T[0:4] T[0:4]中再找到可以向右平移的最小步数(新的k)。
上面分析的过程中对于T的前11位,寻找 T [ 0 : ( 11 − k ) ] = = T [ k : 11 ] T[0 : (11-k)] == T[k : 11] T[0:(11−k)]==T[k:11]等式成立的最小的 k k k,也叫寻找模式串的**最长公共前缀( T [ 0 : ( 11 − k ) ] T[0 : (11-k)] T[0:(11−k)])和后缀(**T[k : 11])。
所以问题的关键就成了寻找出模式串每一位的最长公共前缀和后缀。如果能预计算出来这个信息,暴力匹配中的逐位比较就可以变成跳着比较。
2. 模式串每一位最长公共前后缀的计算
定义数组
i
n
t
[
n
]
n
e
x
t
int [n] \ \ next
int[n] next,
n
n
n为模式串
T
T
T 的长度,
n
e
x
t
[
i
]
next[i]
next[i] 表示
i
i
i位置作为最长公共后缀的末尾时,对应的最长公共前缀的末尾位置。
例如对于上例中的模式串
a
d
a
d
e
b
c
a
d
a
d
e
y
adadebcadadey
adadebcadadey, 每一位对应的
n
e
x
t
[
i
]
next[i]
next[i]如下:
其中 − 1 -1 −1是自定义的一个虚拟索引,表示该位置不存在最长公共前后缀。
接下来考虑 n e x t next next 数组如何简便的计算。
初始位置 n e x t [ 0 ] next[0] next[0]一定等于-1,因为就一个数字,不存在最长公共前后缀。
假设从 n e x t [ 0 ] next[0] next[0] 到 n e x t [ i − 1 ] next[i-1] next[i−1] 已经全都计算好了,那么对于 n e x t [ i ] next[i] next[i] 应该如何计算?
如上图,设 j = n e x t [ i − 1 ] j = next[i - 1] j=next[i−1],那么根据定义,必定有 T [ 0 : j ] = T [ ( i − 1 − j ) : ( i − 1 ) ] T[0 : j] = T[(i - 1 - j) : (i-1)] T[0:j]=T[(i−1−j):(i−1)] (分别表示最长公共前缀和后缀)。即上图中的两个阴影部分可以完全匹配。
此时到了 i i i 这个位置,只需要看 T [ i ] = = T [ j + 1 ] T[i] == T[j + 1] T[i]==T[j+1] 是否成立。
如果T[i] == T[j + 1]成立,那么上面相等的两个阴影部分就可以分别向后扩一位,即 T [ 0 : ( j + 1 ) ] = T [ ( i − j − 1 ) ) : i ] T[0 : (j + 1)] = T[(i - j - 1)) : i] T[0:(j+1)]=T[(i−j−1)):i] 成立,此时 n e x t [ i ] = j + 1 next[i] = j + 1 next[i]=j+1。
如果T[i] == T[j + 1] 不成立,那么就在下面那个阴影部分( T [ 0 : j ] T[0 : j] T[0:j])中,找到** j j j位置作为最长公共后缀时,最长公共前缀的位置**,即 n e x t [ j ] next[j] next[j]。因为 j j j一定小于等于 i − 1 i - 1 i−1, 所以 n e x t [ j ] next[j] next[j] 是已经计算好的。继续看 T [ i ] = = T [ n e x t [ j ] + 1 ] T[i] == T[next[j] + 1] T[i]==T[next[j]+1] 是否成立。
如此利用数学归纳法(递推思想)便可完成所有位置最长公共前后缀( n e x t next next 数组)的计算。
KMP算法代码实现
//string_match_kmp.cpp
#include <string>
#include <iostream>
#include <cstdio>
using namespace std;
void getNext(const char *pattern, int *next) {
/*
预先生成next数组
pattern: 模式串
next: kmp匹配中的next数组
return: void
*/
next[0] = -1; //j 表示上一个位置 next[i - 1] 值
for (int i = 1, j = -1; pattern[i]; ++i) {
while (j != -1 && pattern[j + 1] - pattern[i]) j = next[j];
if (pattern[j + 1] == pattern[i]) j += 1;
next[i] = j;
}
return;
}
void output_next(int *next, int n) {
for (int i = 0; i < n; i++) {
printf("%d ", next[i]);
}
printf("\n");
}
int kmp(const char *text, const char *pattern) {
/*
kmp匹配算法
text: 母串(文本串)
pattern: 模式串
return: 第一个匹配成功的text数组的位置索引
*/
int n = strlen(pattern);
int *next = (int *)malloc(sizeof(int) * n);
getNext(pattern, next); //预先计算好next数组
//output_next(next, n); //可以输出next数组,便于理解
//j表示截止到上一个位置(text[i - 1])已经和 pattern的j位置及之前都匹配成功
for (int i = 0, j = -1; text[i]; i++) {
//text的 i 位置和pattern的j+1位置如果不相等,
//那么继续缩小j到下一个最长公共前后缀的位置
while (j != -1 && text[i] != pattern[j + 1]) j = next[j];
//相等则匹配成功的长度+1
if (text[i] == pattern[j + 1]) j += 1;
//j已经到了pattern的最后一个位置,此时i位置对应模式串的最后一个位置,
//文本串对应的起始位置为i - j
if (pattern[j + 1] == 0) return i - j;
}
return -1;
}
#define TEST(func, s, t) {\
printf("%s(\"%s\", \"%s\") = %d\n", #func, s, t, func(s, t));\
}
int main() {
char s[200], t[200];
while (cin >> s >> t) {
TEST(kmp, s, t);
}
return 0;
}
代码总结与分析: 代码的整体思路和理论部分相对应。
在for循环中,while部分仅涉及pattern索引位置的转换,是近似O(1)的,所以整体算法的时间复杂度为O(n+m), n为文本串的长度,m为模式串的长度(加上了getNext部分的耗时)。
KMP算法的应用价值
- 相当于状态机:由上一节的代码部分可以看出,程序的主要部分为for循环内部的三行语句,而这三行起始可以总结为 j j j 的转换,相当于状态转换,所以KMP算法相当于一个状态机转换过程。
- 可以处理流式数据:由代码部分亦可看出,预先对模式串进行计算后,文本串 i i i 位置的求值,仅和 i i i之前的字符匹配到模式串的哪一位有关,和i后面的值是没有关系的。所以KMP算法可以处理流式文本串。即不需要存储文本串的全部信息,流式输入(文本串中的字符一个一个输入),便可流式地将结果输出。