AIGC标识 字符串基础


0. 地图

结构 结构化了什么 典型询问
哈希 子串相等 任意两子串比较、LCP 二分
KMP / Border 前缀的周期结构 单模匹配、周期、失配树
Z 函数 每个后缀与整串的 LCP 匹配、前缀出现
Manacher 每个中心的最长回文 回文子串计数
AC 自动机 多模式串的后缀关系(fail 树) 多模匹配、自动机上 DP
后缀数组 所有后缀的字典序 + 相邻 LCP 不同子串、LCS、第 \(k\) 小子串
后缀自动机 所有子串的 endpos 等价类(parent 树) 出现次数、在线子串、多串 LCS
回文自动机 所有本质不同回文串 回文出现次数、回文划分
Lyndon 分解 字典序意义下的分解 最小后缀、最小表示

1. 字符串哈希

1.1 多项式哈希

\[H(s) = \sum_{i=1}^{n}s_i\,B^{n-i} \bmod M,\qquad H(s[l..r]) = H_r - H_{l-1}\cdot B^{r-l+1} \]

1.2 安全性

  • 自然溢出(\(M = 2^{64}\))会被卡:Thue–Morse 串对任何奇数底数 \(B\) 都能构造碰撞。
  • 单模 \(10^9+7\):生日攻击,\(\sqrt M \approx 3\times10^4\) 个串就有碰撞风险。
  • 推荐:双模(\(10^9+7\) 与 \(998244353\)),或单模 \(2^{61}-1\)(用 __int128 乘),底数随机。

1.3 哈希能做的"万能"事

  • LCP:二分长度 + 哈希比较,\(O(\log n)\)。
  • 后缀排序 \(O(n\log^2 n)\):比较两个后缀 = LCP + 下一字符。
  • 回文判定:正反两个哈希。
  • 循环同构:\(s\) 与 \(t\) 循环同构 \(\iff\) \(t\) 是 \(ss\) 的子串。

哈希是所有字符串题的暴力基线。 先想清楚哈希能不能做,再考虑专用结构。


2. Border 与周期理论

2.1 定义

  • \(s\) 的 border:既是前缀又是后缀的真子串(长度 \(< n\))。
  • \(p\) 是 \(s\) 的周期:\(s_i = s_{i+p}\) 对所有合法 \(i\)。
  • 基本事实:\(p\) 是周期 \(\iff\) \(n - p\) 是 border 长度。

2.2 KMP 与 next 数组

\(\text{nxt}[i]\) = 前缀 \(s[1..i]\) 的最长 border 长度。

nxt[1] = 0;
for (int i = 2, j = 0; i <= n; i++) {
    while (j && s[i] != s[j + 1]) j = nxt[j];
    if (s[i] == s[j + 1]) j++;
    nxt[i] = j;
}

均摊 \(O(n)\):\(j\) 每次至多增加 1,while 每次至少减少 1。

前缀 \(i\) 的所有 border:\(\text{nxt}[i], \text{nxt}[\text{nxt}[i]], \dots\)。最小周期 \(= n - \text{nxt}[n]\)(若整除 \(n\) 则是循环节)。

2.3 失配树(fail 树)

把 \(i \to \text{nxt}[i]\) 连边,得到以 0 为根的树。前缀 \(i\) 的 border 集合 = \(i\) 到根的路径。

例题:洛谷 P5829 【模板】失配树。求前缀 \(p\) 和前缀 \(q\) 的最长公共 border:即 fail 树上 \(\text{LCA}(p, q)\),但 LCA 若等于 \(p\) 或 \(q\) 本身要再跳一步(border 必须是真子串)。

2.4 周期引理(Fine–Wilf)

若 \(p, q\) 都是 \(s\) 的周期且 \(p + q - \gcd(p, q) \le n\),则 \(\gcd(p, q)\) 也是周期。

弱形式(\(p + q \le n\))的证明:对任意位置 \(i\),若 \(i + p - q \ge 1\) 或 \(i - p + q \le n\) 之一成立(由 \(p + q \le n\) 保证至少一个成立),可以走 \(+p\) 再 \(-q\)(或反之)得 \(s_i = s_{i+p-q}\),所以 \(|p - q|\) 是周期。对 \((p, q)\) 做辗转相减即得。

2.5 Border 的结构定理

\(s\) 的所有 border 长度,按大小排序后可以划分成 \(O(\log n)\) 个等差数列。

