后缀自动机/后缀树

Posted hehe54321

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了后缀自动机/后缀树相关的知识,希望对你有一定的参考价值。

只是笔记罢了,不要看

关于DAWG:

见紫书P390

把后缀自动机上所有节点都设为接受态就形成DAWG,可以接受一个字符串的所有子串。

一个子串的end-set是它在原串w中出现位置(从1开始编号)的右端点集合。

在DAWG中,end-set相同的子串属于同一个状态。

原因没原因,这应该算定义吧?

任意两个节点的end-set要么不相交,要么是包含关系。

原因:在DAWG上走一步,当前end-set的变化是将原end-set中各个元素+1(要去掉超出字符串长度的元素),然后拆分成1个或多个新end-set。
(实际相当于在一组已有子串之后同时插入一个(不同/相同的)字符,得到新子串。)
这里认为起始节点的end-set是{0,1,2,..,n},n为字符串长度。把空串也囊括进去。

以下转自:

http://fanhq666.blog.163.com/blog/static/8194342620123352232937/

冬令营上我犯了最大的一个错误,就是在陈立杰讲后缀自动机的时候睡觉。

这导致了,我在冬令营之后只能花费好几个不眠之夜来思考后缀自动机到底是什么。
突然,在某天的梦里,我看到了一幅神奇的图景,我突然发现,一切都是那么的明晰了。

先说说后缀自动机(Suffix Automaton)是什么东西。一个串A的后缀自动机是一个有限状态自动机(DFA),它能够且仅能够接受A的后缀,并且我们要求它的状态数最少。
有一个强大的定理说明,N个字符的后缀自动机的状态数与转移数都不会超过O(N)。
举个例子:串A="abaaaba"
通过一番努力,我们能够看出,它的后缀自动机是:
            b----->4-a-->6
            |      ^
            |      b
            |      |
S--a->-a-->3-a-->5
|    |             ^
|    b             a
|    v             |
b--->2-a-->7-a---->
其中,红色的S表示开始状态,蓝色的表示接受状态,绿色的表示非接受状态。
 
后缀自动机有什么性质呢?又该如何构造后缀自动机呢?
先看第一个神奇的性质:
我们观察从每个点出发,能够走到接受状态的所有路径(用/来表示空路径):
S:a ba aba aaba aaaba baaaba abaaaba
1:/ ba aba aaba baaaba
2:a aaaba
3:ba aba
4:a
5:ba
6:/
7:/ aaba
首先,所有的路径都是串A的后缀(显然)。
其次,对于任何两个状态,它们能够接受的路径要么没有交集,要么是包含关系。
原因同DAWG。
例如:3号点end-set为{4,5},其中各个元素+1得到{5,6},恰好是5号节点end-set{5}和6号节点end-set{6}的并集,而且后两者不相交。
这个直接保证了,状态数是O(N)的。
(以下为原证明)
技术分享图片 技术分享图片
......
技术分享图片
让我们来看第二个性质:
            b----->4-a-->6
            |      ^
            |      b
            |      |
S--a->-a-->3-a-->5
|    |             ^
|    b             a
|    v             |
b--->2-a-->7-a---->
我们再观察从起点出发,能走到每个点的路径:
S:/
1:a
2:b ab
3:aa
4:aab aaab baaab abaaab
5:aaa baaa abaaa
6:aaba aaaba baaaba abaaaba
7:ba aba
8:baa abaa
它们的特点是:
首先,这些路径两两不同,共同组成了串A的所有子串(显然)。
其次,能走到一个状态的路径都是某个串在某个长度区间里的后缀。(例如走到6的串是abaaaba的长度为4~7的后缀)。
紫书上(相同含义)的描述是:
((补注)在以任何方式从起始状态走到该状态得到的所有串中,)每个状态中都有一个最长串,其他的都是它的后缀,并且长度连续。
原PPT上的描述(有图):见上面
证明嘛...显然?看起来是对的?
这里有一些证明:http://hihocoder.com/problemset/problem/1441
这个性质是否让你浮想联翩?你是否发现了一个后缀树?
是的!不过,它是A的逆序的后缀树!
            S
           / \
          a   ba
         /     \
        1       2
       / \       \
      a   ba      aaba
     /     \       \
    3       7       4
   / \       \
  ba aba    aaba
  /    \       \
 8      5       6
(真不幸,这个例子里的A是回文串。。。)

后缀链接的定义http://hihocoder.com/problemset/problem/1441

将后缀自动机上所有边去掉,加上所有后缀链接,就得到了parent树,稍微改一下形式就可以得到将原串翻转得到的字符串的后缀树(此时所有边从子节点指向父节点)

