后缀自动机全家桶(普通后缀、广义后缀、子序列)

参考文献

咕咕日报上的,就没有一个是差品:https://www.luogu.org/blog/Kesdiael3/hou-zhui-zi-dong-ji-yang-xie,同时,带luogu水印的图也是一律采用这个博客的,因为我太弱了,不会画图QAQ,对于优质的内容也会直接copy借鉴

时间复杂度的证明https://blog.csdn.net/qq_35649707/article/details/66473069

广义后缀数组就是在这学的:https://blog.csdn.net/litble/article/details/78997914

字符集的相关内容:https://oi.men.ci/suffix-automaton-notes/

套B-树:https://www.cnblogs.com/hyghb/p/8445112.html

stO 后缀自动机怎么能少了陈立杰大佬的论文了呢? Orz

博主是真的臭不要脸,拿着别人的图写着自己的博客

后缀自动机是什么神仙玩意?

其实许多题目,我们都可以用后缀数组做,但是后缀数组有时候远远不能满足我们的需求,这个时候,后缀自动机就出现了。

例题

问题描述 

给出一个字符串S(S<=250000),令F(x)表示S的所有长度为x的子串中,出现次数的最大值。求F(1)..F(Lengh(S)); 

样例输入 

ababa 

样例输出 

3
2
2
1
1 

right集合、等价类以及Parent树

定义

首先,我们要定义一个东西,叫\(right\),字符串的每个子串都可以有个\(right\)

\(right(s)\)表示的是\(s\)子串在母串所有出现位置的右端点。

打个比方:在\(ababa\)中,\(right(ab)={2,4}\)

等价类性质

1

假如有两个不相同的子串的\(right\)集合是相等的,那么必然一个是另外一个的后缀。

这个挺好证的,感性理解最好,画个图就知道了。

我们把所有相同的\(right\)集合叫做一个等价类。

如图,\(ab,b\)就是在一个等价类里面。

2

两个\(right\)集合,要么两个集合没有相同的数字,要么一个集合是另外一个集合的子集。

这个也挺好证的,用反证法,如果\(right\)集合中有一个相等的,那么我们就可以进而推出一个是另外一个的后缀(自己想想看是不是),那么在想想看是不是一个子串的\(right\)是不是就是那个作为后缀子串的\(right\)的子集。

3

对于每个\(endpos\)(等价类,其实\(endpos\)到后面都成了一个点了)而言,里面所包含的子串的长度应当是连续的。

假设子串的长度是不连续的,那么设这个不在里面的子串是\(s\),他的后缀这个等价类里面,这个\(right(s)\)数字的个数小于等于\(endpos\)里面后缀\(right\)的数字个数才可以,但是以他为后缀的子串\(s'\)也在里面,所以其的\(right\)数量≥\(s'\),所以这个\(s\)应当在等价类里面。

4

等价类点的个数的数量级是\(O(n)\)的。

我们可以发现,在一个\(endpos\)里面最长的子串前面加一个字符,就是另外一个\(endpos\)里面的,而这个\(endpos\)里面的\(right\)集合应该是原来\(endpos\)里面的\(right\)集合的子集,因为这个新子串要出现肯定是要这个旧子串的基础上还有个字符相等才可以的。

而我们在旧子串前面添加另外一个不一样的字符,得到的\(endpos\)\(right\)和原来的新子串\(endpos\)\(right\)是不相交的,怎么可能有个位置使得两个长度相同但不一样的子串都出现呢?

所以我们可以知道,一个等价类的\(right\)集合通过正确的分割,可以分割成几个其他\(endpos\)\(right\)集合。

那么从\({1,2,3,...,n}\),开始分割,最多也只能像线段树那样分(为什么,倒着想,两个叶子结点就可以产生一个父亲节点,这是最多的合并方案了),也是最多就\(O(n)\)个罢了,所以节点数量最多\(O(n)\)

而这个巧妙的分裂方案又呈现出一个树的样子,那么我们就给这棵树起个文雅点的名字:\(Parent Tree\)

