Ukkonen后缀树算法的真·清晰解释

本站有个翻译的文章,名字叫Ukkonen 的后缀树算法的清晰解释。这篇文章写得不错,但是还是犯了错误的。

我按照这篇文章的说明实现了所谓的Ukkonen算法,但是在测试时出现了错误,事后,进行了大量的排查(因为我一开始认为肯定不是文章的问题是我的问题,花了2天没解决,最后抛开“文章正确”的观点才解决)

下面,是我重新整理后的Ukkonen算法,采用的例子还是"abcabxabcd"。

生成后缀数和绘图的C++源码在http://www.oschina.net/code/snippet_593413_38384

一开始依次插入‘a‘、‘b‘、‘c‘三个字符时,后缀树如下:

图中,绿色的节点表示活动节点;节点内填充黄色,表示为根节点;没有活动边,说明活动长度为零。

图中的黑色箭头表示的边,x:(n,#)格式的,表示一个指向叶节点的边,x为该边第一个字符,n为该边起始的位置。正常的边会列出其包含的所有字符。

叶节点的值,表示该叶节点代表的后缀的起始位置。

从上面的例子说明,当向一个活动长度为零的活动点插入已有的边(的首字符)不存在的字符时,会插入新的边和叶节点。剩余后缀数会在扫描前+1,插入后-1,因此保持为0。

下一步,插入‘a‘(当前扫描位置3):

因为字符‘a‘已经存在于某个边中,于是我们就将活动三元组置为(root,‘a‘,1),剩余后缀数,并完成本次字符的扫描,事实上,活动边不是表示为‘a‘,而是3——这表示活动边是以文本的索引为3的字符起始的。即,活动三元组为(root,3,1)。剩余后缀数在扫描前+1,而本次没有插入后缀,因此剩余后缀数为1。

图中,绿色箭头表示一个活动边,而该边最后冒号后面的数字表示活动长度。

下一步,插入‘b‘(当前扫描位置4):

因为字符‘b‘也已经存在于活动边的下一个位置,于是我们就将活动三元组置为(root,3,2),剩余后缀数为2,并完成本次字符的扫描。

下一步,插入‘x‘(当前扫描位置5):

要插入‘x‘,当前边为"abcabx",而活动长度为2,该边下一个字符是‘c‘不是‘x‘,因此我们将该边分割开,也就是图中的ab边和c:(2,#)边及两者间的节点,并且向该节点添加一个新的边x:(5,#)和叶节点3。

为啥叶节点是3?因为当前后缀数为3,表示我们插入的后缀是"abx",在该后缀前面的部分是"abc",因为"ab"是隐含在已有后缀树里的,实际插入的边是从‘x‘开始的,但是叶节点却是从第二个‘a‘开始的,其索引为3。简化计算的话,就是:当前扫描位置+1-剩余后缀数。

在插入新的边之后,剩余后缀树-1;因为剩余后缀数>0,我们要重复插入后缀的操作,直到剩余后缀数==0或者遇到该后缀被隐含的情况。

在插入新的边和节点后,需要更新活动三元组。因为活动节点是根节点,操作为:活动边(索引)+1,活动长度-1,因此新的三元组为(root,4,1)。

文本索引4的位置,字符为‘b‘,据此,我们确定新的边为b:(1,#)。

因为循环,我们将该边分割、插入了新的边x:(5,#)和叶节点4。

同时,根据规则,我们要添加后缀指针,后缀指针会添加到扫描一次字符的过程中,因为分割边出现的新的内部节点之间(从旧到新)。假设一次扫描中,因分割出现的新节点依次为a,b,c;则应添加后缀指针,a -> b和b -> c。

图中,后缀指针用红色箭头表示。

此时,更新三元组为(root,5,0),而剩余后缀数为1。

下一次插入操作,和插入索引1、2、3位置的字符时一样的规则,在根节点(活动节点)添加了新的边x:(5,#)和叶节点5。

剩余后缀数为0,本次扫描结束。

下一步,插入‘a‘(当前扫描位置6):

更新三元组为(root,6,1),剩余后缀数1

下一步,插入‘b‘(当前扫描位置7):

更新三元组为(root,6,2),剩余后缀数2;因为抵达了新的点,三元组重置为(green,6,0)

下一步,插入‘c‘(当前扫描位置8):

更新三元组为(green,8,1),剩余后缀数3;

下一步,插入‘d‘(当前扫描位置9):

进行了一系列的后缀插入:

剩余后缀数+1;

分割c:(2,#)=>0边,然后因为活动点有后缀指针,因此活动点重置为该点(root经边b到达该点),而活动边和活动长度保持为8和1

分割c:(2,#)=>1边,生成新的后缀指针。此时,活动点没有后缀指针了。活动点也不是根节点。为了找到下一个活动点,有两种方法。

1)总而言之,我们知道剩余后缀数和当前扫描位置,换句话说,我们知道当前要插入的后缀,因此从根节点沿着该后缀查找就是了。

当前扫描位置9,剩余后缀数2,因此当前要插入的后缀从8起始,插入的是[8,9]即"cd",重置活动三元组为(root,8,1):即根节点、当前扫描位置-剩余后缀数+1,剩余后缀数-1

然后对这个三元组进行修正,也就是沿着后缀树走,直到活动长度为0或者小于活动边的长度。

因此,我们找到了root经边c到达的节点。

2)将活动三元组更新为(当前活动点,当前扫描位置-当前活动长度,当前活动长度)

从该活动点向父节点走,每次移动到父节点,都要让活动边(索引)减去经过的边的长度,而当前活动长度加上经过的边的长度

直到根节点,此时的操作和活动节点本来就在根节点是一样的:活动边(索引)+1,活动长度-1

或者移动到的节点有后缀指针,那么,我们沿着后缀指针移动一次活动节点,活动边和活动长度不变

到达根节点或者言后缀指针移动后,也要进行修正,沿着后缀树走,直到活动长度为0或者小于活动边的长度。

这两种方法不管采用哪一种,都会到达同一个点。一般说,树较小时,第一种更简单,而树比较复杂的时候,第二种更好。

分割c:(2,#)=>1边,生成新的后缀指针。活动三元组更新为(root,9,0),剩余后缀数1

插入新的边d:(9,#)和新的点9

本次扫描结束。

…………

这个过程可以一直持续下去,知道接受了一个终止标识符,或者进行结束操作。

因为扫描‘d‘后,剩余后缀数已经减少到0了,我们的结束操作只是将根节点设置为一个后缀标识节点,代表空后缀。

事实上,因为‘d‘在字符串中仅在最后出现了一次,它的行为和扫描终止标识符是相同的。如果我们并不真的插入终止标识符(甚至我们都不用去比较终止标识符和活动位置下一个字符),将每一次添加仅含终止标识符的边和叶节点的操作替换为修改节点的属性,那么就是一个标准的结束操作了。

总的来说,我们只做了一个修改,那就是活动三元组的更新规则。

确切地说,是活动点不是根节点且没有后缀指针的时候的更新规则。

本文中的图都是用graphviz生成的。

时间: 2024-12-25 20:12:45

Ukkonen后缀树算法的真·清晰解释的相关文章

自己写的一个后缀树算法查找一个字符串的最长重复子串

在上个星期面试一家公司的笔试题上面的最后一道题就是写程序查找一个字符串的最长重复子串.当时想了很长时间没想出什么好方法,就把一个算法复杂度比较高的算法写上去了.回来上机把那个算法写了一遍测试没问题,然后自己又到网上面查查还有什么方法,然后发现好像有种叫做后缀树的方法,然后看那个方法,当时没给出代码,看图看了老半天加之自己想了好几个小时终于知道后缀树是个什么东西.然后自己萌生了一个自己写一个后缀树算法解决那个重复子串的问题.然后写了一天终于写出来了.后续有做了一些测试,发现自己写的一个只有几十个字

后缀树

在<字符串匹配算法>一文中,我们熟悉了字符串匹配问题的形式定义: 文本(Text)是一个长度为 n 的数组 T[1..n]: 模式(Pattern)是一个长度为 m 且 m≤n 的数组 P[1..m]: T 和 P 中的元素都属于有限的字母表 Σ 表: 如果 0≤s≤n-m,并且 T[s+1..s+m] = P[1..m],即对 1≤j≤m,有 T[s+j] = P[j],则说模式 P 在文本 T 中出现且位移为 s,且称 s 是一个有效位移(Valid Shift). 比如上图中,目标是找出

后缀自动机/后缀树

只是笔记罢了,不要看 关于DAWG: 见紫书P390 把后缀自动机上所有节点都设为接受态就形成DAWG,可以接受一个字符串的所有子串. 一个子串的end-set是它在原串w中出现位置(从1开始编号)的右端点集合. 在DAWG中,end-set相同的子串属于同一个状态. 原因没原因,这应该算定义吧? 任意两个节点的end-set要么不相交,要么是包含关系. 原因:在DAWG上走一步,当前end-set的变化是将原end-set中各个元素+1(要去掉超出字符串长度的元素),然后拆分成1个或多个新en

利用后缀数组(suffix array)求最长公共子串(longest common substring)

摘要:本文讨论了最长公共子串的的相关算法的时间复杂度,然后在后缀数组的基础上提出了一个时间复杂度为o(n^2*logn),空间复杂度为o(n)的算法.该算法虽然不及动态规划和后缀树算法的复杂度低,但其重要的优势在于可以编码简单,代码易于理解,适合快速实现. 首先,来说明一下,LCS通常指的是公共最长子序列(Longest Common Subsequence,名称来源参见<算法导论>原书第3版p223),而不是公共最长子串(也称为最长公共子串). 最长公共子串问题是在文本串.模式串中寻找共有的

Scala前缀,中缀及后缀运算详解

语法: PostfixExpr ::= InfixExpr [id [nl]] InfixExpr ::= PrefixExpr | InfixExpr id [nl] Inf2424ixExpr PrefixExpr ::= [?-? | ?+? | ?!? | ?~?] SimpleExpr 表达式由算符和操作数构成. 6.12.1. 前缀运算 前缀运算op e由前缀算符op(必须是?+?, ?-?, ?!?或?~?之一).表达式op e等价于后缀方法应用e.unary_op. 前缀算符不同

JavaScript编码指南

出其不意 1920年,William Strunk Jr的<英文写作指南>出版了,这本书给英语的风格定下了一个规范,而且已经沿用至今.代码其实也可以使用相似的方法加以改进. 本文接下来的部分是一些指导方针,不是一成不变的法律.如果能够清晰解释代码含义,当然有很多的理由不这样做,但是,请保持警惕和自觉.他们能经过时间的检验也是有理由的:因为他们通常都是对的.偏离指南应该有好的理由,并不能简单因为突发奇想或者个人偏好就那么做. 基本上写作的基本准则的每一部分都能应用在代码上: 让段落成为文章的基本

WPF-MVVM模式学习笔记4——Lambda表达式学习

在学习MVVM的过程中,其中自定义了一个超类NotificationObject,如下 public abstract class NotificationObject : INotifyPropertyChanged { public event PropertyChangedEventHandler PropertyChanged; protected virtual void RaisePropertyChanged(string propertyName) { PropertyChang

字符串匹配算法总结

转自:http://blog.csdn.net/zdl1016/archive/2009/10/11/4654061.aspx 我想说一句“我日,我讨厌KMP!”.KMP虽然经典,但是理解起来极其复杂,好不容易理解好了,便起码来巨麻烦!老子就是今天图书馆在写了几个小时才勉强写了一个有bug的.效率不高的KMP,特别是计算next数组的部分. 其实,比KMP算法速度快的算法大把大把,而且理解起来更简单,为何非要抓住KMP呢?笔试出现字符串模式匹配时直接上sunday算法,既简单又高效,何乐而不为?

再见,掌机:PSP陪伴我的青葱岁月

一觉醒来,在惯例浏览新闻时突然发现一条让我情绪瞬间波动的消息:PSP将在年末正式停止发售!刹那间睡意全无,脑中全部闪现的是自己为PSP着魔的青葱岁月.作为一名职业撰稿人,如果不为即将逝去的PSP写篇悼文,又怎么对得起它陪伴我的那些年? 吃糠咽菜,只为PSP 实事求是的说,作为一个在小城市农村长大的孩子,第一次见到PSP还是在2006年年底--当时PSP 1000已经发布了两年.但第一次见到它,就被深深地吸引住.那时候我还在上高二,是在过年的时候一个在市里居住的表哥带来的.他拿着PSP 1000向