理由:长度 \(\ge n/2\) 的 border 对应周期 \(\le n/2\),由周期引理它们的周期都是最小周期 \(p_0\) 的倍数,形成一个等差数列(公差 \(p_0\))。长度 \(< n/2\) 的 border 是"\(s\) 的最长的、长度 \(< n/2\) 的 border"\(t\) 的 border,递归到 \(t\),长度至少减半。

应用:

  • 前缀 \(i\) 的 border 数:fail 树深度。
  • [NOI2014] 动物园(洛谷 P2375):\(\text{num}[i]\) = 前缀 \(i\) 中长度 \(\le i/2\) 的 border 个数。维护第二个指针 \(j\),每步在正常 KMP 之后额外 while (j * 2 > i) j = nxt[j],\(\text{num}[i] = \text{dep}[j]\)(fail 树深度,含自身)。均摊仍 \(O(n)\)。
  • CF 1286E / P4482 Border 的四种求法:把 border 按等差数列分段后,每段可以用一次 \(O(1)\) 或 \(O(\log)\) 的查询处理,总 \(O(n\log n)\)。

2.6 Z 函数(扩展 KMP)

\(z[i]\) = \(s\) 与 \(s[i..n]\) 的 LCP 长度。维护当前最右的匹配段 \([l, r]\):

z[1] = n;
for (int i = 2, l = 0, r = 0; i <= n; i++) {
    if (i <= r) z[i] = min(z[i - l + 1], r - i + 1);
    while (i + z[i] <= n && s[z[i] + 1] == s[i + z[i]]) z[i]++;
    if (i + z[i] - 1 > r) l = i, r = i + z[i] - 1;
}

\(O(n)\):\(r\) 单调不减,while 每次成功都让 \(r\) 增大。

匹配:对 \(t\#s\) 求 Z,\(z[|t| + 1 + i] = |t|\) 处即匹配。与 KMP 的区别:KMP 给"每个前缀的最长 border",Z 给"每个后缀与整串的 LCP",两者可以互推但各有方便的场合。模板:洛谷 P5410。


3. Manacher

3.1 算法

在字符间插入 #(首尾加哨兵),使所有回文变成奇回文。\(p[i]\) = 以 \(i\) 为中心的最长回文半径(含中心)。维护最右回文的右端 \(r\) 及其中心 \(\text{mid}\):

// t = "^#a#b#a#$",m = strlen(t)
for (int i = 1, mid = 0, r = 0; i < m - 1; i++) {
    p[i] = i < r ? min(p[2 * mid - i], r - i) : 1;
    while (t[i + p[i]] == t[i - p[i]]) p[i]++;
    if (i + p[i] > r) r = i + p[i], mid = i;
}
// 原串中以 t[i] 为中心的最长回文长度 = p[i] - 1

\(O(n)\):同 Z 函数,\(r\) 单调不减。

3.2 应用

  • 回文子串总数:\(\sum\lfloor p[i]/2\rfloor\)(奇偶合并后)。
  • 每个位置作为回文左端点 / 右端点的最长回文:由 \(p\) 数组差分或倒序取 \(\max\) 得到。
  • 最长双回文串(洛谷 P4555):\(\max_i(\text{以 } i \text{ 结尾的最长回文} + \text{以 } i+1 \text{ 开头的最长回文})\)。

4. AC 自动机

4.1 构造

Trie 上每个节点的 fail 指向"它代表的串的最长真后缀中,在 Trie 里存在的那个"。BFS 逐层构造,并顺便补全转移(Trie 图):不存在的儿子指向 fail 的对应儿子。

int tr[N][26], fail[N], cnt[N], tot;
void insert(const string& s) {
    int p = 0;
    for (char ch : s) { int c = ch - 'a'; if (!tr[p][c]) tr[p][c] = ++tot; p = tr[p][c]; }
    cnt[p]++;
}
void build() {
    queue<int> q;
    for (int c = 0; c < 26; c++) if (tr[0][c]) q.push(tr[0][c]);
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int c = 0; c < 26; c++)
            if (tr[u][c]) fail[tr[u][c]] = tr[fail[u]][c], q.push(tr[u][c]);
            else tr[u][c] = tr[fail[u]][c];
    }
}

4.2 fail 树

\(u \to \text{fail}[u]\) 构成一棵树。fail 树上 \(u\) 的子树 = 所有以 \(u\) 代表的串为后缀的 Trie 节点。

