模板索引: 字符串
字符串哈希
双哈希模板
点击查看代码
using ull = unsigned long long;
ull base = 131;
ull mod1 = 212370440130137957, mod2 = 1e9 + 7;
ull get_hash1(std::string s) {
int len = s.size();
ull ans = 0;
for (int i = 0; i < len; i++) ans = (ans * base + (ull)s[i]) % mod1;
return ans;
}
ull get_hash2(std::string s) {
int len = s.size();
ull ans = 0;
for (int i = 0; i < len; i++) ans = (ans * base + (ull)s[i]) % mod2;
return ans;
}
bool cmp(const std::string s, const std::string t) {
bool f1 = get_hash1(s) != get_hash1(t);
bool f2 = get_hash2(s) != get_hash2(t);
return f1 || f2;
}
P3370 【模板】字符串哈希
给定 \(N\) 个字符串(第 \(i\) 个字符串长度为 \(M_i\),字符串内包含数字、大小写字母,大小写敏感),请求出 \(N\) 个字符串中共有多少个不同的字符串。
点击查看代码
vector< pair<ull, ull> > a;
for(int i = 1; i <= n; i++){
string s;
cin >> s;
ull f1 = get_hash1(s);
ull f2 = get_hash2(s);
a.push_back(make_pair(f1, f2));;
}
sort(a.begin(), a.end());
a.erase(unique(a.begin(), a.end()), a.end());
cout << a.size() << endl;
也可以用 set 做:
set<string> a;
a.insert(str);
cout << a.size() << "\n";
区间哈希
注意哈希有一个用法,类似前缀和的思想,预处理所有前缀的哈希值,然后作差得到区间哈希值。
尽管区间子串数量达到了平方级别,但是查询并不会用那么多,甚至查询可能和 \(n\) 同级,由于查询次数小,所以实际参与哈希比较的次数通常较少,故碰撞概率很小。
这个在后缀数组的暴力做法中可以用到。
设字符串是 \(s_1,s_2,\dots,s_n\),取进制 \(Base\)。
定义前缀哈希为: \(H[i] = H[i-1]\cdot Base + s_i\)
同时预处理幂: \(P[i] = Base^i\)
那么区间 \([l,r]\) 的哈希值可以写成:\(hash(l,r)=H[r] - H[l-1]\cdot P[r-l+1]\)
哈希这里因为有“位权”,所以左部分要多乘一个 \(Base^{r-l+1}\),得到“高位”的内容,再减掉,自然得到“低位”的哈希值。
例题:
P4824 [USACO15FEB] Censoring S
FJ 需要将字符串 \(S\)(长度不超过 \(10^6\))中所有出现的子串 \(T\) 进行删除处理。
删除规则如下:反复找到当前 \(S\) 中第一个出现的子串 \(T\) 并删除,直到 \(S\) 中不再包含 \(T\)。注意,删除操作可能产生新的 \(T\) 子串。
请输出最终处理完成的字符串 \(S\)。
很显然就是做一个类似括号匹配,可以用栈,问题在于这是一个区间。
引入区间哈希即可。
点击查看代码
const ull base = 131;
const ull mod = 1e9 + 7;
const int maxn = 1e6 + 10;
string s, T;
ull pre[maxn], p[maxn];
char stk[maxn]; int tp;
ull gethash(string s){
int len = s.size();
ull ans = 0;
for(int i = 0; i < len; i++) ans = (ans * base + (ull)s[i]) % mod;
return ans;
}
void Pre(){
p[0] = 1;
for(int i = 1; i < maxn; i++)
p[i] = p[i - 1] * base % mod;
}
int main(){
Pre();
cin >> s >> T;
ull t = gethash(T);
for(auto ch : s){
stk[++tp] = ch;
pre[tp] = (pre[tp - 1] * base + (ull)ch) % mod;
if(tp >= T.size()){
ull now = (pre[tp] + mod - pre[tp - T.size()] * p[T.size()] % mod) % mod;
if(now == t) tp = tp - T.size();
}
}
for(int i = 1; i <= tp; i++) cout << stk[i];
return 0;
}
字典树
传入要加引用,避免整体拷贝!
void insert(string& s, int id)void insert(char *s, int l)
oi-wiki 模板
点击查看代码
struct trie {
int nex[100000][26], cnt;
bool exist[100000]; // 该结点结尾的字符串是否存在
void insert(char *s, int l) { // 插入字符串
int p = 0;
for (int i = 0; i < l; i++) {
int c = s[i] - 'a';
if (!nex[p][c]) nex[p][c] = ++cnt; // 如果没有,就添加结点
p = nex[p][c];
}
exist[p] = true;
}
bool find(char *s, int l) { // 查找字符串
int p = 0;
for (int i = 0; i < l; i++) {
int c = s[i] - 'a';
if (!nex[p][c]) return 0;
p = nex[p][c];
}
return exist[p];
}
};
洛谷模板
题意:多测,每个测试点给定 \(n\) 个模式串和 \(q\) 次询问,每次询问给定一个串,问你是多少个模式串的前缀。
点击查看代码
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int maxn = 3e6 + 10;
// P8306 【模板】字典树 / Trie:
// 查询给定串是多少个模式串的前缀
struct trie {
int nex[maxn][66], cnt;
int exist[maxn]; // 该结点为前缀的字符串个数
int getnum(char x){ // 由于此题又大小写字母与数字共存,故先做好映射
if(x >= 'A' && x <= 'Z') return x - 'A';
else if(x >= 'a' && x <= 'z') return x - 'a' + 26;
else return x - '0' + 52;
}
void init(){ // 多测清空
for (int i = 0; i <= cnt; i++) {
memset(nex[i], 0, sizeof(nex[i]));
exist[i] = 0;
}
cnt = 0;
}
void insert(char *s, int l) { // 插入字符串
int p = 0;
for (int i = 0; i < l; i++) {
int c = getnum(s[i]);
if (!nex[p][c]) nex[p][c] = ++cnt; // 如果没有,就添加结点
p = nex[p][c]; // 先走到当前节点
exist[p]++;
}
}
int find(char *s, int l) { // 查找字符串
int p = 0;
for (int i = 0; i < l; i++) {
int c = getnum(s[i]);
if (!nex[p][c]) return 0;
p = nex[p][c]; //
}
return exist[p];
}
}tr;
char s[maxn];
int main(){
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
int t;
cin >> t;
while(t--){
int n, q;
tr.init();
cin >> n >> q;
for(int i = 1; i <= n; i++) {
cin >> s;
tr.insert(s, strlen(s));
}
while(q--){
cin >> s;
cout << tr.find(s, strlen(s)) << "\n";
}
}
return 0;
}
0-1 字典树 (维护异或极值)
关联:P4551 最长异或路径
给定一棵 \(n\) 个点的带权树,结点下标从 \(1\) 开始到 \(n\)。求树中所有异或路径的最大值。
异或路径指树上两个结点之间唯一路径上的所有边权的异或值。
对于这个题,总结为:
- 0-1字典树在此主要解决,对于一个值如何快速找到其可行的另一个值使得异或和最大,暴力做法是平方。
- 预处理办法是,先存根到每个点的路径的异或和 \(s_i\),这样 \(i, j\) 两点间边权异或和就是 \(s_i \oplus s_j\)。
- 算法使用方法:先把所有点的 \(s_i\) 插入字典树。随后对一个已有的值 \(x\),从他二进制数最左边开始,同时字典树从根节点开始,贪心地选择与这一位数不同的节点使得按位异或为 1,如果只有一个子节点直接往下走
点击查看代码
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int maxn = 2e5 + 10;
int n;
int cnte;
int head[maxn], nxt[maxn], to[maxn], val[maxn];
int res[maxn];
int trie[maxn * 31][2];
int tot;
void adde(int u, int v, int w){
to[++cnte] = v;
nxt[cnte] = head[u];
head[u] = cnte;
val[cnte] = w;
}
void dfs1(int u, int fa){
for(int i = head[u]; i; i = nxt[i]){
if(to[i] == fa) continue;
// 从根到该节点的路径异或和
res[to[i]] = res[u] ^ val[i];
dfs1(to[i], u);
}
}
// 造一棵0-1字典树
void insert(int val){
int u = 0;
for(int i = (1 << 30); i; i >>= 1){
int a = bool(val & i);
if(!trie[u][a]) trie[u][a] = ++tot;
u = trie[u][a];
}
}
// 找到给定值的最大异或结果
int ask(int x){
int u = 0, ans = 0;
for(int i = (1 << 30); i; i >>= 1){
int a = bool(i & x);
if(trie[u][!a]) {
ans += i;
u = trie[u][!a];
}
else u = trie[u][a];
}
return ans;
}
int main(){
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
cin >> n ;
for(int i = 1; i < n; i++){
int u, v, w;
cin >> u >> v >> w;
adde(u, v, w);
adde(v, u, w);
}
dfs1(1, 0);
for(int i = 1; i <= n; i++)
insert(res[i]);
int ans = 0;
for(int i = 1; i <= n; i++)
ans = max(ans, ask(res[i]));
cout << ans << endl;
return 0;
}
维护异或和
字典树本来就按二进制位分层,适合处理:
- 最大异或
- 最小异或
- 区间异或性质
- 懒标记整体异或
- 动态维护集合
·
区间异或最值
给定一个长度为 (n) 的非负整数序列,对于任意区间 ([l,r])((1\le l\le r\le n)),定义其价值为区间内所有元素的异或和。
从所有区间中选择 (k) 个互不相同的区间,最大化它们的价值之和。
容易想到,先定义前缀异或,于是任意区间的异或和可以写成 \(w(l,r)=p_{l-1}\oplus p_r\).
固定左端点,形成 \(n\) 条长为 \(n\) 的价值序列,然后先把各个序列的头插入大根堆,堆中始终保存每条序列当前尚未取出的最大元素。每次取最大值即某个序列的值,取完后将该序列的次大值加入堆(这个模型很常见)
最后,我们故意不固定 \(i < j\),将每个无序点对重复计算两次,这样 \(s_j\) 和 \(s_i\) 互为对偶,我们找 \(2k\) 个最大的价值最大的配对,最后价值 /2 即可。
点击查看代码
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int maxn = 5e5 + 10;
int n, k;
ll a[maxn];
int trie[maxn * 33][2];
int sz[maxn * 33];
int tot = 0;
void insert(ll val){
int u = 0;
for(int i = 31; i >= 0; i--){
int z = (val >> i) & 1;
if(!trie[u][z]) trie[u][z] = ++tot;
u = trie[u][z];
sz[u]++;
}
}
// 找这个值对应的第 k 大结果
ll find(ll val, int t){
ll s = 0;
int u = 0;
for(int i = 31; i >= 0; i--){
int z = (val >> i) & 1;
// 这里类似二分查找,能让当前位变成 1 的分支里,至少有 t 个数,那么第 t 大一定在这,否则只能为 0
if(sz[trie[u][z ^ 1]] >= t){
u = trie[u][z ^ 1];
s |= (1ll << i);
}
else {
t -= sz[trie[u][z ^ 1]];
u = trie[u][z];
}
}
return s;
}
struct node{
ll w;
int x, y;
// x 的第 y 大选择
bool operator < (const node &A) const {
return w < A.w;
}
};
node d[maxn];
priority_queue<node> qu;
int main(){
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
cin >> n >> k;
for(int i = 1; i <= n; i++){
cin >> a[i];
a[i] ^= a[i-1];
}
for(int i = 0; i <= n; i++) insert(a[i]);
k <<= 1;
for(int i = 0; i <= n; i++){
d[i].w = find(a[i], 1);
d[i].x = i;
d[i].y = 1;
qu.push(d[i]);
}
ll ans = 0;
node A;
for(int i = 1; i <= k; i++){
A = qu.top();
ans += A.w;
qu.pop();
d[A.x].y++;
d[A.x].w = find(a[A.x], d[A.x].y);
qu.push(d[A.x]);
}
ans >>= 1;
cout << ans << endl;
return 0;
}
可持久化字典树
暂略
KMP
为什么 _tmp 加入的是 \(i - 2 * szp\)?
- 先减去 szp - 1,得到匹配串在 cur 里的起点,再减去前面那段 pattern + '#' 的长度 szp + 1,转成 text 内部下标
即:\(i - szp + 1 - (szp + 1) = i - 2 * szp\).
点击查看代码
// 预处理pi函数:即最长公共真前后缀
vector<int> prefix(string _s){
int n = _s.length();
vector<int> pi(n);
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;
}
// 在 text 里找 pattern 出现的位置, 这里是 0-based 的下标。
vector<int> _find(string text, string pattern){
string cur = pattern + '#' + text;
int szp = pattern.size(); // size pattern
vector<int> _tmp;
vector<int> lps = prefix(cur);
for (int i = szp + 1; i < cur.size(); i++)
if (lps[i] == szp) _tmp.push_back(i - 2 * szp);
return _tmp;
}
字符串的周期性
找给定串的最小周期
关联:P4391 [BalticOI 2009] Radio Transmission 无线传输
给你一个字符串 \(s_1\),它是由某个字符串 \(s_2\) 不断自我连接形成的(保证至少重复 \(2\) 次)。但是字符串 \(s_2\) 是不确定的,现在只想知道它的最短长度是多少。
这个题非常的好,可以很深刻的揭示 \(kmp\) 算法 \(next\) 数组的一些性质。
首先不论循环节是什么,由于我们只需要知道他的长度,于是都可以认为一开头就是循环节。

