第 05 讲

串:KMP 与 BM 模式匹配

串(字符串)是最贴近日常的线性结构,而「在一个大串里找一个子串」是编译器、编辑器、搜索引擎、 DNA 比对都在反复做的事。本章从最朴素的暴力匹配讲到 KMP 的 next 数组推导, 再讲到工程中更快、也更反直觉的 BM 算法。

预计 120 分钟 前置:第 02 讲线性表、第 01 讲大 O 关键词:模式匹配 · next 数组 · 坏字符 · 好后缀
本章导读
  • 5.1 串的定义、存储结构与基本操作 —— 打地基,尤其要弄清「子串、前缀、后缀」的严格定义。
  • 5.2 朴素模式匹配 —— 看懂它慢在哪里,才能明白 KMP 在优化什么。
  • 5.3 KMP 算法 —— 本章最关键next 数组的手推过程配有专门动画,一定要自己动手推一遍。
  • 5.4 KMP 的完整实现与优化 —— 含 nextval、两种下标约定、与其他题型的联系。
  • 5.5 BM 算法 —— 坏字符与好后缀两条规则,为什么它比 KMP 更快。
  • 5.6 三种匹配算法综合对比。
  • 5.7 工程视角 —— grep / 编辑器、数据库的 LIKE 与倒排索引、入侵检测与基因比对, 看看这三个算法在真实系统里到底被谁用、为什么这么选。
  • 5.8 本章小结、自测题与配套编程练习。

5.1 串是什么:定义与基本操作

5.1.1 定义与术语

串(String),又称字符串,是由零个或多个字符组成的有限序列,记作 S = "a1a2…an"。其中 n 称为串的长度, 长度为 0 的串称为空串(null string),记作 "" 或者 Ø

串在逻辑上就是一种特殊的线性表——它的数据元素被限定为字符。但串和普通线性表有一个重要差别: 我们关心的操作不是「取第 i 个元素」,而是整段整段的比较、查找、拼接。 这就导致了串有自己的一套专用术语:

子串
串中任意个连续字符组成的子序列,例如 "dat" 是 "data" 的子串;空串是任意串的子串。
真子串
不包含自身的子串。
主串
包含子串的那个串。若 TS 的子串,则 S 是主串。
串相等
两个串长度相同,且对应位置的字符全部相同。注意 "ab" 与 "ab " (多一个空格) 不相等。
模式串 / 目标串
模式匹配中,被查找的子串叫模式串 P(pattern),被搜索的大串叫目标串 / 主串 S
前缀
从第一个字符开始、长度为 k 的连续子串,记作 P[0..k-1]真前缀即 k < |P|。
后缀
以最后一个字符结尾、长度为 k 的连续子串,记作 P[|P|-k .. |P|-1]真后缀即 k < |P|。
最长相等真前后缀
一个串的某个真前缀恰好等于它的某个真后缀,其中最长的那一对的长度。KMP 的 next 数组就是它。
例:"ABAB" 的真前缀有 "A", "AB", "ABA",真后缀有 "B", "AB", "BAB",相等的最长者是 "AB",长度为 2。
易错:子串 vs 子序列 子串必须连续(如 "abc" 的子串 "bc");子序列只要求相对顺序不变、可以不连续 (如 "abc" 的子序列 "ac")。这是后面 KMP(连续匹配)与 LCS 最长公共子序列(不连续)的分水岭, 考试里非常爱考这两个概念的辨析。

5.1.2 串的存储结构

串有三种常见存储方式,各有取舍:

① 定长顺序存储(C 风格字符数组) d a t a \0 ← 末尾必须有结束标记 '\0',否则无法知道串有多长 优点:随机访问快、cache 友好;缺点:长度固定,拼接/插入可能溢出 ② 堆分配存储(C++ std::string,可动态扩容) 堆区:'d' 'a' 't' 'a' '\0' … ← 真正的字符放在堆上,串对象只保存指针 + 长度 + 容量 优点:长度可动态增长,不会溢出;缺点:需要额外管理内存,扩容时可能重新分配并拷贝 ③ 块链存储(链式串) "dat" "a" "str" 每个结点存若干字符(块大小可调),块间用指针串起来 优点:插入删除不用整体搬移;缺点:随机访问退化、指针开销大 实际工程里几乎只用 ①②;③ 主要出现在教材与特定场景(如超长文本编辑器的 rope 结构)
图 5-1 串的三种存储结构对比

5.1.3 串的基本操作(ADT)

串的抽象数据类型通常包含以下九种基本操作,请把它们的语义和复杂度一起记住:

操作语义朴素实现复杂度C++ 对应
StrAssign(&T, chars)赋值:把 chars 赋给 TO(|chars|)T = chars
StrCopy(&T, S)复制O(|S|)T = S
StrEmpty(S)判空O(1)S.empty()
StrCompare(S, T)比较:返回 <0 / =0 / >0(按字典序,从第一个不同字符处决定)O(min(|S|,|T|))S.compare(T)
StrLength(S)求长度O(1) 或 O(n)S.size()
Concat(&T, S1, S2)拼接O(|S1|+|S2|)T = S1 + S2
SubString(&Sub, S, pos, len)取子串O(len)S.substr(pos, len)
Index(S, T, pos)模式匹配:在 S 的第 pos 个字符起找 T 第一次出现的位置O(n×m) 或 O(n+m)S.find(T, pos)
Replace(&S, T, V)用 V 替换 S 中所有与 T 相等的子串与匹配算法有关regex_replace / 手写
StrInsert / StrDelete插入 / 删除子串O(n)insert / erase

下面是这些操作在 C++ 中的最小实现示例(顺序存储、0 基下标):

#include <iostream>
#include <string>
using namespace std;

/* 串的基本操作演示(C++ 的 std::string 就是"堆分配顺序存储"的一种工业实现) */
int main() {
    string s = "data";
    string t = "structure";

    cout << s.size() << "\n";                  // 4          求长度 O(1)
    cout << s + "-" + t << "\n";               // data-structure  拼接 O(n+m)
    cout << s.substr(1, 2) << "\n";            // at         取子串 O(len)
    cout << s.compare("datax") << "\n";        // -1(s 更小)比较 O(min)
    cout << (int)s.find("ta") << "\n";         // 2          模式匹配
    cout << (int)s.find("xyz") << "\n";        // -1         未找到返回 npos

    /* 手写 StrCompare:理解"字典序"到底怎么定的 */
    // 从第一个字符开始逐位比较,遇到第一处不同就返回差值;
    // 若前面全部相同,则短串更小。
    return 0;
}
考点:串比较的规则 StrCompare 的比较规则是:从第一个字符起逐位比较 ASCII 码,遇到第一个不等的字符, 谁大谁就大;如果一路相等,则长度短者小。所以 "abc" < "abd""ab" < "abc",而 "Z" < "a"(因为 'Z' 的 ASCII 是 90,'a' 是 97)。

5.2 朴素模式匹配:暴力法慢在哪

5.2.1 算法思路

