字符串处理——后缀自动机の板子

(背景:论智障是如何痛苦地学新东西的)
前几天ATP心血来潮学了一个叫做后缀自动机的东西。。
一开始看的是clj的冬令营讲稿。。然后发现。。。我的妈呀定义太多了根本记不住啊有没有每次往后翻一页都要往前翻三页去看看那些定义呀有没有简直脑子要炸飞了啊有没有根本看不懂啊有没有= =
智障的ATP受到10000点伤害= =
诶不是说什么受损的脑细胞不能恢复吗。。。ATP感觉自己损失好惨重啊_ (:з」∠)_= =

===我是分割线===

几天以后ATP已经把前几天看的东西忘得差不多了所以决定来写一发blog就当复习一下反正基本上那些定义也是照着clj的ppt来说的= =
不过那些定义虽然看的时候很难懂但是如果懂了以后是真的好用啊= =

什么是后缀自动机?

后缀自动机(Suffix Automaton)是和AC自动机,WA自动机,RE自动机和TLE自动机(雾)齐名的一种自动机。。。(神志不清)它是对于一个字符串S来建立的,就像AC自动机可以识别塞进去的所有模板串一样,后缀自动机可以识别的是主串S的所有后缀。这也就是说从后缀自动机的起始节点(Root)沿着任意一条路径走到一个结束节点(end),路径上的字符形成的串就是S的一个后缀。进一步地,因为S的所有子串都是某一个后缀的前缀,所以从后缀自动机的起始节点(Root)沿着任意一条路径走到任意一个节点停下,路径上的字符形成的串就是S的一个子串。

一坨定义

clj的ppt里面说了超级多的一大坨定义啊。。但是最最有用的ATP感觉是那个Right集合相关的定义了。。

后缀自动机的每个节点代表的不是一个串而是很多串。像AC自动机的话它可以识别模式串的每个前缀,这就是说从Root跑到任意一个节点停止,路径上的字符组成的是模式串的一个前缀。并且AC自动机的每个节点只代表一个串,也就是要识别不同的前缀就不会跑到同一个节点。但是后缀自动机不同,我们要在有限的空间里让它能够识别S的所有 O(len2) 个子串,假如说用一个愚蠢的方法实现这个目的,把所有子串搞成一个trie树,那么节点数将会达到 O(len2) 的级别。但是观察这个trie树我们可以发现很多子树都是只有一条链,并且很多链都是重复的,那么就有大量的压缩余地。为了压缩空间才造成了用不同的子串在后缀自动机上跑可能跑到的是同一个节点。

那么到底是如何实现对空间的压缩的呢?这就要引入一些定义了。。。后缀自动机的每个节点上有一个“集合”。举个例子来说,在串 S=aabbcaaa 中,“aa”这个子串出现的位置有[1,2],[5,6],[6,7]这三个,那么如果在S的后缀自动机上跑“aa”这个子串到达节点s,那么节点s所代表的集合就是 {2,6,7} ,也就是它所有出现位置的右端点集合,这就是所谓的Right集合,它是定义在自动机的每个节点上的,接下来我们将要把节点s的Right集合称为R(s)。R(s)存在的意义就是当你匹配到s节点的时候告诉你,在这个主串的哪些位置还存在和当前匹配到的串相同的子串。这实际上就是一种对空间的压缩,把同样的子串压到一个节点上去。

我们对于每个节点只记录了R(s),如何知道这个节点对应的是哪些子串呢?这就需要确定一个长度。而对于每个R(s)来说是有一段“合法”长度的,因为如果取的长度太小,通过R(s)确定的子串们就太短,它的出现次数就会变多,就可能出现在除了R(s)以外的地方;但是如果取的长度太长,子串就太长,出现次数就会变少,有些R(s)就不合法。总之长度不合法就会导致R(s)显得太大或者太小,都是不对的。

显然如果长度L和R都合法(L<=R),那么位于[L,R]之间的长度都合法,L和R定义在每个节点上就是ppt里提到的Min(s)和Max(s)。区间[L,R]是有一定长度的,这就说明状态s里面记录的子串是由两个东西决定的,一个是它的Right集合即R(s)的大小,一个是它对应的区间[Min(s),Max(s)]的长度len。Right集合中的每个右端点都对应着len个合法的子串。也就是这 |R(s)|len 个子串任意拿出来一个放到自动机上跑都会在s状态结束。

