构造后缀树

后缀树是字符串匹配算法中一种重要的数据结构,同时使用这种数据结构可以完成许多关于字符串的算法.可是看了许多blog都没有说怎么构造,只有所谓的利用哈希来构造,在网上找到了这一篇译文,便转载过来,周末有时间写一个使用哈希表来构造后缀树的博客吧

 

后缀树

Fast String Searching With Suffix Trees

 

 

 

 

原著

 

Mark Nelson. Fast string searching with suffix trees. 1996.

 

 

 

构造法

 

E. Ukkonen. On-line construction of suffix trees. 1995.

 

 

 

翻译

 

3xian / 三鲜 in GDUT

 

 

 

 

 

 

 

 

三鲜 序

原来是打算翻译Sartaj SahniSuffix tree, 并专注地进行了一周连复习备考的时间也不惜占去我希望给国产的同好者提供更通俗易懂的资料在翻译的同时对原文进行了删改并加入了许多自己的心得然而后来发现了Mark Nelson的这篇文章相比之下更有亲和力于是老实地尽弃前功来翻译这篇更重要一个原因是, Mark Nelson介绍的是Ukkonen的构造法O(n), 它比Sartaj Sahni的构造法O(nr), r为字母表大小 在时间上更有优势但我们不能说Sartaj Sahni的算法慢因为r往往会很小因此实际效率也接近线性两种构造法在思想上均有可取之处.

 

 

 

本文偏重于阐述后缀树的构造过程而并没有直接介绍后缀树除了匹配以外还能做什么其实后缀树是一种功能非常强大的数据结构你可以去搜索引擎了解一下它还有多少功能当然我最希望的是你在阅读本文之后已经足以体会后缀树的妙处日后遇到诸多问题的时候都能随心随意地用上.

 

 

 

最后唠叨一句我所见过的各种介绍后缀树的论文都难免使初学者陷入混乱本文估计也好不到哪里去这在一定程度上说明了后缀树的原理是不太浅显的理解它需要在整体上把握建议希望读者先不要纠结于细节思路不清则反复阅读.

 

 

 

 

问题的来源

字符串匹配问题是程序员经常要面对的问题字符串匹配算法的改进可以使许多工程受益良多比如数据压缩和DNA排列这篇文章讨论的是一种相对鲜为人知的数据结构 --- 后缀树并介绍它是如何通过自身的特性去解决一些复杂的匹配问题.

 

 

 

你可以把自己想象成一名工作于DNA排列工程的程序员那些基因研究者们天天忙着分切病毒的基因材料制造出一段一段的核苷酸序列他们把这些序列发到你的服务器里指望你在基因数据库中定位要知道你的数据库里有数百种病毒的数据而一个特定的病毒可以有成千上万的碱基你的程序必须像C/S工程那样实时向博士们反馈信息这需要一个很好的方案.

 

 

 

很明显在这个问题上采取暴力算法是极其低效的这种方法需要你在基因数据库里对比每一个核苷酸测试一个较长的基因段基本会把你的C/S系统变成一台古老的批处理机.

 

 

 

 

直觉上的解决方法

由于基因数据库一般是不变的通过预处理来把搜索简化或许是个好主意一种预处理的方法是建立一棵Trie. 我们通过Trie引申出一种东西叫作后缀Trie. (后缀Trie离后缀树仅一步之遥.) 首先, Trie是一种n叉树, n为字母表大小每个节点表示从根节点到此节点所经过的所有字符组成的字符串而后缀Trie的 “后缀” 说明这棵Trie包含了所给字段的所有后缀 (也许正是一个病毒基因).

 

 后缀trie

 

1

 


 

BANANAS的后缀Trie

 

 

 

1展示了文本BANANAS的后缀Trie. 关于这棵Trie有两个地方需要注意第一从根节点开始, BANANAS的每一个后缀都插入到Trie包括BANANAS, ANANAS, NANAS, ANAS, NAS, AS, S. 第二鉴于这种结构你可以通过从根节点往下匹配的方式搜索到单词的任何一个子串.

 

 

 

这里所说的第二点正是我们认为后缀Trie优秀的原因如果你输入一个长度为N的文本并想在其中搜索一个长度为M的串传统的暴力匹配需要进行N*M次字符对比而一些改进过的匹配技术比如像Boyer-Moore算法可以在O(N+M)的时间开销内解决问题平均效率更是令人满意然而后缀Trie亮出了O(M)的牌子彻底鄙视了其他算法的成绩后缀Trie对比的次数仅仅相当于被搜索串的长度!

 

 

 

这确实是可圈可点的威力这意味着你能通过仅仅7次对比便在莎士比亚所有作品中找出BANANAS. 但有一点我们可不能忘了构造后缀Trie也是需要时间的.

 

 

 

后缀Trie之所以没有家喻户晓正是因为构造它需要O(n2)的时间和空间平方级的开销使它在最需要它的领域 --- 长串搜索 中被拒之门外.

 

 

 

 

横空出世

