引言
在学习KMP算法后,我不免想到KMP算法能在哪一个地方使用,KMP作为字符串匹配算法,我很容易想到了他的用武之地,那就是文本编辑器中的查找功能,然而,对于主流的文本编辑器而言,似乎没有多少使用KMP算法用于查找功能构建,而他们使用了一个更为高效的算法:BM算法。
一、KMP算法
1.1 暴力匹配
在理解KMP之前,先看最直观的暴力匹配法:
假设文本串 T = "ABABABC",模式串 P = "ABABC"。
暴力匹配会在每个位置尝试对齐,一旦发现不匹配,就将模式串向右移动一位,重新开始比较。最坏情况下时间复杂度为 O(m×n)(m为文本长度,n为模式长度)。
1.2 KMP的核心思想
KMP算法的革命性在于:利用已匹配的信息,避免主串指针回退。
当匹配失败时,模式串不是简单地右移一位,而是根据预先计算的部分匹配表(Partial Match Table,也叫next数组),直接跳到下一个可能匹配的位置。
文本串:A B A B A B C
模式串:A B A B C
↑
这里失配(A ≠ C)
暴力法:回退到文本串第2位,模式串回到开头
KMP: 根据next数组,模式串右移2位,保持文本串指针不动
1.3 next数组的构建
next数组记录的是模式串中最长相等前后缀的长度:
| 模式串P | A | B | A | B | C |
|---|---|---|---|---|---|
| 位置j | 0 | 1 | 2 | 3 | 4 |
| next[j] | 0 | 0 | 1 | 2 | 0 |
暴力构建
我们的目标是求出每个子串的最长相等前后缀的长度,那么对于每个子串我们直接设立两个指针,一个从第二个字符开始,一个从倒数第二个字符开始,分别向后和向前扫,找到最长的相等前后缀。
优化一:利用相邻位置的单调性约束(O(n³) → O(n²))
观察到 :相邻的前缀函数值至多增加1,即 π[i+1] ≤ π[i] + 1
原理:
- 若
π[i+1]要取最大值,必须满足s[i+1] == s[π[i]](新字符等于已匹配前缀的下一个字符) - 此时
π[i+1] = π[i] + 1 - 若不满足,则
π[i+1]必然小于π[i]
优化效果:
- 内层循环的搜索范围从
[i, 0]缩小到[π[i-1]+1, 0] - 字符串比较次数上限从O(n²)降至O(n),总复杂度变为 O(n²)
优化二:利用已计算的π值进行链式跳转(O(n²) → O(n))
观察到 :失配时,次优解长度等于 π[π[i]-1]
原理 :
当在位置 i 计算 π[i] 时,设 j = π[i-1](即上一个最优长度):
-
若匹配 :
s[i] == s[j],则π[i] = j + 1(直接继承+1) -
若失配 :
s[i] != s[j],需要找次长的相等前后缀 长度j'关键性质:由于
s[0...j-1] == s[i-j...i-1],那么次长解j'必须同时满足:- 是
s[0...j-1]的相等前后缀 - 因此
j' = π[j-1]
- 是
-
链式回退 :若仍失配,继续取
j = π[j-1],直到j = 0
状态转移方程:
j(n) = π[j(n-1) - 1], 其中 j(n-1) > 0
构建方法:
cpp
int j = 0;
for (int i=1;i<s.size();i++){
while (j>0 and s[i] != s[j])j = kmp[j-1];
if (s[i] == s[j]) j++;
kmp[i] = j;
}
1.4 KMP算法的实现
在获得了已计算好的前缀函数后,我们就可以避免在失配时从头比较,从而实现 \(O(n+m)\) 的线性匹配,这就是kmp算法所要做到的。
- 构造匹配字符串
将模式串、分隔符、文本串连接起来:
\[\text{concat} = s + \# + t \]
其中 \(\#\) 是一个既不出现在 \(s\) 中也不出现在 \(t\) 中的分隔符。
为什么需要分隔符:
- 防止 \(s\) 的后缀与 \(t\) 的前缀错误匹配
- 确保前缀函数值在 \(s\) 和 \(t\) 的边界处不超过 \(|s|\)
- 前缀函数在匹配中的含义
对于构造好的字符串 \(\text{concat}\),计算其前缀函数 \(\pi\)。
观察到 :考虑 \(\pi\) 数组在 \(t\) 对应部分(即下标 \([n+1, n+m]\))的值:
- \(\pi[i]\) 表示:以位置 \(i\) 结尾的后缀中,与 \(s\) 的前缀相等的最长子串长度
- 若 \(\pi[i] = n\)(模式串长度),意味着 \(s\) 完整出现在位置 \(i\)
下标换算:
- 位置 \(i\) 是在 \(\text{concat}\) 中的下标
- 在 \(t\) 中的实际起始位置为:\(i - (n-1) - (n+1) = i - 2n\)
即:出现位置 = \(i - 2 \times |s|\)
-
匹配过程可视化
concat = s + # + t
↓ ↓ ↓
[0..n-1] [n] [n+1..n+m]当 π[i] = n 时(i 在 t 的部分):
s[0..n-1] 完全匹配到 t[i-n-(n+1)+1 .. i-(n+1)] = t[i-2n .. i-n-1]即 s 在 t 的位置 (i - 2n) 处出现
-
算法实现
cpp
vector<int> find_occurrences(string text,string pattern){
string cur = pattern + '#' + text;
int sz1 = text.size(), sz2 = pattern.size();
vector<int> pi = prefix_function(cur);
vector<int> occurrences;
for (int i = sz2 + 1; i <= sz1 + sz2; i++) {
if (pi[i] == sz2) {
occurrences.push_back(i - 2 * sz2);
}
}
return occurrences;
}
- 在线优化版本
如果已知前缀函数值不会超过特定值(即 \(|s|\)),可以不存储整个字符串 ,只保留 \(s+\#\) 部分,逐个读入 \(t\) 的字符并实时计算。
cpp
int j = 0;
for (int i=0;i<t.size();i++){
while (j>0 and t[i] != s[j]) j = kmp[j-1];
if (t[i] == s[j]) j++;
if (j == s.size()){
cout<<i-j+1<<endl;
}
}
1.5 KMP的时间复杂度
- 预处理 :构建next数组需要 O(n)
- 匹配过程 :文本串指针从不回退,O(m)
- 总复杂度 :O(m + n)
二、KMP的局限性:为什么还不够快?
尽管KMP在理论上已经很优雅,但在实际工程应用(如文本编辑器)中,它并非最优选择。原因如下:
2.1 比较顺序的"保守"
KMP采用从左到右的顺序比较字符。这意味着:
文本串:... X X X X X X X X X X X X X A ...
模式串:A A A A A A A A A A A A A B
↑
失配点
在这个例子中,KMP需要比较13个'A'后才发现最后一个字符是'B'不匹配。虽然利用next数组可以避免文本串回退,但已经做的比较都是"无用功"------我们本可以更早发现不匹配。
2.2 对字符集规模的忽视
KMP只关注模式串自身的结构(通过next数组),却忽略了文本串的实际内容。它没有利用"字符不匹配"这一信息来做出更大的跳跃。
2.3 自然语言限制
KMP算法主要对存在多个重复不完整片段的字符串进行优化,然而,自然语言中这种情况的出现过于苛刻,因此,其复杂度其实相较暴力并没有什么提升,因此并不是作为文本查找算法的最优解。
三、BM算法
针对KMP的局限性,1977年Robert S. Boyer和J. Strother Moore提出了Boyer-Moore算法。它彻底改变了字符串匹配的思路,成为现代文本编辑器的首选算法。
3.1 核心思想:从右向左比较
BM最大的创新是从模式串的末尾开始比较,这种比较方式提供了许多不错的特性,利用这些特性,我们可以最大化跳跃不可能匹配的情况:
文本串:H E R E I S A S I M P L E E X A M P L E
模式串:E X A M P L E
↑
先比较这里(从右向左)
这种"反向比较"的策略带来了两个巨大的优势:
- 早发现早跳跃:如果不匹配发生在模式串尾部,我们可以立即获得大量信息
- 利用文本内容:根据文本中实际出现的字符,决定跳跃距离
3.2 坏字符规则(Bad Character Rule)
当文本串中的字符 c 与模式串对应位置不匹配时:
注意这里的字符
c指的不是特定字符,而是一个变量
情况1 :如果 c 根本不在模式串中,模式串可以直接跳过这个字符:
文本串:... A B C D E F G ...
模式串: T H I N G
↑
比较E与G,不匹配,且E不在"THING"中
结果: 模式串直接右移5位(模式串长度)
情况2 :如果 c 在模式串中存在,将模式串中最右侧的 c 对齐到当前位置:
文本串:... A B C D E F G ...
模式串: T H E N G
↑
比较E与G,不匹配,但E在"THENG"中存在(位置1)
结果: 右移3位,将模式串中的E对齐到文本串的E
3.3 好后缀规则(Good Suffix Rule)
这是BM的第二个启发式规则,与KMP的思想类似,但应用在模式串的后缀上:
当已经匹配了模式串的一个后缀,但在前一个字符失配时:
文本串:... A B C D E F G H I J ...
模式串: F G H I J K G H I J
↑
失配(F ≠ K)
但已匹配"GHIJ"(好后缀)
规则:在模式串中寻找另一个出现该后缀的位置,且前一个字符不同,然后对齐。
如果找不到完整后缀,则寻找最长匹配的前缀。
可以看到,BM算法经常能实现多字符跳跃,而不是KMP的逐个移动。
3.4 BM的简化代码
cpp
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1000005;
const int MAXM = 100005;
const int SIGMA = 256;
char T[MAXN], P[MAXM];
int n, m, delta1[SIGMA], delta2[MAXM], ans[MAXN];
// Z函数 O(n)
vector<int> z_function(const string &s) {
int n = s.size();
vector<int> z(n);
for (int i = 1, l = 0, r = 0; i < n; i++) {
if (i <= r) z[i] = min(r - i + 1, z[i - l]);
while (i + z[i] < n && s[z[i]] == s[i + z[i]]) z[i]++;
if (i + z[i] - 1 > r) l = i, r = i + z[i] - 1;
}
return z;
}
inline void build_delta1() {
for (int i = 0; i < SIGMA; i++) delta1[i] = m;
for (int i = 0; i < m - 1; i++)
delta1[(unsigned char)P[i]] = m - 1 - i;
}
// O(m) 好后缀表,使用Z函数计算suff数组
inline void build_delta2() {
// 计算suff[i]: 以i结尾的后缀与模式串前缀的最长匹配
vector<int> suff(m);
string revP(P, m);
reverse(revP.begin(), revP.end());
vector<int> z = z_function(revP);
for (int i = 0; i < m; i++)
suff[i] = (i == m - 1) ? m : z[m - 2 - i]; // 注意下标映射
// 初始化
for (int i = 0; i < m; i++) delta2[i] = m;
// 情况2: 好后缀的后缀是模式串前缀
// 即 P[0..k-1] = P[m-k..m-1],找最小的k使得失配位置j满足条件
int j = 0;
for (int i = m - 1; i >= 0; i--) {
if (suff[i] == i + 1) { // P[0..i]是P的后缀
while (j < m - 1 - i) {
delta2[j] = m - 1 - i;
j++;
}
}
}
// 情况1: 好后缀在模式串中还有其他出现(利用suff)
for (int i = 0; i < m - 1; i++) {
int pos = m - 1 - suff[i];
delta2[pos] = min(delta2[pos], m - 1 - i);
}
}
inline int bm_search() {
build_delta1();
build_delta2();
int cnt = 0, i = 0;
while (i <= n - m) {
int j = m - 1;
while (j >= 0 && P[j] == T[i + j]) j--;
if (j < 0) {
ans[cnt++] = i;
i += m > 1 ? delta2[0] : 1;
} else {
int bc = delta1[(unsigned char)T[i + j]] - (m - 1 - j);
if (bc < 1) bc = 1;
int gs = (j < m - 1) ? delta2[j] : m;
i += max(bc, gs);
}
}
return cnt;
}
signed main() {
ios::sync_with_stdio(0);
cin.tie(0);
cin >> T >> P;
n = strlen(T);
m = strlen(P);
int cnt = bm_search();
cout << cnt << "\n";
for (int i = 0; i < cnt; i++)
cout << ans[i] << " \n"[i == cnt - 1];
return 0;
}
四、算法对比
| 特性 | KMP | BM |
|---|---|---|
| 比较方向 | 从左到右 | 从右到左 |
| 最佳情况 | O(m) | O(n/m)(亚线性!) |
| 最坏情况 | O(m+n) | O(m×n)(但极少发生) |
| 实际平均 | O(m+n) | O(n/m) ~ O(n) |
| 预处理 | O(n) | O(n + σ)(σ为字符集大小) |
| 适用场景 | 二进制数据、小字符集 | 自然语言、大字符集 |
五、总结
从KMP到BM,字符串匹配算法的发展体现了算法设计的核心哲学:利用一切可利用的信息。
- KMP 利用的是模式串自身的结构信息(next数组),消除了主串指针的回退,是理论上的突破。
- BM 则更进一步,同时利用模式串结构 和文本串内容 (坏字符),通过反向比较策略实现了工程上的飞跃,与此同时BM还有许多更好的优化方式,能够最大化的跳过不可能的部分,因此在工程上经常被采用。
对于现代文本编辑器而言,BM及其变种(如Horspool简化版、Sunday算法)凭借其实际的高效性,成为了查找替换功能使用的算法。