由图可知,最开始那段的循环节不会被归纳到整个数组的最长公共真前后缀中。
令给定串长度为 \(len\), 于是答案就是 len - pi[len - 1] $(0-base) $
找给定串的最长周期 & 最短公共前缀
关联:P3435 [POI 2006] OKR-Periods of Words
如果存在一个字符串 \(B\) 使得 \(A=P+B\),那么字符串 \(P\) 是字符串 \(A\) 的前缀。此外,如果 \(P \neq A\) 且 \(P\) 不是空字符串,我们称 \(P\) 是 \(A\) 的真前缀。
如果 \(Q\) 是 \(A\) 的真前缀,并且 \(A\) 是字符串 \(Q+Q\) 的前缀(不一定是真前缀),那么字符串 \(Q\) 是 \(A\) 的周期。
字符串 \(A\) 的最大周期是其最长的周期,如果 \(A\) 没有周期,则为空字符串。例如,ababab的最大周期是abab;abc的最大周期是空字符串。
计算该字符串所有前缀的最大周期长度之和。
注意这个题和上一个题非常不一样,这里找的是最长周期。
即我们只需要找一个真前缀 \(Q\),使得 \(A\) 是字符串 \(Q+Q\) 的前缀。
换句话说,只要前后的 \(border\) 不为空,就可以接起来,于是中间的不用管都可以当成周期的一部分。
于是容易知道,最长周期就是区间长度减掉最短公共真前后缀。
于是问题转化为,求最短公共真前后缀。
点击查看代码
pair<vector<int>, vector<int>> pre(string s){
vector<int> pi(l); // 最长公共真前后缀
vector<int> mn(l); // 最短公共真前后缀
for(int i = 1; i < l; i++){
int j = pi[i - 1];
while(s[i] != s[j] && j) j = pi[j - 1];
if(s[i] == s[j]) j++;
pi[i] = j;
// 最短公共真前后缀的三种转移
if(!j) mn[i] = 0; // 没有公共真前后缀
else if(mn[j - 1]) mn[i] = mn[j - 1]; // 最长 border 内部还有更短 border
else mn[i] = j; // 最长 border 就是最短 border
if(mn[i]) ans += i + 1 - mn[i];
}
return make_pair(pi, mn);
}
不含模式串的情况数/概率
P16231 [蓝桥杯 2026 省 A] 基因研究
题目大意:对于文本串基因,可以有 \(A, T, G, C\) 四种碱基,给定每个位置是某种碱基的概率,问你整个文本串包含给定模式串的概率是多少?
对于 \(100\%\) 的数据,\(1 \le m \le n \le 3000, 0 \le a_i, t_i, g_i, c_i < 998244353\)。
首先,因为给的是每个位置的概率,所以转移并不相同,不应使用矩阵快速幂。
考虑使用正难则反,我们先算全部不含的概率。
如果不使用正难则反也是可以的,转移如下图所示(参考洛谷第一篇题解):

