[关闭]
@xiaoziyao 2021-03-17T04:35:08.000000Z 字数 9193 阅读 1267

后缀自动机(SAM)学习笔记

字符串 学习笔记


注:文中所有字符串位置从1开始
注:若无特别标出,文中所有子串均为可空子串
注:空串在文中用下划线_表示
注:初始状态与空串代表的状态意义相同
注:文中状态,结点与等价类是同一概念。
注:图中实线表示DAWG边,虚线表示后缀link。


引入

SP1811 LCS - Longest Common Substring
题意:求两个字符串的最长公共子串长度。
数据范围:

前两个算法不可以通过本题,二分哈希虽然能通过,但数据加强就也无能为力了。

我们需要一个更优秀的算法——最好有线性复杂度,它就是后缀自动机,又称SAM(Suffix AutoMaton)。

SAM的定义

SAM是一个能接受字符串所有后缀的最小DFA(确定性有限自动机),同时它其实也可以表示这个字符串的所有子串。

即可以理解为:SAM是一张DAG(其实这个定义不是特别准确,后面会讲),从SAM的起点开始走,可以通过一些边(相当于字符)进行转移,只有字符串的后缀才能到终止集合(注意,终点不止一个,但起点只有一个),且SAM的结点数是最少的。

学SAM需要知道的

结束位置集合

我们定义为子串出现的所有结束位置的集合。(注意,我们定义空串的为所有位置组成的集合)

举个例子,对于字符串

等价类

我们定义等价类为所有相同的子串构成的集合。

还是上面这个例子:对于字符串等价类

对于一个等价类,它在从长到短排序后一定满足后面的字符串是前面的字符串的子串(这个在后面会进行证明),因此我们定义后缀link为两个等价类之间的有向边,且能向连后缀link当且仅当是具有这个性质的中集合大小最小的。

这里再举个例子吧,对于字符串,它的所有等价类为:对应对应对应对应对应对应对应,因此它对应的后缀会连成这样的形式:






终止集合&终止链

对于每一个后缀形成的等价类,形成的是终止集合,它们的后缀link一定会形成一条链,我们称这条链为终止链。

Parent Tree

根据后缀link将所有等价类连成的结构叫做Parent Tree。因为所有非空子串代表的集合都会连向大小严格大于它的集合,而空串代表的集合又是最大的,因此这是一颗以空串代表的集合为根结点的树,这棵树有一些优秀的性质,我们之后再讲。

比如,根据字符串,我们构建出来的Parent Tree为:

DAWG(Directed Acyclic Word Graph)

定义DAWG为能表示字符串所有后缀的图,其中每一个结点都是一个等价类,可以完成SAM的基本操作。

形成DAWG的DAWG边带权,表示某个等价类在后面添加一个字符可以转移到另一个等价类。

DAWG的构造会在后面讲到,这里用上面的字符串举个例子吧:

其实DAWG和Parent Tree是共用一套结点的,只是边不一样而已。

连续的转移

代表的等价类最长串的长度,那么如果一条从连向的DAWG边满足,我们就称这条DAWG的转移是连续的,否则就称是不连续的。

SAM的性质

性质1:对于两个非空子串),

首先证第一个:因为的后缀,所以结束的所有地方一定结束,即。反之,如果,那么结束的地方一定结束,又因为,因此的后缀。

第一个成立,则证明了互为充要条件,反之也可以知道。又因为,所以,因此

性质2:将一个等价类的所有字符串按长度从大到小排序后,每一个字符串都是其前面的字符串的后缀,且这些后缀是连续的(即长度严格递增)

考虑等价类中两个字符串),,由引理1得的后缀。

然后我们设等价类中最长的子串为,最短的子串为的一个后缀且,那么肯定有,由引理1得,又因为属于一个等价类,所以,故,即属于这个等价类。

因此,我们证明了一个等价类中最长的子串的所有长度大于等于这个等价类中最短子串的后缀都属于这个等价类,即里面的后缀是连续的。

性质3:等价类的级别为

考虑一颗表示长度为的字符串的Parent Tree,由引理1和后缀link的定义可以知道任意父结点的所有儿子一定都是这个父结点互不相交的子集,且每个子节点的大小必定小于父亲结点的大小,因此可以考虑分割关系,根结点的子集大小为,最多进行次分割,即产生对父子关系,因此等价类的级别是

这个不懂没关系,在证明复杂度的时候会再次提及(以另一种方式)。

性质4:若存在连向的后缀link,那么

上面讲过,后缀link可以看做分割关系,即被连向的状态分割成所有连向它的状态(注意,某些可能会丢失,这个后面会讲到),因此,我们可以考虑在某一个Parent Tree中非叶子节点代表的等价类中最长串前面添加一个字母,使得它的不属于这个结点的等价类,因此这是一个新的等价类,而根据后缀link的定义,它的后缀link应该连向原来的结点。某些结点可能是串的前缀,在前面是无法添加字母的,因此可能会丢失一些