同时一个字符串只有唯一的\(Parent\)树,因为等价类的划分只有一个。

LOOK一下\(aababa\)\(Parent\)
在这里插入图片描述
点里面的就是\(right\)集合,什么?\(1\)的点为什么不见了?因为\(right\)\(1\)的子串为\(a\),很不巧的是刚好被\(1,2,4,6\)给包含了进去,但是并不会影响我们,只要以后想题的时候注意一下就行了。

5

我们设等价类里面最长的子串长度是\(len\),最短的是\(minlen\),那么\(len(fa(x))+1=minlen(x)\),其实这个也挺好证明的,儿子最短的子串就是自己最长的子串加一个字符吗,对吧。

Trie?DAG?

我们都知道,有个强大的Trie,我们可以把一个字符串的每个后缀插入进去,然后就成了这样(aabab):
大佬的图
他可以支持查询任意一个子串,子串个数等等操作。

为什么?

因为他有以下的性质:

  1. 走过一条边表示在字符串后面添加字符。
  2. 从源点到任意终点形成的字符串为母串的子串。
  3. 从源点走到任意节点形成的是子串,同时也只能是子串。
  4. 任意两条不重叠路径(允许相交或部分重合)形成的子串不相同。

但是点的个数是\(O(n^2)\)的,那么我们要如何处理这个问题呢。

那么我们就得找到一个符合这个性质的一个好东西。

而且我们也发现上图中的\(abab\)中的\(bab\)可以和其他的\(bab\)结合的。

也许我们可以找到一个在\(DAG\)上的一种算法,而不是一棵树。(不能存在环,因为环的话就说明字符串是无限长的)

(当然这个新的算法就是后缀自动机,同时因为他有更多的性质,使得他更加的强大)

当然,特别巧的是,后缀自动机可以直接利用\(Parents\)树上面的构造出来,而在构造的时候,我们也是用两种理论互相完善形成的,接下来的讲解便会从如何在\(Parents\)树上构造后缀自动机出发。

下图就是一个例子:

自动机?

自动机的基本性质

首先,自动机一般有这么五个东西:初始状态,状态集合,结束状态,转移函数,以及字符集。

字符集我们是知道。

初始状态和结束状态呢?初始状态我们设为空串,也就是\(right\)集合\({1,2,3,4...,n}\),而结束状态则是包含母串后缀的\(endpos\)(在\(Parents\)树中是一条链,即下文橙色的点),也就是\(Parent\)树中\(right\)集合为\(n\)的点以及他的父亲与祖先,除了根节点。

状态集合则是所有点的集合。

而转移呢,从一个节点\(x\)到另外一个节点\(y\)连一条为\(c\)的边出来,表示的是\(x\)包含的所有子串在后面加上\(c\)后都是\(y\)中所包含的子串(但\(y\)中的子串去掉最后一个字符不一定是\(x\)中的子串,只有可能是\(x\)儿子所包含的子串)。

那么一个成型的后缀自动机大概长这样:

橙点是结束状态,每个点旁边的红色字符串表示的是最长的子串。

一定存在后缀自动机吗?

我们回想起来后缀自动机必须遵循的几个性质:

  1. 走过一条边表示在字符串后面添加字符。
  2. 从源点到任意终点形成的字符串为母串的子串。
  3. 从源点走到任意节点形成的是母串的子串,同时也只能是母串的子串。
  4. 任意两条不重叠(即不是完全相同)的路径(不一定要到终点)形成的子串不相同。

也就是说我们必须在构造的时候定义一些构造规则来使这些性质成立。

我们可以发现每个\(endpos\)所含的子串都是不同的,同时所有\(endpos\)所含的子串的并集就是母串的所有子串的集合,不妨假定在后缀自动机中到达这个点所形成的子串只能是这个点所包含的子串。

