【字符串】后缀自动机小结

前几天遇到一道关于字符串的题,虽然不需要用到后缀自动机然而一时兴起就去学了一下,这里写写我的理解,如有错误欢迎指正。

以下内容参考了clj的ppt网上某篇俄文翻译

后缀自动机以及相关的一些定义

假设给定的字符串为S,长度为N,下标从0开始,整个串可表示为[0,N)。

对给定字符串S,我们要构造出它的后缀自动机suffix automaton(SAM),使得它能够识别字符串S的所有后缀。

我们假设一个起始状态root和一个终止状态tail,其中tail是虚拟的状态,S的每一个后缀都可到达tail。令trans(s,c)表示当前状态是s,读入字符c后,转移到的状态,同理trans(s,str)表示状态s读入字符串str后到达的状态。那么对于S的所有后缀x,trans(root,x)=tail,即每一条从root走到tail的路径都对应了S的一个后缀,因此每一条从root出发的路径都对应了S的某个后缀的前缀(即S的某个子串)。

对于一个状态s,如果trans(s,x)=tail,我们就说它能识别x。显然这里的x一定是S的后缀,而root能识别S的所有后缀。

对于S的同一个子串x,它可能出现在不同的位置[l1,r1),[l2,r2),…,[ln,rn),但是它们用同一个状态表示。对于x,我们只关心它能识别的后缀,即[r1,N),[r2,N),…,[rn,N)。令Right(x)={r1,r2,…,rn}。对于两个子串a和b,如果Right(a)=Right(b),那么它们都可以用同一个状态表示。所以状态s由所有Right集合是Right(s)的串组成。

知道了Right集合,我们只要知道子串的长度len,就可以确定出一个子串,设r∈Right(s),那么[r-len,r)就是那个子串。假设[r-x,r)和[r-y,r)都是s表示的子串,那么显然对于任意的x<=len<=y,[r-len,r)都是s表示的子串。因此状态s可表示的子串的长度必然是一个区间[min(s),max(s)],并且我们假设状态s可表示的最长和最短的子串分别是minstr=[r-min(s),r)和maxstr=[r-max(s),r)。除此之外,我们可以知道,状态s表示的所有的串就是从root走到s的所有路径。

状态数的线性证明

由以上定义我们知道状态数=不同的Right集合的个数。考虑两个Right集合Ra和Rb,如果它们有交集,假设r∈Ra∩Rb。由于它们不能表示相同的子串,所以[min(a),max(a)]和[min(b),max(b)]不能有交集,不妨设max(a)< min(b)。由于a表示的是[r-min(a),r)…[r-max(a),r),b表示的是[r-min(b),r)…[r-max(b),r),所以所有a表示的子串都是b的后缀,当b出现时a必然也出现。所以Ra包含Rb。

因此,对于任意两个集合,要么不相交,要么一个是另一个的真子集。

对于一个s,它表示一些子串集合,令其中最长的为w,我们知道它的长度为max(s),而其余的长度>=min(s)的子串都是它的后缀,而w剩下的后缀则在别的状态中,令t是这样的后缀中最长的所在的状态集合,则max(t)=min(s)-1(显然这样的后缀一定存在但可能是空后缀,此时t=root),且Right(s)是Right(t)的真子集。从s向t连一条边,那么我们可以知道所有这些状态集合构成了一棵树,称其为parent树,则t是s在parent树上的父亲,记为fa(s)。

在这棵树中,叶子节点的个数不超过N,考虑这棵树的内部节点u,若它只有一个儿子v,那么必然存在u中的值没有被v包含,我们可以创建一个虚拟结点包含这些值,最终这棵树的内部节点都至少有两个儿子,因此这棵树的节点不超过2N-1。那么原树的节点必然也不超过2N-1。

并且这一上限可以达到,例如“abbbbb…”。

转移(边数)的线性证明

注意这里说的边指的是trans(s,c)这样的边,与parent树的边没有关系。

举个例子帮助理解一下两种边的不同之处。

举个例子

考虑以root为根的SAM的最长路径树,这棵最长路径树的边数比结点数少1,即不超过2N-2。考虑每条不在最长路径树上的边(p,q),边上的字符为c,令u表示从root到p的最长路径,v表示从q到tail的最长路径,那么u和v的边都是最长路径树上的边,u+c+v是S的一个后缀。对于每条不在最长路径树上的边,u+c+v都是不同的,因此每条非树边都对应了S的一个不同后缀。由于完整的S串对应从root到tail的最长路径,所以最多有N-1个后缀被非树边对应。于是我们知道边数不超过3N-3。

虽然状态数2N-2的上限可以被“abbbbb…”达到,但是它的边数没有达到3N-3。因此事实上边数上限是3N-4,并且能被“abbbb…bbbbc”达到。

后缀自动机的构建方法

刚开始只有一个状态root,N=0。并且由于每个状态的min(s)=max(fa(s))+1,因此实际程序中我们只记max(s)。

每次添加一个字符c,就相当于要在SAM中添加一些子串,它们都是S+c的后缀,显然在添加完c后,它们都可以由原来的S的后缀转移得到。令last表示添加c之前的S串所在的状态(刚开始last=root),则Right(last)={N},max(last)=N,last所有祖先表示的就是S的所有后缀。

