KMP 算法
思想
KMP 算法, 全程 Knuth-Morris-Pratt 算法, 是一种高效的字符串匹配算法. 它的核心思想是:
在匹配过程中, 当发生文本串(text)与模式串(pattern)不匹配时, 能够利用已匹配过的部分信息, 智能地移动模式串, 从而避免从头开始匹配, 达到提高匹配效率的目的.
KMP算法的巧妙之处在于:
它认为, 当发生不匹配时, 将模式串仅仅右移一位是"愚蠢"的. 当某个地方不匹配时, 表明前面实际上有部分内容已经匹配了. 如果直接从头开始匹配, 就浪费了这些信息. 有时前面已经匹配部分会有"重复"的性质, 可以利用这种性质, 让子串一次多移动几步, 从而加速匹配速度.
举例 主串S和模式串P
S = BBC ABCDAB ABCDABCDABDE
P = ABCDABD
当匹配到下面这种情况时:
S = BBC ABCDAB ABCDABCDABDE
P = ABCDABD
这里的 D 和上面空格不匹配, 此时保理匹配就直接向右移动一位, 但是通过观察已经匹配的部分 “ABCDAB” 可以发现, 该部分有相同的前后缀 “AB”, 因此可以直接将子串的 “A” 对准主串中后缀 “AB” 里面的 “A”:
S = BBC ABCDAB ABCDABCDABDE
P = ABCDABD
此时, S 的指针不用改变, 将 P 的指针回到 “C” 的位置就行了. 通过这种方法, 大大提升了效率.
这里的思想就是: 如果已匹配部分有相同的最大前后缀, 那么当发生不匹配时, 可以直接将已匹配部分的, 模式串的前缀对准主串的后缀, 对应 j = next[j]
(或者有的地方是 j = next[j - 1]
), 就可以一次多移动几步, 而不是朴素算法的一步.
此外, KMP 不仅是利用了 “最大相同前后缀” 来移动多步模式串的思想, 即使在模式串没有任何 “最大相同前后缀” 的时候, 其时间复杂度仍然为 O(m + n)
看下面这个例子:
文本: X Y C D E F G
模式: X Y Z
这里 Z 和 C 不匹配, 朴素算法会将 i 回溯到 Y, j 回溯到 X:
文本: X Y C D E F G
模式: X Y Z
而 KMP 算法此时的 next = [0, 0, 0]
, 因此将 j 回溯到 X (j=0), 而 i 不变, 因为它知道前面已匹配的部分中, 没有任何可以利用的前后缀信息, 因此可以一次移动很多步
文本: X Y C D E F G
模式: X Y Z
再来看一个已匹配部分的前缀有公共前后缀的例子:
文本: A B A C B ...
模式: A B A C D
这里发现 D 不匹配, 已匹配部分为 “ABAC”, 其没有任何公共前后缀, 虽然 “ABA” 有公共前后缀 “A”, 但是这个信息在这里并没有用, 因为我们知道"AB" 和 “AC” 是不匹配的, 因此不用移动成这样:
文本: A B A C B ...
模式: A B A C D
而是直接移动成这样
文本: A B A C B ...
模式: A B A C D
所以, KMP 的思想不仅仅是利用了 “最大公共前后缀” 来加速匹配速度, 同时也利用了, 当没有 “公共前后缀” 的情况加速匹配速度的思想. 在 KMP 中, 主串的指针 i 是永不回溯的, 要回溯的只有模式串的指针 j.
实现
PM (Partial Match) 表
PM 表是"部分匹配表", 用于构造 next 数组, 对于子串 P = "ABCDABD"
为例, 计算其 PM 表:
- “A”: 前后缀集和都为空, 最大前后缀长度为 0.
- “AB”: 前缀为{“A”}, 后缀为{“B”}, 无相等的, 也为 0.
- “ABC”: 前缀为{“A”, “AB”}, 后缀为{“C”, “BC”}, 也没有相等的, 长度为 0.
- “ABCD”: 前缀为{“A”, “AB”, “ABC”}, 后缀为{“D”, “CD”, “BCD”}. 没有相等的, 长度为 0.
- “ABCDA”: 前缀为{“A”, “AB”, “ABC”, “ABCD”}, 后缀为{“A”, “DA”, “CDA”, “BCDA”}, 最长前后缀为 “A”, 长度为 1.
- “ABCDAB”: 前缀为{“A”, “AB”, …}, 后缀为{“B”, “AB”, …}, 最长相等的是 “AB”, 长度为 2.
- “ABCDABD”: 前缀为{“A”, …}, 后缀为{“D”, …}, 没有相等的, 长度为 0.
故得到模式串 P = "ABCDABD"
的 PM 表为 [0, 0, 0, 0, 1, 2, 0]
next 数组
next 数组就是在代码中实际使用的数组, 一般有两种方法, 当 P[j] 匹配失败的时候, 会使用 j = next[j]
或者 j = next[j - 1]
的方法来进行回溯, 下面介绍第一种:
0. PM 表为 [0, 0, 0, 0, 1, 2, 0]
- 将 PM 表向右移动一位:
[_, 0, 0, 0, 0, 1, 2]
- 在开头补上 -1:
[-1, 0, 0, 0, 0, 1, 2]
next[0] = 0
LPS 数组法: KMP 匹配失败时j = next[j - 1]
void getNext_standard(string& P, vector<int>& next) {
int m = P.length();
next.resize(m);
next[0] = 0; // 单个字符没有前后缀
int j = 0; // j 记录最长前后缀的长度
// i 从 1 开始遍历模式串
for (int i = 1; i < m; ++i) {
// 如果当前字符不匹配, j 回溯
// j > 0 防止越界
while (j > 0 && P[i] != P[j]) {
// j 回溯到上一个子串最长相同前后缀的长度
j = next[j - 1];
}
// 如果匹配, j + 1
if (P[i] == P[j]) {
++j;
}
}
}
next[0] = -1
哨兵法: KMP 匹配失败时j = next[j]
void getNext_sentinel(const std::string& P, std::vector<int>& next) {
int m = P.length();
next.resize(m);
// next[0] 设置为 -1 作为哨兵
next[0] = -1;
// i 遍历模式串,j 记录最长相等前后缀长度
// 注意这里 j 的初始值是 -1,与 next[0] 对应
int i = 0, j = -1;
// 循环直到遍历完模式串
while (i < m - 1) {
// 当 j 为 -1 或当前字符匹配时,i 和 j 同时向后移动
if (j == -1 || P[i] == P[j]) {
i++;
j++;
// 更新 next[i]
next[i] = j;
} else {
// 当字符不匹配时,j 回溯到 next[j]
j = next[j];
}
}
}
nextval 数组
next 数组已经很高效了, 但还可以进一步优化为 nextval 数组, 考虑一下情况:
S: A A A B <- i
P: A A A A <- j
这时 P[3] 和 S[3] 不匹配, 根据 P 的 next 数组 [-1, 0, 1, 2, 3]
可知下一次匹配应该是这样:
S: A A A B
P: A A A A
^
j
这时候发现, 之前的 P[3] 和现在 j 回退后指向的 P[2] 都是 “A”, 因此肯定不匹配, 故应该继续回退.
这里就是 nextval 数组的优化, 也是唯一和 next 数组不同的地方:
当 P[j] == P[next[j]]
的时候, 这回退是没有意义的, 因此要继续回退, 直到 P[k] 和 P[j] 不同的位置 k
- 在 next 数组基础上实现
// 基于 next 数组计算 nextval 数组
void getNextval(const string& P, vector<int>& nextval) {
int m = P.length();
vector<int> next(m);
getNext(P, next); // 首先计算 next 数组
nextval.resize(m);
nextval[0] = 0; // nextval[0] 同样为 0
for (int i = 1; i < m; ++i) {
// 如果 P[i] 等于 P[next[i] - 1],说明当前最长相等前后缀的下一个字符与当前字符相同
if (next[i] > 0 && P[i] == P[next[i] - 1]) {
// 回溯到 next[i] 的 nextval 值
nextval[i] = nextval[next[i] - 1];
} else {
// 否则,nextval[i] 就等于 next[i]
nextval[i] = next[i];
}
}
}
- 直接计算 nextval 数组
void getNextval(const string& P, vector<int>& nextval) {
int m = P.length();
nextval.resize(m);
// j 记录最长相等前后缀的长度
int j = 0;
// nextval[0] 始终为 0
nextval[0] = 0;
// i 从 1 开始遍历模式串
for (int i = 1; i < m; ++i) {
// 如果当前字符不匹配,j 回溯
while (j > 0 && P[i] != P[j]) {
// 注意:这里利用 nextval 数组自身进行回溯
j = nextval[j - 1];
}
// 如果字符匹配,j 增加 1
if (P[i] == P[j]) {
j++;
}
// 更新 nextval[i]
// 关键逻辑: 如果 P[i] 和 P[j] 相等,那么 nextval[i] 的值等于 nextval[j]
// 否则, nextval[i] 等于 j
if (j > 0 && P[i] == P[j]) { // 与 next 区别点
nextval[i] = nextval[j];
} else {
nextval[i] = j;
}
}
}
KMP In Action
下面使用 c++ 代码来完成 KMP 算法
#include <iostream>
#include <vector>
#include <string>
using namespace std;
// KMP 算法中的 next 数组计算 (常规版本)
void getNext(const string& P, vector<int>& next) {
int m = P.length();
next.resize(m);
int j = 0;
next[0] = 0;
for (int i = 1; i < m; ++i) {
while (j > 0 && P[i] != P[j]) {
j = next[j - 1];
}
if (P[i] == P[j]) {
j++;
}
next[i] = j;
}
}
// 完整的 KMP 匹配算法
int KMP_standard(const string& T, const string& P) {
int n = T.length();
int m = P.length();
if (m == 0) return 0;
// 计算 next 数组
vector<int> next;
getNext(P, next);
int i = 0; // 主串指针
int j = 0; // 模式串指针
while (i < n) {
// 如果字符匹配,两个指针同时后移
if (T[i] == P[j]) {
i++;
j++;
}
// 匹配成功,返回起始位置
if (j == m) {
// j = next[j-1]; // 如果需要查找所有匹配,则继续
return i - j;
}
// 匹配失败
if (i < n && T[i] != P[j]) {
// 如果 j > 0, 说明模式串可以回溯
if (j > 0) {
j = next[j - 1];
} else {
// 如果 j 已经是 0, 说明无法回溯, 主串指针后移
i++;
}
}
}
return -1; // 匹配失败
}
int main() {
string text = "ABABABABCABABABABD";
string pattern = "ABABABD";
cout << "主串: " << text << endl;
cout << "模式串: " << pattern << endl;
int pos = KMP_standard(text, pattern);
if (pos != -1) {
cout << "使用标准 next 数组, 匹配成功, 起始位置: " << pos << endl;
} else {
cout << "使用标准 next 数组, 匹配失败" << endl;
}
return 0;
}