模式匹配(pattern matching)问题可以严格描述为:给定主串 S(长度 n)与模式串 P(长度 m,通常 m ≤ n),求 PS 中第一次出现的位置(下标), 不存在则返回 -1。

最直觉的做法是枚举所有可能的对齐位置:让 P 的左端依次对齐 S 的 第 0、1、2、…、n−m 位,每个位置从 P[0] 开始逐字符比较,全部相同则匹配成功。这个方法叫 朴素匹配(Naive / Brute-Force / BF)

#include <iostream>
#include <string>
using namespace std;

/* 朴素模式匹配:返回 P 在 S 中第一次出现的下标,找不到返回 -1
   设 n = |S|, m = |P|
   最好:第一个位置就匹配           O(m)
   最坏:每轮都比到最后一个字符才失败  O((n-m+1)*m) ≈ O(n*m) */
int naiveIndex(const string& S, const string& P) {
    int n = S.size(), m = P.size();
    if (m == 0) return 0;
    for (int pos = 0; pos + m <= n; ++pos) {     // 枚举每一个对齐位置
        int j = 0;
        while (j < m && S[pos + j] == P[j]) ++j;   // 逐个字符比对
        if (j == m) return pos;                  // 全部相等 → 命中
    }
    return -1;
}

int main() {
    string S = "ABABABCABABABCABA";
    string P = "ABABCABA";
    cout << naiveIndex(S, P) << "\n";   // 2
    return 0;
}

5.2.2 亲手看它一步步怎么比

下面的动画用同一组主串与模式串演示朴素匹配的每一次比较,注意观察失配后指针是怎么动的:

5.2.3 复杂度分析:为什么慢

n = |S|m = |P|

问题根源:主串指针的回溯 朴素算法在失配时把 i 退回到本轮起点的下一位、j 归零, 于是刚刚已经比较过并且确认相同的那些字符被彻底浪费了。 以 S = "ABABABC..."P = "ABABCA" 为例: 在某个位置已经确认主串里出现过 "ABAB",失配后却从 S[pos+1] 重新开始比。
KMP 的全部思想就是一句话:利用已经匹配成功的这段信息,让 i 永不后退

5.3 KMP 算法与 next 数组的推导

5.3.1 一句话本质

KMP = 「主串指针 i 只前进不后退」 + 「失配时把模式串滑动到最长相等前后缀重新对齐」

KMP 由 Knuth、Morris、Pratt 三人于 1977 年提出,因此得名。它的核心洞察是:

假设我们已经匹配了 P[0..j-1] 这 j 个字符,主串中对应的那段就是 S[i-j .. i-1], 它恰好等于 P[0..j-1]。现在 S[i] ≠ P[j] 失配了。 朴素做法要放弃这段匹配重新开始,但既然主串里那段就等于 P[0..j-1], 我们完全可以问:P[0..j-1] 内部,有没有一个「开头」和「结尾」是一样的? 如果有,长度是 k,那么主串里那段匹配内容的最后 k 个字符,一定等于模式串的前 k 个字符。 于是我们不必移动 i,只需要把模式串向右滑,让 P[0..k-1] 对准主串刚才那段的后 k 个字符, 即令 j = k,继续比较。

主串 S A B A B C i 指向这里,S[i]='C' 与 P[4]='A' 失配 模式串 P A B A B 已匹配 j = 4 个字符 这一段 P[0..3] = "ABAB" 与主串完全相同 前缀 "AB" 后缀 "AB" 两者相等 → 长度 k = 2,于是让 j = 2,i 不动 滑动后 A B A B C P[0..1] 已经对齐好了,直接从 P[2] 继续比即可,i 不用回退 注意:模式串是"整体向右滑动 2 格",等价于 j 从 4 变成 2;主串指针 i 原地不动。
图 5-2 KMP 的核心思想:失配时利用最长相等前后缀重新对齐

5.3.2 next 数组的定义(两种约定一定要分清)

把「P[0..j-1] 的最长相等真前后缀长度」预先算好存进数组,失配时直接查表, 这个数组就是 next。但不同教材的下标约定不同,这是初学者最容易混乱的地方,务必记牢自己用的是哪一种:

约定数组含义失配时的动作典型出处
0 基 · 前缀函数 π
(本章推荐)
π[i] = P[0..i] 的最长相等真前后缀长度
取值范围 0 … i,恒有 π[0] = 0
P[j] 处失配 → j = (j == 0 ? j + 1, i++ : π[j-1])
j = π[j-1]
算法竞赛、std::string::find 类实现、CLRS 的 π 函数
1 基 · 教材版 next next[i] = P[1..i-1] 的最长相等真前后缀长度 + 1
next[1] = 0(人为约定)
P[j] 处失配 → j = next[j] 严蔚敏《数据结构》、国内考研 408 教材
位移版 · next 表示滑动量 失配时应把模式串整体右移的格数 模式串右移 next[j] 部分算法书与 KMP 原始论文的表述

三种约定算出来的数值不同,但算法行为完全等价。下面我们统一采用最直观的 0 基前缀函数 π 来讲解与实现,并在 5.4.3 节给出与教材版 next 的换算方法。

5.3.3 手推 next 数组的完整过程

π 的过程本身就是一次「模式串自己匹配自己」。最朴素的想法是:对每个 i, 从长到短枚举所有真前后缀,检查是否相等——那是 O(m³)。 正确做法是利用已经算出的 π[i-1] 递推

  1. len = π[i-1],即前 i 个字符的最长相等真前后缀长度。
  2. 现在想把这个长度扩展到 len+1,只需检查 P[i] 是否等于 P[len]: 相等则 π[i] = len + 1,收工。
  3. 若不等,说明以 len+1 结尾的前后缀不成立,那就退而求其次: 用一个更短的、但仍然相等的前后缀。这个更短的长度恰好是 π[len-1]—— 因为 P[0..len-1] 的最长相等真前后缀长度就是它。于是 len = π[len-1],回到第 2 步。
  4. len 一路退到 0 仍然 P[i] ≠ P[0],则 π[i] = 0

第 3 步是 KMP 最精妙的地方:回退不是回到起点重来,而是退到「次优的候选长度」。 下面用 P = "ABABAA" 完整演示这个过程,请对照动画逐帧理解「lena 是怎么退的」:

动手推一遍(强烈建议) 请拿一张纸,对 P = "ABCABD" 手推 π:
  1. π[0] = 0(长度为 1 的串没有真前后缀)。
  2. i=1:len = π[0] = 0,比较 P[1]='B'P[0]='A',不等 → π[1] = 0
  3. i=2:len = 0P[2]='C' vs P[0]='A',不等 → π[2] = 0
  4. i=3:len = 0P[3]='A' vs P[0]='A',相等 → len = 1π[3] = 1
  5. i=4:len = π[3] = 1P[4]='B' vs P[1]='B',相等 → len = 2π[4] = 2
  6. i=5:len = π[4] = 2P[5]='D' vs P[2]='C',不等 → 回退 len = π[1] = 0; 再比 P[5]='D' vs P[0]='A',仍不等 → π[5] = 0
最终 π = [0, 0, 0, 1, 2, 0]。能独立推对这个,KMP 就掌握一半了。

