前缀函数与 KMP 学习笔记

先说结论:KMP 不神秘。它只是在回答一个问题——失配了,能不能别从头再来。

约定:本文 pi 下标从 0 开始,pi[i] 对应子串 s[0..i]。真前缀/真后缀指长度严格小于子串长度的前缀/后缀。

从暴力匹配说起

文本 t 里找模式 s,最笨的办法谁都写得出:每个位置逐字符比,一旦崩了,文本指针往后挪一位,模式指针归零,从头再比。

这东西最坏 O(nm)。卡它太容易了,文本 aaaaaaaab、模式 aaab,每次都在最后一位崩,前面比过的全白费。不是错,是低效——蠢在同一个地方反复交学费。

Knuth、Morris、Pratt 三个人 1977 年给了一个答案:匹配过程中文本指针单调前进、永不回退。失配时让模式串自己跳到一个聪明的位置接着比。

凭什么能跳?凭一张提前算好的表。这张表记录模式串的每个前缀"自己和自己有多像"。像得越多,能跳得越远。

KMP 最妙的地方不是它快,而是它把一件看起来必须回退的事,变成了一件不需要回退的事。像走迷宫:暴力法是撞墙退回入口重走,KMP 是撞墙时手里有张地图,知道哪条路走过了,直接换岔路。

前缀函数

给定字符串 s,pi[i] 的定义是:子串 s[0..i] 里,最长的、相等的真前缀和真后缀的长度。

"真"的意思是不能是它自己(长度严格小于 i+1)。所以 pi[0] 永远是 0。说人话就是:盯着 s[0..i] 这段,看开头和结尾最多能重合多长。

拿 abcabcd 走一遍:

  • a:一个字符,没得重合,0
  • ab:开头 a 结尾 b,不搭,0
  • abc:开头 a 结尾 c,不搭,0
  • abca:开头 a 结尾 a,重合长度 1
  • abcab:开头 ab 结尾 ab,长度 2
  • abcabc:开头 abc 结尾 abc,长度 3
  • abcabcd:开头 a 结尾 d,不搭,0

所以 pi = [0, 0, 0, 1, 2, 3, 0]。

再练一个 aabaaab,自己推。答案是 [0, 1, 0, 1, 2, 2, 3]。这个建议手推几遍,别跳。

暴力算:能算,慢得离谱

对每个 i 从大到小枚举长度 j,比前后缀:

for (int i = 1; i < n; i++)
  for (int j = i; j >= 0; j--)
    if (s.substr(0, j) == s.substr(i - j + 1, j)) {
      pi[i] = j; break;
    }

两层循环共 O(n²) 次比较,每次 substr 比较 O(n),总 O(n³)。能跑,但没人这么写。

第一个优化:别从 i 开始试

一个很自然的观察:pi[i+1] ≤ pi[i] + 1。

为什么?设 pi[i+1] = k,则 s[0..k-1] = s[i-k+2..i+1]。两边各砍掉最后一个字符,得 s[0..k-2] = s[i-k+2..i]。这是 s[0..i] 的一个 border,长度 k-1,故 pi[i] ≥ k-1,即 pi[i+1] ≤ pi[i]+1。

那算 pi[i] 时候选长度从 pi[i-1]+1 往下试就行,不用从 i 开始。候选尝试的总次数摊还 O(n)(每次失配 j 至少减 1),但每次比较本身最坏仍是 O(n),总复杂度 O(n²)——还是不够。

第二个优化:失配了往哪跳

假设 pi[i-1] = j,算 pi[i]。先看 s[i] 和 s[j] 相等吗。

相等,皆大欢喜,pi[i] = j+1。

不等,就得找次长的、既是前缀又是后缀的长度 j',再拿 s[i] 和 s[j'] 比。

问题是 j' 怎么快速拿到。这里有个关键结论:

s[0..i-1] 的所有 border 长度,恰好构成一条链:j, pi[j-1], pi[pi[j-1]-1], ...(其中 j = pi[i-1])。这条链叫 border 链。

