字符串匹配算法全景:从BM到AC自动机的演进之路

字符串匹配是计算机科学中最基础也最重要的问题之一。从文本编辑器的查找替换,到搜索引擎的全文检索,再到网络安全中的入侵检测与敏感词过滤,字符串匹配算法无处不在。本文系统梳理从朴素匹配到 AC 自动机的完整算法演进脉络,深入分析各算法的设计思想、预处理策略与工程适用场景。

问题定义与分类

字符串匹配问题的形式化定义如下:给定文本串 T(长度为 n)和模式串 P(长度为 m),在 T 中查找 P 出现的所有位置。

根据模式串的数量,字符串匹配问题可分为两类:

分类 描述 典型算法
单模式匹配 在文本串中查找一个模式串 BF、BM、Horspool、Sunday、KMP、Rabin-Karp
多模式匹配 在文本串中同时查找多个模式串 AC 自动机、Wu-Manber

单模式匹配算法又可按匹配方向进一步划分:

  • 前缀匹配(从左到右):KMP
  • 后缀匹配(从右到左):BM、Horspool
  • 特定方向优化:Sunday(从左到右匹配,但利用窗口后一位字符跳转)
  • 基于哈希:Rabin-Karp(不依赖字符逐一比较)

理解这一分类体系,是掌握各算法设计动机的前提。

朴素匹配算法(Brute Force)

朴素匹配是最直观的策略:将模式串与文本串逐位对齐,逐字符比较。一旦某个位置失配,模式串整体右移一位,重新从头比较。

文本串 T:  A B C A B C A B D
模式串 P:  A B C A B D
                     ↑ 失配

文本串 T:  A B C A B C A B D
模式串 P:    A B C A B D
             ↑ 失配

... 逐位右移,重复比较

其核心逻辑可以用伪代码表示:

BruteForce(T, P):
    for i = 0 to n - m:
        for j = 0 to m - 1:
            if T[i + j] != P[j]:
                break
        if j == m:
            report match at position i

复杂度分析:最坏情况下,每次比较 m 个字符后失配,共需比较 (n - m + 1) 次,时间复杂度为 O(n * m)。典型的最坏用例是 T = "AAAAAAAAB"、P = "AAAAB",每次比较到最后一位才失配。

朴素算法的核心缺陷在于:失配后丢弃了所有已匹配的信息,导致大量冗余比较。后续的所有优化算法,本质上都在解决同一个问题——如何利用已知信息,在失配时尽可能多地跳过无效比较。

基于后缀匹配的算法族

BM 算法:坏字符规则与好后缀规则

Boyer-Moore(BM)算法由 Robert S. Boyer 和 J Strother Moore 于 1977 年提出,是实际工程中应用最广泛的单模式匹配算法之一。Unix/Linux 系统中的 grep 命令在内部实现中即采用了 BM 算法的变体。

BM 算法的核心设计思想有两点:从右到左比较(后缀匹配),以及通过两条跳转规则最大化移动距离

匹配方向

与朴素算法从左到右逐字符比较不同,BM 将模式串与文本串对齐后,从模式串的末尾开始向左比较。这一设计的直觉来源是:如果文本串中某个字符完全不出现在模式串中,从右侧发现这一点后可以直接跳过整个模式串长度,而从左侧发现则只能跳过一位。

坏字符规则(Bad Character Rule)

当从右向左比较过程中,文本串中某个字符 c 与模式串中对应位置的字符不匹配时,该字符 c 即为"坏字符"。此时按以下策略决定移动距离:

  1. 字符 c 不在模式串中出现:模式串直接跳过整个窗口,移动距离为当前比较位置到模式串起始位置的距离加一。
  2. 字符 c 在模式串中出现:将模式串向右滑动,使模式串中最右侧的字符 c 与文本串中的坏字符对齐。
文本串: ... X C B A B ...
模式串:     A B C A B
                ↑ 比较位置 j=2, T 中字符为 'B'
                  'B' 在模式串中最右出现位置为 j=4
                  → 但此时对齐会导致左移,取 1

坏字符规则实质:skip = j - last_occurrence(c)
若 skip <= 0,则至少移动 1 位

