跳转至

第 71 章 字符串匹配与 KMP

配套例题:BISHI92 【模板】前缀函数(KMP) 来源:S4 模板.docx KMP 段 前置70-字符串处理36-哈希与字符串哈希

KMP(Knuth-Morris-Pratt,取三位作者 Knuth、Morris、Pratt 的姓氏首字母) 是最经典的字符串匹配算法。它真正的产物是一张前缀函数表—— 在各家资料里也叫 next 数组或 fail 数组,本章统一记作 \(\pi\)

这一章有一个反直觉的核心结论,先说在前面:

在 Python 里,纯粹的「找子串」几乎永远应该用 str.find / in, 手写 KMP 只会更慢(实测慢 10–240 倍)。

但 KMP 的前缀函数(next 数组)本身是不可替代的—— 求 border、求最小循环节、求所有周期,这些是 find 做不到的。

所以学 KMP 的正确姿势是:学前缀函数,而不是学「怎么找子串」


71.1 暴力匹配

在文本 \(t\)(长 \(n\))里找模式 \(p\)(长 \(m\)):

def naive_search(t, p):
    """暴力匹配,最坏 O(nm)。"""
    n, m = len(t), len(p)
    res = []
    for i in range(n - m + 1):           # i 是对齐起点;再往后 p 就放不下了
        j = 0                            # j = 本轮已经比对成功的长度
        while j < m and t[i + j] == p[j]:
            j += 1
        if j == m:                       # 一路比到底,说明 p 出现在下标 i
            res.append(i)
        # 失配后 i 只 +1、j 归零:刚刚比对成功的 j 个字符全白比了
    return res

最坏情况\(t = \texttt{aaaa}\cdots\texttt{a}\)\(p = \texttt{aaa}\cdots\texttt{ab}\)。 每个起点都要比到最后一位才失配,\(O(nm)\)\(n = m = 10^5\) 时是 \(10^{10}\),必挂。

暴力的浪费在哪:失配后 \(i\) 只前进 1 位,已经比对成功的信息全丢了。 KMP 的全部思想就是:利用「模式串自己和自己的匹配关系」, 让失配后不必从头再来


71.2 前缀函数(next 数组)

定义

对字符串 \(s\)(下标从 0 开始),

\[\pi[i] = \max\{\,k < i+1 \mid s[0..k-1] = s[i-k+1..i]\,\}\]

即:\(s\) 的前缀 \(s[0..i]\) 中,最长的「既是真前缀又是真后缀」的长度

「既是真前缀又是真后缀」的串叫 border(边界)\(\pi[i]\) 就是最长 border 的长度。

s     =  a  a  b  a  b  a  a  b  c  a
pi    =  0  1  0  1  0  1  2  3  0  1

逐个验证:

\(i\) 前缀 \(s[0..i]\) 最长 border \(\pi[i]\)
0 a 空(真前缀不能是整串) 0
1 aa a 1
2 aab 0
3 aaba a 1
6 aababaa aa 2
7 aabaabab… → aabab aa b aab 3

递推:失配链

关键引理\(\pi[i]\) 的候选值只能来自这条链

\[\pi[i-1],\ \pi[\pi[i-1]-1],\ \pi[\pi[\pi[i-1]-1]-1],\ \dots\]

为什么\(s[0..i]\) 的 border 去掉最后一个字符,就是 \(s[0..i-1]\) 的 border。 而 \(s[0..i-1]\)所有 border 恰好就是 \(\pi[i-1]\) 沿着这条链一路跳下去得到的集合。

于是算法是:用一个游标 \(k\) 记住「当前候选 border 长度」,

  • \(s[k] = s[i]\),则 \(\pi[i] = k+1\)
  • 否则沿链回退 \(k \leftarrow \pi[k-1]\),直到匹配上或 \(k = 0\)
def prefix_function(s):
    """前缀函数(KMP 的 next 数组)。pi[i] = s[0..i] 的最长 border 长度。

    均摊 O(n):k 每轮最多 +1,所以总的回退量不超过总的增加量。
    s 可以是 str 或 bytes(bytes 更快,比较的是 int)。
    """
    n = len(s)
    pi = [0] * n
    k = 0                                    # 当前候选 border 长度,同时也是「下一个要比的下标」
    for i in range(1, n):                    # pi[0] 恒为 0(真前缀不能是整串),所以从 1 起
        c = s[i]                             # 取进局部变量,内层 while 每轮少一次索引
        while k and s[k] != c:               # 长度 k 的候选接不上 c,换一个更短的候选
            k = pi[k - 1]                    # ★ 跳到 pi[k-1]:s[0..k-1] 的最长 border
        if s[k] == c:                        # 接上了,候选前缀整体加长一位
            k += 1
        pi[i] = k                            # k == 0 表示 s[0..i] 没有非空 border
    return pi