多模匹配计数的正确做法:文本串在 Trie 图上走,每到一个节点 \(v\) 给 \(v\) 打一个标记(\(+1\))。不要沿 fail 链跳(会退化到 \(O(|T|\cdot\text{模式串总长})\))。最后在 fail 树上做子树求和,节点 \(u\) 的子树和就是模式串 \(u\) 的出现次数。\(O(|T| + \sum|P_i|)\)。模板:洛谷 P5357。

4.3 AC 自动机上的 DP

状态 \((\text{自动机节点},\ \text{已走步数},\ \dots)\)。"不能包含任何模式串":预处理每个节点是否"危险"(自身是终点,或 fail 链上有终点,构造时 danger[u] |= danger[fail[u]]),DP 时不走危险节点。

  • [JSOI2007] 文本生成器(洛谷 P4052):长 \(m\) 的串中至少含一个模式串的数量 $= 26^m - $ 不含任何模式串的数量。
  • [POI2000] 病毒(洛谷 P2444):是否存在无限长的串不含模式串 \(\iff\) 非危险节点构成的图有环。
  • [USACO12JAN] Video Game(洛谷 P3041):最大化出现次数之和,节点权值 = fail 树上祖先中终点数。

4.4 AC 自动机 + 数据结构

"动态加入模式串后查询":AC 自动机不支持在线插入,用二进制分组(数据结构讲义 III.5),CF 710F。
"查询某模式串在某文本前缀中的出现次数":离线,fail 树 DFS 序 + 树状数组([NOI2011] 阿狸的打字机,洛谷 P2414)。


5. 后缀数组

5.1 定义

\(\text{sa}[i]\) = 排名第 \(i\) 的后缀起始位置;\(\text{rk}[i]\) = 后缀 \(i\) 的排名;\(\text{height}[i] = \text{LCP}(\text{sa}[i-1], \text{sa}[i])\)。

5.2 倍增构造 \(O(n\log n)\)

第 \(k\) 轮按 \((\text{rk}[i], \text{rk}[i+k])\) 双关键字基数排序,得到长度 \(2k\) 前缀的排名。

int n, sa[N], rk[N << 1], old[N << 1], id[N], cnt[N];
char s[N];
void buildSA() {
    int m = 127, p;
    for (int i = 1; i <= n; i++) cnt[rk[i] = s[i]]++;
    for (int i = 2; i <= m; i++) cnt[i] += cnt[i - 1];
    for (int i = n; i; i--) sa[cnt[rk[i]]--] = i;
    for (int k = 1; k < n; k <<= 1, m = p) {
        p = 0;
        for (int i = n - k + 1; i <= n; i++) id[++p] = i;           // 第二关键字为空的排最前
        for (int i = 1; i <= n; i++) if (sa[i] > k) id[++p] = sa[i] - k;
        memset(cnt, 0, sizeof(int) * (m + 1));
        for (int i = 1; i <= n; i++) cnt[rk[i]]++;
        for (int i = 2; i <= m; i++) cnt[i] += cnt[i - 1];
        for (int i = n; i; i--) sa[cnt[rk[id[i]]]--] = id[i];       // 按第一关键字稳定排序
        memcpy(old, rk, sizeof(int) * (n + 1));
        p = 0;
        for (int i = 1; i <= n; i++)
            rk[sa[i]] = (old[sa[i]] == old[sa[i - 1]] && old[sa[i] + k] == old[sa[i - 1] + k]) ? p : ++p;
        if (p == n) break;
    }
}

\(\text{rk}\) 数组开两倍,越界处为 0。SA-IS 可做到 \(O(n)\),竞赛中倍增足够。模板:洛谷 P3809。

5.3 height 数组

引理:\(\text{height}[\text{rk}[i]] \ge \text{height}[\text{rk}[i-1]] - 1\)。(后缀 \(i-1\) 与其前一名的 LCP 去掉首字符,就是后缀 \(i\) 与某个排名更靠前的后缀的 LCP。)

void buildHeight() {
    for (int i = 1, k = 0; i <= n; i++) {
        if (rk[i] == 1) { k = 0; continue; }
        if (k) k--;
        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)\)。

5.4 核心性质

\[\text{LCP}(\text{sa}[i], \text{sa}[j]) = \min_{i < k \le j}\text{height}[k]\qquad(i < j) \]

所以任意两后缀的 LCP 是 height 上的 RMQ。排名相近的后缀 LCP 大,这是所有 SA 应用的出发点。

5.5 应用

