后缀树是一种用于字符串处理的强有力的数据结构。事实上也是我见过最精妙与复杂的数据结构,它几乎能完成字符串处理需要的所有功能(几乎……至少在我所知道的范围内)。
在这篇文章中,我将先介绍后缀树的结构和应用,接着阐述它的线性构造算法。
后缀树的结构
后缀树是一棵Trie树(准确地说是一棵Patricia trie,如果不知道Trie树是啥的请询问狗狗)。与Trie存储多个不直接相关的字符串不同,一棵后缀树存储的,是与它对应的某个字符串(下文称“原串”)的所有后缀。准确地说,原串的所有后缀都能由根节点到某个叶子节点路径上的字符组合起来得出。比如:bananas。它所有的后缀为:bananas,ananas,nanas,anas,nas,as,s。而它对应的后缀树就像图1这样:
后缀树的另一个性质是:任何一个叶子节点所“代表”的字符串(根节点走到这个节点过程中边上字符构成的串)一定是原串的一个后缀(反过来则不一定)。这一看似显然的性质在下面将会非常有用。
很容易看出后缀树的一些用途,比如我们可以利用一棵s的后缀树来做字符串s的模式匹配——只需要验证待匹配的串是否能在后缀树中查找到即可。
但是一个很糟糕的事实是这样的算法注定不可能比纯枚举快太多,因为如果s长度为n,那么它的后缀树的节点数为O(n^2)。也就是说构造这样一棵树的时间复杂度至少为O(n^2),而这个复杂度即使是纯枚举也已经够了。
于是我们需要想办法减少一些节点。考虑图1中bananas的后缀树,很多节点都只有一个儿子……这不得不让人产生一些河蟹的想法。我们将这样的节点和它的子节点合并,得出了图2的一棵树:
和通常的Trie不同,这棵树的边上存储的是字符串而非单个字符。将只有一个儿子的节点与它的儿子合并的操作称为路径压缩(pathcompression)。根据证明,这样的一棵长度为n的字符串的后缀树,其节点数不超过2n。这个牛B技巧使得在O(n)时间内构造后缀树成为可能。
后缀树的一些应用
下面的东西转自英文wikipedia。
A suffix tree for a string S of length n can be built in Θ(n) time, if the alphabet is constant or integer[6].Otherwise, the construction time depends on the implementation. Thecosts below are given under the assumption that the alphabet isconstant. If it is not, the cost depends on the implementation (seebelow).
Assume that a suffix tree has been built for the string S of length n, or that ageneralised suffix tree has been built for the set of strings D = {S1,S2,...,SK} of total length n = | n1 | + | n2 | + ... + | nK | . You can:
The suffix tree can be prepared for constant time lowest common ancestor retrieval between nodes in Θ(n) time ([5] chapter 8). You can then also:
看起来挺吊人胃口,但是上面的应用几乎都依赖于后缀树的线性构造。下面我就将介绍它的线性构造算法。
算法的轮廓
这里介绍的算法由Ukkonen提出,这是一个在线的算法,也就是说数据输入和后缀树的构造是同时进行的。那么,当我们输入了字符串bookke的前4位时,book的后缀树就应该已经构造好了,就像图3那样:
现在我们输入了k。book的后缀有book,ook,ok,k,bookk的后缀有bookk,ookk,okk,kk,k。对比一下可以发现原来book的每一个后缀后面都多出了一个k,同时也多出了一个单独的后缀k(原来的k已经变成kk了,这里的k是新加的)——当然我们也可以把它看作空串多出一个k的结果。这样的话,我们可以考虑一下找出book的所有后缀(包括空串),然后在后缀树中延展它们。
于是我们得到了基本的思路:每次读入新的字符后,在后缀树中 从长到短查找当前原串的后缀,将它们加上新输入的字符。(对于空串来说,在它后面加字符相当于直接在根节点上进行操作。)在处理完所有后缀后,我们便已经将新的字符加入到了后缀树中。如此将某个字符串上所有的字符加入完后,我们便得到了它的后缀树。
插入与压缩
现在我们来看看例子。在book上插入k。我们先定位到book所在的节点(蓝色节点),然后在下面加入一个新的节点……咦?这样book那个节点不是只有一个儿子了么……于是将它路径压缩掉。整个来看,我们所做的只不过将book这条边上加上了k而已。就像图4:
现在看看所有的叶子节点,很好很强大:如果我们像刚才一样需要在它的下面插入什么东西,由于一次只能插入一个字符的缘故,我们会发现那样总是会导致一次路径压缩操作。这样的话,所有的叶子节点都可以看作不会在下面插入东西,也就是说,一个叶子节点永远都是叶子节点。
这样的话,我们似乎可以动点手脚优化下在叶子节点插入这个过程了。因为所有叶子节点都代表当前字符串的一个后缀,因此读入新字符后它们都会被插入新的字符(我们在做啥来着?寻找所有后缀并插入~)。又因上面说的路径压缩的必然出现,我们便可以利用一种简化的表示方式来省去叶子节点上的插入操作。
我们改变边上字符(串)的存储方式,不是直接存储串,而是用数对
而叶子节点上的数对有些特别。例如book将会以<1,_|_>表示,符号”_|_”表示当前输入的串的末尾,也就是说当原串为book是_|_=4,而原串变为bookk时_|_=5。
仔细想想可以发现这种代表方式可以“自动”地完成叶子节点上后缀的插入。
回到例子,在对book自动插入了k后。我们查找ook,ok,k,继续进行什么也没有的操作。到最后实际需要做的,只是在根节点上插入k。像根节点一样,在查找后缀时找到的第一个非叶子节点称为活动节点(activepoint)。既然现在我们不需要在叶子节点上插入了,这里就将是我们真正展开插入的地方。
有趣的是现在的情况有些不一样:因为根节点已经有一个儿子的边上第一个字符为k,所以我们的插入操作实际上什么也不需要做(觉得疑惑的话请看注解)。这种情况称为根节点已经“存在正确的儿子”。这是一种情况,如果要插入的是一个f,或者别的什么,那我们就需要为根节点创建一个儿子,然后在边上记上f。
这一轮插入真是轻松呢。
路径压缩带来了麻烦
先看看我们的工作成果:(图5)
现在我们继续——在原串上加上一个e。
就像刚才说的,我们查找bookk,ookk,okk,kk(都是叶子节点)并且忽悠着“插入”e。接下来,问题就来了。我们要查找的k……它根本不在一个节点上!事实上它是边“kk”的一部分……
现在我们给出两个定义:
1、 显式(explicit)节点:就是你在当前后缀树中看到的所有非叶子节点。
2、 隐式(implicit)节点:路径压缩中被压缩掉的节点。(上面所有图中红色的节点)。
现在代表”K”的节点既是隐式节点又是活动节点。
于是我们发现,如果查找的后缀落在隐式节点上,事情就变得很麻烦了。我们需要将隐式节点变为显式节点。图6给出了这一有些麻烦的操作:
运气好些的话。我们也可能遇到查找到的隐式节点存在正确儿子的情况,这时也无需考虑,直接无视即可。
现在,我们得到了4种在查找到的后缀上插入一个字符的方式:
查找到的节点 |
存在正确的儿子 |
操作 |
显式节点 |
否 |
创建新的叶子节点 |
是 | 忽略,搜索下一个后缀 |
|
隐式节点 |
否 | 将其变为显式并插入新叶子节点 |
是 | 忽略,搜索下一个后缀 |
无比科学的东西
上面的“正确的儿子”节点称为结束节点(endpoint)。按照Ukkonen的说法,一旦找到某个结束节点,那么更短的后缀也会在一个存在结束节点的节点上——我们不需要任何操作。这样的话,一旦找到结束节点,当前字符的加入便算是结束了,我们可以读入下一个字符。为了下面的方便,如果所有的后缀都处理完了而没有找到结束节点,那我们将根节点称为结束节点。
还有更神奇的地方,在遇到一个结束节点之前,我们已经处理过(加好了新字符)的后缀要么是叶子节点,要么由上面表格里的两种操作进行了新建节点的操作,并且新建好的都是叶子节点。这样下次再加入字符时,那些后缀都不需要考虑了——因为它们都由叶子节点表示。于是,当读入新的字符时,上一轮中的结束节点便成为了活动节点!而按照活动节点的定义,我们可以一开始就对它代表的后缀进行插入处理,这样就节省了查找比活动节点长的那些后缀的时间。
于是我们将上面的表改一下。
查找到的节点 |
存在正确的儿子 |
操作 |
显式节点 |
否 |
1.创建新的叶子节点 |
是 | 2.将其正确的儿子记为下一次的活动节点,退出当前字符的加入 |
|
隐式节点 |
否 |
3.将其变为显式并插入新叶子节点 |
是 |
4.将其正确的儿子记为下一次的活动节点,退出当前字符的加入 |
让我们总结一下
算法的步骤(加入一个字符):
1、找到活动节点(由上一个字符插入结束时记录),从活动节点代表的后缀开始
2、按照上面的表格进行插入
3、 找到结束节点--->记录结束节点为活动节点,退出
未找到结束节点--->搜索下一个更短的后缀,转2
接近线性了
上面的算法已经有了相当的价值,但是它依然不是线性时间的。问题出在“搜索更短后缀”的部分,虽然我们已经通过活动节点和结束节点的强大性质避免了大部分这样的操作,但这并不足以将时间降到线性。于是,Ukkonen发明了一个强力的工具:后缀链接(Suffix Pointer)。
后缀链接是一个指针,从一个非叶子显式节点开始,指向图中的其它节点。节点A有后缀链接指向节点B当且仅当他们代表的字符串sa,sb满足如下关系:sa=sa[1]+sb。(sa[1]为sa的第一个字符)。也就是说sb是sa的第二个后缀(如果sa本身也算第一个的话)。
下面是一幅出自wikipedia的图,它展示了banana$的后缀链接:
这样的话,当我们处理完某个由显式节点表示的后缀时,我们便可以利用它的后缀链接直接跳跃到下一个待处理的后缀所在的节点。这样就更进一步减少了搜索后缀所需的时间。
那么怎么创建这样一个链接呢?需要注意一个非叶子显式节点的创建只有可能在上表中的情况3里。又因为我们在搜索后缀时是按由长到短的顺序的——刚好符合后缀链接的定义。所以我们只需要在创建一个非叶子显式节点后记录一下,待手工搜索到下一个后缀时将刚才记录的点的后缀链接指过来即可。
根据Ukkonen的证明,在使用了后缀链接后,我们的构造算法便达到了我们期望的线性时间。
注解:
可能这里有读者会疑惑:为什么这里插入k可以什么都不做呢?这样的行为导致的一个结果就是当在后缀树中查找某个字符串时,如果我们的终点落在一个非叶子节点或边上(比如在这里查找k)我们将不会知道这个字符串到底是一个后缀,还是说它只是某个后缀的一部分。解决的方法通常是在原串后面添加一个终止符,比如”$”。这样的话bookk就变成bookk$,而我们在查找时只要查找”k$”就可以保证查到的一定是一个后缀了。
当然,bookk$的后缀树将会和bookk的有些不同。
程序实现上不得不提的一点:
活动节点其实可以用活动节点所代表的字符串在原串上的起点来表示。
活动节点代表的后缀会随着字符的读入变长,但起点不动。而完成一个插入则使这个起点向后移(处理下一个后缀)。我们甚至可以将它和搜索更短后缀的过程同时处理。直接记录当前待插入的后缀的起点,每完成一次插入就将其inc(1);找到结束点的话这个起点就不动,转而读取下一个字符然后再回来处理。
这样做的好处是:当我们必须手工查找某个后缀时,可以通过对起点的记录很快找到需要查找的后缀。
参考文献:
E. Ukkonen. On-line construction of suffix trees. Algorithmica, 14(3):249-260, September 1995.
Mark Nelson. Fast String Searching With Suffix Trees, Published in Dr. Dobb's Journal August, 1996
相关学习笔记: http://blog.csdn.net/hgd250/article/details/5285529
扩展版的SuffixArry,即以单词为单位进行匹配,而不是单个字符的形式源码到此处下载(不要分,免费下):http://download.csdn.net/source/2045119