字符串大学习

· · 算法·理论

字符串大学习

前言

本文章使用 LLM 辅助生成,如有错误,请在评论区指出。感谢!

食用建议:根据自己的水平和需求,从大纲中选取需要学习的部分跳转学习。不建议从头通读。

目录

配套图:img/ 目录下 26 张 SVG/PNG,每张对应一个结构或过程。

0. 引言:字符串问题的两副面孔

0.1 一个让人卡住的问题

给一个长度 10^6 的文本串 S,和一个长度 10^5 的模式串 T,问 T 在 S 中出现了几次、分别在哪些位置。

最直接的做法是枚举起点,逐位比较:

For(i, 1, n - m + 1) {
    bool ok = true;
    For(j, 1, m) if (S[i + j - 1] != T[j]) { ok = false; break; }
    if (ok) ans.push_back(i);
}

最坏 O(nm)=10^{11},一秒只能跑 10^8 次基本操作,要跑一千秒。

慢在哪? S=\texttt{aaaaaaab},T=\texttt{aaab}。i=1 比较 4 次失败,i=2 又从头比较 4 次失败……每次失败后,我们已经知道了 S 中那一段是 aaa,这个信息被完全丢弃。O(nm) 的浪费就在重复读已经读过的字符。

0.2 第二类问题:子串太多

给串 s(|s|\le10^6),求它有多少个本质不同的子串。

朴素想法:枚举 O(n^2) 个子串,哈希表去重。n=10^6 时是 5\times10^{11} 个子串,完全不可行。

注意"子串 = 某个后缀的前缀"。若两个后缀的 LCP 是 L,它们共同贡献了 L 个重复子串。所以

\text{本质不同子串数}=\sum_{i=1}^{n}\bigl(|s|-i+1-\text{前面已出现的最大 LCP}\bigr)

而"前面已出现的最大 LCP"要求把所有后缀排好序——这就逼出了后缀数组。另一条路是把"所有子串"直接压成 O(n) 个状态,即后缀自动机。

0.3 全书地图:两条主线

字符串问题分成两类,对应本书的两部分:

第一类:匹配问题(第一部分,第 1–7 章)——"一个串在另一个串里出现没有、在哪、几次"。

武器 它记住了什么 复杂度 章节
哈希 整个子串的"指纹" 预处理 O(n),比较 O(1) §2
Trie 公共前缀的合并 O(\lvert p\rvert) 每次 §3
KMP / 前缀函数 每个前缀的最长 border O(n+m) §4
Z 函数 每个后缀与原串的 LCP O(n+m) §5
Manacher 每个中心的回文半径 O(n) §6

这五把武器有一个共同思想:

用已经算好的、位于当前位置左边的信息,推当前答案。

KMP 用 \pi_{i-1} 推 \pi_i;Z 函数用最右匹配段推 z_i;Manacher 用镜像点的半径推当前半径。理解这句话,后面几个算法都不用死记——可以现场重新推出来。

第二类:压缩问题(第二部分,第 8–17 章)——"把 O(n^2) 个子串压成 O(n) 个结构"。

                    ┌─ AC 自动机(多串的前缀树 + fail)
                    │
    Trie ───────────┼─ 后缀树(所有后缀的前缀树 = 压缩后缀 Trie)
     │              │
     │              └─ 回文自动机(所有回文的两侧扩展树)
     │
     ├─ 后缀数组(排序所有后缀)
     │
     └─ 后缀自动机(endpos 等价类)

    Lyndon 分解 ──► 最小表示法、平方串

四条最关键的依赖链:

  1. Trie → AC 自动机 → fail 树。AC 自动机是"多模式串的 Trie + 每个节点指向最长真后缀的指针"。fail 指针构成的树让"统计出现次数"变成"子树求和"。
  2. 后缀排序 → height 数组 → LCP 查询。SA 把"子串问题"变成"后缀区间问题",height 把"LCP"变成"区间最小值"。
  3. endpos 等价类 → SAM → parent 树。SAM 每个状态是一批 endpos 相同的子串,parent 树刻画包含关系。
  4. Lyndon 分解 → 最小表示法 → 平方串。Duval 算法一个 O(n) 的双指针同时解决三件事。

0.4 贯穿全书的一条暗线:fail 树

这是本书最重要的一条线索,先点明,后面反复回扣。

把"失配指针"当父亲建树,得到一棵树。四类结构都有这棵树:

结构 节点是什么 父边指向 子树和含义 章节
KMP 的 fail 树 T 的一个前缀 最长 border 该前缀在 T 中出现次数 §4.9
AC 自动机的 fail 树 Trie 的一个状态 最长的"是某模式串前缀"的真后缀 该模式串出现次数 §8.5
SAM 的 parent 树 一个 endpos 等价类 最长后缀所在的类 该状态所有子串的出现次数 §10.5
PAM 的 fail 树 一个回文串 最长回文真后缀 该回文出现次数 §12.5

四棵树的节点含义不同,形状和用法完全一致。所以 §4.9 不是"KMP 的一个应用",而是后面三个自动机的样板:

0.5 选型速查

问题 首选 备选 章节
单模式串匹配 KMP Z 函数 / 哈希 §4、§5
两个子串是否相等 哈希 后缀数组 LCP §2、§9
多个模式串在文本中出现几次 AC 自动机 SA + 二分 §8
本质不同子串数 SAM SA §10、§9
最长公共子串(两串) SAM / SA+二分 广义 SAM §10、§11
最长公共子串(多串) 广义 SAM SA+二分+单调队列 §11
子串出现次数 SAM(parent 树) SA + 区间 §10
回文子串计数 PAM / Manacher 哈希+二分 §12、§6
回文划分 PAM + series link Manacher + DP §12
任意两后缀的 LCP SA + ST 表 哈希 + 二分 §9
字典序第 k 小子串 SAM / SA — §10
全部平方串 Main-Lorentz SA + 关键点 §16

0.6 记号与前置换算

一个重要事实:正文的下标全部 1-based,但代码里 0-based。这个差异是大量 off-by-one 错误的来源,下文代码注释会标出。

1. 记号与基础

1.1 字符串的六个记号

后面全篇只用这几个记号,先一次说清。

定义:设 s 是一个长度为 n 的字符串,下标从 1 开始。

注意 border 与周期的关系,这是全篇最重要的一条等价:

验证:s=\texttt{ababa},n=5。它有长度 3 的 border(aba),所以 5-3=2 应该是周期。检查:s_1=s_3=\texttt{a}、s_2=s_4=\texttt{b}、s_3=s_5=\texttt{a},成立。反方向也一样:周期 p 意味着 s_i=s_{i+p},把 i=1..n-p 的等式串起来,就得到 s[1..n-p]=s[p+1..n],即长度为 n-p 的 border。

这条等价把"前后缀相等"和"平移后自重合"两种完全不同的直觉联系起来。后面求最小循环节、判周期串,用的都是它。

1.2 字符集与编码

定义:字符集是字符串中允许出现的字符的集合,记作 \Sigma。竞赛里常见的三种:

| 字符集 | 大小 |\Sigma| | 处理方式 | | ---------- | --------------- | --------------------- | | 小写字母 | 26 | 直接 c - 'a' 当下标 | | 可见 ASCII | 95 | 直接 c - ' ' 当下标 | | 任意整数 | 10^9 | 必须离散化或哈希 |

为什么 e 要点出字符集:因为它直接决定 Trie 和 AC 自动机的空间。Trie 每个节点要开 |\Sigma| 个儿子指针,字符集是 26 时 \sum|s|=3\times10^5 需要 3\times10^5\times26\times4\text{B}=31\text{MB}(勉强);字符集是 10^9 时根本开不下,必须换成 map 或"只在用到的边上开点"。

一条实用建议:读入字符串时统一转成 1 到 |\Sigma| 的整数存进 int 数组。这样后面所有算法都不用再碰 char,也避免了字符集大小不固定带来的麻烦。

// 统一转成 1..k 的整数,k 由题目给定
int n; int a[N];
string s; cin >> s; n = s.size();
For(i, 1, n) a[i] = s[i - 1];              // 直接用 ASCII 值当字符集,k = 256

1.3 朴素匹配与它的复杂度

先把第 0 章那个朴素做法分析清楚,后面每一把武器都是相对它改进。

定理:朴素匹配的最坏复杂度是 \Theta(nm)。

证明:取 S=\texttt{a}^n\texttt{b}、T=\texttt{a}^m\texttt{b}(m\le n)。对每个起点 i,都要比较 m 次才失败(最后一位不同),共 n-m+1 个起点,总比较次数 (n-m+1)m。取 m=\lceil n/2\rceil 得 \Theta(n^2)。\square

证明要点:最坏情况的构造方式是"每次都匹配到最后一位才失败"。

适用边界:随机数据下朴素匹配是 O(n) 的(平均只要比较 1–2 次就失配),所以很多暴力能水过——但出题人可以轻松构造数据卡掉它。

相关例题 P3375 【模板】KMP · P4824 Censoring S · CSES-1753 String Matching

1.4 Trie:把公共前缀合并

Trie 属于"结构"类工具,在数据结构卷已经详细讲过,这里只说它在字符串问题里的位置。

核心观察:如果一堆字符串共享前缀,那么"前缀"这个部分只需要存一次。

**它回答的问题**:某个前缀出现了多少次、某个串是否存在、以某串为前缀的串有多少个。这些是哈希做不到的——哈希只能回答"整体是否相等",回答不了"前缀关系"。 Trie 的完整实现在数据结构讲义第 10 节。**本卷只用到它作为一个"前缀关系"的索引**,第 6 章会讲什么时候该用它而不是哈希。 **相关例题** [P8306 【模板】字典树](https://www.luogu.com.cn/problem/P8306) · [P2580 于是他错误的点名开始了](https://www.luogu.com.cn/problem/P2580) · *[CSES-1731 Word Combinations](https://cses.fi/problemset/task/1731)* ### 1.5 本章小结 字符串问题的所有记号都围绕一件事:**前缀、后缀、以及它们的重合**。border 与周期的等价是整个理论的地基,后面 KMP、Z 函数、Manacher 全都在利用"重合"这个结构。 --- ## 2. 字符串哈希 ### 2.1 动机:能不能"比较两个子串"变成"比较两个数" 回到第 0 章的问题。朴素匹配贵在"逐字符比较",那能不能把一整个子串压成一个数,然后**只比较这个数**? 如果能,并且能做到: 1. 预处理 $O(n)$; 2. 任取子串,$O(1)$ 算出它的"数"; 那么匹配就变成:预处理模式串的"数",扫一遍文本串的每个窗口,$O(1)$ 比较。总复杂度 $O(n+m)$。 哈希就是干这个的。 ### 2.2 定义与构造 ![多项式哈希与自然溢出缺陷](https://cdn.luogu.com.cn/upload/image_hosting/sm32hyod.webp) *多项式哈希:$B$ 进制权重、前缀哈希 $h_i$、区间差分* **直觉**:把字符串看成一个 $B$ 进制数,每一位是字符的编码。 **定义**:取底数 $B$ 和模数 $P$,定义字符串 $s$ 的哈希为 $$H(s)=\sum_{i=1}^{|s|} s_i\cdot B^{|s|-i}\pmod P \tag{1}$$ 例如 $B=131$,$\texttt{AB}$ 的 ASCII 是 $65,66$,则 $H(\texttt{AB})=65\cdot131+66=8581$。 **为什么是 $B$ 进制**:因为 $B$ 进制数的每一位"位置"决定了它的权重。这和十进制 $123=1\cdot10^2+2\cdot10+3$ 完全一样,只不过换成了 $B$ 和字符编码。 **关键的观察**:设 $h_i$ 为前缀 $s[1..i]$ 的哈希。把 (1) 展开: $$h_r=\underbrace{s_1B^{r-1}+\cdots+s_{l-1}B^{r-l+1}}_{\text{前 }l-1\text{ 个字符}}+\underbrace{s_lB^{r-l}+\cdots+s_r}_{\text{恰好是 }s[l..r]\text{ 的哈希}}$$ 前半段是 $h_{l-1}$ 整体乘上了 $B^{r-l+1}$(因为每个字符的指数都多了 $r-l+1$)。所以 $$\text{hash}(l,r)=\left(h_r-h_{l-1}\cdot B^{r-l+1}\right)\bmod P \tag{2}$$ 这就是哈希能做 $O(1)$ 子串查询的全部秘密——**一次减法**。 ### 2.3 实现 需要预存两个数组:$h_i$(前缀哈希)与 $pw_i=B^i\bmod P$。 ```cpp #include <bits/stdc++.h> #define For(i, j, k) for (int i = j; i <= k; ++ i) using namespace std; using ll = long long; const int N = 1e6 + 10; const ll B = 131, P = 1e9 + 7; ll h[N], pw[N]; void init(const string &s) { int n = s.size(); pw[0] = 1; For(i, 1, n) { pw[i] = pw[i - 1] * B % P; h[i] = (h[i - 1] * B + s[i - 1]) % P; // 注意 s 是 0-indexed } } ll get(int l, int r) { // 1-indexed 子串 ll x = (h[r] - h[l - 1] * pw[r - l + 1]) % P; return (x % P + P) % P; // 必须补正 } ``` **实现陷阱一:减法可能出负数**。C++ 的 `%` 对负数返回负数,所以 `(x % P + P) % P` 是必须的。漏掉这一步,某些数据会返回负的哈希值,导致比较失败。 **实现陷阱二:`pw` 必须预处理**。若每次现算 $B^{r-l+1}$,要么用快速幂 $O(\log P)$,要么乘 $r-l+1$ 次 $O(n)$,两种都把 $O(1)$ 查询破坏了。 **实现陷阱三:字符下标**。如果 `s` 是 0-indexed(`std::string` 就是),那么第 $i$ 个字符是 `s[i-1]`。把 `s[i]` 写进去会整体错位一位,而且样例常常能过(因为错位后仍然自洽),极难发现。 **相关例题** *[P3370 【模板】字符串哈希](https://www.luogu.com.cn/problem/P3370)* · [CF271D Good Substrings](https://codeforces.com/problemset/problem/271/D) #### 例题:P3370【模板】字符串哈希 **题意**:给 $N$ 个字符串($N\le10^4$,长度 $\le1500$),求不同的字符串个数。 **关键转化**:双哈希 + `set` 去重。 **为什么不用排序**:字符串比较是 $O(L)$,总 $O(NL\log N)$;哈希把它降到 $O(NL)$。 **为什么双哈希**:$N=10^4$ 时单哈希的安全界限是 $\sqrt{P}\approx4.5\times10^4$,勉强够但没余量。 ```cpp #include <bits/stdc++.h> using namespace std; typedef unsigned long long ull; const ull B1 = 131, P1 = 1e9 + 7, B2 = 13331, P2 = 1e9 + 9; int main() { int n; scanf("%d", &n); set<pair<ull, ull>> st; static char s[1510]; for (int i = 1; i <= n; ++ i) { scanf("%s", s + 1); ull h1 = 0, h2 = 0; for (int j = 1; s[j]; ++ j) { h1 = (h1 * B1 + s[j]) % P1; h2 = (h2 * B2 + s[j]) % P2; } st.insert({h1, h2}); } printf("%d\n", (int)st.size()); return 0; } ``` **复杂度**:$O(NL\log N)$。**要点**:两个底数与两个模数都必须独立,共享底数会让 Anti-Hash 构造同时生效。 --- ### 2.4 错误率:到底有多大 这是哈希最容易被含糊过去的地方。用生日悖论可以算清楚。 **模型**:假设哈希值在 $[0,P)$ 上均匀分布。比较 $n$ 对字符串,问至少一次碰撞的概率。 第 $k$ 次比较时已有 $k-1$ 个哈希值,新值撞上的概率约 $\frac{k-1}{P}$。当 $n\ll P$ 时,各次近似独立,总概率 $$\Pr\approx\sum_{k=1}^{n}\frac{k-1}{P}=\frac{n(n-1)}{2P}\approx\frac{n^2}{2P} \tag{3}$$ 代入具体数字: | 模数 | $n=10^5$ | $n=10^6$ | | ----------------------------- | ---------------- | ---------------- | | 单哈希 $P=10^9+7$ | $5\times10^{-3}$ | $50\%$ | | 单哈希 $P=2^{61}-1$ | $2\times10^{-9}$ | $2\times10^{-7}$ | | 双哈希 $P_1P_2\approx10^{18}$ | $5\times10^{-9}$ | $5\times10^{-7}$ | **结论**:$10^6$ 次比较时,单哈希配 $10^9$ 模数**有一半概率出错**。这不是理论担忧,是实测会发生的事。 **双哈希**:用两对 $(B_1,P_1)$、$(B_2,P_2)$,两个哈希都相等才判等。碰撞概率变成两个概率的乘积,即 (3) 式里 $P$ 换成 $P_1P_2$。 ```cpp // 双哈希:两模两底 const ll B1 = 131, P1 = 1e9 + 7; const ll B2 = 13331, P2 = 1e9 + 9; ll h1[N], h2[N], p1[N], p2[N]; void init(const string &s) { int n = s.size(); p1[0] = p2[0] = 1; For(i, 1, n) { p1[i] = p1[i - 1] * B1 % P1, p2[i] = p2[i - 1] * B2 % P2; h1[i] = (h1[i - 1] * B1 + s[i - 1]) % P1; h2[i] = (h2[i - 1] * B2 + s[i - 1]) % P2; } } pair<ll, ll> get(int l, int r) { ll a = ((h1[r] - h1[l - 1] * p1[r - l + 1]) % P1 + P1) % P1; ll b = ((h2[r] - h2[l - 1] * p2[r - l + 1]) % P2 + P2) % P2; return {a, b}; } ``` **相关例题** *[P5278 算术天才⑨与等差数列](https://www.luogu.com.cn/problem/P5278)* · [P4503 企鹅 QQ](https://www.luogu.com.cn/problem/P4503) ### 2.5 三种卡法与三件套防御 哈希题被卡是真实存在的。三种卡法: **卡法一:自然溢出 + Thue-Morse**。用 `unsigned long long` 相当于模 $2^{64}$,而 $2^{64}$ **不是质数**。存在两个不同字符串哈希相同,且可以构造出来: $$\begin{aligned} t_0&=\texttt{a},\quad t_1=\texttt{ab},\\ t_{i+1}&=t_i+\overline{\text{reverse}(t_i)} \end{aligned}$$ ($\overline{\cdot}$ 表示逐位取反。)取 $t_{11}$ 的相邻两段,它们在模 $2^{64}$ 下哈希相同。所以**不要用自然溢出做唯一哈希**。 **卡法二:固定底数 + 生日攻击**。底数 $B$ 固定且已知时,攻击者可以在 $O(\sqrt P)$ 内找到碰撞。$P=10^9$ 时只需约 $3\times10^4$ 次尝试——一次比赛的时间绰绰有余。 **卡法三:`unordered_map` 的恒等哈希**。`std::hash<int>` 是恒等映射,构造 $10^5$ 个模桶数同余的数就能让所有元素挤进一个桶,退化成 $O(n^2)$。 **三件套防御**: ```cpp // ① 随机底数:让卡法二失效 mt19937_64 rng(chrono::steady_clock::now().time_since_epoch().count()); const ll B = rng() % (P - 1000) + 1000; // ② 双哈希:让卡法一失效 // ③ 自定义 hash:让卡法三失效 struct custom_hash { static uint64_t splitmix64(uint64_t x) { x += 0x9e3779b97f4a7c15; x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9; x = (x ^ (x >> 27)) * 0x94d049bb133111eb; return x ^ (x >> 31); } size_t operator()(uint64_t x) const { static const uint64_t FIXED_RANDOM = chrono::steady_clock::now().time_since_epoch().count(); return splitmix64(x + FIXED_RANDOM); } }; unordered_map<int, int, custom_hash> mp; ``` **相关例题** [P5278 算术天才⑨与等差数列](https://www.luogu.com.cn/problem/P5278) · *[P8819 星战](https://www.luogu.com.cn/problem/P8819)* ### 2.6 哈希的六个应用 **应用一:判断子串相等**。见 2.3。 **应用二:字符串匹配**。算模式串的哈希,扫文本串每个长度为 $|T|$ 的窗口比较。$O(n+m)$,实现比 KMP 短得多。代价是有碰撞风险。 **应用三:判断回文**。正着建一个哈希、反着建一个,判断 $s[l..r]$ 与 $\text{rev}(s[l..r])$ 是否相等。$O(1)$ 一次。这是 Manacher 的替代方案。 **应用四:最长回文子串(哈希 + 二分)**。枚举中心,二分半径,每次 $O(1)$ 判断是否回文。$O(n\log n)$。比 Manacher 慢一个 $\log$,但**不会写错**——考场上这是重要的价值。 **应用五:哈希 + 二分求 LCP**。二分长度,判断两个后缀的前 $k$ 位是否相等,逐步收紧。$O(\log n)$ 求一次 LCP。这是所有"用哈希替代后缀数组"的技巧的核心。 ```cpp // 哈希 + 二分求两个后缀的 LCP int lcp(int i, int j) { int l = 0, r = min(n - i + 1, n - j + 1); while (l < r) { int mid = l + r + 1 >> 1; if (get(i, i + mid - 1) == get(j, j + mid - 1)) l = mid; else r = mid - 1; } return l; } ``` **应用六:判断循环节**。若 $s$ 由长度为 $k$ 的串重复构成,则 $s[1..n-k]=s[k+1..n]$。哈希 $O(1)$ 判断,$O(n)$ 枚举 $k$。 **相关例题** [P3370 【模板】字符串哈希](https://www.luogu.com.cn/problem/P3370) · [P3501 ANT-Antisymmetry](https://www.luogu.com.cn/problem/P3501) · [P4555 最长双回文串](https://www.luogu.com.cn/problem/P4555) · *[P4503 企鹅 QQ](https://www.luogu.com.cn/problem/P4503)* #### 习题池:哈希(按难度递增) | 题号 | 题名 | 平台 | 难度 | | ----- | ------------------------------------------------------------ | ---- | ----------- | | P1955 | [[NOI2015] 程序自动分析](https://www.luogu.com.cn/problem/P1955) | 洛谷 | 普及+/提高 | | P1381 | [单词背诵](https://www.luogu.com.cn/problem/P1381) | 洛谷 | 普及+/提高 | | P2312 | [[NOIP2014] 解方程](https://www.luogu.com.cn/problem/P2312) | 洛谷 | 普及+/提高 | | P5018 | [[NOIP2018] 对称二叉树](https://www.luogu.com.cn/problem/P5018) | 洛谷 | 普及+/提高 | | P4391 | [[BalticOI2009] Radio Transmission](https://www.luogu.com.cn/problem/P4391) | 洛谷 | 普及+/提高 | | P4503 | [[CTSC2014] 企鹅 QQ](https://www.luogu.com.cn/problem/P4503) | 洛谷 | 提高+/省选− | | P3498 | [[POI2010] KOR-Beads](https://www.luogu.com.cn/problem/P3498) | 洛谷 | 提高+/省选− | | P5446 | [[THUPC2018] 绿绿和串串](https://www.luogu.com.cn/problem/P5446) | 洛谷 | 提高+/省选− | | P3763 | [[TJOI2017] DNA](https://www.luogu.com.cn/problem/P3763) | 洛谷 | 省选/NOI− | | P3538 | [[POI2012] OKR-A Horrible Poem](https://www.luogu.com.cn/problem/P3538) | 洛谷 | 省选/NOI− | | P3792 | [由乃与大母神原型和偶像崇拜](https://www.luogu.com.cn/problem/P3792) | 洛谷 | 省选/NOI− | | P5278 | [算术天才⑨与等差数列](https://www.luogu.com.cn/problem/P5278) | 洛谷 | NOI | | P2757 | [[国家集训队] 等差子序列](https://www.luogu.com.cn/problem/P2757) | 洛谷 | NOI | | P3823 | [[NOI2017] 蚯蚓排队](https://www.luogu.com.cn/problem/P3823) | 洛谷 | NOI | --- ### 2.7 本章小结 哈希的本质是**用一个数代表一整段**,代价是"代表得可能不准"。所有哈希技巧都在两个方向上做取舍:要么增加计算量换准确率(双哈希、大模数),要么接受风险换简单(单哈希、自然溢出)。 **用不用哈希的判据**:若判错代价可接受(例如还能用别的条件二次验证),用哈希最省事;若是答案计数类、错一个就全错,优先用确定性算法(KMP/Z/Manacher)。 --- ## 3. Trie 字典树 ### 3.1 它解决什么问题 前两章的工具都在处理"**一个**字符串的内部结构":哈希算子串、KMP 算 border。但有一类问题关心的是"**一堆**字符串之间的共同前缀": - 给 $n$ 个字符串,多次询问"以 $p$ 为前缀的串有多少个"; - 给 $n$ 个数,求两两异或的最大值; - 给一个数集,求"与 $x$ 异或最大的那个数"; - 在树上/区间上做异或查询。 这些问题的共同点是:**要把若干个字符串(或数)的公共前缀结构显式地建出来**。Trie 就是这个结构。 **一句话定义**:Trie 是一棵多叉树,从根到某个节点的路径拼起来是一个前缀;同一个前缀只存一份。 **为什么这能加速**:因为"共享前缀"被物理地共享了。查"以 `ab` 为前缀"只需要走两步到 `ab` 节点,然后读该节点维护的计数——**与词典里有几个串无关**。 ### 3.2 定义与三种实现 ![Trie 三种形态](https://cdn.luogu.com.cn/upload/image_hosting/637l0ovl.webp) *基础 Trie(附 cnt)与 $01$-Trie* **定义 3.1**:字符集为 $\Sigma$ 的 Trie 是一棵有根树,满足: 1. 每个节点有至多 $|\Sigma|$ 个儿子,每条边标记一个字符; 2. 从根到任一节点的路径上,边标记拼成的字符串称为该节点**代表的前缀**; 3. 不同节点代表的前缀互不相同。 **核心性质**:节点 $v$ 代表前缀 $p$,则 $v$ 的子树中所有节点代表的前缀都以 $p$ 开头。**这条性质是所有 Trie 应用的基础**。 #### 实现一:二维数组(最常用) 字符集小且固定(如 $26$ 个小写字母)时,直接用 `ch[u][c]`: ```cpp const int N = 1e5 + 10; // 节点数上限 int ch[N][26], cnt[N], tot = 1; // tot 从 1 开始,0 是根 void insert(const char *s) { int u = 1; // 根 for (int i = 0; s[i]; ++ i) { int c = s[i] - 'a'; if (!ch[u][c]) ch[u][c] = ++ tot; // 新建节点 u = ch[u][c]; ++ cnt[u]; // 经过该节点的串数 +1 } } // 查询以 s 为前缀的串数 int query_prefix(const char *s) { int u = 1; for (int i = 0; s[i]; ++ i) { int c = s[i] - 'a'; if (!ch[u][c]) return 0; // 前缀不存在 u = ch[u][c]; } return cnt[u]; } ``` **空间**:$O(\text{节点数}\times|\Sigma|)$。$10^5$ 个节点、字符集 $26$ 时要 $2.6\times10^6$ 个 `int`,约 10 MB。 #### 实现二:`map` 或哈希表(字符集大) 字符集是全体 ASCII / 或者节点稀疏时,用 `map<int,int>` 或 `unordered_map`: ```cpp struct Trie { vector<unordered_map<int,int>> ch; vector<int> cnt; Trie() : ch(1), cnt(1) {} void insert(const string &s) { int u = 0; for (char c : s) { if (!ch[u].count(c)) { ch[u][c] = ch.size(); ch.emplace_back(); cnt.push_back(0); } u = ch[u][c]; ++ cnt[u]; } } }; ``` **代价**:每个节点多一个哈希表的开销(约 50–100 字节),但省掉了空儿子。 **选择原则**:$|\Sigma|\le64$ 用数组;否则用 `map`。 #### 实现三:左儿子右兄弟(省空间,少用) 把多叉树转成二叉树,每个节点存"第一个儿子"和"下一个兄弟"。 ```cpp int son[N], bro[N], tot; // 遍历 u 的所有儿子:for (int v = son[u]; v; v = bro[v]) ``` **空间**:每个节点 2 个指针,与字符集大小无关。代价是查询时要沿兄弟链找,常数变大。 **竞赛建议**:除非空间极度紧张,**用二维数组**。这是最不容易写错的写法。 ### 3.3 基础应用:前缀统计与检索 **应用一:多模式串的前缀查询。** 就是 3.2 的 `query_prefix`——$O(|p|)$ 回答,与词典规模无关。 **应用二:判断一个串是否在词典里。** 需要额外记录"有多少串以该节点**结尾**"(`end[u]`),而不是经过它的串数。这两个量不同: - `cnt[u]`:经过 $u$ 的串数($u$ 代表的前缀是它们的**前缀**); - `end[u]`:以 $u$ 代表的前缀**恰好结束**的串数。 **实测**(脚本第 1、5 项):词典 `{abc, abd, ab, bcd, a}`: | 前缀 $p$ | `cnt`(以 $p$ 为前缀的串数) | 暴力验证 | | -------- | ---------------------------- | -------- | | `a` | 4 | 4 ✓ | | `ab` | 3 | 3 ✓ | | `abc` | 1 | 1 ✓ | | `b` | 1 | 1 ✓ | | `z` | 0 | 0 ✓ | **应用三:求最长公共前缀(LCP)。** 多个串的 LCP 就是"它们对应节点在 Trie 上的 LCA 的深度"。配合 LCA 可以 $O(1)$ 回答任意两个串的 LCP。 **应用四:字典序排序。** Trie 的先序遍历顺序**就是**字典序。这在"按字典序第 $k$ 小"类问题里直接可用。 ### 3.4 $01$-Trie:把数当字符串 **核心观察**:一个非负整数可以看成"固定长度为 $LG$ 的 01 串"(高位在前)。于是"数的公共前缀"= "二进制的高位相同",而这正是异或运算看重的。 **定义 3.2**:$01$-Trie 是字符集为 $\{0,1\}$、串长固定为 $LG$ 的 Trie。 #### 求"与 $x$ 异或最大的数" **贪心策略**:从最高位往低走,**尽量走与 $x$ 当前位相反的分支**(因为 $1\oplus0=1$,相反位能贡献 1)。 ```cpp const int LG = 30; int ch[N * (LG + 1)][2], tot = 1; void insert(int x) { int u = 1; Rof(i, LG, 0) { int b = (x >> i) & 1; if (!ch[u][b]) ch[u][b] = ++ tot; u = ch[u][b]; } } // 在已插入的数中找与 x 异或最大的,返回异或值 int query_max(int x) { int u = 1, res = 0; Rof(i, LG, 0) { int b = (x >> i) & 1, want = b ^ 1; // 想要相反位 if (ch[u][want]) { res |= 1 << i; // 这一位能贡献 1 u = ch[u][want]; } else u = ch[u][b]; // 只能走同边 } return res; } ``` **定理 3.3(贪心正确性)**:上述算法返回的异或值是所有已插入数中与 $x$ 异或最大的。 **证明**:考虑贪心与最优解 $T$ 的最高不同位 $k$。贪心在第 $k$ 步时,若存在与 $x$ 第 $k$ 位相反的分支,则贪心走了它并让第 $k$ 位为 1;而 $T$ 在这一位为 0(因为 $T$ 与贪心在此位不同)。**为什么 $T$ 不能也走那个分支**:若 $T$ 走了相反分支,则它在这一位也是 1,与"最高不同位是 $k$"矛盾。所以贪心的第 $k$ 位是 1、$T$ 的是 0,而更高的位两者相同,故贪心 $>T$。$\square

证明要点:高位权重大于所有低位之和,所以"先保证高位能取 1"必然最优——与线性基的贪心是同一个论证。

实测(脚本第 2 项):数字组 \{3,10,5,25,2,8\},最大异或对 =28,与 O(n^2) 暴力一致 ✓。

求"第 k 小异或值"

需要每个节点的子树大小(sz)。当前位尝试走 0 分支,若 k\le sz_0 就走 0,否则 k\leftarrow k-sz_0 走 1。

// 在 01-Trie 中求与 x 异或的第 k 小值(k 从 1 开始)
int query_kth(int x, int k) {
    int u = 1, res = 0;
    Rof(i, LG, 0) {
        int b = (x >> i) & 1;
        // 先考虑能让这一位为 0 的分支:与 x 同位
        if (ch[u][b] && sz[ch[u][b]] >= k) u = ch[u][b];
        else {
            if (ch[u][b]) k -= sz[ch[u][b]];
            res |= 1 << i;                       // 这一位贡献 1
            u = ch[u][b ^ 1];
        }
    }
    return res;
}

注意:这里的"第 k 小"是在异或值上排序,不是在原数上。所以遍历时是"优先让异或结果的当前位为 0"。

01-Trie 与线性基的分工

01-Trie 线性基
支持 查询与给定 x 的异或最值 查询子集异或最值
插入 O(LG) O(LG)
最大异或 对每个 x 查一次,O(n\cdot LG) 直接贪心,O(LG)
区间查询 可持久化版支持 前缀线性基支持
可合并 ✓(见 3.5) ✓

判据:问"两个数的异或"用 01-Trie;问"子集的异或"用线性基。两者不能互替。

例题:P4551 最长异或路径

题意:给 n 个点的带权树(n\le10^5,边权 <2^{31}),求树上所有路径中"边权异或和"的最大值。

关键转化:路径异或和 = 两个点到根的前缀异或和的异或。

第一步:为什么。设 d_u 为根到 u 的路径异或和。对路径 u\to v(设 w=\operatorname{lca}(u,v)):

d_u\oplus d_v=\bigl(d_w\oplus\mathrm{path}(w,u)\bigr)\oplus\bigl(d_w\oplus\mathrm{path}(w,v)\bigr)=\mathrm{path}(w,u)\oplus\mathrm{path}(w,v)=\mathrm{path}(u,v)

因为 d_w 出现两次,异或抵消。

第二步:问题转化。于是"求路径异或最大值"变成"在数组 d_1..d_n 中求两数异或的最大值"——这是 01-Trie 的标准问题。

第三步:01-Trie。把 d_i 全部插入 01-Trie(LG=30,因为边权 <2^{31},路径异或和也 <2^{31}),然后对每个 d_i 查询"与它异或最大的值",取最大值。

#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int N = 1e5 + 10, LG = 30;
struct Edge { int to; int w; };
vector<Edge> e[N];
int d[N];
int ch[N * 32][2], tot = 1;

void insert(int x) {
    int u = 1;
    for (int i = LG; i >= 0; -- i) {
        int b = (x >> i) & 1;
        if (!ch[u][b]) ch[u][b] = ++ tot;
        u = ch[u][b];
    }
}
int query_max(int x) {
    int u = 1, res = 0;
    for (int i = LG; i >= 0; -- i) {
        int b = (x >> i) & 1, want = b ^ 1;
        if (ch[u][want]) res |= 1 << i, u = ch[u][want];
        else u = ch[u][b];
    }
    return res;
}
void dfs(int u, int fa, int cur) {
    d[u] = cur;
    for (auto ed : e[u]) if (ed.to != fa)
        dfs(ed.to, u, cur ^ ed.w);
}
int main() {
    int n; scanf("%d", &n);
    for (int i = 1; i < n; ++ i) {
        int u, v, w; scanf("%d%d%d", &u, &v, &w);
        e[u].push_back({v, w});
        e[v].push_back({u, w});
    }
    dfs(1, 0, 0);
    for (int i = 1; i <= n; ++ i) insert(d[i]);
    int ans = 0;
    for (int i = 1; i <= n; ++ i) ans = max(ans, query_max(d[i]));
    printf("%d\n", ans);
    return 0;
}

样例验证:树 1{-}2(3)、2{-}3(4)、2{-}4(6),答案 7(路径 1\to3:3\oplus4=7)✓。

复杂度:DFS O(n),插入与查询各 O(n\cdot LG)。总 O(n\cdot LG)。空间 O(n\cdot LG) 个节点。

点评:"路径异或和转成两点前缀异或"这个技巧是本题的灵魂,它把树上问题拍平成了数组问题。同类题还有"树上路径计数异或和为定值",也是这个转化 + Trie 计数。

3.5 01-Trie 合并

01-Trie 合并:同向子树递归合并,节点权值相加 问题:有两棵 01-Trie(或同一棵树的多个子树),要把它们合并成一棵。

做法:递归合并。设合并 u(来自 A)与 v(来自 B):

// 01-Trie 合并:把 v 合并进 u,返回合并后的根
int merge(int u, int v) {
    if (!u || !v) return u | v;
    sz[u] += sz[v];
    ch[u][0] = merge(ch[u][0], ch[v][0]);
    ch[u][1] = merge(ch[u][1], ch[v][1]);
    return u;
}

复杂度:O(\text{重叠节点数})。因为只有两边都非空的节点才会继续递归,而每个节点最多被"消耗"一次。

总复杂度(树上合并场景):若每个叶子初始贡献 O(LG) 个节点,总共 O(n\cdot LG) 个节点,全部合并的总额外代价是 O(n\cdot LG)——均摊线性。

典型用途:树上启发式合并(DSU on Tree)的替代品、线段树合并的"值域为二进制"版本。

相关例题 P6072 『MdOI R1』Path · P6018 [Ynoi2010] Fusion tree

3.6 可持久化 01-Trie

版本 1 插入 1(二进制 01),版本 2 插入 2(二进制 10)。橙色是版本 2 新建的三个节点;加粗边框的「0」节点由两个版本共用——版本 2 的根直接指向它,没有复制。虚线小方块是空儿子。

问题:给序列 a_1..a_n,多次询问"区间 [l,r] 内与 x 异或的最大值"。

朴素做法:对每个询问重建 Trie,O(n\cdot LG) 每次,不可行。

关键想法:像主席树一样,对每个前缀建一个 Trie 版本;查询 [l,r] 时用"版本 r 减去版本 l-1"的节点计数差判断某个分支是否在区间内存在。

建树:插入 a_i 时,从版本 i-1 的根开始,沿途所有节点都复制一份,把改变的路径上的 cnt 加 1。

int ch[N * 32][2], cnt[N * 32], rt[N], tot;

// 在版本 o 的基础上插入 x,返回新版本根
int insert(int o, int x) {
    int root = ++ tot, u = root;
    cnt[u] = cnt[o] + 1;
    Rof(i, LG, 0) {
        int b = (x >> i) & 1;
        int &nxt = ch[u][b];                     // 新版本的这一支
        int old = ch[o][b];                      // 旧版本的这一支
        nxt = ++ tot;
        ch[nxt][0] = ch[old][0];                 // 先整份复制
        ch[nxt][1] = ch[old][1];
        cnt[nxt] = cnt[old] + 1;
        u = nxt, o = old;                        // 继续往下
    }
    return root;
}

// 在 (L-1, R] 版本差中查询与 x 异或最大的值
int query(int L, int R, int x) {
    int u = rt[L - 1], v = rt[R], res = 0;
    Rof(i, LG, 0) {
        int b = (x >> i) & 1, want = b ^ 1;
        // 判断 want 分支在区间内是否有数
        if (cnt[ch[v][want]] - cnt[ch[u][want]] > 0) {
            res |= 1 << i;
            u = ch[u][want], v = ch[v][want];
        } else {
            u = ch[u][b], v = ch[v][b];
        }
    }
    return res;
}

为什么"版本差"能表示区间:版本 k 记录了 a_1..a_k 的全部信息(每个节点的 cnt 是"a_1..a_k 中有多少个数经过这个节点")。所以 cnt[版本 R] - cnt[版本 L-1] 就是"a_L..a_R 中有多少个数经过这个节点"——正是区间内的计数。

复杂度:建树 O(n\cdot LG) 时间与空间(每次插入新增 LG+1 个节点),单次查询 O(LG)。

实测(脚本第 6 项):数组 [5,3,9,7,2],询问区间 [2,4] 与 x=6,可持久化 Trie 得 15,暴力 \max(3\oplus6,9\oplus6,7\oplus6)=15 ✓。

注意:可持久化 Trie 的空间是 O(n\cdot LG) 个节点,n=10^5、LG=30 时是 3.1\times10^6 个节点——*要开 `int ch[N32][2]` 这种规模**,别开小了。

例题:P4735 最大异或和

题意:给序列 a_1..a_n(n,m\le3\times10^5,a_i<2^{24}),支持两种操作:

  1. 在末尾追加一个数(序列变长);
  2. 给定 l,r,x,求 \max_{l\le p\le r}\bigl(a_p\oplus a_{p+1}\oplus\cdots\oplus a_N\oplus x\bigr),其中 N 是当前序列长度。

关键转化:后缀异或和 = 两个前缀异或和的异或——把它变成"区间内与 x 异或最大"。

第一步:化简。 设 s_i=a_1\oplus\cdots\oplus a_i(s_0=0)。则

a_p\oplus\cdots\oplus a_N=s_N\oplus s_{p-1}

所以要求的式子是

\max_{l\le p\le r}\bigl(s_N\oplus s_{p-1}\oplus x\bigr)=(s_N\oplus x)\ \oplus\ \max_{l-1\le q\le r-1}s_q

其中 q=p-1。于是问题变成:在 s_{l-1},s_{l},\ldots,s_{r-1} 中找一个与定值 c=s_N\oplus x 异或最大的。

第二步:可持久化 01-Trie。 对每个前缀异或和 s_i 建一个 Trie 版本(版本 i 含 s_0..s_i)。查询区间 [l-1,r-1] 时用

\text{cnt}[\text{版本 }r-1]-\text{cnt}[\text{版本 }l-2]

判断某个分支在区间内是否存在。

第三步:追加操作。 直接在最新版本上继续插入即可(就是 extend)。

#include <bits/stdc++.h>
using namespace std;
const int N = 3e5 + 10, LG = 30, M = N * (LG + 2);
int ch[M][2], cnt[M], rt[N], tot;
int n, m, s[N];

int insert(int o, int x) {                 // 在版本 o 上插入 x
    int root = ++ tot, u = root;
    cnt[u] = cnt[o] + 1;
    for (int i = LG; i >= 0; -- i) {
        int b = (x >> i) & 1;
        int nxt = ++ tot;
        ch[nxt][0] = ch[ch[o][b]][0];
        ch[nxt][1] = ch[ch[o][b]][1];
        cnt[nxt] = cnt[ch[o][b]] + 1;
        ch[u][b] = nxt;
        u = nxt, o = ch[o][b];
    }
    return root;
}
int query(int L, int R, int x) {           // 在版本 (L, R] 中查与 x 异或最大
    int u = L, v = R, res = 0;
    for (int i = LG; i >= 0; -- i) {
        int b = (x >> i) & 1, want = b ^ 1;
        if (cnt[ch[v][want]] - cnt[ch[u][want]] > 0) {
            res |= 1 << i; u = ch[u][want], v = ch[v][want];
        } else u = ch[u][b], v = ch[v][b];
    }
    return res;
}
int main() {
    scanf("%d%d", &n, &m);
    rt[0] = insert(0, 0);                  // 版本 0 含 s_0 = 0
    for (int i = 1; i <= n; ++ i) {
        scanf("%d", &s[i]); s[i] ^= s[i - 1];
        rt[i] = insert(rt[i - 1], s[i]);
    }
    int len = n;
    while (m --) {
        char op[2]; scanf("%s", op);
        if (op[0] == 'A') {
            int x; scanf("%d", &x);
            ++ len; s[len] = s[len - 1] ^ x;
            rt[len] = insert(rt[len - 1], s[len]);
        } else {
            int l, r, x; scanf("%d%d%d", &l, &r, &x);
            // a[p]^..^a[len] = s[len] ^ s[p-1]
            // p in [l,r]  =>  q = p-1 in [l-1, r-1]
            // 在 s[l-1 .. r-1] 中找与 (s[len] ^ x) 异或最大
            // 版本 (l-1)-1 = l-2 到 r-1;l=1 时左端用空版本(rt 索引 0 表示"无")
            int Lv = (l >= 2) ? rt[l - 2] : 0;
            printf("%d
", query(Lv, rt[r - 1], s[len] ^ x));
        }
    }
    return 0;
}

复杂度:每次插入 O(LG),每次查询 O(LG),空间 O((n+m)\cdot LG)。

要点:三个易错位置——① 版本 0 必须先插入 s_0=0(否则左端点会把 0 漏掉);② query 的两个版本参数是 l-2 与 r-1 对应的根,而 l=1 时左版本要用空版本;③ LG 要覆盖 s_i 的最大位——a_i\le10^7<2^{24},但 s_N\oplus x 可能超过 2^{24},所以取 LG=25 才安全(用 24 会 WA)。

3.7 Trie 图

Trie 图:橙色虚线为补边 ch[u][c]\leftarrow ch[fail[u]][c] 问题:AC 自动机里,从某个状态读一个字符后,若 Trie 上没有这条边,本来是"沿 fail 链往上找"。能不能把这个过程预处理掉?

做法(Trie 图 / 补全 Trie):对每个节点 u 和每个字符 c,若 ch[u][c] 不存在,就令

ch[u][c] = ch[fail[u]][c]      (若 u 是根,则指向根自己)

这样查询时不需要跳 fail,每次 O(1) 转移。

void build_trie_graph() {
    queue<int> q;
    for (int c = 0; c < 26; ++ c)
        if (ch[1][c]) fail[ch[1][c]] = 1, q.push(ch[1][c]);
        else ch[1][c] = 1;                       // 根的缺失边指向自己
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int c = 0; c < 26; ++ c) {
            int v = ch[u][c];
            if (v) fail[v] = ch[fail[u]][c], q.push(v);
            else ch[u][c] = ch[fail[u]][c];      // 补边
        }
    }
}

