后缀自动机
定义
字符串 \(s\) 的 SAM 是一个接受 \(s\) 的所有后缀的最小 DFA(确定性有限(状态)自动机)。也就是:
-
SAM 是一个 DAG。节点为状态,边为转移。
-
图的源点 \(t_0\) 称初始状态。整张图从 \(t_0\) 开始可以遍历到。
-
转移标有若干字母,从一个节点出发的所有转移均不同。
-
存在若干终止状态。从 \(t_0\) 到终止状态形成的转移是 \(s\) 的一个后缀。\(s\) 的每个后缀均能由转移得来。
-
在所有满足上述条件的自动机中,SAM 的节点数最少。
子串性质
SAM 包含 \(s\) 所有字串的信息。从 \(t_0\) 到任一点形成的转移都是 \(s\) 的子串。\(s\) 的每个子串对应 \(t_0\) 开始的一条路径。
一个状态对应一些字符串的集合,这个集合的每个元素对应这些路径。
构建过程
(建议配合 OI-Wiki 食用)
结束位置 endpos
考虑 \(s\) 的非空子串 \(t\),记 \(\operatorname{endpos}(t)\) 为 \(s\) 中 \(t\) 的所有结束位置。
对于字符串 abcbc
,\(\operatorname{endpos}(bc)=3,5\).
两个子串 \(t_1\) 和 \(t_2\) 的 \(\operatorname{endpos}\) 可能相等。所有的非空子串可划分为若干等价类。
-
SAM 的每个状态对应若干个 \(\operatorname{endpos}\) 相同的子串。
-
SAM 的状态数等于所有子串的等价类个数及初始状态。
不考虑 SAM 最小性的证明。
一些结论:
-
\(1.\) 字符串 \(s\) 的两个非空子串 \(u\) 和 \(w\)(不妨设 \(|u|\le |w|\))的 \(\operatorname{endpos}\) 相同,当且仅当 \(u\) 在 \(s\) 中的每次出现都以 \(w\) 后缀的形式存在。
-
\(2.\) 上述的 \(u\) 和 \(w\),若 \(u\) 是 \(w\) 的后缀,则 \(\operatorname{endpos}(w)\subseteq\operatorname{endpos}(u)\),否则 \(\operatorname{endpos}(w)\cap\operatorname{endpos}(u)=\varnothing\).
-
\(3.\) 同一等价类的任意两子串,短者为长者的后缀,这一些子串长度覆盖一段完整的区间。
后缀链接 link
对于非 \(t_0\) 的状态 \(v\),其对应具有相同 \(\operatorname{endpos}\) 的等价类。令 \(w\) 为其中最长者,那么其他的都是 \(w\) 的后缀。
将这些后缀按长度降序排序,那么会有前几个包含于这个等价类,其他后缀(包含 \(\varnothing\))在其他等价类中。令 \(t\) 为后者的最长者,且 \(\operatorname{link}(v)\leftarrow t\).
即 \(\operatorname{link}(v)\) 对应 \(w\) 的最长后缀的另一个 \(\operatorname{endpos}\) 等价类状态。
记 \(t_0\) 对应的只包含它自己的等价类为 \(\operatorname{endpos}(t_0)=\{-1,0,\dots,|S|-1\}\).
- \(4.\) 所有后缀链接构成以 \(t_0\) 为根的树。
\(\operatorname{link}\) 指向的状态对应严格更短的字符串。
- \(5.\) 通过 \(\operatorname{endpos}\) 集合构造的树(子节点集合 \(\subseteq\) 父节点集合)与通过 \(\operatorname{link}\) 构造的树相同。
构建
SAM 可以在线性时间内构建。
从左到右扫描原串,不断在 SAM 上添加新状态。
维护指针 \(last\) 表示当前字符对应的状态。一开始指向初始状态。
-
读入字符 \(c\),创建新状态 \(cur\),令 \(\operatorname{len}(cur)\leftarrow\operatorname{last}+1\),表示以 \(c\) 结尾的最长子串比 \(last\) 表示的最长子串多了一个字符。
-
从 \(last\) 开始沿着后缀链接往上跳,找到状态 \(p\) 满足 \(p\) 有一条字符为 \(c\) 的转移边或 \(p=t_0\)。若 \(p\) 满足前一个条件,记这条边指向的状态为 \(q\),判断 \(\operatorname{len}(p)+1=\operatorname{len}(q)\) 是否成立。
-
相等则 \(q\) 是 \(cur\) 的后缀链接,令 \(\operatorname{link}(cur)\leftarrow q\),在 \(p\) 和 \(cur\) 之间添加字符为 \(c\) 的转移边。
-
不相等则 \(q\) 表示多个不同长度的子串,将 \(q\) 复制出一个新状态 \(tp\),令 \(\operatorname{len}(tp)\leftarrow\operatorname{len}(p)+1\),表示 \(tp\) 仅表示长为 \(\operatorname{len}(p)+1\) 的子串。令 \(p\) 指向 \(q\) 的转移重新指向 \(tp\),令 \(\operatorname{link}(q),\operatorname{link}(cur)\leftarrow tp\).
-
在 \(p\) 和 \(cur\) 之间添加字符为 \(c\) 的转移边。另外地,若 \(p=t_0\),令 \(\operatorname{link}(cur)\leftarrow p\).
-
令 \(last\leftarrow cur\),继续扫串。
假设字符集大小 \(|\Sigma|\) 为常数,则构建复杂度线性,否则渐进复杂度
\(O(n\log|\Sigma|)\).
代码实现
空间是 \(O(n|\Sigma|)\) 的。
建议 1-index.
无宏定义
struct state{
int len,link,siz;
map<int,int>next;
}st[N];
int node,last;
void SAM_init(){
st[1].len=0,st[1].link=0;
node=1,last=1;
}
void SAM_extend(int c){
int cur=++node;
st[cur].len=st[last].len+1,st[cur].siz=1;
int p=last;
while(p&&!st[p].next.count(c)){
st[p].next[c]=cur;
p=st[p].link;
}
if(!p)st[cur].link=1;
else{
int q=st[p].next[c];
if(st[p].len+1==st[q].len)
st[cur].link=q;
else{
int tp=++node;
st[tp].len=st[p].len+1;
st[tp].next=st[q].next;
st[tp].link=st[q].link;
while(p&&st[p].next[c]==q){
st[p].next[c]=tp;
p=st[p].link;
}
st[q].link=st[cur].link=tp;
}
}
last=cur;
}
宏定义
struct state{
int len,link,siz;
int nxt[26];
#define len(x) st[x].len
#define link(x) st[x].link
#define siz(x) st[x].siz
#define nxt(x,c) st[x].nxt[c]
}st[N];
int node,last;
void init(){
st[1].len=0,st[1].link=0;
node=last=1;
}
void extend(int c){
int cur=++node;
len(cur)=len(last)+1,siz(cur)=1;
int p=last;
while(p&&!nxt(p,c))
nxt(p,c)=cur,p=link(p);
if(!p)link(cur)=1;
else{
int q=nxt(p,c);
if(len(p)+1==len(q))link(cur)=q;
else{
int tp=++node;
st[tp]=st[q],siz(tp)=0,len(tp)=len(p)+1;
while(p&&nxt(p,c)==q)
nxt(p,c)=tp,p=link(p);
link(q)=link(cur)=tp;
}
}
last=cur;
}
-
建议全部开 \(2\) 倍空间。
-
SAM 的状态数的上界为 \(2n-1(n\ge 2)\).
-
SAM 的转移数的上界为 \(3n-4(n\ge 3)\).
P3804 【模板】后缀自动机(SAM)
对于 \(s\) 的所有出现次数 \(>1\) 的子串 \(t\),最大化
记 \(\operatorname{cnt}(v)=|\operatorname{endpos}(v)|\),即对应字符集的出现次数。
感性思考就是 \(\displaystyle\operatorname{cnt}(u)=\sum_{\operatorname{link}(v)=u}\operatorname{cnt}(v)\).
长度即当前节点的 \(\operatorname{len}\) 值。
在 DAG 上做可以先将节点按照 \(\operatorname{len}\) 排序。
\(\operatorname{link}\) 构成的树其实就是所说的 \(\operatorname{parent}\) 树。
P4070 [SDOI2016] 生成魔咒
在线维护不同子串总数。
加入一个字符,设当前状态为 \(cur\),则 \(\Delta ans=\operatorname{len}(cur)-\operatorname{len}(\operatorname{link}(cur))\).
LCS - Longest Common Substring
求两个字符串 \(S\) 和 \(T\) 的 LCS(最长公共子串)的长度。
对 \(S\) 构建 SAM。
对于 \(T\) 的每一个前缀,在 \(S\) 中寻找该前缀的最长后缀。
记当前状态为 \(v\),当前长度为 \(l\)。初始状态 \(v=t_0\),\(l=0\).
现在添加字符 \(T_i\):
-
存在 \(v\) 到 \(T_i\) 的转移,直接转移且令 \(l\leftarrow l+1\).
-
不存在转移,缩短匹配部分,令 \(v\leftarrow\operatorname{link}(v)\),\(l\leftarrow\operatorname{len}(v)\).
直至存在到 \(T_i\) 的转移,令 \(l\leftarrow l+1\),或者跳到 \(v=t_0\),此时 \(T_i\) 不在 \(S\) 中出现,令 \(v\leftarrow0,l\leftarrow0\).
答案为所有 \(l\) 的 \(\max\).
时间复杂度 \(O(|T|)\),因为实现中要么 \(l\) 加一,要么 \(v\) 沿着 \(\operatorname{link}\) 跳,每次减小 \(l\) 的值。
P5341 [TJOI2019] 甲苯先生和大中锋的字符串
一个出现了 \(k\) 次的子串对其长度贡献为 \(1\).
求贡献最大的那个。若有相等的,取长度最大的。
像模板题那样求出 parent 树每个节点的字数大小。
对于 \(\operatorname{siz}(i)=k\) 的 \(i\),其对区间 \((\operatorname{len}(\operatorname{link(i)}),\operatorname{len}(i)]\) 有贡献。
差分即可。
对于 \(s\) 的所有子串 \(p\),求 \(\operatorname{occur}(s,p)^2\) 的和。
一个子树对答案的贡献即 \(\operatorname{siz}(v)^2\cdot(\operatorname{len}(v)-\operatorname{len}(\operatorname{link}(v)))\).
P3975 [TJOI2015] 弦论
求字符串中的第 \(k\) 小子串。子串可重可不重。
第 \(k\) 小子串即 SAM 中字典序第 \(k\) 小的路径。
对字符集暴力枚举转移,时间复杂度 \(O(|ans|\cdot|\Sigma|)\),其中 \(ans\) 为最终答案。
子串可重可不重对应了 \(\operatorname{endpos}\) 贡献为它的值本身或者 \(1\)。
P6139 【模板】广义后缀自动机(广义 SAM)
-
求 \(s_1,\dots,s_n\) 的本质不同子串个数。
-
求能接受 \(s_1,\dots,s_n\) 的所有后缀的最小 DFA 的点数。
广义 SAM 是对 Trie 建 SAM 的过程。插入的时候将当前状态设为其父亲在 SAM 上的编号。
Warning
- 一定要用这种方法建 GSAM。。。不知道自己写的为什么会错。
P6640 [BJOI2020] 封印
多次询问 \(s[l:r]\) 和 \(t\) 的最长公共子串。
对于位置 \(i\) 求出对于 \(s\) 的前缀 \(i\),其是 \(t\) 的子串的最长后缀长度 \(len_i\),可以对 \(t\) 建 SAM,让 \(s\) 在 SAM 上匹配。
询问 \([l,r]\) 的答案为
- \(len_i\le i-l+1\),可得 \(l\le i-len_i+1\).
右式单调不降。将分段函数的分界点二分出来。
P3346 [ZJOI2015] 诸神眷顾的幻想乡
问树上本质不同子串个数(不包括长为 \(1\) 的串)。叶子数 \(\le 20\).
直接建 SAM 不能处理 lca 非根的情况。
令每个叶子为根建 GSAM 就好了。
Paper task
求括号串的本质不同非空合法括号序的个数。
和 生成魔咒 不同的地方在于还要处理从 \(\operatorname{link}\) 往前拼接合法括号串的情况。
令 (
为 \(-1\),)
为 \(1\),用线段树维护后缀和数点。
JZPGYZ - Sevenk Love Oimaster
给出若干模板串和查询串,问每个查询串是多少模板串的子串。
对模板串建 GSAM,对于一个查询串,其答案为当前状态的子树内的模板串个数。
在 parent-tree 上线段树合并。做法是在线的。
自己写了个屎玩意调不动了。不太考虑补。
Match & Catch
两个字符串,问最短的各只出现一次的公共子串。
值域 \(5000\).
对 \(S\) 和 \(T\) 建 SAM,枚举 \(S\) 的左端点,不断往右扫匹配。
可以看下代码。
Three strings
给出三个串 \(A,B,C\),对每个 \(L\le \min(len_A,len_B,len_C)\),求出 \(A[a:a+L-1]=B[b:b+L-1]=C[c:c+L-1]\) 的三元组 \((a,b,c)\) 的数量。
直接建 GSAM 没了。