相关笔记

概览

本节研究两种基于有限自动机思想的字符串匹配方法。32.3节介绍如何为模式串 构造一个确定性有限自动机(DFA),利用状态转移函数 时间内扫描文本 完成匹配,但预处理代价为 32.4节介绍 Knuth-Morris-Pratt(KMP)算法,通过前缀函数 (又称 failure function)巧妙地避免显式构建完整的DFA转移表,将预处理降至 ,匹配保持 ,总时间 。KMP算法是字符串匹配领域的里程碑成果,由 Knuth、Morris 和 Pratt 三位科学家于1977年联合提出。

知识结构总览

flowchart TD
    A["字符串匹配问题<br/>文本 T[1..n], 模式 P[1..m]"] --> B["32.3 基于有限自动机的匹配"]
    A --> C["32.4 KMP算法"]

    B --> B1["构建DFA"]
    B1 --> B2["状态集合 Q = {0,1,...,m}"]
    B1 --> B3["转移函数 δ(q,a)"]
    B1 --> B4["终态 = m"]
    B2 --> B5["COMPUTE-TRANSITION-FUNCTION<br/>O(m³|Σ|)"]
    B3 --> B5
    B4 --> B5
    B5 --> B6["扫描文本 O(n)"]

    C --> C1["前缀函数 π[q]"]
    C1 --> C2["COMPUTE-PREFIX-FUNCTION<br/>O(m)"]
    C2 --> C3["KMP-MATCHER<br/>O(n)"]
    C3 --> C4["总时间 O(m+n)"]

    B6 --> D["匹配结果:所有合法位移 s"]
    C4 --> D

    C1 -.->|"π函数等价于<br/>压缩的转移函数"| B3

核心思想

32.3 字符串匹配的有限自动机

32.3.1 有限自动机的基本概念

确定性有限自动机(DFA) 是一个五元组 ,其中:

  • 有限状态集合
  • 初始状态
  • 接受状态集合(终态集合)
  • 输入字母表
  • 状态转移函数

对于字符串匹配问题,我们为模式 构造一个特殊的DFA,使得该自动机在扫描文本 的过程中,能够识别出 中的所有出现位置。

32.3.2 字符串匹配自动机的构造

状态集合,共 个状态。

  • 状态 的含义:当前已经成功匹配了模式 的前 个字符,即 是已扫描文本后缀的最长前缀匹配。
  • 状态 :初始状态,尚未匹配任何字符。
  • 状态 终态(接受状态),表示完整的模式 已被匹配。

状态转移函数 的定义:

其中 表示模式 的长度为 的前缀(即 ), 表示字符串 最长后缀同时也是 前缀的长度。

状态转移函数的直觉】:当自动机处于状态 (已匹配 )并读入字符 时,需要确定新的匹配长度。如果 ,则匹配长度增加到 ;如果 ,则需要”回退”——找到 的最长后缀,使得该后缀同时也是 的前缀。这个回退过程本质上就是在寻找模式自身的自相似结构

终态识别:当自动机到达状态 时,说明模式 的全部 个字符都已被成功匹配,此时报告一次匹配成功。

32.3.3 COMPUTE-TRANSITION-FUNCTION 伪代码

COMPUTE-TRANSITION-FUNCTION(P, Σ)
1  m ← length[P]
2  for q ← 0 to m
3      for each character a ∈ Σ
4          k ← min(m, q + 1)
5          while k > 0 AND P[1..k] ≠ P[q-k+2..q]a
                // P[1..k] 不是 P[q-k+2..q]a 的后缀
6              k ← k - 1
7          δ(q, a) ← k
8  return δ

执行流程图:

flowchart TD
    A(["开始"]) --> B["输入 P, Σ"]
    B --> C["m ← length[P]"]
    C --> D["q ← 0"]
    D --> E{"q ≤ m?"}
    E -->|是| F["取下一个字符 a ∈ Σ"]
    F --> G{"还有字符 a?"}
    G -->|是| H["k ← min(m, q+1)"]
    H --> I{"k > 0 且<br/>P[1..k] ≠ P[q-k+2..q]a?"}
    I -->|是| J["k ← k - 1"]
    J --> I
    I -->|否| K["δ(q, a) ← k"]
    K --> G
    G -->|否| L["q ← q + 1"]
    L --> E
    E -->|否| M["返回 δ"]
    M --> N(["结束"])

逐行解析

  • 第1行:获取模式 的长度
  • 第2行:遍历所有状态
  • 第3行:对每个状态,遍历字母表 中的所有字符
  • 第4行:初始化 ,这是可能的匹配长度的上界——最多匹配到 个字符(前 个加上新字符 ),但不能超过模式长度
  • 第5-6行:逐步减小 ,直到找到最大的 使得 等于 的后缀。条件 的含义是:模式的前 个字符不等于”当前已匹配的 个字符中去掉前面部分后再加上新字符 “所形成的字符串的后缀。
  • 第7行:将转移结果记录到 中。

32.3.4 预处理时间复杂度分析

定理:COMPUTE-TRANSITION-FUNCTION 的运行时间为

证明思路

  • 外层循环遍历 个状态,内层循环遍历 个字符,共 次迭代。
  • 每次迭代中,while 循环最多执行 次( 递减到 )。
  • 每次比较 需要比较 个字符,代价为
  • 因此每次迭代代价为 ,总代价为

关键结论(预处理代价高昂)】:虽然匹配阶段只需 时间,但预处理阶段 的代价在实际应用中非常昂贵,特别是当字母表 很大时(例如 Unicode 字符集)。这正是 KMP 算法要解决的核心问题。

32.3.5 有限自动机匹配过程

FINITE-AUTOMATON-MATCHER(T, δ, m)
1  n ← length[T]
2  q ← 0
3  for i ← 1 to n
4      q ← δ(q, T[i])
5      if q = m
6          print "Pattern occurs with shift" i - m

匹配过程非常简洁:从初始状态 开始,逐个读取文本字符,根据转移函数更新状态。一旦到达终态 ,就报告一次匹配。整个匹配过程只遍历文本一次,时间为

32.3.6 逐步执行实例

:设模式 ,字母表

我们构造部分状态转移表

状态
0100
1120
2300
3140
4500
5146
6700
7120

部分转移的计算过程

  • 的最长后缀同时也是 的前缀的长度为 )。
  • 不是 的前缀,长度为
  • ,最长后缀同时也是 前缀的是 (长度 )。
  • 正好是 ,长度为
  • 正好是 ,长度为
  • ,没有任何非空前缀匹配。
  • ,最长后缀同时也是 前缀的是 (长度 )。
  • 正好是 ,长度为
  • 正好是 ,长度为
  • ,最长后缀同时也是 前缀的是 (长度 )。
  • ,最长后缀同时也是 前缀的是 (长度 )。
  • 正好是 ,长度为
  • 正好是 ,长度为 (终态)。

状态转移图(文字描述):

状态0 --a--> 状态1 --b--> 状态2 --a--> 状态3 --b--> 状态4
                                                              |
状态1 <--a-- 状态3                                    a      v
  |                  状态0 <--b-- 状态2            状态5 --c--> 状态6
  |                      |                          ^            |
  b                      c                          b            a
  v                      v                          |            v
状态0                  状态0                    状态4      状态7(终态)
                                                    ^            |
                                                    |            a
                                              状态5 --b--+

匹配实例:设文本

步骤 状态 (转移后)说明
0-0初始状态
1a1
2b2
3a3
4b4
5a5
6b4,回退!
7a5
8c6
9a7,匹配成功!

匹配位移 ,即 中从位置 开始出现(位移 ,CLRS 使用 -indexed 位移)。