直到1976, Edward McCreigh发表了一篇论文咱们的后缀树问世了后缀Trie的困境被彻底打破.

 

 

 

后缀树跟后缀Trie有着一样的布局但它把只有一个儿子的节点给剔除了这个过程被称为路径压缩这意味着树上的某些边将表示一个序列而不是单独的字符.

 

 

 

trie

 

2

 

 

BANANAS的后缀树

 

 

 

2是由图1的后缀Trie转化而来的后缀树你会发现这树基本还是那个形状只是节点变少了在剔除了只有一个儿子的节点之后总节点数由23降为11. 经过证明在最坏情况下后缀树的节点数也不会超过2N (N为文本的长度). 这使构造后缀树的线性时空开销成为可能.

 

 

 

然而, McCreight最初的构造法是有些缺陷的原则上它要按逆序构造也就是说字符要从末端开始插入如此一来便不能作为在线算法它变得更加难以应用于实际问题如数据压缩.

 

 

 

20年后来自赫尔辛基理工大学的Esko Ukkonen把原算法作了一些改动把它变成了从左往右本文接下来的所有描述和代码都是基于Esko Ukkonen的成果.

 

 

 

对于所给的文本T, Esko Ukkonen的算法是由一棵空树开始逐步构造T的每个前缀的后缀树比如我们构造BANANAS的后缀树先由B开始接着是BA, 然后BAN, … 不断更新直到构造出BANANAS的后缀树.

 

 

 

trie

 

3

 

 

逐步构造后缀树

 

 

初窥门径

加入一个新的前缀需要访问树中已有的后缀我们从最长的一个后缀开始(3中的BAN), 一直访问到最短的后缀(空后缀). 每个后缀会在以下三种节点的其中一种结束.

 

 

 

l         一个叶节点这个是常识了4中标号为1, 2, 4, 5的就是叶节点.

 

 

 

l         一个显式节点4中标号为0, 3的是显式节点它表示该节点之后至少有两条边.

 

 

 

l         一个隐式节点4前缀BO, BOO, 或者非前缀OO, 它们都在某条表示序列的边上结束这些位置就叫作隐式节点它表示后缀Trie中存在的由于路径压缩而剔除的节点在后缀树的构造过程中有时要把一些隐式节点转化为显式节点.

 

 trie

 

 

4

 

加入BOOK之后的BOOKKEEPER

 

(也就是BOOK的后缀树)

 

 

 

如图4, 在加入BOOK之后树中有5个后缀(包括空后缀). 那么要构造下一个前缀BOOKK的后缀树的话只需要访问树中已存在的每一个后缀然后在它们的末尾加上K.

 

 

 

4个后缀BOOK, OOK, OKK都在叶节点上结束由于我们要路径压缩只需要在通往叶节点的边上直接加一个字符而不需要创建一个新节点.

 

 

 

在所有叶节点更新之后我们还需要在空后缀后面加上K. 这时候我们发现已经存在一条从0节点出发的边的首字符为K, 没必要画蛇添足了换句话说新加入的后缀K可以在0节点和2节点之间的隐式节点中找到最终形态见图5.

 

 

 

trie

 

5加入BOOKK之后的BOOKKEEPER

 

 

相比图4, 树的结构没有发生变化

 

 

 

如果你是一位敏感的读者可能要发问了如果加入K我们什么都不做的话在查找的时候如何知道它到底是一个后缀呢还是某个后缀的一截如果你同时又是一位熟悉字符串算法的朋友心里可能马上就有答案了 --- 我们只需要在文本后面加个字母表以外的字符比如$或者#. 那我们查找到K$K#的话就说明这是一个后缀了.

 

 

稍微麻烦一点的事情

从图4到图5这个更新过程是相对简单的其中我们执行了两种更新一种是将某条边延长另一种是啥都不做但接下来往图5继续加入BOOKKE, 我们则会遇到另外两种更新:

 

 

 

1.    创建一个新节点来割开某一隐式节点所处的边并在其后加一条新边.

 

 

 

2.    在显式节点后加一条新边.

 

 trie

 

 

 

6

 

 

 

 

 

 

先分割再添加

 

 

 

当我们往图5的树中加入BOOKKE的时候我们是从已存在的最长后缀BOOKK开始一直操作到最短的后缀空后缀更新最长的后缀必然是更新叶节点之前提到了非常简单除此之外5中结束在叶节点上的后缀还有OOKK, OKK, KK. 6的第一棵树展示了这一类节点的更新.

 

 

 

5中首个不是结束在叶节点上的后缀是K. 这里我们先引入一个定义:

 

 

 

在每次更新后缀树的过程中第一个非叶节点称为激活节点它有以下性质:

 

 

 

1.    所有比激活节点长的后缀都在叶节点上结束.

 

 

 

2.    所有在激活节点之后加入的后缀都不在叶节点上结束.

 

 

 

后缀K在边KKE上的隐式节点结束在后缀树中我们要判断一个节点是不是非叶节点需要看它是否有跟待加入字符相同的儿子即本例中的E.

 

 

 