但是这样一定满足\(4\)性质吗?如果存在两条路径\(A,B\),在走到\(x\)之前都是不同的,走到\(x\)相同了,那么设路径去掉\(x\)后路径为\(A',B'\),说明\(A'\)\(B'\)在字符串后面添加了一个字符后相同了,即\(A',B'\)原本就是相同的,那么这与定义矛盾,证毕。(除非\(A'=B'=Ø\),此时路径是重合的,不会影响证明)

后缀自动机是唯一的吗?

我不会证明绝对唯一,只能证在\(Parent\)树上的后缀自动机是绝对唯一的,首先\(Parent\)树是唯一的,而能指向一个\(endpos\)\(endpos\)也是固定的,所以后缀自动机是唯一的。(好草率呀)

后缀自动机的几个性质

这里总结一下性质,方便下个证明(Trie的性质就不搬了,反正后缀自动机也是满足的),也方便做题:

  1. 到达一个点的话,形成的字符串必须是这个点所包含的子串。(后缀自动机必须满足的性质)
  2. 一个点可能被多个点指到。
  3. 到达\(x\)所形成的的字符串一定都比\(fa(x)\)的长,这个可以用性质\(1\)证。
  4. 通过上述的结论,我们可以发现后缀自动机是个DAG(有向无环图)。

边的数量级

后缀自动机边的数量级是\(O(n)\)的,证明将直接采用你谷日报的。

考虑对于一个后缀自动机:先求出其任意一个生成树,舍弃其其它边。我们知道,后缀自动机上有若干个终止节点。于是我们便从每个终止节点往回跑所有属于它(这个类)的子串(从终止节点跑其实就是跑原串的后缀)。注意这里的往回跑是指:对于一个子串,按照原本后缀自动机到达其的唯一路径经过的边往回跑,而非只考虑边的字符,因为可能有多条代表相同字符的边指向同一个节点。

对于每个终止节点:我们按一定顺序跑遍属于它的子串。如果能顺利跑回源点,则跑下一个子串。否则,连上本应该跑回的边,沿它跑回下一个节点。此时,从这个节点出发,一定有一条或多条路径(经过现存的边)回到源点(因为有树的基础结构),则我们往回跑其中任意一条路径。这样,实际走的路径形成的字符串不一定是原本希望跑的子串,但是因为加了一条新边,且路径是从同样的终止节点开始的,所以得到的字符串必然属于此类,且未被跑过。我们只需要将这个字符串在将来要跑的子串中划掉即可。之后重跑我们原本希望跑的子串,直到真正顺利跑完这个子串,再按顺序跑下一个子串。可以发现,我们在此过程中增加的边数不会超过此节点 \(endpos\)的大小。

这样,当跑完所有终止节点时,在原本的生成树上增加的边不会超过后缀的个数,即 \(n\)个。而此时,增加了边的后缀自动机已经完整了。于是,生成树的边数加 \(n\),数量级为 \(O(n)\)。(这里与clj的ppt上的证明方法是刚好相反的,clj的证明是从源点开始向各个终止节点跑后缀,而非从终止节点往回跑到源点。)

具体例子可以去看你谷日报的搬运比较麻烦,你谷日报太详细了

当然,这里补充一下,为什么只要从终止节点跑就行了,即为什么能跑到每个后缀,后缀自动机就是完整的了,我们假设子串\(A\)跑不到,那么\(A\)后面加任意一个字符也肯定跑不到,最终对应到一个后缀跑不到,但是所有后缀都跑得到,矛盾,证毕。

几个小性质

