后缀树系列一:概念以及实现原理( the Ukkonen algorithm)

首先说明一下后缀树系列一共会有三篇文章,本文先介绍基本概念以及如何线性时间内构件后缀树,第二篇文章会详细介绍怎么实现后缀树(包含实现代码),第三篇会着重谈一谈后缀树的应用。html

 

本文分为三个部分,算法

  • 首先介绍一下后缀树的“前身”-- trie树以及后缀树的概念;
  • 而后介绍一下怎么经过trie树在平方时间内构件后缀树;
  • 最后介绍一下怎么改进从而能够在线性时间内构件后缀树;

一,从trie树到后缀树

       在接触后缀树以前先简单聊聊trie树,也就是字典树。trie树有三个性质:优化

  • 根节点不包含字符,除根节点外每个节点都只包含一个字符。
  • 从根节点到某一节点,路径上通过的字符链接起来,为该节点对应的字符串。
  • 每一个节点的全部子节点包含的字符都不相同。

    

       将一系列字符串插入到trie树的过程能够这样来实现:首先,树根不存任何字符;对于每一个字符串,从左到右,沿着树从根节点开始往下走直到找不到“路”能够走的时候,“本身开辟一条路”继续往下走。好比往trie树里面存放ana$, ann$, anna$, 以及anne$是个字符串的时候(注意一下,$是用来标志字符串末尾),咱们会的到这样一棵树:见下左图spa

trie

上左图这样存储的时候有点浪费。为了更高效咱们把没有分支的路径压缩,因而获得上右图。很简单吧.net

      介绍完trie树以后呢,咱们再来看一看后缀,直接列出一个字符串MISSISSIPPI的全部后缀3d

1. MISSISSIPPI
2.   ISSISSIPPI
3.    SSISSIPPI
4.      SISSIPPI
5.        ISSIPPI
6.         SSIPPI
7.           SIPPI
8.             IPPI
9.              PPI
10.              PI
11.               Ihtm

而将这些后缀所有插入前面提到的trie树中并压缩,就获得后缀树啦blog

5046078640_1c523b5017 5045455851_ec8ec3410e

 

二,两种方法在平方时间内构件后缀树

  所谓的平方时间是指O(|T|*|T|),|T|是指字符串的长度。字符串

  第一种方法很是显然,就是直接按照后缀树的定义来就能够了,将各个后缀依次插入trie树中,再压缩,总的时间复杂度显然是平方级别的。get

  这里给出的是另一种方法。对照上面MISSISSIPPI的全部后缀,咱们注意到第一种方法就是从左到右扫描完一个后缀再从上到下扫描全部的后缀。那么另一种思路就是,先安位对齐,而后从上到下扫描完每一个位,再从左到右扫描下一位。举个例子吧,第一种方法至关于先扫描完后缀1:MISSISSIPPI ,再往下扫描后缀2:ISSISSIPPI 以此类推;而第二种方法至关于从上到下先插入第一个字符M,而后再从上到下插入第二个字符I(有两个),而后再从上到下插入字符S(有三个)以此类推,参见下图。

QQ截图20131221093315

  可是具体怎么操做呢?由于显然每次操做不能是简简单单的插入字符而已!

  咱们再后头来看看上述过程,形式化一点,咱们将原先的字符串表示为

  T = t1t2 … tn$,其中ti表示第i个字符

  Pi = t1t2 … ti , i:th prefix of T

  那么,咱们每次插入字符ti,至关于完成一个从Trie(Pi-1)到Trie(Pi)的过程,当全部字符插入完毕的时候咱们整个后缀树也就构建出来了。参见下图:插入第二个字符b至关于完成了从Trie(a)到Trie(ab)的过程。。。。