有了这个性质,我们可以在构造SAM的时候少保存一些信息。

性质5:从初始状态到任意状态,有且仅有一条完全由连续转移组成的路径到达

这个很显然,因为跳连续转移意味着在后面添加字符,如果有两条完全由连续转移组成的路径,那么这两条路径一定是重合的。

SAM的构造

SAM一般指Parent Tree与DAWG一整套结构,下面我们对这两个结构的构造进行讲解:

DAWG

DAWG可以用一种叫做增量法的方式进行在线构造:

首先定义变量:等价类中最长的子串,的后缀link,可以通过字符转移到什么状态。

由于是在线构造,因此我们考虑每次都加入字符,当前位置为(当前位置为当前后缀自动机表示的串所处的等价类),并让新字符串中所有的后缀被表示出来。

我们考虑哪些点需要更新,由于要让所有的后缀被表示出来,因此只需要之前的最长后缀代表的等价类到空串代表的等价类(即新的终止链)都可以转移到表示现在所有后缀的状态就可以了。

那么我们首先新建一个结点,代表这些新后缀代表的等价类,很显然(即新的串为旧的串后面添加一个字符),我们只需要将所有上述状态用一条字符为的边连向就可以完成构建了。

看一种情况:

我们需要将终止链上的状态连向,然后考虑的后缀link怎么连,因为是这个状态代表的,而没有其他的状态代表的中含有位置,所以的后缀link需要连向(即空串表示的状态)。

但是远远没有这么简单,在下图中,已经有一条字符为的边了,此时该怎么处理呢?

首先还是新建状态,并连接连向的边。

此时,我们需要分类讨论一下:

如果,即最长的子串后面添一个字符就是状态的最长子串,那么一定代表的最长串为,其中中最长的串,也是中最长的串的子串。而代表的最长串为的后缀,故需要向连后缀link。(不过此时结点不存在,可以思考一下)

否则,(因为不可能大于,如果大于那么肯定存在更优的属于状态的串使),这就有一个严重的问题:中所有的子串,有些在更新后会多一个地方结束,导致不属于一个等价类、

此时我们需要克隆出一个新状态存储更新后会影响的子串,因为这些子串可以通过原先的DAWG边进行转移,所以我们要先让它接受所有信息(即连出的DAWG边)。

考虑如何更新各个经过修改的结点的后缀link:的后缀link,的后缀link,的后缀link。

因为存在的后缀link组成的路径,所以最长串中某一个串)为最长串最长串)的后缀,所以的后缀link可以连向

同理,因为某些串加上字符构成的等价类,所以最长串可以用表示(中某一个串)。考虑的关系,因为加上字符结束的地方不会改变而出现的地方会改变,所以肯定长度小于,又因为属于一个等价类,所以的后缀,一定是的子集,即的后缀link需要连向

同时,由于原本的后缀link连向,由引理4可以知道中所有的串都是中所有的串的后缀,在克隆之后会替代将后缀link连向

最后,我们遍历剩余的终止链,由于后加可以转移到状态,所以以及之后的状态加都可以转移到状态,因此这些状态都会有一条字符为的DAWG边连向状态,更新之后可以通过这些状态加转移到的子串的一定会改变,因此它们的字符边需要连向

当然,这条终止链上可能还有其他的结点,它们如果不指向状态,那么一定会有一条同样字符的边指向其他的状态(以前的后缀link一定会连向这些状态),这时候我们就不用管了。

代码(原则:理解性默写):

  1. int extend(int last,int x){
  2. int now=++tot,pos=last,tmp,cln;
  3. len[now]=len[last]+1;
  4. while(pos!=0&&nxt[pos][x]==0)
  5. nxt[pos][x]=now,pos=link[pos];
  6. if(pos==0){
  7. link[now]=1;
  8. return now;
  9. }
  10. tmp=nxt[pos][x];
  11. if(len[tmp]==len[pos]+1){
  12. link[now]=tmp;
  13. return now;
  14. }
  15. cln=++tot;
  16. for(int i=1;i<=26;i++)
  17. nxt[cln][i]=nxt[tmp][i];
  18. len[cln]=len[pos]+1;
  19. link[cln]=link[tmp],link[tmp]=link[now]=cln;
  20. while(pos!=0&&nxt[pos][x]==tmp)
  21. nxt[pos][x]=cln,pos=link[pos];
  22. return now;
  23. }

主函数内,维护一下就可以了:

  1. cin>>s;
  2. n=s.size(),s=" "+s;
  3. last=++tot;
  4. for(i=1;i<=n;i++)
  5. last=extend(last,s[i]-96);

Parent Tree

Parent Tree很容易建,只需要把所有后缀link连边就好了。(注意,为了让空串代表的状态为根结点,因此需要反着建边)

代码:

  1. for(i=2;i<=tot;i++)
  2. add(link[i],i);

DAWG和Parent Tree的差别与联系

SAM的正确性证明