k = pi[k-1] 为什么不是 k = 0:长度 \(k\) 的候选接不上 \(s[i]\), 但\(k\) 短的候选未必接不上,直接归零就把它们全丢了,等于退回暴力。 而 \(s[0..i-1]\)全部 border 恰好构成 \(k,\ \pi[k-1],\ \pi[\pi[k-1]-1],\ \dots,\ 0\) 这条严格递减的链, 所以「换成次长的候选」就是沿链走一步——pi[k-1] 正是长度 \(k\) 的那个前缀自己的最长 border。 链一定会终止:每跳一次 \(k\) 至少减 1,跳到 0 就只能重新开始。

复杂度是 \(O(n)\)(均摊):内层 while 每执行一次 \(k\) 至少减 1, 而 \(k\) 在整个循环里总共只增加了不超过 \(n\) 次,所以回退总量 \(\le n\)。 和单调栈的均摊论证是同一类(37-单调栈与单调队列)。

两种下标约定:本教程用 pi[i] 表示「以 \(i\) 结尾的前缀」的 border 长度(0-indexed)。 S4 模板.docx 里的 C++ 写法用 f[i+1] = ...(1-indexed 的 next 数组), 两者只差一个平移:f[i+1] == pi[i]混用会导致差一错误,选一种就别换。


71.3 KMP 匹配

有了模式串 \(p\)\(\pi\) 数组,在文本 \(t\) 上扫描时用同样的失配链回退:

def kmp_search(t, p):
    """在 t 中找出 p 的所有出现位置(可重叠),返回起始下标列表。O(n + m)。"""
    m = len(p)
    if m == 0:
        return list(range(len(t) + 1))       # 空串在每个位置(含末尾)都算出现一次
    pi = prefix_function(p)                  # 只对模式串预处理,与文本无关
    res = []
    k = 0                                    # 当前已匹配的长度,同时是 p 中下一个要比的下标
    for i in range(len(t)):
        c = t[i]
        while k and p[k] != c:               # 与构建循环同形,只是把 s[k] 换成了 p[k]
            k = pi[k - 1]                    # ★ 同一条失配链:换成次长的「已匹配前缀」
        if p[k] == c:
            k += 1
        if k == m:                           # 已匹配长度到达 m,完整命中一次
            res.append(i - m + 1)            # 命中的末位是 i,起点就是 i-m+1
            k = pi[k - 1]                    # ★ 回退,允许重叠匹配
    return res

两段循环是同一个模板。逐行对照 prefix_function,差别只有三处:

构建 \(\pi\) 匹配
游标 \(k\) 的含义 候选 border 的长度 已匹配的前缀长度
拿谁和谁比 s[k]s[i](串和自己比) p[k]t[i](模式对文本)
命中之后 只记 pi[i] = k 多一句「记录位置并回退」

回退用的是同一句 k = pi[k-1],因为两处问的是同一个问题: 「已经匹配上的这 \(k\) 个字符里,最长的那个『既是前缀又是后缀』有多长」

k = pi[k-1] 这一行决定了「是否允许重叠」: - 写 k = pi[k-1] → 允许重叠("aaa" 中找 "aa" 得到 2 个); - 写 k = 0 → 不允许重叠(得到 1 个,和 str.count 一致)。

另一种写法:拼接法

\(\pi\) 函数直接用在拼接串上,代码更短:

def kmp_search_concat(t, p, sep=b"\x00"):
    """在 s = p + sep + t 上求前缀函数,pi 值等于 |p| 的位置就是一次匹配。

    sep 必须是 p 和 t 中都不出现的字符,保证 border 不会跨过分隔符。
    代价:多用 O(n + m) 的内存。
    """
    m = len(p)
    s = p + sep + t                          # p 占下标 0..m-1,sep 占 m,t 从 m+1 开始
    pi = prefix_function(s)
    # pi[i] == m 表示以 i 结尾、长度 m 的那一段等于 p;该段起点是 i-m+1,
    # 减去 t 的偏移 m+1,在 t 中的下标就是 i-2m
    # 下界取 2m:t 的第 0 位若就是一次匹配,它结束在 i = 2m(写成 2m+1 会漏掉这一处)
    return [i - 2 * m for i in range(2 * m, len(s)) if pi[i] == m]

