后缀自动机学习笔记
1.后缀自动机
1.1. 概述
-
在另一个字符串中搜索一个字符串的所有出现位置
-
计算给定的字符串中有多少个不同的子串
以上两个问题用后缀自动机都可以在线性的时间复杂度内解决
直观上,SAM可以理解为给定字符串的所有子串的压缩形式,注意,SAM将所有的这些信息以高度压缩的形式存储,对于一个长度为n的字符串,可以以\(O(n)\)的时间进行构造,而且,一个SAM最多有\(2n-1\)个节点和\(3n-4\)条转移边
1.2. 定义
字符串s的SAM是一个接受s的所有后缀的最小DFA,即确定性有限自动机或确定性有限状态自动机
换句话说,就是:
-
SAM就是一个有向无环图,节点是状态,边是状态间的转移
-
图存在一个点\(t_0\),称作初始状态,其余节点均可从\(t_0\)出发到达
-
每个转移都标有一些字母,从每个节点出发的转移均不同
-
存在一个或多个终止状态,如果我们从初始状态\(t_0\)出发,最终转移到了一个终止状态,则路径上的所有转移连接起来一定是字符串s的一个后缀。s的每个后缀均可用一条从\(t_0\)到某个终止状态的路径构成
-
在满足上述条件的自动机中,SAM的节点最少
1.3. 子串的性质
SAM最简单也是最重要的性质是它包含关于字符串s的所有子串的信息
任意从初始状态\(t_0\)开始的路径,如果我们将转移路径上的标号写下来,都会形成s的一个 子串。反之每个s的子串对应从\(t_0\)开始的某条路径
简单来说就是子串对应一条路径,一条路径对应一个子串
因为到达一个状态的路径不止一条,因此我们说一个状态对应一些字符串的集合,这个集合的每个元素对应这些路径
1.4. 构建过程
1.4.1. 示例
假设蓝色为初始状态,绿色为终止状态
对于字符串\(s=\varnothing\)
对于字符串\(s=a\)
对于字符串\(s=aa\)
对于字符串\(s=ab\)
对于字符串\(s=abb\)
对于字符串\(s=abbb\)
1.4.2. 一些概念
1.4.2.1. 结束位置 endpos
考虑字符串s的任意非空子串t,我们记\(endpos(t)\)为在字符串s中t的所有结束位置(假设对字符串中字符的编号从零开始)
例如,对于字符串\(abcbc\),我们有\(endpos(bc)=2,4\)
两个子串\(t_1\)与\(t_2\)的\(endpos\)集合可能相等,即:\(endpos(t_1)=endpos(t_2)\)
这样所有字符串s的非空子串都可以根据它们的\(endpos\)集合被分为若干等价类
显然,SAM中的每个状态对应一个或多个\(endpos\)相同的子串。换句话说,SAM中的状态数等于所有子串的等价类的个数,再加上初始状态
SAM的状态个数等价于\(endpos\)相同的一个或多个子串所组成的集合的个数+1
相关引理:
引理1:字符串s的两个非空子串u,v,假设 \(|u|\le|v|\),的endpos相同,当且仅当字符串u在 s 中的每次出现,都是以v后缀的形式存在的
引理2:考虑两个非空子串u和v,假设\(|u|\le |v|\))。那么要么\(endpos(u)\cap endpos(w)=\varnothing\),要么\(endpos(w)\subseteq endpos(u)\),取决于 u 是否为 w 的一个后缀:
\[\begin{cases} endpos(w) \subseteq endpos(u) & if\ u\ is\ a\ suffix\ of\ w \\ endpos(w) \cap endpos(u) = \varnothing & otherwise \end{cases} \]
引理3:考虑一个\(endpo\)等价类,将类中的所有子串按长度非递增的顺序排序。每个子串都不会比它前一个子串长,与此同时每个子串也是它前一个子串的后缀。换句话说,对于同一等价类的任一两子串,较短者为较长者的后缀,且该等价类中的子串长度恰好覆盖整个区间\([x,y]\)。
1.4.2.2. 后缀链接 link
考虑SAM中某个不是\(t_0\)的状态v。我们已经知道状态v对应于具有相同\(endpos\)的等价类。我们如果定义w为这些字符串中最长的一个,则所有其它的字符串都是w的后缀
我们还知道字符串w的前几个后缀(按长度降序考虑)全部包含于这个等价类,且所有其它后缀在其它的等价类中
我们记t为最长的这样的后缀,然后将v的后缀链接连到t上,这就是v的后缀链接
换句话说,一个后缀链接\(link(v)\)连接到对应于w的最长后缀的另一个\(endpos\)等价类的状态
以下我们假设初始状态\(t_0\)对应于它自己这个等价类(只包含一个空字符串)
规定\(endpos(t_0)=\{-1,0,\ldots,\left|S\right|-1\}\)
则有如下引理:
引理4:所有后缀链接会构成一颗以\(t_0\)为根的树
引理5:通过\(endpos\)集合构造的树(每个子节点的\(subset\)都包含在父节点的\(subset\)中)与通过后缀链接\(link\)构造的树相同
证明:由引理 2,任意一个 SAM 的\(endpos\)集合形成了一棵树(因为两个集合要么完全没有交集要么其中一个是另一个的子集)
我们现在考虑任意不是\(t_0\)的状态v及后缀链接\(link(v)\),由后缀链接和引理2,我们可以得到
注意这里应该是\(\subsetneq\)而不是\(\subseteq\),因为若\(endpos(v)=endpos(link(v))\),那么v和\(link(v)\)应该被合并为一个节点
结合前面的引理有:后缀链接构成的树本质上是\(endpos\)集合构成的一棵树。
1.4.2.3. parent树
定义:通过\(endpos\)集合构成的树,满足每个子节点的集合都包含在父节点的集合中
由引理2,两个点的集合要么包含要么不重合,所以最后的关系一定是树
显然易得:
由link构成的树与parent树相同
以\(abcbc\)为例,构成的DAG是:
parent树是:
1.4.3. 小结
-
s的子串可以根据它们结束的位置\(endpos\)被划分为多个等价类
-
SAM 由初始状态\(t_0\)和与每一个\(endpos\)等价类对应的每个状态组成
-
对于每一个状态v,一个或多个子串与之匹配。我们记\(longest(v)\)为其中最长的一个字符串,记\(len(v)\)为它的长度。类似地,记\(shortest(v)\)为最短的子串,它的长度为\(minlen(v)\)。那么对应这个状态的所有字符串都是字符串\(longest(v)\)的不同的后缀,且所有字符串的长度恰好覆盖区间\([minlen(v),len(v)]\)中的每一个整数
-
对于任意不是\(t_0\)的状态v,定义后缀链接为连接到对应字符串\(longest(v)\)的长度为\(minlen(v)-1\)的后缀的一条边。从根节点\(t_0\)出发的后缀链接可以形成一棵树。这棵树也表示\(endpos\)集合间的包含关系
-
对于 t_0 以外的状态 v,可用后缀链接\(link(v)\)表达\(minlen(v)\):
- 如果我们从任意状态\(v_0\)开始顺着后缀链接遍历,总会到达初始状态\(t_0\)。这种情况下我们可以得到一个互不相交的区间\([minlen(v_i),len(v_i)]\)的序列,且它们的并集形成了连续的区间\([0,len(v_0)]\)。
1.4.4. 具体过程
构建SAM是一种在线的算法,可以逐个加入字符并进行维护
令\(last\)为添加字符c之前,整个字符串对应的状态,\(last\)的初始值为0
创建一个新的状态cur,并将\(len(cur)\)赋值为\(len(last)+1\),此时\(link(cur)\)的值未知
从last开始,假如当前没有到字符c的转移,就添加一个到状态cur的转移,遍历后缀链接
假设有,则停下来,将该状态标记为p
如果没有找到这样的状态p,我们就到达了虚拟状态-1,我们将\(link(cur)\)赋值为0并退出
假设现在我们找到了一个状态p,它可以从字符c转移
现在我们分类讨论两种状态,要么\(len(p)+1=len(q)\),要么不是
如果\(len(p)+1=len(q)\),则将\(link(cur)\)赋值为q并退出即可
否则需要复制状态q,我们创建一个新的状态clone,复制q除len外的所有信息,即后缀链接和转移
然后,将\(len(clone)\)赋值为\(len(q)+1\),复制之后,我们将后缀链接从\(cur\)指向\(clone\),也从q指向\(clone\)
最终我们需要使用后缀链接从状态p往回走,只要存在一条通过p到状态q的转移,就将该转移重定向到状态\(clone\)
以上三种情况,在完成这个过程之后,我们将\(last\)的值更新为状态\(cur\)
如果我们还想知道哪些状态是终止状态,我们可以在为字符串s构造完完整的SAM后找到所有的终止状态
为此,我们从对应整个字符串的状态,遍历它的后缀链接,直到到达初始状态
我们将所有遍历到的节点都标记为终止节点,容易理解这样做我们会准确地标记字符串s的所有后缀,这些状态都是终止状态
1.5. 应用
1.5.1. 检查字符串是否出现
给定多个模式串,判断是否在字符串T上出现过
先对T建立SAM,和trie很像,从根出发,如果能再会问自动机上将该串走完,则存在,否则不存在
1.5.2. 不同子串个数
给定一个字符串S,计算不同子串的个数
首先对S构造后缀自动机
因为每个子串都相当于后缀自动机中的一些路径,所以不同子串的个数为从根出发的不同路径个数
考虑到SAM为DAG,设\(d_v\)为从状态v开始的路径数量,所以可以用动态规划来计算
即,\(d_{v}\)可以表示为所有v的转移的末端的和
因为要去掉空子串,所以不同子串的个数为\(d_{t_0}-1\)
另一种方法是利用上述后缀自动机的树形结构。每个节点对应的子串数量是\(len(i)-len(link(i))\),对自动机所有节点求和即可
1.5.3. 所有不同子串的总长度
给定一个字符串S,计算所有不同子串的总长度
与5.2类似,分为两部分,不同子串的数量\(d_v\)和总长度\(ans_v\)
在上面已经说过\(d_v\)怎么算了,考虑\(ans_v\),可以通过如下递推式计算:
我们取每个邻接结点w的答案,并加上\(d_{w}\)(因为从状态v出发的子串都增加了一个字符)
同样可以利用上述后缀自动机的树形结构。每个节点对应的所有后缀长度是\(\dfrac{len(i)\times (len(i)+1)}{2}\),减去其\(link\)节点的对应值就是该节点的净贡献,对自动机所有节点求和即可。
1.5.4. 字典序第k大子串
给定一个字符串S,查询S字典序第k大的子串
解决这个问题的思路可以从解决前两个问题的思路发展而来
字典序第k大的子串对应于SAM中字典序第k大的路径,因此在计算每个状态的路径数后,我们可以很容易地从SAM的根开始找到第k大的路径。
预处理的时间复杂度为\(O(|S|)\),单次查询的复杂度为\(O(|ans|\cdot|\sum|)\)。
1.5.5. 出现次数
对于一个给定的文本串T,有多组询问,每组询问给一个模式串P,回答模式串P在字符串T中作为子串出现了多少次
首先对T构造后缀自动机,然后分为两部分
接下来做预处理:对于自动机中的每个状态v,预处理\(cnt_{v}\),使之等于\(endpos(v)\)集合的大小。事实上,对应同一状态\(v\)的所有子串在文本串T中的出现次数相同,这相当于集合\(endpos\)中的位置数
然而我们不能明确的构造集合endpos,而我们只需要它的cnt
对于每个状态,如果它不是通过复制且不为初始状态,则将它的cnt初始化为1
然后按照他们的长度len降序遍历所以状态,并将当前的\(cnt_{v}\)的值加到后缀链接指向的状态上,即:
此时,利用后缀自动机的树形结构,进行dfs即可预处理每个节点的终点集合大小
在自动机上查找模式串P对应的节点,如果存在,则答案就是该节点的终点集合大小,如果不存在,则答案为0
1.5.6. 求多个字符串的最长公共子串
给定一些字符串,求他们的最长公共子串长度
可以先对第一个串建立后缀自动机,根据定义可以发现,后缀自动机的link指针就是AC自动机的fail指针
所以就可以将所有串在这个自动机上匹配,在每次匹配时,记录每个点长度的最大值,每个串结束后,在后缀自动机的节点取最小值,最后取整个自动机的最大值即可
1.6. 例题
1.6.1. P3804 【模板】后缀自动机(SAM)
https://gxyzoj.com/d/gxyznoi/p/121
1.6.1.1. 思路
就是求每个子串的出现次数,因为相同位置的串endpos等价,所以出现次数必然相同
此时,对于同一个位置,取最长的len即可,与出现次数相乘并取max就是答案
因为是要求出现次数大于一,所以求解之前,注意判断cnt的值
1.6.1.2. 代码
#include<cstdio>
#include<string>
#include<iostream>
#include<algorithm>
#define ll long long
using namespace std;
string s;
int n,lst,tot,edgenum,head[2000006];
ll ans,size[2000006];
struct node{
int ch[26],link,len;
}tr[2000006];
struct edge{
int to,nxt;
}e[4000006];
void add_edge(int u,int v)
{
e[++edgenum].nxt=head[u];
e[edgenum].to=v;
head[u]=edgenum;
}
void insert(int c)
{
int p=lst,now=++tot;
tr[now].len=tr[lst].len+1;
size[now]=1;
while(p!=-1&&!tr[p].ch[c])
{
tr[p].ch[c]=now;
p=tr[p].link;
}
// printf("%d ",p);
if(p==-1) tr[now].link=0;
else
{
int q=tr[p].ch[c];
if(tr[p].len+1==tr[q].len)
{
tr[now].link=q;
}
else
{
int clone=++tot;
tr[clone]=tr[q];
tr[clone].len=tr[p].len+1;
while(p!=-1&&tr[p].ch[c]==q)
{
tr[p].ch[c]=clone;
p=tr[p].link;
}
tr[q].link=tr[now].link=clone;
}
}
lst=now;
}
void dfs(int u)
{
// printf("%d\n",u);
for(int i=head[u];i;i=e[i].nxt)
{
int v=e[i].to;
dfs(v);
size[u]+=size[v];
}
if(size[u]>1&&u!=0)
{
ans=max(ans,1ll*size[u]*tr[u].len);
}
}
int main()
{
//freopen("1.txt","r",stdin);
cin>>s;
n=s.size();
s=" "+s;
tr[0].link=-1;
for(int i=1;i<=n;i++)
{
insert(s[i]-'a');
}
for(int i=1;i<=tot;i++)
{
add_edge(tr[i].link,i);
// printf("%d %d %d %d\n",tr[i].link,i,tr[i].len,size[i]);
}
dfs(0);
printf("%lld",ans);
return 0;
}
1.6.2. [SDOI2016] 生成魔咒
https://gxyzoj.com/d/gxyznoi/p/P122
显然是求不同子串的个数,因为题目强制在线,所以显然不能动态规划
考虑第二种方法,每加入一个新的字符,设结束的节点为i,总量增加\(len(i)-len(link(i))\)
注意,因为x很大,可以用map储存子节点的信息
1.6.3. [TJOI2015] 弦论
https://gxyzoj.com/d/gxyznoi/p/P123
这里要分两种情况,如果是1,每一个子串出现的次数就是他在parent树上所在子树内前缀节点的个数
利用SAM有向无环的性质,我们可以在parent树上统计完之后在后缀自动机上dfs,对每个点累计以他为开头的所有子串的总数
如果是0,则只关心字典序,所以每一个节点都是一个子串,字典序相同的子串不会被重复统计
字典序第k大子串的板子,因为这里是第k小,所以直接将求解顺序倒过来即可
1.6.4. [SDOI2008] Sandy的卡片
https://gxyzoj.com/d/gxyznoi/p/P124
显然差分后求LCS,注意要+1
1.6.5. [Ahoi2013] 差异
https://gxyzoj.com/d/gxyznoi/p/P125
两个子串串的长度之和减去公共前缀就是他们在parent树上的距离,建出SAM后暴力求解即可