从KMP到BM:文本编辑器查找算法的进化之路

引言

在学习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](即上一个最优长度):

  1. 若匹配s[i] == s[j],则 π[i] = j + 1(直接继承+1)

  2. 若失配s[i] != s[j],需要找次长的相等前后缀 长度 j'

    关键性质:由于 s[0...j-1] == s[i-j...i-1],那么次长解 j' 必须同时满足:

    • s[0...j-1] 的相等前后缀
    • 因此 j' = π[j-1]
  3. 链式回退 :若仍失配,继续取 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算法所要做到的。

  1. 构造匹配字符串

将模式串、分隔符、文本串连接起来:

\[\text{concat} = s + \# + t \]

其中 \(\#\) 是一个既不出现在 \(s\) 中也不出现在 \(t\) 中的分隔符

为什么需要分隔符

  • 防止 \(s\) 的后缀与 \(t\) 的前缀错误匹配
  • 确保前缀函数值在 \(s\) 和 \(t\) 的边界处不超过 \(|s|\)
  1. 前缀函数在匹配中的含义

对于构造好的字符串 \(\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|\)

  1. 匹配过程可视化

    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) 处出现

  2. 算法实现

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;
}
  1. 在线优化版本

如果已知前缀函数值不会超过特定值(即 \(|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
                   ↑
          先比较这里(从右向左)

这种"反向比较"的策略带来了两个巨大的优势:

  1. 早发现早跳跃:如果不匹配发生在模式串尾部,我们可以立即获得大量信息
  2. 利用文本内容:根据文本中实际出现的字符,决定跳跃距离

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算法)凭借其实际的高效性,成为了查找替换功能使用的算法。