性质中说的“指向”指的是自动机中的,“父亲,儿子”指的是树的,“点”则是\(endpos\)

  1. 有边\(x->y\)且边权为\(c\)(表示走过这条边字符串加入字符\(c\)),若\(x,y\)\(lca\)不是根节点,则说明\(x\)的某个后缀和\(y\)的某个后缀相等,记为\(A=B\),而且\(B\)\(A+c\),所以不难发现的事情是\(A,B\)的循环节为\(c\)
  2. 不可能有一个点\(x\)指向\(y\)\(y\)的父亲(祖先也是,当然,这里说的祖先和父亲不能是\(x,y\)的共同祖先),一定存在\(x\)的一个祖先指向\(y\)的父亲,且如果\(x\)的父亲不指向\(y\)的父亲,则指向\(y\)
  3. 如果\(x\)指向\(y\),把\(y\)\(right\)集合中的元素全部减\(1\),便是\(x\)的子集,\(x\)不指向\(z\)\(z\)也满足\(right\)集合中的元素全部减\(1\),是\(x\)的子集,则\(x\)指向\(z\)的祖先。
  4. 如果\(x\)有一条边指向\(y\)且边权为\(c\),那么\(x\)的祖先也一定有边权为\(c\)的边指向\(y\)的祖先(除了\(x,y\)的共同祖先),而且,\(x,y\)\(lca\)一定有一条边权为\(c\)的边指向\(y\)方向上的儿子。
  5. 如果\(x\)\(right\)集合是多个\(endpos\)\(right\)集合的子集,那么其父亲为\(len\)最大的\(endpos\)
  6. 所有指向\(x\)\(endpos\)集合的并集等于\(x\)\(endpos\)集合。

构造方法

在学习构造之前,建议反复咀嚼\(Parents\)树的性质以及后缀自动机,不然后面可能学起来比较吃力。

代码

终于到了最后的时刻,通过看大佬的博客,我发现代码放前面更容易帮助人理解构造的过程。

所以先放上代码。

struct  node
{
    int  a[30],len,fa;
}tr[N];int  tot=1,last=1;
void  add(int  c)
{
    int  p=last;int  np=last=++tot;
    tr[np].len=tr[p].len+1;
    for(;p  &&  !tr[p].a[c];p=tr[p].fa)tr[p].a[c]=np;
    if(!p)tr[np].fa=1;
    else
    {
        int  q=tr[p].a[c];
        if(tr[q].len==tr[p].len+1)tr[np].fa=q;
        else
        {
            int  nq=++tot;
            tr[nq].fa=tr[q].fa;tr[q].fa=nq;
            tr[nq].len=tr[p].len+1;memcpy(tr[nq].a,tr[q].a,sizeof(tr[q].a));
            tr[np].fa=nq;
            for(;p  &&  tr[p].a[c]==q;p=tr[p].fa)tr[p].a[c]=nq;
        }
    }
}

后缀自动机的构建是在线的,其构造过程是这样的:现在已经构造完了\(A\)字符串,然后接下来加入字符\(c\),构造\(A+c\)的后缀自动机,然后这样一个一个字符加入来构造母串的后缀自动机。

我们跟着KesdiaelKen大佬一起,分成一个个部分来进行传教讲课。

部分1

int  p=last;int  np=last=++tot;
tr[np].len=tr[p].len+1;

\(last\)点是什么,假设现在已经插入的字符串\(A\)\(last\)就是表示\(endpos\)集合中包含\(A\)的点,而因为加入新的字符\(c\),导致\(A\)变成了\(A+c\),而\(np\)就是新的\(last\)\(len\)则是表示这个点的\(endpos\)的所包含的最长字符串的长度,不难发现新点\(np\)\(len\)就是\(last\)\(len\)加一。

部分2

for(;p  &&  !tr[p].a[c];p=tr[p].fa/*fa表示的是Parent树中的*/)tr[p].a[c]=np;

我们通过跳\(p\)的父亲,使得每一个原本的终止节点都有一条指向\(np\)的边,也就是使所有原本的后缀都可以跳到现在的后缀集合。

\(!tr[p].a[c]\)这句话是什么意思呢?如果有个终止节点也有一条边是指向\(c\)的,可以说明的是这个终止节点包含的后缀在原串中出现了至少两次,否则在旧串中一个后缀,是不可能再在后面加点的,而且也说明了,新串的后缀有一部分已经在原串出现过了,就要进行一些新的判断了。(当然,不难发现的是,如果\(p\)\(a[c]\)不为空,那么\(p\)的祖先的\(a[c]\)也不为空)