证明:设 k < j 是 s[0..i-1] 的 border,则 s[0..k-1] = s[i-k..i-1]。右端 s[i-k..i-1] 是 s[i-j..i-1] = s[0..j-1] 的后缀,左端 s[0..k-1] 是 s[0..j-1] 的前缀,所以 k 是 s[0..j-1] 的 border,故 k ≤ pi[j-1]。反过来,pi[j-1] 由传递性也是 s[0..i-1] 的 border(它是 s[0..j-1] 的 border,而 s[0..j-1] 是 s[0..i-1] 的 border)。所以次长 border 就是 pi[j-1],归纳即得整条链。

于是失配时沿着这条链往下跳:

j -> pi[j-1] -> pi[pi[j-1]-1] -> ... -> 0

沿着它找,直到 s[i] == s[j] 或者跳到 0。

最终代码

vector<int> prefix_function(string s) {
  int n = s.length();
  vector<int> pi(n);
  for (int i = 1; i < n; i++) {
    int j = pi[i - 1];
    while (j > 0 && s[i] != s[j]) j = pi[j - 1];
    if (s[i] == s[j]) j++;
    pi[i] = j;
  }
  return pi;
}

就这几行。第一次看到容易怀疑:这能是 O(n)?

复杂度证明值得多看两眼:每个 i 最多执行一次 j++,所以 j 的总增量不超过 n;而 while 每跳一次 j 至少减 1,总减量也不超过 n。所以 while 总共执行 O(n) 次。整个算法 O(n)。

关键在 j 是一个单调有界的量,涨得慢、跌得快,总账算下来线性。别被嵌套循环吓到——这是势能分析/摊还复杂度的典型例子。

还有个性质:这是在线算法。一个字符一个字符读进来,边读边算,不用回头。内存紧张的话甚至可以边读边丢。

匹配:把前缀函数用起来

在文本 t 里找模式 s,标准操作是拼一个字符串:

cur = s + '#' + t

# 是分隔符,保证既不在 s 里也不在 t 里。对 cur 求前缀函数。

因为分隔符挡着,pi 值永远超不过 |s|。当某个位置 i 满足 pi[i] == |s|,就说明 s 完整地以 i 为右端点出现了一次。

下标换算稍微绕。设 n = |s|,m = |t|。cur 里 t 从 n+1 开始。若 pi[i] == n,右端点在 cur 里是 i,对应 t 里是 i-(n+1),那起点是:

i - (n+1) - (n-1) = i - 2n
vector<int> find_occurrences(string text, string pattern) {
  string cur = pattern + '#' + text;
  int sz1 = text.size(), sz2 = pattern.size();
  vector<int> v;
  vector<int> lps = prefix_function(cur);
  for (int i = 2 * sz2; i <= sz1 + sz2; i++)
    if (lps[i] == sz2) v.push_back(i - 2 * sz2);
  return v;
}

循环从 2n 开始,因为 t 里第一个可能匹配的右端点在 cur 中是 n+1 + n - 1 = 2n。上界 sz1+sz2 是 cur 的最后一个下标。若 n > m,2n > n+m,循环自然不执行,正确(短文本里不可能有长模式)。

时间 O(n+m),空间 O(n+m)。想省空间就只存 s+#,长度 n+1,因为 pi 值不会超过 n,t 部分边读边算边扔。

前缀函数的几种用法

求所有 border 和周期

border 就是既是真前缀又是真后缀的子串。pi[n-1] 是最长 border,所有 border 长度构成一条链:

pi[n-1], pi[pi[n-1]-1], pi[pi[pi[n-1]-1]-1], ...

周期定义:s[i] == s[i+p] 对所有合法 i 成立,p 就是周期。重要等价关系:长度为 r 的 border 等价于 |s|-r 是周期。所以最小周期是 n - pi[n-1]。

统计每个前缀出现次数

给定 s,统计每个前缀 s[0..i] 在 s 里出现多少次。

