后缀自动机学习笔记

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]\)

考虑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,我们可以得到

\[endpos(v)\subsetneq endpos(link(v)) \]

注意这里应该是\(\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)\)

\[minlen(v)=len(link(v))+1 \]

  • 如果我们从任意状态\(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=1+\sum_{(w,v)\in E} d_w \]

即,\(d_{v}\)可以表示为所有v的转移的末端的和

因为要去掉空子串,所以不同子串的个数为\(d_{t_0}-1\)

另一种方法是利用上述后缀自动机的树形结构。每个节点对应的子串数量是\(len(i)-len(link(i))\),对自动机所有节点求和即可

1.5.3. 所有不同子串的总长度

给定一个字符串S,计算所有不同子串的总长度

与1.5.2类似,分为两部分,不同子串的数量\(d_v\)和总长度\(ans_v\)

在上面已经说过\(d_v\)怎么算了,考虑\(ans_v\),可以通过如下递推式计算:

\[ans_v=\sum_{(v,w)\in E} ans_w+d_w \]

我们取每个邻接结点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}\)的值加到后缀链接指向的状态上,即:

\[cnt_{link(v)}+=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后暴力求解即可

1.6.6. [雅礼集训 2017 Day7] 事情的相似度

一定要注意是 0,1!!!

首先,根据后缀自动机的原理,parent 树上两前缀结尾的 LCA 的深度就是他们的公共后缀长度

所以原问题就转化为每次给一个点集,求这个点集中任意两点 LCA 的最大深度

可以发现,如果某两个点的 LCA 是 x,那么他们必然位于 x 的两个不同的子树

显然,对于两个不同字子树内的点,任意两个贡献相同,但是数值接近的更容易产生贡献

所以,考虑启发式合并,用set来储存子树内的结尾编号,每次用 size 较小的 set 中的数查找并合并

最后统计答案,可以使用树状数组,在查找时,将编号较小的放在前面,记为 l,编号大的记为 r

每次当 r 小于等于查询右端点时,就在 l 处开始更新

因为每个l只会对在它前面的询问左端点产生贡献,所以要向前更新,向后查询

1.6.7. [loj6198]谢特

https://www.gxyzoj.com/d/gxyznoi/p/P131

这个问题有两部分一部分和上一个一样,求后缀,就把串直接翻转。

第二部分是异或,可以用字典树,先用较少的那一个去查找,再将子树内所有值形成的字典树向上合并即可

1.6.8. [BZOJ3879]SvT

求后缀的公共前缀,可以把串反转,因为给出的点很少,建一个虚树就行了

1.6.9. [Tjoi2016&Heoi2016] 字符串

注意读题,\(s[c,d]\) 是前缀所以可以直接把串翻转,然后二分第二个串的长度

接下来可能否与前面的匹配

因为如果一个串加上一些东西能成功匹配,那么去掉也能成功

我们定右端点为 \(r\),然后倍增往上跳,直到上面没有点满足长度大于等于 \(mid\)

此时,我们直接判断当前点是否有 endpos 在 \([a+min-1,b]\)之间,可以建线段树处理,线段树合并方法见 P4770 [NOI2018] 你的名字 解题报告

2. 广义后缀自动机

给定m个串 \(s_1,s_2,……,s_m\),求不同的子串个数

对于只有一个串,直接建立后缀自动机即可,但是当不为一时,离线做法是将这些字符串连在一起,加入分割符,然后建SAM

但是当强制在线时,这个方法就不行了

2.1. 字典树

针对于多串的问题,要先将这些串整合到一起,可以使用字典树,然后在字典树上建立后缀自动机

2.2. 后缀自动机的建立

如果我们将当前这棵树认为是后缀自动机,则有如下结论:

  1. 对于节点i,其\(len_i\)就是在这棵树中的深度

  2. 如果对字典树进行拓扑排序,可以得到一个len值不递减的序列,BFS结果相同

而后缀自动机在建立的过程中,可以视为不断的插入len严格递增的值,且差值为1。

因此我们可以将拓扑排序后的结构存入队列,按队列顺序依次加入

在普通后缀自动机上,lst的len值时固定的

但因为广义后缀自动机中,插入队列的len不严格递增,为了解决此问题,它的lst应该是固定的,即它的父亲

由于在字典树中,已经建立了一个近似的后缀自动机,所以只需要对整个字典树的结构进行一定的处理即可转化为广义后缀自动机

而这个修改,则是利用简单修改后的insert操作实现,如下:

  1. 由于整个 BFS 的过程得到的顺序,其父节点始终在变化,所以并不需要保存last指针

  2. 由于字典树已经建立完成,所以cur应直接赋值为tr[lst].ch[c]

  3. 对新增节点的赋值改为:

if(len[next[q][i]]) next[clone][i]=next[q][i];
else next[clone][i]=0;

目的在于避免更新了len大于当前节点的值,因为数组中len当且仅当这个值被BFS遍历并插入到后缀自动机后才会被赋值

2.3. 过程简述

  1. 将所有字符串插入字典树

  2. 从根开始bfs,记录顺序及父亲

  3. 将得到的 BFS 序列按照顺序,对每个节点在原字典树上进行构建,注意不能将 len 小于当前 len 的数据进行操作

2.4. 例题

2.4.1. [ZJOI2015] 诸神眷顾的幻想乡

2.4.1.1. 思路

题目要求的是不同的序列个数,就类似于求子串个数

但是他给的是一棵树,因此并不是单纯的一个字符串,而可以抽象为字典树

此时,对原字典树建立广义后缀自动机,因为后缀自动机的性质依然存在,按照1.5.2的式子求解即可

但是,这样求出来的子串必然是从深度小到深度大,但序列是可以折的

注意到这是一棵叶子个数不超过20的无根树,所以可以将叶子依次作为根,将形成的树接到0号节点上,然后统一求解

2.4.1.2. 代码

#include<cstdio>
#include<queue>
#define ll long long
using namespace std;
int n,m,a[100005],head[100005],edgenum,id[100005],d[100005];
ll ans;
struct edge{
	int to,nxt;
}e[200005];
void add_edge(int u,int v)
{
	e[++edgenum].nxt=head[u];
	e[edgenum].to=v;
	head[u]=edgenum;
}
int tot;
struct node{
	int ch[11],len,link;
}tr[2000005];
void dfs(int u,int fa)
{
	if(!tr[id[fa]].ch[a[u]])
	{
		id[u]=++tot;
		tr[id[fa]].ch[a[u]]=tot;
	}
	else id[u]=tr[id[fa]].ch[a[u]];
	for(int i=head[u];i;i=e[i].nxt)
	{
		int v=e[i].to;
		if(v==fa) continue;
		dfs(v,u);
	}
}
struct node1{
	int id,son;
};
queue<node1> q;
int insert(int lst,int c)
{
	int cur=tr[lst].ch[c];
	if(tr[cur].len) return cur;
	tr[cur].len=tr[lst].len+1;
	int p=tr[lst].link;
	while(p!=-1)
	{
		if(!tr[p].ch[c])
		{
			tr[p].ch[c]=cur;
		}
		else break;
		p=tr[p].link; 
	}
	if(p==-1)
	{
		tr[cur].link=0;
		return cur;
	}
	int q=tr[p].ch[c];
	if(tr[p].len+1==tr[q].len)
	{
		tr[cur].link=q;
		return cur;
	}
	int clone=++tot;
	for(int i=0;i<10;i++)
	{
		if(tr[tr[q].ch[i]].len!=0) tr[clone].ch[i]=tr[q].ch[i];
		else tr[clone].ch[i]=0;
	}
	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[clone].link=tr[q].link;
	tr[cur].link=tr[q].link=clone;
	return cur;
}
void build()
{
	for(int i=0;i<10;i++)
	{
		if(tr[0].ch[i]) q.push((node1){0,i});
	}
	while(!q.empty())
	{
		node1 tmp=q.front();
		q.pop();
		int lst=insert(tmp.id,tmp.son);
		for(int i=0;i<10;i++)
		{
			if(tr[lst].ch[i]) q.push((node1){lst,i});
		}
	}
}
int main()
{
	scanf("%d%d",&n,&m);
	for(int i=1;i<=n;i++)
	{
		scanf("%d",&a[i]);
	}
	for(int i=1;i<n;i++)
	{
		int u,v;
		scanf("%d%d",&u,&v);
		add_edge(u,v);
		add_edge(v,u);
		d[u]++,d[v]++;
	}
	for(int i=1;i<=n;i++)
	{
		if(d[i]==1)
		{
			for(int j=1;j<=n;j++) id[j]=0;
			dfs(i,0);
		}
	}
	tr[0].link=-1;
	build();
	for(int i=1;i<=tot;i++)
	{
		ans=ans+tr[i].len-tr[tr[i].link].len;
	}
//	printf("%d ",tot);
	printf("%lld",ans);
	return 0;
}

2.4.2. [CTSC2012] 熟悉的文章

https://www.gxyzoj.com/d/gxyznoi/p/P128

因为是求符合条件的最大的l,所以可以二分

关于check,设\(dp_i\)表示当前段的结束点是i时,前i个字母最多有多少是熟悉的

所以:

\[dp_i=max(dp_{i-1},dp_{x}-x+i(i-mx_i<x\le i-l)) \]

其中,\(mx_i\)是i及之前,最长熟悉的段落

因为\(mx_i\)一定单调递增,所以建立广义后缀自动机求出后,可用单调队列优化

posted @ 2024-06-27 18:00  wangsiqi2010916  阅读(92)  评论(0)    收藏  举报