部分3

if(!p)tr[np].fa=1;
else

很简单,说明原串从未有\(c\)这个数字出现(有的话\(1\)号点会指过去),所以这个新串的所有后缀都在一个\(endpos\)里面,也就是说新串只有一个终止节点。

部分4

int  q=tr[p].a[c];
if(tr[q].len==tr[p].len+1)tr[np].fa=q;

首先,我们知道\(p\)所代表的子串都是旧串的后缀,如果\(q\)\(len\)\(p\)\(+1\),那么说明\(q\)所代表的的子串都是旧串的后缀\(+c\),自然\(np\)就可以认他做父亲。

这是你又会问了,但是\(q\)只能代表\(np\)的一些后缀呀,比如新串是\(dbcabcabc\),然后假设\(q\)表示的是\({bc}\)这个后缀,但是他有个儿子(\(Parent\)树的儿子)表示的又是\({abc}\),那么根据后缀自动机\(np\)不应该认这个儿子吗?

设这个儿子为\(q'\),我们知道后缀自动机的话层数越深,所代表的子串长度越长,那么指向\(q'\)的一个后缀应该是\(ab\),那么理论上将找到\(ab\)会比找到\(b\)更先,所以认的父亲原本就是\(len\)最长的。

但是你认完了以后也没有继续去上面更新又是怎么一回事,因为\(p\)的祖先一定也有指向\(q\)祖先的边权为\(c\)的点(要么就是这个祖先本身就是新串的后缀)。

当然,跳进了那个\(else\)的话,就代表了新串也不只有一个终点了。

部分5

int  nq=++tot;
tr[nq].fa=tr[q].fa;tr[q].fa=nq;
tr[nq].len=tr[p].len+1;memcpy(tr[nq].a,tr[q].a,sizeof(tr[q].a));
tr[np].fa=nq;
for(;p  &&  tr[p].a[c]==q;p=tr[p].fa)tr[p].a[c]=nq;

那么如果\(tr[q].len≠tr[p].len+1\),我们知道,这个时候只有\(tr[q].len>tr[p].len+1\)的情况了。

但是同时这个长的串也不是新串后缀,为什么?因为如果是的话,去掉最后一个字母比\(tr[p].len\)还大,所以应该先被跳到才对,为什么现在还没有被跳到呢?因为这个长的串由最后\(tr[p].len+1\)个字母组成的后缀是新串后缀,但是这个长的串不是。

那么我们就涉及到了一个问题了,这个点现在出现了锅了,我们需要把他拆成两个点了,因此申请了一个\(nq\),然后\(nq\)表示的就是这个点的\(tr[p].len+1\)的后缀以及更短的后缀,因为这些子串在后面又出现了一次,而其余的并没有再出现一次,则归入原本的\(q\)中,所以\(q\)\(right\)\(nq\)的子集,理所应当成为了他的儿子。而根据分割要求,所有\(nq\)\(right\)还有个数字没有分出去(其实有没分出去的情况,但是如果你理解的话会发现这里并不是这种情况),这个数字就是\(tr[np].len\),我们直接把\(np\)的父亲认到\(nq\)身上就行了,而且不难发现\(nq\)是原串中最长的新串后缀。

同时虽然分开了,但是能在后面添加的数字还是可以添加的,于是我们可以把\(q\)\(a\)数组直接拷贝到\(nq\)上面去。

不对,还有个问题,\(p\)以及\(p\)的父亲祖先的\(c\)都指向了\(q\),那是因为\(q\)以前包含了长度为\(tr[p].len+1\)的后缀,但是现在没有了,跑到\(nq\)上去了,所以我们自然需要用到for然后去更新一下父代。

被包含情况

上文我们有提到\(1\)的叶子结点被包括在了其他节点里面(当然,也不是不存在除\(1\)以外的被包含再其他节点的情况,只要前缀多次出现就会被包含,因此一个节点最多包含一个叶子节点)。

而对于这些情况的处理,也需要谨慎一点,下文的思路中便提及到一种处理方法。