32.4 Knuth-Morris-Pratt 算法

32.4.1 从有限自动机到 KMP 的动机

32.3节的有限自动机方法虽然匹配阶段高效(),但预处理代价高达 。关键观察是:大多数转移 在实际匹配中根本不会被用到。KMP 算法的核心洞察是:我们不需要预先计算所有 对的转移,只需要在匹配过程中按需计算那些真正需要的转移。

进一步分析发现,当匹配失败时(即读入字符 ),状态回退只取决于模式 自身的结构,与当前读入的具体字符无关。因此,我们可以预计算一个前缀函数 ,记录当状态 匹配失败时应回退到哪个状态。

32.4.2 前缀函数 的定义

定义:对于模式 ,前缀函数 )定义为 最长真前缀的长度,该真前缀同时也是 后缀

用数学语言表达:

如果不存在这样的 ,则

前缀函数的直觉】:想象你在读一本重复章节的小说。当你读到第 章发现内容对不上时, 告诉你:往前回退多少章,可以从一个你已经熟悉的开头重新开始比对,而不需要从头来过。 本质上编码了模式串自身的自重复结构——哪些前缀会作为后缀再次出现。

,计算 函数:

1234567
ababaca
aababaababababaababacababaca
0012301

逐步计算过程

  • ,没有真前缀等于后缀。
  • ,真前缀 ,真后缀 ,无公共元素。
  • ,真前缀 ,真后缀 ,最长公共者为 ,长度
  • ,真前缀 ,真后缀 ,最长公共者为 ,长度
  • ,真前缀 ,真后缀 ,最长公共者为 ,长度
  • ,真前缀 ,真后缀 ,无公共元素。
  • ,真前缀 ,真后缀 ,最长公共者为 ,长度

32.4.3 COMPUTE-PREFIX-FUNCTION 伪代码

COMPUTE-PREFIX-FUNCTION(P)
1  m ← length[P]
2  π[1] ← 0
3  k ← 0
4  for q ← 2 to m
5      while k > 0 AND P[k+1] ≠ P[q]
6          k ← π[k]
7      if P[k+1] = P[q]
8          k ← k + 1
9      π[q] ← k
10 return π

执行流程图:

flowchart TD
    A(["开始"]) --> B["输入 P"]
    B --> C["m ← length[P]"]
    C --> D["π[1] ← 0, k ← 0"]
    D --> E["q ← 2"]
    E --> F{"q ≤ m?"}
    F -->|是| G{"k > 0 且<br/>P[k+1] ≠ P[q]?"}
    G -->|是| H["k ← π[k]"]
    H --> G
    G -->|否| I{"P[k+1] = P[q]?"}
    I -->|是| J["k ← k + 1"]
    I -->|否| K["保持 k 不变"]
    J --> L["π[q] ← k"]
    K --> L
    L --> M["q ← q + 1"]
    M --> F
    F -->|否| N["返回 π"]
    N --> O(["结束"])

逐行解析

  • 第1行:获取模式长度
  • 第2行,单个字符没有真前缀等于后缀的情况。
  • 第3行 表示当前已知的最长匹配前缀长度,初始化为
  • 第4行:从 开始逐个计算
  • 第5-6行核心回退机制。当 时,说明当前候选前缀无法扩展。此时利用 回退到更短的前缀继续尝试。这就像剥洋葱一样,一层一层地缩短候选前缀,直到找到能匹配的或者退到
  • 第7-8行:如果 ,说明当前候选前缀可以扩展一个字符, 增加
  • 第9行:记录 的值。

逐步执行实例