如果在 \(DP\) 过程中跳失配,由于失配链长度不可控,最坏复杂度 \(O(nm^2)\)。
我们考虑预处理失配转移,令 int to[maxn][4]; 表示模式串的 第 \(j\) 位的转移,其中同时包含匹配成功和失配两种转移。
最终复杂度 \(O(nm)\) 。
点击查看代码
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int mod = 998244353;
const int maxn = 3030;
const int maxm = 3030;
int n, m;
// 本题我们正难则反,先算没有易感序列的概率
ll P[maxn][4];
ll f[maxn][maxm];
// f_{i, j} 表示对于前i个字符,匹配到易感序列第j位时,还没有出现过完整易感序列的概率
int to[maxn][4];
string cmp = "ATGC";
vector<int> prefix(string s){
vector<int> pi(n);
int l = s.length();
for(int i = 1; i < l; i++){
int j = pi[i - 1];
while(j && s[i] != s[j]) j = pi[j - 1];
if(s[i] == s[j]) j++;
pi[i] = j;
}
// 预处理每个易感序列的失配状态
for(int j = 0; j < m; j++){
for(int c = 0; c < 4; c++){
int nj = j;
while(nj && cmp[c] != s[nj]) nj = pi[nj - 1];
if(s[nj] == cmp[c]) nj++;
to[j][c] = nj;
}
}
return pi;
}
int main(){
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
cin >> n >> m;
string s;
cin >> s;
for(int i = 1; i <= n; i++)
for(int j = 0; j < 4; j++) cin >> P[i][j];
vector<int> pi = prefix(s);
f[0][0] = 1;
// DP
for(int i = 1; i <= n; i++){
for(int j = 0; j < m; j++){
if(!f[i - 1][j]) continue; // 概率为 0,提前剪枝
for(int c = 0; c < 4; c++){ // 枚举当前位放的碱基
int nj = to[j][c];
if(nj == m) continue; // 完整匹配了,此人易感,丢弃
(f[i][nj] += f[i - 1][j] * P[i][c]) %= mod;
}
}
}
ll not_inf = 0;
for(int j = 0; j < m; j++) (not_inf += f[n][j]) %= mod;
cout << (1 - not_inf + mod) % mod << '\n';
return 0;
}
Manacher
给定一个长度为 \(n\) 的字符串 \(s\),请找到所有对 \((i, j)\) 使得子串 \(s[i \dots j]\) 为一个回文串。
我们转化问题表达:对于每个位置 \(i = 0 \dots n - 1\),我们找出值 \(d_1[i]\) 和 \(d_2[i]\)。二者分别表示以位置 \(i\) 为中心的长度为奇数和长度为偶数的回文串个数。换个角度,二者也表示了以位置 \(i\) 为中心的最长回文串的半径长度(半径长度 \(d_1[i], d_2[i]\) 均为从位置 \(i\) 到回文串最右端位置包含的字符个数)。
注意偶数回文的“中心”不是某个字符,而是两个字符之间的空隙。d2[i] 表示以 s[i-1] 和 s[i] 中间这条缝为中心的最长偶回文半径。于是每个偶回文都有且只有一个中心缝,所以也只会落到唯一一个 d2[i] 里,不会重复计数。
朴素做法 \(O(n ^ 2)\)
对于每个点,暴力的判左右是不是相等,然后将以该点为中心的回文串伸长出去。
点击查看代码
vector<int> d1(n), d2(n);
for (int i = 0; i < n; i++) {
d1[i] = 1;
while (0 <= i - d1[i] && i + d1[i] < n && s[i - d1[i]] == s[i + d1[i]]) {
d1[i]++;
}
d2[i] = 0;
while (0 <= i - d2[i] - 1 && i + d2[i] < n && s[i - d2[i] - 1] == s[i + d2[i]]) {
d2[i]++;
}
}
优化到 \(O(n)\)
我们考虑利用已有回文串信息,维护已有的右端点最靠右的一个回文串区间 \([l, r]\)。
为方便,初始设 \(l = 0, r = -1\),实际 \(r\) 可以取任意负值。
考虑我们现在要对下一个下标 \(i\),计算 \(d1[i]\),分类讨论:
- 若 \(i > r\),现有字符串帮不上忙,还是使用暴力做法
- 若 \(i \le r\) 有两种情况,设 \(i\) 在 \([l, r]\) 的对称位置上的点下标为 \(j\)(即满足\(j = l + (r - i)\)):
如果 \(j\) 的回文串没有超过维护的区间 \([l, r]\) ,那么由回文串的对称性 \(d1[i] = d1[j]\)。
如果 \(j - d1[j] + 1 \le l\),即 j 的回文串太长,长过了左端点,由于对称位置 i 所覆盖的回文串相应的跨过了右端点,所以是未知的,还是得暴力;
不过,在右端点以内的部分还是明确的,所以先让 \(d1[i] = min(d1[l + r - i], r - i + 1)\),再往外暴力。
注意到朴素算法的每次迭代均会使 \(r\) 增加 \(1\),以及 \(r\) 在算法运行过程中从不减小.这两个观察告诉我们朴素算法总共会进行 \(O(n)\) 次迭代.
\(Manacher\) 算法的另一部分显然也是线性的,因此总复杂度为 \(O(n)\).
如何处理偶数长度的回文串?
如果不想把 \(d1, d2\) 两个数组处理代码都写了,可以考虑将原串的空全插上特殊字符表示空隙。
\(\text{abccba} \to \text{!}\#\text{a}\#\text{b}\#\text{c}\#\text{c}\#\text{b}\#\text{a}\#\text{@}\)
如果字符集是所有可见字符,改成插入不可见字符,这样奇数长度的回文串的中心还是中心,偶数长度的回文串中心会变成中间的\(\#\)。
首尾两个字符是为了避免越界。
容易模拟,处理完后的串无论是求奇还是偶数长度的最长回文串长度,答案都是 ans = max(ans, d[i] - 1);。
如果涉及端点坐标,可以写个函数转换,以及建议从下标\(0\)开始整个字符串(即\(\text{!}\)对应下标\(0\))
计算 \(d1\) 代码:
点击查看代码
vector<int> d1(n);
for (int i = 0, l = 0, r = -1; i < n; i++) {
int k = (i > r) ? 1 : min(d1[l + r - i], r - i + 1);
while (0 <= i - k && i + k < n && s[i - k] == s[i + k]) {
k++;
}
d1[i] = k--;
if (i + k > r) {
l = i - k;
r = i + k;
}
ans = max(ans, d[i] - 1); // 即最长回文串长度,原理模拟一下即可
}
计算 \(d2\) 代码:
只再算术表达式上略有不同。
点击查看代码
vector<int> d2(n);
for (int i = 0, l = 0, r = -1; i < n; i++) {
int k = (i > r) ? 0 : min(d2[l + r - i + 1], r - i + 1);
while (0 <= i - k - 1 && i + k < n && s[i - k - 1] == s[i + k]) {
k++;
}
d2[i] = k--;
if (i + k > r) {
l = i - k - 1;
r = i + k;
}
}
警示
很多马拉车题目都可能会卡一个点:以某字符为中心的最长的串不符合题目要求,但是其次长的串有可能满足要求。
- P4287 双倍回文 要找一个大回文串使得其左右两边内部也是回文串,例如 \(ab ba ab ba\)。但是直接用每个偶数回文长度位置的最长串是不一定满足的,但是其伸长过程中可能有另解,比如 \(hack: aabbaabbaa\) 正确答案应该为 \(8\),如果直接伸长到 \(10\) 会发现不满足左右两边自己也是回文串,答案可能就记录为 \(0\) 了。
- P4555 最长双回文串 要求两个直接相连的回文串的最长长度,当我们直接在对称位置复制过来,就可能丢掉伸长此位置的回文串的过程中可能构造出的解。
ExKMP
算法复杂度线性。
- 前言
如果说 $KMP $像是在问:这个模板能不能完整盖到文本的某个位置上?
而扩展 \(KMP\) 就像是在问:模板放到文本每个位置时,能盖住前面多少个字符?
关联:P5410 【模板】扩展 KMP / exKMP(Z 函数)
给定字符串\(T\),\(S\)。对 \(T\) 的每个后缀求它和 \(S\) 的最长公共前缀。
分析过程和 \(Manacher\) 极其相似。
先对 \(S\) 自己和自己求一遍 \(Z\) 函数,即 \(S = T\) 的情况。
我们维护当前右端点最靠右的已知最长公共前缀。
- 如果当前点 \(i\) 在最长公共前缀右边,直接跑暴力
- 否则,首先\(i\) 的最长公共前缀长度就等于其在 \(S\) 对应位置(根据已知的 \(S\) 和 \(T\) 的公共前缀)\(j\) 的最长公共前缀。
如果 \(j\) 的公共前缀没有超过已知最长公共前缀右端点,则直接复制答案

否则,还是暴力出去

对于 \(S \neq T\) 的情况,几乎相同的内容要做两遍。
所以建议把过程封装起来,比如一种写法是,exkmp(*S, *T, *ansS, *ansT, n),然后两遍只需要传入不同的参数就行。
两遍的实现的唯一不同在于,不能算 \(S\) 和 \(S\) 全串的最长公共后缀作为已知答案,即若 \(S==T\),则从 \(2\) 开始 for。
点击查看代码
void Z(string s, int n){
for(int i = 1; i <= n; i++) z[i] = 0;
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[i + z[i]] == s[z[i] + 1]) z[i]++;
if(i + z[i] - 1 > r) l = i, r = i + z[i] - 1;
}
}
void exkmp(string s, int n, string t, int m){
Z(t, m);
for(int i = 1; i <= n; i++) p[i] = 0 ;
for(int i = 1, l = 0, r = 0; i <= n; i++){
if(i <= r) p[i] = min(z[i - l + 1], r - i + 1);
while(i + p[i] <= n && s[i + p[i]] == t[p[i] + 1]) p[i]++;
if(i + p[i] - 1 > r) l = i, r = i + p[i] - 1;
}
}
AC 自动机
AC 自动机是以 \(Trie\) 为基础,结合 \(KMP\) 思想建立的自动机。
基础 AC自动机
最基础的 AC自动机 解决这样一个问题:
有一些字符串集合 \(S = \{S_1, S_2, \dots, S_m\}\) 和一个字符串 \(T\),对 \(T\) 的每个前缀求一个最长的后缀,使得该后缀是某个 \(S_i\) 的前缀。
先考虑暴力:
把 \(S\) 中所有的串建为一个 \(Trie\)。
对每个前缀枚举后缀,判断其是否是 \(S\) 中某个串的前缀,即查询它是否在 \(Trie\) 里。
考虑利用已知信息优化;
考虑对当前前缀,增加一个字符前后的答案变化。
假设原前缀的答案后缀长度为 \(l\)。利用反证法容易知道,增加一个字符后的新前缀,答案最多是 \(l + 1\),即原答案后缀增加新字符后能够匹配上 \(Trie\)。
如果不能匹配上,新答案后缀的左端点一定不包含原答案后缀的左端点。
我们在所有原答案后缀(下图红色部分)的所有真后缀中找满足他是某个 \(S_i\) 的前缀的最长的后缀,注意可递归定义,或者说有传递性。