思路

而例题,则十分的明显,就是叫我们把每个点的\(right\)集合处理出来,然后拿集合大小去更新\(ans[tr[i].len]\),然后再把\(ans\)从上到下更新一遍。

我也忘记了我到底想到了一个什么SB的思路,反正忘记了,就不管了吧。

至于\(right\)集合的处理,就是每次在创\(np\)时,对\(np\)\(right\)集合的个数++,然后对于每个点,加上其的儿子的\(right\)集合大小即可。

后缀自动机的叶子节点,我更喜欢定义为包含母串前缀的节点。事实上,叶子节点一定包含前缀,但包含前缀的一定不一定是叶子节点,即上文所说的被包含情况,假设\(i\)被包含在\(x\),那么说明没有叶子节点的\(right\)\(i\),这在我们统计\(right\)时是十分致命的,直接规定说叶子节点\(right\)集合的元素数量为\(1\),非叶子节点等于儿子相加,那么\(right\)集合中包含\(i\)的便会少一。因此,如果扩展一下叶子节点的定义,然后规定所有节点还要再加上儿子的\(right\)的元素数量,这个问题就迎刃而解了。

代码

#include<cstdio>
#include<cstring>
#define  N  510000
using  namespace  std;
struct  node
{
    int  a[30],len,fa;
}tr[N];int  tot=1,last=1;
char  st[N];int  n;
int  dp[N],r[N];
void  add(int  c)
{
    int  p=last;int  np=last=++tot;r[np]++;
    tr[np].len=tr[p].len+1;
    for(;p  &&  !tr[p].a[c];p=tr[p].fa)tr[p].a[c]=np;
    if(!p)tr[np].fa=1;
    else
    {
        int  q=tr[p].a[c];
        if(tr[q].len==tr[p].len+1)tr[np].fa=q;
        else
        {
            int  nq=++tot;
            tr[nq].fa=tr[q].fa;tr[q].fa=nq;
            tr[nq].len=tr[p].len+1;memcpy(tr[nq].a,tr[q].a,sizeof(tr[q].a));
            tr[np].fa=nq;
            for(;p  &&  tr[p].a[c]==q;p=tr[p].fa)tr[p].a[c]=nq;
        }
    }
}//后缀自动机
inline  int  mymax(int  x,int  y){return  x>y?x:y;}
inline  int  mymin(int  x,int  y){return  x<y?x:y;}
int  cs[N],sa[N];
int  main()
{
    scanf("%s",st+1);n=strlen(st+1);
    for(int  i=1;i<=n;i++)add(st[i]-'a');
    for(int  i=2;i<=tot;i++)cs[tr[i].len]++;
    for(int  i=1;i<=n;i++)cs[i]+=cs[i-1];
    for(int  i=2;i<=tot;i++)sa[cs[tr[i].len]--]=i;
    int  p=1;
    for(int  i=tot;i>=1;i--)r[tr[sa[i]].fa]+=r[sa[i]];//以上按深度排序的部分其实是可以直接一发DFS暴力解决的,但是这样打也可以。
    for(int  i=2;i<=tot;i++)dp[tr[i].len]=mymax(dp[tr[i].len],r[i]);
    for(int  i=1;i<=n;i++)printf("%d\n",dp[i]);
    return  0;
}

时间复杂度

你问我时间复杂度?我们可以发现能影响时间复杂度的就这两句话:

for(;p  &&  !tr[p].a[c];p=tr[p].fa)tr[p].a[c]=np;

for(;p  &&  tr[p].a[c]==q;p=tr[p].fa)tr[p].a[c]=nq;

第一个循环因为是加边,所以不会总体不会大于\(O(n)\)

更严谨的证明,跳\(for\)循环\(last\)的层数会减,而各种操作都只会让层数\(+1\)或者\(+2\),所以第一个循环是\(O(n)\)