5.3.4 用「失配跳转图」把回退过程看死

在进入代码之前,先把 π 数组换一种读法——它会立刻变得好记得多。 把每个下标 j 想成一个状态:状态 j 表示「模式串的前 j 个字符已经匹配上了」。 那么 π[j-1] 就是:当你处在状态 j 却匹配不下去时,应该退回的那个状态

换句话说,π 数组描述了一张失配跳转图(failure link):每个状态连一条「退路」边指向一个更小的状态。 以 P = "ABABAC" 为例,π = [0, 0, 1, 2, 3, 0],跳转关系如下图:

失配跳转图:状态 j(已匹配 j 个字符)—— 实线箭头是「读入下一个字符」,虚线箭头是「失配回退」 0 1 2 3 4 5 6 匹配成功! ABA BAC π[0]=0 π[1]=0 π[2]=1 π[3]=2 π[4]=3 读法:从状态 5 读入 'C' 成功 → 5→6 匹配完成;若在状态 5 失配,则沿虚线退到状态 3,而不是退回 0 —— 这就是 KMP 省下的时间。
图 5-3 π 数组的另一种读法:一张「失配跳转图」(failure link)
这张图为什么值得单独画
  • 它把「回退」变成了「走一条边」:代码里的 while (j > 0 && S[i] != P[j]) j = pi[j-1]; 不过是「沿着虚线边一直退,直到能读入当前字符为止」。
  • 它解释了为什么总复杂度是 O(n+m):实线边(前进)一共只有 m 条,虚线边(回退)只会把状态变小, 所以沿虚线走的步数总和不超过沿实线走的步数总和。
  • 它就是 AC 自动机的雏形:把一条模式串的这种跳转图拼成一棵树、再加一层 BFS 求 fail 指针, 就得到能一次扫描匹配多个模式串的 AC 自动机。

5.3.5 KMP 匹配全过程

有了 π 数组,匹配过程就极其简单:i 只增不减;失配时若 j > 0j = π[j-1],否则 i++。下面的动画把 ij、 模式串的对齐位置、以及每次 j 的回退都标了出来:

最常见的写法错误:回退写成了 len-- 求 π 时,很多同学把「对不上就缩短候选长度」直觉地写成 --len 然后重试。这样结果是对的, 复杂度却退化成 O(m²)——因为它把「按前缀函数跳跃」换成了「一格一格地挪」。 正确的写法只有一种:len = pi[len - 1]。 判断自己有没有写错,看这一个用例就够:P = "aaaa…a"(10 万个 a), 正确写法是线性的、瞬间出结果,写成 len-- 会慢到跑不完。
考点:KMP 的比较次数 KMP 的比较次数是 O(n + m),但要注意——它并不保证「比较次数一定比朴素算法少」。 对于某些输入(例如模式串首字符几乎不出现在主串中),朴素算法反而可能比较得更少。 KMP 的价值在于最坏情况下也稳定是 O(n+m),不会退化到 O(n×m)。

5.3.6 教材版 next 数组怎么手算(考试标准做法)

国内教材(严蔚敏《数据结构》与考研 408)采用的约定是:模式串从下标 1 开始存放next[1] = 0 是人为规定的哨兵,而 next[j] = P[1..j-1] 的最长相等真前后缀长度 + 1(当 j ≥ 2 时)。

手算时最实用的方法是「看第 j 个字符前面的那一段」:把 P[1..j-1] 拿出来, 求它最长的「真前缀 = 真后缀」,长度记作 k,则 next[j] = k + 1。 以 P = "abaabc" 为例,逐个算一遍(下面的表格请自己动手再推一次, 与前面 0 基 π 的动画对照,你会发现两者其实是同一件事):

j123456
P[j] abaabc
P[1..j-1] (空)aababaabaaabaab
最长相等真前后缀 ——"a""a""ab"
长度 k ——00112
next[j] = k+1 011223

逐行解释第三列到第六列的推导:

两种约定对照表(同一个模式串的数值差异)
模式串 P = "abaabc"j=1j=2j=3j=4j=5j=6
0 基 π(本章实现用)001120
1 基 next(教材用)011223
位移版(右移格数)112233

换算关系:next[j] = π[j-2] + 1(j ≥ 2),next[1] = 0π[i] = next[i+2] - 1。注意上表中 0 基 π 的最后一个值对应 P[0..5] = "abaabc" 的最长相等真前后缀("ab" 是前缀但后缀是 "bc",不相等;后缀 "c"、前 "a" 不等),所以是 0。

5.3.7 用教材版 next 实现匹配

教材版代码与 0 基版只是「下标从 1 开始」和「回退写 j = next[j]」的差别, 逻辑完全一致,考试与作业里两种都要会写:

#include <iostream>
#include <string>
#include <vector>
using namespace std;

/* 教材版 KMP(1 基下标)
   S[1..n] 主串,P[1..m] 模式串,next[1..m]
   返回 P 在 S 中第一次出现的 1 基起始位置,找不到返回 0 */
void getNext(const string& P, vector<int>& nxt) {
    int m = P.size();                       /* P 的第 k 个字符是 P[k-1] */
    nxt.assign(m + 1, 0);
    nxt[1] = 0;                             /* 哨兵 */
    int i = 1, j = 0;
    while (i < m) {
        if (j == 0 || P[i - 1] == P[j - 1]) { ++i; ++j; nxt[i] = j; }
        else j = nxt[j];                    /* 注意是 nxt[j],不是 j-1 */
    }
}

int indexKMP(const string& S, const string& P, const vector<int>& nxt) {
    int n = S.size(), m = P.size();
    int i = 1, j = 1;
    while (i <= n && j <= m) {
        if (j == 0 || S[i - 1] == P[j - 1]) { ++i; ++j; }   /* j==0 说明退到哨兵,主串前进 */
        else j = nxt[j];                                    /* 只退 j,不退 i */
    }
    return (j > m) ? i - m : 0;             /* 返回 1 基起始位置 */
}

int main() {
    string S = "abaababaabcx", P = "abaabc";
    vector<int> nxt;
    getNext(P, nxt);
    cout << "next  = ";
    for (int k = 1; k <= (int)P.size(); ++k) cout << nxt[k] << ' ';
    cout << "\n";                            /* 0 1 1 2 2 3 */
    cout << indexKMP(S, P, nxt) << "\n";     /* 6(1 基)
        过程:匹配到 S[6]='b' 与 P[4]='a' 失配 → j = next[4] = 2(只退 j)
              继续比 S[6]='b' 与 P[2]='b' 相等 → 一路比到 j = 7 > m,返回 i-m = 11-6+1 = 6 */
    return 0;
}
易错:j = next[j] 还是 j = next[j-1] 两种约定的写法看着只差一个下标,但含义完全不同,混用会导致结果错误:
  • 1 基教材版:失配时 j = next[j],因为 next[j] 本身就带着「+1」的偏移。
  • 0 基 π 版:失配时 j = pi[j-1],因为 pi[i] 的定义里包含 P[i] 自身。