问题 做法
本质不同子串数 \(\frac{n(n+1)}{2} - \sum\text{height}[i]\)
最长重复子串 \(\max\text{height}\)
可重叠出现 \(\ge k\) 次的最长子串 height 连续 \(k-1\) 个的最小值的最大值
不可重叠重复子串 二分长度 \(L\),按 \(\text{height} \ge L\) 分组,组内 \(\max\text{sa} - \min\text{sa} \ge L\)
两串最长公共子串 拼接(中间加分隔符),相邻且来自不同串的 height 最大值
多串(\(\ge k\) 个)公共子串 拼接 + 二分长度 + 分组检查来源种类
字典序第 \(k\) 小子串 按 sa 顺序,后缀 \(\text{sa}[i]\) 贡献 \(n - \text{sa}[i] + 1 - \text{height}[i]\) 个新子串
循环串排序 把 \(s\) 复制一遍再排序([JSOI2007] 字符加密,P4051)
\(\sum_{i<j}\text{LCP}(i,j)\) height 上单调栈([AHOI2013] 差异,P4248)
子串比较 / 子串的 LCP RMQ + 比较下一字符

[NOI2015] 品酒大会(洛谷 P2178):对每个 \(r\) 求 LCP \(\ge r\) 的后缀对数与其权值乘积最大值。按 height 从大到小合并相邻排名(并查集维护集合大小、最大、次大、最小、次小),答案对 \(r\) 做后缀 \(\max\)。


6. 后缀自动机

6.1 endpos 与 parent 树

子串 \(t\) 的 \(\text{endpos}(t)\) = \(t\) 在 \(s\) 中所有出现的结束位置集合。

关键事实:

  1. 两个子串 endpos 相同或不交或包含。若 \(\text{endpos}(u) \subsetneq \text{endpos}(v)\),则 \(v\) 是 \(u\) 的后缀。
  2. endpos 相同的子串构成一个等价类,它们是一段连续长度的、互为后缀的串:\([\text{minlen}, \text{maxlen}]\)。
  3. 等价类按 endpos 的包含关系构成树(parent 树):父亲的 endpos 是儿子的超集,父亲的串是儿子的后缀,\(\text{minlen}(u) = \text{maxlen}(\text{link}(u)) + 1\)。

SAM 的状态 = endpos 等价类;转移 = 在串后加一个字符;link = parent 树的父边。

6.2 构造

struct SAM {
    int len[N << 1], link[N << 1], nxt[N << 1][26], cnt[N << 1], sz, last;
    void init() { sz = 1; last = 0; link[0] = -1; }
    void extend(int c) {
        int cur = sz++; len[cur] = len[last] + 1; cnt[cur] = 1;
        int p = last;
        while (p != -1 && !nxt[p][c]) nxt[p][c] = cur, p = link[p];
        if (p == -1) link[cur] = 0;
        else {
            int q = nxt[p][c];
            if (len[p] + 1 == len[q]) link[cur] = q;
            else {
                int cl = sz++;
                len[cl] = len[p] + 1; link[cl] = link[q]; cnt[cl] = 0;
                memcpy(nxt[cl], nxt[q], sizeof nxt[q]);
                while (p != -1 && nxt[p][c] == q) nxt[p][c] = cl, p = link[p];
                link[q] = link[cur] = cl;
            }
        }
        last = cur;
    }
};

为什么要 clone:\(q\) 的 endpos 需要加入新位置 \(n\),但只有 \(q\) 中长度 \(\le \text{len}(p) + 1\) 的那些串才真的以 \(n\) 结尾(更长的串在位置 \(n\) 处不出现)。所以把 \(q\) 拆成两个等价类。

状态数 \(\le 2n - 1\),转移数 \(\le 3n - 4\)。 构造均摊 \(O(n)\)(对固定字母表)。

6.3 应用