如何求这样的后缀呢?
我们在 \(Trie\) 的每个节点上记录一个额外的指针,指向这个节点代表的字符串的所有真后缀中找满足他是某个 \(S_i\) 的前缀的最长的后缀。
可以利用 \(BFS\) 序求每个前缀的此指针。
下图演示对字符串 i、he、his、she、hers 组成的字典树构建 AC 自动机的过程:

这个带有新指针的 \(Trie\) 就是 AC自动机。
一般称这个指针为 \(Fail\) 指针或者说失配指针,意思是当下一位不匹配时要走哪里接着匹配。
时间复杂度是 \(S\) 的长度和。
build() 函数的代码见下方多模式匹配-拓扑排序优化的 build() 去掉入度计数那一行即可。
多模式匹配
在刚刚的基础上,我们引入新的问题:有多少个模式串在文本中至少出现过一次。
对于整个模式串都是文本串前缀的,自然不必多说,在文本串做下方拓扑排序优化的代码中的 query 操作的时候,把经过的状态记录一下即可。
但是对于模式串只在文本串中间,而不是前缀,我们发现 \(Fail\) 指针的定义就是指向【当前前缀最长的后缀】满足其是【模式串集合前缀】的状态节点。
于是对着当前状态往 \(Fail\) 上全跳一次就知道有没有完整的模式串在当前前缀的任意后缀出现过了。
朴素做法是把所有的节点的所有 \(Fail\) 指针全跳一次,如果过程中指针停留的位置不仅仅是某个 \(S_i\) 的前缀,而就是 \(S_i\) 这个模式串,那么就意味着这个模式串完完整整在文本中出现了。
注意到所有的 \(fail\) 边形成一棵指向根的有向树。
为了不在每一个文本串前缀状态都要跳一次很长的链,标记一下即可,可以证明当出现 \(-1\) 时,从 \(-1\) 到根的可能模式串一定已经被统计了。
for (int i = 1, u = 0; t[i]; i++) {
u = tr[u].son[t[i] - 'a'];
for (int j = u; j && tr[j].cnt != -1; j = tr[j].fail)
ans += tr[j].cnt, tr[j].cnt = -1; // 对每个模式串只统计一次
// 由于fail边形成一棵指向根的有向树,当出现-1时,从-1到根的可能模式串一定已经被统计了
}
拓扑排序优化
注意效率优化部分题目继续延展为:统计模式串的出现次数(尤其是每个串的出现次数)。
如果我们只需要知道“有多少个模式串在文本中至少出现过一次”,那在输出答案的时候,改为统计 \(ans[idx[i]] > 0\) 的数量即可。
然而如果要求每个串的出现次数,而不是有多少个模式串在文本中完整出现过,我们无法通过标记为 \(-1\) 使得每个点只跑一次,只能像朴素做法那样把所有的节点的所有 \(Fail\) 指针全跳一次,很容易复杂度爆炸。
我们可以先记录着,最后一并求和。
注意到所有的 \(fail\) 边形成一棵指向根的有向树。
而且每个点只有一个父亲。
于是可以记录好树上点的入度,跑一个拓扑排序。
然后我们在查询的时候就可以只为找到结点的 \(ans\) 打上标记,在最后再用拓扑排序求出答案。
注意 idx[i] 一般表示:第 \(i\) 个模式串插入 \(Trie\) 后,最终停在哪个节点。
完整代码:
点击查看代码
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int maxn = 2e5 + 10;
const int Len = 2e6 + 10;
int n;
struct node{
int son[27]; // 子节点边
int cnt; // 记录匹配数
int fail; // fail指针
int in; // 入度
void init(){
memset(son, 0, sizeof(son));
cnt = fail = 0;
}
}tr[Len];
int tot;
int ans[Len];
int idx[maxn]; // 记录第 i 个串映射到trie上节点的编号
string s[maxn];
void init(){
for(int i = 0; i <= tot; i++) tr[i].init();
tot = 0;
tr[0].init();
}
void insert(string s, int id){
int p = 0;
int l = s.length();
for(int i = 0; i < l; i++){
int c = s[i] - 'a';
if(!tr[p].son[c]) tr[p].son[c] = ++tot;
p = tr[p].son[c];
}
// 由于有可能出现相同的模式串,需要将相同的映射到同一个编号
idx[id] = p;
}
void build(){
queue<int> q;
// 注意先预处理边界,即根的周围节点
// 如果有节点自然加入bfs去算fail,如果没有,那么要自然置0
// 因为失配fail指针跳到0的时候,如果0也没有这个字符的边,那就干脆指向根,即0
// 特别注意,根表示空串
for(int i = 0; i < 26; i++)
if(tr[0].son[i]) q.push(tr[0].son[i]);
// 如果要把失配树建成图,别忘了在这里还要加上0到存在儿子的失配边,便于从0直接开始dfs
while(!q.empty()){
int u = q.front();
q.pop();
for(int i = 0; i < 26; i++){
if(tr[u].son[i]){ // 存在对应子结点
int son = tr[u].son[i];
tr[son].fail = tr[tr[u].fail].son[i]; // 只用跳一次 fail 指针
tr[ tr[tr[u].fail].son[i] ].in++; // 入度计数
q.push(tr[u].son[i]); // 并加入队列
}
else tr[u].son[i] = tr[tr[u].fail].son[i];
// !! 将不存在的字典树的状态链接到了失配指针的对应状态
}
}
}
void query(string s){
int u = 0;
int l = s.length();
for(int i = 0; i < l; i++){
u = tr[u].son[s[i] - 'a'];
tr[u].cnt++;
// 在文本串路径上标记经过的状态,路径上所有经过的模式串都在文本串上完整出现了
}
}
void topu(){
queue<int> q;
for(int i = 0; i <= tot; i++)
if(!tr[i].in) q.push(i);
while(!q.empty()){
int u = q.front();
q.pop();
ans[u] = tr[u].cnt; // 记录该节点状态的答案
int v = tr[u].fail;
tr[v].cnt += tr[u].cnt;
if (!--tr[v].in) q.push(v);
}
}
int main(){
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
init();
cin >> n;
for(int i = 1; i <= n; i++){
cin >> s[i];
insert(s[i], i);
ans[i] = 0;
}
build(); // 构建失配树
cin >> s[0];
query(s[0]);
topu();
int cnt = 0;
// for(int i = 1; i <= n; i++) cout << ans[idx[i]] << endl;
for(int i = 1; i <= n; i++) cnt += (ans[idx[i]] > 0) ;
cout << cnt << endl;
return 0;
}
DFS 优化
和拓扑排序优化解决的是一个相同的问题,思路也大差不差。
完整代码:
点击查看代码
#include <cstdio>
#include <cstring>
#include <queue>
#include <vector>
using namespace std;
constexpr int N = 2e5 + 6;
constexpr int LEN = 2e6 + 6;
constexpr int SIZE = 2e5 + 6;
int n;
namespace AC {
struct Node {
int son[26];
int ans;
int fail;
int idx;
void init() {
memset(son, 0, sizeof(son));
ans = idx = 0;
}
} tr[SIZE];
int tot;
int ans[N], pidx;
vector<int> g[SIZE]; // fail 树
void init() {
tot = pidx = 0;
tr[0].init();
}
void insert(char s[], int &idx) {
int u = 0;
for (int i = 1; s[i]; i++) {
int &son = tr[u].son[s[i] - 'a'];
if (!son) son = ++tot, tr[son].init();
u = son;
}
// 由于有可能出现相同的模式串,需要将相同的映射到同一个编号
if (!tr[u].idx) tr[u].idx = ++pidx; // 第一次出现,新增编号
idx = tr[u].idx; // 这个模式串的编号对应这个结点的编号
}
void build() {
queue<int> q;
for (int i = 0; i < 26; i++)
if (tr[0].son[i]) {
q.push(tr[0].son[i]);
g[0].push_back(tr[0].son[i]); // 不要忘记这里的 fail
}
while (!q.empty()) {
int u = q.front();
q.pop();
for (int i = 0; i < 26; i++) {
if (tr[u].son[i]) {
tr[tr[u].son[i]].fail = tr[tr[u].fail].son[i];
g[tr[tr[u].fail].son[i]].push_back(tr[u].son[i]); // 记录 fail 树
q.push(tr[u].son[i]);
} else
tr[u].son[i] = tr[tr[u].fail].son[i];
}
}
}
void query(char t[]) {
int u = 0;
for (int i = 1; t[i]; i++) {
u = tr[u].son[t[i] - 'a'];
tr[u].ans++;
}
}
void dfs(int u) {
for (int v : g[u]) {
dfs(v);
tr[u].ans += tr[v].ans;
}
ans[tr[u].idx] = tr[u].ans;
}
} // namespace AC
char s[LEN];
int idx[N];
int main() {
AC::init();
scanf("%d", &n);
for (int i = 1; i <= n; i++) {
scanf("%s", s + 1);
AC::insert(s, idx[i]);
AC::ans[i] = 0;
}
AC::build();
scanf("%s", s + 1);
AC::query(s);
AC::dfs(0);
for (int i = 1; i <= n; i++) {
printf("%d\n", AC::ans[idx[i]]);
}
return 0;
}
复杂状态设计ac自动机
ac自动机的题绝大多数都是设置为 \(f[i][j]\) 表示文本串构造到第 \(i\) 个,模式串匹配到第 \(j\) 时的方案数/概率等。
这类题和上面在kmp后面放的【不含模式串的情况数/概率】题型是一样的,ac自动机也能做。
这里给一道复杂状态设计的 ac自动机题目。
非常细节的处理是,一个是状态收缩,一个是阶段设置,再一个是将状态设计为向量,因为向量乘矩阵是平方复杂度,而矩阵乘矩阵是立方复杂度。
点击查看代码
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int maxm = 1e6 + 10;
const int mod = 998244353;
struct matrix{
ll a[15][15];
matrix(){memset(a, 0, sizeof(a));}
matrix(int x){ // 单位矩阵初始化
memset(a, 0, sizeof(a));
for(int i = 0; i < 15; i++) a[i][i] = 1;
}
matrix operator*(const matrix &b) const{
matrix res;
for(int i = 0; i < 15; i++)
for(int j = 0; j < 15; j++)
for(int k = 0; k < 15; k++)
(res.a[i][j] += a[i][k] * b.a[k][j]) %= mod;
return res;
}
} A, B;
struct node{
int son[26]; // 子节点边
int cnt; // 记录匹配数
int fail; // fail指针
int in; // 入度
void init(){
memset(son, 0, sizeof(son));
cnt = fail = 0;
}
}tr[9];
int tot;
void insert(string s, int& id){
int p = 0;
int l = s.length();
for(int i = 0; i < l; i++){
int c = s[i] - 'a';
if(!tr[p].son[c]) tr[p].son[c] = ++tot;
p = tr[p].son[c];
}
id = p;
}
void build(){
queue<int> q;
for(int i = 0; i < 26; i++)
if(tr[0].son[i]) q.push(tr[0].son[i]);
while(!q.empty()){
int u = q.front();
q.pop();
for(int i = 0; i < 26; i++){
if(tr[u].son[i]){
int son = tr[u].son[i];
tr[son].fail = tr[tr[u].fail].son[i];
q.push(tr[u].son[i]);
}
else tr[u].son[i] = tr[tr[u].fail].son[i];
}
}
}
// 这里必须写成向量乘矩阵,矩阵乘法是立方复杂度,无法通过此题
typedef vector<ll> Vec;
Vec vec_mul(const Vec& v, const matrix& M){
Vec r(15, 0);
for(int i = 0; i < 15; i++) if(v[i])
for(int j = 0; j < 15; j++)
r[j] = (r[j] + v[i] * M.a[i][j]) % mod;
return r;
}
const int LOG = 30;
matrix Apow[LOG + 1];
// 第二步:预处理 A 的倍增幂,然后向量快速幂
// 在 build_matrix() 之后调用
void prepare_pow(){
Apow[0] = A;
for(int k = 1; k <= LOG; k++) Apow[k] = Apow[k-1] * Apow[k-1];
}
Vec vec_pow(Vec v, ll L){
for(int k = 0; k <= LOG; k++)
if((L >> k) & 1) v = vec_mul(v, Apow[k]);
return v;
}
matrix mqpow(matrix x, ll y){
matrix tmp = matrix(1);
while(y){
if(y & 1) tmp = x * tmp;
x = x * x;
y >>= 1;
}
return tmp;
}
int end1, end2;
int n, m;
vector<int> jing;
const int NV = 10; // trie 节点数上限
int rid[NV]; // rid[v]: 折叠后编号;终态记为 -1
int eff_n; // 折叠后节点数 = 7
// 折叠——把终态节点(ccf)"压缩"进事件处理里,不作为独立状态保留。节点 8(cspark)同理。
// 折叠前有 9 个节点,折叠掉节点 3 和节点 8,剩 7 个节点。
void rename_nodes(){
eff_n = 0;
for(int v = 0; v <= tot; v++){
if(v == end1 || v == end2) rid[v] = -1;
else rid[v] = eff_n++;
}
}
// idx(u, g) 把 (节点, 阶段) 二维状态打平成 0~14 的一个数。
inline int idx(int u, int g){
if(g == 2) return 14;
return g * eff_n + rid[u];
}
// 原本trie上,或者说自动机上应该有9个状态,我们现在压缩到7个,然后设置三个阶段
// 阶段g:0表示没出现过完整ccf;1表示已经匹配出ccf;2表示不仅匹配了ccf还匹配了cspark。
const int K = 15; // 阶段g = 0、1 各 7 个状态,加上吸收态 g = 2,一共15个状态
void build_matrix(){
// A: 字母段, 每位 26 种字母累加
// A的大小并不是26^2,因为我们要的是每种状态跳到每种状态的转移方案数
// 这个和【矩阵快速幂+kmp求构造含/不含模式串的文本串方案数】题型是一脉相承的思想
for(int u = 0; u <= tot; u++){
if(rid[u] == -1) continue;
for(int g = 0; g < 2; g++){
int s = idx(u, g);
for(int c = 0; c < 26; c++){ // 26 种跳转
int v = tr[u].son[c], ng = g;
if(v == end1){if(!ng) ng = 1; v = 0;}
else if(v == end2){if(ng == 1) ng = 2; v = 0;}
A.a[s][idx(v, ng)]++;
}
}
}
A.a[K - 1][K - 1] = 26; // A[14][14]=26 这一句对应吸收态:合法之后随便填 26 个字母都还是合法。
// B: # 段, 任何 (u, g) 都到 (0, g)
for(int u = 0; u <= tot; u++){
if(rid[u] == -1) continue;
for(int g = 0; g < 2; g++) B.a[idx(u, g)][idx(0, g)] = 1;
// 阶段保留,状态清零
}
B.a[K-1][K-1] = 1;
}
int main(){
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
cin >> n >> m;
for(int i = 1; i <= m; i++) {
int x;
cin >> x;
jing.push_back(x);
}
insert("ccf", end1);
insert("cspark", end2);
build();
// 折叠状态
rename_nodes();
build_matrix();
prepare_pow();
Vec v(K, 0);
v[idx(0, 0)] = 1; // 初始:根节点,阶段 0
int prev = 0;
for(int i = 0; i < m; i++){
int L = jing[i] - prev - 1; // 第 i 段字母长度
v = vec_pow(v, L); // 乘 A^L
v = vec_mul(v, B); // 乘 B(过一个 #)
prev = jing[i];
}
v = vec_pow(v, n - prev); // 最后一段字母
cout << v[K - 1] << '\n'; // 吸收态的方案数
return 0;
}
回文树
例题: 给定字符串 \(S\),求 \(S\) 中本质不同的所有回文串及其第一次出现的右端点的位置。
由于疑似不在NOI考纲中,暂时只贴个思想
-
线性复杂度,证明过程类似 AC 自动机。
-
考虑从左到右不断添加字符的过程,由于要求本质不同,所以每次最多只能形成一个新的不同的回文串:
考虑维护之前的最长回文后缀,则如果新的字符能与原来的最长回文后缀接上,则长度为 \(l + 2\)。
否则,类似 AC自动机思想,在之前的最长回文后缀中找最长的子回文后缀,使得其能与新的字符接上形成新的本质不同回文串。
所以每加入一个字符,最多只有一个新的本质不同回文串,就是“当前最长回文后缀”。 -
那么怎么保证这个“新的”真的没出现过?
这是靠回文树的转移判重保证的。
回文树每个节点表示一个本质不同的回文串,跳 \(fail\) 指针的时候,如果对应节点存在,说明之前已经出现过,不是新的。
反之就是新的,并建点。
下面是回文树的三个应用:
- 本质不同回文子串个数
- 回文子串出现次数
- 最小回文划分
后缀数组
后缀数组基本问题:给一个字符串的所有后缀按照字典序排序。
将给定串的所有后缀按照字典序排序。
后缀数组主要关系两个数组:\(sa\) 和 \(rk\)。
sa[i]表示排名第 \(i\) 的后缀的起始位置。rk[i]表示从 \(i\) 开始的后缀的排名。
暴力
- 将存有全部后缀字符串的数组进行 \(sort\),由于排序进行 \(O(n\log n)\) 次字符串比较,每次字符串比较要 \(O(n)\) 次字符比较,所以这个排序是 \(O(n^2\log n)\) 的时间复杂度.
- 快排+二分哈希比较,复杂度 \(O(nlog^2n)\)。注意可以使用区间哈希。
倍增优化 \(O(nlog^2n)\)
我们先把长度为 \(1\) 的串都排序,并保存好 \(rk_1\) 和 \(sa_1\) 数组。
随后倍增区间长度,比如对所有长度为 \(2K\) 的串排序,可以拆成左右两个长度为 \(K\) 的串,先比较左边那个串的已有排名,如果一样再比较右边那个串的已有排名,即可得到新的长为 \(2K\) 的串的排名了。
于是对每一层,只需要跑一个双关键字排序即可。

