「算法笔记」后缀自动机
写的很烂,别看了 QAQ。2021.12.30 重写了一遍没放这里,真想看的找我 qwq。
一、引入
顾名思义,后缀自动机(Suffix Automaton,简称 SAM)是一个 自动机。这里的自动机指的是确定有限状态自动机(DFA)。
DFA?DFA 的作用就是识别字符串。可以把一个 DFA 看成一个 边上带有字符 的有向图。
图中的节点就是 DFA 中的状态,边就是状态间的转移(DFA 的转移函数)。
DFA 存在一个指定的 起始状态(对应图的起始节点),以及多个 接受状态。
-
一个 DFA 读入字符串 \(S\) 后,会从起始节点开始,第 \(i\) 次沿着字符 \(S_i\) 的转移边走。
-
读入完成后,若 \(S\) 最后位于一个接受状态,则称 DFA 接受 \(S\),否则称 DFA 不接受 \(S\)(转移过程中没有出边,也称 DFA 不接受 \(S\))
其实就是,从起始节点出发,每次沿着与当前字符对应的边走,走完了,并且最后位于可接受的节点上,那就 ok,否则就是不 ok。
二、定义
后缀自动机 是可以且仅可以接受一个母串 \(S\) 的后缀的 DFA。SAM Drawer。
SAM 的结构包含两部分:有向单词无环图(DAWG,它是一个 DAG)以及一棵树(parent 树),它们的节点集合相同。
目标:最小化节点集合大小(SAM 是满足是可以接受 \(S\) 所有后缀的 最小的 DFA)。
1. Endpos 集合
先引入一个概念:子串的结束位置集合 Endpos(或者称其为 Right 集合)。在下文写作 \(\text{end}\)。
记 \(S\) 的一个子串 \(T\) 在 \(S\) 中出现的 结束位置 集合为 \(\text{end}(T)\)。
举个栗子,比如 \(S=\text{banana}\),则 \(\text{end}(\text{ana})=\{4,6\}\)。
对于两个子串 \(t_1,t_2\),若 \(\text{end}(t_1)=\text{end}(t_2)\),则 \(t_1,t_2\) 属于一个 \(\text{end}\) 等价类。
Endpos 集合的性质:对于非空子串 \(t_1,t_2\ (|t_1|\leq|t_2|)\):
-
若 \(\text{end}(t_1)=\text{end}(t_2)\),则 \(t_1\) 在 \(S\) 中每次出现,都是以 \(t_2\) 的后缀形式存在。
-
若 \(t_1\) 为 \(t_2\) 的后缀,则 \(\text{end}(t_2)\subseteq \text{end}(t_1)\);否则 \(\text{end}(t_2)\cap \text{end}(t_1)=\varnothing\)。
-
一个 \(\text{end}\) 等价类中的串为 某个前缀 的 长度连续的后缀。
- \(\text{end}\) 等价类的个数为 \(\mathcal{O}(n)\) 级别。
根据合并等价类的思想,我们将 Endpos 集合完全相同的子串合并到同一个节点。这样一来大大优化了时间和空间复杂度。
\(\text{end}\) 的等价类构成了 SAM 的状态集合。即 SAM 的每一个节点表示的「Endpos 集合相等的子串」的集合。
2. DAWG
DAWG 是 DAG,其中每个 节点 表示一个或多个 \(S\) 的子串。特别地,起始节点对应空串 \(\varnothing\)。
每条转移边上有且仅有一个字符。从起始节点出发,沿着转移边移动,则每条 路径 都会唯一对应 \(S\) 的一个子串。
SAM 维护的是子串,即 SAM 的 DAG 上跑出来的串都是原串的子串。
到达某节点的路径可能不止一条。一个节点对应一些字符串的集合,集合的元素对应这些路径。
不存在可代表同一子串的两个不同状态,因为每个子串唯一地对应一条路径。
规定:除起始节点外,每个节点都是 不同的 \(\text{end}\) 等价类,对应该等价类内子串的集合。
设 \(u\) 的长度最小、最大的子串为 \(\min(u)\) 以及 \(\max(u)\)。
根据 Endpos 集合的性质,每个节点所代表的字符串是 \(S\) 某个前缀 的 长度连续的后缀,则状态 \(u\) 中所有的字符串都是 \(\max(u)\) 的不同后缀,且字符串长度覆盖区间 \([|\min(u)|,|\max(u)|]\)。
3. parent 树
定义:定义 \(u\) 的 parent 指针指向 \(v\),当且仅当 \(|\min(u)|=|\max(v)|+1\),且 \(v\) 代表的子串均为 \(u\) 子串的后缀,记作 \(\text{next}(u)=v\)。也可以将 parent 指针称为后缀链接。
显然,所有节点沿着 parent 指针向前走,都会走到 DAWG 的起始节点(即代表空串的节点。走的过程中串的长度越来越短,总会走到空串)。因此以 parent 指针为边,所有节点组成了一棵树,称为 parent 树。
parent 指针的性质:
-
若 \(|\min(u)|=1\),则 \(\text{next}(u)\) 为起始节点。
-
\(\text{next}(u)\) 所对应的字符串长度严格小于 \(u\) 所表示的字符串。
-
\(\text{end}(u)\subsetneq\text{end}(\text{next}(u))\)。(注意这里是 \(\subsetneq\) 不是 \(\subseteq\),因为若两者相同,那么 \(u\) 和 \(\text{next}(u)\) 应该被合并为一个节点)
-
\(\max(\text{next}(u))\) 为 \(\min(u)\) 的次长后缀(最长为其本身)。
parent 树的性质:
-
在 parent 树中,子节点的 \(\text{end}\) 集合一定是父亲的真子集,即 \(\text{end}(u)\subsetneq\text{end}(\text{next}(u))\)。
-
从节点 \(v_0\) 沿着 parent 指针遍历,总会到达起始节点。设经过的节点为 \(v_1,v_2,\cdots,v_k\)。可以得到一个互不相交的区间 \([|\min(v_i)|,|\max(v_i)|]\),它们的并集形成了连续的区间 \([0,|\max(v_0)|]\),代表 \(S\) 长度为 \(|\max(v_0)|\) 的前缀的所有后缀。
parent 树本质上是 Endpos 集合构成的一棵树,体现了 Endpos 的包含关系。
注:节点 \(u\) 对应着具有相同 Endpos 的等价类,\(\text{end}(u)\) 指的是节点 \(u\) 对应的等价类的 Endpos 集合。
4. 小结
-
\(S\) 的子串可根据结束位置 Endpos 划分为若干个 \(\text{end}\) 等价类。
-
DAWG 中,每个节点表示一个或多个 \(S\) 的子串。除起始节点外,每个节点都是 不同的 \(\text{end}\) 等价类,对应该等价类内子串的集合。
- 每个节点所代表的字符串是 \(S\) 某个前缀 的 长度连续的后缀。
-
对于节点 \(u\),设 \(u\) 的长度最小、最大的子串为 \(\min(u)\) 以及 \(\max(u)\)。
-
对于两个节点 \(u,v\),\(\text{next}(u)=v\),当且仅当 \(|\min(u)|=|\max(v)|+1\),且 \(v\) 代表的子串均为 \(u\) 子串的后缀。
-
以 parent 指针为边,所有节点组成了一棵树(根节点为 DAWG 的起始节点),称为 parent 树。
5. 补充
可能讲的不是很清楚,摘录一下 Dls 博客 里的内容,方便理解 Parent Tree(进行了整理,应该好懂些):
我们知道,SAM 里的每个节点都代表了一堆 Endpos 集合相同的子串。容易发现,对于越短的子串,其 Endpos 集合往往越大。更具体地,若 \(t_1\) 为 \(t_2\) 的后缀,则 \(|\text{end}(t_1)|\geq |\text{end}(t_2)|\)。当且仅当取得等号时,\(t_1,t_2\) 会被压缩到同一个节点中。
而对于 \(t_2\) 的每一个后缀,一定有一个分界点,使得对于长度 \(\geq\) 该分界点的后缀,它和 \(t_2\) 的 Endpos 集合 相同;而长度 \(<\) 该分界点的后缀,因为短,所以有机会可以在 \(S\) 中出现更多次,Endpos 集合会更大,于是就和 \(t_2\) 分开了。因此:每个节点 \(p\) 中存储的一定是一堆长度连续的子串,且短的串是长的串的后缀。
对于 SAM 的每个节点都能找到一个这样的“分界点”。并且每个节点都对应了一个唯一的“分界点”。而如果 \(t_1\) 是 \(t_2\) 的一个后缀且没有和 \(t_2\) 分在一个节点中,那么 \(t_1\) 也可能成为别的子串的后缀(如 \(\text{ab}\) 既可以是 \(\text{cab}\) 的后缀,也可以是 \(\text{zab}\) 的后缀)。这样我们看到:长的串只能“对应”唯一的一个短的串,而短的串可以“对应”多个长的串,如果将“短的串”视为“长的串”的父亲,这就构成了一棵严格的树形结构。我们称为 parent 树。
这时我们发现,一个节点所代表的子串中最短的,就是它在 parent 树上的父亲所代表的的子串中最长长度的 \(+1\)。因此对每个节点都只记录最长的子串长度即可。
三、构建 SAM
SAM 的构建使用 增量法:通过 \(S\) 的 SAM 求出 \(S+c\) 的 SAM(\(c\) 为一个字符)。
加入字符 \(c\) 后,子串只增加了 \(S+c\) 的后缀,已有的子串不受影响。
\(S+c\) 的某些后缀可能在 \(S\) 出现过,在 SAM 中有其对应的节点。
SAM 中一个串只能对应一个节点,需考虑将它们对应到相应节点上。
多看几遍应该就能懂了 QAQ。
1. 初始化与判断
设此前表示 \(S\) 的节点为 \(p\)。
串 \(S+c\) 不可能出现在 \(S\) 中,它一定被对应到新节点上。设新节点为 \(u\),那么 \(|\max(u)|=|S+c|=|\max(p)|+1\)。
考虑如何判断 \(S+c\) 的后缀是否在 \(S\) 出现过。\(S+c\) 的后缀 \(=\) \(S\) 的后缀 \(+\) \(c\),判断 \(S+c\) 的后缀是否在 \(S\) 的后缀出现过,等价于判断 \(S\) 的后缀 有无转移边 \(c\)。
若 \(S\) 的某后缀有转移边 \(c\),那么它一定是新串的后缀,且说明 \(S+c\) 的该后缀在 \(S\) 中出现过。
根据 parent 树的性质,从节点 \(p\) 沿着 parent 指针遍历到达起始节点,等价于按长度递减遍历 \(S\) 的所有后缀。
从节点 \(p\) 沿着 parent 指针遍历,找到第一个有转移边 \(c\) 的节点 \(p'\)。
只需找到 \(p'\) 即可,因为 parent 树上 \(p'\) 的祖先代表的串,均为 \(p'\) 的后缀。它们对应的串的长度小于 \(p'\) 所表示的串,一定也有转移 \(c\)。
int p=lst,x=lst=++tot; //新建一个节点 x。此前表示 S 的节点为 p。 sz[x]=1,len[x]=len[p]+1; //sz(i) 表示节点 i 所代表的 Endpos 集合的大小。len(i) 表示 |max(i)|,即节点 i 长度最大的子串的长度。 //ch[p][c]=q 表示 p 经过转移边 c 后到 q while(p&&!ch[p][c]) ch[p][c]=x,p=fa[p]; //这里的 fa(u)=v 即上文中的 next(u)=v(fa(u) 其实就是 u 在 parent 树上的父亲)。从节点 p 沿着 parent 指针遍历到达起始节点,等价于按长度递减遍历 S 的所有后缀。从节点 p 沿着 parent 指针遍历,找到第一个有转移边 c 的节点 p′。
2. 分类讨论
接下来对 \(S+c\) 的后缀在 \(S\) 中有无出现进行讨论。\(u\) 和 \(p'\) 的定义同上文。
(1)\(S+c\) 的所有后缀在 \(S\) 中 均未出现。
直接将 \(u\) 的 parent 指针指向起始状态。此时 \(u\) 表示 \(S+c\) 的所有后缀 \([1,|S+c|]\)。
if(!p){fa[x]=1;return ;} //S 中不存在子串 为 S+c 的后缀,直接将新节点的 parent 指针指向起始节点(起始节点标号为 1)。
(2)\(S+c\) 的某后缀在 \(S\) 中 出现过。设 \(p'\) 经过转移边 \(c\) 后到达节点 \(q\)(下同)。有 \(|\max(q)|=|\max(p')|+1\)。
则 \(q\) 代表的所有串,以及 parent 树上它的祖先代表的串,均为 \(S+c\) 的后缀。
\(\max(q)\) 为 \(S+c\) 的后缀,应有 \(\text{next}(u)=q\)。
此时 \(u\) 表示 \(S+c\) 的后缀 \([|\max(q)|+1,|S+c|]\)。
//S 中存在子串 为 S+c 的后缀,且 p' 经过转移边 c 后到达节点 q,有 |max(q)|=|max(p')|+1。 int q=ch[p][c],Q; //q 表示 p' 经过转移边 c 后到达的节点,Q 在下文会解释。 if(len[q]==len[p]+1){fa[x]=q;return ;} //q 代表的所有串,以及 parent 树上它的祖先代表的串,均为 S+c 的后缀。应有 next(u)=q,此时 u 表示 S+c 的后缀 [|max(q)|+1,|S+c|]。
(3)\(S+c\) 的某后缀在 \(S\) 中 出现过。有 \(|\max(q)|\neq|\max(p')|+1\)。
首先有 \(|\max(q)|>|\max(p')|+1\)(\(p'\) 经过转移边 \(c\) 可转移到 \(q\),\(|\max(q)|<|\max(p')|+1\) 不成立)。
但 \(q\) 中长度 小于等于 \(|\max(p')|+1\) 的串,及 parent 树上它的祖先代表的串,为 \(S+c\) 的后缀。
考虑将 \(q\) 拆成 \(S+c\) 的后缀部分,和非 \(S+c\) 的部分。
设将 \(q\) 的 \(S+c\) 的后缀部分放入节点 \(q'\) 中,其余的保留在 \(q\) 中。
\(q'\) 应继承 \(q\) 的转移,因为 \(q'\) 中的串与 新的 \(q\) 的串 为后缀关系(新的 \(q\) 指原来的 \(q\) 中非 \(S+c\) 的部分。为后缀关系是因为,上文中说过每个节点所代表的串是某个前缀长度连续的后缀),转移同样字符后也为后缀关系。
显然 \(|\max(q')|=|\max(p')|+1\)。
\(q'\) 代表的子串均为 \(q\) 的后缀。有 \(\text{next}(q')=\text{next}(q)\)。
又因为 \(|\min(q)|=|\max(q')|+1\),则 \(\text{next}(q)=q'\)。
\(q'\) 代表的所有串,及 parent 树上它的祖先代表的串,均为 \(S+c\) 的后缀。应有 \(\text{next}(u)=q'\),此时 \(u\) 代表 \(S+c\) 的后缀 \([|\max(q')|+1,|S+c|]\)。
最后枚举所有 可以转移到 原来的 \(q\) 的比 \(p'\) 还短的 \(S\) 的后缀,将其指向 \(q'\)。(\(p'\) 应转移到 \(q'\),则比 \(p'\) 的串还短的后缀也应转移到 \(q'\))
应转移到 新的 \(q\) 的后缀 的转移不会被修改。
//S 中存在子串 为 S+c 的后缀,但 |max(q)|!=|max(p')|+1。 Q=++tot,memcpy(ch[Q],ch[q],sizeof(ch[q])); //将 q 拆成 S+c 的后缀部分,和非 S+c 的部分。将 q 的 S+c 的后缀部分放入节点 Q 中,其余的保留在 q 中。Q 应继承 q 的转移,因为 Q 中的串与 新的 q 的串 为后缀关系,转移同样字符后也为后缀关系。 fa[Q]=fa[q],len[Q]=len[p]+1,fa[q]=fa[x]=Q; //Q 代表的串均为 q 的后缀。显然有 next(Q)=next(q),next(q)=Q。同时应有 next(u)=Q,此时 u 代表 S+c 的后缀 [|max(Q)|+1,|S+c|]。 while(p&&ch[p][c]==q) ch[p][c]=Q,p=fa[p]; //最后枚举所有 可以转移到 原来的 q 的比 p' 还短的 S 的后缀,将其指向 Q。从 p' 开始沿着 parent 指针遍历,等价于按长度递减遍历比 p' 长度更短的 S 的所有后缀。
3. 代码
我终于会敲 SAM 板子啦!
void insert(int c){ //通过 S 的 SAM 求出 S+c 的 SAM int p=lst,x=lst=++tot; //新建一个节点 x(上文中的 u)。此前表示 S 的节点为 p。 sz[x]=1,len[x]=len[p]+1; //sz(i) 表示节点 i 所代表的 Endpos 集合的大小。len(i) 表示 |max(i)|,即节点 i 长度最大的子串的长度。 //ch[p][c]=q 表示 p 经过转移边 c 后到 q while(p&&!ch[p][c]) ch[p][c]=x,p=fa[p]; //这里的 fa(u)=v 即上文中的 next(u)=v(fa(u) 其实就是 u 在 parent 树上的父亲)。从节点 p 沿着 parent 指针遍历到达起始节点,等价于按长度递减遍历 S 的所有后缀。从节点 p 沿着 parent 指针遍历,找到第一个有转移边 c 的节点 p′。 if(!p){fa[x]=1;return ;} //S 中不存在子串为 S+c 的后缀,直接将新节点的 parent 指针指向起始节点(起始节点标号为 1)。 //S 中存在子串 为 S+c 的后缀,且 p' 经过转移边 c 后到达节点 q,有 |max(q)|=|max(p')|+1。 int q=ch[p][c],Q; //q 表示 p' 经过转移边 c 后到达的节点。 if(len[q]==len[p]+1){fa[x]=q;return ;} //q 代表的所有串,以及 parent 树上它的祖先代表的串,均为 S+c 的后缀。应有 next(u)=q,此时 u 表示 S+c 的后缀 [|max(q)|+1,|S+c|]。 //S 中存在子串 为 S+c 的后缀,但 |max(q)|!=|max(p')|+1。 Q=++tot,memcpy(ch[Q],ch[q],sizeof(ch[q])); //将 q 拆成 S+c 的后缀部分,和非 S+c 的部分。将 q 的 S+c 的后缀部分放入节点 Q 中,其余的保留在 q 中。Q 应继承 q 的转移,因为 Q 中的串与 新的 q 的串 为后缀关系,转移同样字符后也为后缀关系。 fa[Q]=fa[q],len[Q]=len[p]+1,fa[q]=fa[x]=Q; //Q 代表的串均为 q 的后缀。显然有 next(Q)=next(q),next(q)=Q。同时应有 next(u)=Q,此时 u 代表 S+c 的后缀 [|max(Q)|+1,|S+c|]。 while(p&&ch[p][c]==q) ch[p][c]=Q,p=fa[p]; //最后枚举所有 可以转移到 原来的 q 的比 p' 还短的 S 的后缀,将其指向 Q。从 p' 开始沿着 parent 指针遍历,等价于按长度递减遍历比 p' 长度更短的 S 的所有后缀。 }
四、复杂度
可以证明:
-
对于一个长度为 \(n\ (n\geq 2)\) 的字符串 \(S\),它的 SAM 的状态数 \(\leq 2n−1\)。
-
对于一个长度为 \(n\ (n\geq 3)\) 的字符串 \(S\),它的 SAM 的转移数 \(\leq 3n−4\)。
SAM 的 空间复杂度:
-
写成
int ch[N<<1][M]
(其中 \(N\) 为状态数,\(M\) 为字符集大小):空间 \(\mathcal{O}(n|\sum|)\),查询时间 \(\mathcal{O}(1)\)。 -
字符集较大时,可写成
map<int,int>ch[N<<1]
,空间 \(\mathcal{O}(n)\),查询时间 \(\mathcal{O}(\log|\sum|)\)。
构建 SAM 的 时间复杂度:均摊 \(\mathcal{O}(n)\)。
五、模板
Luogu P3804 【模板】后缀自动机 (SAM)。
题目大意:给定一个只包含小写字母的字符串 \(S\),求 \(S\) 的所有出现次数不为 \(1\) 的子串的出现次数乘上该子串长度的最大值。\(|S|\leq 10^6\)。
Solution:建出 SAM 后在 parent 树上 DP 即可。
#include<bits/stdc++.h> using namespace std; const int N=2e6+5,M=30; int n,lst=1,tot=1,cnt,hd[N],to[N],nxt[N],ch[N][M],len[N],fa[N],sz[N]; //注意 1 为起始节点编号,所以这里 lst 和 tot 初值为 1 long long ans; char s[N]; void add(int x,int y){ to[++cnt]=y,nxt[cnt]=hd[x],hd[x]=cnt; } void insert(int c){ int p=lst,x=lst=++tot; sz[x]=1,len[x]=len[p]+1; //sz(i) 表示节点 i 所代表的 Endpos 集合的大小,即所对应的字符串集出现的次数 while(p&&!ch[p][c]) ch[p][c]=x,p=fa[p]; if(!p){fa[x]=1;return ;} int q=ch[p][c],Q; if(len[q]==len[p]+1){fa[x]=q;return ;} Q=++tot,memcpy(ch[Q],ch[q],sizeof(ch[q])); fa[Q]=fa[q],len[Q]=len[p]+1,fa[q]=fa[x]=Q; while(p&&ch[p][c]==q) ch[p][c]=Q,p=fa[p]; } void dfs(int x,int fa){ for(int i=hd[x];i;i=nxt[i]){ int y=to[i]; if(y==fa) continue; dfs(y,x),sz[x]+=sz[y]; } if(sz[x]!=1) ans=max(ans,1ll*sz[x]*len[x]); } signed main(){ scanf("%s",s+1),n=strlen(s+1); for(int i=1;i<=n;i++) insert(s[i]-'a'); for(int i=2;i<=tot;i++) add(fa[i],i); //建出 parent 树 dfs(1,0),printf("%lld\n",ans); return 0; }
为了减小常数,有时我们可以用“基数排序”代替树形 DP。我们知道,长度短的子串是长度长的子串的父亲,也即 \(len\) 值小的节点是 \(len\) 值大的节点的父亲。我们按 \(len\) 值从小到大对节点排个序,就得到了整棵树从树根到树叶的拓扑序。把这个拓扑序倒过来,for
循环一遍,就相当于树形 DP 啦。
#include<bits/stdc++.h> using namespace std; const int N=2e6+5,M=30; int n,lst=1,tot=1,ch[N][M],len[N],fa[N],sz[N],cnt[N],id[N]; //数组开两倍! long long ans; char s[N]; vector<int>v[N]; void insert(int c){ int p=lst,x=lst=++tot; sz[x]=1,len[x]=len[p]+1; while(p&&!ch[p][c]) ch[p][c]=x,p=fa[p]; if(!p){fa[x]=1;return ;} int q=ch[p][c],Q; if(len[q]==len[p]+1){fa[x]=q;return ;} Q=++tot,memcpy(ch[Q],ch[q],sizeof(ch[q])); fa[Q]=fa[q],len[Q]=len[p]+1,fa[q]=fa[x]=Q; while(p&&ch[p][c]==q) ch[p][c]=Q,p=fa[p]; } signed main(){ scanf("%s",s+1),n=strlen(s+1); for(int i=1;i<=n;i++) insert(s[i]-'a'); for(int i=1;i<=tot;i++) cnt[len[i]]++; for(int i=1;i<=tot;i++) cnt[i]+=cnt[i-1]; for(int i=1;i<=tot;i++) id[cnt[len[i]]--]=i; for(int i=tot,x;i>=1;i--) x=id[i],sz[fa[x]]+=sz[x]; for(int i=1;i<=tot;i++) if(sz[i]>1) ans=max(ans,1ll*sz[i]*len[i]); printf("%lld\n",ans); return 0; }
六、性质
SAM 的性质:
同时后缀自动机还有一些有用的性质:
-
反串的 SAM 的 parent 树就是后缀树。
- 正串的 SAM 维护的是原字符串所有前缀的后缀(可以考虑 SAM 增量法的构造过程)。那么同理,反串的 SAM,维护的就是所有后缀的前缀,可以得到所有后缀构成的 Trie(压缩后),即后缀树。
- 感性理解:parent 树中,父亲是孩子的最长后缀(Endpos 不同),而把串反过来后,parent 树就满足,父亲是孩子的最长前缀(Beginpos 不同)。观察压缩后缀树的定义,Beginpos 相同的两个串才能被压缩,所以 SAM 和后缀树是有异曲同工之妙的。
-
两个串的最长公共后缀的长度,等于这两个串所代表的点在 parent 上 LCA 的 \(len\) 值。这是因为,一个串对应节点的祖先节点都是它的后缀,且深度越大长度越长。
七、简单应用
可参考 后缀自动机 (SAM) - OI Wiki 和 这个。其实这里算是搬运 qwq。
一些套路:SAM 的实质为 DAG,可以尝试是否能利用 DP 求解。
有些题目是基于 SAM 的性质的。比如“子串相关”的问题,不妨想想“从起始节点出发,每条路径唯一对应 \(S\) 的一个子串”,或许会有所帮助。
1. 子串相关
求不同子串个数:Problem(\(2\) 种方法)
-
不同子串个数等于从起始节点开始的不同路径条数。令 \(d_i\) 表示从节点 \(i\) 开始的路径数量,\(E\) 表示 DAWG 的边集,则 \(d_i=1+\sum_{(i,j)\in E} d_j\)。
-
parent 树中,每个节点对应的子串数量是 \(len(i)-len(\text{next}(i))\),对所有节点求和即可。
所有不同子串的总长度:
-
考虑不同子串数量 \(d_i\) 和总长度 \(ans_i\),同样 DP 求解。
-
每个节点对应的后缀长度为 \(\frac{len(i)\times (len(i)+1)}{2}\),减去其 \(\text{next}\) 节点的对应值就是改节点的贡献,对所有节点求和即可。
2. 字典序相关
字典序第 k 小子串:Problem。
字典序第 \(k\) 小的子串对应 SAM 中字典序第 \(k\) 小的路径。计算出每个节点的路径数后,可以从 SAM 的根找到第 \(k\) 小的路径。
字典序最小的循环移位:
串 \(S+S\) 包含 \(S\) 的所有循环移位作为子串。
问题转化为在 \(S+S\) 对应的 SAM 上找最小的长度为 \(|S|\) 的路径。从起始节点出发,贪心地访问最小的字符即可。
3. 最长公共子串
两个串的最长公共子串:Problem。
先对 \(S_1\) 构造 SAM,对于 \(S_2\) 的每个位置,找到这个位置结束的 \(S_1\) 和 \(S_2\) 的最长公共子串长度。
设 \(p\) 为当前节点,\(l\) 为当前长度。从起始节点开始匹配,对于每一个字符 \(S_2[i]\):
-
若 \(p\) 存在转移边 \(S_2[i]\),那么就转移并使 \(l\) 加 \(1\)。
-
否则 \(p=\text{next}(p)\),直到找到有转移边 \(S_2[i]\) 的节点,\(l=len(p)\)(经过 \(\text{next}(p)\) 后到达的节点对应的最长字符串是一个子串)。
-
若仍没有找到有转移边 \(S_2[i]\) 的节点,从起始节点开始重新匹配。
最大的 \(l\) 即为答案。
n=strlen(s1+1),m=strlen(s2+1),p=1; //起始节点编号为 1 for(int i=1;i<=n;i++) insert(s1[i]-'a'); for(int i=1;i<=m;i++){ int c=s2[i]-'a'; while(p&&!ch[p][c]) p=fa[p],l=len[p]; if(ch[p][c]) p=ch[p][c],l++; else p=1,l=0; //从起始节点重新匹配 ans=max(ans,l); }
多个串的最长公共子串:Problem
对其中一个串构造 SAM,其他的串跟之前仅有两个串的方法一样跑一遍。对于每个串,记 \(mx(p)\) 表示以节点 \(p\) 为结尾的最长匹配长度。
由于是多个串,记 \(mn(p)=\min\{mx(p)\}\),\(mn(p)\) 才是所有串以 \(p\) 为结尾的最长匹配长度。
答案即为 \(\max\{mn(p)\}\)。
注意一个节点能被匹配,它在 parent 树上的所有祖先都能被匹配。所以对于每一个节点 \(u\),\(mx(u)\) 还要与 \(\max\limits_{v\in son(u)}\{\min(mx(v),len(u))\}\) 取最大值。
每一个串操作过后记得清空 \(mx\)。
#include<bits/stdc++.h> #define int long long using namespace std; const int N=1e6+5,M=30; int t,k,n,lst=1,tot=1,cnt,hd[N],to[N<<1],nxt[N<<1],ch[N][M],len[N],fa[N],mx[N],mn[N],p,l,ans; char s[N]; void add(int x,int y){ to[++cnt]=y,nxt[cnt]=hd[x],hd[x]=cnt; } void insert(int c){ int p=lst,x=lst=++tot; len[x]=len[p]+1; while(p&&!ch[p][c]) ch[p][c]=x,p=fa[p]; if(!p){fa[x]=1;return ;} int q=ch[p][c],Q; if(len[q]==len[p]+1){fa[x]=q;return ;} Q=++tot,memcpy(ch[Q],ch[q],sizeof(ch[q])); fa[Q]=fa[q],len[Q]=len[p]+1,fa[q]=fa[x]=Q; while(p&&ch[p][c]==q) ch[p][c]=Q,p=fa[p]; } void dfs(int x,int fa){ for(int i=hd[x];i;i=nxt[i]){ int y=to[i]; if(y==fa) continue; dfs(y,x),mx[x]=max(mx[x],min(mx[y],len[x])); } } signed main(){ scanf("%lld",&t); while(t--){ scanf("%lld%s",&k,s+1),n=strlen(s+1),k--; for(int i=1;i<=tot;i++){ fa[i]=len[i]=hd[i]=0; for(int j=0;j<26;j++) ch[i][j]=0; } ans=cnt=0,tot=1,lst=1,memset(mn,0x3f,sizeof(mn)); for(int i=1;i<=n;i++) insert(s[i]-'a'); for(int i=2;i<=tot;i++) add(fa[i],i); for(int i=1;i<=k;i++){ scanf("%s",s+1),n=strlen(s+1),p=1,l=0; for(int i=1;i<=n;i++){ int c=s[i]-'a'; while(p&&!ch[p][c]) p=fa[p],l=len[p]; if(ch[p][c]) p=ch[p][c],l++; else p=1,l=0; mx[p]=max(mx[p],l); } dfs(1,0); for(int i=1;i<=tot;i++) mn[i]=min(mn[i],mx[i]),mx[i]=0; } for(int i=1;i<=tot;i++) ans=max(ans,mn[i]); printf("%lld\n",ans); } return 0; }
八、广义 SAM
广义 SAM:SAM 的多串版本。即对多个串建立 SAM。可参考 这里。
广义 SAM 是一种用于维护 Trie 的子串信息的 SAM 的简单变体。
1. 离线做法
离线做法,即将所有串离线插入到 Trie 树中,依据 Trie 树构造广义 SAM。
具体操作:
-
将所有字符串插入到 Trie 树中。
-
对 Trie 进行 BFS 遍历,记录下顺序以及每个节点的父亲。
-
将得到的 BFS 序列按照顺序,把 Trie 树上的每个节点插入到 SAM 中。\(last\) 为它在 Trie 树上的父亲对应的 SAM 上的节点(其中 \(last\) 表示插入字符之前的节点)。也就是每次找到插入节点的父亲作为 \(last\) 往后接即可。
用 BFS 而不是 DFS 是因为 DFS 可能会被卡。
\(insert\) 部分和普通 SAM 一样。加上返回值方便记录 \(last\)。
//Luogu P6139 #include<bits/stdc++.h> using namespace std; const int N=3e6+5,M=27; int n,ch[N][M],pos[N],fa[N],len[N],tot=1; long long ans; char s[N]; queue<int>q; struct Trie{ int ch[N][M],fa[N],c[N],tot; //分别为 Trie 上的转移数组、父节点、节点对应的字符、节点总数 }T; void insert_(char* s){ int len=strlen(s+1),p=1; for(int i=1;i<=len;i++){ int k=s[i]-'a'; if(!T.ch[p][k]) T.ch[p][k]=++T.tot,T.fa[T.tot]=p,T.c[T.tot]=k; p=T.ch[p][k]; } } int insert(int c,int lst){ //将 c 接到 lst 后面。返回值为 c 插入到 SAM 中的节点编号 int p=lst,x=++tot; len[x]=len[p]+1; while(p&&!ch[p][c]) ch[p][c]=x,p=fa[p]; if(!p){fa[x]=1;return x;} int q=ch[p][c],Q; if(len[q]==len[p]+1){fa[x]=q;return x;} Q=++tot,memcpy(ch[Q],ch[q],sizeof(ch[q])); fa[Q]=fa[q],len[Q]=len[p]+1,fa[q]=fa[x]=Q; while(p&&ch[p][c]==q) ch[p][c]=Q,p=fa[p]; return x; } signed main(){ scanf("%d",&n),T.tot=1; //根初始化为 1 for(int i=1;i<=n;i++) scanf("%s",s+1),insert_(s); for(int i=0;i<26;i++) if(T.ch[1][i]) q.push(T.ch[1][i]); //插入第一层字符 pos[1]=1; //Tire 树上的编号为 1 的节点(根节点)在 SAM 上的位置为 1(根节点) while(q.size()){ int x=q.front();q.pop(); pos[x]=insert(T.c[x],pos[T.fa[x]]); //pos[x]: Trie 上节点 x 的前缀字符串(路径 根到 x 所表示的字符串)在 SAM 中的对应节点编号 for(int i=0;i<26;i++) if(T.ch[x][i]) q.push(T.ch[x][i]); } for(int i=2;i<=tot;i++) ans+=len[i]-len[fa[i]]; printf("%lld\n",ans); return 0; }
2. 在线做法
在线做法,即不建立 Trie,直接把给出的串插入到广义 SAM 中。
这里 SAM 的 \(insert\) 部分和普通 SAM 存在差别。
//Luogu P6139 #include<bits/stdc++.h> #define int long long using namespace std; const int N=3e6+5,M=27; int n,m,ch[N][M],pos[N],fa[N],len[N],lst,tot=1,ans; char s[N];
int insert(int c,int lst){ //返回值为 c 插入到 SAM 中的节点编号 int p=lst,x=0; if(!ch[p][c]){ //如果这个节点已存在就不需要新建了 x=++tot,len[x]=len[p]+1; while(p&&!ch[p][c]) ch[p][c]=x,p=fa[p]; } if(!p){fa[x]=1;return x;} //1 int q=ch[p][c],Q=0; if(len[q]==len[p]+1){fa[x]=q;return x?x:q;} //2 Q=++tot,memcpy(ch[Q],ch[q],sizeof(ch[q])); fa[Q]=fa[q],len[Q]=len[p]+1,fa[q]=fa[x]=Q; while(p&&ch[p][c]==q) ch[p][c]=Q,p=fa[p]; return x?x:Q; //3 } signed main(){ scanf("%lld",&n); for(int i=1;i<=n;i++){ scanf("%s",s+1),m=strlen(s+1),lst=1; for(int j=1;j<=m;j++) lst=insert(s[j]-'a',lst); } for(int i=2;i<=tot;i++) ans+=len[i]-len[fa[i]]; printf("%lld\n",ans); return 0; }
可以证明最坏复杂度为线性。
九、参考资料
- 「笔记」后缀三姐妹(大棒子)