首页 > 代码库 > 康复计划#1 再探后缀自动机&后缀树
康复计划#1 再探后缀自动机&后缀树
本篇口胡写给我自己这样的东西都忘光的残废选手 以及那些刚学SAM,看了其他的一些东西并且没有完全懵逼的人
(初学者还是先去看有图的教程吧,虽然我的口胡没那么好懂,但是我觉得一些细节还是讲清楚了的)
大概是重复一些有用的想法和性质,用以加深印象吧…如果可以的话希望也能理解得更透彻一点…
1、如何设计出一个后缀自动机?
现在用的SAM并不是本来就在那里的,要比较深入地理解,就不能只从验证它对不对的角度考虑,而要考虑为什么它是这个样子。
要一个能够接受后缀的有限状态机,并不用像现在的SAM那样弄,比如暴力建后缀Trie就可以做成一个满足要求的有限状态机…
但是这不符合实际的需求,因为状态数和转移数都达到了$O(n^2)$
所以考虑压缩一下…这里就有多种压缩的方向,从观察到后缀Trie上有大量冗长的没有分叉的链入手,把一段路径压缩,得到的就是后缀树(不过它已经不是我们要的自动机了)。
另一个角度就是重新考虑一种构造法,放弃树的形态,使一个状态可以从多个输入串到达,为状态的减少提供了可能。
当然,在分析之前,能做到什么程度是不知道的。现在我们开始分析。
不考虑可行性,我们希望尽量少的状态和转移,当然是希望只有1个后缀状态了,但是当然不可行的。
因为是每次添加一个字母进行转移,转移的过程中必须要在某个状态上,所以任意一个后缀的所有前缀也都必须存在转移,即所有子串必须存在转移。所以如果对所有子串建一个状态,就能得到一个状态数是$n^2$级的自动机了,这没有什么改善,还需要继续分析。
一个Simple的想法是:发现有明显的可以合并的状态,对于一些后缀,从它们共同的尾巴上的某位置切开,得到一些它们的前缀,这些子串被我们分配了不同的状态,但是从它们到终态的转移是完全一样的,感觉可以合并成一个状态。于是就这么干吧,合并一下,哇,刚好只剩n个点了…但是定睛一看,真可惜,转移已经不是唯一的了,现在的情况变成了写开这个字符串,从初始状态能跳到任意一个位置(这里一个字符的转移就无法保证唯一了),然后接下去一步步转移…所以还是失败了。(直接合并后继相同的状态,会让从初始状态到达这个状态的转移途径被往前推,最终转移集中在初始点上,导致一个字符不得不转移到多个后继)
但是明明感觉前面的分析没有问题,怎么就跪了呢…其实是因为我们合并的起点是$n*\left (n-1\right )$个状态。这意味着什么?这就是说可能在一开始,一个本质相同的子串因为出现的位置不同,被我们并入了不同的状态,于是如果输入串就是这个子串,那么就会同时达到不同的状态。而且我们也不能这样做完再合并本质相同的子串,因为后继状态会乱。
于是我们知道,只能一开始就把这个问题先解决,再考虑优化状态数和转移数。所以我们第一步先合并本质相同的子串。
接下来,再回顾我们一开始的想法,把后继相同的合并。不过这个时候有一点不同,一个子串对应的状态现在代表原串中不同位置出现的很多个它,它到终态的转移是所有这些出现位置的右端点后面的后缀。这些右端点也就是后缀自动机概念中的Right集啦。我们把Right集相同的状态合并起来,也就是把后继相同的状态合并。现在我们不用担心上面出现的不唯一的问题,因为所有输入串如果是一个子串,那么它能到达的只有一开始我们合并起来的那个状态。好的~这下也合法了,想做的优化也做了~那么就成功了吧…(等一下QwQ 现在和之前不一样了,现在的状态数和转移数有多少都不知道呢)
2、后缀自动机的状态数和转移数的线性性(参考cls当年的ppt)
有了刚刚所说的Right集的定义,可以知道一些显然的性质…比如,给定一个串的Right集和串长,我们就能知道它是什么…(感觉好像废话…给了右端点和长度还能不知道么…)其实重要的是下面这句:
一个Right集对应的串长是一个区间。
显然如果长度$l$和$r$可以对应这个Right集,比$l$长的长度不会因为出现位置增加而变得不对应,比$r$短的长度不会因为出现位置减少而变得不对应,所以区间$\left [l,r\right ]$内的长度都合适。
接下来还有一个性质。不妨把状态s的Right集对应才串长记作$\left [Min(s),Max(s)\right ]$,如果两个状态a、b的Right集相交于共同的右端点r,那么他们的长度区间不能相交,不然他们就会同时对应右端点为r的某些子串了,所以我们可以交换a、b使得$Max(a) < Min(b)$,那么每次状态b对应的任何子串出现的时候,状态a对应的任何子串都一定出现了,所以$Right(a)$包含$Right(b)$,又因为相同Right集对应的是相同的状态,所以$Right(a)$真包含$Right(b)$。这就是说:
不同的状态的Right集只能是不相交或者真包含关系。
这种性质很容易让人想到树对不对,按照真包含关系做成树的话,大于一个元素的集合必定有大于等于两个儿子(保证了不会出现无用的链),只有一个元素的n个集合就是叶节点,所以总节点数小于等于2n-1。喵呀,这样就说明状态数是线性的了。还差一点点,那就是转移数也是线性的。下面就来证明:
取自动机的一个有向的生成树,使得初始状态出发可以到达所有状态。对于一条不在树上的边,我们可以在树上从初始状态走到它的起点,然后经过它并走到终态,走的这条路径对应的一定是原串的一个后缀。所以每条非树边一定可以对应某些后缀,而不同的两条非树边对应的后缀不可能有交,因为那样的话两条非树边同时成为了“路径上第一条非树边”,所以非树边数小于等于n。算上生成树上的边,我们得到:转移数小于等于2n-1。
呼 完成啦…
3、同时构造后缀自动机和后缀树
弄了那么久,自动机有什么用啊…很多字符串的问题又不是判断是不是后缀就完事了的…SAM的图论性质只是一个DAG而已,只能做做DP而已,维护什么东西很困难呀…
既然树形结构可以维护的东西很多,不妨把树用上吧…你说哪里有树?刚刚证明状态数线性的时候不就有一棵吗?而且这颗树的性质还是挺好的…建SAM的过程中就会用到它。
把这棵树上的父亲称为pre,那么有$Min(s)-1=Max(pre_s)$,因为不断删左端的字符导致Right集增加的临界点就是$Min(s)-1$。同样,$Right(pre_s)$显然就是最小的刚好真包含$Right(s)$的Right集。
这棵树叫做pre树或者parent树什么的…通过开始分析状态线性性的时候发现的那些性质,我们可以弄更多东西,比如说,一个子串的全部出现位置的相关信息…只要自底向上跑一次pre树就好了,要求出现位置的第k大什么的话都行,只要离线一下跑个线段树合并…不离线也行,跑出pre树的DFS序建主席树也可以求…
如果要构造一个SAM,可以使用增量法,在已经建好的SAM上扩展一个字符x使之成为新串的SAM。
首先 我们知道有一件事是必做的 那就是新建一个节点,接在原来的最后一个状态(把整个串作为输入到达的状态)后面,然后按照pre的性质,如果这个状态没有字符x的转移,显然可以直接加上到新点的x转移,然后再跳到它的pre继续这个操作。最后有两种情况,如果跳到了初始状态,那么新节点的pre就应该是初始状态了,通过上面的性质可以说明。
另一种情况就是跳到某个点p已经有了x转移指向q(再往上跳也一定有x转移了),这时如果$Max(p)+1=Max(q)$的话是没有问题的,也容易知道新点的pre应该是q;
但是如果$Max(p)+1<Max(q)$就有事情了,因为x加在$Right(p)$中的最长后缀后面得到的长度是$Max(p)+1$,而如果$Right(q)$里加入新末尾的话,大于$Max(p)+1$的长度的那些串将不再合法,所以不能把pre设成q…怎么办呢?可以以这个出事的长度为分界点,把$Right(q)$对应的长度区间拆开,变成两个长度区间,其中最大值小的一段是$\left [Min(q),Max(p)+1\right ]$,这两段区间对应的是两个Right集,也就是说会变成两个点,由前面的性质可以知道两个区间中最大值小的那个区间会成为最大值大的区间对应的点的pre,同时它们原来的转移是一致的(因为我们没有修改q的转移),现在转化为了$Max(p)+1=Max(q‘)$的情况,把新点的pre设成分裂出来的q‘就好了。
于是构造就这么轻易的完成啦…但是我觉得真正有趣的地方还是在于它另一个非常喵的性质:
串str的后缀自动机的pre树就是串str的逆的后缀树
真是无比奇喵呀,是为什么呢?观察到我们在pre树上往上跳的过程,其实就是把长度区间不断缩短,而本来已经存在的右端点接下来还是存在,所以其实就是把左端点往右缩,而且因为叶节点的Right集有1到n的每个元素,不断跳pre的过程中左端点是从1一直往右跳,直到跳过右端点时达到初始节点。所以其实这就是反串的后缀树,路径压缩也都压好了,边上的字符数也显然就是$Max(p)-Min(p)+1=Max(p)-Max(pre_p)$…所以我们就有了一个线性构造后缀树的简单方法~
倒着从后往前依次插入原串的字符,这个过程等价于把串反过来,所以得到的就是原串的后缀树,不过还差一点,就是,后缀树需要得到边上的第一个字符…怎么弄呢?这种后缀树和后缀自动机的关系提供给了我们一个新的思路。比如说,原来我们做的分裂节点,如果用后缀树的观点看,就是在新加入一个后缀的过程中,需要在一条已经被压缩的路径上分叉,所以要新建一个点,把分叉的路径接上去(观察刚刚我们对pre做的操作,动手画一画就能够发现)。这种理解其实比后缀自动机的观点容易理解多了~用这种观点,我们可以非常简单地求出后缀树边上的第一个字符。
倒着构造后缀自动机的过程中,对与后缀树做的操作是添加一条经过压缩的路径,代表原串的一个后缀。所以我们可以用$End(p)$表示后缀树的根到p这个点的路径对应的子串的结尾在原串的哪里。每次新加的点都是压缩过的原串后缀,所以它们的End都是n,而如果发生了分叉(后缀自动机上的分裂),那么因为边长是可以简单地求得的,所以我们可以计算出分叉点的End值。写出来的话就是:如果q分裂成$q_f$和$q_s$,且$q_f$是pre的话,$End(q_s)$不变,因为它不是分叉的那个,而$End(q_f)=End(q)-(Max(q)-Max(q_f))$。现在我们就可以通过End算出每条边的第一个字符,$pre_p$到$p$的边上的第一个字符就是原串的第$End(p)-(Max(p)-Max(pre_p))+1$个字符。
好的…现在就神奇地完成了后缀自动机和后缀树的线性构造…比Ukk容易多了吧(因为我不会Ukk嘛)
应用的话…现在已经变成后缀树了,比起正着建后缀自动机的pre树,它多了字典序功能(好像很多因为树结构带来的好处pre树也有)…以及DFS一下就可以建出后缀数组…嗯,还是比较强势的…
大概我会的东西就那么多啦…Trie上的广义后缀自动机的论文并没有好好啃完…只是会写而已(不会证就来用是不是耍流氓233)…有机会新开一篇好了…
康复计划#1 再探后缀自动机&后缀树