学习笔记:KMP 算法与 border 理论
参考资料:
下面令字符串中字符的下标从 \(1\) 开始,记长度为 \(|s|=n\),即 \(s=(s_1,s_2,\cdots,s_n)\),其中 \(s_i\in\varSigma\),\(\varSigma\) 为满足全序关系的字符集。
简记为 \(s=s_1s_2\cdots s_n\)。\(s[l,r]\) 表示 \(s\) 的连续子串 \(s_ls_{l+1}\cdots s_r\),其中 \(1\le l\le r\le n\)。
称 \(s[1,i]\) 为 \(s\) 的一个前缀,其中 \(1\le i\le n\)。若 \(i\ne n\),则称 \(s[1,i]\) 为 \(s\) 的一个真前缀。
同样,称 \(s[i,n]\) 为 \(s\) 的一个后缀,其中 \(1\le i\le n\)。若 \(i\ne1\),则称 \(s[i,n]\) 为 \(s\) 的一个真后缀。
称 \(s\) 的长度为 \(i\) 的前缀或后缀为 \(s\) 的「\(\boldsymbol{i}\) 前缀」或「\(\boldsymbol{i}\) 后缀」。
KMP 算法
前缀函数与 KMP 算法
定义字符串 \(s\) 的前缀函数 \(\pi(i)\) 表示 \(s\) 的 \(i\) 前缀 \(s[1,i]=s_1\cdots s_i\) 的最长相等真前后缀长度。
我们可以利用动态规划的思想,在 \(O(n)\) 时间内求出 \(\pi(1),\pi(2),\cdots,\pi(n)\)。
该算法由 Knuth、Pratt 和 Morris 在 1977 年提出,故称为 KMP 算法。下面介绍这一算法。
考虑已知 \(\pi(1),\cdots,\pi(i)\) 时,如何求出 \(\pi(i+1)\)。如果 \(s_{i+1}=s_{\pi(i)+1}\),那么自然 \(\pi(i+1)=\pi(i)+1\)。否则,不断尝试用更小的 \(\pi\) 去匹配 \(s_{i+1}\)。
如图所示,令指针 \(j\) 初始为 \(\pi(i)\),如果 \(s_{j+1}=s_{i+1}\),则得到 \(\pi(i+1)=j+1\),否则不断令 \(j=\pi(j)\),判断能否匹配,直到 \(j=0\) 为止。
由于 \(s[1,\pi(i)]=s[i-\pi(i)+1,i]\),初始时 \(j=\pi(i)\),且 \(s[1,\pi(j)]=s[j-\pi(j)+1,j]\),故始终有 \(s[1,j]=s[i-j+1,i]\),并且 \(j\) 一定是尽量大的。
如图所示,同颜色的子串一定相等。
这样我们就可以得到求 \(\pi\) 函数的算法:(由于每次循环结束后都有 \(j=\pi(i+1)\),下次循环开始时就有 \(j=\pi(i)\),因此 \(j\) 不用重置。)
for (int i = 1, j = 0; i < n; i++) {
while (j && s[j + 1] != s[i + 1]) j = pi[j];
if (s[i + 1] == s[j + 1]) ++j;
pi[i + 1] = j;
}
字符串匹配算法
KMP 算法常用于字符串匹配问题:给定同一字符集 \(\Sigma\) 上的模式串 \(p\) 和文本串 \(t\),求 \(p\) 在 \(t\) 中出现了多少次。
我们构造新串 \(s=p+\texttt{\#}+t\),其中 \(\texttt{\#}\) 是不属于 \(\Sigma\) 的任意字符。那么,求 \(s\) 的前缀函数 \(\pi\) 之后,如果 \(\pi(i)=|p|\),则说明 \(p\) 为 \(s\) 的 \(i\) 前缀的后缀,即 \(p\) 在 \(t\) 中出现过一次。
string t, p;
cin >> t >> p;
int m = p.size();
string s = '#' + p + '#' + t;
int n = s.size() - 1;
vector<int> pi(n + 1, 0);
for (int i = 1, j = 0; i < n; i++) {
while (j && s[i + 1] != s[j + 1]) j = pi[j];
if (s[i + 1] == s[j + 1]) ++j;
pi[i + 1] = j;
if (pi[i + 1] == m) cout << i - 2 * m + 1 << '\n';
}
for (int i = 1; i <= m; i++)
cout << pi[i] << ' ';
cout << '\n';
border 理论与 fail 树
定义字符串 \(s\) 的 border 为 \(s\) 的一个相等的真前后缀,即若 \(s[1,i]=s[n-i+1,n]\)(\(1\le i<n\)),则称 \(s[1,i]\) 为 \(s\) 的一个 border。
下面简称长度为 \(i\) 的 border 为「\(i\) border」。border 的主要性质有:
- \(\pi(n)\) 为 \(s\) 的最长 border 的长度,\(\pi(i)\) 为 \(s[1,i]\) 的最长 border 的长度。
- \(s\) 的所有 border 的长度为 \(\pi(n),\pi(\pi(n)),\cdots\)。
- \(s\) 有长度为 \(r\) 的 border,当且仅当 \(n-r\) 是 \(s\) 的周期(或者说是 \(s\) 无限复制成的字符串的周期)。\(n-\pi(n)\) 是 \(s\) 的最小周期。
- \(s\) 的 border 的最短长度不超过 \(n/2\),因为如果有 \(r>n/2\) 使得 \(s[1,r]\) 为 border,则 \(s[1,2r-n]\) 也是 \(s\) 的 border。
由 \(\pi(i)\) 向 \(i\) 连边,因为 \(\pi(i)<i\),且每个 \(i\) 向上连一条边,所以我们可以得到一棵树。
在 KMP 算法中,如果 \(s_{i+1}\) 与 \(s_{j+1}\) 失配(无法匹配),则在树中向上转移,故称为失配树(fail 树)。
失配树有很好的性质:如果 \(u\) 是 \(v\) 的祖先,则 \(u\) 前缀是 \(v\) 前缀的 border。因此,若要查询两个前缀的最长公共 border,只需要在失配树上查询 LCA 即可。洛谷-P5829 【模板】失配树代码:
#include <bits/stdc++.h>
using namespace std;
int const N = 1e6 + 10, LGN = 20;
string s;
int n, m, lgn, pi[N], dep[N], fa[LGN][N];
int lca(int x, int y) {
if (dep[x] < dep[y]) swap(x, y);
for (int j = lgn; j >= 0; j--)
if (dep[fa[j][x]] >= dep[y]) x = fa[j][x];
if (x == y) return x;
for (int j = lgn; j >= 0; j--)
if (fa[j][x] != fa[j][y]) x = fa[j][x], y = fa[j][y];
return fa[0][x];
}
signed main() {
cin.tie(0)->sync_with_stdio(false);
cin >> s;
n = s.size();
lgn = __lg(n);
s = '#' + s;
dep[0] = 0, dep[1] = 1;
fa[0][1] = 0;
for (int i = 1, j = 0; i < n; i++) {
while (j && s[i + 1] != s[j + 1]) j = pi[j];
if (s[i + 1] == s[j + 1]) ++j;
pi[i + 1] = j;
fa[0][i + 1] = j;
dep[i + 1] = dep[j] + 1;
}
for (int j = 1; j < LGN; j++)
for (int i = 1; i <= n; i++)
fa[j][i] = fa[j - 1][fa[j - 1][i]];
cin >> m;
int p, q, ans;
while (m--) {
cin >> p >> q;
ans = lca(p, q);
if (ans == p) ans = fa[0][p];
if (ans == q) ans = fa[0][q];
cout << ans << '\n';
}
return 0;
}
KMP 自动机
前置知识:有限状态自动机 - OI Wiki
对于一个模式串 \(s\),求文本串 \(t\) 的每个前缀的后缀与 \(s\) 的前缀的最大匹配长度,即求最大的 \(i\) 满足 \(t[p-i+1,p]=s[1,i]\)。
参考刚才的 KMP 字符串匹配算法,刚才是在 \(s+t\) 上跑 KMP。现在也是类似的情况,将 KMP 的失配过程看成自动机的状态转移。
设 \(\pi(i)\) 为 \(s\) 的前缀函数。于是 KMP 自动机的状态转移函数为
换句话说,\(\delta(i,c)\) 表示:当前与 \(s\) 前缀匹配长度为 \(i\),下一个字符为 \(c\),问下一步 \(s[1,i]+c\) 与 \(s\) 的匹配长度。
构建 KMP 自动机的代码:
string s; // 1-indexed
int pi[N];
int tr[26][N];
void getKMPAutomaton() {
for (int i = 0; i < n; i++) {
for (char j = 'a'; j <= 'z'; j++) {
if (i < n && s[i + 1] == j) tr[i][j] = i + 1;
else if (!i) tr[i][j] = 0;
else tr[i][j] = tr[pi[i]][j];
}
}
}
例题
KMP 算法
洛谷-P4824 [USACO15FEB] Censoring S
给定字符串 \(S\) 和 \(T\),反复找到当前 \(S\) 中第一个出现的子串 \(T\) 并删除,直到 \(S\) 中不再包含 \(T\)。
注意,删除操作可能产生新的子串等于 \(T\)。请输出最终处理完成的字符串 \(S\)。\(|T|\le|S|\le10^6\)。
注意到匹配的过程类似于括号匹配的过程。
维护一个栈,从前往后扫 \(S\),将字符入栈,如果栈顶后缀是一个完整的 \(T\),就把它从栈中删去,然后继续匹配。
考虑到遍历 \(S\) 的字符的过程和 KMP 类似,因此首先对 \(T\) 跑一遍 KMP 求出 \(\pi\) 数组,然后用 \(S\) 重复一遍 KMP 算法跳 fail (\(\pi\)) 的过程,维护栈中元素的后缀与 \(T\) 的匹配长度 \(f\)。
如果匹配长度为 \(|T|\),则将栈顶连续 \(|T|\) 个元素删去,然后从新的栈顶开始继续向后匹配。Source code - Virtual Judge
*洛谷-P2375 [NOI2014] 动物园
给定一个字符串 \(S\),设 \(num(i)\) 表示 \(S\) 的 \(i\) 前缀的 border 中前后缀不重叠的数量,求 \(\prod_i(num(i)+1)\)。
结果对 \(10^9+7\) 取模。\(1\le T\le5\),\(1\le|S|\le10^6\)。\(S\) 由小写字母组成。
相当于求失配树上 \(i\) 的祖先中小于等于 \(i/2\) 的数量。当然我们可以在失配树上用倍增解决,时间 \(O(Tn\log n)\),但这样可能会 TLE。
考虑如何利用已有的信息。如果在 KMP 算法求 \(\pi\) 的过程中限定 \(\pi(i)\le i/2\),那么 \(\pi\) 的极大性不能保证,KMP 算法的正确性也就不能保证。因此我们需要先求一遍 \(\pi\) 函数。
设节点在失配树上的深度为 \(dep(x)\),那么对于每个 \(i\),只需找到最深的 \(x\) 满足 \(dep(x)\le i/2\) 即可。
然而,我们不能直接在失配树中向上找(即令 \(j=\pi(j)\) 直到 \(j\le i/2\)),因为这样在 \(S=\texttt{aaa}\cdots\texttt{a}\) 时会退化为 \(O(n^2)\)(因为 \(\pi\) 每次只减少 \(1\))。但是,如果我们跑第二遍 KMP 算法,此时设 \(j\) 为 \(i\) 的最长不重叠 border 长度,即 \(j\le i/2\),那么将 \(j\) 不断往上跳,并且在下一次循环中不更新 \(j=\pi(i)\),这样得到的 \(j\) 一定是最大的满足条件的 \(j\)。由于 \(j\) 每次最多自增 \(1\),如果匹配上了,比 \(i/2\) 的限制增加得快,故仍然是最大的。
UVA-11022 String Factoring
将字符串 \(S\) 按如下方式进行压缩:
- 将相邻的若干相同子串压缩成一个,例如将 \(\texttt{CABABABC}\) 压缩为 \(\texttt{CABC}\)。
压缩的区间只能从大到小,例如 \(\texttt{BABAABABAABA}\) 可以压缩为 \(\texttt{BABAABA}\),然后压缩为 \(\texttt{BABABA}\),但在此之后不能压缩为 \(\texttt{BA}\)。
压缩可以进行任意次。求最终得到的压缩后的字符串的最短长度。\(|S|\le80\)。
注意到数据范围,考虑区间 DP。设 \(dp(l,r)\) 表示将 \(S[l,r]\) 压缩得到的最短长度。
首先枚举分割点,有 \(dp(l,r)\gets\min_{l\le p<r}dp(l,p)+dp(p+1,r)\)。
其次,考虑将 \(S[l,r]\) 整个压缩。如果 \(k\) 为 \(S[l,r]\) 的周期,且 \(k\mid r-l+1\)(称为整周期),则 \(dp(l,r)\overset\min\gets dp(l,l+k-1)\)。
\(O(n^2)\) 枚举区间 \(S[l,r]\) 之后,\(O(n)\) 枚举长度 \(k\mid r-l+1\),可以暴力 \(O(n)\) 判断是否为周期,即判断 \(S[l,r-k+1]\) 是否等于 \(S[l+k-1,r]\),也可以预处理 Hash 后 \(O(1)\) 判断是否为周期。
进一步地,使用 KMP 算法,预处理 \(nxt(i,j)\) 表示 \(S[i+1,n]\) 的前缀函数,即 \(S[i+1,j]\) 的最长 border 长度。
由于 border 与周期一一对应,不断向前跳 border 即可找到所有周期。
border 理论
洛谷-P4391 [BalticOI 2009] Radio Transmission 无线传输
字符串 \(s_1\) 是由某个字符串 \(s_2\) 不断自我连接形成的(保证至少重复 \(2\) 次)。
给定 \(s_1\) 的一个子串(长度为 \(L\)),求 \(s_2\) 的最短长度是多少。\(1\le L\le 10^6\)。
观察给定的样例 \(\texttt{cabcabca}\),虽然样例解释说 \(\texttt{abc}\) 是一个可能的 \(s_2\),但是我们发现前缀 \(\texttt{cab}\) 也可以是。
\(s_2\) 复制的次数是不重要的。因此强制让 \(s_2\) 为给定字符串的前缀,求给定字符串的最短周期即为 \(s_2\)。
根据上面的结论,即求最长 border 长度 \(\pi(L)\),则答案为 \(L-\pi(L)\)。
*洛谷-P4156 [WC2016] 论战捆竹竿
两个字符串 \(a,b\) 可以按如下方式拼在一起形成 \(c\):
\(a\) 的 \(i\) 后缀与 \(b\) 的 \(i\) 前缀相等,将这个相等的部分重叠起来。形式化地,设 \(|a|=n,|b|=m\),则
\[c=a[1..n-i]+b=a+b[i+1..m]. \]特别地,\(i\) 可以为零,即可以令 \(c=a+b\)。
给定长度为 \(n\) 的字符串 \(s\),用任意多个 \(s\) 拼在一起,形成长度不超过 \(w\) 的字符串,共有多少种不同长度?
KMP 自动机
*洛谷-P3193 [HNOI2008] GT考试
给定长度为 \(M\) 的数字串 \(A=A_1A_2\cdots A_M\),求有多少种长度为 \(N\) 的数字串 \(X=X_1X_2\cdots X_N\),使得 \(X\) 中不出现 \(A\)。
输出模 \(K\) 取余的结果。\(N\leq10^9\),\(M\leq 20\),\(K\leq10000\)。
设 \(f(i,k)\) 表示长度为 \(i\) 的数字串 \(X\),\(k\) 后缀匹配 \(A\) 的 \(k\) 前缀的方案数,规定 \(f(i,M)=0\)(即永远不让 \(X\) 完全匹配 \(A\))。则
其中 \(g(j,k)\) 表示在匹配长度为 \(j\) 时添加一个字符使得匹配长度变为 \(k\) 的方案数。构建 KMP 自动机后有
答案为 \(\sum_{k=0}^{M-1}f(N,k)\)。初值为 \(f(0,0)=1\)。
注意到 \(f\) 的状态转移方程是矩阵乘法的形式,使用矩阵快速幂加速。
具体地,设 \(M\) 维向量 \(F_i=(f(i,0),f(i,1),\cdots,f(i,M-1))^{\rm T}\),\(M\) 阶方阵 \(G=(g(i,j))_{0\le i,j\le M-1}\),则 \(F_{i+1}=GF_i\),进而
根据初值 \(F_0=(1,0,\cdots,0)^{\rm T}\),答案为 \(G^N\) 的第一列 / 第一行元素之和。
时间 \(O(M+M^2|\varSigma|+M^3\log N)=O(M^3\log N)\)。Source code - Virtual Judge

浙公网安备 33010602011771号