添加一个状态np表示S+c,则max(np)=max(last)+1,即N+1。一开始令p=last,如果没有从p出发的标号为c的边,那么我们从p中引一条边到np,即trans(p,c)=np,然后从p走到fa(p),直到p中原本就有标号为c的边。如果不存在这样的p,那么fa(np)=root,操作结束。

否则p是第一个有标号为c的边的状态,设trans(p,c)=q。我们不能直接在Right(q)中插入N+1,如图:

这里写图片描述

p表示的最长字符串(不止图上标出的这些)是AA,而q表示的最长字符串是AAAc,如果在Right(q)中插入了N+1,那么q能表示的最长字符串就变成了AAc,max(q)变小。

因此,如果max(p)+1=max(q),那么我们可以令fa(np)=q,这样不会出现问题。否则,我们新建一个结点nq,使得Right(nq)=Right(q)∪{N+1},此时max(nq)=max(p)+1,fa(nq)=fa(q)。由于Right(q)和Right(np)是Right(nq)的真子集,因此fa(q)=fa(np)=nq。nq在转移过程中,结束位置N+1不能转移到其他状态,因此不起作用,trans(nq)=trans(q)。同时,对于所有的trans(v,c)=q,我们要把它变成trans(v,c)=nq。这必然是由p往上的连续的一段。

代码

void extend(int c){
    int p=last,np=++tot;
    mx[np]=mx[p]+1;
    for (;p && !ch[p][c];p=fa[p]) ch[p][c]=np;
    if (!p) fa[np]=root;
    else{
        int q=ch[p][c];
        if (mx[p]+1==mx[q]) fa[np]=q;
        else{
            int nq=++tot;mx[nq]=mx[p]+1;
            memcpy(ch[nq],ch[q],sizeof ch[q]);
            fa[nq]=fa[q];
            fa[q]=fa[np]=nq;
            for (;p && ch[p][c]==q;p=fa[p]) ch[p][c]=nq;
        }
    }
    last=np;
}

其实就是clj的ppt里的代码,我根据自己的习惯改成了数组实现以及换了数组名。

构建过程的线性证明

当字符集k较小时,我们只需要像上述代码一样,使用O(N*k)的空间,在O(N)的时间内构建SAM。而当字符集较大时,则可以使用平衡树存储,时间复杂度O(Nlogk),空间复杂度O(N)。

观察构建过程,需要证明的时间复杂度有三个地方:
1.从last开始,沿着fa添加标号为c的边。
2.将trans(q)复制给trans(nq)。
3.把所有到q的转移变成到nq。
其中1和2操作每次都会增加新的状态转移,显然是线性的,需要证明的是3。

我们观察每次添加完一个字符后的minstr(fa(last))。为了区分,添加字符前用last表示,添加字符后的新的last用np表示。p、q、nq的定义也与上面相同。

我们可以知道,min(fa(last)))>=min(p),因为添加字符前的last是没有出边的,因此p一定是last的祖先而非last本身。然后我们由q拷贝得到了nq,并且从p沿着parent树往上,把通向q的转移改成nq。设v=minstr(当前节点),一开始v=minstr(p),每次p往上走一步,v的长度严格变小。因为存在trans(p,c)=q,因此原本的q对应的子串中包含v+c,改成trans(p,c)=nq,nq对应的子串也包含v+c,所以nq中包含长度为min(p)+1的子串。min(p)变小,此时min(nq)变小。

添加完一个字符c后,fa(np)=nq。刚开始min(nq)<=min(p)+1<=min(fa(last))+1,操作结束后min(nq)减小或不变,且减小的值>=操作数。由于每次最多+1,最多减小N次就到0,所以操作是线性的。

后缀自动机的应用

这个……其实网上有很多后缀自动机的题,读者可以自行百度。由于博主目前也只写了几道模板题,因此对于后缀自动机的某些应用并不是很熟悉。先写几个,或许以后写的题多了会再回来补充。

1.求所有不同的子串个数。在SAM中,每条路径都对应了S的一个不同子串,且SAM是一张有向无环图,可以用动态规划求解。令f[v]为从v开始(包括长度为0)的路径条数,f[v]=1+∑f[u],其中trans(v,c)=u。

2.最小循环移位。把S+S加入SAM,只要求一条从root开始的长度为length(S)的路径,贪心即可。

3.子串出现次数。需要求出每个状态的Right集合的大小,设为cnt[v]。对于一个节点v,如果它不是由拷贝而来的,那么刚开始cnt[v]=1,否则cnt[v]=0。我们知道对于状态s,max(s)>max(fa(s)),因此我们只要按照max的降序遍历节点,对每个节点v,cnt[fa(v)]+=cnt[v]即可。而不经过拷贝的节点恰有length(S)个,只要在添加时顺便记一下就可以了。

4.待补充

最后的吐槽

来来回回看了好几遍才隐隐约约感觉到有点懂了,然而对于应用还是不太会。另外,关于后缀自动机和后缀树之间的转化,感兴趣的读者可以戳那篇译文或者自己找资料,这里就不赘述了(其实是我不会)。

写于2016-11-06 23:12

(ps:因为博客显示的发表时间貌似是保存草稿的时间,然而今天博主脑子抽了突然想把真正写完这篇文章的时间附上,以说明这其实是我努力了一下午+一晚上的成果hhhh)

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值