有时候讲的Parent(x)就是x的后缀链接指向的节点
技术分享图片

看上面两张图中,上面的那张图:后缀链接对应的后缀树上的边是:

5->S===>b,4->5===>baa,3->5===>aa,2->1===>a,1->s===>a,也就是后缀链接起点能接受的所有长度的串,按长度从小到大排,各取第一个字符组成的串

后缀树的根就是S

从某一节点沿着后缀链接走到S,记录下经过所有边代表的串,翻转序列,连成一个字符串,然后翻转整个字符串,就是该节点在后缀自动机上能接受的最长串

某一节点能接受的最长串为S,则该节点能接受的所有串都是S的后缀,往后缀链接跳一步到的节点能接受的串则也是S的

板子https://hihocoder.com/problemset/problem/1445

我们发现,后缀树中的每个节点和后缀自动机里的节点一一对应。树上,从S走到某个节点的路径,对应了自动机里从某个节点走回起点的路径。
这个使得如果我们能够建立一个串的后缀自动机,我们就能够建立它的逆的后缀树。这样,任何后缀树(后缀数组)能够做的事情,后缀自动机都能够做。
 
后缀自动机是可以O(N)时间内构造的!怎么构造?这等于问我:如何在O(N)的时间里构造一个串的后缀树。
线性构造后缀树?听起来挺难的,不过,后缀自动机给了我们一个超级简单的方法:增量法。
对于A="abaaaba",我们往自动机里从短往长添加A的前缀,注意,是前缀!
a ab aba abaa abaaa abaaab abaaaba
对于后缀树来说,就是依次添加前缀的逆序:
a ba aba aaba aaaba baaaba abaaaba
我们记录树上每个节点p的父亲Par[p]和它代表的串的长度Lth[p],同时记录后缀自动机里它的转移Trans[p][x]
注意,Trans[p][x]的意义在树上说就是:p代表的串,往前添加一个字符x,得到的串是谁的前缀?
 
假设我们已经构造好了n-1个字符的后缀自动机,从开始状态走n-1步得到的节点是u(也就是说,树上代表A[n-1..1]的节点是u),我们要添加一个字符x,那么就是说要往树里插入一个字符串‘x‘+A[n-1...1]。
新建一个节点p,设定好长度为n。
p该插入到哪里呢?
我们想:如果Trans[u][x]不是NULL,那么p就应该插入到Trans[u][x]的下面。(根据Trans在树上的意义,Trans[u][x]代表的是,u对应的串的头部添加一个字符得到的串对应的节点,刚好就是p应该去的地方!)。
如果Trans[u][x]是NULL呢?那么,我们就应该看Trans[Par[u]][x]是否是NULL、Trans[Par[Par[u]]][x]是否是NULL。。。
直到找到某个祖宗w,它的Trans[w][x]不是NULL。
令q=Trans[w][x]
                  S
                /   \
             ...    ...
             /       \
            w         q
           /           \      p?
          ...          ...
         /
        u
容易看出,p和q享有长度为Lth[w]+1共同的前缀,它们应该变成兄弟。
                  S
                /   \
             ...    ...
             /       \
            w         r(Lth[r]=Lth[w]+1)
           /         / \
          ...       p   q
         /               \
        u                 ...
新建点r。
注意,如果Lth[w]+1恰好等于Lth[q],那么p应该变成q的孩子。
                  S
                /   \
             ...    ...
             /       \
            w         q(Lth[q]==Lth[w]+1)
           /         / \
          ...       p   ...
         /                
        u                
这样,我们就通过之前定义好的Trans链条找到了p的位置!
计算出Par[p]之后,如何更新Trans[][]呢?
只有两部分的Trans[][x]需要修改:
u~w的Trans[][x]应该修改为p
w及祖先中,所有Trans[?][x]==q的应该替换成r
 
讨论完以上问题之后,我们发现了一个超级短的后缀自动机的建立代码(和你的后缀数组比比?)
(变量的标号和文中不一样)
void build(){
root=curnode=new node(0,NULL);//最开始的后缀自动机只有一个节点,长度是0,父亲是空
for (int i=0;i<N;i++){
int x=A[i]-‘a‘;//增加一个字符
node *p=curnode;
curnode=new node(i+1,NULL);//建立一个Lth为i+1的节点
for (;p && p->trans[x]==NULL;p=p->p)p->trans[x]=curnode;//沿祖先向上,寻找插入位置。同时更新Trans
if (!p)curnode->p=root;//插入到根的下面
else{
node *q=p->trans[x];
if (q->l==p->l+1)curnode->p=q;//成为q的孩子
else{
node *r=new node();r[0]=q[0];r->l=p->l+1;//新建一个节点,表示curnode和q的公共前缀
q->p=r;curnode->p=r;//兄弟
for (;p && p->trans[x]==q;p=p->p)p->trans[x]=r;//更新第二部分的Trans
}
}
}
}
震撼吧?线性构造后缀树,或者说,线性构造后缀自动机,原来这么容易!
为什么时间复杂度是线性的?嗯,势能分析吧。。。总之,真的是线性的。
 
