后缀数组(SA)、后缀自动机(SAM)、广义 SAM 总结
后缀数组(SA)
基础知识
\qquad 后缀数组中涉及到的主要有三个重要的数组:1、 s a sa sa 数组: s a i sa_i sai 表示排名为 i i i 的后缀是哪一个后缀;2、 r a n k rank rank 数组: r a n k i rank_i ranki 表示第 i i i 个后缀的排名是几;3、 h e i g h t height height 数组: h e i g h t i height_i heighti 表示排名为 i i i 的后缀与排名为 i − 1 i-1 i−1 的后缀的最长公共前缀( L C P LCP LCP)的长度。
\qquad 求后缀数组一般用的是时间复杂度 O ( n log n ) O(n\log n) O(nlogn),但是常数极小的倍增法。整体思想是基于基数排序的思想。大体过程为:1、先以第一关键字(长度为 1 1 1 的前缀)为基准排序;2、将原先的第一关键字作为现在的第二关键字,将长度为 2 2 2 的前缀作为第一关键字排序;3、重复上述步骤,每次第一关键字的长度倍增,直到排序结束停止。
\qquad 代码实现上有点小技巧,比如:第一次排序结束后,再进行第二次排序前要先求出所有后缀的第二关键字排名。在求第二关键字排名的时候,我们可以发现:第 i i i 个后缀的第一关键字刚好是第 i − 1 i-1 i−1 个后缀的第二关键字。在后面排序进行次数更多的时候也是同理:假设进行了 k k k 次排序,那么此时第 i i i 个后缀的第一关键字刚好是第 i − 2 k i-2^k i−2k 个后缀的第二关键字。
\qquad 对于第一关键字相同的后缀,我们要按照第二关键字来排序。但是怎么做到呢?这里有个小技巧就是倒序枚举第二关键字的排名。这样就可以在第一关键字相同的情况下第二关键字也有序。
\qquad C o d e : Code: Code:
void Get_sa() {
//初始按照第一关键字排序
for(int i = 1; i <= n; i ++) c[X[i] = s[i]] ++;
for(int i = 1; i <= m; i ++) c[i] += c[i - 1];
for(int i = n; i; i --) sa[c[X[i]] --] = i;
for(int k = 1; k <= n; k <<= 1) {
int num = 0;
for(int i = n - k + 1; i <= n; i ++) Y[++ num] = i;
for(int i = 1; i <= n; i ++) {
if(sa[i] > k) Y[++ num] = sa[i] - k;//求出按照第二关键字排序的结果
}
for(int i = 1; i <= m; i ++) c[i] = 0;
for(int i = 1; i <= n; i ++) c[X[i]] ++;
for(int i = 1; i <= m; i ++) c[i] += c[i - 1];
for(int i = n; i; i --) sa[c[X[Y[i]]] --] = Y[i], Y[i] = 0;//先按第一关键字排,若相同则按照第二关键字排
swap(X, Y);
X[sa[1]] = 1, num = 1;
for(int i = 2; i <= n; i ++) {
X[sa[i]] = ((Y[sa[i]] == Y[sa[i - 1]] && Y[sa[i] + k] == Y[sa[i - 1] + k]) ? num : ++ num);
}
if(num == n) break;
m = num;
}
}
\qquad 关于 H e i g h t Height Height 数组的求法,这里先引出几条性质:1、排名为 i i i 的后缀与排名为 j j j 的后缀( i < j i<j i<j)的 L C P LCP LCP 等于 min \min min( L C P LCP LCP(排名为 i i i 的后缀,排名为 i + 1 i+1 i+1 的后缀), L C P LCP LCP(排名为 i + 1 i+1 i+1 的后缀,排名为 i + 2 i+2 i+2 的后缀), … \dots …)。形式化地, L C P ( s a i , s a j ) = min ( L C P ( s a i , s a i + 1 ) , L C P ( s a i + 1 , s a i + 2 ) , … ) LCP(sa_i,sa_j)=\min(LCP(sa_i, sa_{i+1}), LCP(sa_{i+1}, sa_{i+2}),\dots) LCP(sai,saj)=min(LCP(sai,sai+1),LCP(sai+1,sai+2),…)。2、记 h i = L C P ( i , s a r k i − 1 ) h_i=LCP(i,sa_{rk_i-1}) hi=LCP(i,sarki−1),即 h i = h e i g h t r k i h_i=height_{rk_i} hi=heightrki则 h i ≥ h i − 1 − 1 h_i\geq h_{i-1}-1 hi≥hi−1−1。根据第二条性质,我们便可以通过求解 h h h 数组来求 h e i g h t height height 数组。求 h h h 数组就暴力拓展即可,时间复杂度 O ( n ) O(n) O(n)。
\qquad C o d e : Code: Code:
void get_ht() {
for(int i = 1; i <= n; i ++) rk[sa[i]] = i;
for(int i = 1, k = 0; i <= n; i ++) {
if(rk[i] == 1) continue;
if(k) k --;
int j = sa[rk[i] - 1];
while(i + k <= n && j + k <= n && s[i + k] == s[j + k]) k ++;
ht[rk[i]] = k;
}
}
例题
【模板】后缀排序
\qquad 题面
\qquad 大板。
[NOI2015] 品酒大会
\qquad 题面
\qquad h e i g h t height height 数组的应用。
\qquad 两杯酒 p , q p,q p,q 是 r r r 相似的,意味着以 p p p 开头的后缀和以 q q q 开头的后缀的 L C P LCP LCP 为 r r r。而他们既然是 r r r 相似,也一定是 r − 1 r-1 r−1 相似, r − 2 r-2 r−2 相似……所以我们考虑倒着求解,先求解 n n n 相似,在求解 n − 1 n-1 n−1 相似……假设当前求解到 r r r 相似,那么我们将所有 h e i g h t i = r height_i=r heighti=r 的 i i i 与 i − 1 i-1 i−1 合并(注意这里指的是排名),用并查集维护,顺便维护最大值、次大值、最小值、次小值(两个负数相乘)即可。
\qquad 核心 C o d e : Code: Code:
void merge(int fx, int fy) {//并查集合并
sze[fy] += sze[fx], bin[fx] = fy;
if(max1[fx] >= max1[fy]) max2[fy] = max(max1[fy], max2[fx]), max1[fy] = max1[fx];
else if(max1[fx] > max2[fy]) max2[fy] = max1[fx];
if(min1[fx] <= min1[fy]) min2[fy] = min(min1[fy], min2[fx]), min1[fy] = min1[fx];
else if(min1[fx] < min2[fy]) min2[fy] = min1[fx];
}
PLL solve(int h) {//求解"h相似"
for(int x : hs[h]) {
int fa = B.Find(x - 1), fb = B.Find(x);
cnt -= B.Get(fa) + B.Get(fb);
B.merge(fa, fb);
cnt += B.Get(fb), Max = max(Max, B.Get_max(fb));
}
if(Max == -INF) return make_pair(cnt, 0);
else return make_pair(cnt, Max);
}
基因突变
\qquad 简要题面已经给的很裸了,求出 h e i g h t height height 数组后用 s t st st 表维护下区间最值即可。
[AHOI2013] 差异
\qquad 题面
\qquad 一眼套路题,求出 h e i g h t height height 数组后转化为区间最小值求和,单调栈搞一搞即可。
\qquad 核心 C o d e : Code: Code:
stk.push(1);
for(int i = 2; i <= n; i ++) {
while(!stk.empty() && ht[i] < ht[stk.top()]) r[stk.top()] = i, stk.pop();
if(!stk.empty()) l[i] = stk.top();
stk.push(i);
}
while(!stk.empty()) r[stk.top()] = n + 1, stk.pop();
LL ans = 0, res = 0;
for(int i = 1; i <= n; i ++) ans += 1LL * (n - 1) * i;
for(int i = 2; i <= n; i ++) res += 1LL * (i - l[i]) * (r[i] - i) * ht[i];
printf("%lld\n", ans - 2LL * res);
不同子串个数
\qquad 题面
\qquad 典题。我们考虑正难则反。对于长度为 n n n 的字符串,一共有 n × ( n + 1 ) 2 \frac{n\times (n+1)}{2} 2n×(n+1) 个子串,这些子串都可以看成某个后缀的某个前缀。现在,我们考虑有哪些子串被重复统计了。注意到,若 h e i g h t i = j height_i=j heighti=j,那就意味着排名为 i i i 的后缀与排名为 i − 1 i-1 i−1 的后缀有着长度为 j j j 的公共前缀,那这 j j j 个长度分别为 1 , 2 , … , j 1,2,\dots,j 1,2,…,j 的子串就会被统计两次。所以,我们得出结论:本质不同的子串个数等于 n × ( n + 1 ) 2 − ∑ i = 2 n h e i g h t i \frac{n\times (n+1)}{2}-\sum_{i=2}^nheight_i 2n×(n+1)−∑i=2nheighti。
后缀自动机(SAM)
基础知识
\qquad 一个字符串的后缀自动机可以使用点数为 2 n 2n 2n 级、边数为 3 n 3n 3n 级的代价存储这个字符串的所有子串。后缀自动机上任意一条从根节点出发的路径都代表了原串的一个子串。
\qquad 相关概念: e n d p o s ( T ) endpos(T) endpos(T):在字符串 S S S 中, T T T 的所有结束位置构成的集合记作 e n d p o s ( T ) endpos(T) endpos(T)。 f a ( x ) fa(x) fa(x):将 x x x 这个状态所对应的所有字符串中,长度最短的字符串去掉首字母后所在的状态记作 f a ( x ) fa(x) fa(x)。后缀自动机上所有节点的 f a fa fa 共同构成一棵树,叫做 p a r e n t t r e e parent\;tree parenttree。
\qquad 构建过程理解起来挺抽象,不过代码很好写。
struct node {
int fa, len;
int son[26];
}sam[maxn << 1];
int tot = 1, lst = 1;
void Insert(int x) {
int p = lst, np = lst = ++ tot;//新建节点,更新lst
sam[np].len = sam[p].len + 1;//记录新状态代表的字符串的最长长度
while(p && !sam[p].son[x]) sam[p].son[x] = np, p = sam[p].fa;//给所有没有x这个儿子的节点往后延伸一段
if(!p) sam[np].fa = 1;//连接到根
else {
int q = sam[p].son[x];
if(sam[q].len == sam[p].len + 1) sam[np].fa = q;//是当前已添加串的后缀
else {
int nq = ++ tot;
sam[nq] = sam[q], sam[nq].len = sam[p].len + 1;
sam[q].fa = sam[np].fa = nq;
while(p && sam[p].son[x] == q) sam[p].son[x] = nq, p = sam[p].fa;
}
}
}
例题
【模板】后缀自动机(SAM)
\qquad 题面
\qquad 后缀自动机 p a r e n t t r e e parent\;tree parenttree 的经典应用:查找某一字串出现的次数。首先有显然的结论:一个状态对应的所有子串出现的次数相同;再一个结论:记 f x f_x fx 表示状态 x x x 表示的所有子串的出现次数,则 f x = ∑ f a v = x f v f_x=\sum_{fa_v=x}f_v fx=∑fav=xfv,感性理解很简单: x x x 的儿子出现了 f v f_v fv 次,那 x x x 一定也出现了 f v f_v fv 次。建树后统计即可。
\qquad 核心 C o d e : Code: Code:
void dfs(int x) {
for(int v : to[x]) {
dfs(v);
f[x] += f[v];
}
if(f[x] > 1) ans = max(ans, 1LL * f[x] * sam[x].len);
}
[JSOI2012] 玄武密码
\qquad 题面
\qquad 建立 S S S 的后缀自动机,对于每个 T T T,暴力跑后缀自动机看能匹配多长即可。
\qquad 核心 C o d e : Code: Code:
int p = 1, len = 0;
for(int j = 1; j <= n; j ++) {
int x = Get(s[j]);
if(sam[p].son[x]) p = sam[p].son[x], len ++;
else break;
}
printf("%d\n", len);
最长公共子串
\qquad 先建立第一个串的后缀自动机,对于后面的串,暴力在自动机上跑,若失配则跳 f a fa fa 指针,每跳到一个节点更新一下匹配的长度并储存在当前节点处(同一个串取 m a x max max)。一个串跳完后,将所有结点储存的匹配长度存到 a n s ans ans 数组中(不同串取 m i n min min)。在所有串跳完后,输出 a n s ans ans 数组最大值即可。
\qquad 注意:一个串跳完后,要将自动机上储存的 m a x max max 值在 p a r e n t t r e e parent\;tree parenttree 上自下而上传递贡献。因为每个点记录的长度一定是当前节点表示的字符串的公共后缀,而 f a fa fa 储存的子串也是当前节点的后缀。
\qquad 核心 C o d e : Code: Code:
for(int i = 1; i <= tot; i ++) ans[i] = sam[i].len, add(sam[i].fa, i);//初始化答案,传标记时不用取min
for(int i = 1; i < n; i ++) {
scanf("%s", s + 1);
int len = strlen(s + 1);
int p = 1, res = 0;
memset(Now, 0, sizeof Now);
for(int j = 1; j <= len; j ++) {
int x = s[j] - 'a';
while(p > 1 && !sam[p].son[x]) p = sam[p].fa, res = sam[p].len;//跳fa
if(sam[p].son[x]) p = sam[p].son[x], res ++;
Now[p] = max(Now[p], res);//同串取max
}
dfs(1);//自下而上传递标记
for(int j = 1; j <= tot; j ++) ans[j] = min(ans[j], Now[j]);//不同串取min,因为要求所有串共同满足
}
int res = 0;
for(int i = 1; i <= tot; i ++) res = max(res, ans[i]);//取最大值
printf("%d\n", res);
后缀自动机 2 重复旋律
\qquad 后缀自动机求本质不同子串个数。有两种方法:1、建反图后拓扑排序求路径条数;2、每次插入新字符的时候动态更新,插入一个字符带来的贡献是 l e n ( n p ) − l e n ( f a ( n p ) ) len(np)-len(fa(np)) len(np)−len(fa(np))。
\qquad 核心 C o d e : Code: Code:
//拓扑排序
for(int i = 1; i <= tot; i ++)
for(int j = 0;j < 26; j ++) {
if(sam[i].son[j]) du[i] ++, add(sam[i].son[j], i);//建反图
}
for(int i = 1; i <= tot; i ++) {
if(!du[i]) qq.push(i);
}
while(!qq.empty()) {
int p = qq.front(); qq.pop();
for(int i = head[p]; i; i = edge[i].lst) {
int v = edge[i].to;
du[v] --, tim[v] += tim[p] + 1;
if(!du[v]) qq.push(v);
}
}
printf("%lld\n", tim[1]);
//动态更新
ans += 1LL * (sam[np].len - sam[sam[np].fa].len);
后缀自动机 6 重复旋律
\qquad 先正常求每个状态对应的字符串出现几次,因为每个状态对应的字符串的长度是一段连续区间,所以线段树维护一下,区间取 max \max max 即可。
\qquad 核心 C o d e : Code: Code:
void dfs(int x, int fa) {
for(int v : to[x]) {
dfs(v, x);
f[x] += f[v];
}
if(x != 1 && f[x]) change(1, sam[fa].len + 1, sam[x].len, f[x]);
}
[TJOI2015] 弦论
\qquad 题面
\qquad 求第 K K K 小子串,建出后缀自动机后跑一跑 d p dp dp 即可,有点细节。
\qquad 核心 C o d e : Code: Code:
void dfs(int x) {
for(int i = head[x]; i; i = edge[i].lst) {
int v = edge[i].to;
dfs(v);
f[x] += f[v];
}
}
void calc(int x) {
vis[x] = 1, val[x] = f[x];
for(int i = 0; i < 26; i ++) {
if(sam[x].son[i]) {
if(!vis[sam[x].son[i]]) calc(sam[x].son[i]);
val[x] += val[sam[x].son[i]];
}
}
}
void solve(int x, int pre) {
if(x != 1 && tim + f[x] >= K) exit(0);
tim += f[x];
for(int i = 0; i < 26; i ++) {
if(sam[x].son[i]) {
if(tim + val[sam[x].son[i]] >= K) cout << (char)(i + 'a'), solve(sam[x].son[i], x);
else tim += val[sam[x].son[i]];
}
}
}
[SDOI2016] 生成魔咒
\qquad 题面
\qquad 动态维护本质不同字串个数板子题。
重复的旋律7
\qquad 建立广义 S A M SAM SAM 后跑拓扑 d p dp dp 即可。
广义 SAM
\qquad 广义 S A M SAM SAM 是将多个串建立在一个后缀自动机上,用于处理多串问题。
\qquad 广义 S A M SAM SAM 有两种伪实现方式:1、在相邻两串之间加特殊字符;2、每次加新串之前让 l s t = 1 lst=1 lst=1。这两种实现方式正确性是毋庸置疑的,不过效率较低。真正的广义 S A M SAM SAM 是先建立所有串的 T r i e Trie Trie 树,再在 T r i e Trie Trie 的基础上建立 S A M SAM SAM。
void Insert_tr(int len, int T) {
int p = 1;
for(int i = 1; i <= len; i ++) {
int x = str[i] - 'a';
if(!tr[p][x]) tr[p][x] = ++ tot;
p = tr[p][x], tag[p].push_back(T);
}
}
void Insert_sam(int p, int x) {
int np = tr[p][x];
sam[np].len = sam[p].len + 1;
while(p && !sam[p].son[x]) sam[p].son[x] = np, p = sam[p].fa;
if(!p) sam[np].fa = 1;
else {
int q = sam[p].son[x];
if(sam[q].len == sam[p].len + 1) sam[np].fa = q;
else {
int nq = ++ tot;
sam[nq] = sam[q], sam[nq].len = sam[p].len + 1;
sam[q].fa = sam[np].fa = nq;
while(p && sam[p].son[x] == q) sam[p].son[x] = nq, p = sam[p].fa;
}
}
}
void Get_GSAM() {
for(int i = 0; i < 26; i ++)
if(tr[1][i]) qq.push({1, i});
while(!qq.empty()) {
PII fir = qq.front(); qq.pop();
int p = fir.first, x = fir.second;
Insert_sam(p, x);
int Now = tr[p][x];
for(int i = 0; i < 26; i ++) {
if(tr[Now][i]) qq.push({Now, i});
}
}
}
[HAOI2016] 找相同字符
\qquad 题面
\qquad 正难则反,先建立广义 S A M SAM SAM 求答案,再分别减去两个串单独建立 S A M SAM SAM 的答案。
串
\qquad 建立广义 S A M SAM SAM 后,在 p a r e n t t r e e parent\;tree parenttree 上自上而下传递合法子串的个数。可以将统计答案挂在 S A M SAM SAM 的节点上。
void Get(int loc, int p) {//标记贡献
while(p && tim[p] != loc) f[p] ++, tim[p] = loc, p = sam[p].fa;
}
void dfs(int x) {
if(x != 1) num[x] += num[sam[x].fa];//自上而下传递个数
if(f[x] >= K) num[x] += sam[x].len - sam[sam[x].fa].len;
if(num[x] && x <= Tot) {//统计答案挂在节点上
for(int i : tag[x]) ans[i] += num[x];
}
for(int i = head[x]; i; i = edge[i].lst) {
int v = edge[i].to;
dfs(v);
}
}
//main中
for(int i = 1; i <= n; i ++) {
int p = 1;
for(int x : s[i]) p = tr[p][x], Get(i, p);//遍历每个串的Trie树,标记贡献
}
[ZJOI2015] 诸神眷顾的幻想乡
\qquad 题面
\qquad 遍历叶子节点作为根节点,建立广义 S A M SAM SAM,然后统计本质不同子串个数即可。
\qquad 核心 C o d e : Code: Code:
void Build(int x, int fa, int lst) {
int np = Insert(lst, col[x]);
for(int v : to[x]) {
if(v == fa) continue;
Build(v, x, np);
}
}
//main中
for(int i = 1; i <= n; i ++) {
if(du[i] == 1) Build(i, 0, 1);
}
[NOI2018] 你的名字
\qquad 题面
\qquad 考虑对 S S S 和每个 T T T 均建立 S A M SAM SAM,我们每次并不需要知道 S S S 的 S A M SAM SAM 上哪些节点对应了 [ l , r ] [l,r] [l,r] 这段区间,只需要知道 [ l , r ] [l,r] [l,r] 的 e n d p o s endpos endpos 集合即可。用可持久化线段树合并维护区间 e n d p o s endpos endpos 集合。细节很多。
\qquad Code
[八省联考 2018] 制胡窜
\qquad 题面
\qquad
绝世好题,讲不了一点。
\qquad Code