问题 做法
子串判定 从根走转移
本质不同子串数 \(\sum_u(\text{len}(u) - \text{len}(\text{link}(u)))\),在线可维护([SDOI2016] 生成魔咒,P4070)
子串出现次数 \(\text{cnt}(u)\) = parent 树子树中非 clone 节点数(按 len 计数排序后自底向上累加)
字典序第 \(k\) 小子串 DAG 上 DP 每个状态可到达的子串数,逐位确定([TJOI2015] 弦论,P3975)
两串最长公共子串 对 \(s\) 建 SAM,\(t\) 在上面走:失配时沿 link 回退,成功时长度 \(+1\)
endpos 集合本身 parent 树上线段树合并(每个非 clone 叶子一个位置)
某子串在区间 \([l,r]\) 内的出现次数 定位子串所在状态(parent 树倍增),查它的 endpos 线段树中 $[l +
最小循环表示 对 \(ss\) 建 SAM,贪心走 \(n\) 步取最小字符
后缀树 反串的 SAM 的 parent 树即原串的后缀树

定位子串 \(s[l..r]\) 所在状态:先找到前缀 \(r\) 对应的状态(构造时记录),然后在 parent 树上倍增跳到 \(\text{len}(\text{link}) < r - l + 1 \le \text{len}\) 的祖先。

[HEOI2016] 字符串(洛谷 P4094):\(s[a..b]\) 的所有子串与 \(s[c..d]\) 的最长公共前缀。二分长度 \(L\),定位 \(s[c..c+L-1]\) 的状态,查其 endpos 是否在 \([a + L - 1, b]\) 内有元素(线段树合并 + 可持久化或倍增)。

6.4 广义 SAM(多串)

在线版:每插入一个新串把 last 重置为根。需要处理两个特判:

int extend(int last, int c) {
    if (nxt[last][c]) {                                   // 转移已存在
        int q = nxt[last][c];
        if (len[q] == len[last] + 1) return q;            // 直接复用
        int cl = sz++;                                    // 只 clone,不新建 cur
        len[cl] = len[last] + 1; link[cl] = link[q]; memcpy(nxt[cl], nxt[q], sizeof nxt[q]);
        int p = last;
        while (p != -1 && nxt[p][c] == q) nxt[p][c] = cl, p = link[p];
        link[q] = cl;
        return cl;
    }
    // 否则同普通 extend
}

离线版:把所有串插进 Trie,BFS 遍历 Trie,对每个 Trie 节点以其父节点对应的 SAM 状态为 last 做 extend。必须 BFS,DFS 会因为 last 的 len 不对导致状态数错误。

应用:多串最长公共子串(每个状态记录被哪些串到达,parent 树上传)、[USACO17DEC] Standing Out from the Herd(P4081,只属于一个串的子串数)、[ZJOI2015] 诸神眷顾的幻想乡(P3346,以每个叶子为根的 Trie 插入广义 SAM)。

6.5 SAM 与 SA 的选择

SAM SA
在线 是 否
空间 \(O(n|\Sigma|)\) \(O(n)\)
字典序相关 需 DAG DP 天然
endpos / 出现位置 parent 树 + 线段树合并 排名区间 + 主席树
多串 广义 SAM 拼接
常数 小 倍增大,SA-IS 小

经验:涉及"出现次数 / 出现位置 / 在线"用 SAM;涉及"字典序第 \(k\) / 排序 / 分组"用 SA。


7. 回文自动机

7.1 结构

每个节点代表一个本质不同的回文子串。两个根:偶根(len 0)和奇根(len \(-1\))。转移 \(\text{nxt}[u][c]\):在 \(u\) 两侧各加一个 \(c\)。fail:最长的回文真后缀。

节点数 \(\le n\):每加一个字符最多产生一个新的本质不同回文串(最长的那个回文后缀)。

7.2 构造

int len[N], fail[N], nxt[N][26], cnt[N], tot, last, n;
char s[N];
int newnode(int l) { len[tot] = l; return tot++; }
void init() { tot = 0; newnode(0); newnode(-1); fail[0] = 1; fail[1] = 1; last = 0; n = 0; s[0] = -1; }
int getfail(int x) { while (s[n - len[x] - 1] != s[n]) x = fail[x]; return x; }
void insert(int c) {
    s[++n] = c;
    int x = getfail(last);
    if (!nxt[x][c]) {
        int y = newnode(len[x] + 2);
        fail[y] = nxt[getfail(fail[x])][c];      // 先求 fail,再挂转移
        nxt[x][c] = y;
    }
    last = nxt[x][c];
    cnt[last]++;
}

均摊 \(O(n)\):getfail 的跳跃次数由 last 的深度势能支付。

7.3 应用

  • 本质不同回文子串数:\(\text{tot} - 2\)。
  • 每个回文串的出现次数:\(\text{cnt}\) 沿 fail 树自底向上累加(按节点编号倒序)。
  • 回文子串总数:\(\sum\text{cnt}\)(累加后)。
  • 以每个位置结尾的回文串数:插入后 \(\text{last}\) 在 fail 树的深度。
  • [SHOI2011] 双倍回文(P4287):形如 \(ww^Rww^R\) 的回文。对每个节点维护 \(\text{trans}\) = 长度 \(\le \text{len}/2\) 的最长回文后缀,检查 \(\text{len}(\text{trans}) \cdot 2 = \text{len}\) 且 \(\text{len}(\text{trans})\) 为偶。
  • 两串公共回文子串([HAOI2016] 找相同字符的回文版):分别建 PAM 同步 DFS。

7.4 回文划分与等差性质

CF 932G Palindrome Partition:把串划分成偶数段,\(s_1 = s_k^R\),\(s_2 = s_{k-1}^R\)……转化为:把 \(s_1 s_n s_2 s_{n-1}\cdots\) 交错串划分成若干偶长回文串的方案数。

\(f[i] = \sum_{j:\ s[j+1..i]\text{ 回文}}f[j]\),需要枚举以 \(i\) 结尾的所有回文后缀(fail 链),\(O(n^2)\)。

优化:fail 链上的回文后缀长度构成 \(O(\log n)\) 个等差数列(与 border 结构定理同理,回文后缀 = 回文 border)。定义 \(\text{diff}[u] = \text{len}[u] - \text{len}[\text{fail}[u]]\),\(\text{slink}[u]\) = fail 链上第一个 \(\text{diff}\) 不同的节点(等差数列的"上一段")。同一等差数列的贡献 \(g[u]\) 可以由 \(g[\text{fail}[u]]\) 加一项得到(因为位置 \(i\) 的这一段和位置 \(i - \text{diff}[u]\) 的同一段只差一个元素)。每个位置只处理 \(O(\log n)\) 段,总 \(O(n\log n)\)。

// 插入 s[i] 后,last 为当前节点
for (int u = last; len[u] > 0; u = slink[u]) {
    g[u] = f[i - len[slink[u]] - diff[u]];        // 该等差数列最短的那个回文之前的位置
    if (diff[u] == diff[fail[u]]) g[u] = (g[u] + g[fail[u]]) % MOD;
    if (i % 2 == 0) f[i] = (f[i] + g[u]) % MOD;   // 只在偶数位置累加(题目要求偶长)
}

这是 PAM 上最重要的高级技巧,也用于"最少回文划分"等问题。


8. Lyndon 分解与最小表示

8.1 Lyndon 串

\(s\) 是 Lyndon 串 \(\iff\) \(s\) 严格小于它的所有真后缀(等价:严格小于所有非平凡循环移位)。

Lyndon 分解定理:任意串唯一分解为 \(s = w_1w_2\cdots w_k\),每个 \(w_i\) 是 Lyndon 串且 \(w_1 \ge w_2 \ge \cdots \ge w_k\)。

8.2 Duval 算法 \(O(n)\)

// 输出每段 [i, j]
for (int i = 1; i <= n;) {
    int j = i, k = i + 1;
    while (k <= n && s[j] <= s[k]) { j = (s[j] < s[k]) ? i : j + 1; k++; }
    while (i <= j) { printf("[%d,%d] ", i, i + k - j - 1); i += k - j; }
}

维护"当前近似 Lyndon 串 \(s[i..k-1]\) = 某 Lyndon 串的若干重复 + 一个前缀"。\(s[j] < s[k]\) 时整体变成一个新的 Lyndon 串;\(s[j] > s[k]\) 时把完整的重复段全部输出。模板:洛谷 P6114。

8.3 应用

  • 最小后缀:Lyndon 分解的最后一段 \(w_k\)。
  • 每个前缀的最小后缀:Duval 过程中可顺便得到。
  • 最小表示法:对 \(ss\) 做 Lyndon 分解,第一个起始位置 \(\ge\) … 的段即为最小循环移位起点(也可用下面的双指针)。
  • Runs 定理(了解):串的 runs(极大周期子串)数 \(< n\),可由 Lyndon 树 \(O(n)\) 求出,用于回答"所有周期子串"类问题。

8.4 最小表示法 \(O(n)\)

int minRep() {                             // 返回最小循环移位的起点(0-indexed)
    int i = 0, j = 1, k = 0;
    while (i < n && j < n && k < n) {
        int a = s[(i + k) % n], b = s[(j + k) % n];
        if (a == b) k++;
        else {
            if (a > b) i += k + 1; else j += k + 1;
            if (i == j) j++;
            k = 0;
        }
    }
    return min(i, j);
}

正确性:若 \(s[i+k] > s[j+k]\),则以 \(i, i+1, \dots, i+k\) 开头的移位都不是最小(它们都被对应的 \(j\) 开头的移位打败),可以一起跳过。模板:洛谷 P1368。


9. 组合技巧

9.1 带通配符匹配(FFT)

模式 \(P\) 含通配符 *(匹配任意字符)。定义 \(P_i = 0\) 当通配符。匹配位置 \(x\) 的判据:

\[\sum_{i}P_i\,T_{x+i}\,(P_i - T_{x+i})^2 = 0 \]

展开为三项卷积,FFT \(O(n\log n)\)。模板:洛谷 P4173。

9.2 Shift-And(bitset 匹配)

\(D\) 是长 \(m\) 的 bitset,\(D_j = 1\) 表示模式前 \(j\) 位匹配文本当前后缀。\(D' = ((D \ll 1) | 1)\ \&\ B[T_i]\),其中 \(B[c]\) 是模式中字符 \(c\) 出现位置的 bitset。\(O(nm/64)\),支持"每个位置是字符集合"的模式。

9.3 子序列自动机

\(\text{nxt}[i][c]\) = 位置 \(i\) 之后第一个字符 \(c\) 的位置。倒序 \(O(n|\Sigma|)\) 预处理。判断 \(t\) 是否为 \(s\) 的子序列 \(O(|t|)\);求本质不同子序列数:DAG 上 DP。

9.4 后缀数组 + 单调栈

\(\sum_{i<j}\text{LCP}(\text{sa}[i], \text{sa}[j]) = \sum_{k}\text{height}[k]\times(\text{以 }k\text{ 为最小值的区间数})\)。单调栈求每个 height 作为最小值的左右范围。

9.5 SAM + 线段树合并 + 倍增:区间子串问题

"\(s[l..r]\) 在 \(s[a..b]\) 中出现几次"、"\(s[a..b]\) 的本质不同子串数"(P6292,需要 LCT 维护 parent 树上的最右 endpos)。套路:定位状态 → 该状态的 endpos 线段树 → 区间查询。

9.6 哈希 + 二分 + 数据结构

当专用结构太重时,"哈希求 LCP + 二分 + 主席树 / 线段树"经常是 \(O(n\log^2 n)\) 的可行替代。例如动态修改的串求 LCP:线段树维护区间哈希,二分 LCP,\(O(\log^2 n)\)。


10. 常见错误清单

  1. 自然溢出哈希。 会被 Thue–Morse 卡,用双模或 \(2^{61}-1\)。
  2. KMP 的 border 必须是真子串。 失配树 LCA 等于查询点本身时要再跳一步。
  3. AC 自动机匹配时沿 fail 链累加。 退化为平方,改用 fail 树子树和。
  4. AC 自动机没有补全 Trie 图就做 DP。 转移必须对每个字符都定义。
  5. SA 倍增的第二关键字。 位置 \(> n - k\) 的后缀第二关键字为空,必须排最前。
  6. height 的引理用错下标。 引理是关于 \(\text{height}[\text{rk}[i]]\) 与 \(\text{height}[\text{rk}[i-1]]\),按原串位置递推。
  7. SAM 的 clone 节点 cnt 设为 1。 clone 不是真实结束位置,cnt 应为 0。
  8. SAM 状态数组只开 \(n\)。 需要 \(2n\)。
  9. 广义 SAM 离线用 DFS 建。 必须 BFS。
  10. 广义 SAM 在线版漏掉"转移已存在"的特判。 会产生 len 相同的重复状态。
  11. PAM 新节点的 fail 在挂转移之后求。 会指向自己。
  12. PAM 奇根的 fail。 设为奇根自身(或任何保证 getfail 终止的值),偶根 fail 指向奇根。
  13. Lyndon 分解 Duval 内层 j 的更新。 \(s[j] < s[k]\) 时 \(j\) 回到 \(i\),不是 \(i + 1\)。
  14. 最小表示法 \(i = j\) 时不跳。 会死循环。
  15. Z 函数 \(z[1]\) 未初始化为 \(n\)。 后续 \(z[i - l + 1]\) 可能用到。

11. 课堂练习

  1. 求 ababab 的所有 border 和所有周期。(答:border 长度 4, 2;周期 2, 4, 6)
  2. 求 aabaaab 的 Z 数组。(答:\(7,1,0,2,3,1,0\))
  3. 证明周期引理的弱形式(\(p + q \le n\))。
  4. 手动构造 abab 的 SAM,标出每个状态的 \([\text{minlen}, \text{maxlen}]\) 和 endpos。
  5. banana 的后缀数组和 height 数组。(答:sa \(= 6,4,2,1,5,3\);height \(= 0,1,3,0,0,2\))
  6. banana 的本质不同子串数。(答:\(21 - 6 = 15\))
  7. 用 PAM 求 abaab 的本质不同回文子串数。(答:5:a b aba aa baab)
  8. bcaabca 的 Lyndon 分解。(答:bc | aabc | a)
  9. cbabc 的最小表示。(答:abccb,起点 2)
  10. 为什么 SAM 的转移数上界是 \(3n - 4\) 而不是 \(2n\) 的倍数?(提示:生成树边 \(\le 2n - 2\),非树边每条对应一个不同的后缀)

12. 推荐题单

难度 题目 考点
基础 洛谷 P3375 / P5410 / P3805 KMP / Z / Manacher 模板
基础 洛谷 P5357 AC 自动机(二次加强) fail 树子树和
基础 洛谷 P3809 / P3804 / P5496 SA / SAM / PAM 模板
基础 洛谷 P6114 / P1368 Lyndon / 最小表示
提高 洛谷 P5829 失配树 border 的 LCA
提高 洛谷 P2375 [NOI2014] 动物园 双指针 KMP
提高 洛谷 P4052 文本生成器 AC 自动机 DP
提高 洛谷 P2414 阿狸的打字机 fail 树 + 树状数组离线
提高 洛谷 P4051 字符加密 SA 循环串
提高 洛谷 P4248 差异 SA + 单调栈
提高 洛谷 P2178 品酒大会 SA + 并查集合并
提高 洛谷 P3975 弦论 SAM 第 \(k\) 小
提高 洛谷 P4070 生成魔咒 在线 SAM
提高 洛谷 P3181 找相同字符 SAM / SA 两串统计
提高 洛谷 P6139 广义 SAM 广义 SAM
提高 洛谷 P4081 Standing Out from the Herd 广义 SAM 归属
提高 洛谷 P4287 双倍回文 PAM trans 指针
提高 洛谷 P4173 残缺的字符串 通配符 FFT
挑战 洛谷 P4094 [HEOI2016] 字符串 SAM + 二分 + 线段树合并
挑战 洛谷 P3346 诸神眷顾的幻想乡 Trie 上广义 SAM
挑战 洛谷 P4022 [CTSC2012] 熟悉的文章 广义 SAM + 二分 + 单调队列 DP
挑战 CF 932G Palindrome Partition PAM 等差链
挑战 洛谷 P4482 Border 的四种求法 border 等差结构
挑战 洛谷 P6292 区间本质不同子串个数 SAM + LCT
挑战 洛谷 P5284 [十二省联考2019] 字符串问题 SAM + 拓扑 DP

附录:一页纸总结

周期:\(p\) 周期 \(\iff n-p\) 是 border;\(p+q-\gcd\le n\Rightarrow\gcd\) 周期;border 分成 \(O(\log)\) 段等差数列。

KMP / Z:\(\text{nxt}[i]\) 最长 border;\(z[i]\) 后缀与整串 LCP;两者均摊线性靠"右端单调"。

AC 自动机:fail 树子树 = 以该串为后缀的节点;匹配打标记 + 子树和;DP 走 Trie 图避开危险点。

SA:\(\text{LCP}(\text{sa}_i,\text{sa}_j)=\min\text{height}(i,j]\);不同子串 \(=\frac{n(n+1)}2-\sum\text{height}\)。

SAM:状态 = endpos 等价类,parent 树 = 后缀关系,\(\text{minlen}=\text{len(link)}+1\);出现次数 = 子树非 clone 数;定位子串 = 前缀状态 + 倍增。

PAM:节点 \(\le n\);fail = 最长回文真后缀;回文后缀长度 \(O(\log)\) 段等差 → diff / slink。

Lyndon:Duval \(O(n)\);最小后缀 = 最后一段;最小表示双指针 \(O(n)\)。

选结构:相等 / 比较 → 哈希;周期 → KMP;多模 → AC;字典序 / 分组 → SA;出现次数 / 在线 / 多串 → SAM;回文 → PAM / Manacher。

posted @ 2026-09-20 09:13  ylzqwq  阅读(42)  评论(0)    收藏  举报