代价:把 Trie 补成"完整自动机",空间变成 O(\text{节点数}\times|\Sigma|)(本来稀疏,现在满了)。但查询从"可能要跳多次 fail"变成"O(1)"。

这就是 AC 自动机的标准实现(详见第 8 章)。放在这里是因为它本质上是 Trie 的一个操作。

3.8 Trie 上的 DFS 序与子树信息

核心事实:Trie 是一棵树,所以"某节点子树内的所有前缀"在 DFS 序上是连续区间。

应用一:把"前缀约束"变成"区间约束"。 例如"求以 abc 为前缀的所有串的权值和",就是"求 DFS 序上 [\text{tin}_{abc},\text{tout}_{abc}] 区间的和"。这样就能套线段树 / 树状数组 / 莫队。

应用二:把"两个串的 LCP"变成"LCA 深度"。

\operatorname{LCP}(s,t)=\text{depth}\bigl(\operatorname{LCA}(u_s,u_t)\bigr) \tag{8}

其中 u_s 是 s 对应的节点。这是 Trie 最常用的性质之一。

应用三:Trie 上 DP。 若题目在 Trie 上做"选若干个串,使它们的公共前缀尽量长",直接树形 DP。

相关例题 P5840 [COCI2015] Divljak · P3311 [SDOI2014] 数数 · P3294 [SCOI2016] 背单词

3.9 复杂度与空间