倍增一共 \(logn\) 层,排序 \(nlogn\),总复杂度 \(O(nlog^2n)\)
代码:
点击查看代码
constexpr int N = 1000010;
char s[N];
int n, w, sa[N], rk[N << 1], oldrk[N << 1];
// 为了防止访问 rk[i+w] 导致数组越界,开两倍数组.
// 当然也可以在访问前判断是否越界,但直接开两倍数组方便一些.
int main() {
int i, p;
scanf("%s", s + 1);
n = strlen(s + 1);
for (i = 1; i <= n; ++i) sa[i] = i, rk[i] = s[i];
for (w = 1; w < n; w <<= 1) {
sort(sa + 1, sa + n + 1, [](int x, int y) {
return rk[x] == rk[y] ? rk[x + w] < rk[y + w] : rk[x] < rk[y];
}); // 这里用到了临时函数(匿名函数) lambda
memcpy(oldrk, rk, sizeof(rk));
// 由于计算 rk 的时候原来的 rk 会被覆盖,要先复制一份
// 若两个子串相同,它们对应的 rk 也需要相同,所以要去重
for (p = 0, i = 1; i <= n; ++i)
if (oldrk[sa[i]] == oldrk[sa[i - 1]] && oldrk[sa[i] + w] == oldrk[sa[i - 1] + w]) rk[sa[i]] = p;
else rk[sa[i]] = ++p;
}
for (i = 1; i <= n; ++i) printf("%d ", sa[i]);
return 0;
}
基数排序继续优化 \(O(nlogn)\)
注意到排名范围 \([1, n]\),且只有两个关键字,完全可以把 \(O(nlogn)\) 的 \(sort\) 给换成 \(O(n)\) 的基数排序,于是总时间复杂度降为 \(O(nlogn)\)。
注意到第二关键字排序并不需要基数排序,可以常数优化。
最终代码如下:
点击查看代码
int main() {
scanf("%s", s + 1);
n = strlen(s + 1);
m = 128;
for (int i = 1; i <= n; i++) cnt[rk[i] = s[i]]++;
for (int i = 1; i <= m; i++) cnt[i] += cnt[i - 1];
for (int i = n; i >= 1; i--) sa[cnt[rk[i]]--] = i;
for (int w = 1;; w <<= 1, m = p) { // m = p 即为值域优化
int cur = 0;
for (int i = n - w + 1; i <= n; i++) id[++cur] = i;
for (int i = 1; i <= n; i++)
if (sa[i] > w) id[++cur] = sa[i] - w;
memset(cnt, 0, sizeof(cnt));
for (int i = 1; i <= n; i++) cnt[rk[i]]++;
for (int i = 1; i <= m; i++) cnt[i] += cnt[i - 1];
for (int i = n; i >= 1; i--) sa[cnt[rk[id[i]]]--] = id[i];
p = 0;
memcpy(oldrk, rk, sizeof(oldrk));
for (int i = 1; i <= n; i++) {
if (oldrk[sa[i]] == oldrk[sa[i - 1]] &&
oldrk[sa[i] + w] == oldrk[sa[i - 1] + w])
rk[sa[i]] = p;
else
rk[sa[i]] = ++p;
}
if (p == n) break; // p = n 时无需再排序
}
for (int i = 1; i <= n; i++) printf("%d ", sa[i]);
return 0;
}
给定串的后缀的最长公共前缀
给定字符串 \(S\),多次询问 \(S\) 的两个后缀的最长公共前缀(\(LCP\))
我们先用后缀数组把所有的后缀给排序完了,以 \(banana\) 为例:
排名 串
1 \(\ \ \ \ \ a\)
2 \(\ \ \ \ \ ana\)
3 \(\ \ \ \ \ anana\)
4 \(\ \ \ \ \ banana\)
5 \(\ \ \ \ \ na\)
6 \(\ \ \ \ \ nana\)
随后问题可以转化为:将相邻后缀求LCP,询问时求区间最小值。
原因很显然,在字典序中,两个后缀之间夹着的所有后缀,都和它们共享至少某个公共前缀长度;而这个长度的上界,正好由中间相邻对里最小的那个决定。
令 \(height[i]=lcp(sa[i],sa[i-1])\),即第 \(i\) 名的后缀与它前一名的后缀的最长公共前缀。
注意我们在原字符串上跑以下代码求 \(height\) 数组。证明从略。
点击查看代码
void get_height() {
int k = 0;
height[1] = 0; // 排名第 1 的后缀没有前一个后缀
for (int i = 1; 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;
}
}
给定字符串 S,求S的两个后缀的最长公共前缀
有了后缀排序之后,可以考虑按照排序后的结果来摆放这些后缀
这样询问的两个后缀之间的部分,长度为最长公共前缀的部分就都是一样的了
a
ana
anana
banana
na
nana
容易知道,这样我们可以求出排完序之后,相邻两个后缀的最长公共前缀,然后在询问的时候取一个区间最小值就好了。