循环前 while 执行?循环后
2b0不执行(00
3a0不执行(11
4b1不执行(22
5a2不执行(33
6c3: , ; : , ; : 停止00
7a0不执行(11

最终

32.4.4 KMP-MATCHER 伪代码

KMP-MATCHER(T, P)
1  n ← length[T]
2  m ← length[P]
3  π ← COMPUTE-PREFIX-FUNCTION(P)
4  q ← 0                    // 已匹配的字符数
5  for i ← 1 to n           // 扫描文本
6      while q > 0 AND P[q+1] ≠ T[i]
7          q ← π[q]         // 利用前缀函数回退
8      if P[q+1] = T[i]
9          q ← q + 1        // 匹配成功,扩展
10     if q = m             // 整个模式已匹配
11         print "Pattern occurs with shift" i - m
12         q ← π[q]         // 继续寻找下一个匹配

执行流程图:

flowchart TD
    A(["开始"]) --> B["输入 T, P"]
    B --> C["n ← length[T]<br/>m ← length[P]"]
    C --> D["π ← COMPUTE-PREFIX-FUNCTION(P)"]
    D --> E["q ← 0, i ← 1"]
    E --> F{"i ≤ n?"}
    F -->|是| G{"q > 0 且<br/>P[q+1] ≠ T[i]?"}
    G -->|是| H["q ← π[q]"]
    H --> G
    G -->|否| I{"P[q+1] = T[i]?"}
    I -->|是| J["q ← q + 1"]
    I -->|否| K["保持 q 不变"]
    J --> L{"q = m?"}
    K --> L
    L -->|是| M["输出匹配位移 i - m"]
    M --> N["q ← π[q]"]
    N --> O["i ← i + 1"]
    L -->|否| O
    O --> F
    F -->|否| P(["结束"])

逐行解析

  • 第1-2行:获取文本和模式的长度。
  • 第3行:预处理阶段,计算前缀函数,时间
  • 第4行 为当前已匹配的字符数,初始为
  • 第5行:从左到右逐个扫描文本字符。
  • 第6-7行匹配失败时的回退。当 时,利用 回退到更短的匹配前缀。这个回退过程与 COMPUTE-PREFIX-FUNCTION 中的回退逻辑完全对称。
  • 第8-9行匹配成功时扩展。当前字符匹配, 增加
  • 第10-11行:如果 ,说明完整匹配,报告位移
  • 第12行:匹配成功后,利用 回退,继续寻找可能的下一个匹配(处理重叠匹配的情况)。

逐步执行实例

循环前 while 回退?循环后 输出
1a01-
2b12-
3a23-
4b34-
5a45-
6b5, ; , 停止4-
7a45-
8c56-
9a67shift 2

输出:Pattern occurs with shift 2(即 出现在 )。

回退细节(第6步):当 时,,执行 。此时 ,匹配成功, 增加到 。注意文本指针 始终没有回退——这正是 KMP 的精髓所在。

32.4.5 KMP 算法的正确性:循环不变式分析

引理(KMP-MATCHER 的循环不变式):在第5行 for 循环每次迭代开始时,,即 等于文本前缀 的最长后缀同时也是 的前缀的长度。

初始化:第一次迭代前, 为空串,其最长后缀同时也是 前缀的长度为 ,而 。不变式成立。

维持:假设第 次迭代开始时 成立。需要证明迭代结束后

  • while 循环(第6-7行)的作用:不断将 减小为 ,直到 。由 的定义,每次回退后 仍然是 的某个后缀同时也是 前缀的长度,只是越来越短。
  • if 判断(第8-9行):如果 ,则 既是 的后缀,也是 的前缀。此时 增加 ,恰好等于
  • 如果 ,则

关键结论(不变式维持)】:无论匹配成功还是失败,迭代结束后 始终成立。

终止:for 循环结束时 。在整个过程中,每当 时(第10行),说明 ,即 完整出现在 中,位移为

32.4.6 O(m+n) 线性时间证明

定理:KMP-MATCHER 的总运行时间为

证明

分两部分分析:

第一部分:COMPUTE-PREFIX-FUNCTION 的 时间

使用势能分析法。定义势函数 (第4行 for 循环第 次迭代开始时的 值)。

  • 的值始终满足
  • 第5-6行 while 循环每次执行使 至少减少 (因为 )。
  • 第8行使 最多增加
  • 在整个 for 循环中, 的总增加量不超过 次(每次迭代最多增加 ),而 不能为负数,因此 while 循环的总执行次数不超过 次。
  • 总时间 = (for 循环 次 + while 循环总共不超过 次)。

第二部分:KMP-MATCHER 的 匹配时间

同样使用势能分析法。定义势函数 (第5行 for 循环第 次迭代开始时的 值)。

  • 的值始终满足
  • 第6-7行 while 循环每次执行使 至少减少 (因为 )。
  • 第9行使 最多增加
  • 在整个 for 循环中, 的总增加量不超过 次,因此 while 循环的总执行次数不超过 次。
  • 总时间 =

关键结论(总线性时间)】:预处理 + 匹配 = 。KMP 算法在所有情况下都保证线性时间复杂度,不依赖于字母表大小

32.4.7 函数与有限自动机的关系

KMP 的前缀函数 与有限自动机的转移函数 之间存在深刻的联系:

命题:对于任意状态 和字符 ,如果 ,则 ;如果 ,则

直觉解释

  • 当匹配的下一个字符恰好是 时,状态自然前进到
  • 当匹配失败时,状态回退到 ,然后从回退后的状态继续尝试匹配字符 。这等价于在有限自动机中直接查询

推论 函数编码了DFA转移函数的压缩表示。KMP 算法在匹配过程中通过 函数按需恢复所需的转移,避免了预先计算完整的 转移表。这就是为什么 KMP 的预处理时间是 而不是

用生活类比来说:有限自动机方法像是提前为所有可能的岔路口都画好了地图(转移表),而 KMP 方法只记住了一个”回退规则”( 函数),走到死胡同时根据规则自动找到上一个岔路口重新出发。

KMP算法的历史渊源

KMP算法由 Donald E. Knuth、James H. Morris 和 Vaughan R. Pratt 三位计算机科学家于1977年在 SIAM Journal on Computing 上联合发表,论文题为 “Fast Pattern Matching in Strings”。该算法的提出源于一个实际问题:Knuth 在为文本编辑器实现正则表达式匹配时,发现朴素匹配在最坏情况下效率极低。三位作者独立地发现了相同的核心思想——利用模式串自身的重复结构来避免冗余比较。这篇论文被认为是字符串匹配领域最具影响力的工作之一,奠定了线性时间字符串匹配的理论基础。

参考链接:Fast Pattern Matching in Strings (SIAM, 1977)

前缀函数与Z函数的等价关系

前缀函数(prefix function, )和 Z函数(Z-function, )是字符串算法中两个核心工具,它们在表达能力上完全等价。Z函数 定义为字符串 从位置 开始的子串与 的最长公共前缀的长度。给定 函数可以在 时间内构造 函数,反之亦然。两者在字符串匹配、周期检测、字符串压缩等问题中可以互相替代。在实际应用中,Z函数的实现通常更简洁直观,而前缀函数在KMP算法的框架下更为自然。USACO Guide 提供了两者关系的详细对比和练习。

参考链接:String Searching - USACO Guide

有限自动机与Aho-Corasick多模式匹配

基于有限自动机的字符串匹配思想可以自然地推广到多模式匹配场景。Aho-Corasick 算法(1975)是这一推广的经典成果,它为多个模式串同时构建一个有限状态机(在 Trie 树的基础上添加 failure 指针),能够在 时间内找到文本中所有模式串的所有出现( 为匹配总数)。Aho-Corasick 算法可以看作是 KMP 算法向多模式情形的直接推广——KMP 的 函数对应 Aho-Corasick 中的 failure 指针。该算法广泛应用于入侵检测系统(IDS)、生物信息学和搜索引擎中。

参考链接:String-Matching with Automata (NYU, Mohri)

经典字符串匹配算法的性能对比

在实际应用中,KMP、Boyer-Moore 和 Rabin-Karp 三种算法各有优劣。Boyer-Moore 算法通过从右向左扫描模式串,利用”坏字符规则”和”好后缀规则”实现大跨度跳跃,在实际文本中通常比 KMP 更快(亚线性时间)。Rabin-Karp 算法基于滚动哈希,在多模式匹配场景下具有天然优势。KMP 的优势在于最坏情况保证——无论文本和模式的内容如何,KMP 始终在 时间内完成匹配。基准测试表明,在随机文本上 Boyer-Moore 通常快 3-5 倍,但在退化输入(如 )上 KMP 表现更稳定。

参考链接:String Matching Algorithm Analysis (GitHub)

前缀函数 的精确定义

最长真前缀(proper prefix,即长度严格小于 )的长度,该真前缀同时也是 后缀。注意两个关键限定词:

  • “真”:前缀长度必须严格小于 ,因此 恒成立。 不可能是 本身。
  • “最长”:如果有多个满足条件的前缀,取最长的一个。

常见错误是把 理解为”最长公共前后缀的长度”而忽略了”真前缀”的限制。例如对于 (真前缀 ),而不是

有限自动机预处理的高昂代价

COMPUTE-TRANSITION-FUNCTION 的时间复杂度为 。当字母表很大时(例如 ASCII 有 128 个字符,Unicode 有超过 14 万个字符),这个预处理代价在实际中是不可接受的。KMP 算法通过 函数巧妙地避免了显式构建 的完整转移表,将预处理降至 。这是 KMP 相对于朴素有限自动机方法的核心优势。在实际工程中,几乎不会直接使用完整的DFA转移表进行字符串匹配。

KMP 与 Boyer-Moore 的实际性能差异

虽然 KMP 保证最坏情况 的时间复杂度,但在实际应用中,Boyer-Moore 算法通常表现更好。原因在于 Boyer-Moore 从右向左扫描模式串,利用”坏字符规则”可以跳过大量不必要的比较,在英文文本等自然语言场景下平均只需检查 个字符。然而,Boyer-Moore 的最坏时间复杂度为 ,在退化输入(如重复字符)上可能表现很差。选择哪种算法取决于具体场景:需要最坏情况保证时选 KMP,追求实际平均性能时选 Boyer-Moore。

习题精选

题号题目描述难度
32.3-1为模式构造有限自动机状态转移表★★★
32.3-2模拟有限自动机匹配过程★★☆
32.4-1计算模式的前缀函数π★★★
32.4-5利用前缀函数高效计算转移函数★★★★

视频学习指南

视频来源标题时长覆盖内容推荐度
MIT 6.006Lecture 12: String Matching~75min有限自动机、KMP算法、Rabin-Karp★★★★★
Abudula(算法竞赛)KMP算法详解~40min前缀函数推导、代码实现、例题★★★★☆
KarpathyBuilding a Regex Engine~60min有限自动机在正则表达式中的应用★★★★☆
CP-AlgorithmsPrefix Function~25min 函数的严格证明与应用★★★★★
王道考研KMP算法~50minnext数组、匹配过程、考研题型★★★☆☆

教材原文

“We now show how to construct, in time, a table that allows the KMP matcher to run in time. The table is called the prefix function. The prefix function for a pattern encapsulates information about how the pattern matches against shifts of itself.”

“The KMP algorithm does not need to compute the full transition function . It uses the prefix function to compute the transition function on the fly, as needed during the matching process.”

“The key observation is that the information needed to compute is already contained in the prefix function. Specifically, if , then , and we can apply this identity repeatedly until we find a state where the transition is defined.”

—— CLRS, Introduction to Algorithms, 4th Edition, Section 32.4

参见Wiki


第32章-字符串匹配 KMP