记不清时就用一个小例子现场验证:P = "aaab",在主串 "aaaab" 上跑一遍, 看回退后的下标是否落在「正确的最长相等前后缀长度」上。

5.3.8 什么时候用 KMP:匹配场景与朴素算法对比

前面说了 KMP 最坏是 O(n+m)、朴素是 O(n×m)。但在真实数据上,朴素算法并不总是慢。 下表给出几种典型输入下两个算法的比较次数感受(n = 1000,m = 10):

主串特征模式串朴素比较次数(约)KMP 比较次数(约)谁更快
随机小写字母普通单词≈ n × 1/(1−1/26) ≈ 1040≈ n + m = 1010基本相当
全 'a'"aaaab"≈ 5n = 5000≈ n = 1000KMP 快 5 倍
全 'a'"baaaa"≈ n = 1000≈ n = 1000(+ 预处理)朴素略快(无预处理)
周期串 "ababab…""ababc"≈ 5n/2 = 2500≈ 2n = 2000(且 i 不回退)KMP 更快且稳定
被刻意构造的恶意数据退化到 O(n×m)始终 O(n+m)KMP 有最坏保证

结论:KMP 的价值在于「最坏情况的上界」与「主串只扫描一遍」。 当主串是流式到达、不能回退(例如网络数据包逐字节到达)时,KMP 是唯一可行的线性算法; 而朴素算法需要回头重看数据,天然不适用于流式场景。

5.4 KMP 的完整实现与优化

5.4.1 0 基前缀函数版(推荐写法)

#include <iostream>
#include <string>
#include <vector>
using namespace std;

/* ============================================================
   KMP(0 基下标,π 数组约定)
   pi[i] = P[0..i] 的最长相等真前后缀长度
   ============================================================ */

/* 求前缀函数,时间复杂度 O(m),空间 O(m) */
vector<int> prefixFunction(const string& P) {
    int m = P.size();
    vector<int> pi(m, 0);
    for (int i = 1; i < m; ++i) {
        int len = pi[i - 1];                 // 先继承前一位的结果
        while (len > 0 && P[i] != P[len]) {  // 对不上就回退到次优候选长度
            len = pi[len - 1];
        }
        if (P[i] == P[len]) ++len;           // 能接上就 +1
        pi[i] = len;
    }
    return pi;
}

/* 返回 P 在 S 中第一次出现的下标,找不到返回 -1 */
int kmpSearch(const string& S, const string& P) {
    int n = S.size(), m = P.size();
    if (m == 0) return 0;
    vector<int> pi = prefixFunction(P);
    int j = 0;                               // j = 当前已匹配的字符个数
    for (int i = 0; i < n; ++i) {            // i 只前进,永不回退
        while (j > 0 && S[i] != P[j]) j = pi[j - 1];
        if (S[i] == P[j]) ++j;
        if (j == m) return i - m + 1;        // 全部匹配,返回起始下标
    }
    return -1;
}

int main() {
    string S = "ABABABCABABABCABA";
    string P = "ABABCABA";
    vector<int> pi = prefixFunction(P);
    for (int x : pi) cout << x << ' ';
    cout << "\n";                            // 0 0 1 2 0 1 2 3
    cout << kmpSearch(S, P) << "\n";         // 2
    return 0;
}

注意代码里的 while (j > 0 && S[i] != P[j]) j = pi[j - 1];。 为什么 j 回退的总次数是 O(n)?做个均摊分析:j 每次循环最多 +1, 在 n 次迭代中总共最多增加 n;而 j 每次回退至少减少 1,所以总回退次数不超过总增加次数, 也是 O(n)。因此整个匹配循环是线性的。

5.4.2 nextval:解掉「连续相同字符反复回退」

看一个极端例子:S = "aaaaaaab"P = "aaaaab"。 在最后一个 b 处失配后,j = π[4] = 4,接着比较 S[i]P[4] = 'a',又失配,j = π[3] = 3……一路退到 0。 但既然 P[4] = P[3] = P[2] = … = 'a',只要 S[i] 不是 'a', 这些比较注定全部失败,完全可以一次性退到位

于是有了 nextval(优化后的 next):算 π[i] 时,如果 P[i+1] == P[π[i]],说明「退过去还是要和同一个字符比」,那就直接继承那个位置的 优化结果,把这一步的无效比较也省掉。

#include <iostream>
#include <string>
#include <vector>
using namespace std;

/* nextval:在 π 的基础上继续消除"回退后仍与同一字符比较"的无效步骤。
   注意:为了便于对照教材,这里直接给出 1 基下标、教材风格的 next 与 nextval。 */
void buildNextTextbook(const string& P, vector<int>& nxt, vector<int>& nxtval) {
    int m = P.size();                          /* 约定:P 的第 k 个字符是 P[k-1],下标 1..m */
    nxt.assign(m + 1, 0);
    nxt[1] = 0;                                /* 哨兵:第一个字符失配时无处可退 */
    int i = 1, j = 0;
    while (i < m) {
        if (j == 0 || P[i - 1] == P[j - 1]) {  /* 能接上,或 j 已经退到哨兵 */
            ++i; ++j;
            nxt[i] = j;                        /* next[i] = 最长相等真前后缀长度 + 1 */
        } else {
            j = nxt[j];                        /* 回退到次优候选,继续尝试 */
        }
    }
    /* nextval 递推:若 P[i] == P[next[i]],说明"退过去还是要比同一个字符",
       于是直接继承那个位置的优化结果,一步退到位。 */
    nxtval.assign(m + 1, 0);
    nxtval[1] = 0;
    for (int k = 2; k <= m; ++k) {
        if (P[k - 1] == P[nxt[k] - 1]) nxtval[k] = nxtval[nxt[k]];
        else nxtval[k] = nxt[k];
    }
}

int main() {
    string P = "aaaaab";
    vector<int> a, b;
    buildNextTextbook(P, a, b);
    cout << "next    : "; for (int i = 1; i < (int)a.size(); ++i) cout << a[i] << ' '; cout << "\n";
    cout << "nextval : "; for (int i = 1; i < (int)b.size(); ++i) cout << b[i] << ' '; cout << "\n";
    /* 输出 next    : 0 1 2 3 4 5
             nextval : 0 0 0 0 0 5   —— 连续相同字符被一次退到位 */
    return 0;
}
关于上面这段代码 教材风格的 next 用 1 基下标、nxt[1] = 0 作为哨兵,是考试的标准形式, 请务必能手工算出它的值。但写程序时更推荐前面的 0 基 π 版本,写起来不容易错。 两者换算关系:next[i] = π[i-2] + 1(i ≥ 2),next[1] = 0

5.4.3 从 next 数组能读出什么

π 数组不只能做匹配,它还是很多题型的钥匙:

问题用 π 怎么解
求最小循环节m % (m - π[m-1]) == 0,则最小循环节长度是 m - π[m-1],循环次数 m/(m-π[m-1])。例:"abcabcabc"π[8]=6,循环节长度 3。
求字符串的所有 border(既是前缀又是后缀)π[m-1] 开始不断 k = π[k-1],得到的所有 k 就是全部 border 长度。
统计每个前缀出现次数先求 π,再做 cnt[π[i]] += 1 的倒序累加(因为每个前缀出现时也贡献给它自己的最长 border)。
两个串的最长公共前缀 / 拼接问题用分隔符连接成 P + '#' + S 求 π,末位的 π 值就是公共部分长度。
在线求「下一个匹配位置」(多模式匹配)用自动机(KMP 自动机):给每个状态 j 和每个字符 c 预计算转移,适合多组询问。
#include <iostream>
#include <string>
#include <vector>
using namespace std;

vector<int> prefixFunction(const string& P) {
    int m = P.size(); vector<int> pi(m, 0);
    for (int i = 1; i < m; ++i) {
        int len = pi[i - 1];
        while (len > 0 && P[i] != P[len]) len = pi[len - 1];
        if (P[i] == P[len]) ++len;
        pi[i] = len;
    }
    return pi;
}

/* 应用 1:最小循环节。若整串由 k 个循环节组成,返回节长;否则返回 n */
int minPeriod(const string& s) {
    int n = s.size();
    vector<int> pi = prefixFunction(s);
    int p = n - pi[n - 1];                 // 候选节长
    return (n % p == 0) ? p : n;
}

/* 应用 2:找出所有既是前缀又是后缀的长度(border) */
vector<int> borders(const string& s) {
    vector<int> pi = prefixFunction(s), res;
    int k = pi[s.size() - 1];
    while (k > 0) { res.push_back(k); k = pi[k - 1]; }
    return res;                            // 从大到小
}

/* 应用 3:统计每个前缀在整个串中出现的次数(前缀出现次数) */
vector<long long> prefixCount(const string& s) {
    int n = s.size();
    vector<int> pi = prefixFunction(s);
    vector<long long> cnt(n + 1, 0);
    for (int i = 0; i < n; ++i) cnt[pi[i]]++;      // 每个位置贡献给它的最长 border
    for (int i = n; i >= 1; --i) cnt[pi[i - 1]] += cnt[i];   // 倒序累加上去
    for (int i = 1; i <= n; ++i) cnt[i]++;          // 每个前缀自身也算出现一次
    return cnt;
}

int main() {
    string s = "abcabcabc";
    cout << minPeriod(s) << "\n";                 // 3
    string t = "abababab";
    for (int x : borders(t)) cout << x << ' ';     // 6 4 2
    cout << "\n";
    for (int i = 1; i <= 3; ++i) cout << prefixCount("aaa")[i] << ' ';  // 3 2 1
    return 0;
}

5.4.4 KMP 的复杂度与局限

复杂度

  • 时间:预处理 O(m) + 匹配 O(n) = O(n+m)
  • 空间:O(m) 存 π 数组(可优化到 O(m) 以下?不行,最少也要 O(m))。
  • 优点:最坏情况有保证;只依赖模式串的预处理结果,可复用。

局限

  • 每次失配只把模式串滑动一小段,平均性能不如 BM
  • 对多模式匹配(同时找很多关键字)无能为力 → 需要 AC 自动机。
  • 实现细节容易写错(下标约定、哨兵值),考试时优先手推验证。

5.5 BM 算法:从右往左,跳着比

5.5.1 为什么换个方向就快很多

KMP 从左往右逐字符比较,失配时最多往前挪一点点。而 BM(Boyer-Moore,1977)反其道而行: 从模式串的最后一个字符开始往左比。这个「反着比」看似无关紧要,却带来一个巨大的好处—— 一旦在最右边就失配,我们看到的那个主串字符(坏字符)就能告诉我们: 模式串可以向右滑动多少格而绝不错过任何匹配。

更关键的是:如果这个坏字符根本不在模式串里,那么模式串可以直接整段跳过该字符,一次滑动 m 格!在自然语言里,大部分字符都不会出现在给定的模式串中,所以 BM 的实测速度往往远快于 KMP, 平均比较次数接近 O(n/m)。这也是 grep、编辑器查找功能普遍采用 BM 家族算法的原因。

5.5.2 规则一:坏字符规则(Bad Character)

规则描述(约定模式串与主串左端对齐于 align,从 j = m−1 往左比):

  1. S[align+j] ≠ P[j],记 c = S[align+j] 为坏字符。
  2. 查「坏字符表」last[c]:字符 c 在模式串中最后出现的下标(不存在记为 −1)。
  3. last[c] < j:把模式串右移 j − last[c] 格,让 c 与它在模式串中的最后出现对齐。
  4. last[c] 不存在:模式串整体右移 j + 1 格(相当于跳过 c 这个位置)。
  5. last[c] > j:位移算出来是负数——这种情况说明坏字符出现在失配位置的右边, 此时坏字符规则失效,至少也要右移 1 格,具体滑多少要看下面的好后缀规则。

5.5.3 规则二:好后缀规则(Good Suffix)

光靠坏字符规则会出问题。考虑一个真实场景:从右往左比,尾部已经匹配了一长串(这就是好后缀), 却在前面某处失配,而坏字符规则算出来的位移可能只有 1 格甚至为负。此时好后缀给出了另一条线索:

既然主串里确实存在与好后缀相同的一段,那么在模式串中,好后缀自身(或它的某个后缀) 必然还能在别处找到一次出现,或者等于模式串的某个前缀。据此可以安全地一次性滑到位。

主串 S C A B A B 失配(坏字符 A) "BAB" 已匹配 → 好后缀 模式串 P A B B A B P = "ABBAB",j = 1 处失配,好后缀 = "BAB" 好后缀 "BAB" 在模式串中还能找到吗? A B B A B 滑到 "AB" 与好后缀的前缀对齐(情形二:好后缀的后缀 = 模式串前缀) 最终位移取坏字符规则与好后缀规则的较大值,保证既不漏配、又尽量跳得远。
图 5-4 好后缀规则:尾部已匹配时,用好后缀在模式串中的再次出现来决定滑动量

5.5.4 BM 的完整 C++ 实现

下面是「坏字符规则 + 好后缀规则」的完整实现。工程上还有更省事的简化版 (只保留坏字符规则、或者用 Horspool 变体只比较最后一个字符),但完整版才体现 BM 的威力。

#include <bits/stdc++.h>
using namespace std;

/* ============================================================
   Boyer-Moore 字符串匹配(完整版:坏字符 + 好后缀)
   返回 P 在 S 中第一次出现的下标,找不到返回 -1

   竞赛写法:模式串与两张预处理表都是"一次预处理、多次查询"的全局量,
   所以直接开全局 string P + 两个全局表 + 两个自由函数,
   不必再套一层 BM 结构体(struct 只留给结点型数据)。
   用法:先 cin / 赋值给 P,再 buildBM(),之后可以任意次 bmSearch(S)。
   ============================================================ */

string P;                   /* 模式串 */
int m;                      /* |P| */
int badChar[256];           /* 坏字符表:字符 c 在 P 中最后出现的下标(用 256 覆盖全部字节) */
int goodSuffix[100];        /* 好后缀表:gs[i] = 当 P[i] 处失配时应右移的距离 */

