学习笔记: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\) 为止。

image-20250804095052023

由于 \(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\) 一定是尽量大的。

如图所示,同颜色的子串一定相等。

image-20250804095834810

这样我们就可以得到求 \(\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\) 中出现过一次。

洛谷-P3375 【模板】KMP代码:

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)=\begin{cases} i+1,&s_{i+1}=c,\\ 0,&s_{i+1}\ne c\land i=0,\\ \delta(\pi(i),c),&s_{i+1}\ne c\land i\ge1. \end{cases} \]

换句话说,\(\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\) 的限制增加得快,故仍然是最大的。

Source code - Virtual Judge

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\))。则

\[f(i+1,k)=\sum_{j=0}^{M-1}f(i,j)g(j,k), \]

其中 \(g(j,k)\) 表示在匹配长度为 \(j\) 时添加一个字符使得匹配长度变为 \(k\) 的方案数。构建 KMP 自动机后有

\[g(j,k)=\sum_{c\in\varSigma}[\delta(j,c)=k]. \]

答案为 \(\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_N=G^NF_0. \]

根据初值 \(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

posted @ 2026-09-10 16:45  f2021ljh  阅读(11)  评论(0)    收藏  举报