第二个循环,设\(short(x)\)表示的是\(x\)这个点的最短子串的长度,\(fa(x)\)\(tr[x].fa\),那么我们考虑一下不同的操作对于\(short(fa(last))\)会有怎样的变化。(下文中\(last\)一般指插入前的,不然我直接用\(np\)就行了)

我们知道原循环有两个\(if\),两个\(else\),三种情况。

如果跳到第一种情况,那么\(short(fa(last))\)就会等于0。

如果在第二种情况,我们考虑一下\(p\)在哪,在加字符前,我们会发现\(short(p)<=short(fa(last))\)(为什么会相等,因为\(p\)有可能就是\(fa(last)\),为什么不可能大于,因为\(last\)是最新加入的点,不会有任何儿子),然后跳到了\(q\),因为只加了一个字符,所以\(short(q)<=short(p)+1\),所以我们可以知道\(short(q)<=short(fa(last))+1\),那么在第二种情况下,我们的\(short(fa(last))\)\(+1\)或者更小。

第三种情况下,我们同样可以知道\(short(q)<=short(p)+1\),然后把\(q\)拆成了\(q\)\(nq\),同时我们不是要往上跳吗,,我们知道这个\(for\)循环的重定向是把原来指向\(q\)的指向与\(nq\)了。

而我们的for会跳几层几层呢?我们知道父亲的\(short\)肯定是比儿子要少\(1\)的(不管是\(Parent Tree\)还是后缀自动机后缀自动机哪里有父亲,应该说边的起点和终点),仔细想想最多不就是\(short(p)+1-short(nq)+1\)吗,因为你从\(p\)开始跳的话,\(short(p)\)会不断减少,如果\(short(p)+1\)还小于\(short(nq)\)的话,那么不就指不到\(nq\)了吗,所以只会跳\(short(p)-short(nq)+2\),而最坏情况\(short(p)=short(fa(last))\),综合\(np\)的父亲就是\(nq\),假如循环跳了\(k\)次,那么\(short(nq)=short(p)-(short(p)-short(nq)+2-2)≤short(p)-k+2≤short(fa(last))-k+2\),所以,不妨认为,跳了\(k\)次,\(short(fa(last))\)就减少\(k\),然后又加上\(2\)(且由于\(k≥1\),所以\(short(nq)≤short(fa(last))+1\))。

所以总结下来,第二个循环和\(short(fa(last))\)息息相关,而每次加入字符对于\(short(fa(last))\)最多只会\(+1\),所以是\(O(n)\)的,证毕。


中间不是有个拷贝吗?但是那是字符集的大小,也就是说字符集固定的话复杂度是线性的。

不固定呢?我们可以给每个节点用map呀,当然也有大佬用B-树的。

这里用上https://www.cnblogs.com/hyghb/p/8445112.html大佬的话

首先,我们曾经说过要保证字母表的大小是常数。否则,那么线性时间就不再成立:从一个顶点出发的转移被储存在B-树中,

它支持按值的快速查找和添加操作。因此,如果我们记字母表的大小是k,算法的渐进复杂度将是O(n*logk),空间复杂度O(n)。但

是,如果字母表足够小,就有可能牺牲部分空间,不采用平衡树,而对每个节点用一个长度为k的数组(支持按值的快速查找)和一

个动态链表(支持快速遍历所有存在的键值)储存转移。这样O(n)的算法就能够运行,但需要消耗O(nk)的空间。

因此,我们假设字母表的大小是常数,即,每个按字符查询转移的操作、添加转移、寻找下一个转移——所有这些操作我们都

认为是O(1)的。

注意

后缀自动机打代码最需要注意的一件事情就是,一定不要把\(Parents\)树的父亲儿子的关系和后缀自动机边的关系搞混啊!!!

广义后缀自动机

思路

我们只要每次塞入一个字符串之后,然后把last=1,然后再塞,仔细想想也满足能都识别任意一个字符串的子串。

练习

例题

我们给每个点开个\(set\)存一下以这个点包含的所有串为子串的字符串有哪些,这些可以直接用启发式合并合并出来,如果大于等于\(k\)的话,那么我们就处理他的val为他所包含的字串个数。