由于对 \(h\) 值匹配成功失败个数有上限,所以可以证明是线性的。
然后维护区间查询最小值即可。
后缀自动机
-
后缀自动机的精髓在于把所有点按照 \(endpos\) 划分为等价类。
-
对于每个节点,我们一共要维护三种信息:
-
指向父亲节点的指针。父亲是新建该节点时在【排序】中该节点的前一个节点,或者理解为该节点接受的最短字符串删除首字符,找到所属的另一个状态。
-
指向从该节点出发的每种转移边的到达节点的指针
-
该节点能接受的最长的字符串的长度以及他的位置
-
-
在前一个后缀自动机构造好的基础上,如何构建后一个后缀自动机?
首先我们开一个全局指针 \(last\) 来维护之前接受(整个)原串的那个节点,这样从 \(last\) 开始,顺着父亲边爬上去,我们就能按从后往前的顺序找到上一个【排序】中的所有节点。
我们新建一个节点 \(np\),把它接受的最大字符串长度设为当前字符串长度,在爬的过程中往新节点加一条关于新字符的转移边,直到发现有一个节点已经有了该字母的转移边或者直到加完根的出边为止。
![image]()
![image]()
![image]()
![image]()
![image]()
记得把 \(last\) 更新为 \(np\)。
-
复杂度分析
可以证明,节点个数不超过 \(2n\),因为每增加一个字符最多只会新建两个节点 \(np\) 和 \(nq\)。
注意开两倍数组
随后,从 \(last\) 开始跳父亲总复杂度 \(O(n)\)这是因为加字符之后 \(last\) 的父亲的len不会超过加字符之前 \(last\) 的父亲的 \(len\) 加 1。由于总增量有上限 \(O(n)\),总减少量不会超过增加量,故得证。 -
性质
性质1 树:后缀自动机的所有节点和所有父亲边构成了一棵树,树根为后缀自动机的根。这棵树同时是反串的后缀树。
性质2 位置:后缀自动机的状态 \(right\) 集合大小是其在 \(parent\) 树中子树的 \(np\) 类节点数量,代表这个状态所代表的字串出现次数。
性质3:匹配
![image]()
![image]()
三个简单的应用
默认字符串的字符集为小写字母,串长总和不超过 \(10^6\)
- 已知两个字符串 \(S\) 和 \(T\),求 \(S\) 和 \(T\) 的最长公共子串
直接建出 \(S\) 的后缀自动机,用 \(T\) 在 \(S\) 上跑匹配,取长度最长的一个状态即可 - 已知字符串 \(S\),求 \(S\) 本质不同的非空子串个数
建出 \(S\) 的后缀自动机后,直接把每个非根节点接受的串的个数,即每个非根节点自己的 \(len\) 减去父亲的 \(len\) 加起来就是答案 - 已知两个字符串 \(S, T\),求 \(S\) 和 \(T\) 本质不同的公共非空子串个数。
考虑枚举 \(T\) 的每个本质不同的子串询问其是否是 \(S\) 的子串。
先建出 \(T\) 的后缀自动机,然后考虑每个节点接受的字符串中有哪些是 \(S\) 的子串。
由于一个节点接受的字符串都是某个子串的后缀,所以我们可以找到这些字符串在 \(T\) 中的一个右端点(即节点的 \(at\)),然后查询一下 \(T\) 的长度为 \(at\) 的前缀的最长且是 \(S\) 的子串的后缀的长度 \(l\)。
这样我们就可以知道 \(T\) 的以 \(at\) 为右端点的子串中长度不超过 \(l\) 的都是 \(S\) 的子串,长度大于 \(l\) 的都不是 \(S\) 的子串。
于是我们就可以把 \((0,l]\) 和节点接受字符串长度的区间(即(父亲的 \(len\),自己的 \(len\)])的交中的整数个数计入答案。
至于如何求 \(l\),只要预处理的时候建出 \(S\) 的自动机,用 \(T\) 在上面跑一遍就可以了。
GPT 大人给的代码
这份代码按你的习惯编写:全局数组 + struct node + insert() + calc_right()。
主函数可以直接用于 洛谷 P3804【模板】后缀自动机 ,同时保留了另外两个常用函数:
distinct() 求本质不同的非空子串数量,lcs(t) 求原串和 t 的最长公共子串长度。
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int maxn = 1000000 + 10;
struct node {
int ch[26], fa, len;
int cnt; // endpos 集合大小,即子串出现次数
} tr[maxn << 1];
int tot, last;
int buc[maxn], id[maxn << 1];
void init() {
tot = last = 1; // 1 号节点是根,0 表示不存在
tr[1] = {};
}
void insert(int c) {
int p = last, np = ++tot;
tr[np] = {}; // 多测时清空新节点
tr[np].len = tr[p].len + 1;
tr[np].cnt = 1; // 新前缀节点标记一次
last = np;
for (; p && !tr[p].ch[c]; p = tr[p].fa)
tr[p].ch[c] = np;
if (!p) {
tr[np].fa = 1;
return;
}
int q = tr[p].ch[c];
if (tr[p].len + 1 == tr[q].len) {
tr[np].fa = q;
return;
}
// 克隆节点,拆分 endpos 等价类
int nq = ++tot;
tr[nq] = tr[q];
tr[nq].len = tr[p].len + 1;
tr[nq].cnt = 0; // 克隆节点不是新增前缀
for (; p && tr[p].ch[c] == q; p = tr[p].fa)
tr[p].ch[c] = nq;
tr[q].fa = tr[np].fa = nq;
}
// 本质不同的非空子串个数
ll distinct() {
ll ans = 0;
for (int i = 2; i <= tot; i++)
ans += tr[i].len - tr[tr[i].fa].len;
return ans;
}
// 在当前 SAM 上匹配 T,求与原串的最长公共子串长度
int lcs(const string &t) {
int p = 1, len = 0, ans = 0;
for (char ch : t) {
int c = ch - 'a';
while (p != 1 && !tr[p].ch[c]) {
p = tr[p].fa;
len = tr[p].len;
}
if (tr[p].ch[c]) {
p = tr[p].ch[c];
len++;
} else {
p = 1;
len = 0;
}
ans = max(ans, len);
}
return ans;
}
// 求每个节点的 endpos 大小,建完 SAM 后调用一次
void calc_right() {
int n = tr[last].len;
// 按 len 计数排序
fill(buc, buc + n + 1, 0);
for (int i = 1; i <= tot; i++)
buc[tr[i].len]++;
for (int i = 1; i <= n; i++)
buc[i] += buc[i - 1];
for (int i = 1; i <= tot; i++)
id[buc[tr[i].len]--] = i;
// 按 len 从大到小,向 suffix link 父节点累加
for (int i = tot; i >= 2; i--) {
int u = id[i];
tr[tr[u].fa].cnt += tr[u].cnt;
}
}
// P3804:出现次数至少为 2,求出现次数 * 长度最大值
ll p3804() {
ll ans = 0;
for (int i = 2; i <= tot; i++)
if (tr[i].cnt > 1)
ans = max(ans, 1LL * tr[i].cnt * tr[i].len);
return ans;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
string s;
cin >> s;
init();
for (char c : s)
insert(c - 'a');
calc_right();
cout << p3804() << '\n';
return 0;
}
这份模板最值得记住的细节
第一,cnt 初始值不能乱设。 np 是新插入的整个前缀对应节点,初始化为 1;nq 是克隆节点,初始化为 0。执行 calc_right() 后,cnt[u] 才是正确的 right 集合大小。这个函数不能在同一次构造中重复累加。
第二,不要直接倒序枚举节点编号求 right。 克隆节点可能比它的后缀链接子节点更晚创建,所以必须按 len 递减的顺序统计。这里采用计数排序,避免递归遍历后缀链接树产生栈溢出。
第三,最长公共子串需要维护实际匹配长度。 不是单纯取匹配到的节点的 len,因为同一个 SAM 状态可能接受不同长度的字符串。这也是 lcs() 中额外维护 len 的原因。
第四,你之前记录的第三个应用,需要额外维护 at。 可以在 node 中添加成员 at,创建 np 时设为当前前缀长度,克隆时继承 q 的 at。再配合两个 SAM 和匹配长度数组,就可以计算两个串本质不同的公共非空子串个数。
P3804 【模板】后缀自动机(SAM)
给定一个只包含小写字母的字符串 \(S\)。
请你求出 \(S\) 的所有出现次数不为 \(1\) 的子串的出现次数乘上该子串长度的最大值。
如果没有这样的子串,输出 \(0\)。
对于 \(10 \%\) 的数据,\(\lvert S \rvert \le 1000\)。
对于 $100% $的数据,\(1 \le \lvert S \rvert \le {10}^6\)。
题解区题解代码:
#include<cstdio>
#include<cstring>
#include<string>
#include<cmath>
#include<algorithm>
#include<iostream>
using namespace std;
int tot=1,las=1;
struct NODE
{
int ch[26];
int len,fa;
NODE(){memset(ch,0,sizeof(ch));len=fa=0;}
}dian[2000010];
struct Edge
{
int t,nexty;
}edge[2000010];
int head[2000010],cnt=0;
void jia(int a,int b)
{
cnt++;
edge[cnt].t=b;
edge[cnt].nexty=head[a];
head[a]=cnt;
}
long long zhi[2000010];
inline void add(int c)
{
register int p=las,np=las=++tot;zhi[tot]=1;
dian[np].len=dian[p].len+1;
for(;p&&!dian[p].ch[c];p=dian[p].fa)dian[p].ch[c]=np;
if(!p)dian[np].fa=1;
else
{
register int q=dian[p].ch[c];
if(dian[q].len==dian[p].len+1)dian[np].fa=q;
else
{
register int nq=++tot;
dian[nq]=dian[q];dian[nq].len=dian[p].len+1;
dian[q].fa=dian[np].fa=nq;
for(;p&&dian[p].ch[c]==q;p=dian[p].fa)dian[p].ch[c]=nq;
}
}
}
char s[2000010];
int cd;
long long ans=0;
void dfs(int node)
{
for(register int i=head[node];i;i=edge[i].nexty)
{
dfs(edge[i].t);
zhi[node]+=zhi[edge[i].t];
}
if(zhi[node]!=1)ans=max(ans,zhi[node]*dian[node].len);
}
int main()
{
scanf("%s",s);cd=strlen(s);
for(register int i=0;i<cd;i++)add(s[i]-'a');
for(register int i=2;i<=tot;i++)jia(dian[i].fa,i);
dfs(1);
printf("%lld\n",ans);
return 0;
}









浙公网安备 33010602011771号