构造完成之后,我们拿它有什么用呢?
首先,是解决LCS(最长公共前缀)的查询。因为我们能够构造出后缀树,所以最长公共前缀就成为了最近公共祖先的查询。
然后,是解决子串计数。一个子串在原串中出现多少次,等于从开始状态沿着这个子串转移到达某个状态之后,还有多少条路径能够走到接受态。这个可以通过把自动机拓扑排序(或者,更暴力点,按Lth排序)之后dp来实现。
接下来,是解决字符串匹配。建立模板串的后缀自动机。然后设定一个初始指向开始状态的指针p,依次读取文本串的字符x,如果Trans[p][x]不是空,就沿着Trans[p][x]走,匹配长度加一,否则让p沿着Par[p]回退,同时调整匹配长度为Lth[p],直到Trans[p][x]不是空,再沿着Trans[p][x]走。这样,我们就能够知道,当前文本串出现在模式串中最长的后缀的长度。(其实建立文本串的后缀自动机也可以。。。)
for (int i=0,l=0;i<M;i++){
int x=B[i]-‘a‘;
while (Par[p]!=-1 && Trans[p][x]==-1)p=Par[p],l=Lth[p];
if (Trans[p][x]!=-1){
result=max(result,l);
l++;
p=Trans[p][x];
}else p=0,l=0;
}
把Par[x]理解为类似fail指针(文本串在某节点u处失配,那么该节点能接受的最长串的一系列后缀(到该节点能接受的最短串为止)匹配时都会失配,因此可以将文本串对齐的位置向前移一段)。
最后,最本质的一点,就是能够解决任何后缀树能够解决的问题。
 
举几个例子吧。(来自陈立杰)
SPOJ NSUBSTR(问出现次数为i的子串有多少个)
这个的做法我们已经讨论过了。计算每个状态有多少种方法走到接受态(等于它在后缀树的子孙里有多少个接受状态,这两种计算方法都是可以的)。用这个数更新这个状态代表的串的长度的结果。之后在用长的结果去更新短的结果。
SPOJ SUBLEX(问字典序第x小的后缀)
可以利用后缀树来搞。或者,计算走到每个状态之后有多少种走法(利用dp)。然后dfs一遍即可。
SPOJ LCS(最长公共连续子串)
当然可以把两个串拼起来之后用后缀树来搞。也可以用上面提到的方法,计算第二个串的每个前缀出现在第一个串中的最长的前缀。
SPOJ LCS2(多个串的最长公共前缀)
建立一个串的自动机。之后利用扫描和treeDP,计算每个状态代表的串出现在所有子串中最长的后缀。
 
注意,SPOJ的常数卡的很死,要使用各种常数优化来通过这些题目。
我的代码:

 

转自http://wyfcyx.is-programmer.com/posts/76107.html

搞了半天还是搬到后缀树上理解容易。。。。。

感觉单纯地从"后缀自动机"的角度来入手并不是非常合理.因为我们懂得很多"后缀自动机"的性质,但却并不清楚"后缀自动机"在本质上是什么.

让我们从后缀树说起.

[1]后缀Trie

Trie树是一棵有根树,每个节点都代表从根节点到这个节点的路径上的字母顺次连接起来的字符串.

对于一个长度为\\(n\\)的字符串,我们用一颗Trie树插入它的所有的后缀.不难发现,这样构造的时空复杂度都是\\(O(n^2)\\).

但是后缀Trie却有一些非常好的性质:例如我们想得到后缀数组,只需进行一次dfs即可.又或是我们想查询一个模式串是否在这个串中出现,令模式串长度为\\(m\\),则我们只需\\(O(m)\\)的复杂度便可以解决.

但是这样时空复杂度过高了-QoQ.我们不得不考虑别的解决方法.

[2]后缀树

我们发现后缀Trie上有一些非常长的链.我们考虑对信息进行压缩,但是使得它依然具有后缀Trie的性质.

我们将原有的后缀Trie上的节点分为两类:关键点和非关键点.

形象的说,关键点就是那些链的分叉点,非关键点就是在链中间的点.

我们以串"dbabbaa"为例,看下面的图.

技术分享图片 

