三、区域赛算法模版第三章---字符串相关算法

文章目录

    • 三、区域赛算法模版第三章---字符串相关算法
      • [3.1 KMP / 前缀函数](#3.1 KMP / 前缀函数)
        • [3.1.1 基本原理](#3.1.1 基本原理)
        • [3.1.2 基础模板](#3.1.2 基础模板)
        • [3.1.3 字符串周期与最小循环节](#3.1.3 字符串周期与最小循环节)
        • [3.1.4 统计每个前缀出现次数](#3.1.4 统计每个前缀出现次数)
        • [3.1.5 最长公共前后缀链](#3.1.5 最长公共前后缀链)
        • [3.1.6 KMP 自动机](#3.1.6 KMP 自动机)
        • [3.1.7 扩展 KMP(Z 函数)](#3.1.7 扩展 KMP(Z 函数))
        • [3.1.8 前缀函数的其他性质](#3.1.8 前缀函数的其他性质)
        • [3.1.9 常见应用](#3.1.9 常见应用)
        • [3.1.10 时间复杂度总结](#3.1.10 时间复杂度总结)
        • [3.1.11 注意事项](#3.1.11 注意事项)
        • [3.1.12 区域赛题](#3.1.12 区域赛题)
      • [3.2 Trie 字典树](#3.2 Trie 字典树)
        • [3.2.1 基本原理](#3.2.1 基本原理)
        • [3.2.2 基础模板(字符串统计)](#3.2.2 基础模板(字符串统计))
        • [3.2.3 01Trie(求最大异或和)](#3.2.3 01Trie(求最大异或和))
        • [3.2.4 常见应用](#3.2.4 常见应用)
        • [3.2.5 时间复杂度总结](#3.2.5 时间复杂度总结)
        • [3.2.6 注意事项](#3.2.6 注意事项)
        • [3.2.7 字典树补充---可持续化字典树](#3.2.7 字典树补充---可持续化字典树)
        • [3.2.7.1 基本原理](#3.2.7.1 基本原理)
        • [3.2.7.2 基础模板:区间最大异或和](#3.2.7.2 基础模板:区间最大异或和)
        • [3.2.7.3 树上路径最大异或和](#3.2.7.3 树上路径最大异或和)
        • [3.2.7.4 子树最大异或和](#3.2.7.4 子树最大异或和)
        • [3.2.7.5 次大值 + 可持久化 01Trie](#3.2.7.5 次大值 + 可持久化 01Trie)
        • [3.2.7.6 常见应用总结](#3.2.7.6 常见应用总结)
        • [3.2.7.7 注意事项](#3.2.7.7 注意事项)
        • [3.2.8 区域赛题](#3.2.8 区域赛题)
      • [3.3 Manacher算法](#3.3 Manacher算法)
        • [3.3.1 基本原理](#3.3.1 基本原理)
        • [3.3.2 基础模板](#3.3.2 基础模板)
        • [3.3.3 最长回文子串(LeetCode 5)](#3.3.3 最长回文子串(LeetCode 5))
        • [3.3.4 回文子串数量(LeetCode 647)](#3.3.4 回文子串数量(LeetCode 647))
        • [3.3.5 不重叠回文子串的最多数目(LeetCode 2472)](#3.3.5 不重叠回文子串的最多数目(LeetCode 2472))
        • [3.3.6 长度前 k 名的奇数长度回文子串长度乘积(洛谷 P1659)](#3.3.6 长度前 k 名的奇数长度回文子串长度乘积(洛谷 P1659))
        • [3.3.7 最长双回文串长度(洛谷 P4555)](#3.3.7 最长双回文串长度(洛谷 P4555))
        • [3.3.8 时间复杂度总结](#3.3.8 时间复杂度总结)
        • [3.3.9 注意事项](#3.3.9 注意事项)
        • [3.3.x 区域赛题](#3.3.x 区域赛题)
      • [3.4 字符串哈希(多项式哈希)](#3.4 字符串哈希(多项式哈希))
        • [3.4.1 基本原理](#3.4.1 基本原理)
        • [3.4.2 基础实现(洛谷 P3370)](#3.4.2 基础实现(洛谷 P3370))
        • [3.4.3 哈希冲突及工业优化](#3.4.3 哈希冲突及工业优化)
        • [3.4.4 子串哈希(前缀哈希)](#3.4.4 子串哈希(前缀哈希))
        • [3.4.5 常见应用](#3.4.5 常见应用)
        • [3.4.6 哈希参数选择建议](#3.4.6 哈希参数选择建议)
        • [3.4.7 时间复杂度总结](#3.4.7 时间复杂度总结)
      • [3.5 AC自动机](#3.5 AC自动机)
        • [3.5.1 基本原理](#3.5.1 基本原理)
        • [3.5.2 基础模板(统计每个模式串出现次数)](#3.5.2 基础模板(统计每个模式串出现次数))
        • [3.5.3 AC 自动机 + 数位 DP(洛谷 P3311)](#3.5.3 AC 自动机 + 数位 DP(洛谷 P3311))
        • [3.5.4 AC 自动机的优化技巧](#3.5.4 AC 自动机的优化技巧)
        • [3.5.5 常见应用](#3.5.5 常见应用)
        • [3.5.6 时间复杂度总结](#3.5.6 时间复杂度总结)
        • [3.5.7 注意事项](#3.5.7 注意事项)
      • [3.6 后缀自动机](#3.6 后缀自动机)
        • [3.6.1 基本原理](#3.6.1 基本原理)
        • [3.6.2 基础模板](#3.6.2 基础模板)
        • [3.6.3 不同子串个数](#3.6.3 不同子串个数)
        • [3.6.4 子串出现次数](#3.6.4 子串出现次数)
        • [3.6.5 最长公共子串](#3.6.5 最长公共子串)
        • [3.6.6 字典序第 k 小子串](#3.6.6 字典序第 k 小子串)
        • [3.6.7 最长重复子串](#3.6.7 最长重复子串)
        • [3.6.8 后缀自动机 + DP](#3.6.8 后缀自动机 + DP)
        • [3.6.9 广义后缀自动机(GSAM)](#3.6.9 广义后缀自动机(GSAM))
        • [3.6.10 常见应用总结](#3.6.10 常见应用总结)
        • [3.6.11 注意事项](#3.6.11 注意事项)

三、区域赛算法模版第三章---字符串相关算法

3.1 KMP / 前缀函数

适用场景: 模式匹配、周期串、前缀后缀相关、字符串最小循环节、前缀出现次数统计、KMP 自动机 + DP、字符串压缩

3.1.1 基本原理

核心思想: 利用已经匹配过的信息,避免暴力回溯。KMP 的核心是前缀函数(也叫 next 数组)。

前缀函数定义:

π i = max ⁡ { k ∣ 0 ≤ k < i ,    s 0.. k − 1 = s i − k + 1.. i } \pii = \max\{k \mid 0 \le k < i, \; s0..k-1 = si-k+1..i\} πi=max{k∣0≤k<i,s0..k−1=si−k+1..i}

即 s 0.. i s0..i s0..i 的最长相等真前缀后缀的长度。

关键性质:

  • π 0 = 0 \pi0 = 0 π0=0
  • π i ≤ π i − 1 + 1 \pii \le \pii-1 + 1 πi≤πi−1+1
  • 如果 s i = s π \[ i − 1 ] si = s\\pi\[i-1] si=sπ\[i−1],则 π i = π i − 1 + 1 \pii = \pii-1 + 1 πi=πi−1+1
  • 否则不断跳 π \pi π,直到匹配或为 0

时间复杂度: 预处理 O(n),匹配 O(n + m)。

3.1.2 基础模板
cpp 复制代码
#include <bits/stdc++.h>
using namespace std;

class Solver {
public:
    // 前缀函数:pi[i] = s[0..i] 最长相等真前缀后缀长度
    vector<int> get_pi(const string &s) {
        int n = s.size();
        vector<int> pi(n, 0);
        for (int i = 1; i < n; i++) {
            int j = pi[i - 1];
            while (j > 0 && s[i] != s[j]) j = pi[j - 1];
            if (s[i] == s[j]) j++;
            pi[i] = j;
        }
        return pi;
    }
    // KMP 匹配:返回 t 中所有 p 出现的位置(0-indexed)
    vector<int> kmp(const string &t, const string &p) {
        vector<int> pi = get_pi(p);
        vector<int> res;
        int j = 0;
        for (int i = 0; i < (int)t.size(); i++) {
            while (j > 0 && t[i] != p[j]) j = pi[j - 1];
            if (t[i] == p[j]) j++;
            if (j == (int)p.size()) {
                res.push_back(i - j + 1);
                j = pi[j - 1];
            }
        }
        return res;
    }
};

思路要点:

  • 前缀函数利用已匹配信息,避免暴力回溯
  • 核心:失配时跳到 pi[j-1] 继续比较
  • 匹配成功后,j = pi[j-1] 继续找下一个匹配
  • 时间复杂度 O(n + m)
3.1.3 字符串周期与最小循环节

结论:

  • 若 n % ( n − π n − 1 ) = 0 n \% (n - \pin-1) = 0 n%(n−πn−1)=0,则字符串的最小循环节长度为 n − π n − 1 n - \pin-1 n−πn−1
  • 循环次数为 n / ( n − π n − 1 ) n / (n - \pin-1) n/(n−πn−1)
  • 否则字符串没有完整的循环节

代码:

cpp 复制代码
class Solver {
public:
    // 判断字符串是否由某个循环节重复构成
    bool isPeriodic(const string &s) {
        int n = s.size();
        vector<int> pi = get_pi(s);
        int len = n - pi[n - 1];
        return n % len == 0;
    }
    // 最小循环节长度
    int minPeriod(const string &s) {
        int n = s.size();
        vector<int> pi = get_pi(s);
        return n - pi[n - 1];
    }
};

思路要点:

  • n − π n − 1 n - \pin-1 n−πn−1 是字符串的最小周期
  • 如果 n n n 能被这个周期整除,则是完整循环
  • 经典例题:POJ 2406、洛谷 P4391
3.1.4 统计每个前缀出现次数

题意: 给定字符串 s s s,统计每个前缀在 s s s 中作为子串出现的次数。

思路: 在 fail 树(前缀函数树)上从下往上累加。

  • 每个节点 i i i 代表前缀 s 0.. i s0..i s0..i
  • i i i 的父节点是 π i − 1 − 1 \pii-1 - 1 πi−1−1(即前缀函数指向的位置)
  • 每个节点初始计数为 1(自身出现一次)
  • 从叶子往上累加,得到每个前缀出现次数

代码:

cpp 复制代码
class Solver {
public:
    // 统计每个前缀出现次数
    vector<long long> countPrefix(const string &s) {
        int n = s.size();
        vector<int> pi = get_pi(s);
        vector<long long> cnt(n, 1);   // 每个前缀自身算一次
        // 建 fail 树:i 的父节点是 pi[i-1]-1
        vector<vector<int>> tree(n);
        for (int i = 1; i < n; i++) {
            tree[pi[i - 1]].push_back(i);
        }
        // 从下往上累加(后序遍历)
        function<void(int)> dfs = [&](int u) {
            for (int v : tree[u]) {
                dfs(v);
                cnt[u] += cnt[v];
            }
        };
        dfs(0);
        return cnt;
    }
};

思路要点:

  • 每个前缀出现次数 = 自身 + 所有以它为后缀的前缀出现次数
  • 在 fail 树上从下往上累加
  • 经典例题:洛谷 P2375、CF 432D
3.1.5 最长公共前后缀链

题意: 给定字符串 s s s,求所有既是前缀又是后缀的子串长度。

思路: 从 π n − 1 \pin-1 πn−1 开始,不断跳 π \pi π,得到所有公共前后缀长度。

cpp 复制代码
class Solver {
public:
    // 求所有既是前缀又是后缀的子串长度(从大到小)
    vector<int> border(const string &s) {
        int n = s.size();
        vector<int> pi = get_pi(s);
        vector<int> res;
        int j = pi[n - 1];
        while (j > 0) {
            res.push_back(j);
            j = pi[j - 1];
        }
        return res;
    }
};

思路要点:

  • π n − 1 \pin-1 πn−1 是最长公共前后缀长度
  • 不断跳 π \pi π 得到所有公共前后缀长度
  • 经典例题:洛谷 P3435、CF 126B
3.1.6 KMP 自动机

适用场景: 多文本匹配、DP 转移、字符串计数

思路: 把前缀函数数组扩展成自动机,状态是当前匹配长度,转移是下一个字符。

cpp 复制代码
class KMP {
public:
    // KMP 自动机
    // nxt[i][c] = 当前匹配长度为 i,下一个字符为 c 时,新的匹配长度
    vector<array<int, 26>> buildKMPAutomaton(const string &p) {
        int m = p.size();
        vector<int> pi = get_pi(p);
        vector<array<int, 26>> nxt(m + 1);
        for (int i = 0; i <= m; i++) {
            for (int c = 0; c < 26; c++) {
                if (i < m && p[i] - 'a' == c) {
                    nxt[i][c] = i + 1;
                } else {
                    nxt[i][c] = (i == 0) ? 0 : nxt[pi[i - 1]][c];
                }
            }
        }
        return nxt;
    }
};

思路要点:

  • 状态是当前匹配长度 i ∈ 0 , m i \in 0, m i∈0,m
  • 转移:如果 p i = = c pi == c pi==c,则 i + 1 i+1 i+1;否则跳 π \pi π
  • 可以用于 DP: d p i j dpij dpij 表示长度为 i i i、匹配长度为 j j j 的方案数
  • 经典例题:CF 808G、洛谷 P3193
3.1.7 扩展 KMP(Z 函数)

适用场景: 求字符串 s s s 的每个后缀与 s s s 的最长公共前缀(LCP)

Z 函数定义:

z i = LCP ( s , s i . . n − 1 ) zi = \text{LCP}(s, si..n-1) zi=LCP(s,si..n−1)

即 s s s 与 s i . . n − 1 si..n-1 si..n−1 的最长公共前缀长度。约定 z 0 = 0 z0 = 0 z0=0(或 n n n)。

与 KMP 的关系:

  • KMP 前缀函数是前缀与后缀的匹配
  • Z 函数是前缀与任意后缀的匹配
  • 两者可以互相转化

代码:

cpp 复制代码
class KMP {
public:
    // Z 函数:z[i] = LCP(s, s[i..n-1])
    vector<int> get_z(const string &s) {
        int n = s.size();
        vector<int> z(n, 0);
        int l = 0, r = 0;
        for (int i = 1; i < n; i++) {
            if (i <= r) z[i] = min(r - i + 1, z[i - l]);
            while (i + z[i] < n && s[z[i]] == s[i + z[i]]) z[i]++;
            if (i + z[i] - 1 > r) {
                l = i;
                r = i + z[i] - 1;
            }
        }
        return z;
    }
};

思路要点:

  • 维护当前已知的最右 Z-box l , r l, r l,r
  • 若 i ≤ r i \le r i≤r,利用对称性初始化 z i = min ⁡ ( r − i + 1 , z i − l ) zi = \min(r - i + 1, zi - l) zi=min(r−i+1,zi−l)
  • 然后暴力扩展
  • 时间复杂度 O(n)

Z 函数求模式匹配:

将模式串 p p p 和文本串 t t t 拼接成 p + # + t p + \# + t p+#+t,求 Z 函数,若某个位置的 z i ≥ ∣ p ∣ zi \ge |p| zi≥∣p∣,则匹配成功。

cpp 复制代码
class KMP {
public:
    // Z 函数求模式匹配
    vector<int> zMatch(const string &t, const string &p) {
        string s = p + "#" + t;
        vector<int> z = get_z(s);
        vector<int> res;
        int m = p.size();
        for (int i = m + 1; i < (int)s.size(); i++) {
            if (z[i] >= m) res.push_back(i - m - 1);
        }
        return res;
    }
};
3.1.8 前缀函数的其他性质
性质 说明
前缀函数链 π i , π π \[ i − 1 ] , ... \pii, \pi\\pi\[i-1], \dots πi,ππ\[i−1],... 是所有公共前后缀长度
最小周期 n − π n − 1 n - \pin-1 n−πn−1
前缀出现次数 fail 树上从下往上累加
字符串压缩 最小周期循环
回文串判断 对 s + # + reverse ( s ) s + \# + \text{reverse}(s) s+#+reverse(s) 求 π \pi π
3.1.9 常见应用
应用 做法
模式匹配 KMP 匹配
最小循环节 n − π n − 1 n - \pin-1 n−πn−1
统计前缀出现次数 fail 树上从下往上累加
所有公共前后缀 不断跳 π \pi π
KMP 自动机 + DP 状态是匹配长度,转移是字符
字符串压缩 最小循环节
回文串前缀函数 对 s + # + reverse ( s ) s + \# + \text{reverse}(s) s+#+reverse(s) 求 π \pi π
求每个后缀与前缀的 LCP Z 函数
字符串匹配(Z 函数版) 拼接后求 Z 函数
3.1.10 时间复杂度总结
操作 复杂度
求前缀函数 O(n)
KMP 匹配 O(n + m)
求最小循环节 O(n)
统计前缀出现次数 O(n)
求所有公共前后缀 O(n)
构建 KMP 自动机 O(n × 字符集)
KMP 自动机 + DP O(状态数 × 转移数)
求 Z 函数 O(n)
Z 函数匹配 O(n + m)
3.1.11 注意事项
  • 前缀函数的下标从 0 开始, π 0 = 0 \pi0 = 0 π0=0
  • 失配时跳 π j − 1 \pij-1 πj−1,不是 π j \pij πj
  • 匹配成功后要跳 π j − 1 \pij-1 πj−1 继续找下一个匹配
  • 最小循环节要求 n n n 能被 n − π n − 1 n - \pin-1 n−πn−1 整除
  • KMP 自动机的状态数是 m + 1 m+1 m+1,转移是字符集大小
  • Z 函数的 z 0 z0 z0 通常定义为 0 或 n n n,看具体实现
  • Z 函数拼接时,分隔符 # \# # 必须是不在字符集中的字符
  • 字符串下标从 0 开始时,前缀函数和 KMP 的写法与从 1 开始略有不同
  • 多测试用例时注意清空数组

3.1.12 区域赛题
text 复制代码
【题目描述】
对于字符串 u = u1 u2 ... un,定义:
  - pre(u, i) = u1 u2 ... ui,即 u 的长度为 i 的前缀。
  - suf(u, i) = u_{n-i+1} ... un,即 u 的长度为 i 的后缀。
  - 特别地,pre(u, 0) 和 suf(u, 0) 都是空串。
  - u + v 表示字符串 u 和 v 的拼接。
  - presuf(u, v) = max{ i | i < |u| 且 i ≤ |v| 且 pre(u, i) = suf(v, i) }。

给定两个字符串 s = s1 s2 ... sn 和 t = t1 t2 ... tm。
定义 f(i) = presuf(s, pre(s, i) + t),其中 0 ≤ i ≤ n-1。

要求输出 f(0), f(1), ..., f(n-1)。

【输入】
输入包含多组测试数据,以 EOF 结束。每组数据:
  - 第一行:字符串 s
  - 第二行:字符串 t
  - 1 ≤ n, m ≤ 10^6
  - 字符集为小写字母 a ~ z
  - 所有输入的 max(n, m) 之和 ≤ 10^6

【输出】
对于每组数据,输出 n 个整数,表示 f(0), f(1), ..., f(n-1)。

【样例1】
输入:
  aaa
  a
  ababa
  a
  ab
  cd
输出:
  1 2 2
  1 1 3 1 3
  0 0

【样例说明】
第二组:s = ababa, t = a
  f(4) = presuf(s, pre(s, 4) + t) = presuf(ababa, abab + a) = presuf(ababa, ababa)
  pre(ababa, 3) = aba, suf(ababa, 3) = aba,匹配长度为 3。
  所以 f(4) = 3。

【思路解说】

【一、问题转化】
我们要对每个 i 求:
  f(i) = presuf(s, pre(s, i) + t)
       = max{ k | k < n, k ≤ i + m, pre(s, k) = suf(pre(s, i) + t, k) }

也就是说,对于字符串 pre(s, i) + t,它的后缀与 s 的前缀匹配的最长长度是多少(且匹配长度必须小于 n)。

【二、匹配长度 k 的分类】
把匹配长度 k 分成两类:

  1) k ≤ m:匹配的后缀完全落在 t 中。
     此时 pre(s, k) = suf(t, k),与 i 无关。
     设 b0 = 最长的「s 的前缀等于 t 的后缀」的长度。
     则这类匹配的最大值就是 b0。

  2) k > m:匹配的后缀跨越了 pre(s, i) 和 t 的边界。
     此时需要满足:
       - t = s[k-m+1..k](即 t 出现在 s 中以 k-m+1 开头、长度为 m 的位置)
       - k - m 在 i 的失配链上(即 k-m 是 pre(s, i) 的某个 border)
     设 val[j] = j + m,当 t 恰好出现在 s[j+1..j+m] 位置时。
     则这类匹配的最大值就是「在 i 的失配链上,val 的最大值」。

【三、预处理】
  1) 计算 s 的前缀函数 piS。
  2) 计算 matchEnd[j]:当 t 出现在 s[j+1..j+m] 位置时,记录结束位置 j+m。
     做法:构造字符串 t + '\1' + s,求前缀函数,找到所有 pi 值为 m 的位置。
  3) 计算 overlap = 最长的「s 的前缀等于 t 的后缀」的长度。
     做法:构造字符串 s + '\1' + t,求前缀函数,最后一个值就是 overlap。
  4) 计算 best[i] = 在 s 的前 i 个字符的失配链上,matchEnd 的最大值。
     递推:best[i] = max(matchEnd[i], best[piS[i]])。

【四、答案计算】
对于每个 i:
  q = max(overlap, best[i])
  ans = (q < n) ? q : piS[n]

其中 piS[n] 是 s 的最长真 border(因为匹配长度必须小于 n)。

【五、复杂度】
  - 预处理前缀函数:O(n + m)
  - 计算 matchEnd:O(n + m)
  - 计算 best:O(n)
  - 输出答案:O(n)
  - 总复杂度:O(n + m),可以过。

【六、注意事项】
  - 多组测试数据,以 EOF 结束。
  - 前缀函数的下标从 0 开始,pi[k] 表示 x[0..k-1] 的最长真 border。
  - 构造 t + '\1' + s 时,分隔符 '\1' 不能出现在原字符串中。
  - 匹配长度必须小于 n,所以当 q == n 时要取 piS[n]。
  - 所有输入的 max(n, m) 之和 ≤ 10^6,所以 O(n + m) 可以过。
  - 答案之间用空格分隔,每组数据输出一行。
  • 根据以上思路代码如下:
cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
// 前缀函数:pi[k] = x[0..k-1] 的最长真 border
// pi[0] = 0, pi[k] 表示 x 的前 k 个字符的最长真前缀后缀长度
static vector<int> prefixFunction(const string& x) {
    int L = (int)x.size();
    vector<int> pi(L + 1, 0);               // 多开一位,方便后面取 pi[L]
    for (int i = 1; i < L; i++) {
        int j = pi[i];                      // 从前一个位置的最长 border 开始尝试
        while (j > 0 && x[i] != x[j]) j = pi[j];  // 失配时跳 border
        if (x[i] == x[j]) j++;              // 匹配成功,border 长度加 1
        pi[i + 1] = j;                      // 记录前 i+1 个字符的最长 border
    }
    return pi;
}
int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    string s, t, combined;
    while (cin >> s >> t) {                 // 多组测试数据,以 EOF 结束
        int n = (int)s.size();
        int m = (int)t.size();
        // s 的前缀函数
        vector<int> piS = prefixFunction(s);
        // matchEnd[j] = j + m,当 t 恰好出现在 s[j+1..j+m] 位置(1-based)
        // 即 s 中以 j 开头、长度为 m 的子串等于 t 时,记录其结束位置
        vector<int> matchEnd(n + 1, 0);
        combined.clear();
        combined.reserve(m + 1 + n);
        combined += t;
        combined.push_back('\1');           // 分隔符,避免 t 和 s 之间产生额外的匹配
        combined += s;
        {
            // 对 t + '\1' + s 求前缀函数
            // 若某个位置的前缀函数值等于 m,说明 t 出现在该位置之前
            vector<int> pi = prefixFunction(combined);
            for (int i = m + 1; i < (int)combined.size(); i++) {
                if (pi[i + 1] == m) {       // 匹配到完整的 t
                    int endPos = i - m;     // t 在 s 中的结束位置(1-based)
                    int startPos = endPos - m; // t 在 s 中的起始位置减一(1-based)
                    if (startPos >= 1) matchEnd[startPos] = endPos;
                }
            }
        }
        // overlap = 最长的「s 的前缀」等于「t 的后缀」的长度
        // 构造 s + '\1' + t,求前缀函数,最后一个值就是 overlap
        combined.clear();
        combined.reserve(n + 1 + m);
        combined += s;
        combined.push_back('\1');
        combined += t;
        int overlap = prefixFunction(combined)[(int)combined.size()];
        // best[i] = 在 s 的前 i 个字符的失配链上,matchEnd 的最大值
        // 即:从 i 开始不断跳 piS,取经过的所有位置的 matchEnd 最大值
        vector<int> best(n + 1, 0);
        best[0] = matchEnd[0];
        for (int i = 1; i < n; i++) {
            best[i] = max(matchEnd[i], best[piS[i]]);
        }
        // 输出答案
        // 对于每个 i,q = max(overlap, best[i]) 就是 pre(s,i)+t 的后缀与 s 的前缀的最长匹配长度
        // 但匹配长度必须小于 n,所以如果 q == n,要取 s 的最长真 border piS[n]
        for (int i = 0; i < n; i++) {
            int q = max(overlap, best[i]);
            int ans = (q < n) ? q : piS[n];
            cout << ans << (i + 1 < n ? ' ' : '\n');
        }
    }
    return 0;
}
text 复制代码
【题目描述】
给定一个长度为 n 的字符串 s(只含小写字母),以及 q 次查询。
每次查询给出一个整数 i,表示假设把 s 的第 i 个字符改成 '#'(注意:只是假设,
不会真的修改原串),问修改后的新字符串有多少个周期。

周期的定义:整数 T 是字符串 s 的周期,当且仅当:
  - 1 ≤ T < |s|
  - 对于所有 i ∈ (T, |s|],都有 s[i] = s[i - T]

【输入】
第一行:字符串 s(1 ≤ |s| ≤ 10^6)
第二行:整数 q(1 ≤ q ≤ 10^6)
接下来 q 行:每行一个整数 i(1 ≤ i ≤ |s|)

【输出】
对于每次查询,输出一个整数,表示修改后的新字符串的周期个数。

【样例1】
输入:
  ccpc
  4
  1
  2
  3
  4
输出:
  0
  1
  1
  0

【样例解释】
原串 "ccpc" 的周期只有 T = 3(因为 n - T = 1,即长度为 1 的 border)。
- 查询 i=1:修改后为 "#cpc",周期 T=3 被破坏,答案 0。
- 查询 i=2:修改后为 "c#pc",周期 T=3 仍然成立,答案 1。
- 查询 i=3:修改后为 "cc#c",周期 T=3 仍然成立,答案 1。
- 查询 i=4:修改后为 "ccp#",周期 T=3 被破坏,答案 0。

【思路解说】

【一、原串的周期与 border 的关系】
一个字符串 s 的周期 T 与它的 border(真前缀等于真后缀)有对应关系:
  T 是 s 的周期 ⇔ n - T 是 s 的 border。

所以,求 s 的所有周期,等价于求 s 的所有 border 长度 b,然后 T = n - b。

用 KMP 前缀函数 pi 可以求出所有 border:
  - pi[n-1] 是最长 border 长度。
  - 不断跳 pi 可以得到所有 border 长度。

【二、修改一个字符对周期的影响】
把第 i 个字符改成 '#' 后,周期 T 是否仍然成立?

周期 T 要求:对于所有 j ∈ (T, n],都有 s'[j] = s'[j-T]。

修改后,只有位置 i 变成了 '#'。所以:
  - 如果 j = i 且 i > T,那么需要 s'[i] = s'[i-T],
    即 '#' = s'[i-T]。但 '#' 不是小写字母,所以这个等式不成立。
    因此,只要 i > T,周期 T 就被破坏。
  - 如果 j = i + T 且 i + T ≤ n,那么需要 s'[i+T] = s'[i],
    即 s[i+T] = '#'。这也不成立。
    因此,只要 i + T ≤ n,周期 T 就被破坏。

所以,周期 T 被破坏,当且仅当:
  i > T 或 i + T ≤ n。

【三、周期 T 仍然成立的条件】
周期 T 不被破坏,当且仅当:
  i ≤ T 且 i + T > n。

即:
  T ≥ i 且 T > n - i。

合并得:
  T > max(i - 1, n - i)。

所以,对于查询 i,答案就是:原串的周期集合中,满足
  T > max(i - 1, n - i)
的周期个数。

【四、算法流程】
  1) 用 KMP 求 s 的前缀函数 pi。
  2) 从 pi[n-1] 开始不断跳 pi,收集所有 border 长度 b,
     则周期 T = n - b。把所有周期存入数组 periods。
  3) 对 periods 排序。
  4) 对于每个查询 i:
       - 计算 threshold = max(i - 1, n - i)。
       - 用二分查找(upper_bound)统计 periods 中 > threshold 的个数。
       - 输出这个个数。

【五、复杂度】
  - 求前缀函数:O(n)
  - 收集周期:O(n)
  - 排序:O(n log n)
  - 每次查询:O(log n)
  - 总复杂度:O(n log n + q log n),可以过。

【六、注意事项】
  - 周期 T 必须满足 1 ≤ T < n。
  - 收集 border 时,从 pi[n-1] 开始不断跳 pi,直到 0。
  - 查询时用 upper_bound 找第一个 > threshold 的位置。
  - 答案可能为 0,表示修改后没有周期。
  - 多组测试要清空数组。
  - 字符串长度可达 10^6,要用 O(n) 的 KMP。
  • 基于以上思路,代码如下:
cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
// 求前缀函数 pi
// pi[i] = s[0..i] 的最长相等真前缀后缀长度
vector<int> get_pi(const string &s) {
    int n = s.size();
    vector<int> pi(n, 0);
    for (int i = 1; i < n; i++) {
        int j = pi[i - 1];                      // 从前一个位置的最长 border 开始尝试
        while (j > 0 && s[i] != s[j]) j = pi[j - 1];  // 失配时跳 border
        if (s[i] == s[j]) j++;                  // 匹配成功,border 长度加 1
        pi[i] = j;
    }
    return pi;
}
int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    string s;
    cin >> s;
    int n = s.size();
    // 求前缀函数
    vector<int> pi = get_pi(s);
    // 收集所有周期 T = n - border
    // border 是 s 的真前缀等于真后缀的长度
    // 周期 T 与 border b 的关系:T = n - b
    vector<int> periods;
    int j = pi[n - 1];                          // 最长 border
    while (j > 0) {
        periods.push_back(n - j);               // 对应周期 T = n - j
        j = pi[j - 1];                          // 跳到下一个 border
    }
    sort(periods.begin(), periods.end());       // 排序,方便二分
    int q;
    cin >> q;
    while (q--) {
        int i;
        cin >> i;
        // 修改第 i 个字符后,周期 T 仍然成立的条件:
        //   i ≤ T 且 i + T > n
        // 即 T > max(i - 1, n - i)
        int threshold = max(i - 1, n - i);
        // 统计 periods 中 > threshold 的个数
        // upper_bound 返回第一个 > threshold 的位置
        int ans = periods.end() - upper_bound(periods.begin(), periods.end(), threshold);
        cout << ans << '\n';
    }
    return 0;
}
text 复制代码
Reverse KMP(QOJ 15429 / The 4th Universal Cup Extra Stage 4: Chongqing K)

【题目描述】
在 KMP 算法中,next 数组(也叫 LPS 数组,最长公共前后缀,或失配函数)是一个核心概念。
给定一个长度为 n 的字符串 S = s1s2...sn,它的 next 数组 next[1..n] 定义为:
  next[i] 表示子串 S[1..i] 的最长相等真前缀和真后缀的长度。
  特别地,next[1] = 0。

形式化地:
  next[i] = max{ k | 0 ≤ k ≤ i-1, S[1..k] = S[i-k+1..i] }

给定一个长度为 n 的数组 A[1..n]。你需要统计有多少个长度为 n、定义在字符集 Σ 上的字符串 S,
使得它的 next 数组恰好等于给定的数组 A。字符集大小为 m。
答案对 998244353 取模。

【输入】
第一行:T(1 ≤ T ≤ 10^5),测试用例个数。
每个测试用例:
  第一行:n, m(2 ≤ n ≤ 10^5,1 ≤ m ≤ 10^6)
  第二行:n 个整数 A[1..n](0 ≤ A[i] ≤ i-1)

保证所有测试用例的 n 之和不超过 5 × 10^5。

【输出】
每个测试用例输出一行,一个整数,表示满足条件的字符串数量对 998244353 取模。

【样例1】
输入:
  3
  3 3
  0 1 2
  7 3
  0 0 0 1 2 3 0
  7 3
  0 0 0 1 2 3 1
输出:
  3
  24
  0

【思路解说】

【一、从 next 数组到字符约束】
给定 next 数组 A,我们要构造字符串 S,使得 S 的 next 数组恰好等于 A。

关键约束来自 next 数组的定义:
  1) 如果 A[i] = j > 0,则 S[1..j] = S[i-j+1..i],即 S[i] = S[j](因为 S[1..j] 是 S[1..i] 的后缀)。
     所以 S[i] 和 S[j] 必须相同。
  2) 如果 A[i] = 0,则 S[i] 不能等于任何会导致更长匹配的字符。
     具体来说,S[i] 不能等于失配链上任何已确定字符。

【二、合法性检查(必要条件)】
next 数组有一个重要性质:
  next[i] ≤ next[i-1] + 1

如果给定的 A 不满足这个性质,则答案为 0。

**启发式:** 这个性质是 KMP 算法正确性的基础。如果 next[i] 增长得太快,
说明它"跳过了"某些必须匹配的前缀,这在真实字符串中是不可能的。

【三、失配树与等价类】
根据 next 数组,可以建立一棵**失配树**:
  - 每个节点 i 的父亲是 next[i]。
  - 根节点是 0。

在失配树中,如果 next[i] = j > 0,则 S[i] 和 S[j] 必须相同。
所以我们可以用**并查集**维护字符的等价类:
  - 初始时每个位置自成一个等价类。
  - 对于每个 i,如果 next[i] = j > 0,则把 i 和 j 合并到同一个等价类。

合并完成后,每个等价类代表一个"字符"。同一个等价类中的位置必须填相同的字符。

**启发式:** 这里的等价类合并是"必须相同"的约束。
但要注意,等价类合并的顺序会影响最终的代表元,所以我们需要用失配树的根
作为等价类的代表元。

【四、计数:g 数组的定义】
设 g[p] 表示位置 1..p+1 中已经锁定的不同字符类数。

**为什么是 1..p+1?**
因为当我们考虑 next[i] = 0 时,S[i] 必须是"全新字符",
它不能等于任何已经出现在前缀中的字符。
而前缀中最长的可能匹配长度是 p = next[i-1],
所以我们需要排除的字符是出现在位置 1..p+1 中的字符。

**g 数组的递推:**
  g[0] = 1(位置 1 已经锁定了一个字符)
  g[p] = g[fail[p]] + (p+1 <= n && fail[p+1] == 0 ? 1 : 0)

**启发式:** g[p] 统计的是"在位置 1..p+1 中,有多少个不同的字符类"。
当 next[p+1] = 0 时,说明位置 p+1 是一个"全新字符",所以字符类数 +1。
否则,位置 p+1 与之前的某个位置属于同一个字符类,字符类数不变。

【五、逐个位置计数】
初始化 ans = m(S[1] 有 m 种选择)。

对于 i 从 2 到 n:
  p = fail[i-1](前一个位置的 next 值)
  q = fail[i](当前位置的 next 值)

  **情况 1:q == p + 1**
  说明 S[i] = S[p+1],字符唯一确定,无额外选择。
  **启发式:** 这是"直接扩展"的情况,next 值加 1,字符必须匹配。

  **情况 2:q == 0**
  说明 S[i] 必须是全新字符,不能等于位置 1..p+1 中出现的任何字符。
  选择数 = m - g[p]。
  如果 ≤ 0,返回 0。
  ans *= 选择数。
  **启发式:** 这是"一路失配到 0"的情况,S[i] 必须是一个全新的字符。

  **情况 3:1 ≤ q ≤ p**
  说明失配若干步后匹配到 q。
  需要验证合法性:
    - q-1 必须在 p 的失配链上。
    - 链上从 p 到 q 的每个位置 c,S[c+1] 必须与 S[q] 不同。
  如果不满足,返回 0。
  **启发式:** 这是"失配后部分匹配"的情况,需要确保不会产生更长的匹配。

【六、算法流程】
  1) 读入 n, m 和数组 A。
  2) 检查 A 的合法性:
       - A[1] 必须为 0。
       - 对于 i ≥ 2,A[i] ≤ A[i-1] + 1。
     如果不满足,输出 0。
  3) 建立失配树,计算每个节点的根(等价类代表)。
  4) 计算 g[p]:位置 1..p+1 中已锁定的不同字符类数。
  5) 初始化 ans = m(S[1] 有 m 种选择)。
  6) 对于 i 从 2 到 n:
       - p = A[i-1], q = A[i]。
       - 根据 q 的值分三种情况处理。
  7) 输出 ans % MOD。

【七、复杂度】
  - 每个测试用例 O(n)。
  - 总复杂度 O(Σn) ≤ 5 × 10^5,可以过。

【八、注意事项】
  - next[1] 必须为 0。
  - next[i] ≤ next[i-1] + 1。
  - 用失配树和 g 数组来统计禁选字符数。
  - 对于 q > 0 且 q ≤ p 的情况,需要验证失配链的合法性。
  • 基于以上思路,代码如下:
cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
class ReverseKmp {
public:
    static const long long MOD = 998244353LL;
    void run() {
        ios::sync_with_stdio(false);
        cin.tie(nullptr);
        int T;
        cin >> T;
        while (T--) {
            int n;
            long long m;
            cin >> n >> m;
            A.resize(n);
            for (int i = 0; i < n; i++) cin >> A[i];
            cout << solveCase(n, m) << "\n";
        }
    }

private:
    vector<int> A;          // 输入的 next 数组(1-indexed 存储)
    long long solveCase(int n, long long m) {
        // fail[i] = A[i-1],1-indexed
        fail.assign(n + 1, 0);
        for (int i = 1; i <= n; i++) fail[i] = A[i - 1];
        // ===== 合法性检查 =====
        // KMP 的 next 数组必须满足:
        //   next[1] = 0
        //   next[i] <= next[i-1] + 1
        if (fail[1] != 0) return 0;
        for (int i = 2; i <= n; i++) {
            if (fail[i] > fail[i - 1] + 1) return 0;
        }
        // ===== 失配树:找每个位置的"根"(等价类代表) =====
        // 如果 fail[x] == 0,说明 x 是一个新等价类的根
        // 否则,x 和 fail[x] 属于同一个等价类,根是 root[fail[x]]
        root.assign(n + 1, 0);
        for (int x = 1; x <= n; x++) {
            root[x] = (fail[x] == 0) ? x : root[fail[x]];
        }
        // ===== g[p] = 位置 1..p+1 中已锁定的不同字符类数 =====
        // g[0] = 1:位置 1 已经锁定了一个字符类
        // 对于 p >= 1:
        //   如果 p+1 <= n 且 fail[p+1] == 0,说明位置 p+1 是一个新字符类,g[p] = g[fail[p]] + 1
        //   否则,位置 p+1 和之前的某个位置属于同一类,g[p] = g[fail[p]]
        g.assign(n + 1, 0);
        g[0] = 1;
        for (int p = 1; p <= n; p++) {
            long long add = (p + 1 <= n && fail[p + 1] == 0) ? 1 : 0;
            g[p] = g[fail[p]] + add;
        }
        // ===== 逐个位置计数 =====
        long long ans = m % MOD;    // S[1] 有 m 种选择
        for (int i = 2; i <= n; i++) {
            int p = fail[i - 1];    // 前一个位置的 next 值
            int q = fail[i];        // 当前位置的 next 值
            if (q == p + 1) {
                // 情况 1:直接扩展
                // S[i] = S[p+1],字符唯一确定,无额外选择
                continue;
            }
            if (q == 0) {
                // 情况 2:一路失配到 0
                // S[i] 必须是全新字符,不能等于位置 1..p+1 中出现的任何字符
                // 选择数 = m - g[p]
                long long f = m - g[p];
                if (f <= 0) return 0;
                ans = ans * (f % MOD) % MOD;
            } else {
                // 情况 3:失配若干步后匹配到 q(1 <= q <= p)
                // 需要验证合法性:
                //   (a) q-1 必须在 p 的失配链上
                //   (b) 链上从 p 到 q 的每个位置 c,S[c+1] 必须与 S[q] 不同
                // 验证 (a):q-1 是否在 p 的失配链上
                bool ok = (q - 1 == 0);
                int c = p;
                while (!ok && c > 0) {
                    if (c == q - 1) { ok = true; break; }
                    c = fail[c];
                }
                if (!ok) return 0;
                // 验证 (b):链上每个 S[c+1] 是否与 S[q] 属于同一等价类
                int rq = root[q];   // S[i] 必须与 S[q] 属于同一等价类
                c = p;
                while (c >= q) {
                    if (root[c + 1] == rq) return 0;    // 冲突:S[c+1] 与 S[q] 同类
                    c = fail[c];
                }
            }
        }
        return ans % MOD;
    }
    vector<int> fail;       // next 数组(1-indexed)
    vector<int> root;       // 每个位置的等价类代表
    vector<long long> g;    // g[p] = 位置 1..p+1 中已锁定的不同字符类数
};
int main() {
    ReverseKmp solver;
    solver.run();
    return 0;
}

3.2 Trie 字典树

适用场景: 前缀查询、字符串统计、异或最值(01Trie)、最长公共前缀、字典序第 k 小

3.2.1 基本原理

核心思想: Trie 是一棵多叉树,每条边代表一个字符,从根到某个节点的路径拼成一个字符串(前缀)。

  • 根节点代表空串
  • 每个节点代表一个前缀
  • 从根到某节点的路径上所有字符拼起来,就是该节点代表的前缀
  • 节点上的标记 cnt[u] 表示有多少个字符串以该节点结尾

关键性质:

  • 插入、查询的时间复杂度都是 O(len),与字符串数量无关
  • 空间复杂度 O(总字符数 × 字符集大小)
  • 多个字符串共享公共前缀,节省空间

时间复杂度: 单次插入 / 查询 O(len),len 为字符串长度。

3.2.2 基础模板(字符串统计)
cpp 复制代码
#include <bits/stdc++.h>
using namespace std;

class Solver {
public:
    static constexpr int MAXN = 100005;   // 节点数上限 = 所有字符串总字符数 + 1
    struct Trie {
        int ch[MAXN][26];      // 每个节点的 26 个儿子
        int cnt[MAXN];         // 以该节点结尾的字符串数量
        int idx;               // 节点计数器

        Trie() { idx = 1; }    // 0 号是根节点,从 1 开始分配

        // 插入字符串 s
        void insert(const string &s) {
            int u = 0;
            for (char c : s) {
                int v = c - 'a';
                if (!ch[u][v]) ch[u][v] = idx++;
                u = ch[u][v];
            }
            cnt[u]++;
        }

        // 查询字符串 s 出现的次数
        int query(const string &s) {
            int u = 0;
            for (char c : s) {
                int v = c - 'a';
                if (!ch[u][v]) return 0;
                u = ch[u][v];
            }
            return cnt[u];
        }

        // 查询以 s 为前缀的字符串数量
        int queryPrefix(const string &s) {
            int u = 0;
            for (char c : s) {
                int v = c - 'a';
                if (!ch[u][v]) return 0;
                u = ch[u][v];
            }
            // 需要额外维护 sz[u] = 以 u 为根的子树中的字符串数量
            return sz[u];
        }
    };
};

思路要点:

  • 每个节点代表一个前缀
  • 根节点为空串,路径上的字符组成字符串
  • 插入、查询都是 O(len)
  • cnt[u] 表示以 u 结尾的字符串数量
  • 若要查询前缀数量,需要额外维护 sz[u](子树中的字符串总数)
3.2.3 01Trie(求最大异或和)

核心思想: 把每个整数看成二进制串(从高位到低位),插入 01Trie。查询时,尽量走与当前位相反的子树,这样异或结果的当前位就是 1,最终得到最大异或值。

关键性质:

  • 01Trie 是一棵二叉树,每个节点最多两个儿子:0 和 1
  • 从高位到低位插入,保证高位优先
  • 查询时贪心:能走相反位就走,否则只能走相同位

适用场景:

  • 给定一个数组,多次询问某个数与数组中某个数的最大异或和
  • 求数组中任意两个数的最大异或和
  • 可持久化 01Trie(区间最大异或和)

模板:

cpp 复制代码
#include <bits/stdc++.h>
using namespace std;

class Trie {
public:
    static constexpr int MAXN = 100005;   // 数的个数
    static constexpr int LOG = 31;        // 二进制位数(int 最多 31 位,从高位到低位)
    struct Trie01 {
        int ch[MAXN * LOG][2]; // 每个节点的两个儿子
        int cnt[MAXN * LOG];   // 以该节点结尾的数的数量
        int idx;               // 节点计数器

        Trie01() { idx = 1; }

        // 插入一个数 x
        void insert(int x) {
            int u = 0;
            for (int i = LOG; i >= 0; i--) {
                int bit = (x >> i) & 1;
                if (!ch[u][bit]) ch[u][bit] = idx++;
                u = ch[u][bit];
                cnt[u]++;
            }
        }

        // 查询与 x 异或最大的数(返回异或值)
        int queryMaxXor(int x) {
            int u = 0;
            int res = 0;
            for (int i = LOG; i >= 0; i--) {
                int bit = (x >> i) & 1;
                // 优先走相反位
                if (ch[u][bit ^ 1]) {
                    res |= (1 << i);   // 当前位异或结果为 1
                    u = ch[u][bit ^ 1];
                } else {
                    u = ch[u][bit];
                }
            }
            return res;
        }
    };
};

思路要点:

  • 每个数按二进制从高位到低位插入
  • 查询时贪心走相反位,使异或结果的当前位为 1
  • 如果相反位不存在,只能走相同位
  • 时间复杂度 O(LOG),LOG 通常为 31

示例:求数组中任意两个数的最大异或和

cpp 复制代码
class Trie {
public:
    int findMaximumXOR(vector<int>& nums) {
        Trie01 trie;
        int ans = 0;
        for (int x : nums) {
            trie.insert(x);
            ans = max(ans, trie.queryMaxXor(x));
        }
        return ans;
    }
};

思路要点:

  • 边插入边查询
  • 对于每个数 x,查询与它异或最大的已插入数
  • 取所有结果的最大值

示例:区间最大异或和(可持久化 01Trie)

cpp 复制代码
class Trie {
public:
    // 可持久化 01Trie
    // root[i] 表示插入前 i 个数后的根节点
    int root[MAXN];
    int ch[MAXN * LOG][2];
    int cnt[MAXN * LOG];
    int idx;
    void insert(int pre, int &cur, int x) {
        cur = ++idx;
        int u = cur, v = pre;
        for (int i = LOG; i >= 0; i--) {
            int bit = (x >> i) & 1;
            ch[u][bit ^ 1] = ch[v][bit ^ 1];  // 复制另一棵子树
            ch[u][bit] = ++idx;               // 新建当前子树
            cnt[u] = cnt[v] + 1;
            u = ch[u][bit];
            v = ch[v][bit];
        }
        cnt[u] = cnt[v] + 1;
    }
    // 查询区间 [l, r] 中与 x 异或最大的值
    int query(int l, int r, int x) {
        int u = root[r], v = root[l - 1];
        int res = 0;
        for (int i = LOG; i >= 0; i--) {
            int bit = (x >> i) & 1;
            int want = bit ^ 1;
            if (cnt[ch[u][want]] - cnt[ch[v][want]] > 0) {
                res |= (1 << i);
                u = ch[u][want];
                v = ch[v][want];
            } else {
                u = ch[u][bit];
                v = ch[v][bit];
            }
        }
        return res;
    }
};

思路要点:

  • 可持久化 01Trie:每次插入新建 O(LOG) 个节点
  • root[i] 表示插入前 i 个数后的版本
  • 查询区间 l, r 时,用 root[r] - root[l-1] 做差
  • 时间复杂度 O(LOG)
3.2.4 常见应用
应用 做法
字符串统计 插入时 cnt[u]++,查询时返回 cnt[u]
前缀查询 维护 sz[u] 表示子树中字符串数量
最大异或和 01Trie + 贪心走相反位
区间最大异或和 可持久化 01Trie
最长公共前缀 两个字符串在 Trie 上的 LCA 深度
字典序第 k 小 在 Trie 上按子树大小二分
异或路径计数 树上 DFS + 01Trie
3.2.5 时间复杂度总结
操作 复杂度
插入字符串 O(len)
查询字符串 O(len)
查询前缀 O(len)
插入整数(01Trie) O(LOG)
查询最大异或 O(LOG)
可持久化插入 O(LOG)
区间最大异或 O(LOG)
3.2.6 注意事项
  • 字符集大小决定 ch[u][c] 的维度,小写字母是 26,数字是 10

  • 01Trie 的 LOG 通常取 31(int 范围)或 62(long long 范围)

  • 可持久化 01Trie 要注意空间:O(n × LOG)

  • 插入整数时要从高位到低位,保证贪心正确

  • 多测试用例时注意清空 ch、cnt、idx

  • Trie 的节点数上限 = 所有字符串总字符数 + 1

3.2.7 字典树补充---可持续化字典树

适用场景: 区间最大异或和、树上路径最大异或和、子树最大异或和、区间内选数使异或最大、按位贪心 + 区间计数

3.2.7.1 基本原理

核心思想: 普通 01Trie 只能维护"当前集合"的信息。如果要对任意区间 [l, r] 查询,就需要可持久化 :对每个前缀建一棵 01Trie,版本 root[i] 表示插入前 i 个数后的 Trie。

关键性质:

  • 可持久化 Trie 每次插入只新建 O(BIT) 个节点,BIT 为二进制位数
  • root[i] - root[l-1] 可以表示区间 [l, r] 的信息
  • 查询时同时从 root[r] 和 root[l-1] 向下走,做差判断某条子树是否有数
  • 适合解决"区间内选一个数与 x 异或最大"这类问题

与可持久化线段树的区别:

  • 可持久化线段树:值域二分
  • 可持久化 01Trie:按二进制位贪心
  • 两者本质相同,都是"前缀版本做差"
3.2.7.2 基础模板:区间最大异或和

问题: 给定数组 arr,每次查询 [l, r] 中选一个数与 x 异或,求最大值。

cpp 复制代码
#include <bits/stdc++.h>
using namespace std;

class Solver {
public:
    int run() {
        int n, m;
        cin >> n >> m;
        for (int i = 1; i <= n; i++) {
            int x;
            cin >> x;
            root[i] = insert(x, root[i - 1]);
        }
        while (m--) {
            int x, l, r;
            cin >> x >> l >> r;
            cout << query(x, root[l - 1], root[r]) << '\n';
        }
        return 0;
    }

private:
    static constexpr int MAXN = 200005;
    static constexpr int MAXT = MAXN * 32;
    static constexpr int BIT = 30;

    int root[MAXN];
    int tree[MAXT][2];
    int pass[MAXT];
    int cnt;

    // 插入 num,返回新版本根节点
    int insert(int num, int i) {
        int rt = ++cnt;
        tree[rt][0] = tree[i][0];
        tree[rt][1] = tree[i][1];
        pass[rt] = pass[i] + 1;
        for (int b = BIT, path, pre = rt, cur; b >= 0; b--, pre = cur) {
            path = (num >> b) & 1;
            i = tree[i][path];
            cur = ++cnt;
            tree[cur][0] = tree[i][0];
            tree[cur][1] = tree[i][1];
            pass[cur] = pass[i] + 1;
            tree[pre][path] = cur;
        }
        return rt;
    }

    // 查询 num 在区间 [u, v] 中的最大异或值
    // u = root[l-1], v = root[r]
    int query(int num, int u, int v) {
        int ans = 0;
        for (int b = BIT, path, best; b >= 0; b--) {
            path = (num >> b) & 1;
            best = path ^ 1;
            // 优先走相反位
            if (pass[tree[v][best]] > pass[tree[u][best]]) {
                ans += (1 << b);
                u = tree[u][best];
                v = tree[v][best];
            } else {
                u = tree[u][path];
                v = tree[v][path];
            }
        }
        return ans;
    }
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    Solver solver;
    return solver.run();
}

思路要点:

  • root[i] 表示插入前 i 个数后的版本
  • 插入时只新建 O(BIT) 个节点,复制旧版本信息
  • 查询时从高位到低位贪心,优先走相反位
  • pass[tree[v][best]] > pass[tree[u][best]] 判断区间内是否有该位为 best 的数
  • 时间复杂度 O(BIT),BIT 通常取 30 或 31
3.2.7.3 树上路径最大异或和

问题: 树上每个节点有权值,查询 u 到 v 的路径上任选一个点,与 z 异或最大。

思路:

  • 对每个节点建从根到它的可持久化 01Trie
  • 路径 (u, v) 的信息 = root[u] + root[v] - root[lca] - root[fa[lca]]
  • 用 LCA 拆路径
cpp 复制代码
#include <bits/stdc++.h>
using namespace std;

class Solver {
public:
    int run() {
        cin >> n >> m;
        for (int i = 1; i <= n; i++) cin >> arr[i];
        for (int i = 1; i < n; i++) {
            int u, v;
            cin >> u >> v;
            g[u].push_back(v);
            g[v].push_back(u);
        }
        dfs(1, 0);
        while (m--) {
            int u, v, z;
            cin >> u >> v >> z;
            int lcafa = stjump[lca(u, v)][0];
            int ans = max(query(z, root[lcafa], root[u]),
                          query(z, root[lcafa], root[v]));
            cout << ans << '\n';
        }
        return 0;
    }

private:
    static constexpr int MAXN = 100005;
    static constexpr int MAXT = MAXN * 32;
    static constexpr int MAXP = 17;
    static constexpr int BIT = 29;

    int n, m;
    int arr[MAXN];
    vector<int> g[MAXN];

    int deep[MAXN], stjump[MAXN][MAXP];
    int root[MAXN];
    int tree[MAXT][2];
    int pass[MAXT];
    int cnt;

    int insert(int num, int i) {
        int rt = ++cnt;
        tree[rt][0] = tree[i][0];
        tree[rt][1] = tree[i][1];
        pass[rt] = pass[i] + 1;
        for (int b = BIT, path, pre = rt, cur; b >= 0; b--, pre = cur) {
            path = (num >> b) & 1;
            i = tree[i][path];
            cur = ++cnt;
            tree[cur][0] = tree[i][0];
            tree[cur][1] = tree[i][1];
            pass[cur] = pass[i] + 1;
            tree[pre][path] = cur;
        }
        return rt;
    }

    int query(int num, int u, int v) {
        int ans = 0;
        for (int b = BIT, path, best; b >= 0; b--) {
            path = (num >> b) & 1;
            best = path ^ 1;
            if (pass[tree[v][best]] > pass[tree[u][best]]) {
                ans += (1 << b);
                u = tree[u][best];
                v = tree[v][best];
            } else {
                u = tree[u][path];
                v = tree[v][path];
            }
        }
        return ans;
    }

    void dfs(int u, int fa) {
        deep[u] = deep[fa] + 1;
        stjump[u][0] = fa;
        for (int p = 1; p < MAXP; p++) {
            stjump[u][p] = stjump[stjump[u][p - 1]][p - 1];
        }
        root[u] = insert(arr[u], root[fa]);
        for (int v : g[u]) {
            if (v != fa) dfs(v, u);
        }
    }

    int lca(int a, int b) {
        if (deep[a] < deep[b]) swap(a, b);
        for (int p = MAXP - 1; p >= 0; p--) {
            if (deep[stjump[a][p]] >= deep[b]) a = stjump[a][p];
        }
        if (a == b) return a;
        for (int p = MAXP - 1; p >= 0; p--) {
            if (stjump[a][p] != stjump[b][p]) {
                a = stjump[a][p];
                b = stjump[b][p];
            }
        }
        return stjump[a][0];
    }
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    Solver solver;
    return solver.run();
}

思路要点:

  • 从根 DFS,每个节点继承父亲的 Trie 并插入自己的值
  • 路径 (u, v) 的 Trie 信息 = root[u] + root[v] - root[lca] - root[fa[lca]]
  • 查询时用两个版本做差,等价于区间查询
  • 经典题型:洛谷 P4592
3.2.7.4 子树最大异或和

问题: 查询以 u 为根的子树中任选一个点,与 y 异或最大。

思路:

  • 按 DFS 序展开树,子树 u 对应区间 [dfn[u], dfn[u] + sz[u] - 1]
  • 用可持久化 01Trie 维护 DFS 序上的前缀版本
  • 查询 root[dfn[u] + sz[u] - 1] - root[dfn[u] - 1]
cpp 复制代码
class Solver {
public:
    // 在 dfs 时记录 dfn 和 sz,按 dfn 顺序插入
    void dfs(int u, int fa) {
        dfn[u] = ++cntd;
        siz[u] = 1;
        root1[dfn[u]] = insert(arr[u], root1[dfn[u] - 1]);
        for (int v : g[u]) {
            if (v != fa) {
                dfs(v, u);
                siz[u] += siz[v];
            }
        }
    }
    // 查询子树 u 中与 y 异或最大值
    int querySubtree(int u, int y) {
        return query(y, root1[dfn[u] - 1], root1[dfn[u] + siz[u] - 1]);
    }
};

思路要点:

  • 子树在 DFS 序上是连续区间
  • 用可持久化 01Trie 维护 DFS 序前缀
  • 查询时做差即可
  • 经典题型:洛谷 P4592 操作 1
3.2.7.5 次大值 + 可持久化 01Trie

问题: 选择一个长度 ≥ 2 的子数组,取其次大值与子数组中另一个数异或,求最大结果。

思路:

  • 枚举每个数作为次大值
  • 用单调栈或链表找出每个数的"作为次大值"的合法区间
  • 在区间内查询与它异或最大的数
  • 用可持久化 01Trie 支持区间查询
cpp 复制代码
class Trie {
public:
    // 对每个位置 i,找它作为次大值的区间 [l, r]
    // 用链表维护,从大到小删数,每次查询相邻两个位置的范围
    int compute() {
        int ans = 0;
        for (int i = 1, index, value, l1, l2, r1, r2; i <= n; i++) {
            index = arr[i].first;
            value = arr[i].second;
            l1 = last[index];
            l2 = last[l1];
            r1 = nxt[index];
            r2 = nxt[r1];
            if (l1 != 0) {
                ans = max(ans, query(value, root[l2], root[r1 - 1]));
            }
            if (r1 != n + 1) {
                ans = max(ans, query(value, root[l1], root[r2 - 1]));
            }
            nxt[l1] = r1;
            last[r1] = l1;
        }
        return ans;
    }
};

思路要点:

  • 从大到小枚举,用链表维护未删除位置
  • 对每个数作为次大值,限定区间
  • 区间内查询异或最大值
  • 经典题型:洛谷 P4098
3.2.7.6 常见应用总结
应用 做法 经典题
区间最大异或和 可持久化 01Trie + 版本做差 洛谷 P4735
树上路径最大异或和 从根 DFS + LCA 拆路径 洛谷 P4592
子树最大异或和 DFS 序 + 可持久化 01Trie 洛谷 P4592
区间内选数使 b ^ (x + a[i]) 最大 可持久化权值线段树 洛谷 P3293
次大值 + 区间异或最大 链表 + 可持久化 01Trie 洛谷 P4098
树上字符串前缀查询 可持久化字典树 + LCA 洛谷 P6088

统一思路:

  1. 建版本:对前缀 / 根到节点路径建可持久化 Trie
  2. 查询做差 :root[r] - root[l-1] 或 root[u] + root[v] - root[lca] - root[fa[lca]]
  3. 按位贪心:从高位到低位,优先走相反位
  4. 路径拆解:用 LCA 把路径拆成两段

复杂度:

  • 插入:O(BIT)
  • 查询:O(BIT)
  • 空间:O(n × BIT)
  • 树上路径查询:O(BIT + log n)
3.2.7.7 注意事项
  • BIT 取值要覆盖所有数的二进制位数,通常 30 或 31
  • root[0] 是空版本,所有指针为 0
  • 插入时 pass[rt] = pass[i] + 1 要放在复制之后
  • 查询时 pass[tree[v][best]] > pass[tree[u][best]] 判断区间内是否有该位的数
  • 树上路径查询要处理 LCA 的父节点
  • 空间 O(n × BIT),注意开大数组
  • 多组测试要清空 tree、pass、cnt
  • 对比可持久化权值线段树适合"值域区间计数"的问题,可持久化字典树适合"区间版本做差"的问题
3.2.8 区域赛题
text 复制代码
Perfect Word(2022 ICPC 西安区域赛 G / CF Gym 104077 G)

【题目描述】
给定 n 个字符串,要求找出最长的「完美单词」的长度。

一个字符串 t 被称为「完美单词」,当且仅当 t 的每一个非空子串都出现在给定的字符串中。

一个字符串 s 被称为 t 的子串,当且仅当 s 可以通过删除 t 的开头或结尾的若干个(可能为零个)字符得到。

【输入】
第一行包含一个整数 n(1 ≤ n ≤ 10^5)。
接下来 n 行,每行一个由小写英文字母组成的字符串。
保证所有给定字符串的总长度不超过 10^5。

【输出】
输出一个整数,表示最长的完美单词的长度。

【样例1】
输入:
  4
  a
  t
  b
  ab
输出:
  2

【样例解释】
字符串 "ab" 的子串有 "a", "b", "ab",它们都出现在给定字符串中,
所以 "ab" 是完美单词,长度为 2。没有更长的完美单词,输出 2。

【思路解说】

【一、核心引理】
一个字符串 t 是完美单词,当且仅当:
  1) t 本身出现在给定字符串中;
  2) |t| = 1,或者 t 去掉最后一个字符的前缀 t[1..L-1] 是完美单词,
     并且 t 去掉第一个字符的后缀 t[2..L] 是完美单词。

**证明思路:**
- 必要性:如果 t 是完美单词,那么 t 的所有子串都出现在给定字符串中。
  特别地,t[1..L-1] 和 t[2..L] 都是 t 的子串,所以它们也必须是完美单词。
- 充分性:如果 t[1..L-1] 和 t[2..L] 都是完美单词,那么它们的所有子串都出现在给定字符串中。
  而 t 的任意真子串要么是 t[1..L-1] 的子串,要么是 t[2..L] 的子串(或两者都是)。
  所以 t 的所有真子串都出现在给定字符串中,再加上 t 本身也出现,故 t 是完美单词。

【二、算法设计】
根据引理,我们可以按字符串长度从小到大进行 DP:
  - 用正串 Trie 判断前缀 t[1..L-1] 是否存在且完美。
  - 用反串 Trie 判断后缀 t[2..L] 是否存在且完美。

具体步骤:
  1) 把所有给定字符串插入正串 Trie(F),并记录每个字符串的结束节点。
  2) 把所有给定字符串反转后插入反串 Trie(R),并记录每个字符串的结束节点。
  3) 把所有字符串按长度从小到大排序。
  4) 按顺序处理每个字符串:
       - 如果长度为 1:只要它出现在给定字符串中,就是完美单词。
       - 否则:检查它的前缀(在 F 中)和后缀(在 R 中)是否都是完美单词。
       - 如果是,则标记该字符串为完美单词,并更新答案。
  5) 输出最长的完美单词长度。

【三、复杂度】
  - 建 Trie:O(总长度 × 26)
  - 排序:O(n log n)
  - DP:O(n)
  - 总复杂度:O(总长度 × 26 + n log n),可以过。

【四、注意事项】
  - 正串 Trie 的节点编号从 1 开始(1 为根),0 表示空。
  - 反串 Trie 同理。
  - 处理长度为 1 的字符串时,不需要检查前缀和后缀。
  - 用 char 数组标记 perfect 状态,节省空间。
  - 答案初始为 0,如果没有完美单词则输出 0。
  - 多组测试要清空 Trie 和 perfect 数组。
  • 基于以上思路,代码如下:
cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
// Trie 字典树结构体
struct Trie {
    vector<array<int, 26>> ch;          // ch[u][c] 表示节点 u 走字符 c 到达的儿子编号
                                        // 1 为根,0 表示空;节点编号 1..cnt
    vector<int> par;                    // par[u] = u 的父亲节点编号
    // 构造函数:初始化根节点
    Trie() {
        ch.resize(2);                   // 分配根节点(编号 1)和空节点(编号 0)
        for (auto& a : ch) a.fill(0);   // 所有儿子初始化为 0
        par.assign(2, 0);               // 根的父亲为 0
    }
    int size() const { return (int)ch.size(); }
    // 插入字符串 s,返回结束节点编号到 endNode
    void add(const string& s, int& endNode) {
        int u = 1;                      // 从根节点 1 开始
        for (char c : s) {
            int x = c - 'a';
            if (!ch[u][x]) {            // 如果儿子不存在,新建节点
                ch[u][x] = (int)ch.size();
                ch.emplace_back();
                ch.back().fill(0);
                par.push_back(u);       // 记录父亲
            }
            u = ch[u][x];               // 走到儿子
        }
        endNode = u;                    // 返回结束节点编号
    }
};
int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n;
    if (!(cin >> n)) return 0;
    vector<string> s(n);
    for (int i = 0; i < n; i++) cin >> s[i];
    Trie F, R;                          // F: 正串 Trie;R: 反串 Trie
    vector<int> nf(n), nr(n);           // nf[i]: s[i] 在 F 中的结束节点
                                        // nr[i]: s[i] 的反串在 R 中的结束节点
    for (int i = 0; i < n; i++) {
        F.add(s[i], nf[i]);             // 正串插入 F
        string r(s[i]);
        reverse(r.begin(), r.end());
        R.add(r, nr[i]);                // 反串插入 R
    }
    // 按字符串长度从小到大排序
    vector<int> ord(n);
    iota(ord.begin(), ord.end(), 0);
    sort(ord.begin(), ord.end(), [&](int a, int b) {
        return s[a].size() < s[b].size();
    });
    // perfectF[u]: F 中节点 u 代表的字符串是否是完美单词
    // perfectR[u]: R 中节点 u 代表的字符串是否是完美单词
    vector<char> perfectF(F.size(), 0), perfectR(R.size(), 0);
    int ans = 0;
    for (int idx : ord) {
        int L = (int)s[idx].size();
        int uf = nf[idx];               // 正串结束节点
        int ur = nr[idx];               // 反串结束节点
        bool ok;
        if (L == 1) {
            // 单字符:只要它出现在给定字符串中,就是完美单词
            ok = true;
        } else {
            // 前缀 t[1..L-1] 在 F 中的节点 = uf 的父亲
            int pp = F.par[uf];
            // 后缀 t[2..L] 的反串在 R 中的节点 = ur 的父亲
            int qq = R.par[ur];
            // 前后缀都必须是完美单词
            ok = perfectF[pp] && perfectR[qq];
        }
        if (ok) {
            perfectF[uf] = 1;           // 标记正串为完美单词
            perfectR[ur] = 1;           // 标记反串为完美单词
            ans = max(ans, L);          // 更新答案
        }
    }
    cout << ans << '\n';
    return 0;
}

3.3 Manacher算法

适用场景: 求最长回文子串、回文子串数量、回文子串长度分布、双回文串等

3.3.1 基本原理

核心思想: 在字符串的每个字符之间以及两端插入特殊字符(如 #),把奇数长度和偶数长度的回文串统一成奇数长度,然后利用回文串的对称性,避免重复扩展。

扩展串构造:

原串 s = "aba",扩展后 ss = "#a#b#a#",长度 n = 2 * |s| + 1。

  • 原串下标 i 对应扩展串下标 2 * i + 1
  • 扩展串下标 i 为偶数时是 #,奇数时是原串字符

回文半径数组 p[i]:

p[i] 表示以 ss[i] 为中心的最长回文半径(包含中心本身)。

  • 若 ss[i] == '#',p[i] - 1 是以该位置为中心的偶数长度回文串长度
  • 若 ss[i] != '#',p[i] - 1 是以该位置为中心的奇数长度回文串长度
  • 原串中以 i 为中心的最长回文子串长度 = p[2 * i + 1] - 1
  • 原串中以 i 和 i+1 为中心的最长偶数回文子串长度 = p[2 * i + 2] - 1

核心变量:

  • c:当前已知的最长回文串的中心
  • r:当前已知的最长回文串的右边界(不包含)
  • i:当前遍历到的位置
  • 2 * c - i:i 关于 c 的对称点

状态转移:

复制代码
len = r > i ? min(p[2 * c - i], r - i) : 1
  • 若 i < r,利用对称点 2 * c - i 的半径,但不超过 r - i
  • 若 i >= r,从 1 开始暴力扩展

然后暴力向两边扩展,更新 p[i],并尝试更新 c 和 r。

时间复杂度: O(n),每个字符最多被扩展一次。

3.3.2 基础模板
cpp 复制代码
#include <bits/stdc++.h>
using namespace std;

class Solver {
public:
    static constexpr int MAXN = 1000005;
    char ss[MAXN << 1];
    int p[MAXN << 1];
    int n;
    void manacherss(const string &s) {
        n = s.size() * 2 + 1;
        for (int i = 0, j = 0; i < n; i++) {
            ss[i] = (i & 1) == 0 ? '#' : s[j++];
        }
    }
    void manacher(const string &s) {
        manacherss(s);
        for (int i = 0, c = 0, r = 0, len; i < n; i++) {
            len = r > i ? min(p[2 * c - i], r - i) : 1;
            while (i + len < n && i - len >= 0 && ss[i + len] == ss[i - len]) {
                len++;
            }
            if (i + len > r) {
                r = i + len;
                c = i;
            }
            p[i] = len;
        }
    }
};

思路要点:

  • 扩展串长度为 2 * |s| + 1
  • p[i] 是回文半径(含中心)
  • r 是当前最右回文边界(不包含)
  • 利用对称点 2 * c - i 加速,避免重复扩展
  • 时间复杂度 O(n),空间复杂度 O(n)
3.3.3 最长回文子串(LeetCode 5)

题意: 找到字符串 s 中最长的回文子串并返回。

cpp 复制代码
class Solver {
public:
    class Solution {
    public:
        static const int MAXN = 1005;
        char ss[MAXN << 1];
        int p[MAXN << 1];
        int n, maxLen, endPos;

        void manacherss(const string &s) {
            n = s.size() * 2 + 1;
            for (int i = 0, j = 0; i < n; i++) {
                ss[i] = (i & 1) == 0 ? '#' : s[j++];
            }
        }

        void manacher(const string &s) {
            manacherss(s);
            maxLen = endPos = 0;
            for (int i = 0, c = 0, r = 0, len; i < n; i++) {
                len = r > i ? min(p[2 * c - i], r - i) : 1;
                while (i + len < n && i - len >= 0 && ss[i + len] == ss[i - len]) {
                    len++;
                }
                if (i + len > r) {
                    r = i + len;
                    c = i;
                }
                if (len > maxLen) {
                    maxLen = len - 1;
                    endPos = (i + len - 1) / 2;
                }
                p[i] = len;
            }
        }

        string longestPalindrome(string s) {
            manacher(s);
            return s.substr(endPos - maxLen, maxLen);
        }
    };
};

思路要点:

  • maxLen 记录最长回文子串长度
  • endPos 记录最长回文子串的结束位置(原串下标)
  • 最终答案 = s.substr(endPos - maxLen, maxLen)
3.3.4 回文子串数量(LeetCode 647)

题意: 返回字符串 s 的回文子串数量。

cpp 复制代码
class Solver {
public:
    class Solution {
    public:
        static const int MAXN = 1005;
        char ss[MAXN << 1];
        int p[MAXN << 1];
        int n;

        void manacherss(const string &s) {
            n = s.size() * 2 + 1;
            for (int i = 0, j = 0; i < n; i++) {
                ss[i] = (i & 1) == 0 ? '#' : s[j++];
            }
        }

        void manacher(const string &s) {
            manacherss(s);
            for (int i = 0, c = 0, r = 0, len; i < n; i++) {
                len = r > i ? min(p[2 * c - i], r - i) : 1;
                while (i + len < n && i - len >= 0 && ss[i + len] == ss[i - len]) {
                    len++;
                }
                if (i + len > r) {
                    r = i + len;
                    c = i;
                }
                p[i] = len;
            }
        }

        int countSubstrings(string s) {
            manacher(s);
            int ans = 0;
            for (int i = 0; i < n; i++) {
                ans += p[i] / 2;
            }
            return ans;
        }
    };
};

思路要点:

  • 每个位置 i 的 p[i] 表示以 i 为中心的回文半径
  • 以 i 为中心的回文子串数量 = p[i] / 2
  • 累加所有位置的贡献即为答案
3.3.5 不重叠回文子串的最多数目(LeetCode 2472)

题意: 给定字符串 str 和正数 k,把 str 切分成多个长度 ≥ k 且互不重叠的回文子串,返回最多能划分出几个。

cpp 复制代码
class Solver {
public:
    class Solution {
    public:
        static const int MAXN = 2005;
        char ss[MAXN << 1];
        int p[MAXN << 1];
        int n;

        void manacherss(const string &s) {
            n = s.size() * 2 + 1;
            for (int i = 0, j = 0; i < n; i++) {
                ss[i] = (i & 1) == 0 ? '#' : s[j++];
            }
        }

        // 从扩展串位置 l 开始,找最近的半径 > k 的回文中心
        // 返回最右下标(一定命中 '#')
        int find(int l, int k) {
            for (int i = l, c = l, r = l, len; i < n; i++) {
                len = r > i ? min(p[2 * c - i], r - i) : 1;
                while (i + len < n && i - len >= l && ss[i + len] == ss[i - len]) {
                    if (++len > k) {
                        return i + k + (ss[i + k] != '#' ? 1 : 0);
                    }
                }
                if (i + len > r) {
                    r = i + len;
                    c = i;
                }
                p[i] = len;
            }
            return -1;
        }

        int maxPalindromes(string s, int k) {
            manacherss(s);
            int ans = 0;
            int next = 0;
            while ((next = find(next, k)) != -1) {
                ans++;
            }
            return ans;
        }
    };
};

思路要点:

  • 贪心:从左到右找最近的、长度 ≥ k 的回文子串,切掉后继续
  • find(l, k) 从扩展串位置 l 开始,找半径 > k 的回文中心
  • 返回最右下标,保证命中 #,便于下一次查找
  • 时间复杂度 O(n)
3.3.6 长度前 k 名的奇数长度回文子串长度乘积(洛谷 P1659)

题意: 给定字符串 s 和数值 k,只关心所有奇数长度的回文子串,返回其中长度前 k 名的回文子串的长度乘积。若奇数长度回文子串个数不够 k 个,返回 -1。答案对 19930726 取模。

cpp 复制代码
#include <bits/stdc++.h>
using namespace std;

class Solver {
public:
    int run() {
        int m;
        long long k;
        string s;
        cin >> m >> k >> s;
        cout << compute(s, k) << '\n';
        return 0;
    }

private:
    static constexpr int MOD = 19930726;
    static constexpr int MAXN = 1000005;

    char ss[MAXN << 1];
    int p[MAXN << 1];
    int cnt[MAXN];
    int n;

    void manacherss(const string &s) {
        n = s.size() * 2 + 1;
        for (int i = 0, j = 0; i < n; i++) {
            ss[i] = (i & 1) == 0 ? '#' : s[j++];
        }
    }

    void manacher(const string &s) {
        manacherss(s);
        for (int i = 0, c = 0, r = 0, len; i < n; i++) {
            len = r > i ? min(p[2 * c - i], r - i) : 1;
            while (i + len < n && i - len >= 0 && ss[i + len] == ss[i - len]) {
                len++;
            }
            if (i + len > r) {
                r = i + len;
                c = i;
            }
            p[i] = len;
        }
    }

    long long power(long long x, long long n) {
        long long ans = 1;
        while (n > 0) {
            if (n & 1) ans = ans * x % MOD;
            x = x * x % MOD;
            n >>= 1;
        }
        return ans;
    }

    int compute(const string &s, long long k) {
        manacher(s);
        int m = s.size();
        // 统计每种奇数长度的回文子串数量
        for (int i = 1; i < n; i += 2) {
            cnt[p[i] - 1]++;
        }
        long long ans = 1;
        long long sum = 0;
        for (int len = (m & 1) ? m : (m - 1); len >= 1 && k > 0; len -= 2) {
            sum += cnt[len];
            ans = ans * power(len, min(k, sum)) % MOD;
            k -= sum;
        }
        return k > 0 ? -1 : (int)ans;
    }
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    Solver solver;
    return solver.run();
}

思路要点:

  • 只关心奇数长度回文子串,对应扩展串中 i 为奇数的位置
  • cnt[len] 统计长度为 len 的奇数回文子串数量
  • 从大到小枚举长度,用快速幂累乘
  • 若 k 还有剩余,返回 -1
3.3.7 最长双回文串长度(洛谷 P4555)

题意: 输入字符串 s,求 s 的最长双回文子串 t 的长度。双回文子串就是可以分成两个回文串的字符串。

cpp 复制代码
#include <bits/stdc++.h>
using namespace std;

class Solver {
public:
    int run() {
        string s;
        cin >> s;
        cout << compute(s) << '\n';
        return 0;
    }

private:
    static constexpr int MAXN = 100005;

    char ss[MAXN << 1];
    int p[MAXN << 1];
    int lef[MAXN << 1], rig[MAXN << 1];
    int n;

    void manacherss(const string &s) {
        n = s.size() * 2 + 1;
        for (int i = 0, j = 0; i < n; i++) {
            ss[i] = (i & 1) == 0 ? '#' : s[j++];
        }
    }

    void manacher(const string &s) {
        manacherss(s);
        for (int i = 0, c = 0, r = 0, len; i < n; i++) {
            len = r > i ? min(p[2 * c - i], r - i) : 1;
            while (i + len < n && i - len >= 0 && ss[i + len] == ss[i - len]) {
                len++;
            }
            if (i + len > r) {
                r = i + len;
                c = i;
            }
            p[i] = len;
        }
    }

    int compute(const string &s) {
        manacher(s);
        // 计算每个位置作为回文串右端点时,向左能延伸的最长回文长度
        for (int i = 0, j = 0; i < n; i++) {
            while (i + p[i] > j) {
                lef[j] = j - i;
                j += 2;
            }
        }
        // 计算每个位置作为回文串左端点时,向右能延伸的最长回文长度
        for (int i = n - 1, j = n - 1; i >= 0; i--) {
            while (i - p[i] < j) {
                rig[j] = i - j;
                j -= 2;
            }
        }
        int ans = 0;
        for (int i = 2; i <= n - 3; i += 2) {
            ans = max(ans, lef[i] + rig[i]);
        }
        return ans;
    }
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    Solver solver;
    return solver.run();
}

思路要点:

  • lef[i]:以位置 i 为右端点时,向左能延伸的最长回文长度
  • rig[i]:以位置 i 为左端点时,向右能延伸的最长回文长度
  • 枚举分割点 i,答案 = lef[i] + rig[i] 的最大值
  • 注意分割点必须在 # 上,且两个回文串都不能为空
3.3.8 时间复杂度总结
操作 复杂度
扩展串构造 O(n)
Manacher 预处理 O(n)
最长回文子串 O(n)
回文子串数量 O(n)
不重叠回文子串最多数目 O(n)
长度前 k 名奇数回文子串乘积 O(n log MOD)
最长双回文串 O(n)
3.3.9 注意事项
  • 扩展串长度是原串的 2 倍 + 1,数组要开两倍以上

  • p[i] 是回文半径(含中心),不是回文长度

  • 原串回文长度 = p[i] - 1

  • r 是右边界(不包含),不是最右下标

  • 对称点 2 * c - i 可能越界,要用 min(p[2 * c - i], r - i) 限制

  • 多测试用例时注意清空 p 数组(或用时间戳优化)

3.3.x 区域赛题
  • 题目链接 :2025ccpc哈尔滨站J

  • 题目描述及核心思路:

    text 复制代码
    【题目描述】
    给定一个只由 'o'、'v'、'w' 三种字符组成的字符串 s。
    这些字符在视觉上有"连字"效果:
      - 两个连续的 'v' 看起来像一个 'w'。
      - 连续的 'v' 和 'w' 会连成一条折线。
      - 'o' 是一个圆,不产生尖角。
    题目定义一个字符串是"好的",当且仅当它写在纸上时,
    整个图形是左右镜像对称的(即视觉上是回文)。
    你需要找到给定字符串 s 中最长的"好的"子串,并输出这个子串。
    如果存在多个最长,输出任意一个即可。
    
    【输入】
    第一行:T,表示测试用例个数。
    每个测试用例:
      第一行:n,表示字符串长度。
      第二行:一个长度为 n 的字符串 s(只含 'o'、'v'、'w')。
    
    【输出】
    每个测试用例输出一行,表示最长的"好的"子串。
    
    【样例1】
    输入:
      3
      8
      wwovvvv
      16
      wwwoooooooooovwww
      11
      wwvovvovvvv
    输出:
      wwovvvv
      oooooooo
      vvvv
    
    【思路解说】
    【一、核心观察】
    题目中的"视觉回文"不能直接在原串上判断,
    因为 'w' 在视觉上相当于两个 'v',
    所以需要把原串映射成一个"视觉序列":
      - 'v'  -> 1 个下尖角
      - 'w'  -> 2 个下尖角
      - 'o'  -> 0 个下尖角(或视为分隔符)
    
    映射后的序列是一个整数序列(每个数表示该位置贡献的尖角数量)。
    在这个序列上,一个子串是"好的"当且仅当它本身是回文的。
    
    【二、映射与边界处理】
    直接把 'w' 展开成两个 'v' 会丢失原串的下标信息,
    所以需要记录每个视觉位置对应的原串下标(或原串字符的起始位置)。
    
    具体做法:
      1) 遍历原串,把 'w' 展开成两个 1,'v' 保持 1,'o' 视为 2(或 0)。
      2) 用一个数组 bidx[] 记录每个视觉位置是否对应原串某个字符的起始位置,
         这样在找到回文区间后,可以快速映射回原串区间。
      3) bidx 的前缀和可以用来把视觉下标转换成原串下标。
    
    【三、Manacher 求最长视觉回文】
    在映射后的视觉序列 T 上跑 Manacher:
      - 因为 T 中每个元素是 1 或 2,不一定是字符,
        所以需要用"整数回文"的 Manacher 写法。
      - 分别处理奇数长度回文和偶数长度回文。
      - 每扩展一步,就用当前的回文区间 [a, b] 去更新答案:
          检查 a 和 b 是否都是合法的原串边界(即 isGood(a) 和 isGood(b))。
          若是,则用 bidx[b] - bidx[a] 作为原串中的长度,更新最优解。
    
    【四、为什么不能直接在原串上跑 Manacher】
    因为原串的 'w' 在视觉上是两个 'v',
    直接对原串做 Manacher 会把 'w' 当成一个字符,
    从而漏掉 'w' 和 'v' 之间的视觉对称关系。
    必须先把 'w' 展开成两个视觉单位,再跑 Manacher。
    
    【五、算法流程】
      1) 读入字符串 s。
      2) 构造视觉序列 T:
           'v' -> 1
           'w' -> 1, 1
           'o' -> 2(或 0,视具体定义)
         同时记录每个视觉位置是否对应原串的字符起始位置(bidx)。
      3) 对 bidx 做前缀和,方便把视觉下标映射回原串下标。
      4) 在 T 上跑 Manacher(奇回文 + 偶回文):
           每次扩展回文半径时,检查当前区间 [a, b] 的两端是否都是原串边界。
           若是,则用 bidx[b] - bidx[a] 更新最长长度。
      5) 根据最优的视觉区间 [bestA, bestB],映射回原串区间 [st, st + sublen)。
      6) 输出对应的子串。
    
    【六、复杂度】
      构造视觉序列:O(n)
      Manacher:O(n)
      映射回原串:O(1)
      总复杂度:O(n),可以过。
    
    【七、注意事项】
      - 'w' 必须展开成两个视觉单位,不能当成一个字符处理。
      - Manacher 过程中每次扩展都要检查边界合法性,
        否则可能把视觉上对称但原串上不对齐的区间误判为答案。
      - bidx 的前缀和用于快速计算原串长度,避免 substr 拷贝。
      - 多测试用例时注意只清空用到的部分,不要 memset 整个大数组。
      - 输出时用 cout.write 直接输出区间,避免额外拷贝。
    • 基于以上思路代码如下:

      cpp 复制代码
      #include <bits/stdc++.h>
      using namespace std;
      // 视觉序列总长度上限:n 最大约 1e7,w 展开后最多翻倍
      const int MAXTOK = 20000010;
      int T[MAXTOK];          // 视觉序列:v->1, w->1,1, o->2
      int bidx[MAXTOK + 1];   // bidx[i] = 视觉序列前 i 个位置对应原串的字符数
      int rad[MAXTOK];        // Manacher 回文半径数组
      int main(){
          ios::sync_with_stdio(false);
          cin.tie(nullptr);
          int t;
          cin >> t;
          while (t--) {
              int n;
              string s;
              cin >> n >> s;
              // 计算视觉序列的总长度 N
              // 'w' 展开成两个 1,所以贡献 2;'v' 和 'o' 各贡献 1
              int N = 0;
              for (char ch : s) {
                  if (ch == 'w') N += 2;
                  else N += 1;
              }
              // 只清空用到的部分,避免 memset 整个大数组
              // bidx[0..N] 都需要置 0
              for (int p = 0; p <= N; p++) bidx[p] = 0;
              // 构造视觉序列 T,同时标记原串的字符边界
              int idx = 0;
              for (char ch : s) {
                  if (ch == 'w') {
                      T[idx++] = 1;   // w 展开成两个视觉单位 1
                      T[idx++] = 1;
                  }
                  else if (ch == 'v') {
                      T[idx++] = 1;   // v 是一个视觉单位 1
                  }
                  else // 'o'
                  {
                      T[idx++] = 2;   // o 用 2 表示(与 1 区分开,避免误判)
                  }
                  // 每个原串字符结束后,标记一个边界
                  bidx[idx] = 1;
              }
             // bidx 前缀和:bidx[p] 表示视觉序列前 p 个位置对应原串多少个字符
              for (int p = 1; p <= N; p++)
                  bidx[p] += bidx[p - 1];
              int best = 0, bestA = 0, bestB = 0;
              // 判断视觉位置 p 是否是原串的字符边界
              // p == 0 是起点,或者 bidx[p] > bidx[p-1] 说明 p 处有原串字符结束
              auto isGood = [&](int p) -> bool {
                  return p == 0 || bidx[p] > bidx[p - 1];
              };
              // 尝试用视觉区间 [a, b) 更新答案
              // 要求 a 和 b 都是原串的字符边界,这样映射回原串才是完整的字符
              auto consider = [&](int a, int b) {
                  if (a >= 0 && b <= N && isGood(a) && isGood(b))
                  {
                      int len = bidx[b] - bidx[a];   // 原串中的长度
                      if (len > best)
                      {
                          best = len;
                          bestA = a;
                          bestB = b;
                      }
                  }
              };
              // 先考虑长度为 1 的视觉区间(单个字符本身)
              for (int i = 0; i < N; i++) consider(i, i + 1);
              // ===== 奇数长度回文 Manacher =====
              // 以 i 为中心,向两边扩展
              {
                  int l = 0, r = -1;
                  for (int i = 0; i < N; i++)
                  {
                      // 利用对称点初始化半径
                      int k = (i > r) ? 1 : min(rad[l + r - i], r - i + 1);
                      // 暴力扩展
                      while (i - k >= 0 && i + k < N && T[i - k] == T[i + k])
                      {
                          k++;
                          consider(i - k + 1, i + k);
                      }
                      rad[i] = k - 1;
                      consider(i - k + 1, i + k);
                      // 更新最右回文边界
                      if (i + rad[i] > r)
                      {
                          l = i - rad[i];
                          r = i + rad[i];
                      }
                  }
              }
              // ===== 偶数长度回文 Manacher =====
              // 以 i-1 和 i 之间为中心,向两边扩展
              {
                  int l = 0, r = -1;
                  for (int i = 0; i < N; i++)
                  {
                      // 利用对称点初始化半径
                      int k = (i > r) ? 0 : min(rad[l + r - i + 1], r - i + 1);
                      // 暴力扩展
                      while (i - k - 1 >= 0 && i + k < N && T[i - k - 1] == T[i + k])
                      {
                          k++;
                          consider(i - k, i + k);
                      }
                      rad[i] = k;
                      consider(i - k, i + k);
                      // 更新最右回文边界
                      if (i + rad[i] - 1 > r)
                      {
                          l = i - rad[i];
                          r = i + rad[i] - 1;
                      }
                  }
              }
              // 把最优的视觉区间 [bestA, bestB) 映射回原串区间
              int st = bidx[bestA];                   // 原串起始下标
              int sublen = bidx[bestB] - st;          // 原串子串长度
              // 直接输出,避免 substr 拷贝
              cout.write(&s[st], sublen);
              cout << '\n';
          }
          return 0;
      }

      ​

3.4 字符串哈希(多项式哈希)

适用场景: 快速判断两个字符串是否相等、求最长公共子串、回文判断、子串匹配等

3.4.1 基本原理

核心思想: 把字符串看成一个 base 进制的超大数字,每一位字符对应进制中的一位,最后对一个大质数取模,压缩成一个整数,这个整数就是字符串的哈希值。

滚动递推公式:

Hash = Hash × BASE + c \text{Hash} = \text{Hash} \times \text{BASE} + c Hash=Hash×BASE+c

其中 c 为当前字符的值(通常取 ASCII 码),BASE 一般取质数(如 131、13331)。

原理来源: 类比十进制数字的递推计算:

复制代码
计算 123:
1. num = 1
2. num = 1 * 10 + 2 = 12
3. num = 12 * 10 + 3 = 123

把 10 换成 BASE,把数字换成字符,就得到字符串哈希的递推式。

示例: 以字符串 "aba" 为例,a = 97, b = 98,BASE = 131

复制代码
第一个字符 a:hash = 0 * 131 + 97       = 97
第二个字符 b:hash = 97 * 131 + 98      = 12805
第三个字符 a:hash = 12805 * 131 + 97   = 1677552
3.4.2 基础实现(洛谷 P3370)

题意: 给定 n 个字符串,求其中不同字符串的个数。

cpp 复制代码
#include <bits/stdc++.h>
using namespace std;

class Solver {
public:
    int run() {
        int n;
        cin >> n;
        for (int i = 0; i < n; i++) {
            string s;
            cin >> s;
            nums[i] = getHash(s);
        }

        sort(nums, nums + n);
        int ans = (n == 0 ? 0 : 1);
        for (int i = 1; i < n; i++) {
            if (nums[i] != nums[i - 1]) ans++;
        }
        cout << ans << '\n';
        return 0;
    }

private:
    static constexpr int MAXN = 10005;
    static constexpr long long BASE = 131;

    long long nums[MAXN];

    long long getHash(const string &s) {
        long long h = 0;
        for (char c : s) {
            h = h * BASE + c;
        }
        return h;
    }
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    Solver solver;
    return solver.run();
}

思路要点:

  • 对每个字符串滚动求哈希值,存入数组
  • 排序后统计不同哈希值的个数
  • 不取模时 long long 会溢出,但溢出等价于对 2 64 2^{64} 264 取模(自然溢出),某些场景下可用,但容易被构造冲突
  • 建议显式取模,更安全
3.4.3 哈希冲突及工业优化

哈希冲突: 不同的字符串可能算出相同的哈希值。例如在未取模的自然溢出下:

复制代码
A = "\x02\x05"
B = "\x01\x0B"

两者算出的值可能相同。

优化一:引入大质数模数

cpp 复制代码
class Solver {
public:
    static constexpr long long BASE = 131;
    static constexpr long long MOD = 998244353LL;  // 大质数
    long long getHash(const string &s) {
        long long h = 0;
        for (char c : s) {
            h = (h * BASE + c) % MOD;
        }
        return h;
    }
};
  • 取模防止数字过大溢出
  • BASE 与 MOD 互质时,哈希值分布更均匀,冲突概率更低
  • 常用模数:998244353、1000000007、1000000009、19260817

优化二:双重哈希 / 多重哈希

用两套独立的 (BASE, MOD) 参数分别计算哈希值,只有两个哈希值都相同才认为字符串相同,冲突概率大幅降低。

cpp 复制代码
class Solver {
public:
    static constexpr long long B1 = 131,   M1 = 998244353LL;
    static constexpr long long B2 = 13331, M2 = 1000000007LL;
    pair<long long, long long> getHash(const string &s) {
        long long h1 = 0, h2 = 0;
        for (char c : s) {
            h1 = (h1 * B1 + c) % M1;
            h2 = (h2 * B2 + c) % M2;
        }
        return {h1, h2};
    }
};

优化三:自然溢出 + 无符号类型

使用 unsigned long long,溢出时自动对 2 64 2^{64} 264 取模,速度快,但容易被卡。

cpp 复制代码
class Solver {
public:
    using ull = unsigned long long;
    static constexpr ull BASE = 131;
    ull getHash(const string &s) {
        ull h = 0;
        for (char c : s) {
            h = h * BASE + c;
        }
        return h;
    }
};
3.4.4 子串哈希(前缀哈希)

核心用途: 在 O(1) 时间内求任意子串 s[l..r] 的哈希值。

预处理:

cpp 复制代码
class Solver {
public:
    static constexpr long long BASE = 131;
    static constexpr long long MOD = 998244353LL;
    static constexpr int MAXN = 1000005;
    long long h[MAXN];   // h[i] 表示前 i 个字符的哈希值
    long long pw[MAXN];  // pw[i] = BASE^i % MOD
    void init(const string &s) {
        int n = s.size();
        pw[0] = 1;
        h[0] = 0;
        for (int i = 1; i <= n; i++) {
            h[i] = (h[i - 1] * BASE + s[i - 1]) % MOD;
            pw[i] = pw[i - 1] * BASE % MOD;
        }
    }
    // 求 s[l..r] 的哈希值,下标从 1 开始
    long long subHash(int l, int r) {
        return ((h[r] - h[l - 1] * pw[r - l + 1]) % MOD + MOD) % MOD;
    }
};

公式推导:

Hash ( s l . . r ) = h r − h l − 1 × BASE r − l + 1 ( m o d M O D ) \text{Hash}(sl..r) = hr - hl-1 \times \text{BASE}^{r-l+1} \pmod{MOD} Hash(sl..r)=hr−hl−1×BASEr−l+1(modMOD)

思路要点:

  • h[i] 是前缀 s[1..i] 的哈希值
  • 求子串哈希时,把 h[l-1] 左移 r-l+1 位对齐后相减
  • 由于取模后可能为负,需 + MOD 再 % MOD
  • 预处理 O(n),单次查询 O(1)
3.4.5 常见应用
应用 做法
判断两字符串是否相等 比较哈希值
求最长公共子串 二分长度 + 哈希 + 哈希表
回文串判断 正反两遍哈希,比较对应子串哈希
字符串匹配(KMP 替代) 比较模式串与文本串子串哈希
求不同子串个数 后缀数组 / 后缀自动机(哈希也可,但需 O(n²))
判断循环节 枚举长度 + 子串哈希比较
3.4.6 哈希参数选择建议
参数 推荐值 说明
BASE 131、13331、13131、10007 质数,且大于字符集大小
MOD 998244353、1000000007、1000000009 大质数
自然溢出 unsigned long long 快但易被卡
双重哈希 两套独立参数 冲突概率极低

注意事项:

  • BASE 和 MOD 必须互质,否则哈希值分布不均匀
  • 若字符集含小写字母,BASE > 26 即可;若含 ASCII 全字符,BASE > 128
  • 单哈希在竞赛中可能被构造数据卡掉,重要题目建议双重哈希
  • 自然溢出虽然快,但 Codeforces 等平台有专门的 hack 数据
3.4.7 时间复杂度总结
操作 复杂度
单串哈希计算 O(len)
前缀哈希预处理 O(n)
子串哈希查询 O(1)
n 个串去重 O(n log n)(排序)或 O(n)(哈希表)

3.5 AC自动机

适用场景: 多模式串匹配、敏感词过滤、统计每个模式串出现次数、AC 自动机 + DP 数位统计等

3.5.1 基本原理

核心思想: AC 自动机 = Trie 树 + KMP 的 fail 指针。

  • 把所有模式串插入一棵 Trie 树。
  • 对 Trie 上的每个节点,建立 fail 指针,指向当前节点代表的前缀的最长后缀,且这个后缀也是某个模式串的前缀。
  • 匹配时,如果当前字符无法继续走,就沿着 fail 指针跳转,直到能走或回到根。

关键性质:

  • 每个节点的 fail 指针指向的节点,其代表的字符串是当前节点代表字符串的最长真后缀。
  • 所有 fail 指针构成一棵树(fail 树),根是节点 0。
  • 在 fail 树上,一个节点的所有祖先都是它的后缀。

时间复杂度: 建树 O(总模式串长度),匹配 O(文本串长度 + 匹配次数)。

3.5.2 基础模板(统计每个模式串出现次数)

题意: 给定 n 个模式串和一篇文章,返回每个模式串在文章中出现的次数。

cpp 复制代码
#include <bits/stdc++.h>
using namespace std;

class Solver {
public:
    int run() {
        int n;
        cin >> n;
        for (int i = 1; i <= n; i++) {
            string s;
            cin >> s;
            insert(i, s);
        }
        setFail();

        string text;
        cin >> text;
        for (int u = 0, i = 0; i < (int)text.size(); i++) {
            u = tree[u][text[i] - 'a'];
            times[u]++;
        }

        // 建 fail 树
        for (int i = 1; i <= cnt; i++) {
            addEdge(fail[i], i);
        }

        // 汇总词频
        f2(0);

        for (int i = 1; i <= n; i++) {
            cout << times[endpos[i]] << '\n';
        }
        return 0;
    }

private:
    static constexpr int MAXN = 200005;   // 模式串数量
    static constexpr int MAXS = 200005;   // 所有模式串总字符数

    int tree[MAXS][26];
    int fail[MAXS];
    int cnt = 0;

    int endpos[MAXN];          // 每个模式串的结尾节点
    int times[MAXS];           // 每个节点的匹配次数

    // fail 树(反图)
    int head[MAXS], nxt[MAXS], to[MAXS], edge = 0;

    int box[MAXS];             // 队列 / 栈
    bool visited[MAXS];

    // 插入模式串
    void insert(int id, const string &s) {
        int u = 0;
        for (char ch : s) {
            int c = ch - 'a';
            if (!tree[u][c]) tree[u][c] = ++cnt;
            u = tree[u][c];
        }
        endpos[id] = u;
    }

    // 构建 fail 指针 + 直通表优化
    void setFail() {
        int l = 0, r = 0;
        for (int i = 0; i < 26; i++) {
            if (tree[0][i]) box[r++] = tree[0][i];
        }
        while (l < r) {
            int u = box[l++];
            for (int i = 0; i < 26; i++) {
                if (!tree[u][i]) {
                    tree[u][i] = tree[fail[u]][i];   // 直通表
                } else {
                    fail[tree[u][i]] = tree[fail[u]][i];
                    box[r++] = tree[u][i];
                }
            }
        }
    }

    void addEdge(int u, int v) {
        nxt[++edge] = head[u];
        head[u] = edge;
        to[edge] = v;
    }

    // 非递归遍历 fail 树,汇总词频
    void f2(int u) {
        int r = 0;
        box[r++] = u;
        while (r > 0) {
            int cur = box[r - 1];
            if (!visited[cur]) {
                visited[cur] = true;
                for (int i = head[cur]; i; i = nxt[i]) {
                    box[r++] = to[i];
                }
            } else {
                r--;
                for (int i = head[cur]; i; i = nxt[i]) {
                    times[cur] += times[to[i]];
                }
            }
        }
    }
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    Solver solver;
    return solver.run();
}

思路要点:

  • Trie 树中每个节点代表一个前缀
  • fail 指针指向最长后缀,且该后缀是某个模式串的前缀
  • 匹配时沿 Trie 走,走不动就沿 fail 跳
  • 每个节点被访问的次数就是它代表的字符串在文本中出现的次数
  • 但一个模式串可能作为其他模式串的后缀出现,所以要在 fail 树上从下往上累加词频
  • fail 树是一棵树,用非递归遍历避免爆栈
3.5.3 AC 自动机 + 数位 DP(洛谷 P3311)

题意: 给定数字串集合 S,求不大于 n 的正整数中,十进制表示不包含 S 中任意元素作为子串的数的个数,答案对 1e9+7 取模。

思路: AC 自动机 + 数位 DP。

  • 把 S 中所有模式串插入 AC 自动机。
  • 在 AC 自动机上标记命中节点(即某个模式串的结尾)。
  • 由于 fail 指针的存在,一个节点如果 fail 链上有命中节点,那么它也是命中节点(命中标记前移)。
  • 数位 DP:从高位到低位枚举,状态为 (位置 i, AC 自动机节点 j, 是否顶上限 free, 是否已选数字 has)。
cpp 复制代码
#include <bits/stdc++.h>
using namespace std;

class Solver {
public:
    int run() {
        string s;
        cin >> s;
        n = s.size();
        for (int i = 0; i < n; i++) num[i] = s[i];

        int m;
        cin >> m;
        for (int i = 0; i < m; i++) {
            string t;
            cin >> t;
            insert(t);
        }
        setFail();
        clear();
        cout << dfs(0, 0, 0, 0) << '\n';
        return 0;
    }

private:
    static constexpr int MOD = 1000000007;
    static constexpr int MAXN = 1301;   // 数字长度
    static constexpr int MAXS = 2001;   // 总字符数

    int tree[MAXS][10];
    int fail[MAXS];
    int cnt = 0;
    bool alert[MAXS];         // 命中标记
    int queue_[MAXS];

    int dp[MAXN][MAXS][2][2];
    char num[MAXN];
    int n;

    void insert(const string &s) {
        int u = 0;
        for (char ch : s) {
            int c = ch - '0';
            if (!tree[u][c]) tree[u][c] = ++cnt;
            u = tree[u][c];
        }
        alert[u] = true;
    }

    void setFail() {
        int l = 0, r = 0;
        for (int i = 0; i < 10; i++) {
            if (tree[0][i]) queue_[r++] = tree[0][i];
        }
        while (l < r) {
            int u = queue_[l++];
            for (int i = 0; i < 10; i++) {
                if (!tree[u][i]) {
                    tree[u][i] = tree[fail[u]][i];
                } else {
                    fail[tree[u][i]] = tree[fail[u]][i];
                    queue_[r++] = tree[u][i];
                }
            }
            // 命中标记前移
            alert[u] |= alert[fail[u]];
        }
    }

    void clear() {
        for (int i = 0; i <= n; i++)
            for (int j = 0; j <= cnt; j++)
                for (int a = 0; a < 2; a++)
                    for (int b = 0; b < 2; b++)
                        dp[i][j][a][b] = -1;
    }

    // i: 当前位
    // j: AC 自动机节点
    // free: 是否顶上限(0 顶,1 不顶)
    // has: 是否已经选过数字(0 没选,1 选了)
    int dfs(int i, int j, int free, int has) {
        if (alert[j]) return 0;
        if (i == n) return has;
        if (dp[i][j][free][has] != -1) return dp[i][j][free][has];

        int ans = 0;
        int cur = num[i] - '0';
        if (!has) {
            if (!free) {
                // 没选过数字,且顶上限
                ans = (ans + dfs(i + 1, 0, 1, 0)) % MOD;   // 继续不选
                for (int pick = 1; pick < cur; pick++)
                    ans = (ans + dfs(i + 1, tree[j][pick], 1, 1)) % MOD;
                ans = (ans + dfs(i + 1, tree[j][cur], 0, 1)) % MOD;
            } else {
                // 没选过数字,且不顶上限
                ans = (ans + dfs(i + 1, 0, 1, 0)) % MOD;
                for (int pick = 1; pick <= 9; pick++)
                    ans = (ans + dfs(i + 1, tree[j][pick], 1, 1)) % MOD;
            }
        } else {
            if (!free) {
                // 选过数字,且顶上限
                for (int pick = 0; pick < cur; pick++)
                    ans = (ans + dfs(i + 1, tree[j][pick], 1, 1)) % MOD;
                ans = (ans + dfs(i + 1, tree[j][cur], 0, 1)) % MOD;
            } else {
                // 选过数字,且不顶上限
                for (int pick = 0; pick <= 9; pick++)
                    ans = (ans + dfs(i + 1, tree[j][pick], 1, 1)) % MOD;
            }
        }
        return dp[i][j][free][has] = ans;
    }
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    Solver solver;
    return solver.run();
}

思路要点:

  • alert[u] 标记节点 u 是否命中模式串
  • fail 链上的命中标记要前移:alert[u] |= alert[fail[u]]
  • 数位 DP 状态:(位置, AC 节点, 是否顶上限, 是否已选数字)
  • 已选数字才能选 0,否则 0 表示"还没开始选"
  • 答案对 1e9+7 取模
3.5.4 AC 自动机的优化技巧
技巧 说明
直通表 tree[u][c] = tree[fail[u]][c],匹配时无需沿 fail 跳
命中标记前移 `alert[u]
fail 树 所有 fail 指针构成一棵树,词频统计在 fail 树上从下往上累加
非递归遍历 fail 树深度可能很大,用栈模拟递归避免爆栈
时间戳清空 多测试用例时用时间戳代替 memset,避免反复清空大数组
3.5.5 常见应用
应用 做法
多模式串匹配 文本串在 AC 自动机上跑,统计命中
每个模式串出现次数 fail 树上从下往上累加词频
敏感词过滤 命中标记 + 替换
AC 自动机 + DP 数位 DP、状态机 DP
AC 自动机 + 矩阵快速幂 长度为 L 的串中不包含模式串的方案数
AC 自动机 + 高斯消元 期望步数问题
3.5.6 时间复杂度总结
操作 复杂度
建 Trie O(总模式串长度)
构建 fail O(总模式串长度 × 字符集大小)
匹配文本串 O(文本串长度)
fail 树汇总 O(节点数)
AC 自动机 + DP O(状态数 × 转移数)
3.5.7 注意事项
  • 字符集大小决定 tree[u][c] 的维度,小写字母是 26,数字是 10

  • fail 指针的构建用 BFS,保证按层构建

  • 直通表优化后,匹配时不需要沿 fail 跳,直接走 tree[u][c]

  • 命中标记前移后,匹配时不需要沿 fail 链检查命中,直接看 alert[u]

  • 多测试用例时注意清空 tree、fail、alert、times 等数组

  • fail 树深度可能很大,Java 等语言要用非递归遍历避免爆栈

3.6 后缀自动机

适用场景: 子串出现次数、不同子串个数、最长公共子串、字典序第 k 小子串、子串匹配、后缀树相关

3.6.1 基本原理

核心思想: 后缀自动机是一个能识别字符串所有子串 的最小 DFA(确定性有限自动机)。它把字符串的所有子串压缩成一个有向无环图(DAG),每个节点代表一组 endpos 相同的子串。

关键概念:

  • endpos(S):子串 S 在原串中所有出现位置的结束下标集合
  • 状态(节点):一组 endpos 相同的子串
  • lenu:状态 u 代表的最长子串长度
  • linku:状态 u 的后缀链接,指向 endpos 集合严格更大的状态
  • 转移边:从状态 u 出发,加字符 c 到达状态 v

关键性质:

  • 从根出发的任意路径都对应一个子串
  • 从根出发到某个状态的路径长度范围是 (len[link[u]], len[u]]
  • 后缀链接构成一棵树(parent 树),每个状态代表一个子串集合
  • SAM 的节点数 ≤ 2n - 1,边数 ≤ 3n - 4
  • 每个状态代表的不同子串个数 = len[u] - len[link[u]]
3.6.2 基础模板
cpp 复制代码
#include <bits/stdc++.h>
using namespace std;

class Solver {
public:
    static constexpr int MAXN = 1000005;
    struct SAM {
        int tree[MAXN << 1][26];  // 转移边
        int link[MAXN << 1];      // 后缀链接
        int len[MAXN << 1];       // 最长子串长度
        int cnt[MAXN << 1];       // 每个状态的 endpos 大小
        int sz, last;             // 状态数、最后状态

        SAM() {
            sz = 0;
            last = 0;
            // 初始化根节点
            memset(tree[0], 0, sizeof(tree[0]));
            link[0] = -1;
            len[0] = 0;
            sz = 1;
        }

        // 插入字符 c
        void insert(int c) {
            int cur = sz++;
            len[cur] = len[last] + 1;
            cnt[cur] = 1;
            memset(tree[cur], 0, sizeof(tree[cur]));

            int p = last;
            while (p != -1 && !tree[p][c]) {
                tree[p][c] = cur;
                p = link[p];
            }

            if (p == -1) {
                link[cur] = 0;
            } else {
                int q = tree[p][c];
                if (len[p] + 1 == len[q]) {
                    link[cur] = q;
                } else {
                    // 分裂节点
                    int clone = sz++;
                    len[clone] = len[p] + 1;
                    memcpy(tree[clone], tree[q], sizeof(tree[q]));
                    link[clone] = link[q];
                    cnt[clone] = 0;
                    while (p != -1 && tree[p][c] == q) {
                        tree[p][c] = clone;
                        p = link[p];
                    }
                    link[q] = link[cur] = clone;
                }
            }
            last = cur;
        }

        // 建 parent 树后统计 endpos 大小
        void buildParentTree() {
            // 按 len 排序,从大到小累加
            vector<int> order(sz);
            iota(order.begin(), order.end(), 0);
            sort(order.begin(), order.end(), [&](int a, int b) {
                return len[a] > len[b];
            });
            for (int u : order) {
                if (link[u] != -1) {
                    cnt[link[u]] += cnt[u];
                }
            }
        }
    };
};

思路要点:

  • 插入字符时新建节点 cur
  • 沿后缀链接跳,若没有转移则加上
  • 若 len[p] + 1 == len[q],直接连 link
  • 否则分裂出 clone 节点
  • 每个节点代表的不同子串数 = len[u] - len[link[u]]
3.6.3 不同子串个数

结论: 所有状态代表的子串数之和 = ∑ u ( len u − len link \[ u ] ) \sum_u (\text{len}u - \text{len}\\text{link}\[u]) ∑u(lenu−lenlink\[u])

cpp 复制代码
class Solver {
public:
    long long countDistinctSubstrings() {
        long long ans = 0;
        for (int i = 1; i < sz; i++) {
            ans += len[i] - len[link[i]];
        }
        return ans;
    }
};

思路要点:

  • 每个状态代表 (len[link[u]], len[u]] 长度的子串
  • 累加所有状态即可
  • 时间复杂度 O(n)
3.6.4 子串出现次数

思路: 在 parent 树上从下往上累加 cnt。

cpp 复制代码
class Solver {
public:
    void buildParentTree() {
        // 按 len 从大到小排序
        vector<int> order(sz);
        iota(order.begin(), order.end(), 0);
        sort(order.begin(), order.end(), [&](int a, int b) {
            return len[a] > len[b];
        });
        for (int u : order) {
            if (link[u] != -1) {
                cnt[link[u]] += cnt[u];
            }
        }
    }
    // 查询子串 s 的出现次数
    int queryCount(const string &s) {
        int u = 0;
        for (char ch : s) {
            int c = ch - 'a';
            if (!tree[u][c]) return 0;
            u = tree[u][c];
        }
        return cnt[u];
    }
};

思路要点:

  • 每个前缀节点的 cnt 初始为 1
  • parent 树上从下往上累加,得到每个状态的 endpos 大小
  • 查询时沿转移边走,返回最终状态的 cnt
  • 经典题型:洛谷 P3804
3.6.5 最长公共子串

问题: 给定两个字符串 s 和 t,求它们的最长公共子串。

cpp 复制代码
class Solver {
public:
    int longestCommonSubstring(const string &s, const string &t) {
        // 对 s 建 SAM
        SAM sam;
        for (char c : s) sam.insert(c - 'a');

        int v = 0, l = 0, ans = 0;
        for (char ch : t) {
            int c = ch - 'a';
            while (v && !sam.tree[v][c]) {
                v = sam.link[v];
                l = sam.len[v];
            }
            if (sam.tree[v][c]) {
                v = sam.tree[v][c];
                l++;
            }
            ans = max(ans, l);
        }
        return ans;
    }
};

思路要点:

  • 对 s 建 SAM
  • 用 t 在 SAM 上跑
  • 失配时沿后缀链接跳,更新当前匹配长度
  • 时间复杂度 O(|s| + |t|)
3.6.6 字典序第 k 小子串

问题: 求字符串中字典序第 k 小的子串。

cpp 复制代码
class Solver {
public:
    // 预处理每个状态能到达的子串数
    void dfsCount(int u, vector<long long> &dp) {
        if (dp[u]) return;
        dp[u] = 1;   // 空串
        for (int c = 0; c < 26; c++) {
            if (tree[u][c]) {
                dfsCount(tree[u][c], dp);
                dp[u] += dp[tree[u][c]];
            }
        }
    }
    // 求第 k 小子串
    string kthSubstring(int k) {
        vector<long long> dp(sz, 0);
        dfsCount(0, dp);
        string ans;
        int u = 0;
        while (k > 0) {
            for (int c = 0; c < 26; c++) {
                if (!tree[u][c]) continue;
                int v = tree[u][c];
                if (dp[v] >= k) {
                    ans += char('a' + c);
                    u = v;
                    k--;
                    break;
                } else {
                    k -= dp[v];
                }
            }
        }
        return ans;
    }
};

思路要点:

  • 每个状态出发能形成的子串数(含空串)
  • 按字典序枚举字符,判断第 k 小在哪个分支
  • 时间复杂度 O(n × 字符集)
3.6.7 最长重复子串

结论: 出现次数 ≥ 2 的最长子串长度 = max ⁡ { len u ∣ cnt u ≥ 2 } \max\{\text{len}u \mid \text{cnt}u \ge 2\} max{lenu∣cntu≥2}

cpp 复制代码
class Solver {
public:
    int longestRepeatedSubstring() {
        buildParentTree();
        int ans = 0;
        for (int i = 1; i < sz; i++) {
            if (cnt[i] >= 2) {
                ans = max(ans, len[i]);
            }
        }
        return ans;
    }
};

思路要点:

  • 统计每个状态的 endpos 大小
  • 取 endpos ≥ 2 的状态中最长长度
3.6.8 后缀自动机 + DP

适用场景: 统计不同子串个数、方案数、匹配长度

cpp 复制代码
class Solver {
public:
    // 统计不同子串个数(DP 版)
    long long countDistinctDP() {
        vector<long long> dp(sz, -1);
        function<long long(int)> dfs = [&](int u) -> long long {
            if (dp[u] != -1) return dp[u];
            long long res = 1;   // 空串
            for (int c = 0; c < 26; c++) {
                if (tree[u][c]) {
                    res += dfs(tree[u][c]);
                }
            }
            return dp[u] = res;
        };
        return dfs(0) - 1;   // 减去空串
    }
};

思路要点:

  • dp[u] 表示从状态 u 出发能形成的子串数
  • 记忆化搜索
  • 答案减去空串
3.6.9 广义后缀自动机(GSAM)

适用场景: 多个字符串的子串问题

思路: 把多个字符串依次插入同一个 SAM,每次插入新串时把 last 重置为根。

cpp 复制代码
class GSAM {
public:
    // 广义 SAM:插入多个字符串
    void insertString(const string &s) {
        last = 0;
        for (char c : s) {
            insert(c - 'a');
        }
    }
};

思路要点:

  • 每次插入新串前把 last = 0
  • 节点复用,注意 clone 的处理
  • 经典题型:洛谷 P6139、CF 204E
3.6.10 常见应用总结
应用 做法 经典题
不同子串个数 累加 len[u] - len[link[u]] 洛谷 P2408
子串出现次数 parent 树上累加 cnt 洛谷 P3804
最长公共子串 对 s 建 SAM,t 在 SAM 上跑 SPOJ LCS
字典序第 k 小子串 DP + 按字典序枚举 洛谷 P3975
最长重复子串 cnt ≥ 2 的最大 len 洛谷 P2408
多个字符串公共子串 广义 SAM 洛谷 P6139
子串匹配 SAM 上跑文本串 多题
后缀树 SAM 的 parent 树 多题

统一思路:

  1. 建 SAM:插入字符串,得到转移边和后缀链接
  2. 建 parent 树:按 len 排序,从下往上累加
  3. 查询:在 SAM 上走转移边,或利用 parent 树性质
  4. 统计 :累加 len[u] - len[link[u]] 或 cnt[u]

复杂度:

  • 建 SAM:O(n × 字符集)
  • 查询:O(m)
  • 统计不同子串:O(n)
  • 统计出现次数:O(n)
3.6.11 注意事项
  • SAM 节点数 ≤ 2n - 1,数组要开 2 倍
  • link[0] = -1,注意边界判断
  • 插入时 clone 节点的 cnt 初始为 0
  • parent 树按 len 排序,从大到小累加
  • 广义 SAM 每次插入前 last = 0
  • 字符集大时用 map 或哈希
  • 多组测试要清空 tree、link、len、cnt
  • SAM 的 parent 树等价于后缀树的反向
  • 不同子串个数可能很大,要用 long long
  • 字典序第 k 小子串注意是否包含重复子串

相关推荐
信奥卷王1 小时前
[GESP202509 六级] 划分字符串(含视频)
数据结构·算法
酒醉的胡铁2 小时前
相似度搜索的常见算法
算法·ai
局部有解2 小时前
Leetcode Hot100 - 最长连续序列
算法·leetcode·职场和发展
卿卿的产品经理日记2 小时前
【AI产品经理实战】Day 37|拆开 JSON 与 EXE:数据都有格式
人工智能·笔记·python·json·产品经理
辣知2 小时前
辣知·化智80 《道德经》和光同尘的智慧
学习
wdfk_prog2 小时前
LWIP教程 15:IPv4 与 IPv6——从网络层工作原理到嵌入式工程取舍
运维·开发语言·网络·笔记·学习·智能路由器·ip
行者全栈架构师2 小时前
给开源 Rust 工具加一个新检查项,TRAE 帮我从想法跑到构建产物:全链路实战
前端·算法·架构
chde2Wang2 小时前
numpy学习3
学习·numpy
坤坤子吖2 小时前
Linux 进程信号入门——从 Ctrl+C 到 kill
linux·开发语言·c++·笔记·学习