考虑某个Right集合,它对应着自动机中的一个状态s,可以取到的合法区间长度是[Min(s),Max(s)]。那么如果我们取了比Min(s)还小的长度会怎么样呢?显然这个长度对应着一个更大的Right集合,设为R(f)。并且还可以发现通过这个更小的长度确定出来的子串一定是状态s中子串的后缀,那么R(s)中有的元素R(f)中一定都有,但是R(f)中也一定有一些R(s)中没有的元素,即R(s)是R(f)的真子集。

并且,因为两个状态所能代表的子串不会有交集,那么如果它们的Right集合有交集,那么它们的长度就一定不会有交集。也就是说对于两个Right集合有交集的状态A和B来说,其中一个状态的Min一定大于另一个状态的Max。假设Max(A) < Min(B),那么A中所有串都是B中所有串的后缀。这也就是说,只要两个节点的Right集合出现交集,那么它就一定是像上面的状态s和f这种情况,一个是另一个的真子集。

那么我们令状态s的“Parent”节点表示Right集合包含了R(s)并且大小最小的那个节点,于是我们可以发现所有的Parent关系构成了一棵树。观察Min(s)-1这个长度,它对应的是一个比R(s)大并且包含R(s)的Right集合,并且这个Right集合一定是所有这样的集合中最小的。即它是s的Parent节点。于是我们又得到一个结论:Min(s)=Max(Parent(s))+1。

这个性质非常有用,因为这决定了我们不需要对每个节点都维护Max和Min,只需要维护一个Max,Min可以通过Parent节点算出来。

怎么构造后缀自动机?

后缀自动机的构造算法是在线的,也就是每次添加一个字符。假设我们已经有了串S的后缀自动机,我们在S后面添加一个字符c,然后要维护串S’=S+c的后缀自动机。设串S的长度为len,那么串S’的长度为len+1。下面将把节点s的Parent指针指向的节点称作fa(s)。显然它所代表的字符串都是s所代表字符串的后缀,这一点非常重要。再用step(s)来记录从根节点走到这个节点最多需要几步,也就是Max(s)。

首先对字符c新建一个节点np,因为后缀自动机是识别S的所有后缀的,设加入串S的最后一个字符的时候新建的那个节点为p,于是首先p的后面可以接一条边上所标字母为c的指向np的边来构成新的后缀。显然我们应该把step(np)更新成step(p)+1。然后令p=fa(p),也就是顺着Parent指针往上走,路上经过的所有节点所代表的字符串都是p的后缀,那么它们都可以接上一条标号为c的指向np的边。如果一直沿着fa(p)走到根节点都没有发生冲突那么当然皆大欢喜,把fa(np)赋值成根节点就可以退出了。但是如果走到某个p,它已经有一个标号为c的儿子了,肯定不能直接覆盖掉,需要想一些别的办法。

设q为p节点标号为c的那个儿子。通过前面的构造过程我们可以发现某个节点的“儿子”可能指到一个跨了好远的地方,也就是它和它“儿子”之间可能有很多别的字符。如果step(q)==step(p)+1,说明p和q之间没有别的字符,q所代表的子串就是np所代表的子串的后缀,所以直接把fa(np)赋值为q就可以退出了。

但是如果step(q)>step(p)+1,说明p和q之间还有别的字符,也就是q所代表的字符前面接着的不是p代表的字符,而它所包含的字符串也不是np代表的字符串的后缀。如果还像刚才那样把np的fa连到q上,其实是相当于在q所代表的Right集合里加入了Len+1这个位置。但是q所代表的字符串并没有在Len+1这个位置出现,这就不好了。所以需要新建一个nq节点,让它代替q来接受Len+1这个位置。首先把step(nq)赋值为step(p)+1,让它作为p的一个儿子来代替q。这实际上是让nq作为一个串长更短而Right集合更大的节点存在,使得它所代表的串是q和np的后缀。那么需要把q的儿子信息都copy到nq节点里面去。然后把nq的fa赋值为q的fa,把q和np的fa都连到nq上面去。接下来,因为nq代替了q,所以要从p开始顺着fa指针向上,如果这个节点有儿子q,就要把那个儿子改成nq,这样才能维护fa指针也就是Parent指针的性质。

