字符串大学习
少女樂隊_GirlsBand · · 算法·理论
字符串大学习
前言
本文章使用 LLM 辅助生成,如有错误,请在评论区指出。感谢!
食用建议:根据自己的水平和需求,从大纲中选取需要学习的部分跳转学习。不建议从头通读。
目录
- 0. 引言:字符串问题的两副面孔 — 两条主线(匹配 / 压缩)+ fail 树暗线 + 选型速查
- 1. 记号与基础 — 六个记号、border 理论、border↔周期等价
- 2. 字符串哈希 —
O(1) 子串查询、错误率、卡法与防御 - 3. Trie 字典树 — 基础 /
01 -Trie / 合并 / 可持久化 / Trie 图 - 4. KMP 与前缀函数 — 求
\pi 、用\pi 匹配、fail 树(全书主线) - 5. Z 函数与扩展 KMP — 最右匹配段、镜像复用
- 6. Manacher — 奇偶统一、半径还原、回文计数
- 7. 字符串匹配综合:五把武器怎么选 — 决策表与选型原则
- 8. AC 自动机 — fail 指针、Trie 图、fail 树、AC 上 DP
- 9. 后缀数组 — 倍增构造、height、五个经典应用
- 10. 后缀自动机(SAM) — endpos、extend/clone、parent 树
- 11. 广义 SAM — 多串建 SAM 的两种正确做法
- 12. 回文自动机(PAM) — 两棵扩展树、fail 树、series link
- 13. 子序列自动机 — 下一个字符表、本质不同子序列计数
- 14. 后缀树与后缀平衡树 — 压缩后缀 Trie、动态维护后缀序
- 15. Lyndon 分解与最小表示法 — Duval 算法、双指针
- 16. Main-Lorentz 算法 —
O(n\log n) 求全部平方串 - 17. 字符串算法库:封装与取舍 — 模板选择与常数
- 18. Trick 五维全览 — 33 条(A1–A12 / B1–B9 / C1–C12)+ E 维度 26 条
- 19. 题单总索引 — 按知识点索引 + 按经典度排序 + 扩充补漏
- 20. 总结提升 — 十五个结构的一句话本质 + 五条主线
- 21. 速通指南 — 一下午 / 一天 / 两天 + 十六问自测
配套图:img/ 目录下 26 张 SVG/PNG,每张对应一个结构或过程。
0. 引言:字符串问题的两副面孔
0.1 一个让人卡住的问题
给一个长度
最直接的做法是枚举起点,逐位比较:
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);
}
最坏
慢在哪? aaa,这个信息被完全丢弃。
0.2 第二类问题:子串太多
给串
朴素想法:枚举
注意"子串 = 某个后缀的前缀"。若两个后缀的 LCP 是
而"前面已出现的最大 LCP"要求把所有后缀排好序——这就逼出了后缀数组。另一条路是把"所有子串"直接压成
0.3 全书地图:两条主线
字符串问题分成两类,对应本书的两部分:
第一类:匹配问题(第一部分,第 1–7 章)——"一个串在另一个串里出现没有、在哪、几次"。
| 武器 | 它记住了什么 | 复杂度 | 章节 |
|---|---|---|---|
| 哈希 | 整个子串的"指纹" | 预处理 |
§2 |
| Trie | 公共前缀的合并 | §3 | |
| KMP / 前缀函数 | 每个前缀的最长 border | §4 | |
| Z 函数 | 每个后缀与原串的 LCP | §5 | |
| Manacher | 每个中心的回文半径 | §6 |
这五把武器有一个共同思想:
用已经算好的、位于当前位置左边的信息,推当前答案。
KMP 用
第二类:压缩问题(第二部分,第 8–17 章)——"把
┌─ AC 自动机(多串的前缀树 + fail)
│
Trie ───────────┼─ 后缀树(所有后缀的前缀树 = 压缩后缀 Trie)
│ │
│ └─ 回文自动机(所有回文的两侧扩展树)
│
├─ 后缀数组(排序所有后缀)
│
└─ 后缀自动机(endpos 等价类)
Lyndon 分解 ──► 最小表示法、平方串
四条最关键的依赖链:
Trie → AC 自动机 → fail 树。AC 自动机是"多模式串的 Trie + 每个节点指向最长真后缀的指针"。fail 指针构成的树让"统计出现次数"变成"子树求和"。后缀排序 → height 数组 → LCP 查询。SA 把"子串问题"变成"后缀区间问题",height 把"LCP"变成"区间最小值"。endpos 等价类 → SAM → parent 树。SAM 每个状态是一批 endpos 相同的子串,parent 树刻画包含关系。Lyndon 分解 → 最小表示法 → 平方串。Duval 算法一个O(n) 的双指针同时解决三件事。
0.4 贯穿全书的一条暗线:fail 树
这是本书最重要的一条线索,先点明,后面反复回扣。
把"失配指针"当父亲建树,得到一棵树。四类结构都有这棵树:
| 结构 | 节点是什么 | 父边指向 | 子树和含义 | 章节 |
|---|---|---|---|---|
| KMP 的 fail 树 | 最长 border | 该前缀在 |
§4.9 | |
| AC 自动机的 fail 树 | Trie 的一个状态 | 最长的"是某模式串前缀"的真后缀 | 该模式串出现次数 | §8.5 |
| SAM 的 parent 树 | 一个 endpos 等价类 | 最长后缀所在的类 | 该状态所有子串的出现次数 | §10.5 |
| PAM 的 fail 树 | 一个回文串 | 最长回文真后缀 | 该回文出现次数 | §12.5 |
四棵树的节点含义不同,形状和用法完全一致。所以 §4.9 不是"KMP 的一个应用",而是后面三个自动机的样板:
- 想知道"某模式串出现了几次" → 找 fail 树,求子树和;
- 想知道"两个串的最长公共后缀" → 求 LCA;
- 想统计"有多少状态满足某条件" → DFS 序 + 树状数组。
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 |
| 字典序第 |
SAM / SA | — | §10 |
| 全部平方串 | Main-Lorentz | SA + 关键点 | §16 |
0.6 记号与前置换算
一个重要事实:正文的下标全部 1-based,但代码里 0-based。这个差异是大量 off-by-one 错误的来源,下文代码注释会标出。
1. 记号与基础
1.1 字符串的六个记号
后面全篇只用这几个记号,先一次说清。
定义:设
- 子串
s[l..r] :从第l 个字符到第r 个字符连续取出的一段,长度r-l+1 。例如s=\texttt{abcde} 时s[2..4]=\texttt{bcd} 。 - 前缀
s[1..i] :从开头取i 个字符。s 的真前缀指i<n 的前缀。 - 后缀
s[i..n] :从第i 个字符取到末尾。s 的真后缀指i>1 的后缀。 - border:一个既是
s 的真前缀、又是s 的真后缀的字符串。说"s 有长度为r 的 border",指s[1..r]=s[n-r+1..n] 且r<n 。 - 周期
p :若对所有1\le i\le n-p 都有s_i=s_{i+p} ,称p 是s 的周期。 - LCP(Longest Common Prefix):两个串的最长公共前缀的长度。
注意 border 与周期的关系,这是全篇最重要的一条等价:
验证:aba),所以
这条等价把"前后缀相等"和"平移后自重合"两种完全不同的直觉联系起来。后面求最小循环节、判周期串,用的都是它。
1.2 字符集与编码
定义:字符集是字符串中允许出现的字符的集合,记作
| 字符集 | 大小 c - 'a' 当下标 |
| 可见 ASCII | 95 | 直接 c - ' ' 当下标 |
| 任意整数 |
为什么 e 要点出字符集:因为它直接决定 Trie 和 AC 自动机的空间。Trie 每个节点要开 map 或"只在用到的边上开点"。
一条实用建议:读入字符串时统一转成 1 到 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 章那个朴素做法分析清楚,后面每一把武器都是相对它改进。
定理:朴素匹配的最坏复杂度是
证明:取
证明要点:最坏情况的构造方式是"每次都匹配到最后一位才失败"。
适用边界:随机数据下朴素匹配是
相关例题 P3375 【模板】KMP · P4824 Censoring S · CSES-1753 String Matching
1.4 Trie:把公共前缀合并
Trie 属于"结构"类工具,在数据结构卷已经详细讲过,这里只说它在字符串问题里的位置。
核心观察:如果一堆字符串共享前缀,那么"前缀"这个部分只需要存一次。
证明要点:高位权重大于所有低位之和,所以"先保证高位能取 1"必然最优——与线性基的贪心是同一个论证。
实测(脚本第 2 项):数字组
求"第 k 小异或值"
需要每个节点的子树大小(sz)。当前位尝试走 0 分支,若
// 在 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;
}
注意:这里的"第
01 -Trie 与线性基的分工
| 线性基 | ||
|---|---|---|
| 支持 | 查询与给定 |
查询子集异或最值 |
| 插入 | ||
| 最大异或 | 对每个 |
直接贪心, |
| 区间查询 | 可持久化版支持 | 前缀线性基支持 |
| 可合并 | ✓(见 3.5) | ✓ |
判据:问"两个数的异或"用
例题:P4551 最长异或路径
题意:给
关键转化:路径异或和 = 两个点到根的前缀异或和的异或。
第一步:为什么。设
因为
第二步:问题转化。于是"求路径异或最大值"变成"在数组
第三步:
#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;
}
样例验证:树
复杂度:DFS
点评:"路径异或和转成两点前缀异或"这个技巧是本题的灵魂,它把树上问题拍平成了数组问题。同类题还有"树上路径计数异或和为定值",也是这个转化 + Trie 计数。
3.5 01 -Trie 合并
做法:递归合并。设合并
- 若
u 或v 为空,直接返回另一个; - 否则合并
u 的左儿子与v 的左儿子、右儿子与右儿子,sz[u] += sz[v]。
// 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;
}
复杂度:
总复杂度(树上合并场景):若每个叶子初始贡献
典型用途:树上启发式合并(DSU on Tree)的替代品、线段树合并的"值域为二进制"版本。
相关例题 P6072 『MdOI R1』Path · P6018 [Ynoi2010] Fusion tree
3.6 可持久化 01 -Trie
版本 1 插入
问题:给序列
朴素做法:对每个询问重建 Trie,
关键想法:像主席树一样,对每个前缀建一个 Trie 版本;查询
建树:插入 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;
}
为什么"版本差"能表示区间:版本 cnt 是"cnt[版本 R] - cnt[版本 L-1] 就是"
复杂度:建树
实测(脚本第 6 项):数组
注意:可持久化 Trie 的空间是
例题:P4735 最大异或和
题意:给序列
- 在末尾追加一个数(序列变长);
- 给定
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 是当前序列长度。
关键转化:后缀异或和 = 两个前缀异或和的异或——把它变成"区间内与
第一步:化简。 设
所以要求的式子是
其中
第二步:可持久化
判断某个分支在区间内是否存在。
第三步:追加操作。 直接在最新版本上继续插入即可(就是 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;
}
复杂度:每次插入
要点:三个易错位置——① 版本 query 的两个版本参数是
3.7 Trie 图
Trie 图:橙色虚线为补边
做法(Trie 图 / 补全 Trie):对每个节点 ch[u][c] 不存在,就令
ch[u][c] = ch[fail[u]][c] (若 u 是根,则指向根自己)
这样查询时不需要跳 fail,每次
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 补成"完整自动机",空间变成
这就是 AC 自动机的标准实现(详见第 8 章)。放在这里是因为它本质上是 Trie 的一个操作。
3.8 Trie 上的 DFS 序与子树信息
核心事实:Trie 是一棵树,所以"某节点子树内的所有前缀"在 DFS 序上是连续区间。
应用一:把"前缀约束"变成"区间约束"。 例如"求以 abc 为前缀的所有串的权值和",就是"求 DFS 序上
应用二:把"两个串的 LCP"变成"LCA 深度"。
其中
应用三:Trie 上 DP。 若题目在 Trie 上做"选若干个串,使它们的公共前缀尽量长",直接树形 DP。
相关例题 P5840 [COCI2015] Divljak · P3311 [SDOI2014] 数数 · P3294 [SCOI2016] 背单词
3.9 复杂度与空间
| 操作 | 复杂度 | 说明 |
|---|---|---|
| 插入一个串 | $O(\lvert s\rvert\cdot | \Sigma |
| 查询前缀 | 与词典规模无关 | |
| 前缀计数 | 读 cnt |
|
| 均摊线性 | ||
| 可持久化插入 | 每版新增 |
|
| Trie 图构建 | $O(\text{节点}\times | \Sigma |
节点数上界:
实测(脚本第 4 项):
| 串长 | 字符集 | 实际节点数 | 上界 |
|
|---|---|---|---|---|
| 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 开小。 节点数是
陷阱三:可持久化 Trie 的数组大小。 N*2 会 RE。
陷阱四: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 这一节要带走的三句话
- Trie 把"共享前缀"变成物理共享,所以前缀查询
O(|p|) ,与词典规模无关。 01 -Trie 是"把数当定长 01 串"的 Trie,异或最值靠"尽量走相反位"的高位贪心;求两个数的异或用它,求子集的异或用线性基。- 可持久化
01 -Trie 用"版本差"支持区间查询,空间O(n\cdot LG) ,数组必须按这个量级开。
4. KMP 与前缀函数
4.1 动机:把"每次从头比"变成"带着信息后退"
再看一次朴素匹配的浪费。
关键观察一:
证明:设
证明要点:把等式两边各砍掉末尾一位,得到的是
推论:枚举下界从
复杂度:这样改完后字符串比较次数是
4.4 关键观察二:失配时跳到哪
真正的问题在"往下枚举"这一步。当
引理:设
证明:
这个引理说明什么:
(注意
所以失配时不需要从
直到
算法:
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
(
border 链:长 6 的串失配后依次退到长 4、长 2 的 border
4.5 复杂度:为什么是 O(n)
这是 KMP 最容易被含糊过去的地方,值得严格讲。
定理:计算整个前缀函数数组的复杂度是
证明(势能分析):定义势能
if分支至多让j 增加1 ;while循环每转一圈让j 减少至少1 (因为\pi_{j}<j )。
整个过程中 while 的总执行次数不超过
证明要点:
这个论证的模式值得记住:凡是"指针只朝一个方向单调移动、每次移动都消耗某种单调量"的算法,都能用同样的方式论证线性复杂度。Z 函数、Manacher、双指针、单调栈全都用这个模式。
4.6 用 \pi 做匹配:KMP 的另一半
到这里我们只算了
问题:已知
朴素做法:枚举起点
关键:把"已经匹配了多少"记成一个状态
扫描
也就是"当前已经匹配上
不变量:处理完
(
为什么状态只需一个整数:因为我们要找的是"
三个转移分支
读入
| 情形 | 动作 | 理由 |
|---|---|---|
| 匹配延长一位 | ||
| 沿 border 链后退 | ||
| 不动 | 已无可退 |
为什么失配时是
不变量给出
而由不变量,左边这段位于
这正是"
这条推理同时说明了一件事:失配时
为什么匹配成功后是
走一遍
推论:
这一条看似平凡,却是后面建树的基础:它保证了 fail 关系无环。
推论:fail 链长度
4.9 fail 树(失配树)
既然
定义:fail 树以
为什么一定是树:每个节点
最关键的一条性质:
在 fail 树上,
i 到根的路径(不含根)就是T[1..i] 的全部 border。
这不是新结论,就是 §4.8 那条引理的树上说法。但它把"沿链跳"变成了"树上走路径",于是树上的一切工具都能用。
以
0(root)
├─ 1 (a)
│ ├─ 2 (aa)
│ │ ├─ 5 (aabaa)
│ │ └─ 6 (aabaaa)
│ └─ 4 (aaba)
└─ 3 (aab)
└─ 7 (aabaaab)
读法:节点 aabaaab)到根的路径是 aab,没有更小的了。
节点 aabaaa)的路径是 aa、a。
节点数:fail 数组。
4.10 fail 树上的四种操作
把 fail 树建出来后,KMP 的绝大部分应用都变成树上的标准问题。这一节把四种操作一次性讲清,后面各节只是套用。
操作一:子树和 = 出现次数
定理:前缀
证明:
做法:
// 建树后按编号从大到小累加(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] 的出现次数
为什么不用真的建树:因为
验证(
| 前缀 | 子树大小 | 暴力统计的出现次数 |
|---|---|---|
a |
5 | 5 ✓ |
aa |
3 | 3 ✓ |
aab |
2 | 2 ✓ |
| 其余 | 1 | 1 ✓ |
操作二:深度 = border 个数
定义:
这直接就是 P5829 的解法:建 fail 树,树上倍增求 LCA,
// 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 可能为
例题:P5829 【模板】失配树
题意:给串
关键转化:失配树上的 LCA。
第一步:建失配树。 fail[i] 作为
第二步:答案 = LCA。 "前缀
注意:LCA 可能恰好是
#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;
}
复杂度:建树
点评:"border 链 = 树上的根路径"这个观察把字符串问题变成了树上问题,之后 LCA、倍增、树剖全部可用。这是失配树类题的统一入口。
操作四:DFS 序把"链上问题"变成"区间问题"
fail 链上的查询("
这是第 8 章 AC 自动机 P2414、P5840 的标准套路:"某模式串在文本里出现几次" → "fail 树子树和" → "DFS 序 + 树状数组"。
4.11 应用:border 与周期
这一节的东西全是"沿 fail 链走"或"读
求全部 border
定理:
这正是 §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[k];若把 pi[0..n-1](0-indexed),下一步是 pi[k-1]。混用是最高频的错,写代码前先定死一种。
例题:P3375【模板】KMP
题意:给
关键转化:这就是 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;
}
复杂度:
点评:这道题把 KMP 的两个用途(匹配 + 求前缀函数)合在一起,是最经济的模板。
最小循环节
定理:设
- 若
p\mid n :T 由长度p 的串重复n/p 次构成,p 是最小循环节长度; - 若
p\nmid n :T 不是周期串,最小循环节长度为n 。
证明:由 border 与周期的等价,
先证
再证最小性:若存在更小的周期
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 不能省。
例题:P4391 [BOI2009] Radio Transmission
题意:给一个字符串
关键转化:这是"最小循环节"的弱化版——允许最后一个周期不完整。
观察:设
为什么:若
所以答案就是
#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;
}
复杂度:
点评:一行答案 n - pi[n]。但要能说清"为什么不用判整除"——这是本题与普通最小循环节唯一的分歧点。
最大周期 = n- 最短 border
周期还有另一头:最短 border 给出最大周期。
定理:设
证明:周期长度
求最短 border:沿 fail 链走到不能再走(下一个是
// 求每个前缀的最短非空 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] 恒为 mn[b] 非零而不是 b 非零——这个细节错了会死循环。
例题:P3435 [POI2006] OKR-Periods of Words
题意:给长度
关键转化:最大周期 = 前缀长度 − 最短 border 长度。
第一步:理解周期与 border 的对偶。
设
即"长度为
第二步:等价关系。
验证:
第三步:求最短 border。 沿 fail 链往上跳到最浅的非零节点(即 fail 链上除 0 外的最浅者)。但直接跳是
优化(路径压缩):在求的过程中把 fail 数组"压缩"——若 fail[i] 的 fail 还能用,就让 fail[i] 直接指向那个更浅的。这样均摊
#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(
复杂度:
点评:"周期 fail[i] = j——没有它复杂度退化到
本质不同子串数(副产品)
定理:长度
证明(增量法):逐个字符加入。加入第
这只是理论上的公式:要真的数不同子串,
4.12 应用:KMP 自动机
前面所有应用都是"边扫边跳 fail"。但如果要对同一段文本做很多次不同的匹配(或把匹配嵌进 DP),跳 fail 的均摊性就没了——每次都从头跳,最坏
解法:把跳 fail 的过程预先算成转移表。
定义:状态
约定
这就是 KMP 自动机:
// 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]; // 查表,不再跳链
}
为什么是 nxt[fail[j]][c] 里的 fail[j] < j,所以按
代价:空间 int,约 10 MB,可以接受;若
三个典型用法:
| 用法 | 做法 |
|---|---|
| 多次匹配同一模式串 | 建一次自动机,每次 |
| 禁串计数(不能出现 |
自动机上 DP, |
| 禁串 + 大 |
把转移写成 |
第 8 章的 P3193 GT 考试就是第三种。注意这与第 8 章 AC 自动机的"禁串计数"是同一件事——那里把单串换成多串(Trie),矩阵规模从
例题:P4824 [USACO15FEB] Censoring S
题意:给串
关键转化:用栈模拟 + KMP 边扫边匹配。
朴素做法:每次删除后重新扫描——
关键观察:删除一段后,被删除部分左边的匹配状态不受影响。所以可以用栈保存"左边还没被删除的字符",同时记录"每个栈位置对应的 KMP 匹配长度"。
做法:
- 用栈
st存字符,top[i]存"处理到第i 个栈元素时的 KMP 匹配长度"; - 每读一个字符,就推进 KMP;
- 若匹配长度达到
|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;
}
复杂度:
点评:"栈 + 状态记录"是"反复删除"类问题的标准解法。关键是"恢复状态"——弹出后 KMP 状态自动回到 top[tp],不需要重算。同类题还有 P3121 [USACO15FEB] Censoring G(多模式串,用 AC 自动机)。
例题:综合:CF1200E Compress Words
题意:给
第一步:问题化归
每次合并要求"最大重叠长度"——这正是Z 函数 / KMP 的 LCP 问题。设答案当前为
第二步:用 Z 函数求
构造 # 是分隔符,防跨越),求
更简单:只看
正确做法:要找的是"
改用 KMP:求
// 求 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;
}
第三步:总复杂度
每次只在
点评:这道题说明一个常见模式——"求最大重叠"就是"跑 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 的全部内容可以压缩成两句话:
\pi_i (=\text{fail}[i] )是"最长相等前后缀",而所有 border 构成一条沿 fail 递减的链;- 把 fail 当父亲,链就长成一棵树——于是链上的问题全变成树上的标准问题。
本章的五个应用,全部归到这两句话:
| 应用 | 用的是哪一半 |
|---|---|
| 求全部 border | fail 链(§4.8) |
| 最小循环节、周期计数 | |
| 统计前缀出现次数 | fail 树子树和(§4.10 操作一) |
| 最长公共 border | fail 树 LCA(§4.10 操作三) |
| KMP 自动机 | fail 链被摊平成转移表(§4.12) |
为什么要在这里把 fail 树讲透
因为第二部分的三个自动机都是它的推广。同样是"把指针当父亲建树",同样是"子树和 = 出现次数":
| 结构 | 节点是什么 | 父边指向 | 子树和含义 | 章节 |
|---|---|---|---|---|
| KMP 的 fail 树 | 最长 border | 该前缀在 |
本章 §4.9 | |
| AC 自动机的 fail 树 | Trie 的一个状态 | 最长的"是某模式串前缀"的真后缀 | 该模式串出现次数 | §1.5 |
| SAM 的 parent 树 | 一个 endpos 等价类 | 最长后缀所在的类 | 该状态所有子串的出现次数 | §10.5 |
| PAM 的 fail 树 | 一个回文串 | 最长回文真后缀 | 该回文出现次数 | §12.5 |
四棵树的节点含义不同,但形状和用法完全一致。所以这一章的 fail 树不是"KMP 的一个应用",而是后面三个自动机的样板:
- 想知道"某模式串出现了几次" → 找它的 fail 树,求子树和;
- 想知道"两个串的最长公共后缀/边界" → 找 LCA;
- 想统计"有多少个状态满足某条件" → DFS 序 + 树状数组。
第 8 章讲 AC 自动机时,只需要说一句"把单串换成 Trie,fail 的定义从'最长 border'改成'最长的、且是某模式串前缀的真后缀'",其余全部沿用本章。
三个容易混的点
①
② 失配跳的是
③ 匹配成功后要 j = fail[j]。 清零会漏掉重叠出现(aa 在 aaaa 中出现在 1、2、3)。
5. Z 函数与扩展 KMP
5.1 它回答什么问题
KMP 的前缀函数
对每个位置
i ,求s[i..n-1] 与整个s 的最长公共前缀(LCP)长度。
定理:Z 函数可以在
证明:只看 while 循环的总执行次数。
先看它什么时候会成功执行。设这次成功让 z[i] 的初值满足
- 若
v>r-i ,即i+v>r :这一位的比较发生在r 之外,比较完后i+z[i]-1=i+v>r ,后面必然触发线段更新,r 被推到i+v 。即r 至少增加 1。 - 若
v\le r-i :这会与初值v\ge\max(0,r-i+1) 矛盾(当i\le r 时初值是r-i+1 ,而v 是在初值基础上累加的,所以第一次成功执行时必然v=r-i+1>r-i )。
所以:每一次 while 成功,都把
证明要点:
这个论证与 KMP 的
5.11 扩展 KMP:两个串的版本
问题:给定文本串
即"从
结构完全一样,只是"参照串"和"被扫描串"分开了:
- 线段
[l,r] 的含义变成S[l..r]=T[0..r-l] ; - 位置
i 对应T 的k=i-l ,查的仍是T 的z_k ; - 匹配不能超过
T 的长度m 。
// 扩展 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 的差别:
| 项 | 单串 | 两串 |
|---|---|---|
| 扫描谁 | ||
| 比对谁 | ||
| 镜像查谁 | ||
| 初始 |
||
| 上界 | i+z[i] < n |
i+p[i] < n 且 p[i] < m |
p[i] < m 这一条最容易漏。漏了会读到
为什么
m == 0 的判空:z[i-l] 会越界。
复杂度:
与 KMP 的分工:
| 要什么 | 用谁 |
|---|---|
| KMP | |
| 每个位置"最长能匹配多少" | 扩展 KMP |
| 允许 |
扩展 KMP(需要部分匹配长度) |
相关例题 P5410 【模板】扩展 KMP(Z 函数) · P8112 符文破译
例题:P5410【模板】扩展 KMP(Z 函数)
题意:给串
关键转化:参照串换成
// 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;
}
}
复杂度:
三个 1-indexed 的坑:
- 镜像下标是
z[i - l + 1]而不是z[i - l](下标整体加一)。 z[1] = m(1-indexed 的"自己与自己")。- 第二段
while必须有ext[i] < m。
验证:
5.12 与 KMP 的互相转化
定理:给定
思路:
// 由 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:若
验证:
反方向(
5.13 三个应用
应用一:一次求出所有位置的匹配长度。
应用二:字符串整周期。 若存在
应用三:比较两个后缀的大小。 求出
相关例题 P5546 [POI2000] 公共串 · P8112 符文破译
5.14 本章小结
Z 函数只有一句话:维护一条右端点最靠右的匹配段
拆成四条:
- 匹配段:
z_i=k 产生区间[i,i+k-1] ,段内s[i..i+k-1]=s[0..k-1] (5.3)。 - 对齐:
[l,r] 使s[i..r]=s[i-l..r-l] ,所以i 与k=i-l 行为相同(5.4)。 - 边界:等式只在
[l,r] 内成立,所以初值取\min(z_k,\ r-i+1) (5.5)。 - 推进:
i+z_i-1>r 就更新线段;r 单调不减是O(n) 的唯一依据(5.7、5.10)。
三个最容易犯的错:
- 忘了
r-i+1 这个上界,直接z[i] = z[i-l],在情形二越界。 z[0]的约定混用(0 或n ),涉及位置 0 时答案错。- 扩展 KMP 漏了
p[i] < m上界,读到模式串之外。
与 KMP 的关系:同一套"单调推进量"论证(KMP 推
习题池:Z 函数与扩展 KMP
| 题号 | 题名 | 平台 | 难度 |
|---|---|---|---|
| P7114 | [NOIP2020] 字符串匹配 | 洛谷 | 省选/NOI− |
| P8112 | [Cnoi2021] 符文破译 | 洛谷 | 提高+/省选− |
| P5211 | [ZJOI2017] 字符串 | 洛谷 | NOI |
| P14874 | [ICPC2020 Yokohama R] Suffixes may Contain | 洛谷 | 提高+/省选− |
6. Manacher
6.1 动机:回文的两条对称
回文指正读反读相同的串,如 abba、aba。
朴素求最长回文子串:枚举中心,向两边扩,
关键观察:回文有极强的对称性。若已知
6.2 奇偶统一
回文分奇长度(aba,中心是一个字符)和偶长度(abba,中心在两个字符之间)。分两种讨论会写两套代码。
统一办法:在每两个字符之间插入一个分隔符,首尾也加。例如
这样所有回文都变成奇数长度,且原串的每种回文都对应新串里以某个 # 或字符为中心的奇数长回文。求完后
注意事项:分隔符必须不出现在原字符集中(用 #、|、或
6.3 算法
记
情形一:
情形二:
- 若
d_j<r-i :d_i=d_j (镜像是完整的); - 否则
d_i\ge r-i ,从r+1 继续暴力扩。
// 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
题意:给只含小写字母的串(
关键转化:
#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):插入分隔符后,以某中心、半径
验证:a)时
复杂度:int,约 88 MB——必须开全局且用 int 不用 long long。
点评:这道题的记忆点是"插入分隔符后答案是
6.4 复杂度
定理:Manacher 是
证明:与 Z 函数同理——while 每次成功都推进
6.5 从半径还原原串信息
插入分隔符后,
- 若
i 处是分隔符#(i 为奇数,0-indexed):对应偶长度回文,原长为2d_i ; - 若
i 处是字符(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);
}
更简单的记法:插入分隔符后,最长回文子串的长度恰好等于
6.6 应用:回文串计数
应用一:回文串总数。以每个中心为心的回文个数:若
应用二:本质不同的回文子串数。必须用 PAM(第二卷),Manacher 做不到。
应用三:回文划分。把
相关例题 P1659 拉拉队排练 · P4555 最长双回文串 · P4287 双倍回文
例题:P4287 [SHOI2011] 双倍回文
题意:若一个回文串可以写成
第一步:理解结构
证明要点:把"
实现:按 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,但要输出每个模式串的出现次数。
关键转化:fail 树子树求和(第 1.5 节)。
为什么 12.1 的做法不行:12.1 用 vis 去重,但本题要精确次数,不能去重。而暴力沿 fail 链跳的总复杂度是
做法:文本串跑完后,只给"到达的状态"计数,然后按 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;
}
复杂度:
点评:"只计数到达的状态 + BFS 逆序累加"是 AC 自动机统计的标准做法。它把"沿链跳"变成"一次树形累加"。
8.6 AC 自动机上的 DP
模型:在 AC 自动机上走
状态:
转移:
"不能经过"的处理:若某节点
// build() 中
if (v) {
fail[v] = ch[fail[u]][c];
bad[v] |= bad[fail[v]]; // 关键:继承 fail 的禁止标记
q.push(v);
}
为什么必须继承:因为"
相关例题 P4052 [JSOI2007] 文本生成器 · P3311 [SDOI2014] 数数 · P2444 [POI2000] 病毒
例题:P4052 [JSOI2007] 文本生成器
题意:给
关键转化:补集 + AC 自动机上 DP(正难则反)。
第一步:反向计数。 "至少包含一个"的补集是"一个都不含"。所以
第二步:拆成"禁止节点"。 AC 自动机上,若状态
关键是建 fail 时继承禁止标记:
bad[v] |= bad[fail[v]]; // ★ 在 build() 的 BFS 里,这一步不能漏
第三步:在 AC 自动机上 DP。 设
答案就是
#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;
}
复杂度:
要点:bad[u] |= bad[fail[u]] 是本题唯一的核心——它保证"走到
8.7 复杂度与实现陷阱
| 操作 | 复杂度 |
|---|---|
| 建 Trie | |
| 建 fail(BFS) | |
| 匹配文本 | |
| fail 树统计 |
陷阱一: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 这一节要带走的三句话
- fail 指向"最长真后缀且是某模式串前缀"的节点,BFS 建,靠
fail[fa]\to c 递推。 - Trie 图把缺失边补全,匹配时
O(1) 转移,代价是空间\times|\Sigma| 。 - fail 树上的子树求和 = 出现次数,这是 AC 自动机最重要的性质。
9. 后缀数组
9.1 它解决什么问题
核心洞察(0.1 节已经点出):
所以把
后缀数组就是"排好序的后缀":
两者互逆:
关键副产品:
为什么 height 重要:因为任意两个后缀的 LCP 就等于它们排名区间上 height 的最小值:
于是 LCP 查询变成 RMQ,
9.2 倍增构造
后缀数组:排序后的后缀与 height(橙色为相邻 LCP)
朴素做法:把所有后缀取出来排序,比较两个后缀是
倍增思路:用"长度为
设
所以比较两个长为
这就把"字符串比较"变成了"二元组比较",可以用基数排序
轮数:
终止条件:当所有
9.3 倍增正确性
定理 2.1:倍增法第
证明(对
归纳步:设对
所以字典序比较等价于二元组
证明要点:
注意"越界"的处理:当
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;
}
两个必错的坑:
oldrk要开2n 并清零。因为oldrk[i+w]在i+w>n 时会被访问,越界位必须是 0(表示"空后缀最小")。memcpy的长度要含w :sizeof(int) * (n + w + 1),否则oldrk[n+1..n+w]是垃圾值。
例题:P3809 【模板】后缀排序
题意:给串
关键转化:倍增 + 基数排序(第 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;
}
复杂度:
点评:三个坑——oldrk 开 memcpy 长度含
9.5 O(n) 求 height
朴素求 height:对每个
关键性质:
其中
证明:设
考虑后缀
- 因为后缀
i-1 与j 的前L 个字符相同,所以去掉首字符后,后缀i 与j+1 的前L-1 个字符相同(若L\ge1 )。 - 所以
\operatorname{lcp}(i,j+1)\ge L-1 。 - 现在
rank[i] 与rank[j+1] 的关系:因为rank[j+1] 可能不等于rank[i]-1 ,但j+1 与i 之间的后缀(在排名上)与i 的 LCP 都\ge \operatorname{lcp}(i,j+1) (由 LCP 的单调性,见 (5):区间最小值)。 - 所以
h[i]=\operatorname{lcp}(i,sa[rank[i]-1])\ge\operatorname{lcp}(i,j+1)\ge L-1=h[i-1]-1 。\square
证明要点:把"后缀
由这条性质,求 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;
}
复杂度:while 里),而每轮开头 --k 最多减 1,所以
9.6 五个经典应用
应用一:本质不同子串数
为什么:按排名顺序处理后缀。处理第
注意:为什么"与前一名重复"就够,不需要考虑所有前面的后缀?因为 LCP 的区间最小值性质——与任意更早后缀的 LCP 都不超过与前一名的 LCP。
应用二:最长公共子串(两串)
把两串拼起来(中间加分隔符),求 SA 与 height。答案是"height 中满足
应用三:最长公共子串(多串)
拼接后二分答案
应用四:出现至少 k 次的最长子串
二分答案
应用五:任意两后缀的 LCP
用 (5) + ST 表,
例题:P2408 不同子串个数
题意:给串
关键转化:SA + height(第 2.6 节应用一)。
// 求出 sa 与 height 后
long long ans = 0;
for (int i = 1; i <= n; ++ i)
ans += (n - sa[i] + 1) - height[i];
printf("%lld\n", ans);
复杂度:
另一解法(SAM):
点评:这道题是"SA 与 SAM 都能做、而 SAM 更优"的典型。它揭示了两者的本质关系:(9) 式的 SAM 公式与 (7) 式的 SA 公式完全等价。
例题:P2178 [NOI2015] 品酒大会
题意:给串
- 有多少对
(i,j) (i<j )满足"从i,j 开始的后缀的 LCP\ge r "; - 这些对中
a_i\cdot a_j 的最大值。
关键转化:SA + 按 height 分组合并(并查集)。
第一步:LCP
所以"LCP
第二步:从大到小枚举
第三步:维护每段的(大小、最大值、最小值、答案)。 因为权值可能为负,最大乘积可能来自"两个最小负数"或"两个最大正数"。
// 按 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] = 全局最大乘积
复杂度:
点评:"LCP
例题:P1117 [NOI2016] 优秀的拆分
题意:给串
关键转化:枚举
第一步:分解。 若
第二步:求
第三步:关键点法求
- 求以
i+L 为右端的 LCS(与i 对齐)——即从i 与i+L 往左能匹配多长; - 求以
i+L 为左端的 LCP(与i 对齐)——即从i 与i+L 往右能匹配多长。
若 LCS + LCP
// 用哈希求 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];
复杂度:
点评:"枚举周期长度 + 关键点 + 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 这一节要带走的三句话
- 子串 = 后缀的前缀,这是 SA 与 SAM 的共同基础。
- 倍增把"字符串比较"变成"二元组比较",每轮基数排序,总
O(n\log n) 。 h[i]\ge h[i-1]-1 是O(n) 求 height 的全部秘密,它保证比较指针只前进不回退。
10. 后缀自动机(SAM)
10.1 它解决什么问题
SA 把子串问题变成"后缀数组 + 区间查询",但每次都要配合 height / ST 表 / 二分。而有些问题需要直接回答:
- 本质不同子串数(
O(n) 直接算); - 每个子串的出现次数;
- 字典序第
k 小子串; - 子串在不同串中的出现情况(广义 SAM)。
SAM 用一个
10.2 endpos 等价类
SAM 的 DAG:实线为转移、橙色虚线为 link,节点下方为 len
定义 3.1:对串
例:
-
\operatorname{endpos}(\texttt{a})=\{1,3,5\} -
\operatorname{endpos}(\texttt{ab})=\{2,4\} -
\operatorname{endpos}(\texttt{ba})=\{3,5\} -
\operatorname{endpos}(\texttt{aba})=\{3,5\} -
\operatorname{endpos}(\texttt{babab})=\{5\}
关键观察:
定义 3.2:若两个子串的 endpos 相同,称它们属于同一个 endpos 等价类。
定理 3.3(等价类的结构):同一个 endpos 等价类中的所有子串,按长度排序后是连续的,且较短者是较长者的后缀。
证明:设
先证
再证"连续":若
(越长出现越少)。而已知两端相等,所以中间也相等,
证明要点:endpos 的包含关系按长度单调,两端相等逼迫中间相等。
推论 3.4:每个等价类可以用三元组
SAM 的每个状态 = 一个等价类,状态存
10.3 状态数与转移数上界
定理 3.5:长度
证明思路(状态数):除初始状态外,每个状态对应一次"拆分"或"新建"。
- 每次
extend至少新建 1 个状态(n 次,共n 个); - 每次
clone至多发生 1 次(实际上 clone 的总次数\le n-1 )。
所以总状态数
证明思路(转移数):把 SAM 看成 DAG,用"每个状态至少有一条从初始状态到它的路径"与"连续转移不超过
实测(脚本验证):随机串的状态数远小于上界。
10.4 构造算法
extend 的三个分支:A 无 clone、B 直接复用 extend(c):在已有 SAM 上追加字符
设当前"代表整个串的状态"为
- 从
last 沿 link 往上走,把所有"没有c 转移"的状态都连上c\to cur ; - 若走到根还没有,则
link[cur]=\text{root} ; - 否则设第一个"有
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:若
10.5 parent 树与出现次数
SAM 的 parent 树(按 link 连边)
定义 3.6:以
性质:
其中 cur(非 clone)被创建的次数"——即"该状态对应的结束位置中出现一次的个数"。
注意 clone 状态不计 occ:clone 只是"复制",不产生新的结束位置。
// 求每个状态的出现次数
// 建 SAM 时:非 clone 的 cur 状态 occ[cur] = 1
// 按 len 从大到小排序(等价于按拓扑序),累加
sort by len descending:
occ[link[u]] += occ[u];
为什么按 len 降序就够:
10.6 本质不同子串数
定理 3.7:
证明:每个状态
个。不同状态的子串族由 endpos 不同而互不重叠,且所有子串都被某个状态代表。所以求和即得。
证明要点:把每个状态看作"长度区间",区间长度之和就是子串总数,而 endpos 不同保证了不重。
例题:P3804 【模板】后缀自动机
题意:给串
关键转化:SAM + parent 树累加出现次数。
做法:
- 建 SAM,非 clone 状态的
occ=1; - 按
len 降序累加occ[link[u]] += occ[u]; - 对每个状态
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;
}
复杂度:
点评:occ[cur]=1 只给非 clone 设,这是本题唯一的坑。clone 是"复制"而非"新结束位置",若也设 1 会夸大出现次数。
10.7 字典序第 k 小子串
第一步:算每个状态的"从它出发能走出的子串数"。 设
若子串可以重复计数(同一子串在不同位置算多次),则每个状态还要乘上它的出现次数。
第二步:按
例题:P3975 [TJOI2015] 弦论
题意:给串
关键转化:SAM 上按
第一步:算
为什么要加
第二步:按
第三步:从根走。 按字典序遍历儿子,若
// 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;
}
}
复杂度:
点评:
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 这一节要带走的三句话
- SAM 的每个状态 = 一个 endpos 等价类,同类子串长度连续、短者是长者的后缀。
- 状态数
\le2n-1 ,转移数\le3n-4 ,这是 SAM 能O(n) 的根本。 - parent 树的子树求和 = 出现次数,与 AC 自动机的 fail 树是同一个套路。
11. 广义 SAM
11.1 它解决什么问题
问题:给多个串,求"所有串的本质不同子串总数""多串的最长公共子串""每个串独有的子串"。
朴素做法:把多个串用分隔符拼成一个长串,建普通 SAM。问题:分隔符会引入跨串的"伪子串"(如 a#b),且字符集变大。
广义 SAM:直接在 Trie 上建 SAM,天然只包含"某个串的子串"。
11.2 错误做法:重置 last
广义 SAM:先建 Trie,BFS 序扩展,
for (每个串 s) {
last = 1; // ✘ 错误!
for (char c : s) extend(c);
}
为什么错:extend 的前提是"从 last 出发沿 link 往上走、遇到的都是没有 last=1 后,SAM 里已经有之前串的信息,某些状态可能已经有 extend 会建出错误的结构(表现为状态数虚高或 link 链损坏)。
11.3 正确做法一:特判已有转移
思路:在 extend 开头检查"从 last 出发是否已有
// 广义 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;
}
两个分支的含义:
len[q] == len[p]+1:q 代表的等价类恰好"整块可用",直接复用(不需要新状态)。len[q] > len[p]+1:需要对q 拆分(clone),因为新串只覆盖q 的一部分长度。
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 【模板】广义后缀自动机
题意:给
关键转化:广义 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;
}
复杂度:
点评: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 这一节要带走的三句话
- 直接重置
last是错的,因为已有转移会被破坏。 - 两种正确做法:特判已有转移(在线)或 BFS 建 Trie(离线,推荐)。
len[q]==len[p]+1直接复用,>则需要 clone,这个判据与普通 SAM 一致。
12. 回文自动机(PAM)
12.1 它解决什么问题
问题:求本质不同的回文子串个数、每个回文子串的出现次数、回文划分方案数。
Manacher 的局限:Manacher 能
PAM(回文自动机 / 回文树):每个节点代表一个本质不同的回文串,节点数
关键定理:长度
证明:每个回文子串
更直接的证明:考虑从
证明要点:每个位置最多贡献 1 个新回文(以它结尾的最长者),所以总数
12.2 结构:两棵"扩展树"
PAM:奇根
- 奇根(
len=-1 ):所有奇回文的父亲; - 偶根(
len=0 ):所有偶回文的父亲。
为什么要把奇根的
转移:
fail 指针:
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] 的回文后缀"。回文 s[pos-len[u]-1] == s[pos]。若不等就沿 fail 往上跳。
注意 len[1]=-1 让 while 终止:当 pos-(-1)-1=pos,s[pos]==s[pos] 恒成立,循环停止。
12.4 节点数上界
定理 5.1:PAM 的节点数
证明:由 5.1 的定理,本质不同回文数
实测:随机串与特殊串的节点数都远小于
12.5 fail 树与出现次数
与 AC 自动机完全同构:按 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];
为什么回文
例题:P3649 [APIO2014] 回文串
题意:给串
关键转化:PAM + fail 树累加(与 12.5 的 SAM 版完全同构)。
做法:
- 建 PAM,每个
last被访问时cnt[last]++; - 按
len 降序做cnt[fail[u]] += cnt[u]; - 对每个节点
u ,用cnt[u] * len[u]更新答案。
与 SAM 的类比:
| SAM | PAM | |
|---|---|---|
| 状态代表 | endpos 等价类(一族子串) | 一个回文串 |
| 计数对象 | 非 clone 状态 | 每个 last |
| 累加顺序 | 按 |
按 |
| 答案 |
点评:PAM 与 SAM 的统计代码几乎一模一样,理解一个就懂另一个。
12.6 series link 与回文划分
问题(回文划分):求"把串划分成若干回文串"的方案数。
朴素 DP:
优化思路:在 PAM 的 fail 链上,相邻节点的
定义 5.2:
为什么能合并:在同一 series 内,从
复杂度:
相关例题 P4287 [SHOI2011] 双倍回文 · P4555 [国家集训队] 最长双回文串 · P4762 [CERC2014] Virus synthesis
例题:P5496 【模板】回文自动机
题意:给串
关键转化:PAM + 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;
}
复杂度:
点评: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 这一节要带走的三句话
- 本质不同回文数
\le n ,所以 PAM 只有O(n) 个节点。 - 奇根
len=-1 让"单字符"与"双字符"统一处理,这是 PAM 最巧妙的设计。 - fail 树的子树和 = 出现次数,与 AC 自动机、SAM 完全同构。
13. 子序列自动机
13.1 它解决什么问题
问题:给串
朴素做法:对每个询问贪心匹配,
子序列自动机:预处理 nxt[i][c] = "从位置
13.2 构造
子序列自动机:
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;
}
复杂度:预处理
13.3 本质不同子序列计数
问题:求
DP:
其中
证明:考虑加入
证明要点:容斥——"新增的以
例题:P4112 [HEOI2015] 最短不公共子串
题意:给两个串
关键转化:子序列自动机 + BFS 求最短路。
做法:题目问四种情况(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)
复杂度:
要点:"最短不公共子串"的正解是"最短公共子序列/子串"的反面——建两个自动机后,BFS 第一次走不下去的那个深度就是答案。
13.4 延伸阅读(更多同知识点题目)
相关例题 P4112 [HEOI2015] 最短不公共子串 · ABC299F Square Subsequence · CF1778F Maximizing Root · P3975 [TJOI2015] 弦论(子序列自动机 + SAM 对照)
13.5 这一节要带走的三句话
- 子序列自动机 = 倒序预处理"下一个字符出现位置",
O(1) 转移。 - 本质不同子序列数用容斥 DP:
f_i=2f_{i-1}-f_{\text{last}-1} 。 - 注意"子序列"与"子串"的区别:子序列可以跳过字符。
14. 后缀树与后缀平衡树
14.1 后缀树
后缀 Trie(
性质:
- 叶节点数
=n (每个后缀一个叶子); - 内部节点数
\le n ,总节点数\le2n ; - 从根到叶的路径拼起来 = 一个后缀;
- 两个后缀的 LCP = 它们 LCA 的深度(边权按字符数计)。
与 SA 的关系:后缀树的 DFS 序(按字典序遍历儿子)恰好给出后缀数组;而"相邻叶子之间的 LCA 深度"就是 height。
构造:直接建是
什么时候真需要后缀树:某些"多串公共子串"或"字典序结构"的题。但绝大多数可以用 SA 或广义 SAM 替代。
例题:P4248 [AHOI2013] 差异
题意:给串
其中
关键转化:拆成两半——前半与 LCP 无关,后半是"所有后缀对的 LCP 之和"。
第一步:拆式子。
前两项相等(对称),各为
第二步:
第三步:经典套路——单调栈求"所有子区间最小值之和"。 对每个
// 求 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;
}
复杂度:
与后缀树的关系:这道题也可以用后缀树做——所有后缀对的 LCP 之和 = 后缀树上所有内部节点的"深度 × 左右子树叶子对",一次 DFS 即可。两法本质相同,因为"height 上的笛卡尔树"就是后缀树。
要点:单调栈求"子区间最小值之和"是 SA 类问题的万能套路(P2178、P3181 都用)。注意两次弹出的条件不同(一次 >=、一次 >),否则重复计数。
14.2 后缀平衡树
后缀平衡树:按字典序维护后缀,中序即 sa 问题:要在串的开头插入字符,并维护所有后缀的顺序(用于在线 LCP 或动态后缀排序)。
做法:用平衡树(如重量平衡树 / 替罪羊树)按字典序维护后缀。插入新字符相当于"给所有后缀加同一个前缀"。
比较两个后缀的关键技巧:设要比较后缀
所以比较是
应用:在线 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 这一节要带走的三句话
- 后缀树 = 压缩后缀 Trie,节点
O(n) ,LCP = LCA 深度。 O(n) 建后缀树要 Ukkonen,实战多用 SAM 父树或 SA 替代。- 后缀平衡树比较后缀只需
O(1) (去掉首字符后是已知后缀)。
15. Lyndon 分解与最小表示法
15.1 定义与唯一性
定义 8.1:若串
例:ab < b);abab > bab);
定理 8.2(Lyndon 分解唯一性):任意串
其中每个
证明思路:存在性用 Duval 算法构造(见 8.2)。唯一性:设有两种分解,取它们第一个不同的位置,用"Lyndon 串的前缀关系"导出矛盾。
15.2 Duval 算法
Duval 算法:
// 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;
}
复杂度:
为什么 while (i <= k) 的循环让
15.3 最小表示法
问题:求"循环移位中字典序最小的那个"的起始位置。
做法(双指针,
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);
}
正确性:若
复杂度:
例题:P1368 工艺
题意:给
关键转化:最小表示法双指针(第 8.3 节)。
为什么不能暴力:枚举
做法:维护两个候选起点
#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;
}
复杂度:
要点:i == j 时若不 ++j 会死循环——这是本题唯一但最致命的坑。
15.4 与最小表示法的关系
Lyndon 分解可以求"最小表示",但不是最优的——因为 Lyndon 分解是
反过来,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 这一节要带走的三句话
- Lyndon 串严格小于自己的所有真后缀,任意串有唯一的 Lyndon 分解。
- Duval 算法
O(n) ,双指针维护"当前 Lyndon 串的周期"。 - 最小表示用双指针即可,不必绕道 Lyndon 分解。
16. Main-Lorentz 算法
16.1 它解决什么问题
问题:求串
朴素做法:枚举中心与长度,
应用:NOI2016 优秀的拆分(求"
16.2 关键思想:按长度分治
Main-Lorentz:平方串分三类,跨中点者用 LCS + LCP 定位 分治:把串从中间劈开,平方串分三类:
- 完全在左半边;
- 完全在右半边;
- 跨过中点。
前两类递归,第三类在
16.3 跨越中点的平方串
核心工具:对"左半边的反转"与"右半边"分别求 LCP / LCS(最长公共后缀)。
做法(对一个固定的"半长"
对每个
若
复杂度:每层
结论:Main-Lorentz 求全部平方串是
16.4 竞赛建议
Main-Lorentz 属于"知道结论能写、不知道写不出"的算法。它的代码在所有字符串算法里算最长的之一(约 100 行)。
实战建议:
- 若只需要"是否存在平方串"或"某个特定平方串的信息",用哈希 + 枚举(
O(n^2) 或O(n\sqrt n) )往往更快写对。 - 只有在
n\le10^5 且必须枚举全部平方串时才用 Main-Lorentz。 - P1117 的标准做法其实是 SA + 关键点(枚举平方串的"周期长度",用 LCP/LCS 定位),代码比 Main-Lorentz 短。
16.5 延伸阅读(更多同知识点题目)
相关例题 P1117 [NOI2016] 优秀的拆分(平方串计数的标准题,SA + 关键点) · CF319D Have You Ever Heard About the Word?(连续消除平方串) · P3546 [POI2012] PRE-Prefixuffix · P4199 万径人踪灭
16.6 这一节要带走的三句话
- Main-Lorentz 按"长度分治",跨中点的平方串用 LCP + LCS 定位。
- 总复杂度
O(n\log n) ,代码约 100 行。 - 实战中 SA + 关键点往往是更好写的选择。
17. 字符串算法库:封装与取舍
17.1 五个模板的取舍
| 结构 | 代码行数 | 常数 | 建议 |
|---|---|---|---|
| 哈希 + 二分 | 30 | 小 | 首选, |
| SA(倍增) | 40 | 中 | 需要"有序后缀"时用 |
| SAM | 40 | 中 | 子串统计首选 |
| 广义 SAM | 50 | 中 | 多串统计首选 |
| PAM | 35 | 小 | 回文首选 |
| 后缀树(Ukkonen) | 150+ | 大 | 尽量避免 |
| Main-Lorentz | 100+ | 大 | 仅在必要时 |
17.2 三个通用封装建议
建议一:SAM 用结构体封装,把 extend、parent 树累加、本质不同子串数都做成成员函数。
建议二:数组开 oldrk 要
建议三:字符集用"减去最小值"归一化。若字符集是 a-z 就用 c-'a';若是数字就用 c-'0';不要直接用 ASCII(浪费空间)。
例题对照:同一道题的五种写法
题意:给
五种解法对照(这是"算法库取舍"最直观的例子):
| 方法 | 代码行数 | 复杂度 | 常数 | 何时用 |
|---|---|---|---|---|
| SAM + Σ(len - len[link]) | ~40 | 小 | 首选 | |
| SA + Σ(n-sa[i]+1-height[i]) | ~50 | 中 | 已有 SA 时 | |
| 后缀树(Ukkonen) | ~150 | 大 | 不推荐 | |
哈希 + set 去重 |
~25 | — | 只适用于 |
|
| 哈希 + 二分 + 排序 | ~60 | 大 | 不想写 SAM 时 |
结论:同一道题,SAM 是最优选择;SA 是"顺手有"时的备选;后缀树理论上
这个对照说明什么:第 10 章的取舍表不是空话——它对应着真实可测的差距(代码行数 4 倍、常数 2 倍、复杂度差一个量级)。
17.3 这一节要带走的三句话
- 哈希 + 二分是万能备胎,先想它。
- 数组统一开
2n ,能避免 90% 的 RE。 - 后缀树与 Main-Lorentz 尽量不用,有替代品。
18. Trick 五维全览
18.0 先看索引
不想读全部条目时,按"你要解决什么"查这张表:
| 你要解决的问题 | 看哪条 |
|---|---|
| 要在 border 链上做查询(最长的、计数的、LCA) | A1 失配树 |
| 想求本质不同子串(不用 SAM) | A2 Z 函数或 SA |
| 判回文、求回文半径 | A3 双半径(Manacher 奇偶) |
| 哈希被卡了 | A4 多哈希与随机底数 |
| 求"两个数的最大异或" | A5 |
| 求"区间内的最大异或" | A6 可持久化 |
| 判循环同构 / 求最小表示 | A7 最小表示法 |
| 要比较两段子串的字典序 | A8 哈希 + 二分 LCP |
| KMP 太慢 / 想建自动机 | B1 两步优化 + KMP 自动机 |
| Trie 空间开不下 | B2 三种实现的取舍 |
| 多棵 |
B3 |
| 可持久化 Trie MLE / RE | B4 空间精确估算 |
| 哈希求 |
B5 前缀积批量求逆 |
| 禁串计数,长度到 |
C1 KMP + 矩阵快速幂 |
| 求"路径上数两两异或最大" | C2 |
| 判断两棵树是否同构 | C3 树哈希 |
| 回文划分计数 | C4 Manacher + DP |
| "某两段必须相等"的约束 | C5 Z + 并查集 |
| 把"前缀约束"变成"区间约束" | C6 Trie 的 DFS 序 |
| 多模式串计数 | C7 Trie + fail 树 |
| 代码 WA 了找不到原因 | E1–E14 |
18.1 A 维度:变体与推广
A1. 失配树(fail 树)
动机:题目要在"某个前缀的所有 border"上做查询——"最长的长度
直觉:fail[i] 定义了一棵以 fail[i])。
做法:
- 建出 fail 树,求每个节点的
depth(在树上的深度 = border 个数); - 求最长 border:就是
\text{fa}(i) ; - 求最长的长度
\le x 的 border:在i 到根的路径上找深度最大的、长度\le x 的祖先——用倍增或树剖 + 二分; - 两个前缀的最长公共 border:就是
\operatorname{LCA}(i,j) (在 fail 树上)。
为什么对:border 的包含关系是"链"——若
复杂度:建树
相关例题 P5829 【模板】失配树 · P2375 [NOI2014] 动物园 · P3435 [POI2006] OKR-Periods of Words · P3426 [POI2005] SZA-Template
A2. Z 函数求本质不同子串
动机:不想写 SAM,但要求本质不同子串数。
做法:把串
用 Z 函数的版本(逐位扩展):维护已出现过的子串集合(哈希表)。对每个起点
结论:求本质不同子串,实战推荐 SAM(
相关例题 P2408 不同子串个数 · P4070 [SDOI2016] 生成魔咒
A3. Manacher 的双半径(奇偶统一)
动机:Manacher 要在"奇回文"和"偶回文"两种情况下分别处理,写两遍容易错。
做法:在每两个字符之间插入一个不会出现的字符(如 #),把串长度变成
关键换算:原串中以为中心的奇回文半径
为什么是 #a#b#a#,长度
实例:abcba 插入分隔符得 #a#b#c#b#a#,以 c 为中心的回文半径(到边界)
相关例题 P3805 【模板】Manacher · P4555 [国家集训队] 最长双回文串 · P1659 [国家集训队] 拉拉队排练
A4. 多哈希与随机底数
动机:单哈希会被构造数据卡掉(见 2.5 节)。
三件套防御:
- 双哈希:两个不同的
(B_1,P_1) 、(B_2,P_2) ,只有两个都相等才判等。碰撞概率降到1/(P_1P_2) 量级。 - 随机底数:
B 在程序启动时用chrono随机生成,让出题人无法构造碰撞。 - 大质数模:用
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 串
动机:求"两两异或的最大值"。暴力
直觉:异或值的大小由最高不同位决定,所以"从高位往低位,尽量让这一位为 1"。
做法:把所有数按二进制从高到低插入
为什么对:高位权重大于所有低位之和(
复杂度:插入
注意 long long。
相关例题 P4551 最长异或路径 · P4735 最大异或和 · CF706D Vasiliy's Multiset · CF842D Vitya and Strange Lesson
A6. 可持久化 01 -Trie:区间约束
动机:多次询问"区间
做法:对每个前缀建一个 Trie 版本(像主席树)。版本 cnt[版本 r] - cnt[版本 l-1] 判断某分支在区间内是否存在。
为什么"版本差"表示区间:版本
空间:每次插入新增
相关例题 P4735 最大异或和 · P5283 [十二省联考2019] 异或粽子 · P4592 [TJOI2018] 异或
A7. 最小表示法与循环同构
动机:判两个串是否循环同构;或求"循环移位中字典序最小的那个"。
最小表示法(
- 若
s[i+k]<s[j+k] ,则j 及其之后k+1 个位置都不可能最优,j\leftarrow j+k+1 ; - 反之
i\leftarrow i+k+1 ; - 相等则
++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);
}
为什么
判循环同构:
相关例题 P1368 工艺 · P4051 [JSOI2007] 字符加密
A8. 哈希 + 二分求 LCP
动机:要比较两个后缀的字典序(SA 的倍增里要用),或求任意两段的 LCP。
做法:二分 LCP 长度
// 求 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;
}
复杂度:
与 SA + RMQ 的取舍:SA + ST 表是
相关例题 P3763 [TJOI2017] DNA · P4248 [AHOI2013] 差异
A9. fail 树子树求和
动机:AC 自动机统计每个模式串出现次数,沿 fail 链暴力跳是
直觉:
做法:文本串跑完后,按 BFS 序逆序做 cnt[fail[u]] += cnt[u]。
为什么对:见第 1.5 节定理 1.3 的证明。
复杂度:
相关例题 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 小
动机:求"所有子串中字典序第
做法:
- 算
f[u] = 从u 出发能走出的子串数(DP on DAG,按len 降序或拓扑序); - 若"同一子串在不同位置算多次",则
f[u] 还要乘上状态u 的出现次数; - 从根出发,按字典序遍历儿子,用
f 值决定走哪支。
注意:必须是 DAG 上的 DP——SAM 的转移图是有向无环的(因为
相关例题 P3975 [TJOI2015] 弦论 · SP7258 SUBLEX - Lexicographical Substring Search
A12. PAM 的 series link
动机:回文划分 DP 是
做法:在 fail 链上,把"
复杂度:
相关例题 P4287 [SHOI2011] 双倍回文 · P4762 [CERC2014] Virus synthesis
18.2 B 维度:优化手段
B1. KMP 的两步优化与 KMP 自动机
优化一:while 循环的均摊。朴素 KMP 里 j = fail[j] 可能跳多次,但总跳次数不超过总的 ++j 次数,所以整体
优化二: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]);
}
收益:单次转移从"平均
代价:空间
相关例题 P3193 [HNOI2008] GT考试 · P3082 [USACO13MAR] Necklace G
B2. Trie 三种实现的取舍
| 实现 | 空间 | 速度 | 适用 |
|---|---|---|---|
二维数组 ch[N][26] |
$O(\text{节点}\times | \Sigma | )$ |
map/unordered_map |
慢 3–5 倍 | 字符集大或节点稀疏 | |
| 左儿子右兄弟 | 慢 2–3 倍 | 空间极度紧张 |
判据:先估节点上界 map。
实测(脚本第 4 项):
相关例题 P2580 于是他错误的点名开始了 · P3879 [TJOI2010] 阅读理解 · P8306 【模板】字典树 · P5266 【深基17.例6】学籍管理 · P5755 [NOI2000] 单词查找树 · P5149 会议座位
B3. 01 -Trie 合并的均摊复杂度
结论:把
为什么:merge(u,v) 只在
在树上合并场景的推论:若每个叶子贡献
相关例题 P6072 『MdOI R1』Path · P6623 [省选联考2020A] 树
B4. 可持久化 Trie 的空间精确估算
公式:节点数
实例:
| 节点数 | ch[N][2] 内存(int) |
||
|---|---|---|---|
| 30 | 25.6 MB | ||
| 60 | 49.6 MB | ||
| 30 | 128 MB |
加上 cnt 数组(同样的节点数)后内存翻倍。int 而非 long long、省掉 cnt 改用 rt 差值)。
相关例题 P4735 最大异或和 · P5283 [十二省联考2019] 异或粽子 · P4592 [TJOI2018] 异或 · P4098 [HEOI2013] ALO · P4585 [FJOI2015] 火星商店问题 · P17170 未来
B5. 哈希的前缀积批量求逆
动机:需要
做法:同线性代数的批量逆元——前缀积 + 一次逆元 + 倒推。
收益:qpow 变成 qpow +
B6. AC 自动机 + 树状数组(DFS 序)
动机:动态询问"某模式串在文本的某个前缀中出现几次"(P2414)。
做法:把询问离线,挂到"文本的某个位置"上。遍历文本时,在 fail 树上对"当前状态"做单点加;询问"
为什么对:进入一个状态 = 在 fail 树上给该节点 +1;"
复杂度:
相关例题 P2414 [NOI2011] 阿狸的打字机 · P5840 [COCI2015] Divljak
B7. SAM 的 parent 树按 len 计数排序
动机:要在 parent 树上做子树累加,需要"按拓扑序"。
做法:
// 计数排序 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(
相关例题 P3804 【模板】后缀自动机 · P3975 [TJOI2015] 弦论 · P4070 [SDOI2016] 生成魔咒 · P5341 [TJOI2019] 甲苯先生和大中锋的字符串 · P3181 [HAOI2016] 找相同字符 · CF802I Fake News (hard)
B8. 后缀数组的常数优化
三条:
- 基数排序代替快排:每轮
O(n) 而非O(n\log n) 。 - 提前退出:
if (p == n) break;(所有排名已区分)。 memcpy只拷需要的部分:sizeof(int)*(n+w+1),不要整个数组。
实测:
相关例题 P3809 【模板】后缀排序 · P2852 [USACO06DEC] Milk Patterns G · P4051 [JSOI2007] 字符加密 · P2870 [USACO07DEC] Best Cow Line G · P4248 [AHOI2013] 差异
B9. 哈希 + 二分代替 SA
动机:只想求"任意两段的 LCP",不想写 SA。
做法:哈希
取舍:SA + ST 表是
18.3 C 维度:与其他算法的结合
相关例题 P5431 【模板】乘法逆元 2 · P4503 [CTSC2014] 企鹅 QQ · P3370 【模板】字符串哈希
C1. KMP 自动机 + 矩阵快速幂
动机:求"长度为
做法:
- 建 KMP 自动机,状态
0..m-1 (m=|s| ); - 转移矩阵
M_{ij}= "从状态i 读一个字符转到j 的字符数"(转移到m 的边删掉); - 答案
=\sum_j(M^n)_{0,j} 。
为什么对:
复杂度:
相关例题 P3193 [HNOI2008] GT考试 · P4052 [JSOI2007] 文本生成器* · P3311 [SDOI2014] 数数
C2. 01 -Trie + 线性基:路径异或
动机:树上路径的异或最大值/计数。
做法一(Trie):先 DFS 求每个点到根的异或和
做法二(线性基):若是"路径可以重复走"(P4151),则答案是"一条固定路径的异或和 ⊕ 若干环的异或和",用线性基。
这两个做法的区别:Trie 处理"路径不重复"(树上路径唯一),线性基处理"路径可以重复"(有环可选)。
相关例题 P4551 最长异或路径 · P4151 [WC2011] 最大XOR和路径
C3. 树哈希
动机:判断两棵有根树是否同构。
做法:给每个节点算一个哈希值
其中
更稳的写法:把所有儿子的哈希排序后按顺序拼起来再哈希(这样不依赖"儿子顺序")。
无根树:先找重心(最多两个),以重心为根算哈希,取较小(或两个都算)。
相关例题 P5043 【模板】树同构 · P4323 [JSOI2016] 独特的树叶
C4. Manacher + DP:回文划分
动机:求"把串划分成若干回文串"的方案数,或"最少划分几段"。
做法:设
用 Manacher 求出所有极长回文,再对每个中心向两边扩展……朴素是
优化到
相关例题 P4287 [SHOI2011] 双倍回文 · P4555 [国家集训队] 最长双回文串
C5. Z 函数 + 并查集
动机:题目给若干"这两段必须相等"的约束,问最终有多少种本质不同的串。
做法:对每个约束
优化:用 Z 函数预处理出"哪些位置开始的段相等",然后用并查集跳过已经合并过的位置(每个位置只被合并一次)。总
这是"区间合并"的经典套路:并查集的 find 指向"下一个还没合并的位置"。
相关例题 P3294 [SCOI2016] 背单词 · CF827E Rusty String
C6. Trie 的 DFS 序:前缀约束变区间约束
动机:题目要在"以某前缀开头的所有串"上做区间操作(求和、修改)。
做法:对 Trie 做 DFS,记录每个节点的 tin/tout。则"
为什么对:Trie 的子树恰好是"以该节点代表的前缀开头的所有串"(由 Trie 的核心性质)。
相关例题 P5840 [COCI2015] Divljak · P3311 [SDOI2014] 数数
C7. Trie + fail 树:多模式串计数
动机:AC 自动机上统计每个模式串出现次数。
做法:文本串跑一遍 AC 自动机,在每个到达的状态上 ++cnt。然后按 fail 树的拓扑逆序(深度从深到浅)做 cnt[fail[u]] += cnt[u]。
为什么对:状态
相关例题 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):拼接后二分答案
取舍:广义 SAM 代码短;SA 更容易处理"每个串至少出现一次"这类附加条件。
相关例题 P5546 [POI2000] 公共串 · P2463 [SDOI2008] Sandy 的卡片* · SP1811 LCS - Longest Common Substring
C9. 在线 SAM + LCT
动机:带修改的"本质不同子串数"(P5212)。
做法:每次 extend 时,答案的变化量是
竞赛地位:极难,属于 NOI 压轴级别。
相关例题 P5212 [ZJOI2017] 字符串
C10. SA + 关键点:求平方串
动机:求所有
做法:枚举周期长度
- 以
i+L 为中心向左的 LCS(与i 对齐); - 以
i+L 为中心向右的 LCP。
若 LCS + LCP
复杂度:
这比 Main-Lorentz 好写得多,是 P1117 的标准做法。
相关例题 P1117 [NOI2016] 优秀的拆分 · CF319D Have You Ever Heard About the Word?
C11. 字典序第 k 小后缀
做法:SA 已经排好序,直接取
相关例题 P2408 不同子串个数 · P3975 [TJOI2015] 弦论 · SP7258 SUBLEX - Lexicographical Substring Search · P3809 【模板】后缀排序
C12. 树上字符串问题:广义 SAM
动机:给一棵树,每个节点有一个字符,问"所有路径形成的字符串中有多少种本质不同"(P3346)。
做法:把树当成 Trie(从每个叶子开始 DFS,把路径当字符串插入),建广义 SAM(BFS 版)。注意:要从所有度数为 1 的点(叶子)出发,因为路径不一定要经过根。
相关例题 P3346 [ZJOI2015] 诸神眷顾的幻想乡
18.4 D 维度:经典题型与模型
第一卷(基础与匹配)的题型
| 模型 | 题面信号 | 工具 | 复杂度 | 代表题 |
|---|---|---|---|---|
| 单模式匹配 | "求模式串出现位置" | KMP | P3375 | |
| 多模式匹配 | "多个模式串" | AC 自动机 | P3808、P3796 | |
| 子串比较 | "两个子串是否相同" | 哈希 | — | |
| 最长回文子串 | "最长回文" | Manacher | P3805 | |
| 回文子串计数 | "有多少个回文子串" | Manacher / PAM | P1659 | |
| 最小循环节 | "最小重复单元" | KMP fail | P4391 | |
| 前缀统计 | "以 p 为前缀的串数" | Trie | P2580、P2922 | |
| 最大异或对 | "两两异或最大" | P4551 | ||
| 区间最大异或 | "区间内与 x 异或最大" | 可持久化 |
P4735 | |
| 树上路径异或 | "路径异或和" | P4551 | ||
| 禁串计数 | "不含模式串的长度 n 串数" | KMP 自动机 + 矩阵 | P3193 | |
| 循环同构判定 | "循环移位相同" | 最小表示法 / KMP | P1368 | |
| 本质不同子串 | "不同子串个数" | SAM / SA | P2408 | |
| 失配树查询 | "border 上的查询" | 失配树 + 倍增 | P5829 | |
| 树同构 | "两棵树是否相同" | 树哈希 | P5043 | |
| 回文划分 | "划分成若干回文串" | Manacher + DP | P4287 |
第二卷(自动机与后缀结构)的题型
| 模型 | 题面信号 | 工具 | 复杂度 | 代表题 |
|---|---|---|---|---|
| 多模式匹配 | "多个模式串" | AC 自动机 | P3808、P3796 | |
| 模式串出现次数 | "每个出现了几次" | AC + fail 树 | P5357 | |
| 禁串计数 | "不含模式串的长度 |
AC + 矩阵/DP | P4052、P3311 | |
| 后缀排序 | "按字典序排后缀" | SA | P3809 | |
| 本质不同子串 | "不同子串个数" | SAM / SA | P2408 | |
| 子串出现次数 | "某子串出现几次" | SAM parent 树 | P3804 | |
| 第 |
"字典序第 |
SAM + DAG DP | P3975 | |
| 多串公共子串 | "最长公共子串" | 广义 SAM / SA+二分 | P5546、P2463 | |
| 回文计数 | "回文子串个数" | PAM / Manacher | P3649、P1659 | |
| 回文划分 | "划分成若干回文" | PAM + series link | P4287 | |
| 平方串 | " |
SA + 关键点 | P1117 | |
| 循环同构 | "循环移位相同" | 最小表示法 | P1368 | |
| 本质不同子序列 | "不同子序列个数" | 子序列自动机 + DP | — | |
| 字典序最小表示 | "最小循环移位" | 双指针 | P1368 |
18.5 E 维度:常见错误与边界
- E1. KMP 的
fail[1]未初始化为 0。 全局数组默认 0 时没问题,但多组数据不memset就会带上一组的值。 - E2. KMP 的失配循环写成
if。while (j && s[j+1] != s[i]) j = fail[j];必须是while,因为可能连跳多次。 - E3. 哈希的取模减法忘了补正。
(h[r] - h[l-1]*pw[len]) % P在 C++ 里可能是负数,必须(x % P + P) % P。 - E4. 哈希的底数用偶数或小质数。
B=2 或B=10 极易碰撞。用131 、13331 或随机大奇数。 - E5. Manacher 的分隔符与字符集冲突。 插入的
#必须是"原串不会出现的字符"。补成#a#b#后原串长度2n+1 ,数组要开到2n+2 。 - E6. Manacher 的回文半径换算错。 新串半径
R 对应原串长度R-1 ,不是R 也不是2R-1 。 - E7. Trie 的根编号用 0。 必须用 1,否则
ch[u][c]==0无法区分"没有儿子"与"儿子是根"。 - E8. Trie 的
N按n 开而不是nL 。 节点数上界是nL+1 。 - E9. Trie 的
cnt与end混用。cnt是"经过的串数"(前缀计数),end是"恰好在此结束的串数"(完整串计数)。 - E10.
01 -Trie 的LG 开小。 数值\le10^{18} 时要用LG=60且类型long long。 - E11. 可持久化 Trie 数组开小。 节点数是
n(LG+1) ,n=10^5 、LG=30 时要3.2\times10^6 。 - E12. 可持久化 Trie 的版本 0 未初始化。 要先建一个空版本
rt[0],否则cnt差算错。 - E13. 多组数据不清空 Trie。 用
tot=1重置并清空新建节点的ch;或改用vector+clear()。 - E14. 最小表示法的
i==j未处理。 若i与j撞到一起要++j,否则死循环。
第二卷(自动机与后缀结构)
- E15. SA 的
oldrk只开n 。 访问oldrk[i+w]会越界。必须开2n 并清零(越界位读 0 正好符合"空后缀最小")。 - E16. SA 的
memcpy长度漏了w 。 要sizeof(int)*(n+w+1)。 - E17. SA 未提前退出。 忘了
if (p==n) break会多跑几轮,常数翻倍。 - E18. SAM 的状态数组只开
n 。 上界是2n-1 ,必须开2n 。 - E19. SAM 的 clone 忘记复制转移。
ch[clone][*] = ch[q][*]漏了就全错。 - E20. SAM 求出现次数时把 clone 也算了 occ。 clone 不产生新结束位置,
occ[clone]应为 0。 - E21. SAM 的 parent 树用节点编号顺序累加。 必须按
len 降序。 - E22. 广义 SAM 直接重置
last。 这是最常见的严重错误。 - E23. PAM 的奇根
len写成 0。 必须是-1 。 - E24. PAM 的
fail[cur]在len=1 时不特判。 单字符回文的 fail 应指向偶根(0),不特判会指错。 - E25. AC 自动机的
fail[root]未设为 0 或根。 会导致跳 fail 时死循环。 - E26. AC 自动机统计次数用节点编号逆序。 必须用 BFS 序逆序。
18.6 Trick 总表
第一卷(基础与匹配)的 Trick 总表
| Trick | 维度 | 一句话要点 | 例题 |
|---|---|---|---|
| 失配树 | A1 | fail 树上的路径 = border 链 | P5829、P2375 |
| Z 求本质不同子串 | A2 | 实战推荐 SAM/SA | P2408 |
| Manacher 双半径 | A3 | 插入分隔符,原长 |
P3805、P4555 |
| 多哈希 + 随机底数 | A4 | 双哈希把碰撞概率降到 |
— |
| A5 | 高位贪心走反边 | P4551、CF706D | |
| 可持久化 |
A6 | 版本差表示区间 | P4735、P5283 |
| 最小表示法 | A7 | 双指针, |
P1368 |
| 哈希 + 二分 LCP | A8 | P3763 | |
| KMP 自动机 | B1 | 预处理 trans[i][c] 严格 |
P3193 |
| Trie 实现取舍 | B2 | $ | \Sigma |
| B3 | 每节点只被消耗一次 | P6072 | |
| 可持久化空间估算 | B4 | — | |
| 哈希批量求逆 | B5 | 前缀积 + 一次逆元 | — |
| KMP + 矩阵 | C1 | 状态封装匹配长度 | P3193、P4052 |
| 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 | P5357、P2414 | |
| 广义 SAM 两种建法 | A10 | 特判已有转移 / BFS 建 Trie | P6139、P3346 |
| SAM 第 |
A11 | 按 |
P3975 |
| PAM series link | A12 | P4287、P4762 | |
| AC + 树状数组 | B6 | 进入状态 = 单点加,子树和 = 区间和 | P2414 |
| SAM 按 |
B7 | — | |
| SA 常数优化 | B8 | 基数排序 + 提前退出 | P3809 |
| 哈希 + 二分 LCP | B9 | 15 行代码, |
P3763 |
| 多串公共子串 | C8 | 广义 SAM / SA+二分+单调队列 | P5546、P2463 |
| 在线 SAM + LCT | C9 | 维护 parent 树路径信息 | P5212 |
| SA + 关键点求平方串 | C10 | 枚举周期长度 + LCP/LCS | P1117 |
| 第 |
C11 | 直接取 |
— |
| 树上广义 SAM | C12 | 从每个叶子 DFS 建 Trie | P3346 |
19. 题单总索引
全书的例题与习题按知识点分布在各章末尾的「习题池」里。这一章是横切面:按知识点索引、按经典程度排序、扩充补漏。
19.1 按知识点索引
第一卷(基础与匹配)
| 知识点 | 位置 | 例题 | 习题池 |
|---|---|---|---|
| 字符串哈希 | §2 | 1 | 14 道 |
| 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 | 最长异或路径 | 普及+/提高 | |
| 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 | 最大异或和 | 可持久化 |
省选/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 第 |
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 | 合并公共前缀 | §3 | |
| KMP | border 链 + fail 树 | §4 | |
| Z 函数 | 最右匹配段 + 镜像 | §5 | |
| Manacher | 镜像复用回文半径 | §6 | |
| AC 自动机 | 多串 Trie + fail | §8 | |
| 后缀数组 | 把后缀排好序 | §9 | |
| 后缀自动机 | 按 endpos 分类所有子串 | §10 | |
| 广义 SAM | 多串的 SAM | §11 | |
| 回文自动机 | 每个节点一个本质不同回文 | §12 | |
| 子序列自动机 | 下一个字符位置表 | §13 | |
| 后缀树 | 压缩后缀 Trie | §14 | |
| 后缀平衡树 | 按字典序动态维护后缀 | §14 | |
| Lyndon 分解 | 唯一的 Lyndon 串降序分解 | — | §15 |
| Main-Lorentz | 分治求全部平方串 | — | §16 |
20.2 五条贯穿全书的主线
主线一:不重复读字符。 这是所有匹配算法的出发点。朴素匹配每次失配都从模式串头重来,浪费了"刚才比过的那一段"。哈希把整段压成一个数、KMP 把"已匹配长度"记成状态、Z 函数维护最右匹配段、Manacher 复用镜像半径——四者都在用"左边已算好的信息"避免重读。它们的复杂度论证也是同一个:某个指针单调推进,总推进量
主线二:子串 = 后缀的前缀。 这一句把
主线三:四棵 fail 树是同构的。 KMP 的 fail 树、AC 自动机的 fail 树、SAM 的 parent 树、PAM 的 fail 树,都在做同一件事——把"后缀包含关系"编码成树上的祖先关系。于是 LCA、子树和、DFS 序 + 树状数组这几种工具全部通用。这是本书最值得记的一条。
主线四:所有状态数上界都来自"每次最多新增常数个"。 SAM 的状态数
主线五:哈希 + 二分是万能备胎。 不想写 SA/SAM 时,
20.3 字符串题真正的难点在两处
第一处:转化。 P4287 的"双倍回文 = 回文 + 左半回文"、CF1200E 的"最大重叠 = KMP 停在末尾的
第二处:选型。 同一个问题用哈希、KMP、Z 都能做,但代价与代码量差很多。§0.5 的决策表值得反复看——它的价值不在记住结论,而在养成"先问要什么,再选工具"的习惯。
20.4 已知边界与深入方向
本书未深入的内容:
- 后缀树的
O(n) 构造(Ukkonen):只给了定义与性质。实战多用 SAM parent 树或 SA 替代。 - Main-Lorentz 的完整实现:只讲了思想与复杂度,未给完整代码。
O(n) 后缀数组构造(SA-IS / DC3):只讲了O(n\log n) 的倍增法。SA-IS 在n\ge10^6 且卡常时有用。- Runs / 平方串计数:Lyndon 分解的理论延伸,本书只到"求全部平方串的位置"。
- 字符串与其他领域的交叉:如"字符串 + 网络流""字符串 + 计算几何"。