一眼可以看出, KKE中的第一个K只有一个儿子: K. 所以它是非叶节点(这里同时也是激活节点), 我们要给他加一个儿子来表示E. 这个过程有两个步骤:

 

 

 

1.    在第一个K和第二个K之间把边分割开于是第一个K(隐式节点)成了一个显式节点如图6第二棵树.

 

 

 

2.    在刚刚变身而来的显式节点后加一个新节点表示E, 如图6第三棵树由此我们又多了一个叶节点.

 

 

 

后缀K更新之后别忘了还有空后缀空后缀在根节点(节点0)结束显然此时根节点是一个显式节点我们看一下它后面有没有以E开头的边---没有那么加入一个新的叶节点(如果存在以E开头的边则不用任何操作). 最终如图7.

 

 

 

 trie

 

7

 

 

 

 

 

 

归纳反思优化

借助后缀树的特性我们可以做出一个相当有效的算法首先一个重要的特性是一朝为叶终生为叶一个叶节点自诞生以后绝不会有子孙更重要的是每当我们往树上加入一个新的前缀每一条通往叶节点的边都会延长一个字符(新前缀的最后一个字符). 这使得处理通往叶节点的边变得异常简单我们完全可以在创建叶节点的时候就把当前字符到文本末的所有字符一股脑塞进去是的我们不需要知道后面的字符是啥但我们知道它们最终都要被加进去因此一个叶节点诞生的时候也正是它可以被我们遗忘的时候你可能会担心通往叶节点的边被分割了怎么办那也不要紧分割之后只是起点变了尾部该怎么着还是怎么着.

 

 

 

如此一来我们只需要关心显式节点和隐式节点上的更新.

 

 

 

还要提到一个节约时间的方法当我们遍历所有后缀时如果某个后缀的某个儿子跟待加字符(新前缀最后一个字符)相同那么我们当前前缀的所有更新就可以停止了如果你理解了后缀树的本质你会知道一旦待加字符跟某个后缀的某个儿子相同那么更短的后缀必然也有这个儿子我们不妨把首个这样的节点定义为结束节点比结束节点长的后缀必然是叶节点这一点很好解释要么本来就是叶节点要么就是新创建的节点(新创建的必然是叶节点). 这意味着每一个前缀更新完之后当前的结束节点将成为下一轮更新的激活节点.

 

 

 

好了现在我们可以把后缀树的更新限制在激活节点和结束节点之间效率有了很大的改善整理成伪代码如下:

 

 

 

 

 

PLAIN TEXT

 

C:

 

1.     Update( 新前缀 )

 

2.     {

 

3.         当前后缀 激活节点

 

4.         待加字符 新前缀最后一个字符

 

5.         done = false;

 

6.         while ( !done ) {

 

7.             if ( 当前后缀在显式节点结束 ) {

 

8.                 if ( 当前节点后没有以待加字符开始的边 )

 

9.                     在当前节点后创建一个新的叶节点

 

10.              else

 

11.                  done = true;

 

12.          } else {

 

13.              if ( 当前隐式节点的下一个字符不是待加字符 ) {

 

14.                  从隐式节点后分割此边

 

15.                  在分割处创建一个新的叶节点

 

16.              } else

 

17.                  done = true;

 

18.          if ( 当前后缀是空后缀 )

 

19.              done = true;

 

20.          else

 

21.              当前后缀 下一个更短的后缀

 

22.      }

 

23.      激活节点 当前后缀

 

24.  }

 

 

 

 

 

 

 

 

后缀指针

上面的伪代码看上去很完美但它掩盖了一个问题注意到第21“下一个更短的后缀”如果呆板地沿着树枝去搜索我们想要的后缀那这种算法就不是线性的了要解决此问题我们得附加一种指针后缀指针后缀指针存在于每个结束在非叶节点的后缀上它指向“下一个更短的后缀”如果一个后缀表示文本的第0到第N个字符那么它的后缀指针指向的节点表示文本的第1到第N个字符.

 

 

 

8是文本ABABABC的后缀树第一个后缀指针在表示ABAB的节点上. ABAB的后缀指针指向表示BAB的节点同样地, BAB也有它的后缀指针指向AB. 如此这般.

 


 

 

 

8

 

 

加上后缀指针(虚线)ABABABC的后缀树

 

 

 

介绍一下如何创建后缀指针后缀指针的创建是跟后缀树的更新同步的随着我们从激活节点移动到结束节点我把每个新的叶节点的父亲的路径保存下来每当创建一条新边我同时也在上一个叶节点的父亲那儿创建一个后缀指针来指向当前新边开始的节点. (显然我们不能在第一条新边上做这样的操作但除此之外都可以这么做.)

 

 

 

有了后缀指针就可以方便地一个后缀跳到另一个后缀这个关键性的附加品使得算法的时间上限成功降为O(N).

 

 

参考文献

E.M. McCreight. A space-economical suffix tree construction algorithm. Journal of the ACM, 23:262-272, 1976.

 

 

 

E. Ukkonen. On-line construction of suffix trees. Algorithmica, 14(3):249-260, September 1995.

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值