这样就完成了。下面贴出两种实现方式,ATP一开始照着别人的模板打的时候写的是数组,但ATP作为一个狂热的指针爱好者,在稍微熟练一点以后迫不及待地把它改成了指针。。。

可以看出它的时间和空间复杂度都是线性的。

根节点标号为1,因为建立根节点的过程在主程序里所以没粘过来。


void insert(int c){
    p=last;np=last=++tot;
    step[np]=step[p]+1;
    while (ch[p][c]==0&&p!=0){
        ch[p][c]=np;p=fa[p];
    }
    if (p==0){fa[np]=1;return;}
    q=ch[p][c];
    if (step[q]==step[p]+1){fa[np]=q;return;}
    nq=++tot;step[nq]=step[p]+1;
    memcpy(ch[nq],ch[q],sizeof(ch[q]));
    fa[nq]=fa[q];fa[q]=fa[np]=nq;
    while (ch[p][c]==q){ch[p][c]=nq;p=fa[p];}
}

struct Node{
    Node *ch[30],*fa;
    int step;
    long long res,cnt;
    Node();
}*null,*Root,*t[1100010],P[1100010],*last,*p,*q,*np,*nq;
Node::Node(){
    for (int i=0;i<=26;i++) ch[i]=null;
    fa=null;step=res=cnt=0;
}
Node *New(){return t[++tot];}
void insert(int c){
    p=last;np=last=New();
    np->step=p->step+1;
    while (p->ch[c]==null&&p!=null){
        p->ch[c]=np;p=p->fa;
    }
    if (p==null){np->fa=Root;return;}
    q=p->ch[c];
    if (q->step==p->step+1){np->fa=q;return;}
    nq=New();nq->step=p->step+1;
    memcpy(nq->ch,q->ch,sizeof(q->ch));
    nq->fa=q->fa;q->fa=np->fa=nq;
    while (p->ch[c]==q){p->ch[c]=nq;p=p->fa;}
}

然后贴两个ATP觉得比较好的博客,里面都有图片举例解释后缀自动机的构造过程。。。(其实ATP是懒得画图了2333)
后缀自动机构造过程演示
后缀自动机学习总结

后缀自动机能干什么?

ATP做过的后缀自动机的题目也不是很多而且都是相对比较简单的题目。。(难题根本做不动啊有没有)。。暂且在这里整理一下吧,以后应该还会Update的。。。。

1.公共子串问题(SPOJ1811/1812)

解决这类问题的通用方法就是先对其中一个串建立后缀自动机,然后用另一个串在上面跑。通过这类题目可以发现实际上后缀自动机中的fa指针和AC自动机中的fail指针是非常相似的。在后缀自动机中失配以后要顺着fa指针往上跳,相当于缩短了串的长度,但是出现的位置也更多,后面能匹配上的可能性也更大。

以SPOJ1812为例,先对其中一个串建立后缀自动机,对于其它的串,每次要对每个节点分开维护当前节点匹配到的最长长度,相当于求出了固定右端点在当前节点的Right集合里,能够往前延伸的最大长度。这些长度取一个Max就是当前串和第一个串的最长公共子串,但要求所有串和第一个串的最长公共子串就要对每个节点分开维护。因为小的长度满足了大的长度一定能满足,但是大的长度满足了小的长度不一定能满足,所以要对每个节点的最大匹配长度取Min,求出每个节点上所有串的最长公共子串,再对这些长度取Max。

还有一个问题就是,当匹配到某个节点的时候,它fa指针上面那一串节点肯定都能匹配到,并且能匹配到的长度都是它的最大值也就是Max(fa)。肯定不能每次走到一个结点都顺着fa指针蹦到头,那就T了。所以应该每次把一个串全都跑完的时候再用每个节点来更新它的fa节点。可以发现后缀自动机建立出来以后是一个有向无环图,所以直接用拓扑序就好了。

2.子串的重复出现问题(SPOJ8222、POJ1743、CF235C)

这类问题大多数是利用Right集合的性质,因为一个节点代表的字符串一定在Right集合包含的所有位置上重复出现。我们没有必要为每个节点保存它的Right集合,因为它的Right集合一定是所有fa指针指到它的节点的Right集合的并集,而这些节点的Right集合又是互不相交的,所以想要得到关于每个节点Right集合的信息,只需要在建立自动机以后按照拓扑序从后往前,用每个节点来更新它的fa节点就可以了。