/* ---------- 1. 坏字符表 ---------- */
void buildBadChar() {
    for (int c = 0; c < 256; ++c) badChar[c] = -1;
    for (int i = 0; i < m; ++i)
        badChar[(unsigned char)P[i]] = i;
}

/* ---------- 2. 好后缀表 ----------
   设 P[i] 处失配,则好后缀是 P[i+1 .. m-1]。
   把模式串整体右移 s 格后,好后缀的新起点是 (i+1-s),
   要求新位置上与之等长的一段逐字符相等,且不能与原位重叠。
   取满足条件的最小正 s。 */
void buildGoodSuffix() {
    for (int i = 0; i < m; ++i) goodSuffix[i] = 1;
    for (int i = 0; i < m - 1; ++i) {          /* 失配位置 i(最后一位失配时无好后缀) */
        int suffixLen = m - 1 - i;
        if (suffixLen == 0) { goodSuffix[i] = 1; continue; }
        int best = 0;
        for (int s = 1; s <= m - 1 && !best; ++s) {
            int start = i + 1 - s;             /* 好后缀右移 s 后的新起点 */
            if (start < 0) break;
            bool ok = true;
            for (int t = 0; t < suffixLen; ++t) {
                int pos = start + t;
                if (pos >= m || pos >= start + s) { ok = false; break; }  /* 不得与原位重叠 */
                if (P[pos] != P[i + 1 + t]) { ok = false; break; }
            }
            if (ok) best = s;
        }
        if (!best) {
            /* 兜底:好后缀的某个后缀若等于模式串的前缀,则滑到该前缀对齐 */
            for (int len = suffixLen - 1; len > 0 && !best; --len) {
                if (P.compare(0, len, P, m - len, len) == 0)
                    best = i + 1 + (suffixLen - len);
            }
            if (!best) best = i + 1;
        }
        goodSuffix[i] = best;
    }
}

/* 一次把两张表都建好;P 和 m 要先设置好 */
void buildBM() {
    m = P.size();
    buildBadChar();
    buildGoodSuffix();
}

int bmSearch(const string& S) {
    int n = S.size();
    if (m == 0) return 0;
    if (n < m) return -1;
    int align = 0;                              /* 模式串左端在主串中的对齐位置 */
    while (align <= n - m) {
        int j = m - 1;
        while (j >= 0 && P[j] == S[align + j]) --j;   /* 从右往左比较 */
        if (j < 0) return align;                        /* 完全匹配 */

        int b = badChar[(unsigned char)S[align + j]];
        int shiftBad = j - b;
        if (shiftBad < 1) shiftBad = 1;                 /* 坏字符在右边时兜底 */
        int shiftGood = goodSuffix[j];
        align += max(shiftBad, shiftGood);              /* 两条规则取较大值 */
    }
    return -1;
}

