Lesson 0417 · 字符串匹配:KMP

④ 数据结构与算法 — 第 17 课

上节课:哈希表 —— 把「键」映射成下标,让查询平均 O(1)。
这节课:在长串里找短串。暴力做法是 O(n·m),KMP 把它压到 O(n+m)。核心只有一件事:匹配失败时,别从头再来,退到「还可能是前缀」的那个位置。这个「退到哪」的表叫 pi 数组(很多书上叫 next)。
三句话速览

1. 暴力匹配慢在哪

最直接的做法:让短串 s2 从长串 s1 的每个位置开始,一个字符一个字符比:

for (int i = 0; i + m <= n; i++) {
    int k = 0;
    while (k < m && s1[i + k] == s2[k]) k++;
    if (k == m) 找到一个匹配(位置 i + 1)
}

复杂度是 O(n·m)。最坏情况长这样:

s1 = AAAAAAAA…A     (100 万个 A)
s2 = AAAAA…AAB      (999 个 A 加一个 B)

每次都在最后一个字符上失败,于是每个起点都要白比 1000 个字符。实测(n1 = 106、n2 = 1000):247 ms;把 n2 加到 8000,直接 1885 ms。

慢的根源不是「比了 n 个位置」,而是「每次失败都丢掉了已经比过的信息」。
上例里第 1 次比较已经确认了「前 999 个字符都是 A」,可下一次循环又从第 2 个字符、第 1 个模式字符重新开始 —— 这 999 个 A 的信息全扔了。KMP 干的事就是:把这份信息存进 pi 数组,失配时直接跳。

2. 核心概念:border 与 pi 数组

先看一个字符串:ABABCABAA。它的前缀 ABABCABA 有一个性质:

「既是前缀、又是后缀」且不是自己的那个最长子串,叫 border。这里 ABABCABA 的最长 border 是 ABA(长度 3)。

pi[i] 的定义:前缀 s[0..i] 的最长 border 长度。
换句话说:pi[i] = k 表示 s[0..k-1] == s[i-k+1..i],且 k 取最大(k < i+1)。

手推 ABABCABAA 的 pi

i字符进来时 j = pi[i−1]回退过程 / 比较pi[i]
0A—单个字符没有非自身 border0
1Bj = 0B ≠ s[0]=A → 不动0
2Aj = 0A == s[0]=A → j = 11
3Bj = 1B == s[1]=B → j = 22
4Cj = 2C ≠ s[2]=A → j = pi[1] = 0;C ≠ s[0]0
5Aj = 0A == s[0]=A → j = 11
6Bj = 1B == s[1]=B → j = 22
7Aj = 2A == s[2]=A → j = 33
8Aj = 3A ≠ s[3]=B → j = pi[2] = 1;A ≠ s[1]=B → j = pi[0] = 0;A == s[0] → j = 11

所以 pi = 0 0 1 2 0 1 2 3 1(程序实测输出一致)。

看 i = 8 那一行:它回退了两次(3 → 1 → 0),然后才重新配上。这就是 pi 数组最反直觉的地方 —— 失配时不是从 0 开始,而是沿着 pi 一层层往下退,每一层都还是「可能继续匹配的前缀」。

3. pi 是怎么递推出来的

核心就三行:

for (int i = 1; i < m; i++) {
    int j = pi[i - 1];                              // 从「上一个前缀的最长 border」接着试
    while (j > 0 && s2[i] != s2[j]) j = pi[j - 1];  // 配不上就退一层,退到 0 为止
    if (s2[i] == s2[j]) j++;                        // 配上了,border 长度 +1
    pi[i] = j;
}
为什么是 pi[j - 1],不是 pi[j]?
j 是「当前 border 的长度」,也就是「下一个待配字符的下标」。pi[j-1] 是「长度 j 的前缀的最长 border」—— 退一步就是去找「比 j 短的、仍然可能是前缀」的 border。写成 pi[j] 就整体错位一格,样例可能还能过,大数据必挂。
为什么复杂度是 O(n)?
j 每次成功只 +1,而总共只加了 m 次;每次 j = pi[j-1] 都让 j 严格变小。所以「回退」的总次数不超过「增加」的总次数 = O(m)。
看起来像有两层循环,实际上是线性的 —— 这是 KMP 的第一个反直觉点。

4. 匹配:i 只往前走,j 可以往回退

有了 pi 数组,匹配就是同一套循环:

int j = 0;
for (int i = 0; i < n; i++) {
    while (j > 0 && s1[i] != s2[j]) j = pi[j - 1];   // 失配:退 j,不动 i
    if (s1[i] == s2[j]) j++;                          // 配上:j 往前走
    if (j == m) {                                     // 整个模式串配完了
        cout << i - m + 2 << '\n';                    // 出现位置(1-based)
        j = pi[j - 1];                                // ★ 继续找下一处(可能重叠)
    }
}

用样例 s1 = ABABABC、s2 = ABA(pi = 0 0 1)逐字符走一遍:

is1[i]进来时 j发生了什么之后 j
0A0配 s2[0]1
1B1配 s2[1]2
2A2配 s2[2] → j = 3 = m,记录位置 1,回退 j = pi[2] = 11
3B1配 s2[1]2
4A2配 s2[2] → j = 3 = m,记录位置 3,回退 j = pi[2] = 11
5B1配 s2[1]2
6C2C ≠ s2[2]=A → 退 j = pi[1] = 0;C ≠ s2[0]0