预处理阶段需要构建 MakeSkip 表(坏字符表),记录字符表中每个字符在模式串中最后一次出现的位置。未出现的字符标记为 -1。时间复杂度为 O(|Sigma| + m),其中 |Sigma| 为字符集大小。

好后缀规则(Good Suffix Rule)

当从右到左比较的过程中,已经有若干字符匹配成功(形成"好后缀"),但在某个位置失配时,好后缀规则提供了另一种跳转策略。设好后缀为 t,则有三种情况:

情况一:模式串中存在另一个子串等于好后缀 t。 将模式串右移,使该子串与好后缀在文本串中的位置对齐。需要注意的是,该子串的前一个字符必须与好后缀前的字符不同,否则移动后仍会在同一位置失配。

模式串: C A B C A B
好后缀:       A B
              ↑ 模式串中 CAB 的 AB 可以对齐
移动后: ... C A B C A B

情况二:模式串中没有完整的子串匹配好后缀,但模式串的某个前缀等于好后缀的某个后缀。 此时将模式串右移,使该前缀与好后缀的对应后缀对齐。

模式串: A B C D A B
好后缀:     D A B
模式串前缀 AB = 好后缀后缀 AB
→ 将模式串的前缀 AB 与好后缀的后缀 AB 对齐

情况三:模式串中既无子串匹配好后缀,前缀也无法匹配好后缀的任何后缀。 此时模式串直接移动 m 位。

预处理阶段需要构建 MakeShift 表(好后缀表),记录每种好后缀情况下的移动距离。该表的构建较为复杂,通常借助后缀数组或前缀函数辅助完成,时间复杂度为 O(m)。

BM 的移动策略

每次失配时,BM 算法同时计算坏字符规则和好后缀规则给出的移动距离,取二者中的较大值作为实际移动距离。这是 BM 算法高效的关键——两条规则互相补充,确保了在各种情况下都能获得尽可能大的跳转。

复杂度分析

场景 时间复杂度 说明
最好情况 O(n / (m + 1)) 每次比较第一个字符就跳过整个模式串
最坏情况 O(n * m) 退化为朴素匹配,如 T = "AAAA..."、P = "AAA"
平均情况 亚线性 实际文本中表现优异,通常远快于 O(n)

BM 算法在处理自然语言文本时尤为高效,因为坏字符规则在字符集较大时跳转距离更长。

Horspool 算法:BM 的工程化简化

Horspool 算法(1980)是 BM 算法的简化版本。它的核心观察是:BM 的好后缀规则实现复杂,而在实际应用中,坏字符规则已经能提供足够好的跳转效果。因此 Horspool 直接舍弃了好后缀规则,仅保留并改进了坏字符规则。

Horspool 的改进在于:始终以当前匹配窗口中文本串最末尾的字符作为坏字符参考,而非 BM 中以实际失配位置的字符作为参考。无论在哪个位置失配,都用窗口最右侧对应的文本字符来查表决定移动距离。

文本串: ... A B C D E F ...
模式串:     X X X X
                  ↑ 无论在哪失配,都以 D(窗口最末字符)查表

预处理:构建一个移动距离表,记录每个字符在模式串中距离最右端的距离。对于模式串 P[0..m-1]:

shift[c] = m                           (c 不在 P[0..m-2] 中出现)
shift[c] = m - 1 - max{j : P[j] = c, 0 <= j <= m-2}  (c 在 P[0..m-2] 中出现)

注意最后一个字符 P[m-1] 不参与计算,因为它就是窗口末尾本身。

示例:模式串 P = "BARBER",m = 6

字符 B A R E 其他
shift 1 4 3 2 6

B 在位置 0 和 4 出现(排除最后位置 5),最右为位置 4,shift = 6 - 1 - 4 = 1。

Horspool 算法的最坏时间复杂度仍为 O(n * m),但在实际应用中的平均性能与 BM 非常接近,且实现简洁得多,是许多工程场景下的首选。

Sunday 算法:面向实际场景的进一步优化

Sunday 算法由 Daniel M. Sunday 于 1990 年提出,从设计理念上看,它走了一条与 BM/Horspool 不同的路径:匹配方向从左到右(与朴素算法一致),但在失配时利用了一个独特的观察。

Sunday 的核心思想:当匹配失败时,不关注失配位置本身,而是关注文本串中参与当前匹配窗口的最末位字符的下一位字符,即 T[i + m](i 为当前窗口起始位置)。

判断逻辑:

  1. T[i + m] 不在模式串中出现:模式串直接跳过 m + 1 位(因为包含该字符的任何对齐都不可能匹配)。
  2. T[i + m] 在模式串中出现:将模式串向右移动,使模式串中最右侧的该字符与 T[i + m] 对齐。
文本串: A B C E D A B C D
模式串: A B C D
            ↑ 失配(C != D)
关注 T[i+m] = T[4] = 'E'
'E' 不在模式串中 → 跳过 m+1 = 5 位

文本串: A B C E D A B C D
模式串:           A B C D
                  → 匹配成功

预处理:与 Horspool 类似,构建一个移动距离表。不同的是,Sunday 的表需要包含模式串的最后一个字符(因为参考的是窗口外的下一个字符):

shift[c] = m + 1                       (c 不在 P 中出现)
shift[c] = m - max{j : P[j] = c, 0 <= j <= m-1}  (c 在 P 中出现)

Sunday 算法的优势在于最大跳转距离为 m + 1(比 BM 的 m 还多一位),且实现极其简洁。在短模式串和字符集较大的场景下(如英文文本搜索),Sunday 通常表现最优。但在最坏情况下(如二进制文本中搜索重复模式),其复杂度同样退化为 O(n * m)。

后缀匹配算法族小结

算法 匹配方向 跳转依据 预处理复杂度 实现复杂度
BM 右→左 坏字符 + 好后缀 O(|Sigma| + m) 较高
Horspool 右→左 窗口末尾字符 O(|Sigma| + m)
Sunday 左→右 窗口下一位字符 O(|Sigma| + m) 最低

三者的共性是:都通过预处理模式串,在失配时尽可能地跳过更多的比较位置。差异在于跳转参考字符的选取策略和实现复杂度之间的权衡。

基于前缀匹配的算法

KMP 算法:部分匹配表与无回溯匹配

Knuth-Morris-Pratt(KMP)算法由 Donald Knuth、James Morris 和 Vaughan Pratt 于 1977 年提出,与 BM 算法同年发表,但走了一条完全不同的技术路线。KMP 的设计目标非常明确:文本串指针永远不回溯

在朴素算法中,每次失配后文本串指针要退回到本次匹配开始位置的下一位。KMP 的核心洞察是:当在位置 j 处失配时,模式串的前 j 个字符(P[0..j-1])已经与文本串匹配成功。如果 P[0..j-1] 自身存在"前缀等于后缀"的结构,那么可以直接将模式串滑动到该前缀位置继续比较,而无需回退文本串指针。

next 数组(部分匹配表)

next 数组(也称失败函数或部分匹配表)是 KMP 算法的核心数据结构。对于模式串 P,next[j] 的含义是:P[0..j-1] 这个子串中,最长的"真前缀等于真后缀"的长度。

以模式串 P = "ABCABD" 为例:

j 子串 最长公共前后缀 next[j]
0 "" -1(特殊标记)
1 "A" 0
2 "AB" 0
3 "ABC" 0
4 "ABCA" "A" 1
5 "ABCAB" "AB" 2

next 数组的构建过程

next 数组的构建本身就是一次"模式串对自身的匹配"过程,其思想与 KMP 匹配过程完全一致:

BuildNext(P):
    m = length(P)
    next[0] = -1
    j = 0, k = -1
    while j < m - 1:
        if k == -1 or P[j] == P[k]:
            j++, k++
            next[j] = k
        else:
            k = next[k]    // 利用已计算的 next 值回溯

这段逻辑的关键在于 k = next[k] 这一步。当 P[j] 与 P[k] 不匹配时,不是简单地将 k 重置为 0,而是利用 next 数组已经计算好的部分,跳转到下一个可能匹配的位置。这正是 KMP 思想在预处理阶段的自我应用。

构建过程的时间复杂度为 O(m),因为 j 单调递增且 k 的回退次数有上界。

匹配过程

KMP_Match(T, P):
    i = 0, j = 0
    while i < n and j < m:
        if j == -1 or T[i] == P[j]:
            i++, j++
        else:
            j = next[j]    // 文本串指针 i 不回溯
    if j == m:
        report match at position i - m

匹配过程中,文本串指针 i 始终向前移动,永不回溯。每次失配时,仅调整模式串指针 j 到 next[j] 的位置,相当于将模式串向右滑动 j - next[j] 位。

复杂度分析

阶段 时间复杂度 空间复杂度
预处理(构建 next) O(m) O(m)
匹配 O(n) -
总计 O(n + m) O(m)

KMP 的时间复杂度是严格的 O(n + m),不存在退化为 O(n * m) 的最坏情况,这是它相对于 BM 系列算法的理论优势。然而在实际工程中,BM 及其变体在大字符集文本上的平均表现往往优于 KMP,因为 BM 的跳转距离通常更大。

next 数组的优化

标准 next 数组存在一个可优化的场景:当 P[j] 失配后跳转到 next[j] = k,若 P[k] == P[j],则在位置 k 必然还会失配,这次跳转是浪费的。优化版本在构建时做如下修正:

if P[j] == P[k]:
    next[j] = next[k]    // 跳过必然失败的比较
else:
    next[j] = k

该优化不改变渐进复杂度,但能减少实际比较次数。

基于哈希的匹配

Rabin-Karp 算法:滚动哈希

Rabin-Karp(RK)算法由 Michael Rabin 和 Richard Karp 于 1987 年提出,采用了一种与上述算法截然不同的思路:不逐字符比较,而是比较哈希值

基本思想

将模式串 P 计算出一个哈希值 h(P),然后对文本串 T 中每个长度为 m 的子串计算哈希值,与 h(P) 比较。若哈希值相等,再逐字符验证以排除哈希冲突。

朴素地实现这一思想,每个子串的哈希计算需要 O(m) 时间,总复杂度仍为 O(n * m),并无改善。RK 算法的精妙之处在于滚动哈希(Rolling Hash)。

滚动哈希

选取一个基数 d(通常取字符集大小)和一个素数 q(用于取模防止溢出),定义哈希函数:

h(S[i..i+m-1]) = (S[i] * d^(m-1) + S[i+1] * d^(m-2) + ... + S[i+m-1]) mod q

当窗口从 T[i..i+m-1] 滑动到 T[i+1..i+m] 时,新哈希值可以通过 O(1) 的算术运算从旧值递推得出:

h(T[i+1..i+m]) = (d * (h(T[i..i+m-1]) - T[i] * d^(m-1)) + T[i+m]) mod q

即:移除最高位字符的贡献,整体左移一位(乘以 d),加上新进入窗口的字符。整个过程仅涉及常数次乘法、加法和取模运算。

哈希冲突处理

当 h(T[i..i+m-1]) == h(P) 时,存在两种可能:真正匹配,或哈希冲突。因此必须进行逐字符验证。选择合适的素数 q 可以降低冲突概率。在理论分析中,若 q 足够大且随机选取,冲突概率为 O(1/q)。

复杂度分析

场景 时间复杂度 说明
期望情况 O(n + m) 冲突次数少,验证开销可忽略
最坏情况 O(n * m) 所有窗口哈希均冲突(如 q 选择不当)

RK 的独特优势

RK 算法在单模式匹配中并不比 BM/KMP 更优,但它有一个独特的应用场景:多模式串的同时匹配。当需要在文本中同时搜索 k 个等长的模式串时,可以将所有模式串的哈希值存入哈希表,每次窗口滑动后查表比较,时间复杂度为 O(n + k * m),远优于逐一匹配。此外,RK 算法天然适合二维模式匹配(在矩阵中搜索子矩阵)等扩展场景。

多模式匹配

AC 自动机:Trie + 失败指针

前面讨论的算法都针对单模式匹配问题。当需要在一个文本串中同时搜索多个模式串时(如敏感词过滤需要同时检测数千个关键词),逐一应用单模式算法的效率极低。Aho-Corasick(AC)自动机正是为解决这一问题而设计的。

AC 自动机由 Alfred Aho 和 Margaret Corasick 于 1975 年提出,是多模式匹配的经典算法。其核心思想是将多个模式串构建为一个有限状态自动机,使文本串只需扫描一遍即可找到所有模式串的所有出现位置。

三个核心函数

AC 自动机由三个函数协同工作:

1. goto 函数(转移函数)

goto 函数本质上是一棵 Trie 树(字典树),由所有模式串构建而成。Trie 的每条边代表一个字符,从根节点到某个节点的路径表示某个模式串的前缀。

以模式串集合 {"he", "she", "his", "hers"} 为例,构建的 Trie 结构如下:

        root
       /    \
      h      s
     / \      \
    e   i      h
    |   |      |
    r   s      e
    |
    s

goto(s, c) 表示在状态 s 下输入字符 c 后转移到的下一个状态。若当前状态没有字符 c 对应的子节点,则 goto 返回失败(fail)。根节点是特殊的:对于根节点,任何不匹配的字符都转移回根节点本身(而非失败),这保证了自动机始终可以继续运行。

2. failure 函数(失败指针)

failure 函数是 AC 自动机的精髓所在,其设计理念与 KMP 的 next 数组一脉相承。当在某个状态 s 下无法通过 goto 函数继续前进时,failure(s) 指向另一个状态 s',使得从根到 s' 的路径所代表的字符串是从根到 s 的路径所代表的字符串的最长真后缀,且 s' 是 Trie 中的一个有效状态。

直观理解:failure 指针利用了"已匹配部分的后缀可能是另一个模式串的前缀"这一信息,避免了文本串指针的回溯。

以上例为例,部分 failure 指针:

  • 状态 "sh" 的 failure 指向状态 "h"(因为 "h" 是 "sh" 的最长真后缀且在 Trie 中存在)
  • 状态 "she" 的 failure 指向状态 "he"(因为 "he" 是 "she" 的最长真后缀且在 Trie 中存在)

3. output 函数(输出函数)

output(s) 记录在状态 s 处可以报告的所有匹配模式串。一个状态可能对应多个输出,因为到达某个状态时,不仅该状态本身可能对应一个完整的模式串,其 failure 链上的祖先状态也可能对应完整的模式串。

例如,到达状态 "she" 时,output 不仅包含 "she",还需沿 failure 链检查:failure("she") = "he",而 "he" 也是一个完整模式串,因此 output("she") = {"she", "he"}。

构建过程

AC 自动机的构建分为两个阶段:

阶段一:构建 Trie 树(goto 函数 + 初始 output 函数)

将所有模式串逐一插入 Trie 树。每插入一个完整的模式串后,在其终止节点的 output 集合中记录该模式串。时间复杂度为 O(所有模式串总长度之和)。

阶段二:BFS 构建 failure 指针(同时完善 output 函数)

使用广度优先搜索(BFS)逐层计算 failure 指针:

BuildFailure():
    queue = new Queue()

    // 第一层节点的 failure 指向根
    for each child c of root:
        failure(c) = root
        queue.enqueue(c)

    // BFS 逐层构建
    while queue is not empty:
        current = queue.dequeue()
        for each child node via character a:
            // 核心逻辑:沿 failure 链找可行转移
            state = failure(current)
            while state != root and goto(state, a) == fail:
                state = failure(state)
            failure(child) = goto(state, a)   // 若 goto(root, a) 也 fail,则为 root

            // 合并 output:当前节点的 output 需包含 failure 指向节点的 output
            output(child) = output(child) ∪ output(failure(child))

            queue.enqueue(child)

BFS 保证了在计算某个节点的 failure 指针时,其所有更浅层节点的 failure 指针已经计算完成。合并 output 的操作保证了匹配过程中不会遗漏任何模式串。

匹配过程

AC_Match(T):
    state = root
    for i = 0 to n - 1:
        while state != root and goto(state, T[i]) == fail:
            state = failure(state)
        state = goto(state, T[i])
        if state == fail:
            state = root

        // 报告当前状态的所有匹配
        temp = state
        while temp != root:
            if output(temp) is not empty:
                report output(temp) at position i
            temp = failure(temp)

文本串的每个字符只被读取一次,指针始终向前。对于每个位置,沿 failure 链检查所有可能的匹配输出。

复杂度分析