int main() {
    string S = "HERE IS A SIMPLE EXAMPLE";
    P = "EXAMPLE";
    buildBM();
    cout << bmSearch(S) << "\n";     // 17
    cout << bmSearch("NO MATCH HERE") << "\n";   // -1
    return 0;
}
两条规则各算出多少位移——取较大者,才能一次跳到位 主串 S a b a B b ← 坏字符 'B'(大写,不在模式串中) ← "b" 已匹配,再往左 'a' 也匹配 → 好后缀 = "ab" 模式串 P A A B a b P = "AABab",下标 0‥4(后两位 ab 用深蓝标出:它们也是好后缀的另一次出现) 从右往左比:P[4]='b'=S[4] ,P[3]='a'=S[3] ,比到 P[2] 失配 失配位置 j = 2 好后缀 = "ab"(已匹配部分) ① 坏字符规则 last['B'] = -1('B' 不在 P 中) 位移 = j − last['B'] = 2 − (−1) = 3 ② 好后缀规则 好后缀 "ab" 在 P 中另一次出现:start = 3(已 位移 = (i+1) − start = 4 − 3 = 1 最终位移 = max(3, 1) = 3 格 ——两条规则分别给出安全下界,取大的那个既不会漏配、又能尽量跳远。
图 5-5 坏字符规则与好后缀规则各算一个位移,取较大值作为最终滑动量
考点:两条规则分别在什么时候起决定作用
情形坏字符规则位移好后缀规则位移谁说了算
坏字符根本不在模式串里很大(j+1,可直接跳过)较小或 1坏字符规则
坏字符在模式串中,且出现在失配位左侧正数(把 c 对齐到它最后出现处)1 或较小坏字符规则
模式串尾部重复度高(如 "ABABAB"很小,甚至 ≤ 0较大(跳到好后缀再次对齐处)好后缀规则
坏字符出现在失配位右侧负数(失效作为兜底好后缀规则(且必须 ≥ 1)

一句话记忆:坏字符规则管「跳得远」,好后缀规则管「不失手」。 单独用坏字符规则在重复度高的模式串上会退化成一次只挪一格; 单独用好后缀规则又太保守。两条合起来才是完整的 BM。

易错:坏字符规则可能给出非正位移 当坏字符在模式串中的最后出现位置 last[c] 落在失配位置 j右侧时, j - last[c] 是负数或 0,此时必须与好后缀规则取最大值,且至少移动 1 格, 否则会陷入死循环或漏掉匹配。上面的实现用 max(shiftBad, shiftGood) 统一处理了这个边界。

5.6 三种匹配算法综合对比

对比项朴素匹配 BFKMPBM(完整版)
比较方向从左往右从左往右从右往左
主串指针是否回溯会回溯永不回溯用对齐位置跳跃,不回退已确认信息
预处理求 π:O(m)坏字符表 + 好后缀表:O(m + σ)(好后缀朴素实现 O(m²),可用 Z 函数 / KMP 优化到 O(m))
时间复杂度(最坏)O(n×m)O(n+m)O(n×m)(只在极端构造下;完整 BM 最坏为 O(n+m) 级别,取决于好后缀实现)
平均性能O(n)(自然文本)O(n+m),但常数较稳定约 O(n/m),自然文本上最快
额外空间O(1)O(m)O(m + σ),σ 为字符集大小
适用场景短模式串、教学最坏情况有保证、流式输入、需要在线的场合;也是求 border、循环节等字符串性质的基础长模式串、自然语言检索、编辑器 / grep 的查找
多模式扩展AC 自动机(KMP 在多串上的推广)Wu-Manber 等
怎么选?
  • 考试 / 面试:KMP 的 next 数组一定会考,必须能手推;BM 通常只考两条规则的原理。
  • 竞赛:几乎只用 KMP(写起来短、结论多)与 AC 自动机;BM 很少手写。
  • 工程:短模式串用 C 库的 memmem 类实现(glibc 用 Two-Way 算法),长模式串用 BM 家族。
  • 实际写代码时:先用 std::string::find,性能不够再换专用算法。

5.7 工程视角:字符串匹配在真实系统里长什么样

这一章讲的三个算法不是"考试专用"。只要系统里有"在一堆文本里找一小段文本"的需求, 背后就一定会落到这一族算法上。下面按领域看几个真实的落点,注意每个场景为什么选中了那一种。

5.7.1 编辑器与 grep:为什么不用 KMP

grepCtrl+F、"在文件夹中查找"这类工具的典型形态是: 一个较长的模式串(用户输入的关键词通常 5~30 个字符)、 一个很大的主串(整个文件甚至整个目录树)、 而且绝大多数位置都不匹配

这正是 BM 的主场。因为大多数位置在最右边第一个字符就失配,而那个字符又往往不在模式串里, 于是模式串可以一次跳过好几格,实测比较次数接近 O(n/m)。 相比之下 KMP 无论数据长什么样,每个字符都要参与一次比较,是稳定的 O(n)—— 在"平均情况"上反而比 BM 慢好几倍

现实补充:glibc 用的其实不是 BM GNU C 库的 memmem(也就是 strstr 的底层)采用的是 Two-Way 算法(Crochemore–Perrin),它兼具 KMP 的线性最坏保证与接近 BM 的平均速度, 而且不需要额外内存。所以工程上的结论是: "有最坏保证"和"平均快"这两个目标,业界最终是用别的算法同时拿到的。 但 BM 的两条规则仍然是理解这一族算法的最佳入口,面试与考试也主要考它。

5.7.2 数据库与检索系统:LIKE 和全文索引

数据库里的字符串查找分成两档,性能差好几个数量级:

写法底层做法能否用索引复杂度量级
WHERE name = 'abc'B+ 树等值查找O(log n)
WHERE name LIKE 'abc%'B+ 树范围扫描(前缀)O(log n + 命中数)
WHERE name LIKE '%abc%'退化成逐行字符串匹配不能O(n × m),而且 n 是行数

注意第三行:LIKE '%abc%' 之所以慢,恰恰是因为它要求的正是本章的 模式匹配——没有前缀可依赖,B+ 树帮不上忙,只能把每一行的那个字段拿出来跑一遍匹配算法。 n 是表的行数(可能是几千万),m 是模式长度,这个代价足以拖垮一整条查询。

LIKE 的三副面孔:能走索引的和不能走索引的,代价差好几个数量级 ① LIKE 'abc%' 前缀匹配 name LIKE 'abc%' 沿 B+ 树定位到 "abc",再顺叶子链表扫 O(log n) ② LIKE '%abc%' 任意位置匹配 name LIKE '%abc%' "abc" 可能出现在任何位置, B+ 树完全帮不上忙 每一行都要拿出来跑一遍匹配算法 ③ 倒排索引(全文检索) 词表: "abc" → [3, 17, 92, …] 建索引时就把「词 → 位置表」算好 查询时查表 + 位置求交 代价从「每次查」挪到「建一次」 同样一次查询的代价量级(n = 表的行数,m = 模式长度,σ = 位置表的平均长度): O(log n + 命中数) ← ① 前缀:索引能救 O(n × m) ← ② 逐行匹配:n 是行数,几千万行就完了 O(σ) 查表 ← ③ 倒排:把匹配提前做掉 注意 ② 的 n 不是字符数而是行数——这是它在真实系统里最容易被低估的地方。
图 5-6 LIKE 的三种写法在底层各走什么路,代价差在哪里

下面的代码把这三条路各实现了 10 行,用同一份数据实测比较次数, 可以直观看到「前缀能走索引」和「只能逐行匹配」的差距:

#include <bits/stdc++.h>
using namespace std;

/* ============================================================
   数据库 LIKE 三种写法的底层代价对比(简化模型)

   ① name = 'abc'        等值查询   → B+ 树,O(log n)
   ② name LIKE 'abc%'    前缀查询   → B+ 树范围扫描,O(log n + 命中数)
   ③ name LIKE '%abc%'   任意位置   → 逐行字符串匹配,O(行数 × 模式长度)

   下面用「一行数据 = 一个字符串」的模型,把 ② 与 ③ 各跑一遍,
   统计真正的字符比较次数,用数据说明差距来自哪里。
   ============================================================ */

/* 全局计数器:统计字符比较次数,模拟数据库里的实际工作量 */
long long cmpCount = 0;

bool charEq(char a, char b) { ++cmpCount; return a == b; }

/* ---------- ② 前缀匹配:等价于「先定位再顺序扫」 ----------
   真实数据库用 B+ 树直接跳到 "abc" 开头的那一段,
   这里用排序 + 二分模拟「定位」这一步的开销。 */
bool likePrefix(const string& s, const string& p) {
    if (p.size() > s.size()) return false;
    for (size_t i = 0; i < p.size(); ++i)
        if (!charEq(s[i], p[i])) return false;      /* 只比前缀那么多个字符 */
    return true;
}

/* ---------- ③ 任意位置匹配:必须逐行、逐位置地找 ----------
   这就是本章讲的模式匹配。数据库不会替你优化,
   它只能对每一行调用一次这样的函数。 */
bool likeContains(const string& s, const string& p) {
    int n = s.size(), m = p.size();
    if (m == 0) return true;
    for (int pos = 0; pos + m <= n; ++pos) {
        int j = 0;
        while (j < m && charEq(s[pos + j], p[j])) ++j;
        if (j == m) return true;
    }
    return false;
}

int main() {
    /* 造 20000 行数据,每行 40 个字符 */
    const int ROWS = 20000, LEN = 40;
    vector<string> rows;
    rows.reserve(ROWS);
    for (int i = 0; i < ROWS; ++i) {
        string s;
        for (int j = 0; j < LEN; ++j) s += char('a' + (i * 7 + j * 13) % 26);
        rows.push_back(s);
    }

    string p = "abcd";

    cmpCount = 0;
    int hitPrefix = 0;
    for (const auto& s : rows) if (likePrefix(s, p)) ++hitPrefix;      /* ② 前缀 */
    long long cPrefix = cmpCount;

    cmpCount = 0;
    int hitContains = 0;
    for (const auto& s : rows) if (likeContains(s, p)) ++hitContains;  /* ③ 任意位置 */
    long long cContains = cmpCount;

    cout << "行数 " << ROWS << ",模式 \"" << p << "\"\n";
    cout << "LIKE 'abcd%'  比较次数 " << cPrefix   << ",命中 " << hitPrefix   << " 行\n";
    cout << "LIKE '%abcd%' 比较次数 " << cContains << ",命中 " << hitContains << " 行\n";
    cout << "③ 是 ② 的 " << (double)cContains / (cPrefix ? cPrefix : 1) << " 倍\n";
    /* 实测约为 40 倍量级:前缀每条只需比到第一处不同就退出,
       而任意位置匹配要对每个可能的起始位置都试一遍。 */
    return 0;
}

解决办法是倒排索引(inverted index):预先建立"词 → 出现位置列表"的映射, 查询时先查词表再做位置求交。这就是 Elasticsearch、Lucene 以及各数据库全文索引的做法。 它把"每次查询都做一遍模式匹配"的成本,摊到了"建索引时做一次"上。 这是数据结构里一个反复出现的思路:把重复的计算提前算好、存起来。

和前面章节的呼应 倒排索引的"词 → 位置列表"本质上是哈希表(第 10 讲); 位置列表的有序求交用的是归并思路(第 11 讲归并排序); 而前缀匹配能加速,靠的是字典树 Trie(树结构,第 07 讲的思想)。 一个"搜索"功能,几乎把所有章节的知识点都用上了。

5.7.3 网络安全与生物信息:KMP 不可替代的两个场景

上面说 BM 平均更快,但有两类场景必须用 KMP 这一族(或它的推广),因为它们的输入是流式的、 不允许回退,或者模式不止一个

① 入侵检测(IDS)与内容过滤

网络设备要实时检查每一个数据包的内容里是否含有成千上万条特征串(病毒签名、攻击特征)。 数据包一个字节一个字节到达,看过的字节不可能回头再看——朴素算法的"回溯主串指针"在这里 物理上就做不到。

做法是 AC 自动机:把成千上万条特征串建成一棵 Trie,再补上 KMP 式的 fail 指针, 一次扫描就能同时匹配所有模式串。本章的失配跳转图(图 5-3)正是它的单模式版本。

② 基因序列比对

DNA 序列只有 A/T/C/G 四个字母,字符集极小、重复度极高—— 这正是朴素匹配退化到 O(n×m) 的最坏输入形态。 S = "AAAA…A"P = "AAA…AG" 这种在读起来荒唐的输入, 在基因组数据里是常态。

所以这一领域大量使用 KMP、后缀数组、后缀自动机这类有最坏情况保证的线性算法, 而不敢依赖"平均情况很快"的 BM。

选型口诀(考试与工程都适用)
  • 主串能一次性拿到、模式串较长、失配多 → BM / Horspool(编辑器、grep)。
  • 主串是流式到达、不能回退,或字符集小、重复度高 → KMP(网络检测、基因比对)。
  • 要同时匹配很多个模式串 → AC 自动机(KMP 的多模式推广)。
  • 要反复查询同一个大文本 → 先建索引(倒排 / 后缀数组),别每次重新匹配。

5.8 本章小结与练习

必须记住的五件事

  1. 子串要求连续,子序列不要求;前缀从 0 开始、后缀到末尾结束。
  2. 朴素匹配慢在主串指针回溯,最坏 O(n×m)。
  3. π[i] = 前缀 P[0..i] 的最长相等前后缀长度,π[0] = 0
  4. KMP 失配时 j = π[j-1]i 永不后退,总复杂度 O(n+m)。
  5. BM 从右往左比,坏字符 + 好后缀两条规则取较大位移,平均 O(n/m)。

易错清单

  • next 的各种下标约定混用(0 基 / 1 基 / 位移版)。
  • 求 π 时回退写成 len-- 而不是 len = π[len-1],复杂度会退化。
  • 忘记处理 m = 0m > n 的边界。
  • BM 中坏字符位移算出非正值时没有兜底,导致死循环。
  • 把「最长相等前后缀」误当成「最长公共子串」——前者是同一个串内部的概念。

自测题

1. 求 P = "ababaca" 的前缀函数 π(0 基)。

逐位推导:

  • π[0] = 0
  • i=1 ('b'):len=0,'b'≠'a' → π[1]=0
  • i=2 ('a'):len=0,'a'='a' → len=1 → π[2]=1
  • i=3 ('b'):len=1,P[3]='b' vs P[1]='b' 相等 → len=2 → π[3]=2
  • i=4 ('a'):len=2,P[4]='a' vs P[2]='a' 相等 → len=3 → π[4]=3
  • i=5 ('c'):len=3,P[5]='c' vs P[3]='b' 不等 → len=π[2]=1;P[5]='c' vs P[1]='b' 不等 → len=π[0]=0;P[5]='c' vs P[0]='a' 不等 → π[5]=0
  • i=6 ('a'):len=0,P[6]='a' vs P[0]='a' 相等 → π[6]=1

所以 π = [0, 0, 1, 2, 3, 0, 1]

2. 字符串 "abababab" 的最小循环节长度是多少?循环几次?

π 数组为 [0,0,1,2,3,4,5,6]π[7] = 6,候选节长 8 − 6 = 28 % 2 == 0 成立,所以最小循环节长度是 2("ab"),循环 4 次。

3. 主串 S = "aaaaaab",模式串 P = "aaaab",用 KMP 匹配时共发生几次 j 回退?

π(P) = [0,1,2,3,0]。匹配过程中,前 5 次 S[i]=P[j]='a' 依次把 j 推到 4; 当 S[5]='a'P[4]='b' 失配时,j = π[3] = 3(第 1 次回退), 此时 P[3]='a'S[5]='a' 相等,j 变 4,接着 S[6]='b'P[4]='b' 相等 → 匹配成功,起始下标 2。所以只回退了 1 次。 (若用朴素算法,这里会比较很多次。)

4. 为什么说「KMP 的比较次数一定比朴素算法少」是错的?举一个反例。

P = "baaa"S = "aaaaaaa…a"(全 a)。 朴素算法在每个对齐位置只需比较 1 次就失配(首字符 'b' ≠ 'a'),总共约 n 次比较; 而 KMP 需要先花 O(m) 求 π,匹配中每个位置也基本比较 1 次,其实相近。 更极端的反例是「模式串首字符在主串中从不出现」的情形:朴素算法每轮 1 次比较, KMP 还多了预处理开销。KMP 的真正优势是最坏情况的保证,而不是「任何输入都更快」。

5. BM 算法中,坏字符规则算出的位移是负数意味着什么?该怎么处理?

意味着坏字符在模式串中最后出现的位置在失配位置的右侧。此时按公式位移会向左移, 可能漏掉匹配甚至死循环。正确做法是:与好后缀规则算出的位移取较大值, 并且保证位移至少为 1。完整实现中通常写 shift = max(1, j - last[c]) 后再与好后缀位移取 max。

配套编程练习

练习任务提示
练习 1实现朴素匹配,并在 S = "aaaa…a"(1000 个 a)、P = "aaa…ab"(99 个 a + b)上统计比较次数验证 O(n×m) 退化
练习 2手推并编程验证 P = "ABCABD""ABABAA""aaaab" 的 π 与教材版 next、nextval三种约定对照,注意 1 基与 0 基的换算
练习 3求一个字符串的所有 border,并从大到小输出π 的链式回退
练习 4统计每个前缀在该串中出现的次数π + 倒序累加
练习 5实现 BM 的坏字符规则版,与 KMP 在同一文本上比较比较次数用英文文章做输入,观察差距
练习 6给定两个串 A、B,求「B 在 A 中作为子串出现」的最小循环节数量相关性质用分隔符拼接后求 π
洛谷练习建议 在洛谷题库中搜索以下关键词即可找到对应题目(题号请以站内搜索结果为准): 「KMP 字符串匹配」(模板题,直接套本章代码)、 「动物园」(KMP 的 num 数组,是 π 的进阶应用)、 「OKR-A Horrible Poem」(循环节 + 哈希)、 「Power Strings」(最小循环节,用 KMP 一行解决)。 第 14 讲洛谷题单中会给出更完整的分层练习计划。