输出 1、3 —— 和样例一致。

匹配成功后为什么还要 j = pi[j-1]?
因为答案可能有重叠。比如 s1 = AAAAA、s2 = AA,位置 1、2、3、4 都是答案。如果匹配成功后把 j 清成 0,就会漏掉 2、3、4。
另外:j == m 时如果继续用 s2[j] 就会越界,所以这一步也是必须的。

5. 完整代码(P3375 可直接交)

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

int main() {
    ios::sync_with_stdio(false); cin.tie(nullptr);
    string s1, s2;
    cin >> s1 >> s2;
    int n = (int)s1.size(), m = (int)s2.size();

    // ① 求 pi 数组
    vector<int> pi(m, 0);
    for (int i = 1; i < m; i++) {
        int j = pi[i - 1];
        while (j > 0 && s2[i] != s2[j]) j = pi[j - 1];
        if (s2[i] == s2[j]) j++;
        pi[i] = j;
    }

    // ② 匹配
    int j = 0;
    for (int i = 0; i < n; i++) {
        while (j > 0 && s1[i] != s2[j]) j = pi[j - 1];
        if (s1[i] == s2[j]) j++;
        if (j == m) {
            cout << i - m + 2 << '\n';   // 1-based 位置
            j = pi[j - 1];
        }
    }

    // ③ 输出 pi 数组(P3375 要求的最后一行)
    for (int i = 0; i < m; i++) cout << pi[i] << (i + 1 == m ? '\n' : ' ');
}

实测四组(本机编译运行,-O2 -std=c++17 -Wall -Wextra 零警告):

输入输出说明
ABABABC / ABA1、3、0 0 1样例
AAAAA / AA1234、0 1重叠匹配:4 处
ABC / Z(无位置行)、0m = 1、无匹配
A / A1、0边界

6. 实测:KMP 到底快多少

固定 n1 = 106,模式串是「n2−1 个 A 加一个 B」(最坏情况:每次都在最后一个字符失败):

n2KMP暴力KMP 快几倍
5001.7 ms126 ms75×
10001.6 ms247 ms151×
20001.5 ms486 ms317×
40001.9 ms959 ms512×
80001.6 ms1885 ms1163×

KMP 一列几乎不动(1.5–1.9 ms),暴力一列随 n2 线性涨 —— 这就是 O(n+m) 与 O(n·m) 的区别,不是常数快慢。

另外两组真实场景:

数据KMP 耗时结果
n1 = 106 全 A,n2 = 105 全 A(匹配极多)2.0 ms900001 处匹配
n1 = 106 随机,n2 = 105 随机1.0 ms0 处匹配

正确性也对拍过:500 组随机小串,KMP 的结果与暴力完全一致(0 组不一致)。

7. 坑点清单

8. 小测

Q1. pi[i] 的定义是什么?
Q2. 求 pi 时失配为什么要退到 pi[j-1]?
Q3. 匹配成功后为什么还要执行 j = pi[j-1]?
Q4. KMP 的复杂度为什么是 O(n+m) 而不是 O(n·m)?
Q5. P3375 的输出顺序是什么?

9. 动手练习

  1. P3375 【模板】KMP —— 主战场。|s1|, |s2| ≤ 106,全大写字母。三件必须自己对的东西:① 位置是 1-based(i - m + 2);② 最后一行输出 |s2| 个 pi 值、空格隔开;③ 匹配成功后立刻 j = pi[j-1]。
  2. 自测重叠:拿 AAAAA 找 AA,答案应该是 4 处(1、2、3、4)。如果你只输出 1 处,就是匹配后没回退。
  3. 选做:用 KMP 数出现次数——同一份代码把「输出位置」换成「计数器 +1」,就是「统计子串出现次数」的模板题。
卡住时的自查顺序
  1. 求 pi 的循环是从 i = 1 开始的吗?
  2. 回退写的是 pi[j-1] 吗?
  3. 输出位置加了 +1(或 i - m + 2)吗?
  4. 最后一行输出了 m 个数吗?
  5. 还不行就把代码贴过来,我按行给你看 —— 按规矩,我会先让你猜「这组输入应该输出什么」。

本课小结

下一课预告
0418 贪心专题 —— 不回头、不枚举,每一步都取「当前看起来最好」的,然后证明它不亏。核心不是写出来,而是证明:交换论证(任意最优解都能通过交换若干步变成你的贪心解,且不变差)。配题 P4995(跳跳!)、P1223(排队接水)。
这一课的代码都真的编译跑过了:样例 1 / 3 / 0 0 1、重叠 AAAAA/AA 得 4 处、ABC/Z 只输出 0、A/A 输出 1 和 0;500 组随机与暴力对拍 0 不一致;第 6 节所有耗时都是本机实测。哪一句看不懂 —— 比如「为什么 pi[j-1] 能保证不漏」「border 到底在算什么」—— 直接问我,我从零再讲一遍。

← 上一课:哈希表 · 课程对照表 · 算法总结

书籍 《算法 (第4版)》第 5.3 节(子字符串查找)· 《算法导论》第 32 章(字符串匹配)

在线 OI-Wiki · KMP · 洛谷 P3375