对《史上最通俗的后缀自动机详解》一文的批注
Wyh_dailyAC · · 算法·理论
前言
笔者在学习后缀自动机时找到了《史上最通俗的后缀自动机详解》一文。上述一文的文内有一些写的不清楚的地方,特进行批注。
本文即为对上述一文的摘选、批注文稿。
性质部分
后缀自动机的边数为 O(n)
对于模拟证明方法一部分
此时剩下 ab,到达点 3,我们还是没办法跑它跑回源点。但是,我们不一定要跑它跑回源点,只要能跑回源点就行了。于是,我们往回跑 1 到 3 的边(代表 b)。那么现在,我们就等于跑了
b+c+d=bcd 的后缀了,它是一个之后我们要跑的后缀,我们把它划掉。然后接着往回跑 abcd,连上 1 到 2 的边……
此处较绕,其实际意义为“可以将目前跑出来的后缀(即 bcd)跑回源点”。
梳理后缀自动机性质一部分:
点之间有父子关系,到达点
i 的所有字符串的长度都必然大于到达fa(i) 的所有字符串的长度,且到达fa(i) 的任意一字符串必为到达i 的任意一字符串的后缀。
这一部分的父子关系是在 Parent Tree 上的,所以
性质部分的批注结束。
构造部分
后缀自动机怎么构造
在讨论在线加入字符
注意一下,加入
c 后,endpos 可能发生变化的字符串只有新串的后缀(或者说,旧串的后缀加c )(它们无论在旧串出现与否,在新串中出现的次数一定会比在旧串中多1 )。
这一部分其实挺清晰,但是还是要解释一下。
考虑旧串的子串,如果其
归根结底,还是新串的后缀串的
在讨论跳
for(;p&&!dian[p].ch[c];p=dian[p].fa)dian[p].ch[c]=np;我们进行一个循环操作。这个循环操作进行下去的条件是:
!dian[p].ch[c](p只是防止其跳出根节点),即p 没有一条c 的出边。而p 每一次循环后会跳到dian[p].fa ,即fa(p) 。首先我们得知道往上跳到底是干什么。我们知道存在父子关系节点包含的子串有后缀关系。那么,这个
p 跳fa(p) 操作的意思即:一个是longest(p) (还记得longest 是什么意思吗?定义在结论 5 的证明下面)的后缀,且与longest(p) 所属类不同的最长串所属的类(这里的longest 改成shortest 也无妨)。因为由一个节点往外连一条边就等于允许到达此节点的所有字符串往尾部添加一个新的字符。并且,由于
len(fa(i))+1=minlen(i) ,因此对于i 和fa(i) ,它们包含的子串的长度从大到小排序时也是连续的。所以我们把每一个节点想象成所有到达它的字符串的集合。那么,这个跳fa(i) 的操作可以理解为压缩地遍历一个串的所有后缀。在这里,p=fa(p) 即从长到短遍历旧串的所有后缀。
这部分的“父子关系”依旧是建立在 Parent Tree 上的父子关系,因为父子节点是“后缀
加粗部分非常关键,这里说的也挺明白的——跳
这部分之后的一段用了不少“显然”“由上面思想”一类的词语,还是不太清楚,这里根据引用内容直接引出下一段。
引用的代码部分其实就是对于旧串到新串加
简要地概括目的,就是“为所有不包含字符
说人话的话,就是“如果旧串某一个后缀串加
原文后一段的大意就是如此。
但原文没提“为何有
其实本段所对应的代码根本不处理已有
SAM 要保证最小状态数原则,因此对于已有
原文中下面一处:
else { int q=dian[p].ch[c]; if(dian[q].len==dian[p].len+1)dian[np].fa=q;
这里声明一下停止原因,其实就是原文这句(下用加粗的方式标明重点内容):
注意循环的终止条件。如果长度再短,这些旧串后缀加
c 形成的新字符串就已经在旧串里出现过了,显然与新串最长前缀的endpos 不同,所以不需要继续连边。
这其实就是跳
下面这段文字比较重要:
……,我们将做一个非常有趣的判断:
len(q) 是否等于len(p)+1 。这个判断是什么意思呢?因为longest(p)+c 是新串后缀,又因为len(q)=len(p)+1 ,所以longest(q)=longest(p)+c ,所以longest(q) 是新串后缀。而q 类中的所有串都是longest(q) 的后缀,所以到达q 的所有串都是新串后缀,它们的endpos 都增加了n ,因此到达q 的所有串的endpos 一致,符合后缀自动机的定义。由于q 是我们找到的第一个与np 不同的且有后缀关系的节点,我们把fa(np) 设为q 。
这里重新解释一下本段。
跳
原文中,
对于“……,它们的
由上,我们自然就得到了文中“到达
然后就是“符合后缀自动机的定义”很句话,这显然:一,
同时,我们上面构造是符合最小性要求的,所以正确。
对于“由于
稀里糊涂扯了上面一大坨,其实都在说一个结论——长度大于
扯出来这么一坨结论,就可以从 Parent Tree 的角度去理解“把
请注意:对于上部分的讨论全在
接下来都是较困难的内容了。
对于下面这段:
那么,
len(q)>len(p)+1 代表了什么呢?它代表了还有至少一个比longest(p)+c 更长的串属于q 。而这个更长的串必定不是新串的后缀,因为如果是,那么去掉其最后一个字符得到的字符串必然是旧串的后缀,且其长度大于longest(p) ,因此应该先被跳到。然而事实并不是这样。所以,现在出现了一个问题:属于q 的长度不大于len(p)+1 的串是新串的后缀(Case 2说明过),但大于len(p)+1 的串却不是。此时,到达q 这个节点的字符串并不全属于一个类(准确来说,属于两个类,一类的endpos 比另一类的endpos 多出了一个n ),出现了问题(q 的endpos 无法得到定义)。而现在,我们要想办法将其中一个类的子串移到另一个节点上,只保留其中一类的字符串,让q 的endpos 可以得到定义。
对于本段的大意进行一下重新阐发。
本段大意即为:由于
对于两个类的分化其实说的不太清楚,其实就是后缀串与新串非后缀子串的区别。
接下来我要重新讲一下原文中把
首先让我们再次明确一些量:旧串 / 新串最长前缀所在类
其实,SAM 的每条转移边 u --c--> v 代表一个确定性承诺:
对于
u 中的每一个字符串x ,x+c 的所有出现位置,恰好等于v 的endpos 集合向上追溯(沿v 的fa 链)能覆盖到的范围。
上面这块其实是最应该讲的,就是 SAM 的转移边的目的 / 宗旨之一,讲了这个我们才能更好的讲拆分过程。
对于我们所谓
此时我们需要把满足
显然,分裂集合其实只是一种内部的分类讨论,所以
那么还需要讨论一下
关于向上跳的部分其实原文挺清楚的了,不过注意要从
还有就是 dian[p].ch[c]==q 这个判断本身就是长度筛选器,循环在
重新概述一下上面的 Case。
- Case 1:从旧串最长前缀的
endpos 类p 一直向上跳,对每个跳到的点都连指向np 的边,直到不满足无c 出边的条件。 - Case 2:跳到第一个有
c 出边的endpos 类p' 后,记该节点通过c 边后到达类q ,如果len(q)=len(p')+1 ,则将新串最长前缀所在endpos 类的父节点fa(np) 设为q 。 - Case 3:在 Case 2 的基础上,如果
len(q)>len(p')+1 ,那么将q 类中的新串后缀串分离出单独的nq ,从而实现管辖的分离,使 SAM 维持性质。具体地,还要令fa(nq)=fa'(q),fa(q)=nq,fa(np)=nq (其中fa' 表示过往值)。
对于具体的代码实现,我们的结构体中至少要实现如下内容。
注意,在一个合法的 SAM 上,对于任意的
到此,“后缀自动机构造”一部分的批注内容就完结了。
应用
求 endpos 大小
这里讲一讲最重要的内容:求出每个节点
原文在这里讲的很乱,所以这里重讲一下。
初始化:每个状态
对于每个新建点
那对于 Case 3 克隆的节点
于是有如下操作:
- Case 1 新建的叶子节点
np :f[np] = 1 (因为np 代表了一个新前缀,这个前缀在当前位置出现了一次)。 - Case 3 克隆的节点
nq :f[nq] = 0 (克隆节点不代表新前缀)。
累加传递:按
为什么这样是对的?因为一个子串的每次出现,必然对应某个前缀的结尾。每个前缀在插入时会给一个状态
如下为“P3804 【模板】后缀自动机(SAM)”参考代码:
#include <bits/stdc++.h>
using namespace std;
const int M = 26, N = 1e6 + 10;
int tot = 1, lst = 1;
// int edp[N << 1];
struct SAM {
int ch[M];
int len, fa;
int edp;
} sam[N << 1];
vector<int> E[N << 1];
void add(int c) {
int p = lst, np = lst = ++tot;
sam[np].len = sam[p].len + 1;
sam[np].edp = 1;
// Case 1 ---
for ( ; p && !sam[p].ch[c]; p = sam[p].fa) sam[p].ch[c] = np;
if (!p) { sam[np].fa = 1; return; }
// Case 1 ---
// ---
// Case 2 | 3 ---
int q = sam[p].ch[c];
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[nq].edp = 0;
sam[nq].fa = sam[q].fa;
sam[q].fa = nq;
sam[np].fa = nq;
for ( ; p && sam[p].ch[c] == q; p = sam[p].fa) sam[p].ch[c] = nq;
}
// Case 2 | 3 ---
}
char tch;
long long ans;
void dfs(int u) {
for (auto &v : E[u]) {
dfs(v);
sam[u].edp += sam[v].edp;
}
if (sam[u].edp != 1) {
ans = max(ans, 1ll * sam[u].edp * sam[u].len);
}
}
string s;
signed main() {
#ifndef ONLINE_JUDGE
#define FILEIO "P3804_3"
freopen(FILEIO ".in", "r", stdin);
freopen(FILEIO ".out", "w", stdout);
#endif
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
// while (cin >> tch) {
// add(tch - 'a');
// }
cin >> s;
for (auto &c : s) add(c - 'a');
for (int i = 2; i <= tot; ++i) {
E[sam[i].fa].emplace_back(i);
}
dfs(1);
cout << ans << "\n";
}