思路:pi[i] 给了以 i 结尾的最长前缀匹配,但更短的前缀也可能以 i 结尾出现,它们正好是 pi[i] 的 border 链上的长度。所以先统计每个 pi 值的频次,再沿链累加:

vector<int> ans(n + 1);
for (int i = 0; i < n; i++) ans[pi[i]]++;
for (int i = n - 1; i > 0; i--) ans[pi[i - 1]] += ans[i];
for (int i = 0; i <= n; i++) ans[i]++;

第一行数每个长度作为最长匹配出现了几次。第二行从长到短,把长度 i 的计数加到它的最长 border pi[i-1] 上——每个出现的位置,更短的 border 也跟着出现。最后加 1 是因为每个前缀至少出现一次,它自己。注意 ans[0] 无实际意义,只是累加过程的中间量。

字符串压缩

给 s,求最短的 t,使 s 是 t 重复整数次。令 k = n - pi[n-1](最小周期长度)。若 k 整除 n,最短 t 的长度就是 k(t = s[0..k-1]);否则答案是 n,压不了。

证明思路:k 整除 n 时,s 切成若干长度 k 的块,由 border 性质可推出所有块相等。k 不整除 n 时反证,假设有更短压缩会推出矛盾。

KMP 自动机

把状态转移预先打成表:状态是当前匹配长度 j,输入字符 c,输出新状态。匹配时不回退,直接查表。适合处理显式构造不出来的巨型字符串。

构造时有个 DP 优化:若 i > 0 且 s[i] != c,则 aut[i][c] = aut[pi[i-1]][c],直接复用算过的结果,把 O(|Σ|n²) 降到 O(|Σ|n)。

典型场景是 Gray 字符串 g_k,长度 2^k-1,天文数字,但可以用自动机加递推。G[i][j] 表示从状态 j 处理完 g_i 后的状态,K[i][j] 表示出现次数:

mid = aut[G[i-1][j]][i]
G[i][j] = G[i-1][mid]
K[i][j] = K[i-1][j] + (mid == |s|) + K[i-1][mid]

没做过这题的话不用深究,知道自动机能这么用就行。

容易踩的坑

  1. 下标从 0 还是从 1。 OI Wiki 从 0,很多教材从 1,next 数组整体右移一位。两种等价,混用必出错。本文统一从 0。

  2. while 条件顺序。 j > 0 && s[i] != s[j],不能反。先判 j > 0 才能安全访问 pi[j-1],不然越界。

  3. if (s[i] == s[j]) j++ 在 j=0 时也要执行。 这时比的是 s[i] 和 s[0],相等则 pi[i]=1,不等保持 0。这个分支别漏。

  4. 分隔符选择。 必须既不在 s 也不在 t 里。字符集小的话 #、$ 都行;字符集是全集就得特殊构造,或者改用不拼接、直接双指针跑 KMP 的写法。

  5. 复杂度证明别被嵌套循环骗了。 每个 i 最多一次 j++,总增量 O(n);每次 while 至少减 1,总跳转 O(n)。势能分析的思路在摊还复杂度里很常见,值得记住。

The End

KMP 的美不在于它有多难,而在于它把重复劳动彻底消掉了。暴力算法每次失配都从头再来,KMP 用前缀函数记住已经匹配的部分里哪些前缀同时也是后缀,失配时直接跳到下一个候选,文本指针永不回头。

整个骨架三件事:

  1. 前缀函数:pi[i] 是最长相等真前后缀长度,用 border 链 j -> pi[j-1] 实现 O(n) 计算。
  2. 匹配:拼 s + '#' + t,pi[i] == |s| 即匹配。
  3. 应用:border 和周期、出现次数统计、字符串压缩、自动机加速。

建议手推几遍 aabaaab 的 pi 数组,再对着代码走一遍 while 跳转,对 border 链就有直觉了。然后做几道题,基本就掌握。

posted @ 2026-10-18 22:14  华沙白鹰  阅读(9)  评论(0)    收藏  举报