字符串后缀相关
1. 后缀数组
1.1 内容
我们将一个字符串 s 的所有后缀按照字典序从小到大排序得到数组 sa,其中 sai 表示以 sai 开始的后缀排名是第 i 个。
这个数组就叫后缀数组(Suffix Array, SA)。考虑到长度各不相同,所以显然是个排列,设数组 rk 是这个数组的逆排列。
我们考虑倍增:先对所有长为 2w 的子串排序,然后对于 2w+1 的子串可以表示为一个二元组 (rki,rki+2w),按照这个数组排序就是 2w+1 的子串的排序。
直接快排是 O(nlog2n),我们考虑优化掉快排,注意到值域不大,考虑基数排序。
我们先对第二关键字排序,再对第一关键字排序,由于桶排是稳定排序,这样排就能排出来。
还有一个小优化,如果排序到某一步就各不相同了可以直接结束。
下面是模板题代码,参考了 qAlex_Weiq 的实现:
#include <iostream>
#include <vector>
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 1e6 + 5;
char s[N];
int n, sa[N] = {0}, rk[N] = {0}, buc[N] = {0}, ork[N] = {0}, id[N] = {0};
void SA() {
int m = (1 << 7), p = 0;
for (int i = 1; i <= n; i++)
buc[rk[i] = s[i]]++;
for (int i = 1; i <= m; i++)
buc[i] += buc[i - 1];
for (int i = n; i >= 1; i--)
sa[buc[rk[i]]--] = i;
for (int w = 1; ; m = p, p = 0, w <<= 1) {
for (int i = n - w + 1; i <= n; i++) id[++p] = i;
for (int i = 1; i <= n; i++) if (sa[i] > w) id[++p] = sa[i] - w;
p = 0;
for (int i = 1; i <= m; i++) buc[i] = 0;
for (int i = 1; i <= n; i++) buc[ork[i] = rk[i]]++;
for (int i = 1; i <= m; i++) buc[i] += buc[i - 1];
for (int i = n; i >= 1; i--) sa[buc[rk[id[i]]]--] = id[i];
for (int i = 1; i <= n; i++) rk[sa[i]] = (ork[sa[i - 1]] == ork[sa[i]] && ork[sa[i - 1] + w] == ork[sa[i] + w]) ? p : ++p;
if (p == n)
break;
}
}
int main() {
scanf("%s", s + 1);
n = strlen(s + 1);
SA();
for (int i = 1; i <= n; i++)
printf("%d ", sa[i]);
return 0;
}
1.2 height 数组
SA 能解决一个很重要的问题:任意两个子串的 LCP,而这就基于 height 数组。
定义 heighti 表示 sai−1 与 sai 的 LCP 长度,再定义 hi 表示 heightrki。我们有一条重要结论:
这就意味着我们可以类似 KMP 的方法来求出这个 height 数组,时间复杂度 O(n)。
注意需要设置边界字符。
s[0] = s[n + 1] = '#';
for (int i = 1, k = 0; i <= n; i++) {
if (k)
k--;
while (s[i + k] == s[sa[rk[i] - 1] + k])
k++;
ht[rk[i]] = k;
}
1.3 应用
多测清空记住 buc 的范围要至少到 27!!!!!!!!!!!!!!!!!!!!
求任意两个子串的 LCP 长度
我们有一个很重要的结论:x,y 开始的后缀的 LCP 长度等于 [rkx,rky] 的区间中 height 的最小值,这个结论不难证明。
将子串转化成后缀不影响,变成了 RMQ 问题,一般用 ST 表进行查询。
P2408 不同子串个数
考虑到每个子串都是前缀的后缀,所以我们可以枚举所有后缀。
我们考虑逐步计数,我们从 sa1 开始计数,我们发现,sa2 新增的子串个数刚好等于 |s[sa2,n]|−height2,而后面的也是一样的。
所以最终我们就得到答案为 n(n+1)2−∑iheighti。
P5546 [POI2000] 公共串
首先对于多串问题,必须把他们都接在一起用特殊字符隔开,然后再跑 SA。
我们考虑二分,然后我们将所有 height<d 的断开,分成若干段,则如果有一段的的后缀中包括了 n 种字符串的就是可行的。
显然我们可以用双指针或者排序后并查集合并去掉二分。
P4248 [AHOI2013] 差异
将和式拆开,真正难算的是两两的 LCP 之和。
我们转化成求所有区间的最小值之和,这个问题可以用单调栈来做。
我们定义最小值是最靠左的那个,这样最小值就唯一了,然后处理出他作为最小值最远的左右段点,然后直接计算总共有多少个区间即可,时间复杂度 O(n)。
P3181 [HAOI2016] 找相同字符
我们还是将两个串接到一起求 SA。
然后我们发现答案实际上就是两两属于不同字符串的后缀的 LCP 之和,我们可以容斥,用总的减去两个分别的,就是上面的问题。
CF123D String
第一种是将其转化成两两的 LCP,但是这是因为这道题式子本身的特性。
还有一种可以对于任意的式子都能解决。
我们还是枚举最小值和所处区间,我们发现,这意味着这个最小值就去出现了这个区间的长度次,并且所有比这个区间左右两边小于最小值的还大的一直到最小值都是如此,我们可以直接统计这些子串的贡献。
但是这样就漏到了那些只出现一次的,所以我们还要用总的减去两侧 height 的最大值,也就是那些不会被统计到的贡献。
时间复杂度还是 O(n)。
P2178 [NOI2015] 品酒大会
我们考虑之前讲过的哪种倒过来用并查集不断合并统计贡献的。
显然两个后缀的贡献会在所有小于等于其 LCP 的时候被统计到,我们用并查集维护,合并时更新答案即可。
P4094 [HEOI2016/TJOI2016] 字符串
子串肯定是取到后缀最优,答案变成 max。
这个问题显然满足单调性,不妨考虑二分答案,假设答案为 k,则我们要检查的就是是否有 i 满足:
-
a \le i \le b - k + 1。
-
LCP(i,c) \ge k。
我们还是考虑经典的做法:求出 ht 数组后从大到小合并,那么我们只要知道合并到 k 时的区间,然后看一下区间中的 sa 是否有属于 [a, b - k + 1] 即可。
前一部分可以用 ST 表加二分做到 O(n \log n),后一部分也可以用主席树做到 O(n \log n)。
然而我没想到 ST 表加二分,直接冲了一个可持久化并查集。
CF235C Cyclical Quest
SA 被卡了,可恶。
首先,一个串的所有循环同构的所有起点是 [1, root],所以我们要 KMP 求出每个询问串的 root。
然后我们将 S 和所有询问串拼接起来,对于一个询问串,我们将其所有起点的后缀拿去比较。我们希望他们的 LCP 都大于等于长度。
所以我们用并查集维护集合大小倒序合并,同时处理询问即可。时间复杂度 O(n \log n) 被卡了。
CF802I Fake News (hard)
和 123D 一样的技巧。
CF616F Expensive Strings
我们考虑将 n 个串全部拼接起来,然后我们看作在某个串出现一次有固定的贡献。
我们枚举每个 height 值,然后看以它作为答案的贡献,可以预处理前缀和算。同时还要注意把所有单个的也算一下。
P1117 [NOI2016] 优秀的拆分
经典 trick。
我们显然是计算以 i 结尾和开始的 AA 型字符串个数,然后统计即可。
我们考虑枚举 A 的长度,然后将串按照长度分段。
不难发现 AA 必然包含了整整一段,并且不断循环往两边拓展。
我们枚举这一段,倒着求一遍 LCP 再正着求一遍 LCP 就可以得到所有可能的位置。
这些位置显然构成一个区间,用差分实现区间加。
这个 trick 太常用了。时间复杂度 O(\sum \frac{n}{i}) = O(n \log n)。
POJ3693 Maximum repetition substring
我们还是一样的技巧,枚举重复若干次的那个串的长度,分段,求最长扩展长度。
但是这里还要求字典序最小,对于不同的答案直接 LCP 比较字典序即可。对于一个区间的,直接找 rk 最小的即可。
所以需要两个 SA 加上两个 ST 表求 LCP 加上一个 ST 表求 rk。最后一个没有必要记位置,可以用 sa 找到。
P7456 [CERC2018] The ABCD Murderer
这道题显然是对每个位置求出 g_i 表示从这个位置开始最长匹配长度。
然后所有接到一起求 SA,然后每个 ST 表加二分求每个单词的所有出现位置,然后并查集区间 max 即可。
但是显然 AC 自动机正常一些,容易得不是一点半点。
2. 后缀自动机 SAM
2.1 定义
状态集即一个自动机的所有状态的集合,包括初始状态 \text{init} 和 终止状态 \text{end}。
字符集 \Sigma 是这个自动机的字符的集合。
转移函数 \text{tr(x,c)} 表示从 x 状态读入 c 后到达的状态。
\text{suf(i)} 表示字符串 S 以 i 开始的后缀。
\text{endpos(a)} 表示对于字符串 a,其在 S 中所有出现位置的结束位置(也就是左端点)。
\text{x(a)} 表示自动机输入 a 后到达的状态。
\text{rec(A)} = \{a|\text{x(a)} \in \text{end}\},也就是一个自动机 A 能识别的所有字符串。
\text{rec(x)} 表示从 x 出发能识别到的串。
\text{SAM(S)},也就是一个自动机 A 使得 \text{rec(A)} = \{\text{suf(i)}|1 \le i \le |S|\}。
2.2 内容
我们现在希望建出一个 SAM 来,最暴力的方法是对所有后缀建立 Trie,但是时间复杂度太高了。
我们需要一些引理和观察来简化。
观察 1: 若对于状态 x,x' 满足 \text{rec(x)} = \text{rec(x')},则 x,x' 可以合并到一个状态。
观察 2: \text{rec(x(a))} = \{\text{suf(i)}|i \in \text{endpos(a)}\}。
观察 3: x,x' 可合并 \iff \text{endpos(x) = endpos(x')}
观察 4: 如果 \text{x(a) = x} 且 |a| = len,则可推出 a。
不妨设 \text{len(x)} = \{len|\exist a, \text{x(a) = x},|a| = len\}。
观察 5: \forall len \in (len_1, len_2) 且 len_1,len_2 \in \text{len(x)},则 len \in \text{len(x)}。
引理 1: \text{endpos} 集合是嵌套结构,要么不相交,要么包含。
推论: SAM 节点数是 O(n) 的。
我们再定义 parent 树,每个节点的父节点是包含它的最小的 \text{endpos} 的状态。
观察 6: x 的父亲是 fa,则 \text{maxlen(fa)} = \text{minlen(x)} - 1。
观察 7: SAM 的有效边构成 DAG,如果 x \to y,则 \text{minendpos(y)} > \text{minendpos(x)} 且 \text{maxlen(y)} > \text{maxlen(x)}。
引理 2: SAM 边数 O(n)。
考虑随便选一棵树形图,则每条非树边可以对应一个不同的后缀,所有总数不超过 2n。
接下来就能得到一些性质。
性质 1: 如果 x 出发有 c 的边,则 fa 也有。
性质 2: 如果 \text{tr(x,c)} = y, \text{tr(y,c)} = z,则 \text{endpos(y)} \sube \text{endpos(z)}。
性质 3: endpos 包含 |S| \iff x 可表达后缀。
性质 4: a 是 S 的子串 \iff \text{x(a)} 存在,出现次数为 |\text{endpos(x(a))}|。
至此我们可以给出一个 O(n\sigma) 构造 SAM 的代码:
const int N = 2e6 + 5;
const int S = 26;
struct SAM {
int root = 0;
int tot = 0;
int ch[N][26] = {{0}};
int len[N] = {0};
int fa[N] = {0};
int newNode(int _len) {
int x = ++tot;
len[x] = _len, fa[x] = 0;
for (int i = 0; i < S; i++)
ch[x][i] = 0;
return x;
}
int cpy(int y) {
int x = ++tot;
len[x] = len[y], fa[x] = fa[y];
for (int i = 0; i < S; i++)
ch[x][i] = ch[y][i];
return x;
}
int add(int p, int c) {
int cur = newNode(len[p] + 1);
while (p && !ch[p][c])
ch[p][c] = cur, p = fa[p];
if (!p) {
fa[cur] = root;
return cur;
}
int q = ch[p][c];
if (len[q] == len[p] + 1)
fa[cur] = q;
else {
int r = cpy(q);
len[r] = len[p] + 1;
fa[q] = fa[cur] = r;
while (p && ch[p][c] == q)
ch[p][c] = r, p = fa[p];
}
return cur;
}
SAM () {}
} sam;
2.3 应用
P3804 【模板】后缀自动机(SAM)
建出 SAM,然后我们可以以 len 为关键字排序然后计算 endpos,一个点的 endpos 大小就是其子树的大小加上它自己。
P4070 [SDOI2016] 生成魔咒
相当于求每个前缀的本质不同子串个数。
我们知道 SAM 构成了一张 DAG,并且每个从初始状态出发的路径都恰好对应了一个子串!所以本质不同子串个数就是路径数,直接跑 dp 即可。
但是这道题这种方法不够好,所以我们还有一种:一个点储存的字符串个数等于 \text{maxlen(i)} - \text{minlen(i)}+1,也就是 len(i) - len(fa_i),动态维护这个值之和即可。
P5212 SubString
先考虑如何用 SAM 解决字符串匹配,我们直接将要匹配的 t 在自动机上走,如果走不下去说明没有这个子串。
否则我们到达一个节点,而出现次数刚好就是这个节点的 endpos 集合大小。
但是 endpos 集合大小需要计算子树和,而由于动态加入,parent 树不断在变。
我们发现 parent 树的主要变化就是一个点断开其父亲,指向新的父亲。LCT 维护子树和(需要用虚子树技巧)即可。
P5341 [TJOI2019] 甲苯先生和大中锋的字符串
恰好出现 k 次,也就是 endpos 恰好大小为 k 的所有状态的所有字符串,统计一下即可。
P3975 [TJOI2015] 弦论
我们先考虑求最小的固定长度的子串该如何求。
我们可以贪心地走,每次走能走的最小的边即可。
这就其实我们我们求第 k 小也是类似的,不妨先算出 f_i 表示从 i 出发的所有路径个数。
那么我们每次先看最小的够不够,够就走,否则就减去,然后看下一个。
如果位置不同的算不同的,那就把 f_i 计算时初值设为 endpos 大小即可。
SP1812 LCS2 - Longest Common Substring II
求最长公共子串有两种方法,都很重要。
方法一: 先考虑两个字符串 s,t。我们考虑对 s 建出 SAM。
我们发现,如果我们能求出 g_r 表示 t[r] 往前最多多少位都是 s 的子串,那么答案就是 \max g_r。
我们考虑 g_r 的计算。假设我们已经知道了 g_{i-1},并且当前在 SAM 的状态 x。
如果 tr(x, s_i) 存在,那么我们直接 g_i = g_{i-1} + 1 并且 x \to tr(x,s_i)。
否则,我们画一下图知道答案一定是 x 所表示的所有字符串的前缀,也就是其在 parent 树上的祖先。
于是我们往上跳 parent 树,找到第一个后面可以接 s_i 的点,如果没有,那么 g_i = 0, x \to root。
假设这个点是 y,那么 y 其实代表的是很多字符串,我们为了最长,所以让 g_i = len_y+1,并且 x \to tr(y,s_i) 即可。
拓展到多串时,我们选取把除了长度最短的字符串之外的字符串全部建 SAM,然后每个都在最短的字符串上求一遍 g,那么答案就是 \max_i\min_jg^{(j)}_i
方法二: 我们反过来,考虑建出最短的字符串的 SAM,对于每个节点维护一个 mx 表示最长能匹配的长度。
那么我们对于其他串直接跑,每次找到能匹配的最长长度来更新 mx,然后不同串跑出来的 mx 取最小,然后再取最大值即可。
但是这里注意我们需要在每次跑完一个串时用 mx 更新其到根节点上的所有路径的 mx,这是因为如果一个子树内有两个点在不同串中分别跑出了最大值的话,我们就统计不到了。但是如果两个串就没有关系。
P6640 [BJOI2020] 封印
这道题就需要用到上面的方法一。我们对 t 建出 SAM,然后对 s 求出 g,那么答案就是 \max_{i=l}^r\min\{g_i, i - l + 1\},也就是 \max_{i=l}^r (i - \max\{i - g_i + 1, l\} + 1),二分一下然后 ST 表查询即可。
HDU4416 Good Article Good sentence
这里用到的是上面的方法二。我们对于每个状态求出其匹配的最长长度,那么没到的就是只在 A 出现的了。
51nod1600 Simple KMP
好题,我们设 T[i] 表示 \sum_{l=1}^if(s[l,r]),则答案就是 T 的前缀和。我们推一下式子:
我们把后面记作 A[r],则 T 就是 A 的前缀和。我们继续:
考虑到所有的 s[l,r] 对应到 parent 树上就是从根开始的链,所以我们可以将问题转化为每次查询链上加权和与链上区间加,树剖加线段树即可,时间复杂度 O(n \log n)。
P4081 [USACO17DEC] Standing Out from the Herd P
我们考虑将所有的字符串接到一起,中间用特殊字符隔开,然后求 SAM。
我们发现,如果一个子串只在一个串中出现,就是其 endpos 的集合的位置都在这个串上。我们只需要记录最大最小来判断即可。
同时我们还要记录一下一个结点的合法长度,也就是最多多长能没有特殊字符。
P4094 [HEOI2016/TJOI2016] 字符串
这道题也有 SAM 离线来做,我们还是二分答案,变成看 s[c, c + k - 1] 是否在 s[a,b] 出现过。
于是我们找到 s[1, c + k - 1] 的状态倍增往上跳,然后看 endpos 有没有就行,线段树合并即可。
3. 后缀树
3.1 内容
后缀树就是将所有的后缀插到 Trie 上。
我们可以证明后缀树的有效边只有 O(n)。
然后就是神奇的结论了:
S 的后缀树等于 S 取反的 parent 树。
所以我们可以高效建出后缀树。
3.2 应用
求两个后缀的 LCP
显然就是两个后缀在后缀树上 LCA 的深度。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步