后缀自动机学习笔记

1. 概述

  • 在另一个字符串中搜索一个字符串的所有出现位置

  • 计算给定的字符串中有多少个不同的子串

以上两个问题用后缀自动机都可以在线性的时间复杂度内解决

直观上,SAM可以理解为给定字符串的所有子串的压缩形式,注意,SAM将所有的这些信息以高度压缩的形式存储,对于一个长度为n的字符串,可以以\(O(n)\)的时间进行构造,而且,一个SAM最多有\(2n-1\)个节点和\(3n-4\)条转移边

2. 定义

字符串s的SAM是一个接受s的所有后缀的最小DFA,即确定性有限自动机或确定性有限状态自动机

换句话说,就是:

  • SAM就是一个有向无环图,节点是状态,边是状态间的转移

  • 图存在一个点\(t_0\),称作初始状态,其余节点均可从\(t_0\)出发到达

  • 每个转移都标有一些字母,从每个节点出发的转移均不同

  • 存在一个或多个终止状态,如果我们从初始状态\(t_0\)出发,最终转移到了一个终止状态,则路径上的所有转移连接起来一定是字符串s的一个后缀。s的每个后缀均可用一条从\(t_0\)到某个终止状态的路径构成

  • 在满足上述条件的自动机中,SAM的节点最少

3. 子串的性质

SAM最简单也是最重要的性质是它包含关于字符串s的所有子串的信息

任意从初始状态\(t_0\)开始的路径,如果我们将转移路径上的标号写下来,都会形成s的一个 子串。反之每个s的子串对应从\(t_0\)开始的某条路径

简单来说就是子串对应一条路径,一条路径对应一个子串

因为到达一个状态的路径不止一条,因此我们说一个状态对应一些字符串的集合,这个集合的每个元素对应这些路径

4. 构建过程

4.1. 示例

假设蓝色为初始状态,绿色为终止状态

对于字符串\(s=\varnothing\)

对于字符串\(s=a\)

对于字符串\(s=aa\)

对于字符串\(s=ab\)

对于字符串\(s=abb\)

对于字符串\(s=abbb\)

4.2. 一些概念

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\)集合构成的一棵树。

4.2.3. parent树

定义:通过\(endpos\)集合构成的树,满足每个子节点的集合都包含在父节点的集合中

由引理2,两个点的集合要么包含要么不重合,所以最后的关系一定是树

显然易得:

由link构成的树与parent树相同

\(abcbc\)为例,构成的DAG是:

parent树是:

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

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的所有后缀,这些状态都是终止状态

5. 应用

5.1. 检查字符串是否出现

给定多个模式串,判断是否在字符串T上出现过

先对T建立SAM,和trie很像,从根出发,如果能再会问自动机上将该串走完,则存在,否则不存在

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))\),对自动机所有节点求和即可

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

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

与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\)节点的对应值就是该节点的净贡献,对自动机所有节点求和即可。

5.4. 字典序第k大子串

给定一个字符串S,查询S字典序第k大的子串

解决这个问题的思路可以从解决前两个问题的思路发展而来

字典序第k大的子串对应于SAM中字典序第k大的路径,因此在计算每个状态的路径数后,我们可以很容易地从SAM的根开始找到第k大的路径。

预处理的时间复杂度为\(O(|S|)\),单次查询的复杂度为\(O(|ans|\cdot|\sum|)\)

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

5.6. 求多个字符串的最长公共子串

给定一些字符串,求他们的最长公共子串长度

可以先对第一个串建立后缀自动机,根据定义可以发现,后缀自动机的link指针就是AC自动机的fail指针

所以就可以将所有串在这个自动机上匹配,在每次匹配时,记录每个点长度的最大值,每个串结束后,在后缀自动机的节点取最小值,最后取整个自动机的最大值即可

6. 例题

6.1. P3804 【模板】后缀自动机(SAM)

https://gxyzoj.com/d/gxyznoi/p/121

6.1.1. 思路

就是求每个子串的出现次数,因为相同位置的串endpos等价,所以出现次数必然相同

此时,对于同一个位置,取最长的len即可,与出现次数相乘并取max就是答案

因为是要求出现次数大于一,所以求解之前,注意判断cnt的值

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;
}

6.2. [SDOI2016] 生成魔咒

https://gxyzoj.com/d/gxyznoi/p/P122

显然是求不同子串的个数,因为题目强制在线,所以显然不能动态规划

考虑第二种方法,每加入一个新的字符,设结束的节点为i,总量增加\(len(i)-len(link(i))\)

注意,因为x很大,可以用map储存子节点的信息

6.3. [TJOI2015] 弦论

https://gxyzoj.com/d/gxyznoi/p/P123

这里要分两种情况,如果是1,每一个子串出现的次数就是他在parent树上所在子树内前缀节点的个数

利用SAM有向无环的性质,我们可以在parent树上统计完之后在后缀自动机上dfs,对每个点累计以他为开头的所有子串的总数

如果是0,则只关心字典序,所以每一个节点都是一个子串,字典序相同的子串不会被重复统计

字典序第k大子串的板子,因为这里是第k小,所以直接将求解顺序倒过来即可

6.4. [SDOI2008] Sandy的卡片

https://gxyzoj.com/d/gxyznoi/p/P124

显然差分后求LCS,注意要+1

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