前缀函数与 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:一个字符,没得重合,0ab:开头a结尾b,不搭,0abc:开头a结尾c,不搭,0abca:开头a结尾a,重合长度 1abcab:开头ab结尾ab,长度 2abcabc:开头abc结尾abc,长度 3abcabcd:开头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]
没做过这题的话不用深究,知道自动机能这么用就行。
容易踩的坑
-
下标从 0 还是从 1。 OI Wiki 从 0,很多教材从 1,next 数组整体右移一位。两种等价,混用必出错。本文统一从 0。
-
while 条件顺序。
j > 0 && s[i] != s[j],不能反。先判j > 0才能安全访问pi[j-1],不然越界。 -
if (s[i] == s[j]) j++在 j=0 时也要执行。 这时比的是s[i]和s[0],相等则pi[i]=1,不等保持 0。这个分支别漏。 -
分隔符选择。 必须既不在
s也不在t里。字符集小的话#、$都行;字符集是全集就得特殊构造,或者改用不拼接、直接双指针跑 KMP 的写法。 -
复杂度证明别被嵌套循环骗了。 每个
i最多一次j++,总增量 O(n);每次 while 至少减 1,总跳转 O(n)。势能分析的思路在摊还复杂度里很常见,值得记住。
The End
KMP 的美不在于它有多难,而在于它把重复劳动彻底消掉了。暴力算法每次失配都从头再来,KMP 用前缀函数记住已经匹配的部分里哪些前缀同时也是后缀,失配时直接跳到下一个候选,文本指针永不回头。
整个骨架三件事:
- 前缀函数:
pi[i]是最长相等真前后缀长度,用 border 链j -> pi[j-1]实现 O(n) 计算。 - 匹配:拼
s + '#' + t,pi[i] == |s|即匹配。 - 应用:border 和周期、出现次数统计、字符串压缩、自动机加速。
建议手推几遍 aabaaab 的 pi 数组,再对着代码走一遍 while 跳转,对 border 链就有直觉了。然后做几道题,基本就掌握。

浙公网安备 33010602011771号