QQ截图20131221094705

  那咱们怎么作呢?

  上图中也提示了,其实咱们须要额外保留一个尾部链表,链接着当前的“尾部”节点--也就是对应着Pi的一个后缀的那些个点。咱们注意到尾部链表其实是从表示T[0 .. i]后缀的点指向表示T[1 .. i]后缀的点再指向表示T[2 .. i]后缀的点,以此类推

  也能够看得出来,每次插入一个字符都须要遍历一下链表,第一次遍历的时候链表长度为1(就是根节点),第二次遍历的时候链表长度为2(点a,和根节点,参见Trie(a) ),以此类推,可知遍历的总复杂度是O(|T|*|T|),创建链表也须要O(|T|*|T|),后续压缩Trie也须要O(|T|*|T|),故而整个算法复杂度就是O(|T|*|T|)。

  如今说明一下为何算法是正确的?Trie(Pi-1)存储的是Pi-1的全部后缀,Trie(Pi)存储的是Pi的全部后缀。Pi的后缀能够由Pi-1全部后缀后面插入字符ti,以及后缀ti所构成。那么咱们沿着Trie(Pi-1)尾部链表插入字符ti的过程也就是插入Pi的全部后缀的过程,全部算法是正确的。

  可是,有没有小失望,毕竟干了这么久发现跟第一种方法相比没有收益(哭!)。

  其实不用失望,咱们作这么多的目的在于经过改进,整个算法能够实现线性的,下面就一步步介绍这种改进算法。

 

三,改进第二种算法以实现线性时间创建后缀树

  1 直接在后缀树上操做  

  首先一点咱们必须直接在后缀树上操做了,不能先创建Trie树再压缩,由于遍历Trie树的复杂度就已是平方级别了。

  咱们定义几种节点:

  •   叶节点:   出如今后缀树叶子上的节点;
  •   显式节点:全部出如今后缀树中的节点。显然叶节点也是显示节点;
  •   内部节点:显示节点中不是叶子节点的全部节点;
  •   隐式节点:出如今Trie树中可是没有出如今后缀树中的点;(由于路径压缩)

5046078486_397fb08303

 

  接下来咱们来看看前面提到的尾部链表,尾部链表显然包含了当先后缀树中的叶节点以及部分的显式/隐式节点。沿着尾部链表更新:

  • 遇到叶子节点时只需往叶子所在的边上面的字符串后面插入字符就行了,不用改变树的结构;
  • 遇到显式节点的时候,先看看插入的字符是否出如今显式节点后紧跟的字符集合中(好比上图中红色的显式节点后紧跟的字符集和就是{s,p}),若是插入的字符出如今集合中,那么什么也不要作(是指不用改变树的结构),由于已经存在了;若是没有出现,在显式节点后面增长一个叶子,边上标注为这个字符。
  • 遇到隐式节点时,同样,先看看隐式节点后面的字符是否是当前将要插入的字符,若是有则不用管了,没有则须要将当前隐式节点变为显式节点,再增长新叶子。

  咱们用个例子来讲明一下怎么操做,为了便于说明隐式节点,我采用Trie树表示:

QQ截图20131221103813 QQ截图20131221105514

  从第三个图到第四个图,沿着尾部链表插入字符a,那么链表第一个节点为叶节点,故而直接在边上插入这个字符就行了;链表第二个节点仍是叶子,在边上插入字符就行了;第三个节点是隐式节点,看看紧跟着隐式节点后面的字符,不是a,故而将这个隐式节点变为显式节点,再增长一个叶子;第四个是显式节点(根节点),其紧跟的字符集和为{a,b},a出如今这个集合中,故而不用改变结构了。固然了,链表仍是要维护的啊,O(∩_∩)O哈哈~

  好了,到此,咱们实现了直接在后缀树上操做而彻底撇开Trie树了,小有进步啦,~\(≧▽≦)/~啦啦啦

  如今开始优化啦!

  2.  自动更新叶节点

  首先一点,在后缀树上直接操做的时候,边上的字符串就不必直接存储啦,咱们能够存这个字符串对于在原先总的字符串T中的坐标。如上方右边那个图就是将左边第四个图,压缩以后获得的后缀树。[2,4]就表示baa。

  这样一来啊,存储后缀树的空间就大大减少了。

  接着,咱们来看一下啊,后缀树S(Pi-1)中的叶子节点在S(Pi)中也是叶子节点,也就是说”一朝为叶,终身为叶“。并且咱们还能够注意到尾部链表的前半部分全是叶子。也就是说若是S(Pi)有k个叶子,那么表示T[0 .. i],……,T[k-1 .. i]后缀的点全是叶子。

  咱们首先来看一下何时后缀会不在叶子上:T[j .. i-1]不在S(Pi-1)叶子上,代表表明该后缀的点以后还有点存在,也就是说T[0 .. i-1]中存在子串S=T[j .. i-1] + c’ ,其中c'不为空。注意一下这是充分必要条件,由于叶子节点后面是不可能还存在点的。

  如今咱们来证实一下:(ti加入到 S(Pi-1) 的过程)

    • 首先,T[0 .. i-1]确定在叶子上。为何呢,由于在S(Pi-1)中T[0 .. i-1]是最长的,若是它不在叶子上,那么必然存在比T[0 - i-1]还长的串,矛盾,故而T[0 .. i-1]必定在叶子上。
    • 其次,对于任何 j < i-1, 若是 T[j .. i-1] 不在树叶上,那么 T[j+1 .. i-1] 更不可能在树叶上;为何呢,由于T[j .. i-1]不在叶子上代表T[0 .. i-1]中存在子串S=T[j .. i-1] + c’ ,其中c'不为空。那么T[0 .. i-1]中y也必然存在子串S‘=T[j+1 .. i-1] + c’,由于S’是S的后缀。故而 T[j+1 .. i-1]也不在叶子上
    • 因而咱们知道k个叶子必定是T[0 .. i],……,T[k-1 .. i]

  咱们来利用一下上述性质。叶节点每次更新都是把ti插入到叶子所在边的后缀字符串中,因此表示字符串的区间就变成了[ , i]。那么咱们还有必要每次都沿着尾部链表去更新么?

  咱们能够这样,将叶子那个边上的表示字符串的区间用[ , #]来表示,#表示当前插入字符在T中的下标。那么这样一来,叶子节点就自动更新啦。

  再利用第二个性质,咱们彻底就能够无论尾部链表的前k个节点啦

  这是又一大进步!

  我们接着来!

  3. 当新后缀出如今原前后缀树中

  咱们来看,根据沿尾部链表更新的算法,不管是显式节点仍是隐式节点,当带插入字符ti出如今节点的紧跟字符集合的时候,咱们就不用管了。也就是说若是T[j .. i]出如今S(Pi-1),也就是S(T[0 .. i-1]),中的时候,咱们就不用改变树的结构了(固然须要还调整一些参数)。

  咱们再来看,对于任何 j < i-1,若是T[j .. i]出如今S(T[0 .. i-1])中,那么T[j+1 .. i]也必然出如今S(T[0 .. i-1])中。下面给出证实:

  • 首先咱们知道T[0..i-1] 的全部后缀都在后缀树中。
  • 其次,T[0..i-1] 的任意子串均可以表示为它的某一个后缀的前缀。
  • 因此 T[0..i-1] 的全部子串都在后缀树中。
  • T[j+1 .. i] 是 T[j..i] 的子串, T[j..i] 又是 T[0..i-1] 的子串(由于T[j .. i]出如今S(T[0 .. i-1])中),因此 T[j+1 .. i] 也是 T[0..i-1] 的子串。
  • 因此后缀树中存在 T[j+1 .. i]

  这也就是说若是尾部链表中某一个节点所表明的后缀加上ti,也就是T[j .. i],出如今S(T[0 .. i-1])中,那么链表后面的全部节点表明的后缀加上ti也都出如今S(T[0 .. i-1])中。

  故而全部这些点,不管是显式仍是隐式节点均可以不用管了。

  这又是一个大优化!

  综合上面两个优化,咱们知道事实上咱们只须要处理原先尾部链表的中间一段节点就能够了,对于这些节点而言,每处理一次一定增长一个新叶子(为何呢,由于这些节点既不是叶子节点,又不知足显或是隐式节点不用增长叶子的条件)。而”一朝为叶,终身为叶“,咱们最终的后缀树S(T[0 .. n])只有n个叶子(其中tn=$)。(为何呢,由于不可能存在子串S = T[j .. n]+c’,由于这要求子串中$以后还有字符,这是办不到的),这也就是说整个建树过程当中咱们一共只须要在尾部链表上处理n次就能够了,这是一个好兆头!

  种种迹象代表咱们快到O(|T|)时间了,哈哈,原理就先说这么多了。能不能实现最终的线性时间,就看下一节--线性时间内构建后缀树!

 

四 引用

1. http://www.cnblogs.com/snowberg/archive/2011/10/21/2468588.html

2.  http://blog.csdn.net/v_july_v/article/details/6897097

3.  On–line construction of suffix trees

相关文章
相关标签/搜索