以SPOJ8222为例,它要求每个长度的串出现次数的最大值,那么只需要对于每个节点递推出它的Right集合大小,然后映射到长度上面去就可以了。需要注意的一个问题就是如果长度为i的串出现了k次,那么长度为i-1的串一定至少出现了k次,因为可以取那些串的后缀。所以最后还要用F(i)去更新一下F(i-1)。

3.第K小子串问题(SPOJ7258、BZOJ3998)

因为后缀自动机可以识别串S的所有子串,所以它是解决这类问题十分方便的一个方法。我们可以顺着自动机这个有向无环图来DFS,对于每个节点预处理它往后还能经过多少不同的子串,再决定走哪条路就可以了。

这个预处理就相当于求DAG中一个节点往后还有多少不同的路径,是一个简单的DP,每次枚举当前节点的所有儿子它们累加过来就可以了。如果是相同子串不重复计算,每个点的初始权值就是1;否则每个点的初始权值就是它Right集合的大小。

4.信息统计问题(BZOJ4516/4566/1396)

这类题目感觉比较灵活,因为要统计一些东西所以很多都会用到Max(fa)=Min(s)-1这条性质。以BZOJ4516为例,它要求计算每次在后面接上一个字符以后字符串中不同的子串个数,那么就要计算每次加入的时候新增了多少子串。观察自动机的建立过程,设添加的字符为c,那么对于那些Right集合里包含Len+1这个位置的节点来说,那些本来就有c这个儿子的节点一定不会新增子串,只有那些原来没有c这个儿子,加入字符c的时候才连上c这个儿子的那些节点才会对新增子串产生贡献。并且每个节点的贡献就是[Min(s),Max(s)]的区间长度,因为这个节点代表着许多子串,都要计算进去。在insert的时候统计即可。

5.Parent树的性质(BZOJ3238/4556)

后缀自动机的Parent指针反向以后可以形成一个树结构,和AC自动机的Fail树是类似的。这棵树有一个性质:两个子串的最长公共后缀所代表的状态,是这两个子串代表的状态在Parent树上的LCA位置。

原因也很简单,首先顺着Parent指针往上跳达到的就一定是一个串的后缀,并且跳的步数越少后缀越长。那么用最少的步数让两个状态到达一个节点,这个节点肯定就是最长的公共后缀了。BZOJ3238就直接应用了这个性质。

6.由一个串到多个串——广义后缀自动机(BZOJ3926/2780、CF542E)

Trie树和字符串实际上有很多相似之处。它们每个位置都有唯一的一个“前一个”节点,就是读字符串的时候在它前面读到的那个节点。对于字符串来说,v[i]的前一个节点就是v[i-1];而对于Trie树来说,v[i]的前一个结点是有儿子连到v[i]的那个节点——不是fail[v[i]]啊要注意。

在对字符串建立后缀自动机的时候,可以看出p指针的初始值就是它的“前一个”节点所在的位置。那么如果对Trie树建立后缀自动机,只需要把p也赋值为它的“前一个”节点所在位置就可以了,如果理解了字符串的建立过程,可以发现Trie树的建立过程和它是很相似的。

那么这个新的后缀自动机可以识别的是Trie树上所有字符串的所有子串,可以非常方便地统计一些信息。对于BZOJ3926这道题来说,它给定的那棵树每个点都有一个字符,实际上这就是一个Trie树的模型。所以不需要直接把Trie树建立起来,对题目中给定的树进行dfs就可以直接建立后缀自动机了。如果真的把Trie树搞出来会跑得非!常!慢!。。。。。

更一般地,对于多个串建立后缀自动机的时候,虽然trie树的模型非常好理解但是实际操作的时候没有必要先建立trie树再建立后缀自动机,每次插入一个串的时候把last还原到根节点Root然后直接做就可以了。虽然这样可能会有一些“多余”的节点,但效果上是不会出问题的。

7.后缀自动机+DP(BZOJ2806/4032)

这类题目一般是用后缀自动机处理出某些信息然后再进行DP。注意利用后缀自动机本身带来的性质。BZOJ2806就是一道比较好的题目,需要自己发现由于进行字符串匹配而带来的性质,用来对DP进行优化。

你可能感兴趣的:(板子们,记不住性质的后缀自动机)