从这幅图来看,白色节点就是非关键点,有色节点就是关键节点.其中红色节点表示一个后缀.

我们考虑的后缀树,事实上仅包含这些有色的关键节点.两个关键点之间的边,便包含着之前路径上的非关键点.

技术分享图片

上面这幅图便是串"dbabbaa"的后缀树,至于那些指针是什么?一会再说.

为了建立刚才描述的这颗后缀树,我们依旧可以暴力将\\(n\\)个后缀插入Trie树中,只不过需要做一些变化:在产生分叉时新建节点即可.

通过观察我们发现:红色节点只有\\(n\\)个,而每一个绿色节点的出现都是在合并红色节点的集合,由于红色节点只有\\(n\\)个,也就是说至多被合并\\(n\\)次,因此绿色节点只有\\(O(n)\\).因此,我们有后缀树的节点数是\\(O(n)\\)级别的.

即便如此,暴力建树的时间复杂度依旧是\\(O(n^2)\\)级别的.我们需要寻求更有效率的建树方法.

[3]后缀树的高效构造(只是其中一种能用来说明后缀自动机的方法而已)

这是我们需要介绍刚才图中出现的一些指针了.

我们定义,除了根节点之外,每个节点都有一个\\(pre\\)指针,指向将这个节点代表的串的首字母删除后得到的串对应的节点.

刚才图中的指针就是\\(pre\\)指针.不过刚才的图是后缀树,我们重新给出在后缀Trie上的\\(pre\\)指针:

技术分享图片

再定义\\(pre\\)的逆指针\\(tranc\\)指针.一个点显然不一定只有一个\\(tranc\\)指针.令节点\\(p\\)的\\(tranc\\)指针\\(tranc(p,x)\\)指向的字符串表示在节点\\(p\\)表示的字符串前面加上一个字符\\(x\\)得到的字符串对应的节点.这显然很符合\\(pre\\)的逆指针的性质.

这两个指针能够帮助我们高效的构造后缀树.

结合上面的图我们看到,后缀树的压缩信息的方式事实上是将非关键点的信息全部压缩到它的深度最小的关键点儿子上.例如,将从别处指来的\\(pre\\)指针指到他的关键点儿子上.将自己的\\(tranc\\)指针从自己的关键点儿子指出去.

事实上,我们需要的后缀树仅仅是每个节点的父节点以及\\(tranc\\)指针.

我们不妨采用增量法构造一颗后缀树.

假设对于字符串\\(s[1,n]\\),我们得到了\\([i+1,n]\\)的后缀树,考虑我们如何得到\\([i,n]\\)的后缀树.

不妨令字符\\(i\\)为\\(x\\).

事实上我们发现只是多出了一个后缀\\(i\\)而已,那么我们需要将这个后缀也插入后缀树中.新建一个节点\\(np\\)表示这个后缀.

对于后缀树中的每一个节点,我们顺便记录这个节点表示的字符串的长度.

考虑从根节点到表示串\\([i+1,n]\\)的节点到一条路径(链)上的若干个节点,事实上每个节点都表示着一个串\\([i+1,n]\\)的前缀.

我们发现我们需要拓宽这些前缀的\\(tranc\\)指针,使得它们能够通过在前面加上一个字符\\(x\\)到达一个新的状态.

{1}如果对于链上的每一个点都没有\\(tranc(x)\\)的出边,我们自然只需都添加上这条出边就好了.

{2}如果有链上的某个点有\\(tranc(x)\\)的出边呢?

假设点\\(p\\)是这条链上深度最大的有\\(tranc(x)\\)的出边的点,那么显然从根节点到\\(p\\)的路径上的所有点必定都有\\(tranc(x)\\)的出边.

为何是深度最大的?

该节点有该出边,那么该节点的任意祖先(表示的字符串都是其表示字符串的前缀)都有该出边。

我们首先让剩下的点都连上\\(tranc(x)\\)的出边.

都连上同一个点?

这样就产生了下面的"被压缩到它的一个关键点儿子“情况。

我们令\\(q=tranc(p,x)\\).

Case1:如果\\(q\\)原本就在后缀树中,我们只需让\\(np\\)的树上的父亲为\\(q\\),便可以结束这个阶段.因为我们考虑一下别的点,貌似都没有什么影响的说.

Case2:如果\\(q\\)原本是后缀树中的无用节点.这什么意思?指针不是都指向后缀树中的点吗?而这些点显然应该是原有的关键节点啊!

对应上面fhq博客中插入在"儿子和兄弟"的两种情况。

参见上面谈论的后缀树的压缩方式.可能\\(q\\)是被压缩到它的一个关键点儿子上了.我们令它的关键点儿子为\\(q\\),而其自身为\\(nq\\).