因为每一次添加字符一定会让当前的终止链中所有结点(包括空串代表的状态)存在一条边权为这个字符的DAWG边,所以SAM一定可以表示出字符串的所有后缀,进一步,也可以表示字符串的所有子串。(因为走后缀的时候一定会走过后缀的前缀,而子串可以理解为后缀的前缀)

SAM的复杂度证明

这里假定字符集大小是常数,那么SAM的复杂度为,否则SAM的复杂度为为字符串长度,为字符集大小)

SAM的状态数是

按照建SAM的过程,有一个初始状态(空串),第一次添加状态和第二次添加状态的时候不可能进行克隆,后面每一次都有可能进行克隆,因此总状态数最多为,是的。

实际上,存在状态数达到上限的情况:

因为SAM中所有结点是这个串所有的等价类,所以自然等价类的级别也是的。

SAM的转移数是

我们构造以空串代表的状态为根的最长路径生成树,那么这个生成树上的转移一定是连续的转移(否则一定找得出一条更长的路径来代替树上不连续的转移)。我们称在最长路径生成树上的转移为树边,其他的转移为非树边。因此有且仅有一条路径可以通过树边从根走到任意结点。

我们考虑每一条非树边(边权为),我们可以用字符串来表示从空串代表的状态到状态的最长路径(那么一定在最长路径生成树上),的边权,状态到终止状态的最长路径。那么,如果非树边的不同,其他的相同,那么在最长路径生成树上的一定不同;如果转移的不同,相同,那么一定不相同(由DAWG边的定义可得);如果不相同,那么这条路径也一定不相同。因此,我们可以发现每一条非树边都只能映射到一条这样的路径,即非树边和这些路径是单射的。

得到了非树边和路径是单射的后,我们考虑这些路径的含义:因为这些路径都是从空串代表的状态到终止状态的路径,因此每一条路径都代表字符串的一个后缀,且完整串代表的后缀一定不在这些路径上(因为完整串代表的路径上的转移一定都是连续的,而由SAM的性质“初始状态到某一状态连续转移组成的路径一定只有一条”得完整串代表的路径一定在最长路径生成树上),所以非树边的个数为真后缀(即非完整串的后缀)代表的路径的个数,因此非树边最多有条。

最长路径生成树的边数(即转移数)为点数减一,点数最多为,因此树边总数最多为。加上条非树边,SAM的转移数最多为,为的。

实际上,SAM的转移数无法到达上界,因为如果状态数达到了上界,那么串一定是的形式,而它的转移数却无法到达(如果状态数无法到达上界,那么转移数也无法到达上界)。所以SAM的转移数真正的上界是,且存在情况使转移数到达上界:

构建SAM的时间复杂度是

我们看SAM的构建代码:

  1. int extend(int last,int x){
  2. int now=++tot,pos=last,tmp,cln;
  3. len[now]=len[last]+1;
  4. while(pos!=0&&nxt[pos][x]==0)
  5. nxt[pos][x]=now,pos=link[pos];
  6. if(pos==0){
  7. link[now]=1;
  8. return now;
  9. }
  10. tmp=nxt[pos][x];
  11. if(len[tmp]==len[pos]+1){
  12. link[now]=tmp;
  13. return now;
  14. }
  15. cln=++tot;
  16. for(int i=1;i<=26;i++)
  17. nxt[cln][i]=nxt[tmp][i];
  18. len[cln]=len[pos]+1;
  19. link[cln]=link[tmp],link[tmp]=link[now]=cln;
  20. while(pos!=0&&nxt[pos][x]==tmp)
  21. nxt[pos][x]=cln,pos=link[pos];
  22. return now;
  23. }

首先,里面非循环的复杂度是,而的次数是次,所以非循环的总复杂度是的。

然后,我们看向个循环:

故,我们证明了一个很美妙的性质——SAM的时间复杂度是的!

SAM的复杂度优化

上面,我们证明了SAM的复杂度是的,但是,如果字符集过大,那么字符集大小也会对复杂度进行影响,我们需要更优的复杂度!

代码很简单(离散化不给出):

  1. map<int,int>nxt[maxn];
  2. int extend(int last,int x){
  3. int now=++tot,pos=last,tmp,cln;
  4. len[now]=len[last]+1;
  5. while(pos!=0&&nxt[pos][x]==0)
  6. nxt[pos][x]=now,pos=link[pos];
  7. if(pos==0){
  8. link[now]=1;
  9. return now;
  10. }
  11. tmp=nxt[pos][x];
  12. if(len[tmp]==len[pos]+1){
  13. link[now]=tmp;
  14. return now;
  15. }
  16. cln=++tot;
  17. nxt[cln]=nxt[tmp];
  18. len[cln]=len[pos]+1;
  19. link[cln]=link[tmp],link[tmp]=link[now]=cln;
  20. while(pos!=0&&nxt[pos][x]==tmp)
  21. nxt[pos][x]=cln,pos=link[pos];
  22. return now;
  23. }

SAM的应用

下一篇:后缀自动机(SAM)应用与做题记录

其他

参考文献:

好东西:SAM生成器

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注