分隔符不能省。不加分隔符时,p = "aa"t = "aaa" 会让 \(\pi\) 值超过 \(m\), 匹配位置就全错了。这是拼接法唯一的坑。


71.4 现实:str.find 是 C 实现,几乎总是更快

CPython 的 str.find / bytes.find / in 用的是 Crochemore-Perrin(two-way)算法加上一个坏字符跳表—— \(O(n+m)\) 最坏复杂度,而且整个循环在 C 层

本机实测(CPython 3.9,\(n = 10^6\)):

数据 str.find 找全部出现 手写 KMP 倍数
随机 ab 串,$ p = 20$ 0.0071 s
\(t = \texttt{a}^{10^6}\)\(p = \texttt{a}^{1000}\texttt{b}\)(暴力最坏) 0.0004 s 0.1046 s 慢 241 倍
str.find 只找第一次出现 0.00022 s

结论表

需求 用什么
判子串是否存在 p in t
找第一次出现的位置 t.find(p)
数不重叠出现次数 t.count(p)
找所有出现位置(可重叠) while (i := t.find(p, i)) >= 0 循环,仍比 KMP 快
求 border / 最小循环节 / 所有周期 必须 KMP 的 \(\pi\) 数组
求每个前缀的最长 border(BISHI92) 必须 \(\pi\) 数组
求最长公共前缀(LCP)数组 Z 函数或后缀数组
需要 \(O(1)\) 比较任意两个子串 字符串哈希(36 章
# 找所有出现位置的「正确」Python 写法(比手写 KMP 快)
def find_all(t, p):
    res = []
    i = t.find(p)                   # find 找不到返回 -1,正好当循环出口
    while i >= 0:
        res.append(i)
        i = t.find(p, i + 1)        # 第二个参数是起始搜索位置;+1 允许重叠,+len(p) 则不重叠
    return res

注意这个写法在极端数据上会退化:如果 \(p\)\(t\) 中出现了 \(O(n)\) 次, 每次 find 都要重新初始化 two-way 算法的预处理(\(O(m)\)),总代价 \(O(nm/1)\)。 出现次数很多且 \(m\) 很大时,KMP 反而更稳。但这种数据很罕见。

那为什么还要学 KMP? 因为下面这些事 find 做不到:


71.5 周期性质:KMP 真正不可替代的用途

\(|s| = n\)\(\pi[n-1]\) 是整个串的最长 border 长度。

性质一:最小周期

\[\text{最小周期长度} = n - \pi[n-1]\]

而且:当且仅当 \(n \bmod (n - \pi[n-1]) = 0\) 时,\(s\) 由整数个循环节拼成。

def min_period(s):
    """返回 s 的最小周期长度 p(s[i] == s[i+p] 对所有合法 i 成立)。

    若 len(s) % p == 0,则 s 恰好由 len(s)//p 个循环节拼成;
    否则 s 只是「以 p 为周期的串」的一个前缀。
    """
    n = len(s)
    pi = prefix_function(s)
    # 最长 border 长 pi[n-1],把它从右端对齐回左端要平移 n-pi[n-1] 位,
    # 这个平移量就是最小周期
    return n - pi[n - 1]

验证:

\(s\) \(\pi[n-1]\) 最小周期 \(n \bmod p = 0\) 结论
abcabcabc 6 3 由 3 个 abc 拼成
aaaa 3 1 由 4 个 a 拼成
abcab 2 3 周期 3,但不是整数个
abcd 0 4 只有 1 个循环节(自己)

这个性质是 KMP 的杀手锏。「最少再添几个字符使串变成周期串」 「求字符串的循环节」「判断能否由某个子串重复得到」——全靠它。

性质二:所有 border

\(s\)全部 border 长度就是这条链:

\[\pi[n-1],\ \pi[\pi[n-1]-1],\ \dots,\ 0\]
def all_borders(s):
    """返回 s 的所有 border 长度(从大到小),不含 s 自身。"""
    pi = prefix_function(s)
    res = []
    k = pi[len(s) - 1]                       # 整串的最长 border
    while k:                                 # 沿失配链一路跳,长度严格递减,跳到 0 结束
        res.append(k)
        k = pi[k - 1]                        # 长度 k 的前缀,它自己的最长 border
    return res

性质三:每个前缀的最小周期

\(\pi\) 数组一次求出所有前缀的 border,所以每个前缀的最小周期是 \(i + 1 - \pi[i]\)——一次 \(O(n)\) 得到 \(n\) 个答案,这是 find 完全办不到的。


71.6 Z 函数(扩展 KMP)

定义

\[z[i] = \text{LCP}(s,\ s[i..])\]

\(s\) 与它从 \(i\) 开始的后缀的最长公共前缀长度。约定 \(z[0] = n\)

s  =  a  a  b  a  b  a  a  b  c  a
z  = 10  1  0  1  0  3  1  0  0  1

求法:维护「最靠右的匹配段」

思想和 Manacher 一模一样(72-回文): 维护一个已知的匹配区间 \([l, r)\)(满足 \(s[l..r) = s[0..r-l)\)), 用它对当前位置做镜像预测,再从预测值继续暴力扩展。

def z_function(s):
    """Z 函数:z[i] = s 与 s[i:] 的最长公共前缀长度。O(n)。"""
    n = len(s)
    z = [0] * n
    if n == 0:
        return z
    z[0] = n                                 # 约定:s 与自己的公共前缀就是整串
    l = r = 0                                # 最靠右的匹配段 [l, r),满足 s[l:r] == s[0:r-l]
    for i in range(1, n):
        if i < r:                            # i 落在已知段内,可以借用镜像位置的结论
            zi = z[i - l]                    # i 关于段首 l 的镜像位置
            ri = r - i                       # 已知段在 i 右边还剩多少字符可信
            z[i] = zi if zi < ri else ri     # ★ 下界 = min(镜像值, 区间剩余)
        while i + z[i] < n and s[z[i]] == s[i + z[i]]:
            z[i] += 1                        # 超出已知段的部分没有信息,只能逐位暴力扩
        if i + z[i] > r:
            l, r = i, i + z[i]               # 扩出了更靠右的边界,换成新的已知段
    return z

为什么是 \(O(n)\):每次 while 扩展一步,\(r\) 必然增大一步,而 \(r \le n\) 且单调不减。

Z 函数做匹配

def z_search(t, p, sep=b"\x00"):
    """用 Z 函数在 t 中找 p 的所有出现。O(n + m)。"""
    m = len(p)
    s = p + sep + t                          # t 从下标 m+1 开始(p 占 m 位,sep 占 1 位)
    z = z_function(s)
    # z[i] >= m 表示从 i 开始至少有 m 个字符与 s 的开头(即 p)一致;
    # 有 sep 挡着,z[i] 不可能超过 m。减去偏移 m+1 即为 t 中的下标
    return [i - m - 1 for i in range(m + 1, len(s)) if z[i] >= m]

\(\pi\)\(z\) 的分工

前缀函数 \(\pi\) Z 函数
语义 每个前缀的最长 border 每个后缀与整串的 LCP
求周期/循环节 首选 ⚠️ 可以但绕
求所有 border
求「\(s\) 的每个后缀和 \(s\) 的公共前缀」 首选
判「\(p\)\(t\) 的每个位置匹配了多长」 ⚠️ 要改造 ✅ 直接就是
实现难度 简单 中等
Python 常数 快(实测 \(10^6\) 约 0.08 s) 慢约 3 倍(约 0.22 s)

实战选择默认写 \(\pi\)。只有明确需要「每个位置的 LCP 长度」时才用 Z 函数。 大部分「扩展 KMP」题也能用字符串哈希 + 二分做(\(O(n \log n)\) 但代码短)。


71.7 例题

BISHI92 【模板】前缀函数(kmp)(中等)

\(T \le 2\times10^6\) 组数据,每组给一个长 \(n\) 的小写字母串(\(\sum n \le 2\times10^6\)), 输出它的前缀函数 \(\pi_1, \pi_2, \dots, \pi_n\),一行一组,空格分隔。 特殊测试点 11–15:仅使用一个字符构建串。 题面见 BISHI92 原题(牛客)。 题解见 solutions/BISHI92.py(已用官方样例验证)。

这题是纯模板 + 纯 IO 压力测试。 算法就是 71.2 的十行递推, 但 \(\sum n = 2\times10^6\)、输出 \(2\times10^6\) 个整数(4–14 MB), IO 才是真正的对手

import sys


def main():
    data = sys.stdin.buffer.read().split()
    t = int(data[0])
    out = []
    p = 1
    for _ in range(t):
        n = int(data[p]); s = data[p + 1]; p += 2     # 全程 bytes,不 decode
        pi = [0] * n                # 每组重开一张表,k 也必须跟着归零
        k = 0                       # 候选 border 长度 = 下一个要比的下标
        for i in range(1, n):       # pi[0] 恒为 0,直接跳过
            c = s[i]                # bytes 取出来是 int,整数比较比字符对象快
            while k and s[k] != c:      # 沿失配链回退,均摊 O(1)
                k = pi[k - 1]           # 换成次长的候选,而不是从头再来
            if s[k] == c:
                k += 1                  # 候选前缀加长一位
            pi[i] = k
        out.append(" ".join(map(str, pi)))   # 每组先拼成一行,最后统一输出
    sys.stdout.write("\n".join(out) + "\n")   # 2e6 个整数只能一次写出,逐行 print 必 TLE


main()

复杂度 \(O(\sum n)\)

关于「测试点 11–15 只用一个字符」:出题人想卡的是没写失配链回退的假 KMP。 但有趣的是,\(s = \texttt{aaaa}\cdots\) 恰恰是 KMP 的最快情况—— \(k\) 一路 \(+1\) 从不回退。真正让内层 while 跑得多的是 aaaab 这类结构, 但均摊仍然是线性的

四个 Python 要点

要点 理由
全程 bytes,不 decode s[i] 得到 int,整数比较比字符串对象比较快
c = s[i] 先取出来 内层 while 里少一次索引
逻辑装进 main() 局部变量走 LOAD_FAST,比全局快 20–30%
" ".join(map(str, pi)) map + C 层 str,比列表推导略快

读入格式的坑:每组是「先一个整数 \(n\),再一个长度 \(n\) 的字符串」, 两者在同一行。但用 split() 按 token 读就完全不用关心换行位置—— 这是 token 流读法相对于按行读的最大好处,见 20-输入输出处理

Python 现实性评估\(\sum n = 2\times10^6\),主循环 \(2\times10^6\) 次 (实测 \(10^6\) 的前缀函数约 0.08 s,所以主循环约 0.16 s), 但输出 \(2\times10^6\) 个整数的 map(str, ...) + join 约 0.5–1 s。 时限「其他语言 4 秒」,能过,但 IO 一定要一次性做。 若逐行 print\(2\times10^6\) 次系统调用直接 TLE。


71.8 本章速查

要点 结论
核心结论 Python 里找子串一律用 find/in,手写 KMP 慢 10–240 倍
KMP 的真正价值 前缀函数:border、最小循环节、所有周期
\(\pi[i]\) 定义 \(s[0..i]\) 的最长「真前缀 = 真后缀」长度
递推核心 失配就沿链回退 k = pi[k-1]
复杂度 均摊 \(O(n)\)\(k\) 总增量 \(\le n\)
匹配时允许重叠 命中后 k = pi[k-1]
匹配时不许重叠 命中后 k = 0
拼接法 p + sep + t分隔符不能省
最小周期 \(n - \pi[n-1]\)
整数个循环节 \(n \bmod (n-\pi[n-1]) = 0\)
所有 border \(\pi[n-1] \to \pi[\pi[n-1]-1] \to \cdots\)
每个前缀的最小周期 \(i+1-\pi[i]\)一次 \(O(n)\) 出全部
\(z[i]\) 定义 \(s\)\(s[i:]\) 的最长公共前缀,\(z[0]=n\)
Z 函数思想 维护最右匹配段 \([l,r)\) + 镜像预测(同 Manacher)
\(\pi\) vs \(z\) 的常数 \(z\) 约慢 3 倍,默认写 \(\pi\)
数据表示 全程 bytes,比较 int 最快
输出大量整数 一次 "\n".join + 一次 write
看到什么 → 想到什么
\(t\) 里有没有 \(p\)
\(p\) 出现了几次」
「最小循环节 / 由某串重复而成」
「所有既是前缀又是后缀的串」
「每个前缀的最长 border」
「每个位置和开头能匹配多长」
「任意两个子串是否相等」
「多模式串同时匹配」
「最长回文子串」