SAM 学习笔记 & 复杂度证明 & 例题
更多柚子好的阅读体验。
复杂度证明仙人最新力作\qiang,有伪证麻烦评论反馈/kel
复杂度相关证明都在第 4 章。
1 定义
字符串
- 是一个 DAG,点被称为状态,边被称为转移,转移上有字母。
- 存在一个源点
t_0 ,从它出发将转移连起来可以走出所有S 的后缀,后缀的终点被称为终止状态。 - 状态和转移数都是
O(n) 。
例如 abbb 的 SAM 如下(绿色表示终止状态):
注意到如果我们不走到终止状态,本质上是走了一条后缀的前缀也就是一个子串,也就是说从 SAM 的源点可以走出
如果你对
S 的所有后缀建立 AC 自动机,实际上可以做到相似的效果, 但是它会有O(n^2) 个节点。对于上图的例子,在 AC 自动机中我们会让
bbb和abbb导向不同的节点,而 SAM 却巧妙地将它们的状态合并了。本质上,SAM 实际就是将状态充分合并的 AC 自动机。
2 状态信息
2.1 结束位置集合 endpos
2.1.1 定义与 endpos 等价类
结束位置集合
对于字符串 bangbangt,有
仔细观察还会发现
实际上,SAM 中的每个节点就对应着一个等价类,下文我们将基于此进行构建。
2.1.2 引理与证明
这些引理能理解就不用看证明了,证明都很简单而且没啥用😶。
-
引理:若子串
u 和v 处于同一等价类(|u|\ge|v| ),那么v 每次出现都是u 的后缀。证明:这真显然吧,看不懂的自己摸几个串🤫。
-
引理:对于子串
u 和v (|u|\ge|v| ),有:\begin{cases} \text{endpos}(u) \subseteq \text{endpos}(v), & \text{if } v \text{ is a suffix of } u\\ \text{endpos}(u) \cap \text{endpos}(v) = \varnothing, & \text{otherwise.} \end{cases} 证明:如果
u 和v 的\text{endpos} 有交,那么考虑结尾相同的位置,显然v 一定是u 的后缀,而当v 是u 的后缀时,所有u 出现的位置v 一定出现,得证。 -
引理:对于同一等价类的任意两子串,较短者为较长者的后缀,且该等价类中的子串长度是连续且不重复的。
证明:由引理 1 可知等价类中子串长度都不重复且较短者为较长者的后缀,接着考虑等价类中最长和最短的两个串,对于长度在它们之间的最长串的后缀,显然也会出现在等价类中(比它更短的最短串都只能以最长串的后缀形式出现)。
2.2 后缀链接 link
2.2.1 定义
定义一个状态的
特别地,下文规定源点
t_0 等价类中只有空串,\text{endpos}(t_0)=\{-1,0\cdots |S|-1\} 。
2.2.2 引理与证明
和 2.1.2 一样,引理能理解就不用看证明了🤐。
-
引理:所有后缀链接构成一颗根节点为
t_0 的树。证明:每次跳后缀链接必然会到达对应字符串更短的节点,所以不会有环而且所有节点最后都能跳到空串
t_0 上。 -
引理:以
\text{endpos} 集合为节点,集合的包含关系为边所构造的树与\text{link} 树相同。证明:由于实际上
\text{link} 指向的是最长串的后缀,由 2.1.2 的引理 2 有:\text{endpos}(u) \subsetneq \text{endpos}(\text{link}(u))
2.3 小结
以字符串
- SAM 上存在一条路径到原串本身,经过的状态对应原串的所有前缀。
- 后缀链接本质上是指向了子串的后缀,后缀树本质上是将等价类合并后的若干后缀链。
- 从源点出发的路径本质上是某一后缀的前缀,也就是一个子串。
- 到达同一个状态的转移标号必然相同。
也就是说,对于 SAM,我们有两种理解方式:
- SAM 本身路径上存储了原串所有后缀的前缀信息。
- 在后缀链接树上的所有叶子节点都是原串的前缀,所以所有状态都是某个前缀的后缀。
在下文对于一个状态
特别地,规定
\text{len}(t_0)=0 ,\text{link}(t_0)=-1 。
3 构建
3.1 构建过程
SAM 与大多数自动机一样同样采用增量构建方法,下文介绍当
贴张 OI-wiki 的图,其中
-
令当前表示整个字符串的状态为
last ,初始last=0 。 -
创建一个新的状态
cur 存储加入字符c 后真个字符串的状态,\text{len}(cur)\leftarrow \text{len}(last)+1 。 -
从
last 开始,若当前状态没有c 的转移,就添加一个到cur 的c 的转移并将当前状态沿后缀链接移动,否则就停下来并将这个状态标记为p 。解释:
从原串本身开始跳后缀链接会跳到原串的所有后缀所在的
\text{endpos} 集合,显然当我们新增字符时,之前的所有后缀都要增加c 的转移。而在
p 后的所有状态所表示的字符串都是\text{str}(p) 的后缀,p 有c 的转移那么后缀一定也有转移,所以我们停下新增转移的步骤。 -
情况一:若跳到了
-1 都没有找到p ,那么我们将\text{link}(cur)\leftarrow 0 后退出。解释:
考虑后缀链接的定义:
一个状态的
\text{link} 指向状态所表示等价类中,最长串的不在等价类中的最长后缀所在的状态。若之前真个字符串的所有后缀都没有向
c 的转移,说明\text{str}(cur) (即新字符串)的所有后缀都没有在之前出现过,那么它们自然也和新字符串的\text{endpos} 等价(都只在最后一位结束过)。所以
\text{link} 应该指向空串,也就是源点0 。 -
我们将
p 转移到的状态标记为q 。补充:
若存在
p ,显然此时的不在等价类中的最长后缀是\text{str}(p)+c ,因为p 是我们第一个找到的有c 转移的状态,自然\text{len} 也是最长的。 -
情况二:如果
\text{len}(p)+1=\text{len}(q) ,那么\text{link}(cur)\leftarrow q 后退出。解释:
这时说明
q 没有被别的节点 NTR,\text{str}(q)=\text{str}(p)+c ,所以我们可以放心的让后缀链接指向q 。 -
情况三:如果
\text{len}(p)+1\ne\text{len}(q) ,那么我们需要复制状态q 到q' ,然后让\text{len}(q')\leftarrow\text{len}(p)+1 、\text{link}(cur)\leftarrow q' 、\text{link}(q)\leftarrow q' ,并且对于从P(p) 上的点,如果它们有到q 的c 的转移,那么都要改到q' 上。解释:
太可恶了居然有NTR😡,我们只能将
q 分裂出一个q' 专门存储\text{str}(p)+c 所在的等价类。-
对于
\text{len} :由于\text{str}(q')=\text{str}(p)+c ,所以显然有\text{len}(q')\leftarrow\text{len}(p)+1 。 -
对于转出的转移:由于存在
c 的转移,所以\text{str}(q') 一定是\text{str}(q) 的后缀,所以q 的转移可以直接继承。 -
对于转入的转移:因为
p 的后缀加c 的字符串全部都被分裂到q' 里了,所以对于P(p) 上的点,如果它们有到q 的c 的转移,那么都要改到q' 上。 -
对于
\text{link} :考虑新字符串的\text{endpos} 集合。对于
P(q) 上的子串,它们都是\text{str}(q) 的后缀,当然也是\text{str}(q') 的后缀亦或者说\text{str}(cur) 的后缀,那么它们的\text{endpos} 集合都会新增最后一位。而
\text{str}(q') 的\text{endpos} 集合当然也会增加最后一位,所以相对来说所有后缀和q' 的\text{endpos} 集合包含关系不变。总的来说,
q' 能直接继承q 的\text{link} 链接,因为后缀没变、后缀的等价类也没变,不在等价类中的最长后缀自然也不会变。 -
对于
cur 的\text{link} :我们费半天劲把q 分裂开就是为了让cur 的\text{link} 有指向🤗,直接让\text{link}(cur)\leftarrow q' 即可。 -
对于
q 的\text{link} :由于我们是直接从原来的q 中分裂出了一部分,由\text{endpos} 的性质可知这一部分一定都是\text{str}(q) 的后缀,而且显然它们是所有\text{str}(q) 的后缀中最长的,所以\text{link}(q)\leftarrow q' 。
-
这是上面图片加入
3.2 代码
struct{
int link,len,son[26];
}sam[2001000];
int lst,tot;
void init(){//初始化,代码里我将上面的标号整体 +1 以防止 RE
lst=tot=1;
memset(sam,0,sizeof sam);
sam[1].link=0;
}
void insert(int ch){
sam[++tot].len=sam[lst].len+1;
siz[tot]=1;
int pos=lst;
lst=tot;
while(pos!=0&&!sam[pos].son[ch]){//找 p & 新建转移
sam[pos].son[ch]=tot;
pos=sam[pos].link;
}
if(pos==0){//情况 1
sam[tot].link=1;
return;
}
int p=pos,q=sam[pos].son[ch];
if(sam[p].len+1==sam[q].len){//情况 2
sam[tot].link=q;
}
else{//情况 3
sam[++tot]=sam[q];//复制转移
sam[tot].len=sam[p].len+1;
sam[tot-1].link=sam[q].link=tot;
while(pos!=0&&sam[pos].son[ch]==q){//重定向转移
sam[pos].son[ch]=tot;
pos=sam[pos].link;
}
}
}
4 相关证明
4.1 状态数与转移数
这个部分有更紧的上界但是变化量
O(1) ,这里不做赘述,因为确实没啥用😕。
-
状态数:显然我们每次加入新的字符最多新建两个点而且第一次加入字符绝对不会进入状态三,所以 SAM 的状态数至多为
2n 。 -
转移数:下文我们令若转移
u\to v 满足\text{len}(u)+1=\text{len}(v) ,则称这个转移为重边,否则称它为轻边。对于重边,显然有每个状态(除源点)都有且仅有一条重边入度,所以重边的数量至多
2n 级别。对于轻边
u\xrightarrow{c} v ,我们构造一条路径从源点开始走重边到u ,再从v 随机游走到某一后缀的结尾。显然这条路径是存在的:
-
首先,因为每个点都有重边入度并且重边显然没有环,所以实际上重边会构成以源点为根的外向树并且一定包含所有节点。
-
其次,因为 SAM 的转移本质上是在维护后缀的前缀,所以我们一定可以从任意点游走到一个后缀的结尾
对于我们走出的这个路径,
u\to v 是路径的第一条轻边,并且对于每一个后缀路径和映射方案都是唯一的,所以轻边最多有后缀数量也就是n 个。总的来说,转移数至多为
3n 。 -
结论:SAM 的状态数至多为
4.2 复杂度证明
下文的分析建立在操作后的 SAM 上。
-
添加转移:由于我们总共只有
3n 个转移,所以这个部分的总复杂度O(n) 。 -
复制转移:每次复制转移实际上都会新增转移,所以和 4.2.1 同理是共计
O(n) 的。 -
重定向转移:重定向转移时,我们实际上都在操作轻边,因为我们已经有
\text{len}(p)+1=\text{len}(q') ,并且\text{len}(\text{link}(p))<\text{len}(p) ,所以\text{len}(\text{link}(p))+1<\text{len}(q') ,也就是说这些转移都是轻边。所以我们考虑两个点到后缀路径上的轻边数量。
引理:若有重边
x\xrightarrow{a} y ,则P(x) 向P(y) 的轻边数量等于|P(x)|-|P(y)|+1 。证明:
-
对于
P(y) :考虑其上一非源点u ,\text{str}(u)-a 一定在P(x) 中出现(因为它是\text{str}(y)-a=\text{str}(x) 的后缀)。并且因为
\text{str}(u) 和上一个状态不在一个\text{endpos} 等价类中,所以它们同时删去a 也不会在一个\text{endpos} 集合中,换言之,\text{str}(u)-a 一定等于P(x) 上一点v 的\text{str}(v) 。总而言之,所有
P(y) 上的状态都有一条重边来自P(x) 上(除源点)。 -
对于
P(x) :考虑其上任意一点u ,\text{str(u)}+a 一定在P(y) 中出现(因为它是\text{str}(x)+a=\text{str}(y) 的后缀)。总而言之,所有
P(x) 上的状态都有一条转移指向P(y) 上。
(注意关于
y 的都是重边而关于x 的可能是轻边)将重边对应完之后剩下的就是轻边了,所以轻边的数量是
|P(x)|-|P(y)|+1 (加一加的是源点)。根据引理,轻边数量为
|P(p)|-|P(q')|+1 。由于
|P(cur)|=|P(q')|+1 ,|P(last)|\ge |P(p)| ,所以|P(p)|-|P(q')|+1\le |P(last)|-|P(cur)|+2 。所以总重定向复杂度
=\sum 重定向边数量\le\sum 轻边数量\le\sum |P(last)|-|P(cur)|+2 。对于这个式子,由于我们每次的
cur 都会变成下次的last ,所以展开后会疯狂抵消只剩下第一次的|P(last)| 减去最后一次的|P(cur)| 再加2 ,这显然是O(n) 级别的。注意这里的第一次是第一次进入情况三而不是第一次加点,如果是第一次加点你会发现这一段的复杂度怎么变成
O(1) 了😨。 -
综上,SAM 的总构建复杂度是线性的。
5 例题
[SDOI2016] 生成魔咒
题目大意
给定一个字符串,求在每次加入节点后的本质不同子串个数。
题解
考虑每次加入新点后字符串增加的本质不同子串个数,我们发现实际上就是
[TJOI2015] 弦论
题目大意
给定一个长度为
题解
相同子串不算做同一个无非就是让每个串都对排名产生出现次数次贡献,出现次数考虑对于状态
为保证复杂度,我们需要记录走入一个状态内要消耗多少排名,dfs 时优先走最小的转移,模仿平衡树第
[AHOI2013] 差异
题目大意
给定一个长度为
其中,
题解
首先这个
考虑
考虑在
原因意会,感觉比较显然。
[NOI2018] 你的名字
噔噔咚。
题目大意
给定一个模版
题解
首先我们将问题转化为
先考虑
直接对
对于一般情况:
发现本质的差别是有点状态不能走了,本质上能走的转移就是在