然后我们可以知道一件事情,就是加入一个点是可以的话,那么再Parent树的父亲也满足条件,而且我们知道Parent树中一个点的子树里面所有由\(np\)组成的点(不包括\(nq\)的点)的个数就是这个点\(right\)集合的个数,所以我们只要DFS遍历一下统计答案。

#include<cstdio>
#include<cstring>
#include<set>
#include<algorithm>
#define  N  210000
using  namespace  std;
struct  node
{
	int  y,next;
}a[N];int  len,last[N];
void  ins(int  x,int  y){len++;a[len].y=y;a[len].next=last[x];last[x]=len;}
struct  SAM
{
	int  a[30],len,fa,id;
}tr[N];int  tot=1,las;
set<int>ss[N];
void  add(int  id,int  c)
{
	int  p=las;int  np=las=++tot;
	tr[np].len=tr[p].len+1;ss[np].insert(id);tr[np].id=id;
	for(;p  &&  !tr[p].a[c];p=tr[p].fa)tr[p].a[c]=np;
	if(!p)tr[np].fa=1;
	else
	{
		int  q=tr[p].a[c];
		if(tr[q].len==tr[p].len+1)tr[np].fa=q;
		else
		{
			int  nq=++tot;
			tr[nq].fa=tr[q].fa;tr[q].fa=nq;
			tr[nq].len=tr[p].len+1;
			memcpy(tr[nq].a,tr[q].a,sizeof(tr[nq].a));
			tr[np].fa=nq;
			for(;p  &&  tr[p].a[c]==q;p=tr[p].fa)tr[p].a[c]=nq;
		}
	}
}
set<int>::iterator  ii;
long  long  val[N];int  n,m;
void  dfs1(int  x)
{
	for(int  k=last[x];k;k=a[k].next)
	{
		int  y=a[k].y;
		dfs1(y);
		if(ss[y].size()>ss[x].size())swap(ss[x],ss[y]);//减少常数
		for(ii=ss[y].begin();ii!=ss[y].end();ii++)ss[x].insert(*ii);
		ss[y].clear();//顺便清空
	}
	if(ss[x].size()>=m)val[x]=tr[x].len-tr[tr[x].fa].len;
}
long  long  ans[N];
void  dfs2(int  x,long  long  zz)
{
	zz+=val[x];
	if(tr[x].id)ans[tr[x].id]+=zz;//说明他是np
	for(int  k=last[x];k;k=a[k].next)dfs2(a[k].y,zz);
}
char  st[N];
int  main()
{
	scanf("%d%d",&n,&m);
	for(int  i=1;i<=n;i++)
	{
		scanf("%s",st+1);
		int  slen=strlen(st+1);las=1;//别忘了
		for(int  j=1;j<=slen;j++)add(i,st[j]-'a');
	}
	for(int  i=2;i<=tot;i++)ins(tr[i].fa,i);
	dfs1(1);dfs2(1,0);
	for(int  i=1;i<n;i++)printf("%lld ",ans[i]);
	printf("%lld\n",ans[n]);
	return  0;
}

子序列自动机

这里说说,子序列按顺序,但是不一定是连续的,懂吧。

放上https://blog.csdn.net/litble/article/details/78997914的介绍,写的很好


后缀自动机的一条路径是原串的一个子串,那么序列自动机上的一条路径就是原串的一个子序列
序列自动机很好写,就是每次查看最后出现过的一些表示字母x的节点,如果它们没有当前插入的字符y的儿子,那么就将它们的y儿子赋为当前节点,显然这样可以表示出原串的所有子串。

void ins(int x) {
	++SZ,pre[SZ]=last[x];
	for(RI i=0;i<26;++i) {
		int now=last[i];
		while(!ch[now][x]) ch[now][x]=SZ,now=pre[now];
	}
	last[x]=SZ;
}


posted @ 2019-08-26 11:30  敌敌畏58  阅读(377)  评论(0)    收藏  举报