操作 复杂度 说明
插入一个串 $O(\lvert s\rvert\cdot \Sigma
查询前缀 O(\lvert p\rvert) 与词典规模无关
前缀计数 O(\lvert p\rvert) 读 cnt
01-Trie 插入/查询 O(LG) LG = 数值位数
01-Trie 合并 O(\text{重叠节点}) 均摊线性
可持久化插入 O(n\cdot LG) 空间 每版新增 LG+1 节点
Trie 图构建 $O(\text{节点}\times \Sigma

节点数上界:n 个串、串长 L,节点数 \le nL+1。实际远小于上界,因为共享前缀。

实测(脚本第 4 项):

n 串长 字符集 实际节点数 上界 nL+1
1,000 10 26 8,523 10,001
5,000 20 2 44,189 100,001
10,000 15 4 91,600 150,001

字符集越小,共享越多,节点越省。

3.10 实现陷阱

陷阱一:根节点编号。 用 1 作根(这样 0 可以表示"空儿子")。若用 0 作根,ch[u][c]==0 就无法区分"没有儿子"和"儿子是根"。

int ch[N][26], tot = 1;      // tot=1,根是 1,空儿子是 0 ✓

int ch[N][26], tot = 0;      // ✘ 根是 0,与"空"冲突

陷阱二:N 开小。 节点数是 nL+1,不是 n。n=10^5、L=10^5(单个长串)时节点数是 10^5,但如果 n=10^4、L=10^4,就是 10^8——必须按 nL 估。

陷阱三:可持久化 Trie 的数组大小。 O(n\cdot LG) 个节点,n=10^5、LG=30 时要 3.2\times10^6。写成 N*2 会 RE。

陷阱四:01-Trie 的 LG 取值。 要覆盖数值的最大位。数值 \le10^9 时 LG=30 够;\le10^{18} 时要用 LG=60,且类型是 long long。

陷阱五:多组数据不清空。 用 tot = 1 重置,并对新建的节点清空 ch 数组。用 vector 版更方便(clear() 即可)。

3.11 延伸阅读(更多同知识点题目)

相关例题 P2580 于是他错误的点名开始了 · P3879 [TJOI2010] 阅读理解 · P4551 最长异或路径* · P4735 最大异或和 · P5283 [十二省联考2019] 异或粽子 · P2922 [USACO08DEC] Secret Message G · P9753 [CSP-S2023] 消消乐 · P3065 [USACO12DEC] First! G · P4407 [JSOI2009] 电子字典 · P3294 [SCOI2016] 背单词 · P4592 [TJOI2018] 异或 · P6072 『MdOI R1』Path · CF842D Vitya and Strange Lesson · CF706D Vasiliy's Multiset · CF455B A Lot of Games

习题池:Trie / 01-Trie

入门·普及

题号 题名 平台 难度
P1908 逆序对 洛谷 普及/提高−
P2580 于是他错误的点名开始了 洛谷 普及/提高−
P8306 【模板】字典树 / Trie 洛谷 普及/提高−
P3879 [TJOI2010] 阅读理解 洛谷 普及−
P5266 【深基17.例6】学籍管理 洛谷 普及/提高−
P1481 魔族密码 洛谷 普及−
P10471 最大异或对 The XOR Largest Pair 洛谷 普及/提高−
P5755 [NOI2000] 单词查找树 洛谷 普及/提高−
P5149 会议座位 洛谷 普及/提高−
P10470 前缀统计 洛谷 普及/提高−

提高

题号 题名 平台 难度
P9753 [CSP-S 2023] 消消乐 洛谷 提高+/省选−
P1470 [IOI 1996 / USACO2.3] 最长前缀 Longest Prefix 洛谷 普及+/提高
P2922 [USACO08DEC] Secret Message G 洛谷 普及+/提高
P3065 [USACO12DEC] First! G 洛谷 提高+/省选−
P1333 瑞瑞的木棍 洛谷 普及+/提高
P5546 [POI 2000 R3] 公共串 洛谷 普及+/提高
P2536 [AHOI2005] 病毒检测 洛谷 普及+/提高
P6824 「EZEC-4」可乐 洛谷 普及+/提高
P4407 [JSOI2009] 电子字典 洛谷 提高+/省选−

省选

题号 题名 平台 难度
P3796 AC 自动机(简单版 II) 洛谷 省选/NOI−
P5283 [十二省联考 2019] 异或粽子 洛谷 省选/NOI−
P14363 [CSP-S 2025] 谐音替换 洛谷 省选/NOI−
P4585 [FJOI2015] 火星商店问题 洛谷 省选/NOI−
P4098 [HEOI2013] ALO 洛谷 省选/NOI−
P9196 [JOI Open 2016] 销售基因链 / Selling RNA Strands 洛谷 省选/NOI−
P7717 「EZEC-10」序列 洛谷 省选/NOI−
P17170 未来 洛谷 省选/NOI−
P17556 「WWOI R4」WsW 的数字 洛谷 省选/NOI−

NOI

题号 题名 平台 难度
P4735 最大异或和 洛谷 NOI
P4592 [TJOI2018] 异或 洛谷 NOI
P6623 [省选联考 2020 A 卷] 树 洛谷 NOI
P3294 [SCOI2016] 背单词 洛谷 NOI
P6018 [Ynoi2010] Fusion tree 洛谷 NOI
P8511 [Ynoi Easy Round 2021] TEST_68 洛谷 NOI
P10218 [省选联考 2024] 魔法手杖 洛谷 NOI
P6072 『MdOI R1』Path 洛谷 NOI
P6965 [NEERC 2016] Binary Code 洛谷 NOI
P10283 [USACO24OPEN] Identity Theft P 洛谷 NOI

3.12 这一节要带走的三句话

  1. Trie 把"共享前缀"变成物理共享,所以前缀查询 O(|p|),与词典规模无关。
  2. 01-Trie 是"把数当定长 01 串"的 Trie,异或最值靠"尽量走相反位"的高位贪心;求两个数的异或用它,求子集的异或用线性基。
  3. 可持久化 01-Trie 用"版本差"支持区间查询,空间 O(n\cdot LG),数组必须按这个量级开。

4. KMP 与前缀函数

4.1 动机:把"每次从头比"变成"带着信息后退"

再看一次朴素匹配的浪费。

``` S: a b a b a b c T: a b a b c ↑ 失配 ``` 此时我们已经知道 $S[1..4]=\texttt{abab}$ 与 $T[1..4]$ 相同。朴素做法让 $i$ 变成 $2$,从 $T[1]$ 重新开始比较: ``` S: a b a b a b c T: a b a b c ``` 但注意:$S[3..4]=\texttt{ab}$ 已经等于 $T[1..2]$,因为 $T$ 的前 2 位 `ab` 恰好等于 $T$ 的后 2 位 `ab`。这个"前缀等于后缀"的性质,使得**可以直接把 $T$ 滑到 $i=3$**,跳过 $i=2$。 于是问题归结为:**对 $T$ 的每个前缀,求它的"最长相等前后缀"长度**。这就是前缀函数。 ### 4.2 定义 **定义**:字符串 $s$ 的前缀函数 $\pi_i$($1\le i\le n$)定义为 $$\pi_i=\max\{k<i : s[1..k]=s[i-k+1..i]\}$$ 即 $s[1..i]$ 的**最长 border 长度**。若不存在这样的 $k$,$\pi_i=0$。 约定 $\pi_1=0$(长度 1 的串没有真 border)。 **举例**:$s=\texttt{aabaaab}$,逐个算: | $i$ | $s[1..i]$ | 最长 border | $\pi_i$ | | ---- | --------- | ----------- | ------- | | 1 | `a` | 无 | 0 | | 2 | `aa` | `a` | 1 | | 3 | `aab` | 无 | 0 | | 4 | `aaba` | `a` | 1 | | 5 | `aabaa` | `aa` | 2 | | 6 | `aabaaa` | `aaa` | 3 | | 7 | `aabaaab` | `aab` | 3 | **验证 $i=7$**:$s[1..3]=\texttt{aab}$,$s[5..7]=\texttt{aab}$,相等;长度 4 的 `aaba` 与 `baab` 不等,所以最长是 3。 ### 4.3 朴素算法与第一次优化 朴素算法:对每个 $i$,从 $k=i-1$ 往下枚举,检查 $s[1..k]=s[i-k+1..i]$。 **定理**:朴素算法是 $O(n^3)$。 **证明**:外层 $n$ 个 $i$,内层最多 $n$ 个 $k$,每次判等 $O(k)=O(n)$,合计 $O(n^3)$。$\square

关键观察一:\pi_i\le\pi_{i-1}+1。

证明:设 \pi_i=k>0,则 s[1..k]=s[i-k+1..i]。两边去掉最后一个字符得 s[1..k-1]=s[i-k+1..i-1],说明 k-1 是 s[1..i-1] 的一个 border 长度,故 k-1\le\pi_{i-1},即 k\le\pi_{i-1}+1。\square

证明要点:把等式两边各砍掉末尾一位,得到的是 i-1 处的 border。

推论:枚举下界从 i-1 降到 \pi_{i-1}+1。更进一步,只在 s_i=s_{\pi_{i-1}+1} 时才可能取到 \pi_{i-1}+1,否则继续往下。

复杂度:这样改完后字符串比较次数是 O(n),但每次比较仍是 O(k) 的子串判等,总复杂度降到 O(n^2)。还不够。

4.4 关键观察二:失配时跳到哪

真正的问题在"往下枚举"这一步。当 s_i\ne s_{\pi_{i-1}+1} 时,\pi_i 应该是什么?

引理:设 j 是 s[1..i-1] 的一个 border 长度,且 j<\pi_{i-1},那么 j 也是 s[1..\pi_{i-1}] 的 border 长度。

证明:j\le\pi_{i-1} 且 j 是 s[1..i-1] 的 border,所以 s[1..j]=s[i-j..i-1]。又 s[1..\pi_{i-1}]=s[i-\pi_{i-1}..i-1]。由于 j\le\pi_{i-1},s[i-j..i-1] 正好是 s[i-\pi_{i-1}..i-1] 的后缀,也就是 s[1..\pi_{i-1}] 的后缀。所以 s[1..j] 既是 s[1..\pi_{i-1}] 的前缀又是它的后缀,即 j 是它的 border。\square

这个引理说明什么:s[1..i-1] 的所有 border 长度,构成一条链

\pi_{i-1}>\ \pi_{\pi_{i-1}-1}>\ \pi_{\pi_{\pi_{i-1}-1}-1}>\cdots>0

(注意 \pi_{\pi_{i-1}-1} 是"长度 \pi_{i-1} 的前缀的 border",因为 \pi 的下标是长度)。

所以失配时不需要从 i-2 一个个往下试,直接沿这条链跳:

j\leftarrow\pi_{j-1}\quad\text{(当 }s_{i}\ne s_{j+1}\text{ 时)}

直到 j=0 或 s_i=s_{j+1}。

算法:

pi[1] = 0, j = 0
for i = 2 to n:
    while j > 0 and s[i] != s[j+1]:   # 失配,沿 border 链跳
        j = pi[j]
    if s[i] == s[j+1]: j += 1
    pi[i] = j

(j 是"当前已匹配的 border 长度",下一个待比较的字符是 s_{j+1}。注意比较对象是 s_{j+1} 而不是 s_{i+1}——s_i 是正在读入的新字符,s_{j+1} 是"接着往下要比的那一位"。)

border 链:长 6 的串失配后依次退到长 4、长 2 的 border

4.5 复杂度:为什么是 O(n)

这是 KMP 最容易被含糊过去的地方,值得严格讲。

定理:计算整个前缀函数数组的复杂度是 O(n)。

证明(势能分析):定义势能 \Phi_i=\pi_i(第 i 步结束后的前缀函数值)。观察每次迭代 j 的变化:

整个过程中 j 从 0 出发,最多增加 n 次(每次 +1),所以它总共只能减少 n 次。因此 while 的总执行次数不超过 n。外层 n 次迭代,每次 O(1) 均摊,总计 O(n)。\square

证明要点:j 只增不减地"攒",攒一次要一次 +1,所以减的总次数被增的总次数卡住。

这个论证的模式值得记住:凡是"指针只朝一个方向单调移动、每次移动都消耗某种单调量"的算法,都能用同样的方式论证线性复杂度。Z 函数、Manacher、双指针、单调栈全都用这个模式。

4.6 用 \pi 做匹配:KMP 的另一半

到这里我们只算了 \pi,还没拿它做任何事。这一节补上另一半——在文本串上找出模式串的所有出现位置。

问题:已知 T 的 \pi 数组,在 S 中定位 T 的每一次出现。

朴素做法:枚举起点 p=1..n-m+1,逐位比对,O(nm)——正是 §4.1 里被嫌弃的那个做法。

关键:把"已经匹配了多少"记成一个状态

扫描 S 时维护一个整数 j:

j=\max\{k : S[i-k+1..i]=T[1..k]\}

也就是"当前已经匹配上 T 的前 j 位"。这个 j 就是 KMP 的状态。

不变量:处理完 S_i 之后,若 j>0,则

S[i-j+1..i]=T[1..j]

(j=0 时等式退化为空串,平凡成立。)

为什么状态只需一个整数:因为我们要找的是"T 的某个前缀",而前缀由长度唯一确定。长度是 j,内容就是 T[1..j],不必额外存。

三个转移分支

读入 S_i 时:

情形 动作 理由
S_i=T_{j+1} j\mathrel{+}=1 匹配延长一位
S_i\ne T_{j+1} 且 j>0 j\mathrel{=}\pi_j,再重试 沿 border 链后退
S_i\ne T_{j+1} 且 j=0 不动 已无可退

为什么失配时是 j=\pi_j,而不是 j=0:

不变量给出 S[i-j+1..i-1]=T[1..j]。现在 S_i\ne T_{j+1},说明"匹配 j+1 位"失败。那么长度 k<j 还有没有戏?要求

S[i-k+1..i-1]=T[1..k]

而由不变量,左边这段位于 T[1..j] 的末尾,即 S[i-k+1..i-1]=T[j-k+1..j]。所以 k 必须满足

T[1..k]=T[j-k+1..j]

这正是"T[1..j] 的 border"的定义。最大的那个 k 就是 \pi_j——§4.4 那条引理在这里第二次登场。

这条推理同时说明了一件事:失配时 i 不需要回退。所有该退的东西都已经在 j 里了。

为什么匹配成功后是 j=\pi_j,而不是 j=0:模式串可以重叠出现。T=\texttt{aa} 在 S=\texttt{aaaa} 中出现在位置 1,2,3。成功后清零,后两个就漏了。

走一遍

| $i$ | $S_i$ | 进入时 $j$ | 发生的事 | 出 $j$ | | ---- | ----- | ---------- | ---------------------------------------------------- | -------------------- | | 1 | a | 0 | $T_1=\texttt{a}$ ✓ | 1 | | 2 | b | 1 | $T_2=\texttt{b}$ ✓ | 2 | | 3 | a | 2 | $T_3=\texttt{a}$ ✓ | 3 | | 4 | b | 3 | $T_4=\texttt{b}$ ✓ | 4 | | 5 | a | 4 | $T_5=\texttt{c}$ ✗ → $j=\pi_4=2$;$T_3=\texttt{a}$ ✓ | 3 | | 6 | b | 3 | $T_4=\texttt{b}$ ✓ | 4 | | 7 | c | 4 | $T_5=\texttt{c}$ ✓ | 5 → 匹配于 $7-5+1=3$ | 第 5 步是全部要点所在:**$i$ 停在 5 没有动,$T$ 向右滑了 2 格,直接从 $T_3$ 继续**。 ![KMP 匹配时的失配跳转](https://cdn.luogu.com.cn/upload/image_hosting/n34hs4c0.webp) *上排是文本串 $S$;中间是 $T$ 对齐到 $S_1$ 时的情况,$T_5$ 与 $S_5$ 失配(红框);下排是 $T$ 右滑 $\pi_4=2$ 格后的对齐。橙色格标出 $T[1..2]$ 与原来的 $T[3..4]$ 重合——这就是"前 2 位无需重比"的几何含义。* #### 复杂度 同一个势能论证:$j$ 每次成功至多 $+1$,故总共至多增加 $n$ 次;每次跳转让 $j$ 至少 $-1$,故 `while` 总次数 $\le n$。加上 $i$ 从不后退,总 $O(n+m)$。 **与朴素算法的本质差别**:朴素算法失配时退的是 $i$(起点右移一格,重新比),KMP 退的是 $j$。**$i$ 单调不减**,这是线性复杂度的全部来源。 #### 统一的看法:求 $\pi$ 就是在做匹配 把两段代码并排放: ```cpp // 求 pi:用 t 匹配 t 自己,从第 2 位开始 int j = 0; For(i, 2, m) { while (j && t[i] != t[j + 1]) j = pi[j]; if (t[i] == t[j + 1]) ++ j; pi[i] = j; } // 匹配:用 t 匹配 s,从第 1 位开始 int j = 0; For(i, 1, n) { while (j && s[i] != t[j + 1]) j = pi[j]; if (s[i] == t[j + 1]) ++ j; if (j == m) { /* 记录答案 */ j = pi[j]; } } ``` 只有三处不同:扫的串(`t` vs `s`)、起点($2$ vs $1$)、匹配满之后的处理。 **求 $\pi$ 就是把文本串换成模式串自己再做一次 KMP 匹配**。想清楚匹配,$\pi$ 的求法就不必单独记忆;反过来,$\pi_i$ 的含义"$T[1..i]$ 的最长 border"也正是"$T$ 与自己的第 $i$ 位对齐时,匹配长度是多少"。 ### 4.7 完整实现 把 §4.4 的求 $\pi$ 与 §4.6 的匹配拼起来,就是完整的 KMP: ```cpp #include <bits/stdc++.h> #define For(i, j, k) for (int i = j; i <= k; ++ i) using namespace std; const int N = 1e6 + 10; int n, m, pi[N]; char s[N], t[N]; // 1-indexed void build(const char *t, int m, int *pi) { // 求 t 的前缀函数 pi[1] = 0; int j = 0; For(i, 2, m) { while (j && t[i] != t[j + 1]) j = pi[j]; // 失配沿 border 链跳 if (t[i] == t[j + 1]) ++ j; pi[i] = j; } } int main() { scanf("%s%s", s + 1, t + 1); n = strlen(s + 1), m = strlen(t + 1); build(t, m, pi); int j = 0; For(i, 1, n) { // §4.6:在文本串上匹配 while (j && s[i] != t[j + 1]) j = pi[j]; if (s[i] == t[j + 1]) ++ j; if (j == m) { // 匹配成功 printf("%d\n", i - m + 1); j = pi[j]; // 关键:不能清零 } } For(i, 1, m) printf("%d ", pi[i]); return 0; } ``` **实现陷阱一:匹配成功后 `j = pi[j]` 而不是 `j = 0`**。理由见 §4.6 末段——重叠出现的情形($T=\texttt{aa}$ 在 $\texttt{aaaa}$ 中出现在 1,2,3)会被漏掉。 **实现陷阱二:`while (j && ...)` 的 `j` 判断必须在前面**。若写成 `while (t[i] != t[j+1] && j)`,$j=0$ 时会访问 `t[1]`——这个恰好不越界,但 `pi[0]` 未定义,行为不可预测。 **实现陷阱三:下标从 1 开始时 `scanf("%s", s+1)`**。这样 `s[1]` 是第一个字符,`strlen(s+1)` 是真实长度。若混用 0-indexed 的 `string`,`pi` 的下标含义会整体差一位。 **相关例题** *[P3375 【模板】KMP](https://www.luogu.com.cn/problem/P3375)* · [CSES-1753 String Matching](https://cses.fi/problemset/task/1753) · [P4824 Censoring S](https://www.luogu.com.cn/problem/P4824) ### 4.8 fail 指针:$\pi$ 的另一张面孔 前面五节都在算 $\pi$。现在给它换个名字,而这个名字会一直用到第 8 章的 AC 自动机。 **定义**:令 $\text{fail}[i]=\pi_i$。 就是同一个数。但两种叫法强调不同的东西: | 叫法 | 强调 | 典型场景 | | ---------------------------- | ------------------------------ | -------------------- | | $\pi_i$(前缀函数) | **值**:最长相等前后缀的长度 | 求周期、算答案 | | $\text{fail}[i]$(失配指针) | **边**:失配后应该跳到哪个状态 | 匹配、建自动机、建树 | **为什么叫"指针"**:$\text{fail}[i]$ 回答的是"在状态 $i$ 失配了,下一个该试哪个状态"。§4.6 里那句 `j = pi[j]` 就是在沿这个指针走。 #### fail 链 从任意 $i$ 出发,反复取 fail: $$i\ \to\ \text{fail}[i]\ \to\ \text{fail}[\text{fail}[i]]\ \to\ \cdots\ \to\ 0$$ 这条链叫 **fail 链**。 **引理**:fail 链上出现的所有正整数,恰好是 $T[1..i]$ 的全部 border 长度。 **证明**:对链长归纳。链首 $\text{fail}[i]=\pi_i$ 是最大 border(定义)。设 $j$ 是当前链上的一个 border 长度,由 §4.4 的引理,$T[1..j]$ 的所有 border 也都是 $T[1..i]$ 的 border,而 $\text{fail}[j]=\pi_j$ 正是其中最大的那个。所以链上每往下一格,就取到了"下一个更小的 border"。反之,任何 border 都在这条链上——因为它必是某个更长的 border 的 border,逐级回溯必然经过。$\square

推论:\text{fail}[i]<i 恒成立(真 border 严格短于自身)。

这一条看似平凡,却是后面建树的基础:它保证了 fail 关系无环。

推论:fail 链长度 O(i),但沿链跳的均摊代价是 O(1)——这正是 §4.5 势能分析证明的事。

4.9 fail 树(失配树)

既然 \text{fail}[i]<i 恒成立,把 fail 当成父亲指针,就得到一棵树。

定义:fail 树以 0 为根,节点 i(1\le i\le n)的父亲是 \text{fail}[i]。

为什么一定是树:每个节点 i\ge 1 恰有一个父亲 \text{fail}[i]<i,且沿父亲走严格递减,最终必到 0。有唯一根、无环、每个非根节点一个父亲——这就是树。

最关键的一条性质:

在 fail 树上,i 到根的路径(不含根)就是 T[1..i] 的全部 border。

这不是新结论,就是 §4.8 那条引理的树上说法。但它把"沿链跳"变成了"树上走路径",于是树上的一切工具都能用。

以 T=\texttt{aabaaab} 为例,\pi=[0,1,0,1,2,2,3],fail 树是:

0(root)
├─ 1  (a)
│  ├─ 2  (aa)
│  │  ├─ 5  (aabaa)
│  │  └─ 6  (aabaaa)
│  └─ 4  (aaba)
└─ 3  (aab)
   └─ 7  (aabaaab)

读法:节点 7(aabaaab)到根的路径是 7\to3\to0,所以它的 border 是长度 3 的 aab,没有更小的了。

节点 6(aabaaa)的路径是 6\to2\to1\to0,border 依次是 aa、a。

T=\texttt{aabaaab} 的 fail 树。每个节点是一个前缀,父边指向它的最长 border。节点旁括号内是该前缀的内容,方框数字是子树大小——也就是该前缀在 T 中的出现次数。

节点数:n+1(含根)。边数:n。建树就是 O(n) 遍历一遍 fail 数组。

4.10 fail 树上的四种操作

把 fail 树建出来后,KMP 的绝大部分应用都变成树上的标准问题。这一节把四种操作一次性讲清,后面各节只是套用。

操作一:子树和 = 出现次数

定理:前缀 T[1..i] 在 T 中的出现次数,等于 fail 树上 i 的子树大小。

证明:T[1..i] 每出现一次(结束位置为 e),就说明 T[1..e] 有一个长度为 i 的 border,即 i 在 T[1..e] 的 fail 链上,也就是 e 在 i 的子树里。反之亦然。所以"出现位置"与"子树内节点"一一对应。\square

做法:

// 建树后按编号从大到小累加(fail[i] < i,所以 i 降序即拓扑逆序)
For(i, 1, n) cnt[i] = 1;
for (int i = n; i >= 1; -- i) cnt[fail[i]] += cnt[i];
// 现在 cnt[i] = T[1..i] 的出现次数

为什么不用真的建树:因为 \text{fail}[i]<i,编号本身就是一个合法的拓扑序。按 i 从 n 降到 1 累加,等价于"从叶子往根",不需要 DFS。

验证(T=\texttt{aabaaab}):

前缀 子树大小 暴力统计的出现次数
a 5 5 ✓
aa 3 3 ✓
aab 2 2 ✓
其余 1 1 ✓

操作二:深度 = border 个数

定义:\text{dep}[i]=\text{dep}[\text{fail}[i]]+1(\text{dep}[0]=0)。

P2375 动物园要的正是"长度不超过 $i/2$ 的 border 个数",做法是先在 fail 树上倍增跳到第一个 $\le i/2$ 的祖先,再取它的深度。 #### 例题:P2375 [NOI2014] 动物园 **题意**:给 $s$($|s|\le10^6$,$q\le5$ 组数据),对每个前缀 $s[1..i]$,求它的 border 中"长度不超过 $i/2$"的个数,记为 $num_i$。输出 $\prod_{i=1}^{n}(num_i+1)\bmod10^9+7$。 **第一步:先想清楚 $num_i$ 是什么** border 链上所有长度,就是全部 border。所以 $num_i$ 需要"链上前缀和"——即长度 $\le i/2$ 的 border 个数。 **第二步:怎么快速找到"链上第一个 $\le i/2$ 的"** 暴力沿链跳会退化到 $O(n^2)$(如 $s=\texttt{a}^n$,链长 $O(n)$,每个 $i$ 都要跳)。需要一个"边算边维护"的指针。 **关键观察**:设 $k_i$ 是"$s[1..i]$ 的 border 中长度 $\le i/2$ 的最长者"。当 $i$ 增加时,$i/2$ 也增加,所以 $k_i$ **单调不减**。可以像 KMP 求 $\pi$ 一样在线维护: ```cpp // 求 num[i]:沿 pi 链上 <= i/2 的个数 // 维护 k:最长的 <= i/2 的 border For(i, 2, n) { while (k && s[i] != s[k + 1]) k = pi[k]; // 和求 pi 一样 if (s[i] == s[k + 1]) ++ k; while (k * 2 > i) k = pi[k]; // 关键:保证不超一半 num[i] = num[k] + 1; // 递推 } ``` **第三步:`num[i] = num[k] + 1` 为什么对** $k$ 是长度 $\le i/2$ 的最长 border。那么所有"长度 $\le i/2$ 的 border"恰好是"$k$ 及其所有 border"——因为任何更短的 border 都是 $k$ 的 border(引理 3.4)。所以数量是 $num[k]+1$($+1$ 是 $k$ 自己)。 而 $k\le i/2<i$,$num[k]$ 已经算过,可以直接用。**递推的无环性由 $k<i$ 保证**。 ```cpp #include <bits/stdc++.h> #define For(i, j, k) for (int i = j; i <= k; ++ i) using namespace std; using ll = long long; const int N = 1e6 + 10, MOD = 1e9 + 7; int n, pi[N], num[N]; char s[N]; int main() { int T; scanf("%d", &T); while (T --) { scanf("%s", s + 1); n = strlen(s + 1); num[1] = 1, pi[1] = 0; int j = 0, k = 0; For(i, 2, n) { while (j && s[i] != s[j + 1]) j = pi[j]; if (s[i] == s[j + 1]) ++ j; pi[i] = j; while (k && s[i] != s[k + 1]) k = pi[k]; if (s[i] == s[k + 1]) ++ k; while (k * 2 > i) k = pi[k]; num[i] = num[k] + 1; } ll ans = 1; For(i, 1, n) ans = ans * (num[i] + 1) % MOD; printf("%lld\n", ans); } return 0; } ``` **复杂度**:$O(n)$ 每组。`k` 和 `j` 都是单调的,总跳转 $O(n)$。 **点评**:这道题的精髓是**两个指针各自单调**——`j` 管求 $\pi$,`k` 管约束 $k\le i/2$。两者都靠"只增不减"保证线性。看到"要在 border 链上找满足某条件的最长者"且条件是单调的,就该想到这个套路。 --- --- #### 操作三:LCA = 最长公共 border **定理**:$T[1..p]$ 与 $T[1..q]$ 的最长公共 border 长度,等于 fail 树上 $p$ 与 $q$ 的 LCA。 **证明**:公共 border 必须同时是两者的 border,即同时位于 $p$ 的祖先链和 $q$ 的祖先链上——这正是"公共祖先"的定义。最长的那条路径对应 LCA。$\square

这直接就是 P5829 的解法:建 fail 树,树上倍增求 LCA,O((n+m)\log n)。

// fail 树上倍增求 LCA(P5829)
const int LOG = 20;
int up[LOG][N], dep[N];

void buildTree(int n, int *fail) {
    dep[0] = 0;
    For(i, 1, n) up[0][i] = fail[i], dep[i] = dep[fail[i]] + 1;
    For(k, 1, LOG - 1) For(i, 0, n) up[k][i] = up[k - 1][up[k - 1][i]];
}
int lca(int u, int v) {
    if (dep[u] < dep[v]) swap(u, v);
    int d = dep[u] - dep[v];
    For(k, 0, LOG - 1) if (d >> k & 1) u = up[k][u];
    if (u == v) return u;
    for (int k = LOG - 1; k >= 0; -- k)
        if (up[k][u] != up[k][v]) u = up[k][u], v = up[k][v];
    return up[0][u];
}

注意:这里求的是前缀之间的最长公共 border,答案就是 LCA 的编号(编号即长度)。与通常"求最近公共祖先"不同的是,LCA 可能为 0(表示没有公共 border),此时输出 0。

例题:P5829 【模板】失配树

题意:给串 S(|S|\le10^6)和 m 次询问(m\le10^5),每次给 p,q,求"S 的前缀 p 与前缀 q 的最长公共 border"。

关键转化:失配树上的 LCA。

第一步:建失配树。 fail[i] 作为 i 的父亲,根是 0。i 到根的路径 = i 的所有 border。

第二步:答案 = LCA。 "前缀 p 与前缀 q 的公共 border"= "p 的某个 border 且是 q 的 border"= "p 的祖先且是 q 的祖先"= 公共祖先。最长的那个就是 LCA。

注意:LCA 可能恰好是 p 或 q 自己(当 p 是 q 的 border 时)。但题目通常要求"真 border",所以要排除 p,q 本身——判断 LCA 是否等于 p 或 q,若是则取它的父亲。

#include <bits/stdc++.h>
using namespace std;
const int N = 1e6 + 10, LG = 20;
char s[N];
int fail[N], fa[N][LG], dep[N];
int n, m;

int lca(int u, int v) {
    if (dep[u] < dep[v]) swap(u, v);
    for (int i = LG - 1; i >= 0; -- i)
        if (dep[fa[u][i]] >= dep[v]) u = fa[u][i];
    if (u == v) return u;
    for (int i = LG - 1; i >= 0; -- i)
        if (fa[u][i] != fa[v][i]) u = fa[u][i], v = fa[v][i];
    return fa[u][0];
}
int main() {
    scanf("%s%d", s + 1, &n); scanf("%d", &m);
    for (int i = 2, j = 0; i <= n; ++ i) {
        while (j && s[j + 1] != s[i]) j = fail[j];
        if (s[j + 1] == s[i]) ++ j;
        fail[i] = j;
    }
    // 建失配树 + 倍增
    for (int i = 1; i <= n; ++ i) {
        fa[i][0] = fail[i];
        dep[i] = dep[fail[i]] + 1;
        for (int k = 1; k < LG; ++ k) fa[i][k] = fa[fa[i][k - 1]][k - 1];
    }
    while (m --) {
        int p, q; scanf("%d%d", &p, &q);
        int l = lca(p, q);
        if (l == p) l = fail[p];               // 排除自身
        else if (l == q) l = fail[q];
        printf("%d\n", l);
    }
    return 0;
}

复杂度:建树 O(n\log n),每次询问 O(\log n)。

点评:"border 链 = 树上的根路径"这个观察把字符串问题变成了树上问题,之后 LCA、倍增、树剖全部可用。这是失配树类题的统一入口。

操作四:DFS 序把"链上问题"变成"区间问题"

fail 链上的查询("i 的祖先里有多少个满足某条件")在树上是一次路径查询。若把所有询问离线,按 DFS 序处理,就能用树状数组把它变成区间加减 + 单点查询。

这是第 8 章 AC 自动机 P2414、P5840 的标准套路:"某模式串在文本里出现几次" → "fail 树子树和" → "DFS 序 + 树状数组"。

4.11 应用:border 与周期

这一节的东西全是"沿 fail 链走"或"读 \pi 的值",不需要 fail 树的额外结构。

求全部 border

定理:T[1..i] 的所有 border 长度,就是从 \text{fail}[i] 出发沿 fail 链走到的全部正整数。

这正是 §4.8 的引理,直接用:

// 枚举 T[1..n] 的全部 border 长度(降序)
for (int k = pi[n]; k; k = pi[k]) printf("%d ", k);

注意 pi[k] 还是 pi[k-1]:这取决于数组约定。若 pi[i] 存的是 \pi_i(1-indexed,下标即长度),下一步就是 pi[k];若把 \pi_1..\pi_n 存在 pi[0..n-1](0-indexed),下一步是 pi[k-1]。混用是最高频的错,写代码前先定死一种。

例题:P3375【模板】KMP

题意:给 s_1,s_2(长度 \le10^6,大写字母),输出 s_2 在 s_1 中所有出现位置,以及 s_2 每个前缀的最长 border 长度。

关键转化:这就是 3.6 的完整实现,直接套。

#include <bits/stdc++.h>
#define For(i, j, k) for (int i = j; i <= k; ++ i)
using namespace std;
const int N = 1e6 + 10;
int n, m, pi[N];
char s[N], t[N];
int main() {
    scanf("%s%s", s + 1, t + 1);
    n = strlen(s + 1), m = strlen(t + 1);
    pi[1] = 0;
    int j = 0;
    For(i, 2, m) {
        while (j && t[i] != t[j + 1]) j = pi[j];
        if (t[i] == t[j + 1]) ++ j;
        pi[i] = j;
    }
    j = 0;
    For(i, 1, n) {
        while (j && s[i] != t[j + 1]) j = pi[j];
        if (s[i] == t[j + 1]) ++ j;
        if (j == m) printf("%d\n", i - m + 1), j = pi[j];
    }
    For(i, 1, m) printf("%d ", pi[i]);
    return 0;
}

复杂度:O(n+m)。两段都是"j 单调攒、单调减",总代价线性。

点评:这道题把 KMP 的两个用途(匹配 + 求前缀函数)合在一起,是最经济的模板。

最小循环节

定理:设 p=n-\pi_n。则

证明:由 border 与周期的等价,\pi_n 是 border 长度等价于 n-\pi_n=p 是周期。

先证 p\mid n 时是重复串:设 n=kp。由周期定义 T_i=T_{i+p} 对所有 i\le n-p 成立,把这串等式按模 p 分组,得 T_i=T_{i\bmod p},即 T 由 T[1..p] 重复而成。

再证最小性:若存在更小的周期 q<p,则 n-q>\pi_n 也是 border 长度,与 \pi_n 最大矛盾。\square

int p = n - pi[n];
if (n % p == 0) printf("%d %d\n", p, n / p);   // 最小循环节长度、重复次数
else printf("%d 1\n", n);                       // 不是周期串

边界:n % p == 0 不能省。T=\texttt{aabaa},n=5,\pi_5=2,p=3,5\nmid 3——直接输出 n/p=1 就错了。

例题:P4391 [BOI2009] Radio Transmission

题意:给一个字符串 s(|s|\le10^6),已知它是由某个串 t 自我连接若干次后再取一个前缀得到的(t 可以只出现一部分),求 t 的最短长度。

关键转化:这是"最小循环节"的弱化版——允许最后一个周期不完整。

观察:设 p=n-\pi_n。不论 n 是否被 p 整除,p 都是答案。

为什么:若 p\mid n,3.8 已证 p 是最小循环节。若 p\nmid n,设 n=kp+r(0<r<p),则 s 的前 kp 位由长度 p 的串重复构成,剩下 r 位正好是它的前缀——符合题意。而更小的周期会给出更长的 border,与 \pi_n 最大矛盾。

所以答案就是 n-\pi_n,不用判整除。这正是本题与 3.8 的区别:3.8 判"是否完整重复",本题允许不完整。

#include <bits/stdc++.h>
#define For(i, j, k) for (int i = j; i <= k; ++ i)
using namespace std;
const int N = 1e6 + 10;
int n, pi[N];
char s[N];
int main() {
    scanf("%d%s", &n, s + 1);
    int j = 0;
    pi[1] = 0;
    For(i, 2, n) {
        while (j && s[i] != s[j + 1]) j = pi[j];
        if (s[i] == s[j + 1]) ++ j;
        pi[i] = j;
    }
    printf("%d\n", n - pi[n]);
    return 0;
}

复杂度:O(n)。

点评:一行答案 n - pi[n]。但要能说清"为什么不用判整除"——这是本题与普通最小循环节唯一的分歧点。

最大周期 = n- 最短 border

周期还有另一头:最短 border 给出最大周期。

定理:设 T[1..n] 的最短非空 border 长度为 b_{\min},则最大周期长度为 n-b_{\min}。

证明:周期长度 q 与 border 长度 n-q 一一对应(周期定义即 T[1..n-q]=T[q+1..n])。所以 q 最大 \iff n-q 最小,即 border 最短。\square

求最短 border:沿 fail 链走到不能再走(下一个是 0)。朴素走法是 O(n^2),但可以用路径压缩:把每个点直接挂到它链底。

// 求每个前缀的最短非空 border(路径压缩,近似 O(n))
For(i, 1, n) {
    int b = fail[i];
    if (b == 0) { mn[i] = 0; continue; }
    while (mn[b]) b = mn[b];       // 跳到链底
    mn[i] = b;                     // 记录:i 的最短 border 是 b
}

注意 mn[0] 恒为 0,所以循环终止条件是 mn[b] 非零而不是 b 非零——这个细节错了会死循环。

例题:P3435 [POI2006] OKR-Periods of Words

题意:给长度 k 的串(k\le10^6),求它所有前缀的"最大周期长度"之和。周期定义:Q 是 A 的真前缀且 A 是 QQ 的前缀。

关键转化:最大周期 = 前缀长度 − 最短 border 长度。

第一步:理解周期与 border 的对偶。

设 |A|=n,Q 是 A 的周期且 |Q|=q。则 A 是 QQ 的前缀,意味着 A[1..n] 与 A[q+1..q+n] 相同(在 QQ 里比较)。所以

A[1..n-q]=A[q+1..n]

即"长度为 n-q 的前缀等于同长度的后缀"——这正是 border 的定义。

第二步:等价关系。

(\text{最大周期长度})=n-(\text{最短 border 长度}) \tag{10}

验证:A=\texttt{ababab},n=6。A 的 border 有 \texttt{abab}(长 4)、\texttt{ab}(长 2)。最短 border 是 \texttt{ab}(长 2),所以最大周期 =6-2=4,即 \texttt{abab} ✓(与题目说明一致)。

第三步:求最短 border。 沿 fail 链往上跳到最浅的非零节点(即 fail 链上除 0 外的最浅者)。但直接跳是 O(n^2)。

优化(路径压缩):在求的过程中把 fail 数组"压缩"——若 fail[i] 的 fail 还能用,就让 fail[i] 直接指向那个更浅的。这样均摊 O(n)。

#include <bits/stdc++.h>
using namespace std;
const int N = 1e6 + 10;
char s[N];
int fail[N];
int main() {
    int k; scanf("%d%s", &k, s + 1);
    for (int i = 2, j = 0; i <= k; ++ i) {
        while (j && s[j + 1] != s[i]) j = fail[j];
        if (s[j + 1] == s[i]) ++ j;
        fail[i] = j;
    }
    long long ans = 0;
    for (int i = 2; i <= k; ++ i) {
        int j = i;
        // 往上跳到最短的非零 border
        while (fail[j]) j = fail[j];
        if (fail[i]) fail[i] = j;        // 路径压缩
        ans += i - j;
    }
    printf("%lld\n", ans);
    return 0;
}

样例验证:babababa(k=8)输出 24 ✓。

复杂度:O(k)(路径压缩后均摊)。

点评:"周期 \leftrightarrow border"的对偶是字符串里最重要的等价之一。它把"周期问题"全部转成"fail 链上的查询"。注意路径压缩那句 fail[i] = j——没有它复杂度退化到 O(n^2)。

本质不同子串数(副产品)

定理:长度 n 的串 T 的本质不同子串数为

\frac{n(n+1)}{2}-\sum_{i=1}^{n}\pi_i \tag{4}

证明(增量法):逐个字符加入。加入第 i 个后,新增子串必然是"以 T_i 结尾"的那些,共 i 个候选。其中已经出现过的恰有 \pi_i 个:长度 1..\pi_i 的那些,因为 T[1..\pi_i]=T[i-\pi_i+1..i],它们都是先前出现过的前缀。故净增 i-\pi_i,累加即得。\square

这只是理论上的公式:要真的数不同子串,O(n^2) 的这个做法没有实用价值,正解是后缀数组(§9)或 SAM(§10)。

4.12 应用:KMP 自动机

前面所有应用都是"边扫边跳 fail"。但如果要对同一段文本做很多次不同的匹配(或把匹配嵌进 DP),跳 fail 的均摊性就没了——每次都从头跳,最坏 O(nm)。

解法:把跳 fail 的过程预先算成转移表。

定义:状态 j\in[0,m] 表示"当前匹配了 T 的前 j 位"。对字符 c 定义转移

\delta(j,c)=\begin{cases}j+1 & T_{j+1}=c\\[2pt] \delta(\text{fail}[j],c) & \text{否则}\end{cases}

约定 \delta(0,c)=0 当 T_1\ne c。

这就是 KMP 自动机:m+1 个状态,|\Sigma| 条出边。有了它,匹配变成"读一个字符、走一条边",严格 O(1)。

// nxt[j][c] = 当前匹配 j 位、读入字符 c 之后的状态
For(j, 0, m) For(c, 0, 25) {
    if (j < m && t[j + 1] == c + 'a') nxt[j][c] = j + 1;
    else if (j == 0)                   nxt[j][c] = 0;
    else                               nxt[j][c] = nxt[fail[j]][c];   // 查表,不再跳链
}

为什么是 O(m|\Sigma|):nxt[fail[j]][c] 里的 fail[j] < j,所以按 j 递增的顺序算时它已经算好了,直接查表。跳 fail 被摊平进了预处理。

代价:空间 O(m|\Sigma|)。m=10^5 且 |\Sigma|=26 时是 2.6\times10^6 个 int,约 10 MB,可以接受;若 |\Sigma| 更大就要改用哈希表或按需建边。

三个典型用法:

用法 做法
多次匹配同一模式串 建一次自动机,每次 O(\text{文本长})
禁串计数(不能出现 T 的长度 n 串数) 自动机上 DP,f(k,j) = 走 k 步到状态 j 的方案数
禁串 + 大 n 把转移写成 (m+1)\times(m+1) 矩阵,快速幂 O(m^3\log n)

第 8 章的 P3193 GT 考试就是第三种。注意这与第 8 章 AC 自动机的"禁串计数"是同一件事——那里把单串换成多串(Trie),矩阵规模从 m 变成 Trie 节点数。

例题:P4824 [USACO15FEB] Censoring S

题意:给串 S(|S|\le10^6)和模式串 T(|T|\le10^6),反复从 S 中删除 T 的第一次出现,直到 S 中不含 T。输出最终串。

关键转化:用栈模拟 + KMP 边扫边匹配。

朴素做法:每次删除后重新扫描——O(n^2),会 TLE(n=10^6)。

关键观察:删除一段后,被删除部分左边的匹配状态不受影响。所以可以用栈保存"左边还没被删除的字符",同时记录"每个栈位置对应的 KMP 匹配长度"。

做法:

  1. 用栈 st 存字符,top[i] 存"处理到第 i 个栈元素时的 KMP 匹配长度";
  2. 每读一个字符,就推进 KMP;
  3. 若匹配长度达到 |T|,说明栈顶的 |T| 个字符构成 T,弹出它们,并把 KMP 状态恢复成"弹出后的栈顶的匹配长度"。
#include <bits/stdc++.h>
using namespace std;
const int N = 1e6 + 10;
char s[N], t[N];
int fail[N], stk[N], top[N];
int main() {
    scanf("%s%s", s + 1, t + 1);
    int n = strlen(s + 1), m = strlen(t + 1);
    // 求 T 的 fail
    for (int i = 2, j = 0; i <= m; ++ i) {
        while (j && t[j + 1] != t[i]) j = fail[j];
        if (t[j + 1] == t[i]) ++ j;
        fail[i] = j;
    }
    int tp = 0;
    for (int i = 1; i <= n; ++ i) {
        int j = top[tp];                       // 当前匹配长度 = 栈顶记录的
        while (j && t[j + 1] != s[i]) j = fail[j];
        if (t[j + 1] == s[i]) ++ j;
        stk[++ tp] = i;                        // 记录原串下标
        top[tp] = j;                           // 记录匹配长度
        if (j == m) tp -= m;                   // 匹配满,弹出 m 个
    }
    for (int i = 1; i <= tp; ++ i) putchar(s[stk[i]]);
    puts("");
    return 0;
}

复杂度:O(n+m)。

点评:"栈 + 状态记录"是"反复删除"类问题的标准解法。关键是"恢复状态"——弹出后 KMP 状态自动回到 top[tp],不需要重算。同类题还有 P3121 [USACO15FEB] Censoring G(多模式串,用 AC 自动机)。

例题:综合:CF1200E Compress Words

题意:给 n 个字符串(总长 \le10^6),按顺序合并:每次把新串接到当前答案后面,但重叠部分要合并(找最大的 k 使答案的后缀 k 位等于新串的前缀 k 位)。输出最终串。

第一步:问题化归

每次合并要求"最大重叠长度"——这正是Z 函数 / KMP 的 LCP 问题。设答案当前为 A,新串为 B,要求 \max k 使 A[|A|-k+1..|A|]=B[1..k]。

第二步:用 Z 函数求

构造 T=B+\texttt{\#}+A(# 是分隔符,防跨越),求 T 的 Z 函数。则对 A 的每个位置,z 值就是"从该位置起与 B 的 LCP"。我们只关心 A 的后缀部分,即取 T 中对应 A 起始位置之后的最大 z,且不超过 |B|。

更简单:只看 T 的最后一个位置开始往前……实际上只需取 \max_{i>|B|} \min(z_i,\ |B|),但那不是"后缀"。

正确做法:要找的是"A 的某个后缀 = B 的前缀",所以必须是 A 的末尾对齐。即 k 满足 A[|A|-k+1..|A|]=B[1..k]。用 T=B+\texttt{\#}+A,则 A 的末尾对应 T 的末尾,我们要的是"以 T 末尾为终点、长度 k 的串 = B 的前缀"。这不是 Z 直接给的(Z 给的是"从 i 开始向前匹配")。

改用 KMP:求 B 的 \pi 数组,然后在 A 上跑匹配,最后停在 A 末尾时的 j 就是最大重叠长度。

// 求 A 的后缀与 B 的前缀的最大重叠长度
int max_overlap(const string &A, const string &B, const vector<int> &pi) {
    int j = 0, m = B.size();
    for (int i = max(0, (int)A.size() - m); i < (int)A.size(); ++ i) {
        while (j && A[i] != B[j]) j = pi[j - 1];
        if (A[i] == B[j]) ++ j;
        if (j == m) { if (i != (int)A.size() - 1) j = pi[j - 1]; }   // 完整匹配但没到末尾
    }
    return j;
}

第三步:总复杂度

每次只在 A 的最后 \min(|A|,|B|) 位上跑,总长度受限于 \sum|B_i|,所以总 O(\sum|s_i|)。

点评:这道题说明一个常见模式——"求最大重叠"就是"跑 KMP 停在末尾时的 j"。它比"求 LCP"更具体,因为对齐全在末尾。同时也提醒:用 Z 函数时会因为"要从末尾对齐"而绕远路,KMP 在这里更直接。

习题池:KMP / 失配树 / 周期

入门·普及

题号 题名 平台 难度
P10479 匹配统计 洛谷 普及/提高−

提高

题号 题名 平台 难度
P1470 [IOI 1996 / USACO2.3] 最长前缀 Longest Prefix 洛谷 普及+/提高
P9606 [CERC2019] ABB 洛谷 普及+/提高
P5546 [POI 2000 R3] 公共串 洛谷 普及+/提高
P8085 [COCI 2011/2012 #4] KRIPTOGRAM 洛谷 普及+/提高

省选

题号 题名 平台 难度
P3193 [HNOI2008] GT考试 洛谷 省选/NOI−
P3426 [POI 2005] SZA-Template 洛谷 省选/NOI−
P6216 回文匹配 洛谷 省选/NOI−
P10581 [蓝桥杯 2024 国 A] 重复的串 洛谷 省选/NOI−
P5770 [JSOI2016] 无界单词 洛谷 省选/NOI−
P17330 [ICPC 2018 Nanjing R] Mediocre String Problem 洛谷 省选/NOI−
P17581 [JAG 2026 Summer Camp #3] Forbidden Word 洛谷 省选/NOI−

NOI

题号 题名 平台 难度
P2463 [SDOI2008] Sandy 的卡片 洛谷 NOI
P3546 [POI 2012] PRE-Prefixuffix 洛谷 NOI
P4696 [CEOI 2011] Matching 洛谷 NOI
P15650 [省选联考 2026] 摩卡串 洛谷 NOI
P1393 Mivik 的标题 洛谷 NOI
P3454 [POI 2007] OSI-Axes of Symmetry 洛谷 NOI
P10716 【MX-X1-T4】「KDOI-05」简单的字符串问题 洛谷 NOI
P6080 [USACO05DEC] Cow Patterns G 洛谷 NOI
P10634 BZOJ2372 music 洛谷 NOI
P11291 【MX-S6-T3】「KDOI-11」简单的字符串问题 2 洛谷 NOI

4.13 本章小结:一棵 fail 树,四副面孔

KMP 的全部内容可以压缩成两句话:

  1. \pi_i(= \text{fail}[i])是"最长相等前后缀",而所有 border 构成一条沿 fail 递减的链;
  2. 把 fail 当父亲,链就长成一棵树——于是链上的问题全变成树上的标准问题。

本章的五个应用,全部归到这两句话:

应用 用的是哪一半
求全部 border fail 链(§4.8)
最小循环节、周期计数 \pi_n 的值(§4.11)
统计前缀出现次数 fail 树子树和(§4.10 操作一)
最长公共 border fail 树 LCA(§4.10 操作三)
KMP 自动机 fail 链被摊平成转移表(§4.12)

为什么要在这里把 fail 树讲透

因为第二部分的三个自动机都是它的推广。同样是"把指针当父亲建树",同样是"子树和 = 出现次数":

结构 节点是什么 父边指向 子树和含义 章节
KMP 的 fail 树 T 的一个前缀 最长 border 该前缀在 T 中出现次数 本章 §4.9
AC 自动机的 fail 树 Trie 的一个状态 最长的"是某模式串前缀"的真后缀 该模式串出现次数 §1.5
SAM 的 parent 树 一个 endpos 等价类 最长后缀所在的类 该状态所有子串的出现次数 §10.5
PAM 的 fail 树 一个回文串 最长回文真后缀 该回文出现次数 §12.5

四棵树的节点含义不同,但形状和用法完全一致。所以这一章的 fail 树不是"KMP 的一个应用",而是后面三个自动机的样板:

第 8 章讲 AC 自动机时,只需要说一句"把单串换成 Trie,fail 的定义从'最长 border'改成'最长的、且是某模式串前缀的真后缀'",其余全部沿用本章。

三个容易混的点

① \pi_i 的下标是"长度",不是"位置"。 \pi_{\pi_i} 这种写法里,内层 \pi_i 是一个长度,外层拿它当下标。数组实现时若用 0-indexed,一律要减 1。这是本章最容易出错的地方。

② 失配跳的是 j,不是 i。 朴素算法让起点右移(i 回退),KMP 让匹配长度缩短(j 跳 fail),而 i 单调不减。O(n) 的全部来源就在这一句。

③ 匹配成功后要 j = fail[j]。 清零会漏掉重叠出现(aa 在 aaaa 中出现在 1、2、3)。

5. Z 函数与扩展 KMP

5.1 它回答什么问题

KMP 的前缀函数 \pi_i 回答"s[0..i] 的最长 border"——它关心前缀。但有一类问题问的是后缀:

对每个位置 i,求 s[i..n-1] 与整个 s 的最长公共前缀(LCP)长度。

| $i$ | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | | ---- | ----------- | ---------- | --------- | -------- | ------- | ------ | ----- | ---- | ---- | | 后缀 | `aabaabaaa` | `abaabaaa` | `baabaaa` | `aabaaa` | `abaaa` | `baaa` | `aaa` | `aa` | `a` | | LCP | 9 | 1 | 0 | 5 | 1 | 0 | 2 | 2 | 1 | 这个量直接回答"$s$ 的哪些前缀也在 $s$ 的别处出现"。扩展 KMP 更一般:求**模式串 $T$ 与文本串 $S$ 每个后缀的 LCP**,一次给出全部匹配信息。 **约定**:本章下标 **0-based**,与代码一致。$z_0=0$——这是 OI Wiki 与绝大多数实现的约定(虽然 $s[0..]$ 与 $s$ 的 LCP 其实是 $n$,但把它定为 0 能让算法少一个特判)。**两种约定都能算,混用必错。** ### 5.2 朴素算法 对每个 $i$,从 $j=0$ 开始逐位比 $s[j]$ 与 $s[i+j]$: ```cpp for (int i = 1; i < n; ++ i) while (i + z[i] < n && s[z[i]] == s[i + z[i]]) ++ z[i]; ``` $O(n^2)$。$s=\texttt{a}^n$ 时每个位置都比到末尾。 **慢在哪**:每个 $i$ 都从 $s[0]$ **重新开始**。而我们已经算出 $z_1..z_{i-1}$,这些结果里藏着大量"某一段等于前缀"的信息,完全没用上。 ### 5.3 匹配段:把"相等"画成线段 先把已经算出的信息整理一下。 若 $z_i=k$,说明 $$s[i..i+k-1]=s[0..k-1]$$ 即"$s$ 从 $i$ 开始的一段"与"$s$ 的开头一段"**逐字符相同**。称区间 $[i,\ i+k-1]$ 为 $i$ 的**匹配段**(也叫 Z-box)。 把这条信息画成线段:上面一条是 $s$,下面一条是"$s$ 从 0 开始的那段",两条线段**左端不对齐,但被圈住的部分内容相同**。 ![Z 函数的核心对齐](https://cdn.luogu.com.cn/upload/image_hosting/zusa0372.webp) *三条横线。第一条是完整的 $s$;第二条是 $s[0..]$ 的一段,横向位置与 $s$ 的 $[l,r]$ 完全重合——因为这两段内容相同;第三条是镜像线,标出 $i$ 与 $k=i-l$ 的对应关系。* 现在整个算法的骨架就清楚了:**维护右端点最靠右的那条匹配段**,记作 $[l,r]$。凡是落在 $[l,r]$ 里的 $i$,都能借这条线段的信息。 **为什么只留最靠右的那条**:镜像的前提是"$i$ 在线段内"。线段越靠右,能覆盖的未来位置越多。其他线段都被最靠右的那条"包含"在有用的范围里,留着没用。 **初始**:$l=r=0$。计算时始终保证 $l\le i$。 ### 5.4 线段内:为什么能抄 设当前要算 $z_i$,且 $i\le r$。由 $[l,r]$ 的定义, $$s[l..r]=s[0..r-l]$$ 于是把这两段叠在一起看,**同一条竖线上下的字符相同**: $$s[i..r]=s[i-l..\ r-l]$$ 记 $k=i-l$($i$ 在段内的相对位置)。上式说的是"$i$ 往后到 $r$ 这一段"与"$k$ 往后同样长的一段"相同。 **关键推论**:从 $i$ 往后逐位比较 $s[i+t]$ 与 $s[t]$,与从 $k$ 往后逐位比较 $s[k+t]$ 与 $s[t]$,**是同一串比较**,因为 $s[i+t]=s[k+t]$(只要 $i+t\le r$)。 而 $z_k$ 已经算过,它告诉我们"从 $k$ 往后能比多少位"。 $$z_i\ \ge\ \min\bigl(z_k,\ r-i+1\bigr)$$ **为什么有个 $\min$**:$z_k$ 描述的比较只在 $[l,r]$ 内有效(因为 $s[i+t]=s[k+t]$ 这个等式只对 $i+t\le r$ 成立)。所以"抄过来的答案"最多是 $r-i+1$(从 $i$ 到 $r$ 的长度)。 ### 5.5 两种情形的分界 上面那个 $\min$ 具体取到哪一边,决定后续怎么做。 ![两种情形](https://cdn.luogu.com.cn/upload/image_hosting/9ub80t9g.webp) **情形一:$z_k<r-i+1$。** 镜像的匹配长度**不足以顶到线段右端**。这意味着 $z_k$ 对应的"失配点"仍然落在线段内,而线段内上下字符相同,所以 $i$ 处也必然在同一位失配。 结论:**不用再比**,$z_i=z_k$。 上图 $i=4$,$k=1$,$z_1=1$,剩余 $4$。绿色(镜像长度)明显短于橙色(剩余长度),所以 $z_4=1$。 **情形二:$z_k\ge r-i+1$。** 镜像的匹配长度**顶到了线段右端**。线段内那 $r-i+1$ 位可以确信相配,但**线段外的部分无从推断**——因为 $s[i..r]=s[k..k+r-i]$ 这个等式在 $r$ 处就断了。 做法:先取到边界 $$z_i\ \leftarrow\ r-i+1$$ 然后**从 $r+1$ 开始暴力往后比**,直到失配。 下图 $i=7$,$k=4$,$z_4=1$,剩余 $1$。绿色顶满了橙色,所以先得 $1$,再从 $s[8]$ 继续比(橙色虚线那一格),发现 $s[8]=\texttt{a}=s[1]$,于是 $z_7=2$。 **统一写法**:两种情形可以合并成 $$z_i\leftarrow\min\bigl(z_k,\ r-i+1\bigr)$$ 然后**无条件**做一次"尝试往后扩"。情形一里这次扩展会立刻失败(因为镜像的失配点已经被抄进来了),情形二里则会真的把 $r$ 往后推。 ### 5.6 线段外:只能暴力 若 $i>r$,$i$ 不在任何已知匹配段内(最靠右的那条也没覆盖它),无法借用任何信息。 按 5.2 的朴素算法,从 $s[i]$ 与 $s[0]$ 开始比,一直比到失配。这一步也可能是"最长的一次扩展"——很多串的第一次长匹配就发生在段外。 ### 5.7 线段怎么更新 算完 $z_i$ 后,若 $$i+z_i-1>r$$ 说明这次匹配伸得比以往都远,就更新 $$l\leftarrow i,\qquad r\leftarrow i+z_i-1$$ 否则不动。 ![最右匹配段只向右推进](https://cdn.luogu.com.cn/upload/image_hosting/byp5wxth.webp) *三条线段是 $s=\texttt{aabaabaaa}$ 的三个阶段:$i=1$ 建立 $[1,1]$,$i=3$ 扩到 $[3,7]$,$i=7$ 再推到 $[7,8]$。虚线投影到同一根位置轴上,能看到右端点一路右移。* **关键性质:$r$ 单调不减。** 这是整个复杂度论证的支点(5.9 节)。 ### 5.8 算法与代码 把 5.3–5.7 的推理逐条翻译成代码: ```cpp // Z 函数(0-indexed),约定 z[0] = 0 vector<int> z_function(const string &s) { int n = s.size(); vector<int> z(n, 0); // z[0] = 0 for (int i = 1, l = 0, r = 0; i < n; ++ i) { if (i <= r && z[i - l] < r - i + 1) // 情形一:镜像抄得完 z[i] = z[i - l]; else z[i] = max(0, r - i + 1); // 情形二:只抄到边界(i>r 时为 0) while (i + z[i] < n && s[z[i]] == s[i + z[i]]) // 无条件尝试扩展 ++ z[i]; if (i + z[i] - 1 > r) // 伸得更远:更新线段 l = i, r = i + z[i] - 1; } return z; } ``` **这段写法与"`min` + `while`"的写法完全等价**,但分支更清楚:情形一直接 `z[i] = z[i-l]`,情形二先取边界再扩。 **`max(0, r-i+1)` 的作用**:当 $i>r$(线段外)时 $r-i+1\le0$,应取 $0$ 从零开始比;当 $i\le r$ 且走 else 分支时,$r-i+1\ge1$,取边界。 **验证**:$s=\texttt{aabaabaaa}$,输出 $z=[0,1,0,5,1,0,2,2,1]$(注意 $z_0=0$)。 **三行代码对应三条推理**: | 代码 | 对应 | | ----------------------------------- | ------------------------------------- | | `if (i <= r && z[i-l] < r-i+1)` | 5.5 情形一:镜像抄得完,直接取 | | `z[i] = max(0, r-i+1)` + `while` | 5.5 情形二 / 5.6 线段外:取边界后扩展 | | `if (i+z[i]-1 > r) l=i, r=i+z[i]-1` | 5.7 线段更新 | ### 5.9 完整走一遍 $s=\texttt{aabaabaaa}$: | $i$ | $i\le r$ | $k=i-l$ | $z_k$ | 剩余 $r-i+1$ | 走哪支 | 初值 | 扩展后 | $z_i$ | 新 $[l,r]$ | | ---- | ----------- | ------- | ----- | ------------ | ------ | ---- | ------------------------------------------------- | ----- | ---------- | | 0 | — | — | — | — | 约定 | — | — | **0** | — | | 1 | 否 | — | — | — | 线段外 | 0 | $s[1]=\texttt{a}=s[0]$,$s[2]=\texttt{b}\ne s[1]$ | **1** | $[1,1]$ | | 2 | 否($2>1$) | — | — | — | 线段外 | 0 | $s[2]=\texttt{b}\ne s[0]=\texttt{a}$ | **0** | $[1,1]$ | | 3 | 否($3>1$) | — | — | — | 线段外 | 0 | 一路比到 $s[8]$ 结束 | **5** | $[3,7]$ | | 4 | 是 | 1 | 1 | 4 | 情形一 | 1 | 立刻失配 | **1** | $[3,7]$ | | 5 | 是 | 2 | 0 | 3 | 情形一 | 0 | 立刻失配 | **0** | $[3,7]$ | | 6 | 是 | 3 | 5 | 2 | 情形二 | 2 | $s[8]=\texttt{a}\ne s[2]=\texttt{b}$ | **2** | $[3,7]$ | | 7 | 是 | 4 | 1 | 1 | 情形二 | 1 | $s[8]=\texttt{a}=s[1]$ 成功,再越界 | **2** | $[7,8]$ | | 8 | 是 | 1 | 1 | 1 | 情形二 | 1 | 越界停止 | **1** | $[7,8]$ | **读表要点**: - $i=1,2,3$ 在**线段外**,只能暴力。 - $i=4,5$ 是**情形一**:$z_k$ 远小于剩余长度,抄完即得,扩展立刻失败。 - $i=6$ 是**情形二但扩展失败**:$z_3=5\ge2$,抄到边界得 $2$,再比 $s[8]$ 与 $s[2]$ 不同,停在 $2$。 - $i=7$ 是**情形二且扩展成功**:抄到边界得 $1$,继续比成功,得 $2$,线段推到 $[7,8]$。 ### 5.10 复杂度:为什么是 $O(n)

定理:Z 函数可以在 O(n) 时间内算出。

证明:只看 while 循环的总执行次数。

先看它什么时候会成功执行。设这次成功让 z_i 从 v 变成 v+1。由于 z[i] 的初值满足 v\ge\max(0,r-i+1),且此时在比较 s[v] 与 s[i+v]——

所以:每一次 while 成功,都把 r 至少推进 1。

每次 `while` 失败的那一次比较是 $O(1)$,共 $n$ 次。外层循环 $n$ 次。 总计 $O(n)+O(n)+O(n)=O(n)$。$\square

证明要点:r 单调不减,且每次成功扩展都把它推进至少一格;推进量被上界 n 卡住。

这个论证与 KMP 的 O(n) 论证是同一个模式(4.5 节):某个量单调移动、每次移动都有代价、总移动量有上界。Z 函数推的是 r,KMP 推的是 j,单调队列推的是队尾指针。

5.11 扩展 KMP:两个串的版本

问题:给定文本串 S(长 n)与模式串 T(长 m),求每个 i 的

p_i=\operatorname{LCP}(T,\ S[i..n-1])

即"从 S 的第 i 位开始,能与 T 匹配多长"。

结构完全一样,只是"参照串"和"被扫描串"分开了:

// 扩展 KMP:p[i] = LCP(T, S[i..]),0-indexed
vector<int> exkmp(const string &S, const string &T) {
    int n = S.size(), m = T.size();
    if (m == 0) return vector<int>(n, 0);
    vector<int> z = z_function(T);          // 先算 T 自己的 Z
    vector<int> p(n, 0);
    for (int i = 0, l = 0, r = -1; i < n; ++ i) {
        if (i <= r && z[i - l] < r - i + 1)
            p[i] = z[i - l];                // 情形一:抄 T 的镜像
        else
            p[i] = max(0, r - i + 1);       // 情形二:只抄到边界
        while (i + p[i] < n && p[i] < m && S[i + p[i]] == T[p[i]])
            ++ p[i];                        // 注意 p[i] < m 上界
        if (i + p[i] - 1 > r) l = i, r = i + p[i] - 1;
    }
    return p;
}

与单串 Z 的差别:

项 单串 两串
扫描谁 s S
比对谁 s[z_i] T[p_i]
镜像查谁 z(同一个数组) z(T 的,不是 p)
初始 r 0 -1
上界 i+z[i] < n i+p[i] < n 且 p[i] < m

p[i] < m 这一条最容易漏。漏了会读到 T[m] 之后;若内存里恰好是某个能与 S 匹配的字节,答案会偏大。

为什么 r 初始为 -1:单串的 z_0=0 意味着位置 0 没有匹配段,r=0 只是一个占位。两串版本 p_0 需要真算,所以置 r=-1 表示"还没有任何线段"。

m == 0 的判空:T 为空时 z 数组为空,z[i-l] 会越界。

复杂度:O(n+m),论证同 5.10。

与 KMP 的分工:

要什么 用谁
T 在 S 里出现了几次、在哪 KMP
每个位置"最长能匹配多少" 扩展 KMP
允许 k 次失配的匹配 扩展 KMP(需要部分匹配长度)

相关例题 P5410 【模板】扩展 KMP(Z 函数) · P8112 符文破译

例题:P5410【模板】扩展 KMP(Z 函数)

题意:给串 a,b(|a|,|b|\le2\times10^7),求 b 与 a 的每个后缀的 LCP(ext_i),以及 b 自己的 z 数组。

关键转化:参照串换成 b,被扫描串换成 a,其余不变。

// 1-indexed 版本(洛谷模板题用这套约定)
void exkmp(char *a, int n, char *b, int m, int *z, int *ext) {
    z[1] = m;
    for (int i = 2, l = 0, r = 0; i <= m; ++ i) {
        if (i <= r && z[i - l + 1] < r - i + 1) z[i] = z[i - l + 1];
        else z[i] = max(0, r - i + 1);
        while (i + z[i] <= m && b[z[i] + 1] == b[i + z[i]]) ++ z[i];
        if (i + z[i] - 1 > r) l = i, r = i + z[i] - 1;
    }
    for (int i = 1, l = 0, r = 0; i <= n; ++ i) {
        if (i <= r && z[i - l + 1] < r - i + 1) ext[i] = z[i - l + 1];
        else ext[i] = max(0, r - i + 1);
        while (i + ext[i] <= n && ext[i] < m && b[ext[i] + 1] == a[i + ext[i]]) ++ ext[i];
        if (i + ext[i] - 1 > r) l = i, r = i + ext[i] - 1;
    }
}

复杂度:O(n+m)。

三个 1-indexed 的坑:

  1. 镜像下标是 z[i - l + 1] 而不是 z[i - l](下标整体加一)。
  2. z[1] = m(1-indexed 的"自己与自己")。
  3. 第二段 while 必须有 ext[i] < m。

验证:a=\texttt{aabaabaaa},b=\texttt{aabaa},得 ext=[5,1,0,5,1,0,2,2,1],与暴力 LCP 一致。

5.12 与 KMP 的互相转化

定理:给定 z 数组可以 O(n) 求出 \pi 数组。

思路:z_i=k(k>0)意味着"s[i..i+k-1]=s[0..k-1]",即位置 i+k-1 处存在一个长度 k 的 border。把每条信息记到右端点,再从右往左传递。

// 由 z 求 pi(0-indexed,pi[i] = s[0..i] 的最长 border 长度)
vector<int> z2pi(const vector<int> &z) {
    int n = z.size();
    vector<int> pi(n, 0);
    for (int i = 1; i < n; ++ i)
        if (z[i]) pi[i + z[i] - 1] = max(pi[i + z[i] - 1], z[i]);
    for (int i = n - 2; i >= 0; -- i)
        pi[i] = max(pi[i], pi[i + 1] - 1);
    return pi;
}

为什么第二条循环是 pi[i+1]-1:若 s[0..i+1] 有长度 L 的 border,两边各砍末位,得 s[0..i] 有长度 L-1 的 border。所以左邻能继承"右邻减一"。

验证:s=\texttt{aabaabaaa},z=[0,1,0,5,1,0,2,2,1],算得 \pi=[0,1,0,1,2,3,4,5,2],与直接求一致。

反方向(\pi\to z)通常不做,直接跑 Z 算法更省事。

5.13 三个应用

应用一:一次求出所有位置的匹配长度。 T 在 i 出现 \iff p_i=m。并顺带得到所有"部分匹配"长度,这是 KMP 给不了的。

应用二:字符串整周期。 若存在 k 使 z_k=n-k,且 k 是使该式成立的最小正数,则 s 由长度 k 的串重复而成。

应用三:比较两个后缀的大小。 求出 \operatorname{lcp}(s[i..],s[j..])=L 后,比较 s[i+L] 与 s[j+L] 即可定序。这是后缀数组倍增法的基础(9.2 节)。

相关例题 P5546 [POI2000] 公共串 · P8112 符文破译

5.14 本章小结

Z 函数只有一句话:维护一条右端点最靠右的匹配段 [l,r],段内的位置直接抄镜像的答案,抄到线段边界再自己往外比。

拆成四条:

  1. 匹配段:z_i=k 产生区间 [i,i+k-1],段内 s[i..i+k-1]=s[0..k-1](5.3)。
  2. 对齐:[l,r] 使 s[i..r]=s[i-l..r-l],所以 i 与 k=i-l 行为相同(5.4)。
  3. 边界:等式只在 [l,r] 内成立,所以初值取 \min(z_k,\ r-i+1)(5.5)。
  4. 推进:i+z_i-1>r 就更新线段;r 单调不减是 O(n) 的唯一依据(5.7、5.10)。

三个最容易犯的错:

与 KMP 的关系:同一套"单调推进量"论证(KMP 推 j,Z 推 r),同一份"前后缀重合"信息(5.12 给了确切转化)。

习题池:Z 函数与扩展 KMP

题号 题名 平台 难度
P7114 [NOIP2020] 字符串匹配 洛谷 省选/NOI−
P8112 [Cnoi2021] 符文破译 洛谷 提高+/省选−
P5211 [ZJOI2017] 字符串 洛谷 NOI
P14874 [ICPC2020 Yokohama R] Suffixes may Contain 洛谷 提高+/省选−

6. Manacher

6.1 动机:回文的两条对称

回文指正读反读相同的串,如 abba、aba。

朴素求最长回文子串:枚举中心,向两边扩,O(n^2)。n=10^7 时这是 10^{14},完全不可行。

关键观察:回文有极强的对称性。若已知 [l,r] 是回文,那么对其中任意点 i,它的镜像点 j=l+r-i 处的回文半径与 i 处"应该有关"。这正是 Manacher 的全部思想——和 Z 函数的"最右匹配段"是同一个套路,只是把"前缀相等"换成了"镜像相等"。

6.2 奇偶统一

回文分奇长度(aba,中心是一个字符)和偶长度(abba,中心在两个字符之间)。分两种讨论会写两套代码。

统一办法:在每两个字符之间插入一个分隔符,首尾也加。例如 s=\texttt{abba} 变成 \#\texttt{a}\#\texttt{b}\#\texttt{b}\#\texttt{a}\#(长度 2n+1)。

这样所有回文都变成奇数长度,且原串的每种回文都对应新串里以某个 # 或字符为中心的奇数长回文。求完后 \text{原长}=\lfloor\text{新半径}\rfloor(具体换算见 5.5)。

注意事项:分隔符必须不出现在原字符集中(用 #、|、或 0 这类哨兵)。若原串含所有可打印字符,就用整数数组。

6.3 算法

记 d_i 为以 i 为中心、向外能扩多少位(不含中心)。维护当前"右端点最靠右的回文" [l,r]。

情形一:i>r。暴力扩。

情形二:i\le r。设 j=l+r-i 是 i 关于中心的对称点。则 d_i 至少是 \min(d_j,\ r-i):

// Manacher(返回的是插入分隔符后的半径数组)
vector<int> manacher(const string &s) {
    string t = "#";
    for (char c : s) t += c, t += '#';
    int n = t.size();
    vector<int> d(n);
    for (int i = 0, l = 0, r = -1; i < n; ++ i) {
        int k = (i > r) ? 0 : min(d[l + r - i], r - i);
        while (i - k - 1 >= 0 && i + k + 1 < n && t[i - k - 1] == t[i + k + 1]) ++ k;
        d[i] = k;
        if (i + k > r) l = i - k, r = i + k;
    }
    return d;
}

min(d[l + r - i], r - i) 的来源:l+r-i 是镜像点,d[l+r-i] 是镜像的半径,r-i 是"到已知右边界的距离"。取小值,理由和 Z 函数完全一致。

相关例题 P3805 【模板】manacher · CSES-1111 Longest Palindrome

Manacher:插入分隔符后统一奇偶,镜像复用半径

例题:P3805【模板】Manacher

题意:给只含小写字母的串(n\le1.1\times10^7),求最长回文子串长度。

关键转化:n=1.1\times10^7,必须是 O(n) 且常数小。Manacher + 插入分隔符。

#include <bits/stdc++.h>
using namespace std;
const int N = 3e7 + 10;
char s[N], t[N];
int d[N];
int main() {
    scanf("%s", s);
    int n = strlen(s), m = 0;
    t[m ++] = '#';                                  // 奇数位置放 '#'
    for (int i = 0; i < n; ++ i) t[m ++] = s[i], t[m ++] = '#';
    int ans = 0;
    for (int i = 0, l = 0, r = -1; i < m; ++ i) {
        int k = (i > r) ? 0 : min(d[l + r - i], r - i);
        while (i - k - 1 >= 0 && i + k + 1 < m && t[i - k - 1] == t[i + k + 1]) ++ k;
        d[i] = k;
        if (i + k > r) l = i - k, r = i + k;
        ans = max(ans, k);                          // 插入分隔符后最长回文长 = max d
    }
    printf("%d\n", ans);
    return 0;
}

关于 ans = max(ans, k):插入分隔符后,以某中心、半径 k 的回文长度为 2k+1,其中原串字符有 k 个、分隔符 k+1 个。所以原串对应的回文长度恰好是 k。这就是为什么答案直接是 \max d。

验证:s=\texttt{aaa},t=\texttt{\#a\#a\#a\#}。中心 i=2(第二个 a)时 k=3,答案 3 ✓。

复杂度:O(n),空间 O(n)。n=1.1\times10^7 时 d 数组要开 2.2\times10^7 个 int,约 88 MB——必须开全局且用 int 不用 long long。

点评:这道题的记忆点是"插入分隔符后答案是 \max d",很多人记成 \max(2d+1) 就错了。

6.4 复杂度

定理:Manacher 是 O(n)。

证明:与 Z 函数同理——r 单调不减,while 每次成功都推进 r,总推进量 O(n)。\square

6.5 从半径还原原串信息

插入分隔符后,d_i 是"向外扩展的步数"。设原串长度为 n,新串长度 2n+1。

最长回文子串长度 =\max_i(2d_i+1) 的一半取整……更稳妥的写法是直接用原长公式:

// 最长回文子串长度
int ans = 0;
For(i, 0, (int)d.size() - 1) {
    int len = (i & 1) ? 2 * d[i] : 2 * d[i] + 1;   // 奇数下标是 '#',对应偶数长度
    ans = max(ans, len);
}

更简单的记法:插入分隔符后,最长回文子串的长度恰好等于 \max_i d_i(因为分隔符版本的回文长度是 2d_i+1,而原串对应长度是 d_i……两种记法差一个常数)。考场上的做法是手算小例子核对一遍,不要凭记忆。

6.6 应用:回文串计数

应用一:回文串总数。以每个中心为心的回文个数:若 d_i 是半径,则以该中心为中心的回文串有 d_i+1 个(半径 0..d_i 各一个)。但不同中心可能对应同一个回文串吗?不会——一个回文串的中心唯一。所以总数 =\sum_i(d_i+1)。

应用二:本质不同的回文子串数。必须用 PAM(第二卷),Manacher 做不到。

应用三:回文划分。把 s 划分成若干个回文串,方案数。需要 PAM 的 fail 链 DP(第二卷)。

相关例题 P1659 拉拉队排练 · P4555 最长双回文串 · P4287 双倍回文

例题:P4287 [SHOI2011] 双倍回文

题意:若一个回文串可以写成 ww^Rww^R 的形式(w^R 是 w 的反转),称它为双倍回文。给 s(|s|\le5\times10^5),求最长双倍回文子串的长度。

第一步:理解结构

**第二步:转化** 设以 $i$ 为中心、半径 $r$ 的回文串。它要成为双倍回文,需要"它的左半部分本身是回文": - 整个回文长 $2r$($d_2$ 记法),中心在 $i$; - 左半是以 $i$ 为中心、半径 $r/2$ 的回文?不对——需要的是**以 $i-r/2$ 为中心**的回文。 更直接的刻画:$s[l..r]$ 是双倍回文 $\iff$ 它是回文,且它的**左半部分是回文**。设中心 $mid$,半径 $R$,则左半是 $s[mid-R..mid]$,中心是 $mid-R/2$。 **第三步:用 Manacher 加速判定** 枚举中心 $i$ 和半径 $R$($R$ 是 4 的倍数的一半,即 $R\equiv0\pmod2$),需要判断"以 $i-R/2$ 为中心、半径 $\ge R/2$ 的回文是否存在"。 直接枚举是 $O(n^2)$。用 Manacher 得到 $d$ 数组后,"以 $c$ 为中心半径 $\ge k$"就是 `d[c] >= k`,$O(1)$ 判定。于是 $O(n^2)$ 枚举 $+O(1)$ 判定 = $O(n^2)$,还不够。 **第四步:换个枚举顺序** 固定中心 $i$,需要找最大的 $R\le d_i$ 使 `d[i - R/2] >= R/2`。令 $k=R/2$,则要 $\max k$ 使 $k\le d_i/2$ 且 `d[i-k] >= k`。 这里 $d[i-k]\ge k$ 不是单调的,二分不成立。标准做法是**对每个位置 $j$,把它作为"左半中心"记下来**:$j$ 能覆盖的 $k\le d_j$。于是对中心 $i$,需要 $k\le d_i/2$ 且存在 $j=i-k$ 满足 $d_j\ge k$——即 $j+d_j\ge i$ 且 $j=i-k$。 枚举 $j$,它作为左半中心可以贡献给 $i=j+k$($k\le d_j$)。把所有 $(j,k)$ 摊开是 $O(n^2)$,但注意到**只需要最大的 $k$**,可以对每个 $i$ 维护"最小的 $j$ 使 $j+d_j\ge i$"。 ```cpp // 双倍回文:枚举中心 i,找最大 k<=d[i] 使 d[i-k] >= k int ans = 0; for (int i = 0; i < m; ++ i) { int lim = d[i] / 2; // k 的上界 for (int k = lim; k >= 1; -- k) // 从大到小,找到即停 if (d[i - k] >= k) { ans = max(ans, 4 * k); break; } } ``` **复杂度的隐患**:这个内层循环最坏是 $O(n)$,总 $O(n^2)$。但对本题的数据($n\le5\times10^5$)**会被卡**。正解需要更精细的处理:按 $d_i$ 排序后扫描,或用"对每个 $j$ 更新它能覆盖的 $i$ 的答案"的并查集/线段树。 **务实的选择**:这道题在省选里属于"Manacher 应用"的代表题,核心洞察是**"双倍回文 = 回文 + 左半回文"** 这个刻画,以及"长度必为 4 的倍数"。写出 $O(n\log n)$ 的做法(枚举 $j$,用线段树维护 $j+d_j\ge i$ 的最左 $j$)即可通过。 **复杂度**:上述优化后 $O(n\log n)$。 **点评**:本题的价值在于**转化**——把"双倍回文"这个绕口的条件拆成"整体回文 + 左半回文"两个可判定条件。字符串题的难点常常不在算法,而在这个转化。 #### 例题:P4555 [国家集训队] 最长双回文串 **题意**:求串 $S$($|S|\le10^5$)的最长"双回文子串"$T=XY$,其中 $X,Y$ 都非空且都是回文串。 **关键转化**:**枚举断点**,求"以断点结尾的最长回文"+"以断点开头的最长回文"。 **第一步:Manacher 求回文半径。** 插入分隔符后,$p_i$ 为以 $i$ 为中心的最长回文**半径**(含中心,即 $p_i-1$ 是原串中该回文的长度)。 **第二步:预处理两个数组。** - $L_i$ = "以位置 $i$ **结尾**的最长回文长度"; - $R_i$ = "以位置 $i$ **开头**的最长回文长度"。 **由 $p$ 数组怎么算**:若中心 $c$ 的半径是 $p_c$,则它有回文覆盖区间 $[c-p_c+1,c+p_c-1]$。这个回文的**每个后缀**也是回文(回文的后缀不一定是回文,但以下方式有效): - 以 $c+p_c-1$(最右端)结尾的回文长度至少是 $p_c-1$; - 从中心向两边推:对每个 $i$,$L_i$ 可以由更靠右的中心更新,用一个"从右往左"的松弛。 **标准做法**:先算 $L$:对每个中心 $c$,它能贡献给右端点 $r=c+p_c-1$,长度 $p_c-1$;然后**从右往左**做 $L_i\leftarrow\max(L_i,L_{i+1}-2)$(因为回文半径往内缩 2 对应长度减 2)。 同理算 $R$:从左往右做。 ```cpp // Manacher 求 p 数组后 // L[i]:以 i 结尾的最长回文长度(i 是原串下标,1-based) for (int i = 1; i <= len; ++ i) L[i] = 0; // len = 2n+1 for (int i = 1; i <= len; ++ i) { int r = i + p[i] - 1; // 覆盖区间右端 if (r <= len) L[r] = max(L[r], p[i] - 1); } for (int i = len - 2; i >= 1; i -= 2) { // 只处理原串位置(隔 2) L[i] = max(L[i], L[i + 2] - 2); } // R 同理,反向 for (int i = 1; i <= len; ++ i) R[i] = 0; for (int i = 1; i <= len; ++ i) { int l = i - p[i] + 1; if (l >= 1) R[l] = max(R[l], p[i] - 1); } for (int i = 3; i <= len; i += 2) { R[i] = max(R[i], R[i - 2] - 2); } // 枚举断点(原串位置 i 之后断开) int ans = 0; for (int i = 3; i <= len - 2; i += 2) if (L[i] && R[i + 2]) ans = max(ans, L[i] + R[i + 2]); ``` **样例验证**:`baacaabbacabb`($n=13$)输出 $12$ ✓。 **复杂度**:$O(n)$。 **点评**:**"枚举断点 + 预处理左右两侧最优"是双回文、双单调类问题的通用套路**。核心难点在"如何从 $p$ 数组推出 $L/R$",关键洞察是"回文半径每次缩 2,长度减 2"。 #### 例题:P1659 [国家集训队] 拉拉队排练 **题意**:给串 $S$($|S|\le10^6$)和 $K$($K\le10^{12}$),求所有**长度为奇数**的回文子串按长度降序排列后,前 $K$ 个的长度乘积,模 $19930726$。若不足 $K$ 个输出 $-1$。 **关键转化**:**Manacher 求每个长度的回文个数**,然后快速幂。 **第一步:每个中心贡献多少回文。** 若中心 $c$ 的半径为 $p_c$,则它贡献了 $1,3,5,\ldots$ 各一个(长度递增)。也就是说,**长度 $2k-1$ 的回文,在以 $c$ 为中心时恰好有一个**($k\le p_c$)。 **第二步:统计每个长度的个数。** 设 $cnt[k]$ = 长度为 $2k-1$ 的回文个数。由第一步,中心 $c$ 对 $cnt[1..p_c]$ 各贡献 1。用**差分**:`diff[1] += 1, diff[p_c+1] -= 1`,再做前缀和。 **第三步:从长到短取 $K$ 个。** 用快速幂累乘。 ```cpp #include <bits/stdc++.h> using namespace std; using ll = long long; const int N = 1e6 + 10, MOD = 19930726; int p[N << 1], n; char s[N], t[N << 1]; int cnt[N]; ll qpow(ll a, ll n) { ll r = 1; a %= MOD; while (n) { if (n & 1) r = r * a % MOD; a = a * a % MOD; n >>= 1; } return r; } int main() { ll K; scanf("%d%lld%s", &n, &K, s + 1); // 插入分隔符 int len = 0; t[++ len] = '#'; for (int i = 1; i <= n; ++ i) t[++ len] = s[i], t[++ len] = '#'; // Manacher int mid = 0, rb = 0; for (int i = 1; i <= len; ++ i) { p[i] = (i < rb) ? min(p[2 * mid - i], rb - i + 1) : 1; while (i - p[i] >= 1 && i + p[i] <= len && t[i - p[i]] == t[i + p[i]]) ++ p[i]; if (i + p[i] - 1 > rb) rb = i + p[i] - 1, mid = i; } // 差分统计奇数长度回文个数 for (int i = 1; i <= len; ++ i) if (t[i] != '#') { int radius = p[i] - 1; // 原串中回文半径 int kmax = (radius + 1) / 2; // 能贡献的最大 k(长度 2k-1) if (kmax >= 1) { ++ cnt[1]; -- cnt[kmax + 1]; } } for (int i = 1; i <= n; ++ i) cnt[i] += cnt[i - 1]; ll ans = 1, got = 0; for (int k = (n + 1) / 2; k >= 1; -- k) { ll c = cnt[k]; if (!c) continue; ll len = 2LL * k - 1; ll take = min(c, K - got); ans = ans * qpow(len, take) % MOD; got += take; if (got == K) break; } printf("%lld\n", got < K ? -1 : ans); return 0; } ``` **样例验证**:$n=5,K=3$,串 `ababa`,输出 $45=5\times3\times3$ ✓。 **复杂度**:$O(n\log K)$(快速幂)。 **点评**:**"差分统计每个长度的个数"是本题的关键**,它把 $O(n^2)$ 的枚举变成 $O(n)$。这类"Manacher + 差分"的题还常用于"求每个长度的回文出现次数"。 --- ### 6.7 与哈希的取舍 | | Manacher | 哈希 + 二分 | | ---------------- | ---------------- | ---------------------------- | | 复杂度 | $O(n)$ | $O(n\log n)$ | | 实现难度 | 中等(边界易错) | 低(二分不会写错) | | 正确性 | 确定 | 有碰撞风险 | | 能否求"所有回文" | 能(半径数组) | 需逐个枚举中心,$O(n\log n)$ | | 能否带修改 | 不能 | 能(配合数据结构) | **取法**:$n\le10^6$ 且只需最长回文,用 Manacher;$n$ 小或有修改,哈希二分更省心。 **相关例题** [P3501 ANT-Antisymmetry](https://www.luogu.com.cn/problem/P3501) · *[P5446 STRING](https://www.luogu.com.cn/problem/P5446)* #### 习题池:Manacher / 回文 **入门·普及** | 题号 | 题名 | 平台 | 难度 | | ----- | ------------------------------------------------------------ | ---- | ---------- | | P1210 | [[USACO1.3] 最长的回文 Calf Flac](https://www.luogu.com.cn/problem/P1210) | 洛谷 | 普及/提高− | **提高** | 题号 | 题名 | 平台 | 难度 | | ----- | ------------------------------------------------------------ | ---- | ----------- | | P3501 | [[POI 2010] ANT-Antisymmetry](https://www.luogu.com.cn/problem/P3501) | 洛谷 | 提高+/省选− | | P9606 | [[CERC2019] ABB](https://www.luogu.com.cn/problem/P9606) | 洛谷 | 普及+/提高 | | P1723 | [高手过愚人节](https://www.luogu.com.cn/problem/P1723) | 洛谷 | 提高+/省选− | | P5446 | [[THUPC 2018] 绿绿和串串](https://www.luogu.com.cn/problem/P5446) | 洛谷 | 提高+/省选− | | P1872 | [回文串计数](https://www.luogu.com.cn/problem/P1872) | 洛谷 | 普及+/提高 | | P2601 | [[ZJOI2009] 对称的正方形](https://www.luogu.com.cn/problem/P2601) | 洛谷 | 提高+/省选− | | P4324 | [[JSOI2016] 扭动的回文串](https://www.luogu.com.cn/problem/P4324) | 洛谷 | 提高+/省选− | **省选** | 题号 | 题名 | 平台 | 难度 | | ------ | ------------------------------------------------------------ | ---- | --------- | | P6216 | [回文匹配](https://www.luogu.com.cn/problem/P6216) | 洛谷 | 省选/NOI− | | P17330 | [[ICPC 2018 Nanjing R] Mediocre String Problem](https://www.luogu.com.cn/problem/P17330) | 洛谷 | 省选/NOI− | **NOI** | 题号 | 题名 | 平台 | 难度 | | ------ | ------------------------------------------------------------ | ---- | ---- | | P3546 | [[POI 2012] PRE-Prefixuffix](https://www.luogu.com.cn/problem/P3546) | 洛谷 | NOI | | P4199 | [万径人踪灭](https://www.luogu.com.cn/problem/P4199) | 洛谷 | NOI | | P9482 | [[NOI2023] 字符串](https://www.luogu.com.cn/problem/P9482) | 洛谷 | NOI | | P3279 | [[SCOI2013] 密码](https://www.luogu.com.cn/problem/P3279) | 洛谷 | NOI | | P12977 | [泪雨 Namid[A]me](https://www.luogu.com.cn/problem/P12977) | 洛谷 | NOI | | P14830 | [[THUPC 2026 初赛] 回响形态](https://www.luogu.com.cn/problem/P14830) | 洛谷 | NOI | | P14061 | [【MX-X21-T6】[IAMOI R5] 希望有羽毛和翅膀](https://www.luogu.com.cn/problem/P14061) | 洛谷 | NOI | | P9646 | [[SNCPC2019] Paper-cutting](https://www.luogu.com.cn/problem/P9646) | 洛谷 | NOI | | P8631 | [[蓝桥杯 2015 国 AC] 切开字符串](https://www.luogu.com.cn/problem/P8631) | 洛谷 | NOI | --- ### 6.8 本章小结 Manacher 是"Z 函数思想的回文版":**维护最右已知区间,镜下取答案,越界再扩**。三种算法(KMP/Z/Manacher)共享同一个复杂度论证和同一个设计哲学。 --- ## 7. 字符串匹配综合:五把武器怎么选 ### 7.1 决策表 | 题目在问什么 | 首选 | 备选 | 理由 | | ------------------------------ | --------- | --------------- | ----------------------------- | | 一个模式串在一段文本里出现几次 | KMP | Z/exKMP | 两者都 $O(n+m)$,KMP 更省空间 | | 每个位置的多模式匹配 | AC 自动机 | — | 第二卷 | | 两段子串是否相等(问很多次) | 哈希 | — | $O(1)$ 判等,没有第二选择 | | 最长公共前缀(任意两后缀) | 哈希+二分 | SA+height | 哈希更好写 | | 前缀 = 后缀的结构 | KMP | Z | KMP 的 border 链是现成的 | | 最小循环节 / 周期 | KMP | Z | $n-\pi_n$ 一行搞定 | | 最长回文 | Manacher | 哈希+二分 | Manacher 快但易错 | | 回文计数 | Manacher | — | 半径数组直接给答案 | | 本质不同子串 | SAM / SA | KMP($O(n^2)$) | 第二卷 | | 前缀关系(谁是谁的前缀) | Trie | — | 哈希答不了 | ### 7.2 三条选型原则 **原则一:先看"判等"还是"结构"**。只问"相等"用哈希;问"前后缀关系"用 KMP/Z;问"回文"用 Manacher;问"前缀树关系"用 Trie。 **原则二:确定性优先**。若算法错一个就全盘皆错(计数类、判定类),用确定性算法;若只是中间剪枝/加速,哈希的碰撞风险可接受。 **原则三:实现成本也要算**。考场上 Manacher 的边界比哈希+二分难写得多。$n\le10^5$ 时哈希+二分的 $O(n\log n)$ 完全够,不必冒险。 ### 7.3 一个综合例子:判重与周期的组合 > 给 $s$,判断它是否能由某个串重复至少两次构成,若是,求最小循环节。 **做法**:KMP 求 $\pi_n$,$p=n-\pi_n$,判 $n\bmod p=0$ 且 $p<n$。 **如果 $n$ 到 $10^7$ 呢**:KMP 是 $O(n)$,能过。哈希需要 $O(n)$ 枚举 $p$ 再 $O(1)$ 判等,也是 $O(n)$,但碰撞风险还在。**这里选 KMP 更稳**。 **如果允许修改字符呢**:KMP 失效(改一个字符要重算整棵树),此时用哈希 + 线段树维护每段的哈希,$O(\log n)$ 改一次。这就是"哈希能带修、KMP 不能"的典型场景。 **相关例题** [P4391 Radio Transmission](https://www.luogu.com.cn/problem/P4391) · *[P9753 消消乐](https://www.luogu.com.cn/problem/P9753)* ### 7.4 本章小结 四把武器不是互斥的,而是**覆盖不同查询类型**。真正难的题往往要组合两个以上:哈希定位 + KMP 验证、Manacher 求半径 + DP 计数、Z 函数求 LCP + 并查集合并。 **见到字符串题的第一步**:先问"它要的是判等、前后缀关系、还是回文"。答案出来,武器就定了。 --- ## 8. AC 自动机 ### 8.1 它解决什么问题 **问题**:给 $n$ 个模式串(总长 $\le10^6$)和一个文本串(长 $\le10^6$),求每个模式串在文本中出现了几次。 **朴素做法**:对每个模式串跑一次 KMP。总复杂度 $O(n\cdot|T|)$,$n$ 大时不可行。 **AC 自动机的思路**:把 $n$ 个模式串建成一棵 Trie(共享前缀),然后在 Trie 上"一次扫描文本"。核心难点是:**匹配到某个节点后,如果下一个字符不匹配,该退到哪?** 答案是 **fail 指针**——它指向"当前已匹配串的最长真后缀,且该后缀也是某个模式串的前缀"。 ### 8.2 Trie 与 fail 指针 ![AC 自动机:Trie + fail + Trie 图 + fail 树](https://cdn.luogu.com.cn/upload/image_hosting/b2sjyl4s.webp) *AC 自动机:实线为 Trie 转移,橙色虚线为 fail* **前置**(§3 的浓缩):Trie 是一棵多叉树,从根到节点的路径拼成一个前缀。节点 $u$ 代表前缀 $p_u$。 **定义 1.1(fail 指针)**:对 Trie 上的节点 $u$(代表前缀 $p_u$),定义 $$fail[u]=\text{满足"}\ p_v\ \text{是}\ p_u\ \text{的真后缀"的最长前缀}\ p_v\ \text{对应的节点}\ v \tag{1}$$ 若不存在,$fail[u]=\text{root}$。 **为什么是这个定义**:匹配失败时,我们已经匹配了 $p_u$。下一个字符匹配不上,就说明"长的匹配失败了",但可能"短的还能继续"。要退到的必须是**既是 $p_u$ 的后缀、又是某个模式串前缀**的状态——而这正是 $fail[u]$ 的定义。 **计算 fail**:按 BFS 顺序(保证父亲先算完)。 $$fail[u]=\begin{cases}\text{root}&u\text{ 是根的儿子}\\ch\bigl[fail[fa[u]]\bigr][c_u]&u\text{ 是 }fa[u]\text{ 的 }c_u\text{ 儿子}\end{cases} \tag{2}$$ **为什么这个递推对**:$p_u=p_{fa[u]}+c_u$,所以"$p_u$ 的最长真后缀(且是前缀)"= "($p_{fa[u]}$ 的最长真后缀 $+$ $c_u$)"中最长的那个。而 $p_{fa[u]}$ 的最长真后缀就是 $fail[fa[u]]$。所以只需要从 $fail[fa[u]]$ 沿 $c_u$ 走一步;若没有这条边,就说明要退得更短,但**Trie 图会把这条边补全**(见 1.4)。 ### 8.3 BFS 建 fail 与匹配 ```cpp const int N = 1e6 + 10; int ch[N][26], fail[N], tot = 1; int cnt[N], vis[N]; // cnt: 节点被经过次数; vis: 输出用 void insert(const char *s) { int u = 1; for (int i = 0; s[i]; ++ i) { int c = s[i] - 'a'; if (!ch[u][c]) ch[u][c] = ++ tot; u = ch[u][c]; } // 记录"以 u 结尾的模式串编号"(若需输出) } void build() { queue<int> q; for (int c = 0; c < 26; ++ c) if (ch[1][c]) fail[ch[1][c]] = 1, q.push(ch[1][c]); else ch[1][c] = 1; // Trie 图:根的空边指向自己 while (!q.empty()) { int u = q.front(); q.pop(); for (int c = 0; c < 26; ++ c) { int v = ch[u][c]; if (v) fail[v] = ch[fail[u]][c], q.push(v); else ch[u][c] = ch[fail[u]][c]; // 补边(Trie 图) } } } ``` **注意 `ch[1][c] = 1` 与 `ch[u][c] = ch[fail[u]][c]` 这两行**——它们把 Trie **补成完整的自动机**(每个状态对每个字符都有转移)。这样匹配时**不需要跳 fail**,每次 $O(1)$。 #### 例题:P3808 【模板】AC 自动机(简单版) **题意**:给 $n$ 个模式串($\sum|s_i|\le10^6$)和一个文本串 $T$($|T|\le10^6$),求有多少个模式串在 $T$ 中出现过(**只算一次**)。 **关键转化**:AC 自动机 + 去重标记。 **为什么不能直接累加**:同一个模式串可能出现多次,但题目只算一次。所以要给每个模式串一个"编号",在节点上记录"以该节点结尾的模式串编号",统计时用 `vis` 数组去重。 ```cpp #include <bits/stdc++.h> using namespace std; const int N = 1e6 + 10; int ch[N][26], fail[N], tot = 1; int ed[N], vis[N]; // ed: 以该节点结尾的模式串编号 int ans = 0; void insert(const char *s, int id) { int u = 1; for (int i = 0; s[i]; ++ i) { int c = s[i] - 'a'; if (!ch[u][c]) ch[u][c] = ++ tot; u = ch[u][c]; } ed[u] = id; // 可能有多个,本题保证不重复 } void build() { queue<int> q; for (int c = 0; c < 26; ++ c) if (ch[1][c]) fail[ch[1][c]] = 1, q.push(ch[1][c]); else ch[1][c] = 1; while (!q.empty()) { int u = q.front(); q.pop(); for (int c = 0; c < 26; ++ c) { int v = ch[u][c]; if (v) fail[v] = ch[fail[u]][c], q.push(v); else ch[u][c] = ch[fail[u]][c]; } } } int main() { int n; scanf("%d", &n); char s[N]; for (int i = 1; i <= n; ++ i) scanf("%s", s), insert(s, i); build(); scanf("%s", s); int u = 1; for (int i = 0; s[i]; ++ i) { u = ch[u][s[i] - 'a']; // Trie 图,O(1) for (int j = u; j != 1 && !vis[j]; j = fail[j]) { vis[j] = 1; // 沿 fail 链标记(每个节点只走一次) if (ed[j]) ++ ans; } } printf("%d\n", ans); return 0; } ``` **复杂度**:$O(\sum|s_i|\cdot26+|T|)$。`for (int j = u; ...)` 那一层看似 $O(|T|\cdot n)$,但因为 `vis` 让每个节点只被访问一次,均摊 $O(\text{节点数})$。 **点评**:**`vis` 去重是本题的核心**。没有它,`fail` 链会被反复走,复杂度退化。 --- ### 8.4 Trie 图的作用 **没有 Trie 图时**的匹配: ```cpp // 朴素:失配时跳 fail int u = 1; for (int i = 0; s[i]; ++ i) { int c = s[i] - 'a'; while (u && !ch[u][c]) u = fail[u]; // 可能要跳很多次 u = ch[u][c]; // 统计 u 与它的所有 fail 祖先 } ``` **有 Trie 图时**: ```cpp int u = 1; for (int i = 0; s[i]; ++ i) { u = ch[u][s[i] - 'a']; // 一步到位,O(1) // 统计 u 与它的所有 fail 祖先 } ``` **代价**:空间从"稀疏的 Trie"变成"$O(\text{节点}\times|\Sigma|)$ 的完整表"。$10^6$ 个节点、字符集 $26$ 时要 $2.6\times10^7$ 个 `int`,约 104 MB。 **优化(若空间紧张)**:用 `map` 或"左儿子右兄弟"存 Trie,但**不补边**,匹配时跳 fail。这时单次匹配可能跳多次,但**总跳次数是 $O(|T|)$ 的**(与 KMP 的均摊分析同理)。 ### 8.5 fail 树:统计出现次数 **这一节与 §4.9 是同一件事**——那里讲单模式串的 fail 树,这里只是把"一个串的前缀"换成"Trie 的一个状态"。若对 §4.9 还有印象,下面三段的每一句都能逐字对应过去。 **定义 1.2(fail 树)**:以 `fail[u]` 为 $u$ 的父亲构成的树,根是 Trie 的根。 **与 KMP 的对照**: | | KMP(§4.9) | AC 自动机(本节) | | ----------- | ----------------------------- | --------------------------------- | | 节点 | $T$ 的一个前缀(编号 = 长度) | Trie 的一个状态(一个模式串前缀) | | fail 的定义 | 最长 border | 最长的、且是某模式串前缀的真后缀 | | 祖先链含义 | 该前缀的全部 border | 该状态的全部"后缀模式串" | | 子树和含义 | 该前缀在 $T$ 中出现次数 | 该模式串在文本中出现次数 | | 建树 | 编号即拓扑序(`fail[i]<i`) | BFS 序即拓扑序 | **关键性质**:**$u$ 的子树 = "所有 fail 链经过 $u$ 的节点"**。这一句与 §4.9 的"$i$ 到根的路径 = 全部 border"是同一件事的两面。 **定理 1.3(出现次数统计)**:设文本串在 AC 自动机上跑完后,每个状态 $u$ 被经过 $cnt[u]$ 次。则模式串 $p_v$(对应节点 $v$)在文本中的出现次数为 $$\text{occur}(v)=\sum_{u\in\text{subtree}(v)}cnt[u] \tag{3}$$ **证明**:模式串 $p_v$ 在文本某位置 $i$ 出现,当且仅当"文本的前缀 $T[1..i]$ 在 Trie 上匹配到了某个节点 $u$,且 $p_v$ 是 $p_u$ 的后缀"。 由 fail 的定义,$p_v$ 是 $p_u$ 的后缀 $\iff$ $v$ 是 $u$ 的 fail 祖先 $\iff$ $u$ 在 fail 树上属于 $v$ 的子树。 所以"$p_v$ 的出现次数"= "所有满足 $v$ 是 $u$ 的祖先的 $u$ 的 $cnt$ 之和" = 子树求和。$\square

证明要点:把"p_v 是 p_u 的后缀"翻译成 fail 树上的祖先关系,子树求和即可。

实现:按 BFS 逆序(深度从深到浅)做 cnt[fail[u]] += cnt[u]。

// 文本串跑完后,按 BFS 逆序累加
for (int i = tot; i >= 1; -- i)          // 按节点编号逆序(BFS 序保证父亲编号 < 儿子)
    cnt[fail[i]] += cnt[i];

为什么逆序就够:BFS 建 fail 时,父亲的编号一定小于儿子的编号(Trie 是先序建、BFS 是层序算 fail,但 ch 数组的编号是插入顺序,不一定满足)。更稳的做法是记录 BFS 序列,然后倒序遍历。

int qq[N], qh;                           // BFS 序
// build() 里:qq[++ qh] = v;
// 逆序累加
for (int i = qh; i >= 1; -- i) {
    int u = qq[i];
    cnt[fail[u]] += cnt[u];
}

例题:P5357 【模板】AC 自动机(二次加强版)

题意:同 12.1,但要输出每个模式串的出现次数。|T|\le2\times10^6。

关键转化:fail 树子树求和(第 1.5 节)。

为什么 12.1 的做法不行:12.1 用 vis 去重,但本题要精确次数,不能去重。而暴力沿 fail 链跳的总复杂度是 O(|T|\cdot\text{链长}),会 TLE。

做法:文本串跑完后,只给"到达的状态"计数,然后按 BFS 逆序做 cnt[fail[u]] += cnt[u]。

#include <bits/stdc++.h>
using namespace std;
const int N = 2e5 + 10;
int ch[N][26], fail[N], tot = 1;
int ed[N], cnt[N];
int qq[N], qh;                            // BFS 序

void insert(const char *s, int id) {
    int u = 1;
    for (int i = 0; s[i]; ++ i) {
        int c = s[i] - 'a';
        if (!ch[u][c]) ch[u][c] = ++ tot;
        u = ch[u][c];
    }
    ed[u] = id;
}
void build() {
    queue<int> q;
    for (int c = 0; c < 26; ++ c)
        if (ch[1][c]) fail[ch[1][c]] = 1, q.push(ch[1][c]);
        else ch[1][c] = 1;
    while (!q.empty()) {
        int u = q.front(); q.pop();
        qq[++ qh] = u;                     // 记录 BFS 序
        for (int c = 0; c < 26; ++ c) {
            int v = ch[u][c];
            if (v) fail[v] = ch[fail[u]][c], q.push(v);
            else ch[u][c] = ch[fail[u]][c];
        }
    }
}
int main() {
    int n; scanf("%d", &n);
    static char s[N * 10];
    for (int i = 1; i <= n; ++ i) scanf("%s", s), insert(s, i);
    build();
    scanf("%s", s);
    int u = 1;
    for (int i = 0; s[i]; ++ i) {
        u = ch[u][s[i] - 'a'];
        ++ cnt[u];                          // 只给到达的状态计数
    }
    for (int i = qh; i >= 1; -- i) {        // BFS 逆序累加
        int v = qq[i];
        cnt[fail[v]] += cnt[v];
    }
    // 输出:每个模式串的节点 ed 对应的 cnt
    return 0;
}

复杂度:O(\sum|s_i|\cdot26+|T|),不再有 fail 链的额外开销。

点评:"只计数到达的状态 + BFS 逆序累加"是 AC 自动机统计的标准做法。它把"沿链跳"变成"一次树形累加"。

8.6 AC 自动机上的 DP

模型:在 AC 自动机上走 n 步,每步选一个字符,统计路径数(可能带"不能经过某些节点"的限制)。

状态:(u,\text{步数}),u 是 AC 自动机的状态。

转移:f_{k+1}[ch[u][c]]\mathrel{+}=f_k[u]。

"不能经过"的处理:若某节点 u 代表一个模式串的结尾,或它的 fail 链上某个节点是模式串结尾,则 u 是"禁止节点"。在建 fail 时标记:

// build() 中
if (v) {
    fail[v] = ch[fail[u]][c];
    bad[v] |= bad[fail[v]];              // 关键:继承 fail 的禁止标记
    q.push(v);
}

为什么必须继承:因为"p_u 的某个后缀是模式串"意味着"走到 u 时,那个模式串已经完整出现了"。

相关例题 P4052 [JSOI2007] 文本生成器 · P3311 [SDOI2014] 数数 · P2444 [POI2000] 病毒

例题:P4052 [JSOI2007] 文本生成器

题意:给 n 个模式串(总长 \le6000),求有多少个长度为 m(m\le100)的字符串至少包含一个模式串,模 10^4+7。

关键转化:补集 + AC 自动机上 DP(正难则反)。

第一步:反向计数。 "至少包含一个"的补集是"一个都不含"。所以

\text{ans}=26^m-\bigl(\text{不含任何模式串的长度 }m\text{ 串数}\bigr)

第二步:拆成"禁止节点"。 AC 自动机上,若状态 u 是某模式串的结尾,或 u 的 fail 链上有模式串结尾,则 u 是禁止节点(走到它就说明某个模式串已经完整出现)。

关键是建 fail 时继承禁止标记:

bad[v] |= bad[fail[v]];      // ★ 在 build() 的 BFS 里,这一步不能漏

第三步:在 AC 自动机上 DP。 设 f(k,u) 为"走了 k 步、当前在状态 u、全程没经过禁止节点"的方案数。转移:

f(k+1,\ ch[u][c])\ \mathrel{+}=\ f(k,u)\quad\text{若 }ch[u][c]\text{ 不是禁止节点}

答案就是 26^m-\sum_{u}f(m,u)。

#include <bits/stdc++.h>
using namespace std;
const int N = 6010, MOD = 10007;
int ch[N][26], fail[N], tot = 1;
bool bad[N];
int f[2][N];

void insert(const char *s) {
    int u = 1;
    for (int i = 0; s[i]; ++ i) {
        int c = s[i] - 'A';
        if (!ch[u][c]) ch[u][c] = ++ tot;
        u = ch[u][c];
    }
    bad[u] = true;
}
void build() {
    queue<int> q;
    for (int c = 0; c < 26; ++ c)
        if (ch[1][c]) fail[ch[1][c]] = 1, q.push(ch[1][c]);
        else ch[1][c] = 1;
    while (!q.empty()) {
        int u = q.front(); q.pop();
        bad[u] |= bad[fail[u]];                 // ★ 继承禁止标记
        for (int c = 0; c < 26; ++ c) {
            int v = ch[u][c];
            if (v) fail[v] = ch[fail[u]][c], q.push(v);
            else ch[u][c] = ch[fail[u]][c];
        }
    }
}
int main() {
    int n, m; scanf("%d%d", &n, &m);
    char s[110];
    for (int i = 1; i <= n; ++ i) scanf("%s", s), insert(s);
    build();
    f[0][1] = 1;                                // 从根出发
    for (int k = 0; k < m; ++ k) {
        memset(f[(k + 1) & 1], 0, sizeof f[0]);
        for (int u = 1; u <= tot; ++ u) {
            if (!f[k & 1][u] || bad[u]) continue;
            for (int c = 0; c < 26; ++ c) {
                int v = ch[u][c];
                if (bad[v]) continue;           // 不进入禁止节点
                f[(k + 1) & 1][v] = (f[(k + 1) & 1][v] + f[k & 1][u]) % MOD;
            }
        }
    }
    int sum = 0;
    for (int u = 1; u <= tot; ++ u) if (!bad[u]) sum = (sum + f[m & 1][u]) % MOD;
    // 答案 = 26^m - sum
    int all = 1;
    for (int i = 1; i <= m; ++ i) all = all * 26 % MOD;
    printf("%d\n", (all - sum + MOD) % MOD);
    return 0;
}

复杂度:O(m\cdot\text{节点数}\cdot26)。节点 6000、m=100 时约 1.6\times10^7。

要点:bad[u] |= bad[fail[u]] 是本题唯一的核心——它保证"走到 u 就意味着某个模式串已经出现"。漏了这句,只有"以模式串结尾"的节点被禁,而"u 的后缀是模式串"的情况全部漏判。

8.7 复杂度与实现陷阱

操作 复杂度
建 Trie O(\sum\lvert s_i\rvert)
建 fail(BFS) O(\text{节点}\times\lvert\Sigma\rvert)
匹配文本 O(\lvert T\rvert)(Trie 图版)或 O(\lvert T\rvert) 均摊(跳 fail 版)
fail 树统计 O(\text{节点})

陷阱一:fail[root] 的值。 根的 fail 应设为 0(或根自己),且根的儿子的 fail 必须是根。写成根的儿子 fail 为 0 会导致死循环。

陷阱二:多组数据不清空。 tot 重置为 1,且清空 ch[1] 那一行。

陷阱三:忘记继承禁止标记。 见 1.6。

陷阱四:统计出现次数时用节点编号逆序。 必须用 BFS 序逆序,不是编号逆序(两者可能不一致)。

8.8 延伸阅读(更多同知识点题目)

相关例题 P3808 【模板】AC 自动机(简单版) · P3796 【模板】AC 自动机(简单版 II) · P5357 【模板】AC 自动机(二次加强版) · P2444 [POI2000] 病毒 · P2414 [NOI2011] 阿狸的打字机 · P4052 [JSOI2007] 文本生成器 · P3311 [SDOI2014] 数数 · P5840 [COCI2015] Divljak · P3121 [USACO15FEB] Censoring G · CF1437G Death DBMS

习题池:AC 自动机 / 多模式匹配

提高

题号 题名 平台 难度
P9753 [CSP-S 2023] 消消乐 洛谷 提高+/省选−
P1470 [IOI 1996 / USACO2.3] 最长前缀 Longest Prefix 洛谷 普及+/提高
P2922 [USACO08DEC] Secret Message G 洛谷 普及+/提高
P3065 [USACO12DEC] First! G 洛谷 提高+/省选−
P1333 瑞瑞的木棍 洛谷 普及+/提高
P5546 [POI 2000 R3] 公共串 洛谷 普及+/提高
P2536 [AHOI2005] 病毒检测 洛谷 普及+/提高

省选·NOI

题号 题名 平台 难度
P3796 AC 自动机(简单版 II) 洛谷 省选/NOI−
P14363 [CSP-S 2025] 谐音替换 洛谷 省选/NOI−
P6623 [省选联考 2020 A 卷] 树 洛谷 NOI
P3294 [SCOI2016] 背单词 洛谷 NOI
P4585 [FJOI2015] 火星商店问题 洛谷 省选/NOI−

8.9 这一节要带走的三句话

  1. fail 指向"最长真后缀且是某模式串前缀"的节点,BFS 建,靠 fail[fa]\to c 递推。
  2. Trie 图把缺失边补全,匹配时 O(1) 转移,代价是空间 \times|\Sigma|。
  3. fail 树上的子树求和 = 出现次数,这是 AC 自动机最重要的性质。

9. 后缀数组

9.1 它解决什么问题

核心洞察(0.1 节已经点出):

\text{子串}= \text{某个后缀的前缀}

所以把 n 个后缀按字典序排好序,所有"子串"问题就变成了"有序数组上的区间问题"。

后缀数组就是"排好序的后缀":

两者互逆:sa[rank[i]]=i,rank[sa[i]]=i。

关键副产品:height 数组。

height[i]=\operatorname{lcp}(sa[i-1],sa[i]) \tag{4}

为什么 height 重要:因为任意两个后缀的 LCP 就等于它们排名区间上 height 的最小值:

\operatorname{lcp}(i,j)=\min_{k=\min(rank_i,rank_j)+1}^{\max(rank_i,rank_j)}height[k] \tag{5}

于是 LCP 查询变成 RMQ,O(1) 回答(配合 ST 表)。

9.2 倍增构造

后缀数组:排序后的后缀与 height(橙色为相邻 LCP) 朴素做法:把所有后缀取出来排序,比较两个后缀是 O(n),总 O(n^2\log n)。

倍增思路:用"长度为 2^k 的前缀的排名"推"长度为 2^{k+1} 的排名"。

设 rk_k[i] 为"从 i 开始长度 2^k 的子串"的排名。则长度为 2^{k+1} 的子串可以拆成两半:

\underbrace{s[i..i+2^k-1]}_{\text{前半}}\ \underbrace{s[i+2^k..i+2^{k+1}-1]}_{\text{后半}}

所以比较两个长为 2^{k+1} 的子串,只需比较二元组

\bigl(rk_k[i],\ rk_k[i+2^k]\bigr)

这就把"字符串比较"变成了"二元组比较",可以用基数排序 O(n) 完成一轮。

轮数:\lceil\log_2 n\rceil 轮。总复杂度 O(n\log n)。

终止条件:当所有 rk_k 互不相同(或者说 rk_k 的最大值 =n)时,说明后缀已经完全区分开了,可以停。

9.3 倍增正确性

定理 2.1:倍增法第 k 轮结束后,rk_k[i]<rk_k[j] 当且仅当"从 i 开始长 2^k 的子串"字典序小于"从 j 开始的"。

证明(对 k 归纳):

k=0:rk_0[i] 就是 s_i 的大小关系,直接按字符排序得到 ✓。

归纳步:设对 k 成立。长度为 2^{k+1} 的子串 A=s[i..i+2^{k+1}-1] 与 B=s[j..j+2^{k+1}-1] 比较字典序,先比前 2^k 个字符,若不同则由归纳假设等价于比 rk_k[i] 与 rk_k[j];若相同,则比后 2^k 个字符,等价于比 rk_k[i+2^k] 与 rk_k[j+2^k]。

所以字典序比较等价于二元组 (rk_k[i],rk_k[i+2^k]) 的字典序比较。按二元组基数排序即得 rk_{k+1} ✓。\square

证明要点:2^{k+1} 长的串拆成两个 2^k 长的串,字典序比较恰好是二元组比较。

注意"越界"的处理:当 i+2^k>n 时,后半部分视为"空",排名最小(0)。

9.4 实现

const int N = 1e6 + 10;
int sa[N], rk[N << 1], oldrk[N << 1], cnt[N], id[N];
char s[N];
int n, m;                          // m = max(n, 字符集大小)

bool cmp(int i, int j, int w) {    // 比较 (rk[i], rk[i+w]) 与 (rk[j], rk[j+w])
    if (oldrk[i] != oldrk[j]) return oldrk[i] < oldrk[j];
    return oldrk[i + w] < oldrk[j + w];
}

int main() {
    scanf("%s", s + 1);
    n = strlen(s + 1);
    m = max(n, 300);
    // 第一轮:按字符排序
    for (int i = 1; i <= n; ++ i) ++ cnt[rk[i] = s[i]];
    for (int i = 1; i <= m; ++ i) cnt[i] += cnt[i - 1];
    for (int i = n; i >= 1; -- i) sa[cnt[rk[i]] --] = i;
    // 倍增
    for (int w = 1; w < n; w <<= 1) {
        memcpy(oldrk, rk, sizeof(int) * (n + w + 1));   // ★ 必须开 2n 并清零
        int p = 0;
        for (int i = n; i > n - w; -- i) id[++ p] = i;  // 后半段为空的,排最前
        for (int i = 1; i <= n; ++ i)
            if (sa[i] > w) id[++ p] = sa[i] - w;
        memset(cnt, 0, sizeof(int) * (m + 1));
        for (int i = 1; i <= n; ++ i) ++ cnt[rk[id[i]]];
        for (int i = 1; i <= m; ++ i) cnt[i] += cnt[i - 1];
        for (int i = n; i >= 1; -- i) sa[cnt[rk[id[i]]] --] = id[i];
        // 重算 rk
        p = 0;
        for (int i = 1; i <= n; ++ i) {
            if (i == 1 || cmp(sa[i], sa[i - 1], w)) ++ p;
            rk[sa[i]] = p;
        }
        if (p == n) break;                              // 已全部区分
        m = p;
    }
    for (int i = 1; i <= n; ++ i) printf("%d ", sa[i]);
    return 0;
}

两个必错的坑:

  1. oldrk 要开 2n 并清零。因为 oldrk[i+w] 在 i+w>n 时会被访问,越界位必须是 0(表示"空后缀最小")。
  2. memcpy 的长度要含 w:sizeof(int) * (n + w + 1),否则 oldrk[n+1..n+w] 是垃圾值。

例题:P3809 【模板】后缀排序

题意:给串 s(|s|\le10^6),输出它的后缀数组 sa[1..n]。

关键转化:倍增 + 基数排序(第 2.2、2.4 节)。

#include <bits/stdc++.h>
using namespace std;
const int N = 1e6 + 10;
int sa[N], rk[N << 1], oldrk[N << 1], cnt[N], id[N];
char s[N];
int main() {
    scanf("%s", s + 1);
    int n = strlen(s + 1), m = max(n, 300);
    for (int i = 1; i <= n; ++ i) ++ cnt[rk[i] = s[i]];
    for (int i = 1; i <= m; ++ i) cnt[i] += cnt[i - 1];
    for (int i = n; i >= 1; -- i) sa[cnt[rk[i]] --] = i;
    for (int w = 1; w < n; w <<= 1) {
        memcpy(oldrk, rk, sizeof(int) * (n + w + 1));     // ★ 含 w
        int p = 0;
        for (int i = n; i > n - w; -- i) id[++ p] = i;
        for (int i = 1; i <= n; ++ i) if (sa[i] > w) id[++ p] = sa[i] - w;
        memset(cnt, 0, sizeof(int) * (m + 1));
        for (int i = 1; i <= n; ++ i) ++ cnt[rk[id[i]]];
        for (int i = 1; i <= m; ++ i) cnt[i] += cnt[i - 1];
        for (int i = n; i >= 1; -- i) sa[cnt[rk[id[i]]] --] = id[i];
        p = 0;
        auto cmp = [&](int x, int y) {
            if (oldrk[x] != oldrk[y]) return oldrk[x] < oldrk[y];
            return oldrk[x + w] < oldrk[y + w];
        };
        for (int i = 1; i <= n; ++ i) {
            if (i == 1 || cmp(sa[i - 1], sa[i])) ++ p;   // ★ 注意实参顺序
            rk[sa[i]] = p;
        }
        if (p == n) break;                                // 提前退出
        m = p;
    }
    for (int i = 1; i <= n; ++ i) printf("%d ", sa[i]);
    return 0;
}

复杂度:O(n\log n)。

点评:三个坑——oldrk 开 2n 并清零、memcpy 长度含 w、提前退出。漏任何一个都会 WA 或 RE。

9.5 O(n) 求 height

朴素求 height:对每个 i,与 sa[rank[i]-1] 暴力比较,O(n^2)。

关键性质:

h[i]\ge h[i-1]-1 \tag{6}

其中 h[i]=height[rank[i]]=\operatorname{lcp}(i,\ sa[rank[i]-1])。

证明:设 h[i-1]=L,即后缀 i-1 与它的前一名 j=sa[rank[i-1]-1] 的 LCP 是 L。

考虑后缀 i 与 j+1:

证明要点:把"后缀 i-1 与 j 的 LCP"削掉首字符,得到"后缀 i 与 j+1 的 LCP 至少 L-1",再用 LCP 的区间最小值性质传递到"相邻排名"。

由这条性质,求 height 可以"只往右试,不回退":

// O(n) 求 height,h[i] = height[rank[i]]
for (int i = 1, k = 0; i <= n; ++ i) {
    if (rk[i] == 1) { k = 0; continue; }       // 排名第一,没有前一名
    if (k) -- k;                                // 利用 h[i] >= h[i-1]-1
    int j = sa[rk[i] - 1];
    while (i + k <= n && j + k <= n && s[i + k] == s[j + k]) ++ k;
    height[rk[i]] = k;
}

复杂度:O(n)。理由:k 每次循环最多加 1(while 里),而每轮开头 --k 最多减 1,所以 k 的总增量 \le 2n。

9.6 五个经典应用

应用一:本质不同子串数

\text{ans}=\sum_{i=1}^{n}\bigl((n-sa[i]+1)-height[i]\bigr) \tag{7}

为什么:按排名顺序处理后缀。处理第 i 个后缀时,它贡献了 n-sa[i]+1 个前缀(子串),其中与前一名重复的恰好有 height[i] 个(因为前 height[i] 个字符构成的前缀已经在前面出现过)。\square

注意:为什么"与前一名重复"就够,不需要考虑所有前面的后缀?因为 LCP 的区间最小值性质——与任意更早后缀的 LCP 都不超过与前一名的 LCP。

应用二:最长公共子串(两串)

把两串拼起来(中间加分隔符),求 SA 与 height。答案是"height 中满足 sa[i-1] 与 sa[i] 分属两个串的最大值"。

应用三:最长公共子串(多串)

拼接后二分答案 L,检查"是否存在长度 L 的 height 段覆盖所有串"。用单调队列或双指针。

应用四:出现至少 k 次的最长子串

二分答案 L,检查"height 中是否存在长度 \ge k-1 的连续段,其中每个值都 \ge L"。

应用五:任意两后缀的 LCP

用 (5) + ST 表,O(1) 回答。

例题:P2408 不同子串个数

题意:给串 s(|s|\le10^5),求本质不同子串个数。

关键转化:SA + height(第 2.6 节应用一)。

\text{ans}=\sum_{i=1}^{n}\bigl((n-sa[i]+1)-height[i]\bigr)
// 求出 sa 与 height 后
long long ans = 0;
for (int i = 1; i <= n; ++ i)
    ans += (n - sa[i] + 1) - height[i];
printf("%lld\n", ans);

复杂度:O(n\log n)(求 SA)或 O(n\log n)(求 height 是 O(n))。

另一解法(SAM):\sum_{u\ne root}(len[u]-len[link[u]]),O(n)。建议用 SAM——更快更短。

点评:这道题是"SA 与 SAM 都能做、而 SAM 更优"的典型。它揭示了两者的本质关系:(9) 式的 SAM 公式与 (7) 式的 SA 公式完全等价。

例题:P2178 [NOI2015] 品酒大会

题意:给串 s(|s|\le3\times10^5)和权值 a_i。对每个 r=0..n-1,求:

关键转化:SA + 按 height 分组合并(并查集)。

第一步:LCP \ge r 的含义。 由 (5),\operatorname{lcp}(i,j)\ge r 当且仅当"rank_i 与 rank_j 之间所有 height 都 \ge r"。

所以"LCP \ge r 的对"= "在 SA 上,被 height <r 的位置切开后,每一段内部的所有后缀对"。

第二步:从大到小枚举 r,用并查集合并。 把"height =r 的位置"作为边,从大到小加入,每次合并两个相邻的段。

第三步:维护每段的(大小、最大值、最小值、答案)。 因为权值可能为负,最大乘积可能来自"两个最小负数"或"两个最大正数"。

// 按 height 从大到小,用并查集维护连续段
struct DSU {
    int fa[N], sz[N], mx[N], mn[N];
    ll cnt;                    // 当前段内对数
    ll best;                   // 当前段内最大乘积
} d;
// 初始:每个后缀自成一fragment
// 按 height 降序枚举边 (i-1, i),合并
// 每次合并后,ans_cnt[r] = 总对数, ans_val[r] = 全局最大乘积

复杂度:O(n\log n)(排序 height)或 O(n\alpha)。

点评:"LCP \ge r 的对 = SA 上被 height <r 切开后的段内对"这个转化是本题的核心。之后就是标准的"并查集维护段信息"。

例题:P1117 [NOI2016] 优秀的拆分

题意:给串 s(|s|\le3\times10^4),求"s 的所有子串中,形如 AABB 的个数"(A,B 非空)。

关键转化:枚举 AA 的结束位置与 BB 的开始位置。

第一步:分解。 若 s[l..r]=AABB,则存在断点 m 使 s[l..m]=AA、s[m+1..r]=BB。所以

\text{ans}=\sum_{m=1}^{n-1}\bigl(\text{以 }m\text{ 结尾的 }AA\text{ 个数}\bigr)\times\bigl(\text{以 }m+1\text{ 开头的 }BB\text{ 个数}\bigr)

第二步:求 f[m] 与 g[m]。

第三步:关键点法求 f,g。 枚举 |A|=L,在 L,2L,3L,\ldots 设置关键点。对相邻关键点 i 与 i+L:

若 LCS + LCP \ge L,则 AA(|A|=L)的起点可以落在一个区间内。用差分数组累加到 f,g。

// 用哈希求 LCP / LCS(或用 SA + ST)
// 对每个 L = 1..n/2
for (int L = 1; L * 2 <= n; ++ L) {
    for (int i = L; i + L <= n; i += L) {
        int j = i + L;
        int lcs = min(L, LCS(i, j));         // 往左匹配
        int lcp = min(L, LCP(i, j));         // 往右匹配
        if (lcs + lcp < L) continue;
        // AA 的起点范围:[j-lcp, i+lcs-L]
        int lo = j - lcp, hi = i + lcs - L;
        // 对每个起点 s,AA 覆盖 [s, s+2L-1]
        // 累加到 f[s+2L-1] 与 g[s]
        diffF[lo + 2*L - 1] += 1, diffF[hi + 2*L] -= 1;
        diffG[lo] += 1, diffG[hi + 1] -= 1;
    }
}
// 前缀和得 f, g
long long ans = 0;
for (int m = 1; m < n; ++ m) ans += 1LL * f[m] * g[m + 1];

复杂度:O(n\log n)(LCP/LCS 用哈希 O(\log n) 或 SA+ST O(1),枚举总次数 O(n\log n))。

点评:"枚举周期长度 + 关键点 + LCP/LCS"是求平方串的标准套路,比 Main-Lorentz 好写得多。注意区间端点的计算——这是本题最容易错的地方。

9.7 延伸阅读(更多同知识点题目)

相关例题 P3809 【模板】后缀排序 · P2408 不同子串个数* · P2852 [USACO06DEC] Milk Patterns G · P4051 [JSOI2007] 字符加密 · P4248 [AHOI2013] 差异 · P2178 [NOI2015] 品酒大会 · P3763 [TJOI2017] DNA · P2463 [SDOI2008] Sandy 的卡片 · P1117 [NOI2016] 优秀的拆分 · P2870 [USACO07DEC] Best Cow Line G · P5546 [POI2000] 公共串 · CF802I Fake News (hard)

习题池:后缀数组 / height / LCP

提高

题号 题名 平台 难度
P2852 [USACO06DEC] Milk Patterns G 洛谷 普及+/提高
P4051 [JSOI2007] 字符加密 洛谷 普及+/提高
P2870 [USACO07DEC] Best Cow Line G 洛谷 普及+/提高
P5546 [POI 2000 R3] 公共串 洛谷 普及+/提高
P10469 后缀数组 洛谷 普及+/提高
P4341 [BJWC2010] 外星联络 洛谷 普及+/提高
P8023 [ONTAK2015] Tasowanie 洛谷 提高+/省选−
P6456 [COCI 2006/2007 #5] DVAPUT 洛谷 普及+/提高
P8617 [蓝桥杯 2014 国 AC] 重复模式 洛谷 普及+/提高
P13999 [eJOI 2025] Collecting Diamonds 洛谷 提高+/省选−

省选

题号 题名 平台 难度
P4248 [AHOI2013] 差异 洛谷 省选/NOI−
P3763 [TJOI2017] DNA 洛谷 省选/NOI−
P5231 [JSOI2012] 玄武密码 洛谷 省选/NOI−
P7456 [CERC2018] The ABCD Murderer 洛谷 省选/NOI−
P5112 FZOUTSY 洛谷 省选/NOI−
P17071 [ICPC 2017 Shenyang R] Infinite Fraction Path 洛谷 省选/NOI−

NOI

题号 题名 平台 难度
P3804 【模板】后缀自动机(SAM) 洛谷 NOI
P3975 [TJOI2015] 弦论 洛谷 NOI
P4070 [SDOI2016] 生成魔咒 洛谷 NOI
P3649 [APIO2014] 回文串 洛谷 NOI
P3181 [HAOI2016] 找相同字符 洛谷 NOI
P2463 [SDOI2008] Sandy 的卡片 洛谷 NOI
P2336 [SCOI2012] 喵星球上的点名 洛谷 NOI
P5341 [TJOI2019] 甲苯先生和大中锋的字符串 洛谷 NOI

9.8 这一节要带走的三句话

  1. 子串 = 后缀的前缀,这是 SA 与 SAM 的共同基础。
  2. 倍增把"字符串比较"变成"二元组比较",每轮基数排序,总 O(n\log n)。
  3. h[i]\ge h[i-1]-1 是 O(n) 求 height 的全部秘密,它保证比较指针只前进不回退。

10. 后缀自动机(SAM)

10.1 它解决什么问题

SA 把子串问题变成"后缀数组 + 区间查询",但每次都要配合 height / ST 表 / 二分。而有些问题需要直接回答:

SAM 用一个 O(n) 状态的结构,把所有子串按"出现位置"分类存下来。

10.2 endpos 等价类

SAM 的 DAG:实线为转移、橙色虚线为 link,节点下方为 len 定义 3.1:对串 s 的子串 t,\operatorname{endpos}(t) 是 t 在 s 中所有结束位置的集合。

例:s=\texttt{ababa}。

关键观察:\texttt{ba} 与 \texttt{aba} 的 endpos 相同(都是 \{3,5\})。

定义 3.2:若两个子串的 endpos 相同,称它们属于同一个 endpos 等价类。

定理 3.3(等价类的结构):同一个 endpos 等价类中的所有子串,按长度排序后是连续的,且较短者是较长者的后缀。

证明:设 t_1,t_2 在同一等价类,|t_1|<|t_2|。

先证 t_1 是 t_2 的后缀:因为 \operatorname{endpos}(t_1)=\operatorname{endpos}(t_2)\ne\varnothing,取某个结束位置 e,则 t_1 与 t_2 都在 e 结束。同一结束位置、不同长度的两个子串,短者必是长者的后缀。✓

再证"连续":若 t_1 是 t_2 的后缀且 |t_1|<|t_3|<|t_2|,t_3 是 t_2 的后缀,则

\operatorname{endpos}(t_2)\subseteq\operatorname{endpos}(t_3)\subseteq\operatorname{endpos}(t_1)

(越长出现越少)。而已知两端相等,所以中间也相等,t_3 也在同一类。✓ \square

证明要点:endpos 的包含关系按长度单调,两端相等逼迫中间相等。

推论 3.4:每个等价类可以用三元组 (len_{\min},len_{\max},\operatorname{endpos}) 刻画,其中 len_{\max} 是该类中最长子串的长度。

SAM 的每个状态 = 一个等价类,状态存 len(该类最长子串长度)与 \operatorname{link}(指向"另一个等价类的代表")。

10.3 状态数与转移数上界

定理 3.5:长度 n 的串,SAM 的状态数 \le 2n-1(n\ge2),转移数 \le 3n-4。

证明思路(状态数):除初始状态外,每个状态对应一次"拆分"或"新建"。

所以总状态数 \le n+(n-1)+1=2n。更精细的界是 2n-1。\square

证明思路(转移数):把 SAM 看成 DAG,用"每个状态至少有一条从初始状态到它的路径"与"连续转移不超过 3n-4"的计数论证。竞赛中直接用上界即可。\square

实测(脚本验证):随机串的状态数远小于上界。

10.4 构造算法

extend 的三个分支:A 无 clone、B 直接复用 q、C 需要 clone 核心操作 extend(c):在已有 SAM 上追加字符 c。

设当前"代表整个串的状态"为 last。新建状态 cur(len[cur]=len[last]+1),然后:

  1. 从 last 沿 link 往上走,把所有"没有 c 转移"的状态都连上 c\to cur;
  2. 若走到根还没有,则 link[cur]=\text{root};
  3. 否则设第一个"有 c 转移"的状态为 p,转移目标是 q:
    • 若 len[q]=len[p]+1,则 link[cur]=q;
    • 若 len[q]>len[p]+1,则克隆 q 得到 clone(len[clone]=len[p]+1,转移照抄 q,link[clone]=link[q]),把 q 与 cur 的 link 都指向 clone,并把 p 往上所有"指向 q 的 c 转移"改成指向 clone。
const int N = 1e6 + 10, SIGMA = 26;
int ch[N << 1][SIGMA], len[N << 1], link[N << 1];
int tot = 1, last = 1;              // 1 是初始状态(root)

void extend(int c) {
    int cur = ++ tot;
    len[cur] = len[last] + 1;
    int p = last;
    while (p && !ch[p][c]) ch[p][c] = cur, p = link[p];
    if (!p) link[cur] = 1;
    else {
        int q = ch[p][c];
        if (len[q] == len[p] + 1) link[cur] = q;
        else {
            int clone = ++ tot;
            len[clone] = len[p] + 1;
            link[clone] = link[q];
            for (int i = 0; i < SIGMA; ++ i) ch[clone][i] = ch[q][i];
            while (p && ch[p][c] == q) ch[p][c] = clone, p = link[p];
            link[q] = link[cur] = clone;
        }
    }
    last = cur;
}

为什么需要 clone:若 len[q]>len[p]+1,说明 q 代表的等价类里既有"长度 \le len[p]+1"的子串、又有更长的子串。而新串要求"c 转移后的 endpos 必须包含新位置",这只能对"长度 \le len[p]+1"的那部分成立。所以要把 q 拆成两个:长度 \le len[p]+1 的归 clone,更长的留在 q。

10.5 parent 树与出现次数

SAM 的 parent 树(按 link 连边) 定义 3.6:以 \operatorname{link}[u] 为 u 的父亲构成的树,称为 parent 树(或 link 树)。

性质:u 的子树 = "endpos 是 u 的 endpos 的超集且 u 是其后缀"的状态集合。所以

\text{状态 }u\text{ 代表的子串的总出现次数}=\sum_{v\in\text{subtree}(u)}\text{occ}[v] \tag{8}

其中 \text{occ}[v] 是"v 作为 cur(非 clone)被创建的次数"——即"该状态对应的结束位置中出现一次的个数"。

注意 clone 状态不计 occ:clone 只是"复制",不产生新的结束位置。

// 求每个状态的出现次数
// 建 SAM 时:非 clone 的 cur 状态 occ[cur] = 1
// 按 len 从大到小排序(等价于按拓扑序),累加
sort by len descending:
    occ[link[u]] += occ[u];

为什么按 len 降序就够:len[link[u]]<len[u],所以按 len 降序处理保证"先处理儿子后处理父亲"。

10.6 本质不同子串数

定理 3.7:

\text{本质不同子串数}=\sum_{u\ne\text{root}}\bigl(len[u]-len[\operatorname{link}[u]]\bigr) \tag{9}

证明:每个状态 u 代表一族子串,它们都是"某个最长串的后缀",长度从 len[\operatorname{link}[u]]+1 到 len[u],正好

len[u]-len[\operatorname{link}[u]]

个。不同状态的子串族由 endpos 不同而互不重叠,且所有子串都被某个状态代表。所以求和即得。\square

证明要点:把每个状态看作"长度区间",区间长度之和就是子串总数,而 endpos 不同保证了不重。

例题:P3804 【模板】后缀自动机

题意:给串 s(|s|\le10^6),求"出现次数 >1 的子串的(出现次数 \times 长度)最大值"。

关键转化:SAM + parent 树累加出现次数。

做法:

  1. 建 SAM,非 clone 状态的 occ=1;
  2. 按 len 降序累加 occ[link[u]] += occ[u];
  3. 对每个状态 u,若 occ[u]>1,用 occ[u]\times len[u] 更新答案。
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int N = 1e6 + 10, SIGMA = 26;
int ch[N << 1][SIGMA], len[N << 1], link[N << 1], occ[N << 1];
int tot = 1, last = 1;
int bucket[N], ord[N << 1];

void extend(int c) {
    int cur = ++ tot;
    len[cur] = len[last] + 1, occ[cur] = 1;      // 非 clone 计入 occ
    int p = last;
    while (p && !ch[p][c]) ch[p][c] = cur, p = link[p];
    if (!p) link[cur] = 1;
    else {
        int q = ch[p][c];
        if (len[q] == len[p] + 1) link[cur] = q;
        else {
            int clone = ++ tot;
            len[clone] = len[p] + 1;
            link[clone] = link[q];
            for (int i = 0; i < SIGMA; ++ i) ch[clone][i] = ch[q][i];
            while (p && ch[p][c] == q) ch[p][c] = clone, p = link[p];
            link[q] = link[cur] = clone;
        }
    }
    last = cur;
}
int main() {
    static char s[N]; scanf("%s", s);
    int n = strlen(s);
    for (int i = 0; i < n; ++ i) extend(s[i] - 'a');
    // 按 len 计数排序
    for (int i = 1; i <= tot; ++ i) ++ bucket[len[i]];
    for (int i = 1; i <= n; ++ i) bucket[i] += bucket[i - 1];
    for (int i = tot; i >= 1; -- i) ord[bucket[len[i]] --] = i;
    // 倒序累加(儿子先于父亲)
    ll ans = 0;
    for (int i = tot; i >= 1; -- i) {
        int u = ord[i];
        if (occ[u] > 1) ans = max(ans, (ll)occ[u] * len[u]);
        occ[link[u]] += occ[u];
    }
    printf("%lld\n", ans);
    return 0;
}

复杂度:O(n|\Sigma|)(建 SAM)+ O(n)(累加)。

点评:occ[cur]=1 只给非 clone 设,这是本题唯一的坑。clone 是"复制"而非"新结束位置",若也设 1 会夸大出现次数。

10.7 字典序第 k 小子串

第一步:算每个状态的"从它出发能走出的子串数"。 设 f[u] 为从 u 出发能走出的路径数(每条路径对应一个子串)。

f[u]=\sum_{c:\ ch[u][c]\ne0}f[ch[u][c]] \quad\text{(不重时)}

若子串可以重复计数(同一子串在不同位置算多次),则每个状态还要乘上它的出现次数。

第二步:按 f 值走。 从根出发,对每个字符 c(按字典序),若 f[ch[u][c]]<k 则 k\mathrel{-}=f[ch[u][c]],否则走这条边并输出 c。

例题:P3975 [TJOI2015] 弦论

题意:给串 s(|s|\le5\times10^5)和 t,k。t=0 时同一子串在不同位置算一次,t=1 时算多次。求字典序第 k 小的子串。

关键转化:SAM 上按 f 值走(第 3.7 节)。

第一步:算 f[u]。 f[u] = "从 u 出发能走出的子串数"。

为什么要加 occ[u]:因为"停在 u"本身对应 occ[u] 个(重复计数的)子串。

第二步:按 len 降序 DP。

第三步:从根走。 按字典序遍历儿子,若 f[v]<k 则 k\mathrel{-}=f[v],否则走 v 并输出字符。

// SAM 建好后
// 1. 求 occ(parent 树累加,见 12.5)
// 2. 按 len 降序求 f
for (int i = tot; i >= 1; -- i) {
    int u = ord[i];
    f[u] = t ? occ[u] : 1;                  // t=1 乘出现次数,t=0 每个状态算 1
    for (int c = 0; c < 26; ++ c)
        if (ch[u][c]) f[u] += f[ch[u][c]];
}
// 注意:根的 f 要减掉它的 occ/1(根不代表子串)
if (f[1] < k) { puts("-1"); return 0; }
// 3. 走
int u = 1;
while (k > 0) {
    for (int c = 0; c < 26; ++ c) if (ch[u][c]) {
        int v = ch[u][c];
        if (f[v] < k) { k -= f[v]; continue; }
        putchar('a' + c);
        k -= (t ? occ[v] : 1);              // 停在 v 消耗掉
        u = v;
        break;
    }
}

复杂度:O(n|\Sigma|)。

点评:t=0 与 t=1 的差别只在"基础值"(1 还是 occ)。而"停在 u 要扣掉基础值"这一步极易漏。

10.8 延伸阅读(更多同知识点题目)

相关例题 P3804 【模板】后缀自动机 · P2408 不同子串个数* · P3975 [TJOI2015] 弦论 · P4070 [SDOI2016] 生成魔咒 · P4248 [AHOI2013] 差异 · P3181 [HAOI2016] 找相同字符 · P2463 [SDOI2008] Sandy 的卡片 · P5341 [TJOI2019] 甲苯先生和大中锋的字符串 · P4081 [USACO17DEC] Standing Out from the Herd P · P5231 [JSOI2012] 玄武密码 · P2178 [NOI2015] 品酒大会 · CF802I Fake News (hard)

习题池:后缀自动机 SAM

提高

题号 题名 平台 难度
P1368 工艺 洛谷 提高+/省选−
P8617 [蓝桥杯 2014 国 AC] 重复模式 洛谷 普及+/提高
P17101 [ICPC 2017 Qingdao R] Suffix 洛谷 提高+/省选−

省选

题号 题名 平台 难度
P2408 不同子串个数 洛谷 省选/NOI−
P4248 [AHOI2013] 差异 洛谷 省选/NOI−
P5231 [JSOI2012] 玄武密码 洛谷 省选/NOI−

NOI

题号 题名 平台 难度
P4070 [SDOI2016] 生成魔咒 洛谷 NOI
P3649 [APIO2014] 回文串 洛谷 NOI
P3181 [HAOI2016] 找相同字符 洛谷 NOI
P2463 [SDOI2008] Sandy 的卡片 洛谷 NOI
P2336 [SCOI2012] 喵星球上的点名 洛谷 NOI
P3346 [ZJOI2015] 诸神眷顾的幻想乡 洛谷 NOI
P5341 [TJOI2019] 甲苯先生和大中锋的字符串 洛谷 NOI

10.9 这一节要带走的三句话

  1. SAM 的每个状态 = 一个 endpos 等价类,同类子串长度连续、短者是长者的后缀。
  2. 状态数 \le2n-1,转移数 \le3n-4,这是 SAM 能 O(n) 的根本。
  3. parent 树的子树求和 = 出现次数,与 AC 自动机的 fail 树是同一个套路。

11. 广义 SAM

11.1 它解决什么问题

问题:给多个串,求"所有串的本质不同子串总数""多串的最长公共子串""每个串独有的子串"。

朴素做法:把多个串用分隔符拼成一个长串,建普通 SAM。问题:分隔符会引入跨串的"伪子串"(如 a#b),且字符集变大。

广义 SAM:直接在 Trie 上建 SAM,天然只包含"某个串的子串"。

11.2 错误做法:重置 last

广义 SAM:先建 Trie,BFS 序扩展,last 取父节点的 SAM 状态 最常见但错误的做法:

for (每个串 s) {
    last = 1;                    // ✘ 错误!
    for (char c : s) extend(c);
}

为什么错:extend 的前提是"从 last 出发沿 link 往上走、遇到的都是没有 c 转移的状态"。但重置 last=1 后,SAM 里已经有之前串的信息,某些状态可能已经有 c 转移。这时 extend 会建出错误的结构(表现为状态数虚高或 link 链损坏)。

11.3 正确做法一:特判已有转移

思路:在 extend 开头检查"从 last 出发是否已有 c 转移"。若有,分两种情形处理(复用或 clone),而不是盲目新建。

// 广义 SAM 的 insert(单串插入的一个字符)
void insert_general(int c) {
    if (ch[last][c]) {                        // ★ 已有转移,特判
        int p = last, q = ch[p][c];
        if (len[q] == len[p] + 1) { last = q; return; }     // 直接复用
        int clone = ++ tot;                                 // 需要拆分
        len[clone] = len[p] + 1;
        link[clone] = link[q];
        for (int i = 0; i < SIGMA; ++ i) ch[clone][i] = ch[q][i];
        while (p && ch[p][c] == q) ch[p][c] = clone, p = link[p];
        link[q] = clone;
        last = clone;
        return;
    }
    // 否则走普通 extend 逻辑
    int cur = ++ tot;
    len[cur] = len[last] + 1;
    int p = last;
    while (p && !ch[p][c]) ch[p][c] = cur, p = link[p];
    if (!p) link[cur] = 1;
    else {
        int q = ch[p][c];
        if (len[q] == len[p] + 1) link[cur] = q;
        else {
            int clone = ++ tot;
            len[clone] = len[p] + 1;
            link[clone] = link[q];
            for (int i = 0; i < SIGMA; ++ i) ch[clone][i] = ch[q][i];
            while (p && ch[p][c] == q) ch[p][c] = clone, p = link[p];
            link[q] = link[cur] = clone;
        }
    }
    last = cur;
}

两个分支的含义:

11.4 正确做法二:BFS 建 Trie 再扩展

思路:先把所有串建成一棵 Trie,然后 BFS 遍历 Trie,对每个 Trie 节点用"它父亲在 SAM 中对应的状态"作为 last 调普通 extend。

为什么不会冲突:Trie 上每个节点代表的字符串是唯一的,BFS 时"父亲已经建好",所以沿 last 往上走遇到的转移关系是自洽的。

// 先建 Trie,记录每个 Trie 节点对应哪个 SAM 状态
int samNode[N];                     // Trie 节点 -> SAM 状态
void bfs_build() {
    queue<int> q;
    for (int c = 0; c < 26; ++ c)
        if (trie[1][c]) {
            last = 1;
            extend(c);                         // 从根出发
            samNode[trie[1][c]] = last;
            q.push(trie[1][c]);
        }
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int c = 0; c < 26; ++ c) if (trie[u][c]) {
            last = samNode[u];                 // ★ 用父亲的 SAM 状态
            extend(c);
            samNode[trie[u][c]] = last;
            q.push(trie[u][c]);
        }
    }
}

两个做法的取舍:

特判版 BFS 版
代码量 长(要在 extend 里加分支) 短(普通 extend + BFS)
空间 只有 SAM 额外一棵 Trie
处理"在线加串" ✓ ✘(要先知道所有串)
出错风险 高(分支容易漏) 低

建议:离线时用 BFS 版(不易错);需要在线加串时用特判版。

例题:P6139 【模板】广义后缀自动机

题意:给 n 个串(总长 \le10^6),求它们所有本质不同子串的总数。

关键转化:广义 SAM + 本质不同子串公式。

做法:用 4.4 的 BFS 版建广义 SAM,然后套 (9) 式求和。

#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int N = 1e6 + 10, SIGMA = 26;
int ch[N << 1][SIGMA], len[N << 1], link[N << 1];
int trie[N][SIGMA], sam_of_trie[N];
int totSam = 1, last = 1;
int totTrie = 1;

void extend(int c) {                       // 普通 SAM extend
    int cur = ++ totSam;
    len[cur] = len[last] + 1;
    int p = last;
    while (p && !ch[p][c]) ch[p][c] = cur, p = link[p];
    if (!p) link[cur] = 1;
    else {
        int q = ch[p][c];
        if (len[q] == len[p] + 1) link[cur] = q;
        else {
            int clone = ++ totSam;
            len[clone] = len[p] + 1;
            link[clone] = link[q];
            for (int i = 0; i < SIGMA; ++ i) ch[clone][i] = ch[q][i];
            while (p && ch[p][c] == q) ch[p][c] = clone, p = link[p];
            link[q] = link[cur] = clone;
        }
    }
    last = cur;
}
int main() {
    int n; scanf("%d", &n);
    static char s[N];
    for (int i = 1; i <= n; ++ i) {
        scanf("%s", s);
        int u = 1;
        for (int j = 0; s[j]; ++ j) {       // 建 Trie
            int c = s[j] - 'a';
            if (!trie[u][c]) trie[u][c] = ++ totTrie;
            u = trie[u][c];
        }
    }
    // BFS 建广义 SAM
    queue<int> q;
    for (int c = 0; c < SIGMA; ++ c) if (trie[1][c]) {
        last = 1; extend(c);
        sam_of_trie[trie[1][c]] = last;
        q.push(trie[1][c]);
    }
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int c = 0; c < SIGMA; ++ c) if (trie[u][c]) {
            last = sam_of_trie[u];          // ★ 用父亲的 SAM 状态
            extend(c);
            sam_of_trie[trie[u][c]] = last;
            q.push(trie[u][c]);
        }
    }
    ll ans = 0;
    for (int u = 2; u <= totSam; ++ u) ans += len[u] - len[link[u]];
    printf("%lld\n", ans);
    return 0;
}

复杂度:O(\sum|s_i|\cdot|\Sigma|)。

点评:BFS 版的关键是 last = sam_of_trie[u]——用"父亲在 SAM 中的状态"而不是重置为根。这是它与错误做法的唯一区别。

11.5 延伸阅读(更多同知识点题目)

相关例题 P6139 【模板】广义后缀自动机 · P3346 [ZJOI2015] 诸神眷顾的幻想乡* · P3181 [HAOI2016] 找相同字符 · P4081 [USACO17DEC] Standing Out from the Herd P · P5546 [POI2000] 公共串 · CF204E Little Elephant and Strings

习题池:广义 SAM / 多串

题号 题名 平台 难度
P3346 [ZJOI2015] 诸神眷顾的幻想乡 洛谷 NOI
P3181 [HAOI2016] 找相同字符 洛谷 NOI
P4081 [USACO17DEC] Standing Out from the Herd P 洛谷 NOI
P5546 [POI2000] 公共串 洛谷 普及+/提高
P2463 [SDOI2008] Sandy 的卡片 洛谷 NOI
P6640 [BJOI2020] 封印 洛谷 NOI
P6793 [SNOI2020] 字符串 洛谷 NOI
P2336 [SCOI2012] 喵星球上的点名 洛谷 NOI

11.6 这一节要带走的三句话

  1. 直接重置 last 是错的,因为已有转移会被破坏。
  2. 两种正确做法:特判已有转移(在线)或 BFS 建 Trie(离线,推荐)。
  3. len[q]==len[p]+1 直接复用,> 则需要 clone,这个判据与普通 SAM 一致。

12. 回文自动机(PAM)

12.1 它解决什么问题

问题:求本质不同的回文子串个数、每个回文子串的出现次数、回文划分方案数。

Manacher 的局限:Manacher 能 O(n) 求出"以每个位置为中心的最长回文半径",但它不直接给出"有多少种本质不同的回文"——因为不同的中心可能产生相同的回文。

PAM(回文自动机 / 回文树):每个节点代表一个本质不同的回文串,节点数 =O(n)。

关键定理:长度 n 的串,本质不同的回文子串数 \le n。

证明:每个回文子串 t 都可以用"它的最右出现位置的右端点 r + 长度"唯一确定。而对每个 r,以 r 结尾的本质不同回文串最多有一个"是首次出现的"……

更直接的证明:考虑从 s_1 到 s_n 逐个加入字符。每次加入 s_r 时,新增的本质不同回文只能是以 r 结尾的那一批中最长的那个(其余都是它或更早的回文的后缀,已经出现过)。所以每次最多新增 1 个,总共 \le n 个。\square

证明要点:每个位置最多贡献 1 个新回文(以它结尾的最长者),所以总数 \le n。

12.2 结构:两棵"扩展树"

PAM:奇根 len=-1、偶根 len=0,实线两端加字符、虚线 fail PAM 有两个根:

为什么要把奇根的 len 设为 -1:因为"在两端加同一个字符"时,新长度是 len+2。从奇根(len=-1)加一个字符得到长度 1——正好是单字符回文。这个设定让"单字符"与"双字符"两种基础回文统一处理。

转移:ch[u][c]=v 表示"在回文 u 的两端各加一个 c,得到回文 v"。

fail 指针:fail[u] 指向"u 的最长真回文后缀"。

12.3 构造算法

const int N = 1e6 + 10, SIGMA = 26;
int ch[N][SIGMA], len[N], fail[N], cnt[N];
int tot = 1, last = 0;                    // 1 是奇根,0 是偶根
char s[N];

void init() {
    len[1] = -1, fail[1] = 1;             // 奇根:自己指自己
    len[0] = 0, fail[0] = 1;              // 偶根:fail 指向奇根
    tot = 1, last = 0;
}

// 找到"能在两端接上 s[pos] 的最长回文后缀"
int get_fail(int u, int pos) {
    while (s[pos - len[u] - 1] != s[pos]) u = fail[u];
    return u;
}

void extend(int pos) {                    // pos 是 1-based
    int c = s[pos] - 'a';
    int p = get_fail(last, pos);
    if (!ch[p][c]) {
        int cur = ++ tot;
        len[cur] = len[p] + 2;
        // 求 cur 的 fail
        int q = get_fail(fail[p], pos);
        fail[cur] = (len[cur] == 1) ? 0 : ch[q][c];
        ch[p][c] = cur;
    }
    last = ch[p][c];
    ++ cnt[last];                         // last 出现一次
}

get_fail 为什么这样写:我们要找"最长的、能在两端接上 s[pos] 的回文后缀"。回文 u 能在两端接 c,需要"u 左边的字符"等于 c,即 s[pos-len[u]-1] == s[pos]。若不等就沿 fail 往上跳。

注意 len[1]=-1 让 while 终止:当 u 是奇根时,pos-(-1)-1=pos,s[pos]==s[pos] 恒成立,循环停止。

12.4 节点数上界

定理 5.1:PAM 的节点数 \le n+2(n 是串长,+2 是两个根)。

证明:由 5.1 的定理,本质不同回文数 \le n,每个回文对应一个节点,加上两个根即得。\square

实测:随机串与特殊串的节点数都远小于 n+2。

12.5 fail 树与出现次数

与 AC 自动机完全同构:按 len 降序(或 BFS 逆序)做 cnt[fail[u]] += cnt[u]。

// 按 len 降序累加(用计数排序)
for (int i = 1; i <= tot; ++ i) ++ bucket[len[i]];
// 前缀和 -> 按 len 从大到小遍历
for (int i = tot; i >= 1; -- i) cnt[fail[u]] += cnt[u];

为什么回文 u 的出现次数 = 子树和:若回文 v 是回文 u 的后缀,则"u 每出现一次,v 也出现一次"。而 v 是 u 的后缀 \iff v 在 fail 树的子树里包含 u(即 u 在 v 的子树)。所以 v 的出现次数 = 子树内所有 u 的基准计数之和。

例题:P3649 [APIO2014] 回文串

题意:给串 s(|s|\le3\times10^5),求"回文子串出现次数 \times 长度"的最大值。

关键转化:PAM + fail 树累加(与 12.5 的 SAM 版完全同构)。

做法:

  1. 建 PAM,每个 last 被访问时 cnt[last]++;
  2. 按 len 降序做 cnt[fail[u]] += cnt[u];
  3. 对每个节点 u,用 cnt[u] * len[u] 更新答案。

与 SAM 的类比:

SAM PAM
状态代表 endpos 等价类(一族子串) 一个回文串
计数对象 非 clone 状态 每个 last
累加顺序 按 len 降序 按 len 降序
答案 occ[u]\times len[u] cnt[u]\times len[u]

点评:PAM 与 SAM 的统计代码几乎一模一样,理解一个就懂另一个。

12.6 series link 与回文划分

问题(回文划分):求"把串划分成若干回文串"的方案数。

朴素 DP:f_i=\sum_{j:\,s[j..i]\text{ 回文}}f_{j-1},O(n^2)。

优化思路:在 PAM 的 fail 链上,相邻节点的 len 差往往相同。把"len 差相同"的连续一段合并成 series,用区间加更新。

定义 5.2:u 的 series link 定义为

\operatorname{slink}[u]=\text{fail 链上第一个满足}\ len[v]-len[\operatorname{fail}[v]]\ne len[u]-len[\operatorname{fail}[u]]\ \text{的}\ v

为什么能合并:在同一 series 内,从 u 沿 fail 往上跳时,len 是等差数列。而 DP 转移里出现的 f_{i-len[v]} 的下标也是等差数列,可以用"差分 + 前缀和"一次处理整段。

复杂度:O(n\log n) 或 O(n)(取决于维护方式)。

相关例题 P4287 [SHOI2011] 双倍回文 · P4555 [国家集训队] 最长双回文串 · P4762 [CERC2014] Virus synthesis

例题:P5496 【模板】回文自动机

题意:给串 s(|s|\le5\times10^5,强制在线),求以每个位置结尾的回文子串个数。

关键转化:PAM + fail 链长度。

为什么:"以 i 结尾的回文子串"= "PAM 中 last 状态的所有回文后缀" = "last 到 fail 树的根路径上的节点数"。

所以只要在 PAM 上维护 dep[u] = dep[fail[u]] + 1,答案就是 dep[last]。

#include <bits/stdc++.h>
using namespace std;
const int N = 5e5 + 10, SIGMA = 26;
int ch[N][SIGMA], len[N], fail[N], dep[N];
int tot = 1, last = 0;
char s[N];

void init() {
    len[1] = -1, fail[1] = 1;
    len[0] = 0, fail[0] = 1;
    dep[1] = 0, dep[0] = 1;              // 偶根的深度算 1(它代表空串)
    tot = 1, last = 0;
}
int get_fail(int u, int pos) {
    while (s[pos - len[u] - 1] != s[pos]) u = fail[u];
    return u;
}
void extend(int pos) {
    int c = s[pos] - 'a';
    int p = get_fail(last, pos);
    if (!ch[p][c]) {
        int cur = ++ tot;
        len[cur] = len[p] + 2;
        int q = get_fail(fail[p], pos);
        fail[cur] = (len[cur] == 1) ? 0 : ch[q][c];
        dep[cur] = dep[fail[cur]] + 1;
        ch[p][c] = cur;
    }
    last = ch[p][c];
}
int main() {
    init();
    scanf("%s", s + 1);
    int n = strlen(s + 1), ans = 0;
    for (int i = 1; i <= n; ++ i) {
        s[i] = (s[i] - 'a' + ans) % 26 + 'a';      // 强制在线解码
        extend(i);
        printf("%d ", ans = dep[last]);
    }
    return 0;
}

复杂度:O(n)。

点评:dep[last] 直接就是答案,这是 PAM 最优雅的性质之一。注意强制在线的解码方式:s[i] = (s[i]-'a'+lastans)%26+'a'。

12.7 延伸阅读(更多同知识点题目)

相关例题 P5496 【模板】回文自动机 · P3649 [APIO2014] 回文串* · P4287 [SHOI2011] 双倍回文 · P4555 [国家集训队] 最长双回文串 · P1659 [国家集训队] 拉拉队排练 · P4762 [CERC2014] Virus synthesis · P5685 [JSOI2013] 快乐的 JYY · P5555 秩序魔咒 · P3449 [POI2006] PAL-Palindromes

习题池:回文自动机 PAM

提高

题号 题名 平台 难度
P4555 [国家集训队] 最长双回文串 洛谷 提高+/省选−
P1659 [国家集训队] 拉拉队排练 洛谷 提高+/省选−
P15696 [2018 KAIST RUN Spring] QueryreuQ 洛谷 普及+/提高

省选

题号 题名 平台 难度
P17330 [ICPC 2018 Nanjing R] Mediocre String Problem 洛谷 省选/NOI−

NOI

题号 题名 平台 难度
P4287 [SHOI2011] 双倍回文 洛谷 NOI
P4762 [CERC2014] Virus synthesis 洛谷 NOI
P5555 秩序魔咒 洛谷 NOI
P5685 [JSOI2013] 快乐的 JYY 洛谷 NOI
P8406 [COCI 2021/2022 #6] Palindromi 洛谷 NOI
P6396 要有光 洛谷 NOI
P12671 「TFXOI Round 2」String 洛谷 NOI
P16693 Tokitsukaze and Palindrome Border 洛谷 NOI

12.8 这一节要带走的三句话

  1. 本质不同回文数 \le n,所以 PAM 只有 O(n) 个节点。
  2. 奇根 len=-1 让"单字符"与"双字符"统一处理,这是 PAM 最巧妙的设计。
  3. fail 树的子树和 = 出现次数,与 AC 自动机、SAM 完全同构。

13. 子序列自动机

13.1 它解决什么问题

问题:给串 s,多次询问"t 是否是 s 的子序列"。

朴素做法:对每个询问贪心匹配,O(|s|) 每次。

子序列自动机:预处理 nxt[i][c] = "从位置 i 之后第一个字符 c 的位置",O(1) 转移。

13.2 构造

子序列自动机:t=\texttt{abc} 的贪心跳转与 nxt 表

int nxt[N][26];
void build(const char *s, int n) {
    for (int c = 0; c < 26; ++ c) nxt[n][c] = n + 1;    // n+1 表示"不存在"
    for (int i = n - 1; i >= 0; -- i) {
        for (int c = 0; c < 26; ++ c) nxt[i][c] = nxt[i + 1][c];
        nxt[i][s[i] - 'a'] = i;
    }
}
// 判 t 是否为子序列
bool is_subseq(const char *t) {
    int pos = 0;                        // 当前位置(0-based,表示"从 pos 开始找")
    for (int i = 0; t[i]; ++ i) {
        pos = nxt[pos][t[i] - 'a'];
        if (pos > n) return false;
        ++ pos;                         // 下一个字符要从 pos+1 开始找
    }
    return true;
}

复杂度:预处理 O(n|\Sigma|),单次询问 O(|t|)。

13.3 本质不同子序列计数

问题:求 s 的本质不同子序列个数(含空序列)。

DP:

f_i=\begin{cases}2f_{i-1}-f_{\text{last}(s_i)-1}&s_i\text{ 之前出现过}\\2f_{i-1}&\text{否则}\end{cases} \tag{10}

其中 \text{last}(s_i) 是 s_i 上一次出现的位置,f_0=1(空序列)。

证明:考虑加入 s_i 后新增的子序列。所有"不含 s_i"的子序列有 f_{i-1} 个;所有"以 s_i 结尾"的子序列,等于"前 i-1 个字符的任意子序列 + s_i",也是 f_{i-1} 个。但这两批可能重复——重复的恰好是"以 s_i 结尾且之前已经出现过"的,即 f_{\text{last}(s_i)-1} 个(把 s_{\text{last}} 换成 s_i 得到同一个子序列)。所以

f_i=f_{i-1}+f_{i-1}-f_{\text{last}(s_i)-1}\qquad\square

证明要点:容斥——"新增的以 s_i 结尾的"减去"已经由上一次 s_i 贡献过的"。

例题:P4112 [HEOI2015] 最短不公共子串

题意:给两个串 A,B(|A|,|B|\le2000),求最短的串,它既是 A 的子串又是 B 的子序列(或四种组合之一)。

关键转化:子序列自动机 + BFS 求最短路。

做法:题目问四种情况(A 的子串/子序列 × B 的子串/子序列)。对"子序列"那几问,建子序列自动机 nxt[i][c];对"子串"那几问,用后缀自动机或直接暴力匹配。然后在"两个自动机的状态对"上 BFS,第一个到达终态的距离就是答案。

// 子序列自动机:nxt[i][c] = 从 i 之后第一个字符 c 的位置
void build(const char *s, int n, int nxt[][26]) {
    for (int c = 0; c < 26; ++ c) nxt[n][c] = n + 1;
    for (int i = n - 1; i >= 0; -- i) {
        for (int c = 0; c < 26; ++ c) nxt[i][c] = nxt[i + 1][c];
        nxt[i][s[i] - 'a'] = i;
    }
}
// BFS over (stateA, stateB)

复杂度:O(|A||B|\cdot26)。

要点:"最短不公共子串"的正解是"最短公共子序列/子串"的反面——建两个自动机后,BFS 第一次走不下去的那个深度就是答案。

13.4 延伸阅读(更多同知识点题目)

相关例题 P4112 [HEOI2015] 最短不公共子串 · ABC299F Square Subsequence · CF1778F Maximizing Root · P3975 [TJOI2015] 弦论(子序列自动机 + SAM 对照)

13.5 这一节要带走的三句话

  1. 子序列自动机 = 倒序预处理"下一个字符出现位置",O(1) 转移。
  2. 本质不同子序列数用容斥 DP:f_i=2f_{i-1}-f_{\text{last}-1}。
  3. 注意"子序列"与"子串"的区别:子序列可以跳过字符。

14. 后缀树与后缀平衡树

14.1 后缀树

后缀 Trie(O(n^2) 节点)压缩成后缀树(O(n) 节点) 定义 7.1:把所有后缀插入 Trie,再"把只有一个儿子的链压缩成一条边"(边标记为字符串),得到后缀树。

性质:

  1. 叶节点数 =n(每个后缀一个叶子);
  2. 内部节点数 \le n,总节点数 \le2n;
  3. 从根到叶的路径拼起来 = 一个后缀;
  4. 两个后缀的 LCP = 它们 LCA 的深度(边权按字符数计)。

与 SA 的关系:后缀树的 DFS 序(按字典序遍历儿子)恰好给出后缀数组;而"相邻叶子之间的 LCA 深度"就是 height。

构造:直接建是 O(n^2)。O(n) 构造要用 Ukkonen 算法(在线加字符,维护"活动点"与"后缀链"),实现复杂。实战中通常用 SAM 的父树 / SA + 笛卡尔树代替。

什么时候真需要后缀树:某些"多串公共子串"或"字典序结构"的题。但绝大多数可以用 SA 或广义 SAM 替代。

例题:P4248 [AHOI2013] 差异

题意:给串 S(|S|\le5\times10^5),求

\sum_{1\le i<j\le n}\bigl(\operatorname{len}(i)+\operatorname{len}(j)-2\operatorname{lcp}(i,j)\bigr)

其中 \operatorname{len}(i)=n-i+1 是后缀 i 的长度。

关键转化:拆成两半——前半与 LCP 无关,后半是"所有后缀对的 LCP 之和"。

第一步:拆式子。

\sum_{i<j}\operatorname{len}(i)+\sum_{i<j}\operatorname{len}(j)-2\sum_{i<j}\operatorname{lcp}(i,j)

前两项相等(对称),各为 \sum_{i=1}^{n}(n-i)\cdot(n-i+1)/2 型,可 O(n) 算。

第二步:\sum_{i<j}\operatorname{lcp}(i,j) 怎么做。 由 (5),\operatorname{lcp}(i,j)=\min height[rank_i+1..rank_j]。所以

\sum_{i<j}\operatorname{lcp}(i,j)=\sum_{\text{所有区间}} \min height

第三步:经典套路——单调栈求"所有子区间最小值之和"。 对每个 height[k],用单调栈求出"以它为最小值的区间数" L_k\times R_k,贡献 height[k]\cdot L_k\cdot R_k。

// 求 sum of min over all subarrays of height[2..n]
long long sum_of_mins(vector<int> &h) {      // h[0..m-1]
    int m = h.size();
    vector<int> L(m), R(m);
    stack<int> st;
    for (int i = 0; i < m; ++ i) {
        while (!st.empty() && h[st.top()] >= h[i]) st.pop();
        L[i] = st.empty() ? -1 : st.top();
        st.push(i);
    }
    while (!st.empty()) st.pop();
    for (int i = m - 1; i >= 0; -- i) {
        while (!st.empty() && h[st.top()] > h[i]) st.pop();
        R[i] = st.empty() ? m : st.top();
        st.push(i);
    }
    long long s = 0;
    for (int i = 0; i < m; ++ i)
        s += 1LL * h[i] * (i - L[i]) * (R[i] - i);
    return s;
}

复杂度:O(n\log n)(求 SA)或 O(n)(用 SA-IS)。

与后缀树的关系:这道题也可以用后缀树做——所有后缀对的 LCP 之和 = 后缀树上所有内部节点的"深度 × 左右子树叶子对",一次 DFS 即可。两法本质相同,因为"height 上的笛卡尔树"就是后缀树。

要点:单调栈求"子区间最小值之和"是 SA 类问题的万能套路(P2178、P3181 都用)。注意两次弹出的条件不同(一次 >=、一次 >),否则重复计数。

14.2 后缀平衡树

后缀平衡树:按字典序维护后缀,中序即 sa 问题:要在串的开头插入字符,并维护所有后缀的顺序(用于在线 LCP 或动态后缀排序)。

做法:用平衡树(如重量平衡树 / 替罪羊树)按字典序维护后缀。插入新字符相当于"给所有后缀加同一个前缀"。

比较两个后缀的关键技巧:设要比较后缀 A 与 B(都已存在)。若 A_0\ne B_0(首字符不同),直接比。若相同,则去掉首字符后比较 A' 与 B'——而 A',B' 是已经存在的后缀,它们的相对顺序已经知道!

所以比较是 O(1)(若已知排名),插入是 O(\log n)。

应用:在线 LCP、动态字符串的字典序问题。

相关例题 P4036 [JSOI2008] 火星人 · P5212 [ZJOI2017] 字符串

习题池:后缀树 / 后缀平衡树

题号 题名 平台 难度
P6164 【模板】后缀平衡树 洛谷 NOI
P4036 [JSOI2008] 火星人 洛谷 省选/NOI−
P7409 SvT 洛谷 NOI
P9664 [ICPC2021 Macao R] LCS Spanning Tree 洛谷 NOI
P4248 [AHOI2013] 差异 洛谷 省选/NOI−
P5212 [ZJOI2017] 字符串 洛谷 NOI

14.3 这一节要带走的三句话

  1. 后缀树 = 压缩后缀 Trie,节点 O(n),LCP = LCA 深度。
  2. O(n) 建后缀树要 Ukkonen,实战多用 SAM 父树或 SA 替代。
  3. 后缀平衡树比较后缀只需 O(1)(去掉首字符后是已知后缀)。

15. Lyndon 分解与最小表示法

15.1 定义与唯一性

定义 8.1:若串 w 满足"对所有 1\le i<|w|,w<s[i+1..|w|]"(即 w 严格小于它的所有真后缀),称 w 是 Lyndon 串。

例:\texttt{ab} 是(ab < b);\texttt{abab} 不是(abab > bab);\texttt{aab} 是。

定理 8.2(Lyndon 分解唯一性):任意串 s 可以唯一分解为

s=w_1w_2\cdots w_k

其中每个 w_i 是 Lyndon 串,且 w_1\ge w_2\ge\cdots\ge w_k。

证明思路:存在性用 Duval 算法构造(见 8.2)。唯一性:设有两种分解,取它们第一个不同的位置,用"Lyndon 串的前缀关系"导出矛盾。\square

15.2 Duval 算法

Duval 算法:i / j / k 三指针

// Lyndon 分解,返回每个 Lyndon 串的起始位置
vector<int> duval(const string &s) {
    int n = s.size();
    vector<int> res;
    int i = 0;
    while (i < n) {
        int j = i + 1, k = i;
        while (j < n && s[k] <= s[j]) {
            if (s[k] < s[j]) k = i;
            else ++ k;
            ++ j;
        }
        while (i <= k) {
            res.push_back(i);
            i += j - k;
        }
    }
    return res;
}

复杂度:O(n)。

为什么 O(n):j 单调递增且不超过 n;每次 while (i <= k) 的循环让 i 前进 j-k,而 j-k 的总和不超过 n。

15.3 最小表示法

问题:求"循环移位中字典序最小的那个"的起始位置。

做法(双指针,O(n)):

int min_rep(const string &s) {
    int n = s.size(), i = 0, j = 1, k = 0;
    while (i < n && j < n && k < n) {
        char a = s[(i + k) % n], b = s[(j + k) % n];
        if (a == b) { ++ k; continue; }
        if (a > b) i = i + k + 1;
        else j = j + k + 1;
        if (i == j) ++ j;
        k = 0;
    }
    return min(i, j);
}

正确性:若 s[i..i+k-1]=s[j..j+k-1] 且 s[i+k]>s[j+k],则所有以 i..i+k 为起点的循环移位都不是最小的(它们都比以 j 为起点的对应移位大),所以 i 可以跳到 i+k+1。\square

复杂度:O(n)(i+j+k 单调增,上界 3n)。

例题:P1368 工艺

题意:给 n 个数(n\le3\times10^5),求它的循环同构序列中字典序最小的那个。

关键转化:最小表示法双指针(第 8.3 节)。

为什么不能暴力:枚举 n 个起点,每个比较 O(n),总 O(n^2)=9\times10^{10}。

做法:维护两个候选起点 i,j 和已匹配长度 k。关键在于"一次跳 k+1 步"而不是 +1。

#include <bits/stdc++.h>
using namespace std;
const int N = 3e5 + 10;
int a[N], n;
int main() {
    scanf("%d", &n);
    for (int i = 0; i < n; ++ i) scanf("%d", &a[i]);
    int i = 0, j = 1, k = 0;
    while (i < n && j < n && k < n) {
        int x = a[(i + k) % n], y = a[(j + k) % n];
        if (x == y) { ++ k; continue; }
        if (x > y) i = i + k + 1;
        else       j = j + k + 1;
        if (i == j) ++ j;              // ★ 撞到一起必须 ++j,否则死循环
        k = 0;
    }
    int st = min(i, j);
    for (int t = 0; t < n; ++ t) printf("%d ", a[(st + t) % n]);
    return 0;
}

复杂度:O(n)(i+j+k 单调增,上界 3n)。

要点:i == j 时若不 ++j 会死循环——这是本题唯一但最致命的坑。

15.4 与最小表示法的关系

Lyndon 分解可以求"最小表示",但不是最优的——因为 Lyndon 分解是 O(n) 但常数大。求最小表示直接用双指针。

反过来,Lyndon 分解在"字符串的幂次结构""平方串"类问题里不可替代。

15.5 延伸阅读(更多同知识点题目)

相关例题 P6114 【模板】Lyndon 分解 · P1368 工艺* · P4051 [JSOI2007] 字符加密 · CF594E Cutting the Line

习题池:Lyndon 分解 / 最小表示法

题号 题名 平台 难度
P6114 【模板】Lyndon 分解 洛谷 NOI
P1368 工艺 洛谷 提高+/省选−
P4051 [JSOI2007] 字符加密 洛谷 普及+/提高
P6896 [ICPC2014 WF] Maze Reduction 洛谷 NOI
P8023 [ONTAK2015] Tasowanie 洛谷 提高+/省选−

15.6 这一节要带走的三句话

  1. Lyndon 串严格小于自己的所有真后缀,任意串有唯一的 Lyndon 分解。
  2. Duval 算法 O(n),双指针维护"当前 Lyndon 串的周期"。
  3. 最小表示用双指针即可,不必绕道 Lyndon 分解。

16. Main-Lorentz 算法

16.1 它解决什么问题

问题:求串 s 中所有形如 tt(t 非空)的子串(称为平方串)的位置。

朴素做法:枚举中心与长度,O(n^2) 个平方串,O(n^3) 检查。

应用:NOI2016 优秀的拆分(求"AA 型串"的计数)。

16.2 关键思想:按长度分治

Main-Lorentz:平方串分三类,跨中点者用 LCS + LCP 定位 分治:把串从中间劈开,平方串分三类:

  1. 完全在左半边;
  2. 完全在右半边;
  3. 跨过中点。

前两类递归,第三类在 O(n\log n) 内处理完(每层 O(n),共 \log n 层)。

16.3 跨越中点的平方串

核心工具:对"左半边的反转"与"右半边"分别求 LCP / LCS(最长公共后缀)。

做法(对一个固定的"半长" u):

对每个 i(左半边),计算:

若 l+r\ge u,则存在平方串。具体位置要仔细算。

复杂度:每层 O(n)(每个 u 的处理是 O(n/u) 次 LCP 查询,而 LCP 用 SA+ST 是 O(1)),总计 O(n\log n)。

结论:Main-Lorentz 求全部平方串是 O(n\log n),而"全部本质不同的平方串"是 O(n) 个(可以进一步优化)。

16.4 竞赛建议

Main-Lorentz 属于"知道结论能写、不知道写不出"的算法。它的代码在所有字符串算法里算最长的之一(约 100 行)。

实战建议:

16.5 延伸阅读(更多同知识点题目)

相关例题 P1117 [NOI2016] 优秀的拆分(平方串计数的标准题,SA + 关键点) · CF319D Have You Ever Heard About the Word?(连续消除平方串) · P3546 [POI2012] PRE-Prefixuffix · P4199 万径人踪灭

16.6 这一节要带走的三句话

  1. Main-Lorentz 按"长度分治",跨中点的平方串用 LCP + LCS 定位。
  2. 总复杂度 O(n\log n),代码约 100 行。
  3. 实战中 SA + 关键点往往是更好写的选择。

17. 字符串算法库:封装与取舍

17.1 五个模板的取舍

结构 代码行数 常数 建议
哈希 + 二分 30 小 首选,q 小时够用
SA(倍增) 40 中 需要"有序后缀"时用
SAM 40 中 子串统计首选
广义 SAM 50 中 多串统计首选
PAM 35 小 回文首选
后缀树(Ukkonen) 150+ 大 尽量避免
Main-Lorentz 100+ 大 仅在必要时

17.2 三个通用封装建议

建议一:SAM 用结构体封装,把 extend、parent 树累加、本质不同子串数都做成成员函数。

建议二:数组开 2n。SAM 的状态数 \le2n-1,PAM 的节点数 \le n+2,SA 的 oldrk 要 2n。统一按 2n 开就都不会错。

建议三:字符集用"减去最小值"归一化。若字符集是 a-z 就用 c-'a';若是数字就用 c-'0';不要直接用 ASCII(浪费空间)。

例题对照:同一道题的五种写法

题意:给 s(|s|\le10^5),求本质不同子串数。

五种解法对照(这是"算法库取舍"最直观的例子):

方法 代码行数 复杂度 常数 何时用
SAM + Σ(len - len[link]) ~40 O(n) 小 首选
SA + Σ(n-sa[i]+1-height[i]) ~50 O(n\log n) 中 已有 SA 时
后缀树(Ukkonen) ~150 O(n) 大 不推荐
哈希 + set 去重 ~25 O(n^2\log n) — 只适用于 n\le2000
哈希 + 二分 + 排序 ~60 O(n\log^2 n) 大 不想写 SAM 时

结论:同一道题,SAM 是最优选择;SA 是"顺手有"时的备选;后缀树理论上 O(n) 但代码量与常数都不划算;哈希版在 n 大时根本不可行。

这个对照说明什么:第 10 章的取舍表不是空话——它对应着真实可测的差距(代码行数 4 倍、常数 2 倍、复杂度差一个量级)。

17.3 这一节要带走的三句话

  1. 哈希 + 二分是万能备胎,先想它。
  2. 数组统一开 2n,能避免 90% 的 RE。
  3. 后缀树与 Main-Lorentz 尽量不用,有替代品。

18. Trick 五维全览

18.0 先看索引

不想读全部条目时,按"你要解决什么"查这张表:

你要解决的问题 看哪条
要在 border 链上做查询(最长的、计数的、LCA) A1 失配树
想求本质不同子串(不用 SAM) A2 Z 函数或 SA
判回文、求回文半径 A3 双半径(Manacher 奇偶)
哈希被卡了 A4 多哈希与随机底数
求"两个数的最大异或" A5 01-Trie
求"区间内的最大异或" A6 可持久化 01-Trie
判循环同构 / 求最小表示 A7 最小表示法
要比较两段子串的字典序 A8 哈希 + 二分 LCP
KMP 太慢 / 想建自动机 B1 两步优化 + KMP 自动机
Trie 空间开不下 B2 三种实现的取舍
多棵 01-Trie 要合并 B3 01-Trie 合并的均摊分析
可持久化 Trie MLE / RE B4 空间精确估算
哈希求 n 个位置的逆 B5 前缀积批量求逆
禁串计数,长度到 10^9 C1 KMP + 矩阵快速幂
求"路径上数两两异或最大" C2 01-Trie + 线性基
判断两棵树是否同构 C3 树哈希
回文划分计数 C4 Manacher + DP
"某两段必须相等"的约束 C5 Z + 并查集
把"前缀约束"变成"区间约束" C6 Trie 的 DFS 序
多模式串计数 C7 Trie + fail 树
代码 WA 了找不到原因 E1–E14

18.1 A 维度:变体与推广

A1. 失配树(fail 树)

动机:题目要在"某个前缀的所有 border"上做查询——"最长的长度 \le x 的 border"、"border 个数"、甚至"两个前缀的最长公共 border"。沿 fail 链暴力跳是 O(n) 每次,q 次询问就爆了。

直觉:fail[i] 定义了一棵以 0 为根的树(i 的父亲是 fail[i])。i 到根的路径恰好是"i 的所有 border"。

做法:

  1. 建出 fail 树,求每个节点的 depth(在树上的深度 = border 个数);
  2. 求最长 border:就是 \text{fa}(i);
  3. 求最长的长度 \le x 的 border:在 i 到根的路径上找深度最大的、长度 \le x 的祖先——用倍增或树剖 + 二分;
  4. 两个前缀的最长公共 border:就是 \operatorname{LCA}(i,j)(在 fail 树上)。

为什么对:border 的包含关系是"链"——若 b 是 i 的 border,则 i 的所有 \le b 的 border 也是 b 的 border。这正是树上的祖先关系。所以"i 的全部 border"= "i 到根的路径",而"两个前缀的公共 border"= "两个节点的公共祖先"中深度最大的那个 = LCA。

复杂度:建树 O(n),单次询问 O(\log n)(倍增)或 O(\log^2 n)(树剖)。

相关例题 P5829 【模板】失配树 · P2375 [NOI2014] 动物园 · P3435 [POI2006] OKR-Periods of Words · P3426 [POI2005] SZA-Template

A2. Z 函数求本质不同子串

动机:不想写 SAM,但要求本质不同子串数。

做法:把串 s 反转后接在原串后面(或用 SA)。更简单的做法是:对每个后缀 i,它对答案的贡献是 |s|-i+1-\max_{j<i}\operatorname{LCP}(s[i..],s[j..])。而 \max_{j<i}\operatorname{LCP} 在 SA 上是"rank_i 与已处理后缀的最大 height"。

用 Z 函数的版本(逐位扩展):维护已出现过的子串集合(哈希表)。对每个起点 i,从 \max 已匹配长度开始往右试,用哈希 O(1) 判重。总复杂度 O(n\log n)(因为每个成功的扩展都对应一个"新子串",总共 O(n^2) 个新子串——这个做法只在 n 较小时可用)。

结论:求本质不同子串,实战推荐 SAM(O(n))或 SA(O(n\log n))。Z 函数版本只是"能算",不是"好用"。

相关例题 P2408 不同子串个数 · P4070 [SDOI2016] 生成魔咒

A3. Manacher 的双半径(奇偶统一)

动机:Manacher 要在"奇回文"和"偶回文"两种情况下分别处理,写两遍容易错。

做法:在每两个字符之间插入一个不会出现的字符(如 #),把串长度变成 2n+1。这样所有回文都变成奇数长度,只需一套代码。

关键换算:原串中以为中心的奇回文半径 r_{\text{odd}} 对应新串中半径 r_{\text{odd}};偶回文半径 r_{\text{even}} 也对应新串中半径 r_{\text{even}}。设新串的回文半径(含中心)为 R,则

\text{原回文长度}=R-1 \tag{8}

为什么是 R-1:新串中每个回文串形如 #a#b#a#,长度 2k+1 对应原串长度 k;而 R=k+1,所以原长 =R-1。

实例:abcba 插入分隔符得 #a#b#c#b#a#,以 c 为中心的回文半径(到边界)R=6,原回文长度 =6-1=5 ✓。

相关例题 P3805 【模板】Manacher · P4555 [国家集训队] 最长双回文串 · P1659 [国家集训队] 拉拉队排练

A4. 多哈希与随机底数

动机:单哈希会被构造数据卡掉(见 2.5 节)。

三件套防御:

  1. 双哈希:两个不同的 (B_1,P_1)、(B_2,P_2),只有两个都相等才判等。碰撞概率降到 1/(P_1P_2) 量级。
  2. 随机底数:B 在程序启动时用 chrono 随机生成,让出题人无法构造碰撞。
  3. 大质数模:用 10^9+7 配 10^9+9,或 unsigned long long 自然溢出 + 一个大质数模(混合)。
// 双哈希:一次算两个值
struct Hash {
    static const int P1 = 1e9 + 7, P2 = 1e9 + 9;
    int h1[N], h2[N], pw1[N], pw2[N];
    void init(const char *s, int n) { /* ... */ }
    pair<int,int> get(int l, int r) { /* ... */ }
};

代价:常数翻倍,空间翻倍。收益:几乎不可能被卡。

什么时候可以只用单哈希:题目不是 hack 赛制、或者只用来做"内部判重"(不涉及对手构造数据)。

相关例题 P3375 【模板】KMP · P5829 【模板】失配树

A5. 01-Trie:把数当定长 01 串

动机:求"两两异或的最大值"。暴力 O(n^2)。

直觉:异或值的大小由最高不同位决定,所以"从高位往低位,尽量让这一位为 1"。

做法:把所有数按二进制从高到低插入 01-Trie。对每个 x,在 Trie 上尽量走与 x 当前位相反的分支,能走就让答案这一位为 1。

为什么对:高位权重大于所有低位之和(2^k>\sum_{i<k}2^i),所以"先保证高位取 1"必然最优。与线性基的贪心是同一个论证。

复杂度:插入 O(n\cdot LG),查询 O(n\cdot LG),总计 O(n\cdot LG)。

注意 LG 的取值:数值 \le10^9 用 LG=30;\le10^{18} 用 LG=60 且类型是 long long。

相关例题 P4551 最长异或路径 · P4735 最大异或和 · CF706D Vasiliy's Multiset · CF842D Vitya and Strange Lesson

A6. 可持久化 01-Trie:区间约束

动机:多次询问"区间 [l,r] 内与 x 异或的最大值"。

做法:对每个前缀建一个 Trie 版本(像主席树)。版本 k 记录 a_1..a_k。查询 [l,r] 时用 cnt[版本 r] - cnt[版本 l-1] 判断某分支在区间内是否存在。

为什么"版本差"表示区间:版本 k 的每个节点记录"a_1..a_k 中有多少个数经过它"。相减就是"a_l..a_r 中有多少个数经过它"。

空间:每次插入新增 LG+1 个节点,总 O(n\cdot LG)。n=10^5、LG=30 时 3.2\times10^6 个节点。

相关例题 P4735 最大异或和 · P5283 [十二省联考2019] 异或粽子 · P4592 [TJOI2018] 异或

A7. 最小表示法与循环同构

动机:判两个串是否循环同构;或求"循环移位中字典序最小的那个"。

最小表示法(O(n) 双指针):维护两个起点 i,j,比较 s[i+k] 与 s[j+k]:

// 最小表示法:返回最小循环移位的起始下标(0-based)
int min_representation(const string &s) {
    int n = s.size(), i = 0, j = 1, k = 0;
    while (i < n && j < n && k < n) {
        char a = s[(i + k) % n], b = s[(j + k) % n];
        if (a == b) ++ k;
        else {
            if (a > b) i = i + k + 1; else j = j + k + 1;
            if (i == j) ++ j;
            k = 0;
        }
    }
    return min(i, j);
}

为什么 O(n):每次比较失败都让 i+j+k 严格增加,而它不超过 3n。

判循环同构:s 与 t 循环同构 \iff t 是 ss 的子串(用 KMP 或哈希判)。

相关例题 P1368 工艺 · P4051 [JSOI2007] 字符加密

A8. 哈希 + 二分求 LCP

动机:要比较两个后缀的字典序(SA 的倍增里要用),或求任意两段的 LCP。

做法:二分 LCP 长度 L,用哈希 O(1) 判断两段长度 L 的子串是否相等。总 O(\log n) 每次。

// 求 s[a..] 与 s[b..] 的 LCP 长度(哈希版)
int lcp(int a, int b) {
    int lo = 0, hi = min(n - a, n - b) + 1;
    while (hi - lo > 1) {
        int mid = (lo + hi) / 2;
        if (get(a, a + mid - 1) == get(b, b + mid - 1)) lo = mid;
        else hi = mid;
    }
    return lo;
}

复杂度:O(\log n) 每次,预处理 O(n)。

与 SA + RMQ 的取舍:SA + ST 表是 O(1) 查询但预处理重(O(n\log n));哈希 + 二分是 O(\log n) 但代码只有十几行、常数小。q 不大时用哈希。

相关例题 P3763 [TJOI2017] DNA · P4248 [AHOI2013] 差异

A9. fail 树子树求和

动机:AC 自动机统计每个模式串出现次数,沿 fail 链暴力跳是 O(n\cdot\text{链长})。

直觉:p_v 是 p_u 的后缀 \iff v 是 u 的 fail 祖先。所以"p_v 出现次数"= "fail 树上 v 的子树内的计数和"。

做法:文本串跑完后,按 BFS 序逆序做 cnt[fail[u]] += cnt[u]。

为什么对:见第 1.5 节定理 1.3 的证明。

复杂度:O(\text{节点})。

相关例题 P5357 【模板】AC 自动机(二次加强版) · P2414 [NOI2011] 阿狸的打字机 · P5840 [COCI2015] Divljak

A10. 广义 SAM 的两种正确建法

动机:多串建 SAM。直接"每串重置 last"是错的(见 4.2)。

做法一(特判):extend 开头检查 ch[last][c] 是否已存在;若存在,分"直接复用"与"clone 拆分"两种情形。

做法二(BFS):先建 Trie,BFS 遍历,用"父亲对应的 SAM 状态"当 last。

取舍:离线用 BFS(代码短、不易错);在线加串用特判。

相关例题 P6139 【模板】广义 SAM · P3346 [ZJOI2015] 诸神眷顾的幻想乡*

A11. SAM 上按字典序第 k 小

动机:求"所有子串中字典序第 k 小"(可能按"出现次数"加权)。

做法:

  1. 算 f[u] = 从 u 出发能走出的子串数(DP on DAG,按 len 降序或拓扑序);
  2. 若"同一子串在不同位置算多次",则 f[u] 还要乘上状态 u 的出现次数;
  3. 从根出发,按字典序遍历儿子,用 f 值决定走哪支。

注意:必须是 DAG 上的 DP——SAM 的转移图是有向无环的(因为 len 严格增),所以可以按 len 排序后倒推。

相关例题 P3975 [TJOI2015] 弦论 · SP7258 SUBLEX - Lexicographical Substring Search

A12. PAM 的 series link

动机:回文划分 DP 是 O(n^2)(枚举所有回文后缀)。

做法:在 fail 链上,把"len 差相同"的连续一段合并成 series。同一 series 内 len 是等差数列,DP 转移的下标也是等差数列,可用"差分 + 前缀和"整段处理。

复杂度:O(n\log n) 或 O(n)。

相关例题 P4287 [SHOI2011] 双倍回文 · P4762 [CERC2014] Virus synthesis

18.2 B 维度:优化手段

B1. KMP 的两步优化与 KMP 自动机

优化一:while 循环的均摊。朴素 KMP 里 j = fail[j] 可能跳多次,但总跳次数不超过总的 ++j 次数,所以整体 O(n)(见 4.5 节证明)。不需要额外优化。

优化二:KMP 自动机的预处理。若要在同一个模式串上反复匹配(或做 DP),把 trans[i][c] 预处理出来:

// trans[i][c]:已匹配 i 个字符时读入 c,匹配到几个字符
for (int i = 0; i <= m; ++ i)
    for (int c = 0; c < 26; ++ c) {
        if (i < m && s[i + 1] == 'a' + c) trans[i][c] = i + 1;
        else trans[i][c] = (i == 0 ? 0 : trans[fail[i]][c]);
    }

收益:单次转移从"平均 O(1) 但可能跳多次"变成严格 O(1)。这对"禁串计数 + 矩阵快速幂"(C1)是必需的。

代价:空间 O(m\cdot26)。

相关例题 P3193 [HNOI2008] GT考试 · P3082 [USACO13MAR] Necklace G

B2. Trie 三种实现的取舍

实现 空间 速度 适用
二维数组 ch[N][26] $O(\text{节点}\times \Sigma )$
map/unordered_map O(\text{节点}) + 每节点开销 慢 3–5 倍 字符集大或节点稀疏
左儿子右兄弟 O(\text{节点}) 慢 2–3 倍 空间极度紧张

判据:先估节点上界 nL+1。若 nL\times26\times4\text{B}<256\text{MB},用数组;否则换 map。

实测(脚本第 4 项):n=10^4、L=15、字符集 4 时实际只有 91,600 个节点(上界 150,001)——共享前缀能省掉 40% 的节点。

相关例题 P2580 于是他错误的点名开始了 · P3879 [TJOI2010] 阅读理解 · P8306 【模板】字典树 · P5266 【深基17.例6】学籍管理 · P5755 [NOI2000] 单词查找树 · P5149 会议座位

B3. 01-Trie 合并的均摊复杂度

结论:把 k 棵各含 s_i 个节点的 01-Trie 合并,总代价 O(\sum s_i)。

为什么:merge(u,v) 只在 u,v 都非空时递归,且每次递归都把 v 的一个节点"消耗"(合并进 u)。每个节点最多被消耗一次,所以总调用次数 \le 总节点数。

在树上合并场景的推论:若每个叶子贡献 O(LG) 个节点,共 n 个叶子,总节点 O(n\cdot LG),全部合并的总额外代价 O(n\cdot LG)——这使得"树上 01-Trie 合并"成为 DSU on Tree 的可行替代。

相关例题 P6072 『MdOI R1』Path · P6623 [省选联考2020A] 树

B4. 可持久化 Trie 的空间精确估算

公式:节点数 =1+n\cdot(LG+1)(根节点 + 每次插入新增 LG+1 个)。

实例:

n LG 节点数 ch[N][2] 内存(int)
10^5 30 3.2\times10^6 25.6 MB
10^5 60 6.2\times10^6 49.6 MB
5\times10^5 30 1.6\times10^7 128 MB

加上 cnt 数组(同样的节点数)后内存翻倍。n=5\times10^5、LG=30 时已经 256 MB,需要压缩(如用 int 而非 long long、省掉 cnt 改用 rt 差值)。

相关例题 P4735 最大异或和 · P5283 [十二省联考2019] 异或粽子 · P4592 [TJOI2018] 异或 · P4098 [HEOI2013] ALO · P4585 [FJOI2015] 火星商店问题 · P17170 未来

B5. 哈希的前缀积批量求逆

动机:需要 n 个不同位置的哈希逆元(如在"删除一个字符后比较"类问题里)。

做法:同线性代数的批量逆元——前缀积 + 一次逆元 + 倒推。

收益:n 次 O(\log P) 的 qpow 变成 1 次 qpow + O(n) 次乘法。

B6. AC 自动机 + 树状数组(DFS 序)

动机:动态询问"某模式串在文本的某个前缀中出现几次"(P2414)。

做法:把询问离线,挂到"文本的某个位置"上。遍历文本时,在 fail 树上对"当前状态"做单点加;询问"v 的出现次数"变成"fail 树上 v 的子树和"——用 DFS 序 + 树状数组维护。

为什么对:进入一个状态 = 在 fail 树上给该节点 +1;"v 的出现次数"= 子树内所有节点的 +1 之和 = 树状数组的区间和。

复杂度:O((n+q)\log n)。

相关例题 P2414 [NOI2011] 阿狸的打字机 · P5840 [COCI2015] Divljak

B7. SAM 的 parent 树按 len 计数排序

动机:要在 parent 树上做子树累加,需要"按拓扑序"。

做法:len[\operatorname{link}[u]]<len[u] 恒成立,所以按 len 从小到大排序就是一个合法的拓扑序;倒过来就是"儿子先于父亲"。

// 计数排序 O(n + maxlen)
for (int i = 1; i <= tot; ++ i) ++ bucket[len[i]];
for (int i = 1; i <= n; ++ i) bucket[i] += bucket[i - 1];
for (int i = tot; i >= 1; -- i) ord[bucket[len[i]] --] = i;
// ord 按 len 升序;倒序遍历即"儿子先于父亲"

注意:不要用 sort(O(n\log n) 且常数大),计数排序 O(n) 更快。

相关例题 P3804 【模板】后缀自动机 · P3975 [TJOI2015] 弦论 · P4070 [SDOI2016] 生成魔咒 · P5341 [TJOI2019] 甲苯先生和大中锋的字符串 · P3181 [HAOI2016] 找相同字符 · CF802I Fake News (hard)

B8. 后缀数组的常数优化

三条:

  1. 基数排序代替快排:每轮 O(n) 而非 O(n\log n)。
  2. 提前退出:if (p == n) break;(所有排名已区分)。
  3. memcpy 只拷需要的部分:sizeof(int)*(n+w+1),不要整个数组。

实测:n=10^6 时,这三条把运行时间从约 3 秒压到 0.8 秒。

相关例题 P3809 【模板】后缀排序 · P2852 [USACO06DEC] Milk Patterns G · P4051 [JSOI2007] 字符加密 · P2870 [USACO07DEC] Best Cow Line G · P4248 [AHOI2013] 差异

B9. 哈希 + 二分代替 SA

动机:只想求"任意两段的 LCP",不想写 SA。

做法:哈希 O(1) 判等 + 二分长度,O(\log n) 每次。

取舍:SA + ST 表是 O(1) 查询但预处理 O(n\log n) 且代码 40 行;哈希 + 二分是 O(\log n) 但代码 15 行。q 不大时用哈希。

18.3 C 维度:与其他算法的结合

相关例题 P5431 【模板】乘法逆元 2 · P4503 [CTSC2014] 企鹅 QQ · P3370 【模板】字符串哈希

C1. KMP 自动机 + 矩阵快速幂

动机:求"长度为 n、不含模式串 s 的字符串个数",n\le10^9。

做法:

  1. 建 KMP 自动机,状态 0..m-1(m=|s|);
  2. 转移矩阵 M_{ij}= "从状态 i 读一个字符转到 j 的字符数"(转移到 m 的边删掉);
  3. 答案 =\sum_j(M^n)_{0,j}。

为什么对:M^n 的第 0 行第 j 列 = "从状态 0 出发走 n 步到状态 j 的路径数",而状态封装了"当前匹配了多长的前缀",所以"永不进入状态 m"就是"不含模式串"。

复杂度:O(m^3\log n)。

相关例题 P3193 [HNOI2008] GT考试 · P4052 [JSOI2007] 文本生成器* · P3311 [SDOI2014] 数数

C2. 01-Trie + 线性基:路径异或

动机:树上路径的异或最大值/计数。

做法一(Trie):先 DFS 求每个点到根的异或和 d_u。则路径 u\to v 的异或 =d_u\oplus d_v。于是问题变成"在 d 数组中求两数异或最大",用 01-Trie。

做法二(线性基):若是"路径可以重复走"(P4151),则答案是"一条固定路径的异或和 ⊕ 若干环的异或和",用线性基。

这两个做法的区别:Trie 处理"路径不重复"(树上路径唯一),线性基处理"路径可以重复"(有环可选)。

相关例题 P4551 最长异或路径 · P4151 [WC2011] 最大XOR和路径

C3. 树哈希

动机:判断两棵有根树是否同构。

做法:给每个节点算一个哈希值

h(u)=1+\sum_{v\in\text{son}(u)}f\bigl(h(v)\bigr) \tag{9}

其中 f 是一个"打乱"函数(如把 h 乘上一个随机数再异或)。有根树同构 \iff 根哈希相同。

更稳的写法:把所有儿子的哈希排序后按顺序拼起来再哈希(这样不依赖"儿子顺序")。

无根树:先找重心(最多两个),以重心为根算哈希,取较小(或两个都算)。

相关例题 P5043 【模板】树同构 · P4323 [JSOI2016] 独特的树叶

C4. Manacher + DP:回文划分

动机:求"把串划分成若干回文串"的方案数,或"最少划分几段"。

做法:设 f_i 为前 i 个字符的答案,则

f_i=\sum_{j:\,s[j..i]\text{ 是回文}}f_{j-1}

用 Manacher 求出所有极长回文,再对每个中心向两边扩展……朴素是 O(n^2)。

优化到 O(n\log n):用回文树的 fail 链 + series link(见 §12.6)。

相关例题 P4287 [SHOI2011] 双倍回文 · P4555 [国家集训队] 最长双回文串

C5. Z 函数 + 并查集

动机:题目给若干"这两段必须相等"的约束,问最终有多少种本质不同的串。

做法:对每个约束 (a,b,len),把位置 a+k 与 b+k 合并(k<len)。朴素是 O(len) 每次,会 TLE。

优化:用 Z 函数预处理出"哪些位置开始的段相等",然后用并查集跳过已经合并过的位置(每个位置只被合并一次)。总 O(n\alpha)。

这是"区间合并"的经典套路:并查集的 find 指向"下一个还没合并的位置"。

相关例题 P3294 [SCOI2016] 背单词 · CF827E Rusty String

C6. Trie 的 DFS 序:前缀约束变区间约束

动机:题目要在"以某前缀开头的所有串"上做区间操作(求和、修改)。

做法:对 Trie 做 DFS,记录每个节点的 tin/tout。则"u 的子树"= 区间 [tin_u,tout_u]。于是"以 p 为前缀的所有串"= 一个区间,可以套线段树/树状数组。

为什么对:Trie 的子树恰好是"以该节点代表的前缀开头的所有串"(由 Trie 的核心性质)。

相关例题 P5840 [COCI2015] Divljak · P3311 [SDOI2014] 数数

C7. Trie + fail 树:多模式串计数

动机:AC 自动机上统计每个模式串出现次数。

做法:文本串跑一遍 AC 自动机,在每个到达的状态上 ++cnt。然后按 fail 树的拓扑逆序(深度从深到浅)做 cnt[fail[u]] += cnt[u]。

为什么对:状态 u 的计数需要贡献给它的所有 fail 祖先(那些祖先代表的模式串也在文本中出现)。一次逆序累加让每份贡献沿树上传一次。

相关例题 P5357 【模板】AC 自动机(二次加强版) · P2414 [NOI2011] 阿狸的打字机

相关例题 P3763 [TJOI2017] DNA · P4503 [CTSC2014] 企鹅 QQ · P3538 [POI2012] OKR-A Horrible Poem · P5410 【模板】扩展 KMP · P9606 [CERC2019] ABB

C8. 多串最长公共子串

做法一(广义 SAM):每个状态记录"在每个串中的最大匹配长度",沿 parent 树上传,取所有串的最小值的最小值。

做法二(SA):拼接后二分答案 L,用单调队列判定"是否存在连续 k 个后缀(分属不同串)的 height 都 \ge L"。

取舍:广义 SAM 代码短;SA 更容易处理"每个串至少出现一次"这类附加条件。

相关例题 P5546 [POI2000] 公共串 · P2463 [SDOI2008] Sandy 的卡片* · SP1811 LCS - Longest Common Substring

C9. 在线 SAM + LCT

动机:带修改的"本质不同子串数"(P5212)。

做法:每次 extend 时,答案的变化量是 len[cur]-\text{某个量}。当需要 clone 时,要把"被 clone 的那一段贡献"从旧状态移到新状态——用 LCT 维护 parent 树上的路径信息。

竞赛地位:极难,属于 NOI 压轴级别。

相关例题 P5212 [ZJOI2017] 字符串

C10. SA + 关键点:求平方串

动机:求所有 AA 型子串(P1117)。

做法:枚举周期长度 L,在 L,2L,3L,\ldots 处设"关键点"。对相邻关键点 i,i+L,求:

若 LCS + LCP \ge L,则存在跨这两个关键点的平方串,可以算出它起点的合法区间。用差分数组累加。

复杂度:O(n\log n)(LCP/LCS 用 SA + ST)。

这比 Main-Lorentz 好写得多,是 P1117 的标准做法。

相关例题 P1117 [NOI2016] 优秀的拆分 · CF319D Have You Ever Heard About the Word?

C11. 字典序第 k 小后缀

做法:SA 已经排好序,直接取 sa[k](k 从 1 开始)。O(1)。

相关例题 P2408 不同子串个数 · P3975 [TJOI2015] 弦论 · SP7258 SUBLEX - Lexicographical Substring Search · P3809 【模板】后缀排序

C12. 树上字符串问题:广义 SAM

动机:给一棵树,每个节点有一个字符,问"所有路径形成的字符串中有多少种本质不同"(P3346)。

做法:把树当成 Trie(从每个叶子开始 DFS,把路径当字符串插入),建广义 SAM(BFS 版)。注意:要从所有度数为 1 的点(叶子)出发,因为路径不一定要经过根。

相关例题 P3346 [ZJOI2015] 诸神眷顾的幻想乡

18.4 D 维度:经典题型与模型

第一卷(基础与匹配)的题型

模型 题面信号 工具 复杂度 代表题
单模式匹配 "求模式串出现位置" KMP O(n+m) P3375
多模式匹配 "多个模式串" AC 自动机 O(\sum\lvert s\rvert+nm) P3808、P3796
子串比较 "两个子串是否相同" 哈希 O(1) —
最长回文子串 "最长回文" Manacher O(n) P3805
回文子串计数 "有多少个回文子串" Manacher / PAM O(n) / O(n\log n) P1659
最小循环节 "最小重复单元" KMP fail O(n) P4391
前缀统计 "以 p 为前缀的串数" Trie O(\lvert p\rvert) P2580、P2922
最大异或对 "两两异或最大" 01-Trie O(n\cdot LG) P4551
区间最大异或 "区间内与 x 异或最大" 可持久化 01-Trie O((n+q)LG) P4735
树上路径异或 "路径异或和" 01-Trie + DFS O(n\cdot LG) P4551
禁串计数 "不含模式串的长度 n 串数" KMP 自动机 + 矩阵 O(m^3\log n) P3193
循环同构判定 "循环移位相同" 最小表示法 / KMP O(n) P1368
本质不同子串 "不同子串个数" SAM / SA O(n) / O(n\log n) P2408
失配树查询 "border 上的查询" 失配树 + 倍增 O((n+q)\log n) P5829
树同构 "两棵树是否相同" 树哈希 O(n) P5043
回文划分 "划分成若干回文串" Manacher + DP O(n^2) / O(n\log n) P4287

第二卷(自动机与后缀结构)的题型

模型 题面信号 工具 复杂度 代表题
多模式匹配 "多个模式串" AC 自动机 O(\sum\lvert s\rvert+\lvert T\rvert) P3808、P3796
模式串出现次数 "每个出现了几次" AC + fail 树 O(\sum\lvert s\rvert) P5357
禁串计数 "不含模式串的长度 n 串数" AC + 矩阵/DP O(\text{节点}^3\log n) P4052、P3311
后缀排序 "按字典序排后缀" SA O(n\log n) P3809
本质不同子串 "不同子串个数" SAM / SA O(n) / O(n\log n) P2408
子串出现次数 "某子串出现几次" SAM parent 树 O(n) P3804
第 k 小子串 "字典序第 k 小" SAM + DAG DP O(n\lvert\Sigma\rvert) P3975
多串公共子串 "最长公共子串" 广义 SAM / SA+二分 O(n) / O(n\log n) P5546、P2463
回文计数 "回文子串个数" PAM / Manacher O(n) P3649、P1659
回文划分 "划分成若干回文" PAM + series link O(n\log n) P4287
平方串 "AA 型子串" SA + 关键点 O(n\log n) P1117
循环同构 "循环移位相同" 最小表示法 O(n) P1368
本质不同子序列 "不同子序列个数" 子序列自动机 + DP O(n) —
字典序最小表示 "最小循环移位" 双指针 O(n) P1368

18.5 E 维度:常见错误与边界

第二卷(自动机与后缀结构)

18.6 Trick 总表

第一卷(基础与匹配)的 Trick 总表

Trick 维度 一句话要点 例题
失配树 A1 fail 树上的路径 = border 链 P5829、P2375
Z 求本质不同子串 A2 实战推荐 SAM/SA P2408
Manacher 双半径 A3 插入分隔符,原长 =R-1 P3805、P4555
多哈希 + 随机底数 A4 双哈希把碰撞概率降到 1/(P_1P_2) —
01-Trie A5 高位贪心走反边 P4551、CF706D
可持久化 01-Trie A6 版本差表示区间 P4735、P5283
最小表示法 A7 双指针,i+j+k 单调增 P1368
哈希 + 二分 LCP A8 O(\log n) 每次,代码短 P3763
KMP 自动机 B1 预处理 trans[i][c] 严格 O(1) P3193
Trie 实现取舍 B2 $ \Sigma
01-Trie 合并均摊 B3 每节点只被消耗一次 P6072
可持久化空间估算 B4 n(LG+1) 个节点 —
哈希批量求逆 B5 前缀积 + 一次逆元 —
KMP + 矩阵 C1 状态封装匹配长度 P3193、P4052
01-Trie + 线性基 C2 路径不重复用 Trie,可重复用线性基 P4551、P4151
树哈希 C3 儿子哈希排序后拼接 P5043
Manacher + DP C4 回文划分 P4287
Z + 并查集 C5 区间合并用 find 跳 P3294
Trie 的 DFS 序 C6 子树 = 连续区间 P5840
Trie + fail 树 C7 拓扑逆序累加 P5357、P2414

第二卷(自动机与后缀结构)的 Trick 总表

Trick 维度 一句话要点 例题
fail 树子树求和 A9 p_v 是 p_u 后缀 \iff v 是 u 祖先 P5357、P2414
广义 SAM 两种建法 A10 特判已有转移 / BFS 建 Trie P6139、P3346
SAM 第 k 小 A11 按 f 值在 DAG 上走 P3975
PAM series link A12 len 等差段合并 P4287、P4762
AC + 树状数组 B6 进入状态 = 单点加,子树和 = 区间和 P2414
SAM 按 len 计数排序 B7 len[link]<len 保证拓扑序 —
SA 常数优化 B8 基数排序 + 提前退出 P3809
哈希 + 二分 LCP B9 15 行代码,O(\log n) P3763
多串公共子串 C8 广义 SAM / SA+二分+单调队列 P5546、P2463
在线 SAM + LCT C9 维护 parent 树路径信息 P5212
SA + 关键点求平方串 C10 枚举周期长度 + LCP/LCS P1117
第 k 小后缀 C11 直接取 sa[k] —
树上广义 SAM C12 从每个叶子 DFS 建 Trie P3346

19. 题单总索引

全书的例题与习题按知识点分布在各章末尾的「习题池」里。这一章是横切面:按知识点索引、按经典程度排序、扩充补漏。

19.1 按知识点索引

第一卷(基础与匹配)

知识点 位置 例题 习题池
字符串哈希 §2 1 14 道
Trie / 01-Trie / 可持久化 §3 2 30 道
KMP / fail 树 / 周期 §4 7 25 道
Z 函数与扩展 KMP §5 1 4 道
Manacher / 回文 §6 4 25 道

第二卷(自动机与后缀结构)

知识点 位置 例题 习题池
AC 自动机 §8 3 24 道
后缀数组 §9 4 30 道
后缀自动机 SAM §10 2 28 道
广义 SAM §11 1 8 道
回文自动机 PAM §12 2 16 道
子序列自动机 §13 1 —
后缀树 / 后缀平衡树 §14 1 6 道
Lyndon / 最小表示法 §15 1 5 道

跨知识点:§18 的 Trick 章,33 个条目每条挂 3–6 道「相关例题」直达链接。

19.2 按经典程度排序(跨知识点)

必做经典(打底 8 题)

题号 题名 考察点 难度
P3370 【模板】字符串哈希 多项式哈希 普及/提高−
P2580 于是他错误的点名开始了 Trie 基础 普及/提高−
P3375 【模板】KMP KMP 模板 普及+/提高
P3805 【模板】Manacher Manacher 模板 提高+/省选−
P4551 最长异或路径 01-Trie 普及+/提高
P4391 [BOI2009] Radio Transmission 最小循环节 普及+/提高
P2375 [NOI2014] 动物园 fail 树 + 倍增 提高+/省选−
P4824 [USACO15FEB] Censoring S 栈 + KMP 提高+/省选−

进阶经典(提高组压轴 ~ 省选,8 题)

题号 题名 考察点 难度
P4503 [CTSC2014] 企鹅 QQ 去掉一位的哈希 提高+/省选−
P3435 [POI2006] OKR-Periods of Words 周期 ↔ border 提高+/省选−
P5829 【模板】失配树 border 链 = 树路径 提高+/省选−
P4555 [国家集训队] 最长双回文串 枚举断点 提高+/省选−
P1659 [国家集训队] 拉拉队排练 Manacher + 差分 提高+/省选−
P3193 [HNOI2008] GT考试 KMP 自动机 + 矩阵 省选/NOI−
P3763 [TJOI2017] DNA 哈希 + 二分 + 跳跃 省选/NOI−
P3538 [POI2012] OKR-A Horrible Poem 哈希 + 质因子 省选/NOI−

拔高(NOI 级,6 题)

题号 题名 考察点 难度
P4735 最大异或和 可持久化 01-Trie 省选/NOI−
P5278 算术天才⑨与等差数列 线段树 + 幂和 NOI
P3792 由乃与大母神原型和偶像崇拜 线段树 + Zobrist 省选/NOI−
P4036 [JSOI2008] 火星人 平衡树维护哈希 省选/NOI−
P3294 [SCOI2016] 背单词 Trie + 贪心 NOI
P5283 [十二省联考2019] 异或粽子 可持久化 Trie + 堆 省选/NOI−

传说(极难,5 题)

题号 题名 考察点 难度
P4287 [SHOI2011] 双倍回文 Manacher/PAM 变形 NOI
P3546 [POI2012] PRE-Prefixuffix 周期 + 哈希 NOI
P4199 万径人踪灭 Manacher + FFT NOI
P9482 [NOI2023] 字符串 哈希 + 回文 NOI
P2463 [SDOI2008] Sandy 的卡片 多串 LCP NOI

必做经典(打底 8 题)

题号 题名 考察点 难度
P3808 【模板】AC 自动机(简单版) AC 自动机模板 提高+/省选−
P5357 【模板】AC 自动机(二次加强版) fail 树统计 省选/NOI−
P3809 【模板】后缀排序 SA 模板 省选/NOI−
P3804 【模板】后缀自动机 SAM 模板 NOI
P5496 【模板】回文自动机 PAM 模板 NOI
P2408 不同子串个数 SAM/SA 对照 省选/NOI−
P6139 【模板】广义 SAM 广义 SAM 模板 NOI
P1368 工艺 最小表示法 提高+/省选−

进阶经典(省选核心,8 题)

题号 题名 考察点 难度
P3975 [TJOI2015] 弦论 SAM 第 k 小 NOI
P3649 [APIO2014] 回文串 PAM 统计 NOI
P2178 [NOI2015] 品酒大会 SA + 并查集 NOI
P2414 [NOI2011] 阿狸的打字机 AC + 树状数组 NOI
P2463 [SDOI2008] Sandy 的卡片 多串公共子串 NOI
P4248 [AHOI2013] 差异 SA + 单调栈 省选/NOI−
P4287 [SHOI2011] 双倍回文 PAM fail 链 NOI
P1117 [NOI2016] 优秀的拆分 SA + 关键点 NOI

拔高(NOI 级,6 题)

题号 题名 考察点 难度
P3346 [ZJOI2015] 诸神眷顾的幻想乡 树上广义 SAM NOI
P5212 [ZJOI2017] 字符串 SAM + LCT NOI
P5840 [COCI2015] Divljak AC + DFS 序 NOI
P3311 [SDOI2014] 数数 AC + 数位 DP NOI
P4762 [CERC2014] Virus synthesis PAM + DP NOI
P4036 [JSOI2008] 火星人 后缀平衡树 省选/NOI−

传说(极难,5 题)

题号 题名 考察点 难度
P9482 [NOI2023] 字符串 SA + 回文 NOI
P4199 万径人踪灭 Manacher + FFT NOI
P3546 [POI2012] PRE-Prefixuffix 周期 + 哈希 NOI
P7409 SvT 后缀树 + 虚树 NOI
P6793 [SNOI2020] 字符串 SAM + 贪心 NOI

19.3 扩充补漏

题号 题名 考察点 难度
P8306 【模板】字典树 Trie 最裸模板 普及/提高−
P5266 【深基17.例6】学籍管理 Trie + 哈希表 普及−
P1481 魔族密码 Trie 上 DP 普及/提高−
P5755 [NOI2000] 单词查找树 Trie 节点计数 普及/提高−
P5149 会议座位 Trie + 逆序对 普及+/提高
P2922 [USACO08DEC] Secret Message G 前缀与后缀计数 普及+/提高
P3065 [USACO12DEC] First! G Trie + 拓扑排序 提高+/省选−
P4407 [JSOI2009] 电子字典 Trie + 编辑距离 提高+/省选−
P9753 [CSP-S2023] 消消乐 栈 + 哈希 提高+/省选−
P9606 [CERC2019] ABB 哈希 / Manacher 普及+/提高
P3501 [POI2010] ANT-Antisymmetry Manacher 变形 提高+/省选−
P5446 [THUPC2018] 绿绿和串串 Manacher 应用 提高+/省选−
题号 题名 考察点 难度
P6114 【模板】Lyndon 分解 Duval 算法 NOI
P6164 【模板】后缀平衡树 后缀平衡树 NOI
P4051 [JSOI2007] 字符加密 SA + 循环 普及+/提高
P2852 [USACO06DEC] Milk Patterns G SA + 二分 普及+/提高
P2870 [USACO07DEC] Best Cow Line G SA + 贪心 普及+/提高
P4070 [SDOI2016] 生成魔咒 SAM 在线 NOI
P5341 [TJOI2019] 甲苯先生和大中锋的字符串 SAM + 差分 NOI
P4081 [USACO17DEC] Standing Out from the Herd P 广义 SAM NOI
P3181 [HAOI2016] 找相同字符 广义 SAM NOI
P5555 秩序魔咒 PAM + LCP NOI
P5685 [JSOI2013] 快乐的 JYY PAM NOI
P2336 [SCOI2012] 喵星球上的点名 SA + 莫队 NOI

用法:每条知识点/Trick 下面最后那个代码块里是纯题号,逗号分隔。直接复制到洛谷「创建题单 → 批量添加题目」即可。 全部题号取自配套讲义《字符串算法完整讲义》的例题与习题池。

19.4 一次全拿

全部去重后 162 道,按知识点顺序排列:

P10218,P10283,P10469,P10470,P10471,P10479,P10581,P10634,P10716,P1117,P11291,P1210,P12671,P12977,P1333,P1368,P1381,P1393,P13999,P14061,P14363,P1470,P1481,P14830,P14874,P15650,P15696,P1659,P16693,P17071,P17101,P17170,P1723,P17330,P17556,P17581,P1872,P1908,P1955,P2178,P2312,P2336,P2375,P2408,P2414,P2463,P2536,P2580,P2601,P2757,P2852,P2870,P2922,P3065,P3082,P3181,P3193,P3279,P3294,P3311,P3346,P3370,P3375,P3426,P3435,P3454,P3498,P3501,P3538,P3546,P3649,P3763,P3792,P3796,P3804,P3805,P3808,P3809,P3823,P3879,P3975,P4036,P4051,P4052,P4070,P4081,P4098,P4112,P4151,P4199,P4248,P4287,P4323,P4324,P4341,P4391,P4407,P4503,P4551,P4555,P4585,P4592,P4696,P4735,P4762,P4824,P5018,P5043,P5112,P5149,P5211,P5212,P5231,P5266,P5278,P5283,P5341,P5357,P5410,P5431,P5446,P5496,P5546,P5555,P5685,P5755,P5770,P5829,P5840,P6018,P6072,P6080,P6114,P6139,P6164,P6216,P6396,P6456,P6623,P6640,P6793,P6824,P6896,P6965,P7114,P7409,P7456,P7717,P8023,P8085,P8112,P8306,P8406,P8511,P8617,P8631,P9196,P9482,P9606,P9646,P9664,P9753

共 162 道。若洛谷单次批量添加上限不够,按下面各知识点分块粘贴。

19.5 按知识点分块(13 个知识域)

§2 字符串哈希 · 15 道

例题:P3370 习题池:P1955, P1381, P2312, P5018, P4391, P4503, P3498, P5446, P3763, P3538, P3792, P5278, P2757, P3823

P1955,P1381,P2312,P5018,P4391,P4503,P3498,P5446,P3763,P3538,P3792,P5278,P2757,P3823,P3370
§3 Trie 字典树(含 01-Trie / 可持久化 / Trie 图) · 39 道

例题:P4551, P4735 习题池:P1908, P2580, P8306, P3879, P5266, P1481, P10471, P5755, P5149, P10470, P9753, P1470, P2922, P3065, P1333, P5546, P2536, P6824, P4407, P3796, P5283, P14363, P4585, P4098, P9196, P7717, P17170, P17556, P4735, P4592, P6623, P3294, P6018, P8511, P10218, P6072, P6965, P10283

P1908,P2580,P8306,P3879,P5266,P1481,P10471,P5755,P5149,P10470,P9753,P1470,P2922,P3065,P1333,P5546,P2536,P6824,P4407,P3796,P5283,P14363,P4585,P4098,P9196,P7717,P17170,P17556,P4735,P4592,P6623,P3294,P6018,P8511,P10218,P6072,P6965,P10283,P4551
§4 KMP 与前缀函数 / fail 树 · 28 道

例题:P2375, P5829, P3375, P4391, P3435, P4824 习题池:P10479, P1470, P9606, P5546, P8085, P3193, P3426, P6216, P10581, P5770, P17330, P17581, P2463, P3546, P4696, P15650, P1393, P3454, P10716, P6080, P10634, P11291

P10479,P1470,P9606,P5546,P8085,P3193,P3426,P6216,P10581,P5770,P17330,P17581,P2463,P3546,P4696,P15650,P1393,P3454,P10716,P6080,P10634,P11291,P2375,P5829,P3375,P4391,P3435,P4824
§5 Z 函数与扩展 KMP · 5 道

例题:P5410 习题池:P7114, P8112, P5211, P14874

P7114,P8112,P5211,P14874,P5410
§6 Manacher / 回文 · 23 道

例题:P3805, P4287, P4555, P1659 习题池:P1210, P3501, P9606, P1723, P5446, P1872, P2601, P4324, P6216, P17330, P3546, P4199, P9482, P3279, P12977, P14830, P14061, P9646, P8631

P1210,P3501,P9606,P1723,P5446,P1872,P2601,P4324,P6216,P17330,P3546,P4199,P9482,P3279,P12977,P14830,P14061,P9646,P8631,P3805,P4287,P4555,P1659
§8 AC 自动机 · 15 道

例题:P3808, P5357, P4052 习题池:P9753, P1470, P2922, P3065, P1333, P5546, P2536, P3796, P14363, P6623, P3294, P4585

P9753,P1470,P2922,P3065,P1333,P5546,P2536,P3796,P14363,P6623,P3294,P4585,P3808,P5357,P4052
§9 后缀数组 · 28 道

例题:P3809, P2408, P2178, P1117 习题池:P2852, P4051, P2870, P5546, P10469, P4341, P8023, P6456, P8617, P13999, P4248, P3763, P5231, P7456, P5112, P17071, P3804, P3975, P4070, P3649, P3181, P2463, P2336, P5341

P2852,P4051,P2870,P5546,P10469,P4341,P8023,P6456,P8617,P13999,P4248,P3763,P5231,P7456,P5112,P17071,P3804,P3975,P4070,P3649,P3181,P2463,P2336,P5341,P3809,P2408,P2178,P1117
§10 后缀自动机 SAM · 15 道

例题:P3804, P3975 习题池:P1368, P8617, P17101, P2408, P4248, P5231, P4070, P3649, P3181, P2463, P2336, P3346, P5341

P1368,P8617,P17101,P2408,P4248,P5231,P4070,P3649,P3181,P2463,P2336,P3346,P5341,P3804,P3975
§11 广义 SAM · 9 道

例题:P6139 习题池:P3346, P3181, P4081, P5546, P2463, P6640, P6793, P2336

P3346,P3181,P4081,P5546,P2463,P6640,P6793,P2336,P6139
§12 回文自动机 PAM · 14 道

例题:P3649, P5496 习题池:P4555, P1659, P15696, P17330, P4287, P4762, P5555, P5685, P8406, P6396, P12671, P16693

P4555,P1659,P15696,P17330,P4287,P4762,P5555,P5685,P8406,P6396,P12671,P16693,P3649,P5496
§13 子序列自动机 · 1 道

例题:P4112

P4112
§14 后缀树 / 后缀平衡树 · 6 道

例题:P4248 习题池:P6164, P4036, P7409, P9664, P4248, P5212

P6164,P4036,P7409,P9664,P4248,P5212
§15 Lyndon 分解 / 最小表示法 · 5 道

例题:P1368 习题池:P6114, P1368, P4051, P6896, P8023

P6114,P1368,P4051,P6896,P8023

19.6 按 Trick 分块(33 条)

A1 失配树(fail 树)
P5829,P2375,P3435,P3426
A2 Z 函数求本质不同子串
P2408,P4070
A3 Manacher 的双半径(奇偶统一)
P3805,P4555,P1659
A4 多哈希与随机底数
P3375,P5829
A5 01-Trie:把数当定长 01 串
P4551,P4735
A6 可持久化 01-Trie:区间约束
P4735,P5283,P4592
A7 最小表示法与循环同构
P1368,P4051
A8 哈希 + 二分求 LCP
P3763,P4248
A9 fail 树子树求和
P5357,P2414,P5840
A10 广义 SAM 的两种正确建法
P6139,P3346
A11 SAM 上按字典序第 k 小
P3975
A12 PAM 的 series link
P4287,P4762
B1 KMP 的两步优化与 KMP 自动机
P3193,P3082
B2 Trie 三种实现的取舍
P2580,P3879,P8306,P5266,P5755,P5149
B3 01-Trie 合并的均摊复杂度
P6072,P6623
B4 可持久化 Trie 的空间精确估算
P4735,P5283,P4592,P4098,P4585,P17170
B5 哈希的前缀积批量求逆

本条无专属题目,见同章习题池。

B6 AC 自动机 + 树状数组(DFS 序)
P2414,P5840
B7 SAM 的 parent 树按 len 计数排序
P3804,P3975,P4070,P5341,P3181
B8 后缀数组的常数优化
P3809,P2852,P4051,P2870,P4248
B9 哈希 + 二分代替 SA
P5431,P4503,P3370
C1 KMP 自动机 + 矩阵快速幂
P3193,P4052,P3311
C2 01-Trie + 线性基:路径异或
P4551,P4151
C3 树哈希
P5043,P4323
C4 Manacher + DP:回文划分
P4287,P4555
C5 Z 函数 + 并查集
P3294
C6 Trie 的 DFS 序:前缀约束变区间约束
P5840,P3311
C7 Trie + fail 树:多模式串计数
P5357,P2414
C8 多串最长公共子串
P5546,P2463
C9 在线 SAM + LCT
P5212
C10 SA + 关键点:求平方串
P1117
C11 字典序第 k 小后缀
P2408,P3975,P3809
C12 树上字符串问题:广义 SAM
P3346

20. 总结提升

20.1 全部结构的一句话本质

结构 一句话 状态数 章节
哈希 用数代表一段 — §2
Trie 合并公共前缀 O(\sum\lvert p\rvert) §3
KMP border 链 + fail 树 n §4
Z 函数 最右匹配段 + 镜像 n §5
Manacher 镜像复用回文半径 2n+1 §6
AC 自动机 多串 Trie + fail O(\sum\lvert s\rvert) §8
后缀数组 把后缀排好序 n §9
后缀自动机 按 endpos 分类所有子串 \le 2n-1 §10
广义 SAM 多串的 SAM \le 2\sum\lvert s\rvert §11
回文自动机 每个节点一个本质不同回文 \le n+2 §12
子序列自动机 下一个字符位置表 n\lvert\Sigma\rvert §13
后缀树 压缩后缀 Trie \le 2n §14
后缀平衡树 按字典序动态维护后缀 n §14
Lyndon 分解 唯一的 Lyndon 串降序分解 — §15
Main-Lorentz 分治求全部平方串 — §16

20.2 五条贯穿全书的主线

主线一:不重复读字符。 这是所有匹配算法的出发点。朴素匹配每次失配都从模式串头重来,浪费了"刚才比过的那一段"。哈希把整段压成一个数、KMP 把"已匹配长度"记成状态、Z 函数维护最右匹配段、Manacher 复用镜像半径——四者都在用"左边已算好的信息"避免重读。它们的复杂度论证也是同一个:某个指针单调推进,总推进量 O(n)。

主线二:子串 = 后缀的前缀。 这一句把 O(n^2) 个子串降成 n 个后缀,是 SA 与 SAM 的共同基础。SA 是"把后缀排序后逐个处理",SAM 是"把后缀的前缀按 endpos 合并"。

主线三:四棵 fail 树是同构的。 KMP 的 fail 树、AC 自动机的 fail 树、SAM 的 parent 树、PAM 的 fail 树,都在做同一件事——把"后缀包含关系"编码成树上的祖先关系。于是 LCA、子树和、DFS 序 + 树状数组这几种工具全部通用。这是本书最值得记的一条。

主线四:所有状态数上界都来自"每次最多新增常数个"。 SAM 的状态数 \le 2n-1(每次 extend 最多 +2)、PAM 的节点数 \le n+2(每次最多 +1)、本质不同回文 \le n。这个摊还思想是压缩结构能 O(n) 的根本。

主线五:哈希 + 二分是万能备胎。 不想写 SA/SAM 时,O(\log n) 的 LCP 查询能解决大部分问题。只有需要"枚举全部子串"或"需要精确的 endpos 等价类"时才必须上自动机。

20.3 字符串题真正的难点在两处

第一处:转化。 P4287 的"双倍回文 = 回文 + 左半回文"、CF1200E 的"最大重叠 = KMP 停在末尾的 j"、P1117 的"AABB = 两个 AA 从中间拼起来"——把绕口的题面条件翻译成算法能回答的问题,这一步比写代码难得多。

第二处:选型。 同一个问题用哈希、KMP、Z 都能做,但代价与代码量差很多。§0.5 的决策表值得反复看——它的价值不在记住结论,而在养成"先问要什么,再选工具"的习惯。

20.4 已知边界与深入方向

本书未深入的内容: