一、BF算法的问题
回顾BF算法,当匹配失败时:
text
主串: a b c a b d
模式: a b d
↑ ↑ ✗ (c != d)
此时i回溯到2,j重置为1,重新开始比较。问题是,我们已经知道S[2]=b,而T[1]=a,肯定不匹配。BF算法没有利用这个信息,做了很多无用的比较。
KMP的核心思想:当匹配失败时,主串指针i不回溯,只移动模式串指针j,利用已匹配部分的前缀和后缀信息,跳过不必要的比较。
二、前缀、后缀和部分匹配值
2.1 定义
-
前缀:除最后一个字符外,字符串的所有头部子串
-
后缀:除第一个字符外,字符串的所有尾部子串
-
部分匹配值:前缀和后缀的最长相等长度
例如 "ababa":
| 长度 | 前缀 | 后缀 | 是否相等 |
|---|---|---|---|
| 1 | a | a | ✓ |
| 2 | ab | ba | ✗ |
| 3 | aba | aba | ✓ |
| 4 | abab | baba | ✗ |
最长相等长度为3,所以部分匹配值为3。
2.2 计算模式串的部分匹配值
以 T = "abab" 为例:
| 子串 | 前缀 | 后缀 | 最长相等长度 |
|---|---|---|---|
| "a" | 空 | 空 | 0 |
| "ab" | {a} | {b} | 0 |
| "aba" | {a,ab} | {a,ba} | 1 (a) |
| "abab" | {a,ab,aba} | {b,ab,bab} | 2 (ab) |
得到部分匹配值数组:[0, 0, 1, 2]
三、next数组的推导
3.1 next数组的含义
next[j] 表示:当模式串的第j个字符与主串不匹配时,模式串指针j应该跳到的位置(即下一次从哪个位置开始比较)。
重要约定 :为了方便,我们通常让数组下标从1开始,next[1] = 0 表示特殊情况。
3.2 递推公式
text
next[1] = 0
next[2] = 1
当 j > 2 时:
令 k = next[j-1]
如果 T[j-1] == T[k],则 next[j] = k + 1
否则,递归地让 k = next[k],继续比较,直到 k=0
这个递推关系比较抽象,我们通过具体例子来理解。
3.3 手算next数组
例1:T = "abab"
text
j=1: next[1] = 0
j=2: next[2] = 1
j=3: 看前一个字符 T[2]=b
取 k = next[2]=1
比较 T[2] 和 T[1]: 'b' != 'a'
让 k = next[1]=0,k=0 停止
next[3] = k+1 = 1
j=4: 看前一个字符 T[3]=a
取 k = next[3]=1
比较 T[3] 和 T[1]: 'a' == 'a',匹配
next[4] = k+1 = 2
结果:next = [0, 1, 1, 2]
例2:T = "abcabc"
text
j=1: next[1]=0
j=2: next[2]=1
j=3: T[2]=b, k=1, T[1]=a, b!=a, k=0 → next[3]=1
j=4: T[3]=c, k=1, T[1]=a, c!=a, k=0 → next[4]=1
j=5: T[4]=a, k=1, T[1]=a, a==a → next[5]=2
j=6: T[5]=b, k=2, T[2]=b, b==b → next[6]=3
结果:next = [0, 1, 1, 1, 2, 3]
3.4 代码实现next数组
c
void getNext(const char *T, int *next, int len) {
int i = 1, j = 0; // i是模式串当前下标,j是已匹配的前缀长度
next[1] = 0;
while (i < len) {
if (j == 0 || T[i-1] == T[j-1]) { // 注意字符串下标从0开始
i++;
j++;
next[i] = j;
} else {
j = next[j];
}
}
}
四、KMP匹配算法
4.1 算法流程
有了next数组,KMP匹配就很简洁了:
text
i = 1, j = 1
while (i <= S.len && j <= T.len) {
if (j == 0 || S[i] == T[j]) {
i++;
j++;
} else {
j = next[j]; // i不回溯,j跳转
}
}
if (j > T.len) 返回 i - T.len
else 返回 0
4.2 动画演示
以 S = "ababcabc", T = "abcabc" 为例,next = [0,1,1,1,2,3]
text
第1轮:i=1,j=1
S: a b a b c a b c
T: a b c a b c
↑ ↑ ✗ (a==a, b==b, a!=c)
此时 j=3,根据next[3]=1,j跳到1
i保持=3
第2轮:i=3,j=1
S: a b a b c a b c
T: a b c a b c
↑ ✗ (a!=a? 等等,S[3]=a, T[1]=a,相等!)
等等,仔细看:S[3]=a, T[1]=a,匹配!
实际上,KMP的关键是当不匹配时,j跳转到next[j],继续比较。
4.3 完整代码
c
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#define MAXLEN 100
// 获取next数组(下标从1开始)
void getNext(const char *T, int *next, int len) {
int i = 1, j = 0;
next[1] = 0;
while (i < len) {
if (j == 0 || T[i-1] == T[j-1]) {
i++;
j++;
next[i] = j;
} else {
j = next[j];
}
}
}
// KMP算法
int kmp(const char *S, const char *T) {
int lenS = strlen(S);
int lenT = strlen(T);
if (lenT == 0) return 1;
if (lenS < lenT) return 0;
int *next = (int*)malloc((lenT + 1) * sizeof(int));
getNext(T, next, lenT);
int i = 1, j = 1; // 从1开始
while (i <= lenS && j <= lenT) {
if (j == 0 || S[i-1] == T[j-1]) {
i++;
j++;
} else {
j = next[j];
}
}
free(next);
if (j > lenT) {
return i - lenT; // 返回位置(从1开始)
}
return 0;
}
// 打印next数组
void printNext(const char *T) {
int len = strlen(T);
int *next = (int*)malloc((len + 1) * sizeof(int));
getNext(T, next, len);
printf("T = \"%s\"\n", T);
printf("j: ");
for (int i = 1; i <= len; i++) {
printf("%2d ", i);
}
printf("\nnext: ");
for (int i = 1; i <= len; i++) {
printf("%2d ", next[i]);
}
printf("\n\n");
free(next);
}
int main() {
// 测试next数组
printNext("abab");
printNext("abcabc");
printNext("aaaaa");
// 测试匹配
printf("--- 匹配测试 ---\n");
printf("'abcabd' 找 'abd': %d\n", kmp("abcabd", "abd"));
printf("'hello world' 找 'world': %d\n", kmp("hello world", "world"));
printf("'aaaaa' 找 'aaa': %d\n", kmp("aaaaa", "aaa"));
printf("'abc' 找 'def': %d\n", kmp("abc", "def"));
return 0;
}
运行结果:
text
T = "abab"
j: 1 2 3 4
next: 0 1 1 2
T = "abcabc"
j: 1 2 3 4 5 6
next: 0 1 1 1 2 3
T = "aaaaa"
j: 1 2 3 4 5
next: 0 1 2 3 4
--- 匹配测试 ---
'abcabd' 找 'abd': 4
'hello world' 找 'world': 7
'aaaaa' 找 'aaa': 1
'abc' 找 'def': 0
五、nextval数组(优化)
5.1 next数组的问题
当 T[j] == T[next[j]] 时,匹配失败后跳转到next[j],但该位置的字符和当前字符相同,仍然会失败,造成额外比较。
例如 T = "aaaab":
text
j=4, T[4]=a, next[4]=3, T[3]=a,相同
匹配失败后:j=4 → j=3 → j=2 → j=1,连续跳转多次
5.2 nextval优化
递归地优化:如果 T[j] == T[next[j]],则 nextval[j] = nextval[next[j]]。
计算nextval:
text
nextval[1] = 0
for j = 2 to len:
if T[j] == T[next[j]]:
nextval[j] = nextval[next[j]]
else:
nextval[j] = next[j]
5.3 代码实现
c
void getNextval(const char *T, int *nextval, int len) {
int i = 1, j = 0;
nextval[1] = 0;
while (i < len) {
if (j == 0 || T[i-1] == T[j-1]) {
i++;
j++;
if (T[i-1] != T[j-1]) {
nextval[i] = j;
} else {
nextval[i] = nextval[j];
}
} else {
j = nextval[j];
}
}
}
六、复杂度分析
| 算法 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| BF | O(n×m) | O(1) |
| KMP | O(n+m) | O(m)(next数组) |
KMP的优越性在大规模文本匹配中非常明显。例如:
-
主串长度 n=1,000,000
-
模式串长度 m=1,000
-
BF最坏需要 10^9 次比较
-
KMP只需要约 1,001,000 次比较
七、KMP与BF的对比
| 对比项 | BF算法 | KMP算法 |
|---|---|---|
| 主串指针 | 会回溯 | 不回溯 |
| 模式串指针 | 重置为1 | 跳转到next[j] |
| 预处理 | 无 | 计算next数组 |
| 时间复杂度 | O(n×m) | O(n+m) |
| 空间复杂度 | O(1) | O(m) |
| 适用场景 | 小规模、简单匹配 | 大规模、多次匹配 |
八、小结
这一篇我们学习了KMP算法:
| 要点 | 说明 |
|---|---|
| 核心思想 | 主串指针不回溯,利用部分匹配信息移动模式串 |
| next数组 | 表示匹配失败时j应该跳到的位置 |
| next推导 | 递推公式,基于前缀和后缀的相等关系 |
| nextval优化 | 避免连续跳转到相同字符 |
| 时间复杂度 | O(n+m),比BF的O(n×m)高效很多 |
KMP的精髓:当匹配失败时,我们已经知道前面哪些字符是匹配的,利用这个信息跳过不必要的比较。
下一篇我们讲数组的压缩存储。
九、思考题
-
模式串
"abcaabca"的next数组是多少?手动推导一下。 -
KMP算法中,主串指针为什么不回溯?这样会不会漏掉可能的匹配?
-
如果模式串是
"abc",它的next数组是多少?匹配"abcabc"时,KMP和BF的比较次数分别是多少? -
什么时候用KMP比BF好?什么时候BF其实就够了?