nq现在实际上还未被建出来。

下面考虑我们需要对后缀树进行的修改.

首先我们令\\(nq\\)的父亲为\\(q\\)原来的父亲,同时\\(q\\)和\\(np\\)的父亲都设为\\(nq\\).这些都是显而易见的.

回忆:np是新建节点

举例:原来是x---(abcd)---q,现在要插入一个abef,插入后变为x---(ab)---nq---(cd)---q
                                                                                                                     \\
                                                                                                                      \\---(ef)---np

由于\\(nq\\)的关键点儿子是\\(q\\),那么\\(nq\\)的\\(tranc\\)转移显然应该与\\(q\\)相同.否则,\\(nq\\)就不至于当做无用节点去掉.所以,我们令\\(nq\\)拷贝\\(q\\)的\\(tranc\\)转移.

让我们通过第三幅图来观察一下在后缀Trie上\\(pre\\)指针的某些特性.我们观察一整条链的\\(pre\\)指向,我们发现这些指向的节点形成了另外一条链.

那么反过来\\(tranc\\)的指针指向的节点也是呈一条链的.

在后缀树上,由于信息被压缩,那么不难发现指针指向的节点是分段的.(有点意识流QoQ,看图就会有体会了吧)

首先\\(p\\)的\\(tranc(x)\\)指针应该指向\\(nq\\).(否则我们为什么要把它搞出来?)考虑\\(p\\)的一段连续原先\\(tranc(x)\\)指向\\(q\\)的祖先,它们的\\(tranc(x)\\)指针都应该指向\\(nq\\).

注意”连续“

暂时没搞明白

这是由于一条链的连续性使然.

看起来别的节点大概就没有什么影响了.

蛮清晰吧?

总结一下,有一棵倒着建的后缀树,在原串末尾加入一个字符ch时,维护后缀树需要的操作:

(也是有一个后缀自动机,在原串末尾加入字符ch时维护需要的操作;正着建,就是原始的在首部插入字符的O(n)建后缀树算法)

(每个节点x记录父亲par[x],当前节点表示的字符串长度len[x],也相当于后缀自动机上该节点接受的最长子串的长度)

(trans[u][ch]表示:表示"(点u表示的字符串)头部加上一个ch后得到新串"的节点)

1.新建一个点np用于存放新字符

2.找到后缀树上表示原来的原串的节点u,暴跳u的父亲,直到到达某个节点p,满足p不存在(由根节点的父亲得到,用0表示)或者p的trans[p][ch]不为空;在跳的过程中对于所有不合法节点x都要将trans[x][ch]设为np

3.如果p不存在,将par[np]设为根,退出

4.令q=trans[p][ch]。若len[q]==len[p]+1,表示"q原本就在后缀树中",则直接让par[np]为q即可,退出

5.否则,表明需要拆一些点/边。新建节点nq,令par[q]=nq,par[np]=nq,par[nq]=p,将q原来的整个trans数组复制到nq中。更新len[nq]为len[p]+1。

6.暴跳p及其祖先,遍历到点x时,如果trans[x][ch]==q,则改为nq;否则break。

我们能够证明,这样建立后缀树的复杂度是\\(O(n)\\).

[email protected] 19:50 tag:更新了一些题解的链接

http://wyfcyx.is-programmer.com/posts/76391.html

http://wyfcyx.is-programmer.com/posts/76400.html

http://wyfcyx.is-programmer.com/posts/76423.html

aaaaaa

 

 

 

你有一棵按倒过来的字符串建的后缀树,对于每一个节点u,额外记"后缀指针"trans[u][ch]表示在当前节点表示的字符串前添加字符ch得到的字符串对应的节点。

对于某一个节点u,设其表示的字符串为S,则ch+S是trans[u][ch]能匹配的字符串的前缀(因为存在路径压缩,ch+S的信息可能被压到了以它为前缀的其他串上)。

要在原串末尾加一个字符ch,就是要向后缀树中插入一条新后缀。

那么找到表示原来的原串的节点u,找到trans[u][ch]。先新建一个节点x,表示新字符位。

aaaaaaaa

以上是关于后缀自动机/后缀树的主要内容,如果未能解决你的问题,请参考以下文章

整理如何选取后缀数组&&后缀自动机

HDU - 6704 K-th occurrence (后缀数组+主席树/后缀自动机+线段树合并+倍增)

后缀自动机如何限制串长

康复计划#1 再探后缀自动机&后缀树

bzoj3238 [Ahoi2013]差异

后缀自动机 线段树合并 codeforces666E