阶段 时间复杂度 说明
构建 Trie O(L) L 为所有模式串总长度之和
构建 failure O(L) BFS 遍历所有节点
匹配 O(n + z) n 为文本长度,z 为匹配结果总数
空间 O(L * |Sigma|) 可通过链表或哈希优化

工程应用场景

AC 自动机在工业界有广泛的应用:

  • 敏感词过滤:将敏感词库构建为 AC 自动机,对用户输入文本进行一次扫描即可检出所有敏感词。典型的敏感词库包含数万个词条,使用 AC 自动机可以在毫秒级完成检测。
  • 病毒特征码扫描:杀毒软件将病毒特征码库构建为 AC 自动机,扫描文件时一次遍历即可匹配所有已知特征。
  • 网络入侵检测系统(IDS):如 Snort,利用 AC 自动机在网络数据包中实时检测攻击特征。
  • 搜索引擎:对查询词进行多关键词高亮标注。
  • DNA 序列分析:在基因组序列中搜索多个目标片段。

算法选型指南

字符串匹配算法的选型不存在"银弹",需要根据具体场景的特征进行权衡:

复杂度对比

算法 预处理时间 匹配时间(最优) 匹配时间(最差) 空间 适用场景
Brute Force O(n) O(n * m) O(1) 极短模式串、一次性匹配
BM O(|Sigma| + m) O(n / m) O(n * m) O(|Sigma| + m) 长文本、大字符集
Horspool O(|Sigma| + m) O(n / m) O(n * m) O(|Sigma|) BM 的工程简化替代
Sunday O(|Sigma| + m) O(n / (m+1)) O(n * m) O(|Sigma|) 短模式串、交互式搜索
KMP O(m) O(n) O(n) O(m) 需要最坏保证的场景
Rabin-Karp O(m) O(n) O(n * m) O(1) 多模式等长匹配、二维匹配
AC 自动机 O(L) O(n + z) O(n + z) O(L * |Sigma|) 多模式匹配

场景决策路径

单模式短文本(m 较小,n 较小):朴素算法或 Sunday 算法。预处理开销在短文本上不划算,Sunday 的实现简单且跳转距离大。

单模式长文本(大字符集,如自然语言):BM 算法或 Horspool 算法。大字符集意味着坏字符规则的跳转距离更大,BM 系列算法的亚线性性能优势最为明显。Linux 的 grep 正是基于这一判断选择了 BM 变体。

单模式长文本(小字符集,如 DNA 序列):KMP 算法。小字符集下 BM 系列的跳转距离有限,KMP 的 O(n) 最坏保证更有价值。

需要严格最坏复杂度保证:KMP 算法。在安全相关场景中(如正则表达式引擎的实现),算法的最坏性能不可接受为 O(n * m),此时 KMP 是唯一的选择。

多个等长模式串:Rabin-Karp 算法。通过哈希表存储所有模式串的哈希值,一次滚动扫描即可完成。

大规模多模式匹配:AC 自动机。模式串数量从几十到数万,AC 自动机的匹配时间与模式串数量无关(仅与文本长度和匹配结果数相关),是唯一可行的方案。

工程实践中的补充考量

算法选择不仅取决于渐进复杂度,还需考虑以下工程因素:

  • 缓存友好性:BM/Horspool 的跳跃式访问模式对 CPU 缓存不太友好,KMP 的顺序访问模式在某些场景下可能因缓存命中率更高而表现更好。
  • 预处理开销:如果文本很短或匹配只执行一次,预处理的固定开销可能超过它带来的收益。此时朴素算法反而最优。
  • 实现复杂度与可维护性:BM 的完整实现(包括好后缀规则)相当复杂,Horspool 和 Sunday 在牺牲极少理论性能的前提下大幅降低了实现难度。
  • 并行化潜力:Rabin-Karp 的哈希计算具有天然的可并行性,适合 GPU 或 SIMD 加速。

字符串匹配算法的演进史,实质上是一部信息利用效率的进化史。从朴素算法的"零信息利用",到 KMP/BM 的"利用已匹配字符信息",再到 AC 自动机的"利用多模式串间的共享前缀信息",每一步跨越都建立在对问题结构更深入的理解之上。理解这些算法的设计思想,远比记忆它们的具体实现更有价值。

加载导航中...

评论