双数组后缀树 Double-Array Trie(DAT)

LINK:http://blog.csdn.net/zzran/article/details/8462002

自己对树是情有独钟,故在元旦放假的时候,翻译了an efficient implementation of trie structures。作者及来源就不叙述了。英文水平有限,但是尽量还原此文章的灵魂。如果有什么不对的地方,敬请各位给予指点。(http://blog.csdn.net/zzran/article/details/8461985-源码).学习的时候要有trie的基础。

概论

    下面将呈现一种新的内部数组结构,它便是double-array。double-array继承了数组访问快速的特性和链表结构紧密的特点。对于double-array的插入,查找和删除将会通过实例来给出解析。虽然插入的过程很慢,但是还是很实用的,对于查找和删除,由于double-array继承了链表的特性,所以很速度。在操作大量关键的时候,我们把double-array和list形式(也就是原始trie的链表的形式)进行比较,会得到如下结果:double-array占用的空间比trie以链表的形式存储节省了百分之十七的空间,同时double-array的遍历,也就是查找的速度会比链表的形式快3.1到5.1倍。

简介

     在很多检索的应用中,有必要采用trie树的形式来检索单词。例如:编译器的单词分析器,拼写检查器,参考书目的检索, 在自然语言处理中的形态字典,等等。看到这里,是不是觉得trie是一个很强大的数据结果。对于trie树,例如它的节点是下面这样的几个struct形式: struct node {char data, struct node next[26]},这是最常见的trie节点形式。它是array-structured。对于next数组的索引index是由一个单词中data所存储字母的下一个字母来决定的。next[index]所指示或者是一个新的trie节点,或者是一个NULL值。图一给出了一个这样形式的trie树,它是基于关键字数组K = {baby, bachelor, badge, jar}建立的。trie树的检索,插入,删除都很快,但是它占用了很大的内存空间,而且空间的复杂度是基于节点的个数和字符的个数。如果是纯单词,而且兼顾大小写的话,每个节点就要分配52*4的内存空间,耗费很大。一种很多人都知道压缩空间的策略就是列出从每个节点引申出来的边,并以空值结尾。图二基于list-structured形式的trie。这种链表的形式通过对于数组形式中NULL值的压缩来节省空间,也就说指向子节点的指针是以链表的形式来存放而不是数组的形式。但是如果从每个节点引申出很多边的话,检索的速度会很慢。

    接下来这篇文章会讲解它trie树的结构压缩到两个一位数组BASE和CHECK中去,这种结构叫做double-array。在double-array中,通过数组BASE,非空子节点的位置被映射到CHECK中去,同时,原来array-trie中,每个节点的非空子节点的位置不会被映射到CHECK的相同位置中。trie树中的每条边都可以在double-array中以O(1)的时间检索到,也就是说,在最坏的情况下,检索一个长度为k的单词只要O(k)的时间。对于拥有大量关键字的结合,trie树种将会有很多节点,所以要用double-array的形式来达到减少空间分配的目的。为了能够让trie存储大量的关键字,double-array尽可能的根据需要存储关键字的前缀来区分不同的单词,除去前缀剩下的部分会被存储到TAIL的一维数组中,以便更进一步的区分不同的单词。

《双数组后缀树 Double-Array Trie(DAT)》

图一


《双数组后缀树 Double-Array Trie(DAT)》

图2

trie树的构建

     关于trie的叙述如下,从根节点到叶子节点形成的每条路径提取出来的字母组成的单词代表了在关键字集合中的一个关键字,或者说,这个提取出来的关键字可以在关键字集合中找到。所有的这些路径代表的单词加起来,就是关键字的结合。为了不混淆像“the”和“then”这样的单词,在每个单词的 后面多加一个结束符号:“#”。接下来的这些定义将会被用到插入,查找,删除的步骤中去,所以不求能够先理解这些,只要能够记得怎么用,在插入,查找,删除的过程中,就会逐渐明白这些定义的用意。K代表关键字集合。trie树是由弧和节点主城的。如果一条弧上标记着a,(注意,在这里a是代表子母集合中的某一个字母,而不是真正的a),那么这个弧可以这样定义 g(n,a)=m,其中n,m是trie树种节点的标号。解释一下n,m。我们用的是两个一位数组来存储的trie,所以n,m就是这两个一位数组中的索引。代表了trie的两个节点对于K中的一个关键字key,他的节点m满足关系g(n,a) = m,如果字母a是一个能够有效的将自己所属的关键字和其他关键字区分的字母的话,那么m就是一个独立的节点。怎么理解这个呢,看下图:

《双数组后缀树 Double-Array Trie(DAT)》


    还记得之前说过的吗,double-array尽量存储关键字的前缀来压缩空间,但是还要能够把这些拥有公共前缀的关键字区分开,就要求这些关键字要有自己的特色,特色就是独立于其他关键字的节点。看上图中的字母c,d,b,先说c吧,g(3,c) = 5,这条边中m=5是一个节点,这个节点能够把bachelo这个单词和其他单词区分开,那么m=5就是一个独立的节点。在这里,话题要进行一个转移,我们想一下原始的trie,就是上面的那个struct形式的那个,它会把每个单词的每个字母都以一个节点罗列出来,刚才我们找到了独立节点(不包括独立节点中的字母),那么从原始trie中对应的这个节点开始到单词最后一个字母所占的节点结束,这些字母所组成的字符串叫做独立节点m的字母串。也就是后面的内容都是由m衔接出来的。一棵树,它是由K集合中所有关键字的独立节点,和独立节点之前的节点,以及这些节点之间的边所组成的,那么我们就叫这棵树为reduced trie。

对于图三,就是一个棵reduced trie。TAIL是用来存储独立节点之后的string的。对TAIL中的符号?现在不要在意,等到我们分析插入和删除的时间就会用到,标记为?,其实相应的内容不是?,而是一些由插入和删除造成的无效字符。

reduced trie 和double-array,还有TAIL之间的关系如下

     1,如果在reduced trie中有一条这样的边:g(n, a) = m, then BASE[n] + a = m, and CHECK[m] = n.(在这个等式BASE[n] + a = m中,a代表的是一个字母,但是在相加的过程中会把它替换成一个整数,因为这个字母代表的是一条边,定义如下:“#”= 1, “a”=2,…”z”= 27)。在实际的编码中没有这么做,因为前面的定义只涉及到27个字符,实际应用中会涉及到更多的字符。


    2,如果m是一个独立的节点,那么string str[m] = b1b2…bn。then (a), BASE[m] < 0, (b), let p = – BASE[m], TAIL[p]= b1, TAIL[p + 1]= b2, TAIL[p + h + 1]= bn.

在整个论文中,这两个关系式是很重要的,所以请先用机械的方式把这两个关系记录下来,不要试图去理解,在检索,插入,和删除的过程中就会明白这样定义的目的和巧妙之处了。

trie树的检索

    先从检索说起吧,建设我们已经将K={bachelor, jar, badge, baby}中的关键字都处理过,放到reduced trie和TAIL中去了。以bachelor为例子作为检索吧。还是以下图为例。

《双数组后缀树 Double-Array Trie(DAT)》


    step1:把trie树的根节点放在BASE数组中第一个位置,然后从第一个位置开始,字母‘b’代表的弧的值为3,在上面定义过,从上面的关系1中可以得到:BASE[n] + ‘b’ = BASE[1]  + ‘b’ = BASE[1] + 3 = 4 + 3 = 7; 然后看CHECK[7]的值是1.


    step2,:因为在第一步中BASE[1]  + ‘b’ 得到的值是正数,如果不是正数,那它的值就代表独立节点后字符串在TAIL中存储的起始位置。是正数,继续进行。把得到的值7作为BASE数组的新索引,字母‘a’代表的弧的值为2,所以:BASE[7]+’a’=BASE[7] + 2 = 3 and CHECK[3]=7.先解释CHECK[3]=7吧,它表示的是指向节点3的弧是顺从节点7出发的。


    step3,4: ‘c’代表的弧值是4, step2中求得的3作为BASE的新索引,BASE[3]+4 = 5, CHECK[5]=3,


    step5,再看上面的数组,得到BASE[5]=-1,这个负数表明,bachelor#中剩下的字母存储在TAIL中,TAIL[-BASE[5]]=1,从索引1开始的。K中的其他关键字可以用同样的方式检索。不过开始位置都是BASE数组的第一个位置-position 1.


     从上面的检索步骤我们可以看到,检索的过程中我们只是做了简单的读取数组中的值然后和其他值进行相加,没有进行整整的查找。所以这种reduced trie的实现,对于检索来说效率相当高。

关键字的插入

    在插入之前首先要做的事情就是初始化,BASE[1] = 1, 除此之外,BASE和CHECK中的其他数值都设置为0;0表示这些节点还没有被使用。


    1,当double-array都是空的时候,即BASE和CHECK中存储的元素都是0的时候。

    2,插入新的关键字的时候没有发生碰撞。

    3,插入新关键字的时候发生了碰撞,发生这种碰撞的有两种原因,第一种原因就是因为两个关键字有相同的前缀,解决的方法是为这些前缀包含的单词都创建一个节点,并把对应的节点与边之间的关系写入到BASE,CHECK当中去。同时还对TAIL进行了操作,因为要提取TAIL中的字母。对于BASE和CHECK在发生碰撞之前原有的内容不做改变。

    4,插入新关键字的时候发生碰撞,发生这种碰撞的原因不是因为单词之间有公共前缀,而是因为插入过程中某个关键字字母通过计算即将存放它所代表的弧的弧头节点已经被其他关键字的某个字母代表的边的弧头节点所占用。


    在插入之前着重的给大家讲解BASE和CHECK的概念:BASE中如果存储的是正数,表示的是一个基数,什么基数呢,假设有两个节点n,m同属于一条边a,知道边a的弧尾节点,也就是非箭头指向的节点,知道这个边代表的值,比如是2,那么怎么求另一个节点是谁呢,那就需要BASE[n]+2=m,m便是这个节点。如果BASE中存储的是负数呢,那就代表了一个关键字除了被边表示的字母之外,其他的字母都被存储在TAIL数组中,BASE[n]的绝对值就代表存储位置的开始。CHECK呢,CHECK表示的是当前索引指示的节点有没有被其他边作为弧头节点或者弧尾节点来使用,如果为0表示没有,如果为正数,表示有,同时这个正数也表示了是从哪个节点出发的弧指向了当前节点。


第一种情况:double-array都是空的情况,插入bachelor#步骤如下:

step1,在BASE数组的第一个节点开始,‘b’所代表的边的值是3,有:BASE[1]+‘b’= BASE[1] + 3 = 4, and CHECK[4]= 0 != 1, 这说明什么呢,说明还没有弧指向第四个节点,那么我们可以把’b’代表的这条边指向第四个节点。


step2,CHECK[4]=0同时也表示着achelor#将被放在TAIL数组中去,然后定义’b’代表的边,g(1,’b’)=4.


step3, 置BASE[4]= -POS = -1,这表示着bachelor#除了我们已经定义的边b之外,其他的字母被放到了TAIL数组中去了,起始位置是POS。同时CHECK[4]=1,表示指向节点4的边是从节点1发射出来的。


step4,POS <—9,表示下次可以出入的位置,算一下achelor#长度是8,则下次有效插入位置将是9.

下图显示了插入bachelor之后double-array和TAIL。

《双数组后缀树 Double-Array Trie(DAT)》


第二种情况:插入jar#

step1,在BASE的第一个位置开始,‘j’代表边的值是11,所以:BASE[1]+’j’=BASE[1]+11 = 12, and CHECK[12]=0 != 1,


step2,CHECK[12]=0表示了jar#中的其他部分要被插入到TAIL中去,同时也表示了插入的过程中是没有发生碰撞的,即存在公共前缀或者计算出来的节点已经被占用。从POS=9的位置开始,将ar#存入到TAIL中去。


step3,置BASE[12]= -9,CHECK[12]=1,表示从节点1出发的弧‘j’的尾部节点是第12个点。


step4, POS= 12,下一个有效插入位置。

《双数组后缀树 Double-Array Trie(DAT)》

从上面的两种插入情况来看,插入的过程并没有明显的区别,他们只是再理论上有所不同,即是不是在double-array为空的时候插入的。还有造成他们插入操作相同的原因是没有发生碰撞。

在讲述第三种情况之前,先要说一个概念,考虑有这样一个函数   X_CHECK(LIST), 它返回最小的q,q满足以下两个条件:q>0 and 对于在LIST中的所有字母c都满足:CHECK[q + c] = 0。q的值总是从1开始,并且每次只增值1。记住这个重要的条件,就是q要满足LIST中的所有字母。


第三种情况,插入badge#:

step1,从BASE数组的第一位开始,‘b’代表的边的取值为3, 有:BASE[1]+’b‘=4,and CHECK[4]=1,CHECK[4]中的非零值告诉我们,存在一条这样的边:它的起始位置是CHECK[4]=1,结束位置是节点4.。也就是‘b’边代表的弧尾节点和弧头节点。


step2,由于在第一步中找到的值是整数4,则要继续进行下一个字母的寻找,4被用来当做BASE数组的新索引,BASE[4]=-1,说明搜索暂时停止,要进行字符串的比对,比对那些字符串呢,一个是badge#剩余的没有进行查找的部分,一个是存储在TAIL数组中的部分,为什么要进行比对呢,比对的原因很简单,看这个关键字之前是否已经插入了,如果已经插入了,那么badge#的再次插入是重复的,所以应该停止。在-BASE[4]=1的TAIL的起始位置找到字符串achelor#,然后和剩余未插入的字符串adge#进行比较,比较的结果是不相同。但是细心的看一下,他们有相同的前缀,ba,b就不用说了,因为已经有一条边用b代表了。那a呢,如果我们贸然的将剩下的字符插入到TAIL数组中,会有什么后果呢,后果就是BASE[4]中不知道该存储哪个值好,存储-1吧badge#下次找的时候找不到,存储-9吧,那下次找bachehlor#的时候就找不到了。解决的办法是找到能够独立代表两个关键字的方法,那就是要除去他们的公共部分之后为两个关键字都建立一个独立的节点,注意,这个独立的节点我们之前就提到过。


step3,把BASE[4]=-1存放到一个临时变量中去,TEMP<—BASE[4]


step4,对adge#和achelor#的公共字符a使用X_CHECK[{‘a’}]函数,CHECK[q+a]= CHECK[1+’a’] = CHECK[1+2]=CHECK[3]=0,这表示什么呢,节点3还没有被那条边当做弧头或者弧尾使用,我们可以把它当做从节点4发射出来的边‘a’的弧头。q=1是BASE[4]的一个候选值,为什么说是候选值呢,等到后面就会理解了,暂时不用在意。然后有:BASE[4]=1, CHECK[BASE[4]+’a’]=CHECK[1+2]=CHECK[3]<-4。它表示了我们又定义了一条新的边’a’,从节点4起,到节点3终止。注意到一点,因为这两个字符串的公共前缀只有a,如果换做其他字符串,不只是一个公共前缀字母,step3和step4就要循环操作。


step6,接下来这个比较复杂,用英语把,要不然会打乱逻辑: to store remaining string ‘chelor#’ and ‘dge#’, calculate the value to be store into BASE[3] for two arc labels ‘c’,  and ‘d’, according to hte closest neighbour available by X_CHECK[{c,d}], 也就是说找到一个合适的q值,即BASE值,使得从节点3出发,加上这个值,得到的另外两个节点都没有被使用过,都可以用来分别作为弧c,d的弧头。

FOR c : CHECK[q+’c’]= CHECK[1+4]=CHECK[5]=0=>available

FOR d : CHECK[q+’d’]= CHECK[1+5]=CHECK[6]=0=>available

得到的q=1作为BASE[3]的候选值,BASE[3]=1,要理解候选的意思,就是这不是最终的值。


step7, 接下来就是计算BASE和CHECK的值,以找到合适的节点和合适的TAIL中位置来存储剩余的chelor#,在上步骤中,已经找到了BASE[3]的值,有:

BASE[3]+c = 5, BASE[5]=-TEMP, CHECK[5]=3,想一想,为什么这么直接的就做了呢,因为节点3到节点5的弧c已经能够把当前的两个有前缀的关键字区分开了,那么剩余的就没有必要在细分了,直接放到TAIL数组中去了。同理,CHECK[5]=3代表从3出发,到5截止有一条弧c。


step8,剩余的字符’helor#’要放到TAIL中去了其实位置是1,计算一下,是6个字符,所有TAIL[7],TAIL[8]中的位置存储的字符就没有意义了。为什么呢,因为之前我们已经计算好了下一个有效的TAIL中的位置POS=9.


stpe9,对于’dge#’有同样的处理:BASE[3]=’d’= 1+ 5 = 6, BASE[6]= -POS=12 and CHECK[6]=3, store ‘ge#’ int TAIL starting at POS.


step10,计算下一个TAIL中的有效存储位置:POS = 12 + length[‘ge#’]= 15


插入badge#后的结果如图所示:

《双数组后缀树 Double-Array Trie(DAT)》


插入的第四种情况:插入’baby#’:

step1: 还是从BASE中的第一个节点开始,计算步骤如下: 

         BASE[1]+’b’= BASE[1]+3 = 4 and CHECK[4]=1,

 BASE[4]+’a’= BASE[4]+2 = 3, and CHECK[3]=4,

BASE[3]+’b’= 4 and CHECK[4]=1 != 3,这是怎么回事呢,让我来清晰的解释一下:因为baby#的前两个字母是ba,按照规定,前缀都必须用单独的边表示,因为他们不足以区分有着相同前缀的不同单词。所以接下来我们还得给’by#’中的b建立一条边,那么b这条边的起始节点有了,怎么找它的终止节点呢,按照我们当时的要求机械记忆的第一条BASE[3]+’b’=1+ 3=4,那么我们就要用节点4来当做这个’b’代表的边,但是看一下前面的4已经被其他节点征用了。就产生了矛盾。那么到底是用4节点作为当前边’b’的截止节点呢,还是它为原来的边贡献。这要做一下pk吧。但是还是寻找问题的根源吧,因为BASE[1]=1,BASE[3]等于,这次遇到了’b‘差生了矛盾,那么下次遇到其他单词中含有’b’也还有可能产生矛盾,那么为了根除这个矛盾,就得改变BASE[1]或者BASE[3]中的值,使得通过它中的值计算出来的可用节点不在发生冲突。刚才说道pk,那么怎么pk呢,代价最小原则,看使用BASE[1]计算出来的在使用的节点个数多还是使用BASE[3]计算出来的在使用的节点的个数多,计算的时候要包括即将插入的边的弧尾节点。


step2:设定一个临时变量TEMP_NODE1 <-BASE[3]+’b’= 4,


step3:把由节点3引申出来的边所代表的字母存放到LIST[3]中,很显然有’c’,’d’, 把由节点1引申出来的边代表的字母存在LIST[1],即:b,j。


stpe4:接下来就pk了,因为节点3刚才要新引申边’b’来着,所以要加上,compare(length[LIST[3]]+1 , length[LIST[1]]) = compare(3,2) .从节点1,引申出来的边少,就改变BASE[1]的值,如果情况是相反的,那么就改变BASE[3]中的值。


step5:设定临时变量:TEMP_BASE <-BASE[1]=1, and calculate a new BASE using LIST[1] according to the closest neighbour available as follows:

            X_CHECK[‘b’]=: CHECK[q+’b’ ]= CHECK[1+3]= CHECK[4]!= 0

CHECK[q+’b’ ]= CHECK[2+3]= CHECK[5]!= 0

CHECK[q+’b’ ]= CHECK[3+3]= CHECK[6]!= 0

CHECK[q+’b’ ]= CHECK[4+3]= CHECK[7]= 0

对于j X_CHECK[‘j’]: CHECK[q+’j’]= CHECK[4+11]= CHECK[15]=0=>available.

所以q=4是BASE[1]的候选值。BASE[1]=4


step6:store the value for the states to be modified in temporal variables: TEMP_NODE1 = TEMP_BASE+’b’=1+3 = 4, TEMP_NODE2= BASE[1]+’b’=7

把原来放在BASE[TEMP_NODE1]中的值放到BASE[TEMP_NODE2]中去,因为BASE[1]改变了,所以由BASE[1]计算出来的节点也要相应的做改变 BASE[TEMP_NODE2]=BASE[TEMP_NODE1]即:BASE[7]=BASE[4]=1,CHECK[TEMP_NODE2]=CHECK[4]=1


step7:BASE[TEMP_NODE1]=BASE[4]=1>0,说明什么呢,说明原来由节点4引申出去的边不能在从节点4出发了,应该从新的节点,即节点7出发,所以要做改动:

           CHECK[BASE[TEMP_NODE1]+E]=TEMP_NODE1, CHECK[BASE[4]+E]=CHECK[1+E]=4=>E = 2

           and modify CHECK to point to new status:CHECK[BASAE[4]+2]=CHECK[3]<-TEMP_NODE2=7


step8:因为更换BASE[1]的值,我们弃用了节点4,所以它将重新变为一个下次插入时候可用的节点。CHECK[TEMP_NODE1]=0,BASE[TEMP_NODE1]=0


step9:for ‘j’,TEMP_NODE1<-TEMP_BASE+’j’= 1+11 = 12, TEMP_NODE2<-BASE[1]+’j’= 4+11= 15,

         BASE[TEMP_NODE2]<-BASE[TEMP_NODE1] 即:BASE[15]=BASE[12]=-9 and SET the CHECK value for new node :              CHECK[TEMP_NODE2]=CHECK[15]=CHECK[12]=1.


step10: BASE[TEMP_NODE1]= BASE[12]= -9,说明BASE中存储的值是在TAIL中的有效存储剩余字符串的位置,所以可以重置其值。BASE[TEMP_NODE1]=BASE[12]=0,

CHECK[12]=0;


step11:继续考虑引起冲突的节点3,我们继续进行插入,BASE[3]+’b’=4,and CHECK[4]=0,这回节点4可以用了,有CHECK[4]=3,表示从节点3出发到节点4截止的边’b’,那么可以很直观的看出,到目前位置,这条边足够能够把baby#和其他单词区分开,则右BASE[4]=-15, TAIL[POS]= TAIL[15]= ‘y#’,


step12, 重新计算POS的有效值:POS+length[‘y#’]= 17.

最终插入结果如下图:

《双数组后缀树 Double-Array Trie(DAT)》


删除关键字

     关键字的删除首先要找到double-array中是否有存储此关键字。就像插入过程的case2那样,只是操作有所不同,需要把对应关键字的独立节点的BASE中存储的指向TAIL数组中的有效位置清空,即变成0.同时CHECK也需要置为0.表示指向独立节点的边被删除。

下面以删除‘badge#’为例:

stpe1:从BASE数组的第一个位置开始,对‘badge’的前三个字节:

BASE[1]+’b’= BASE[1]+3= 4+3 = 7, and CHECK[7]=1

BASE[7]+’a’= BASE[7]+2 = 1+2 = 3, and CHECK[3]=7

BASE[3]+’d’= BASE[3]+ 5= 1+5 = 6, and CHECK[6]=3

BASE[6]=-12<0 ==> separate node, 独立节点BASE中的值指示了剩余字符串在TAIL中存储的起始位置.

step2:将给定的字符串剩余部分和TAIL中存储的剩余部分进行比较,compare(’ge#’, ‘ge#’).

step3: 两个字符串的比较结果相等,所以重置指向TAIL的BASE[6],和去掉指向独立节点的边:BASE[6]<-0 , CHECK[6]<-0

 由于指向TAIL中’ge#’的独立节点BASE的值置成了0, 那么说明’ge#’再也没有办法被读取了,便成了没有用的内容:garbage,这些空间可以供以后的插入字符使用。


评估

其余的内容都是对这个算法的时间和空间效率的评估,对理解算法帮助不大。等有空的时候再做翻译。同时,将这篇文章的pdf上传,希望对大家有帮助,特别的要说明一下,pdf中源码有错误之处,我已经改正,在文章开头的连接中。http://download.csdn.net/detail/zzran/4966777

http://www.cnblogs.com/zhangchaoyang/articles/4508266.html

Double Array Trie

Trie树主要应用在信息检索领域,非常高效。今天我们讲Double Array Trie,请先把Trie树忘掉,把信息检索忘掉,我们来讲一个确定有限自动机(deterministic finite automaton ,DFA)的故事。所谓“确定有限自动机”是指给定一个状态和一个变量时,它能跳转到的下一个状态也就确定下来了,同时状态是有限的。请注意这里出现两个名词,一个是“状态”,一个是“变量”,下文会举例说明这两个名词的含义。

举个例子,假设我们一共有10个汉字,每个汉字就是一个“变量”。我们为每个汉字编个序号。

 

1

2

3

4

5

6

7

8

9

10

             表1. “变量”的编号

这10个汉字一共可以构成6个词语:啊,埃及,阿胶,阿根廷,阿拉伯,阿拉伯人。         

这里的每个词以及它的任意前缀都是一个“状态”,“状态”一共有10个:啊,阿,埃,阿根,阿根廷,阿胶,阿拉,阿拉伯,阿拉伯人,埃及

我们把DFA图画出来:

《双数组后缀树 Double-Array Trie(DAT)》

        图1. DFA,同时也是Trie树

在图中每个节点代表一个“状态”,每条边代表一个“变量”,并且我们把变量的编号也标在了图中。

下面我们构造两个int数组:base和check,它们的长度始终是一样的。数组的长度定多少并没有严格的规定,反正随着词语的插入,数组肯定是要扩容的。说到数组扩容,大家可以看一下java中HashMap的扩容策略,每次扩容数组的长度都会变为2的整次幂。HashMap中有这么一个精妙的函数:

1 2 3 4 5 6 7 8 9 10 //给定一个整数,返回大于等于这个数的2的整次幂 static  int  tableSizeFor( int  cap) {          int  n = cap -  1 ;          n |= n >>>  1 ;          n |= n >>>  2 ;          n |= n >>>  4 ;          n |= n >>>  8 ;          n |= n >>>  16 ;          return  (n <  0 ) ?  1  :  n +  1 ; }

回到今天的正题,我们不妨把double array的初始长度就定得大一些。两数组元素初始值均为0。

double array的初始状态:

下标

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

base

0

0

0

0

0

0

0

0

0

0

0

0

0

0

0

0

0

0

0

check

0

0

0

0

0

0

0

0

0

0

0

0

0

0

0

0

0

0

0

state

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

把词添加到词典的过程就给base和check数组中各元素赋值的过程。下面我们层次遍历图1所示的Trie树。

step1.

第一层上取到3个“状态”:啊,阿,埃。把这3个状态按照其对应的变量的编号(查表1)放到state数组中。

下标

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

base

0

0

0

0

0

0

0

0

0

0

0

0

0

0

0

0

0

0

0

check

0

0

0

0

0

0

0

0

0

0

0

0

0

0

0

0

0

0

0

state

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

step2.

当存在状态转移《双数组后缀树 Double-Array Trie(DAT)》时,有

1 2 check[t]=s base [s]+c=t

其中s和t代表某个状态在数组中的下标,c代表变量的编号。

此时层次遍历来到了图1所示DFA的第二层,我们看到“阿”的子节点有“阿根”、“阿胶”、“阿拉”,已知状态“阿”的下标是2,变量“根”、“胶”、“拉”的编号依次是4、5、6,下面我们要给base[2]赋值:从小到大遍历所有的正整数,直到发现某个数正整k满足base[k+4]=base[k+5]=base[k+6]=check[k+4]=check[k+5]=check[k+6]=0。得到k=1,那么就把1赋给base[2],同时也确定了状态“阿根”、“阿胶”、“阿拉”的下标依次是k+4、k+5、k+6,即5、6、7,而且check[5]=check[6]=check[7]=2。

同理,“埃”的子节点是“埃及”,状态“埃”的下标是3,变量“及”的编号是7,此时有check[1+7]=base[1+7]=0,所以base[3]=1,状态“埃及”的下标是8,check[8]=3。

遍历完DFA的第二层后得到下表:

下标

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

base

0

1

1

0

0

0

0

0

0

0

0

0

0

0

0

0

0

0

0

check

0

0

0

0

2

2

2

3

0

0

0

0

0

0

0

0

0

0

0

state

 

阿根

阿胶

阿拉

埃及

 

 

 

 

 

 

 

 

 

 

 

step3.

重复step2,层次遍历完整查询树之后,得到:

下标

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

base

0

1

1

0

1

0

1

0

0

1

0

0

0

0

0

0

0

0

0

check

0

0

0

0

2

2

2

3

5

7

10

0

0

0

0

0

0

0

0

state

 

阿根

阿胶

阿拉

埃及

阿根廷

阿拉伯

阿拉伯人

 

 

 

 

 

 

 

 

step4.

最后遍历一次DFA,当某个节点已经是一个词的结尾时,按下列方法修改其base值。

1 2 3 4 if ( base [i]==0)      base [i]=-i else      base [i]=- base [i]

得到:

下标

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

base

-1

1

1

0

1

-6

1

-8

-9

-1

-11

0

0

0

0

0

0

0

0

check

0

0

0

0

2

2

2

3

5

7

10

0

0

0

0

0

0

0

0

state

 

阿根

阿胶

阿拉

埃及

阿根廷

阿拉伯

阿拉伯人

 

 

 

 

 

 

 

 

double array建好之后,如果词典中又动态地添加了一个新词,比如“阿拉根”,那么“阿拉”的所有子孙节点在double array中的位置要重新分配。

 《双数组后缀树 Double-Array Trie(DAT)》

图2. 动态添加一个词

首先,把“阿拉伯”和“阿拉伯人”对应的base、check值清0,把“阿拉伯”和“阿拉伯人”从state数组中删除掉,把“阿拉”的base值清0。

下标

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

base

-1

1

1

0

1

-6

0

-8

-9

0

0

0

0

0

0

0

0

0

0

check

0

0

0

0

2

2

2

3

5

0

0

0

0

0

0

0

0

0

0

state

 

阿根

阿胶

阿拉

埃及

阿根廷

 

 

 

 

 

 

 

 

 

 

然后,按照上面step2所述的方法把“阿拉伯”、“阿拉根”插入到double array中。变量“根”、“伯”的编号是4和9,满足base[k+4]=base[k+9]=check[k+4]=check[k+9]=0的最小的k是6,所以base[7]=6,“阿拉伯”和“阿拉根”对应的下标是10和15。同理把“阿拉伯人”插入到double array中。

下标

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

base

-1

1

1

0

1

-6

6

-8

-9

0

0

0

0

0

1

0

0

0

0

check

0

0

0

0

2

2

2

3

5

7

15

0

0

0

7

0

0

0

0

state

 

阿根

阿胶

阿拉

埃及

阿根廷

阿拉根

阿拉伯人

 

 

 

阿拉伯

 

 

 

 

最后,遍历图2所示的DFA,当某个节点已经是一个词的结尾时按照step4中的方法修改其base值。

下标

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

base

-1

1

1

0

1

-6

6

-8

-9

-10

-11

0

0

0

-1

0

0

0

0

check

0

0

0

0

2

2

2

3

5

7

15

0

0

0

7

0

0

0

0

state

 

阿根

阿胶

阿拉

埃及

阿根廷

阿拉根

阿拉伯人

 

 

 

阿拉伯

 

 

 

 

 

double array建好之后,如何查询一个词是否在词典中呢?

比如要查“阿胶及”,每个字的编号是已知的,我们画出状态转移图。

《双数组后缀树 Double-Array Trie(DAT)》

变量“阿”的编号是2,base[2]=1,变量“胶”的编号是5,base[2]+5=6,我们检查一下check[6]是否等于2。check[6]确实等于2,则继续看下一个状态转移。同时我们发现base[6]是负数,这说明“阿胶”已经是一个完整的词了。

继续看下一个状态转移,base[6]=-6,负数取其相反数,base[6]=6,变量“及”的编号是7,base[6]+7=13,我们检查一下check[13]是否等于6,发现不满足,则“阿胶及”不是一个词,甚至都是不是任意一个词的前缀。

LINK: http://ju.outofmemory.cn/entry/109689

Trie逻辑结构

    Trie是一种常见的数据结够,可以实现前缀匹配(hash是不行的),而且对于词典搜索来说也是O(1)的时间复杂度,虽然比不上Hash,但是空间会省不少。

     比如下图表示了包含“pool, prize, preview, prepare, product, progress”的一个Trie

     《双数组后缀树 Double-Array Trie(DAT)》

     Trie的逻辑结构:每个圆圈都表示一个状态,比如状态1,状态之间的边表示状态1遇到字符p就变成状态2。用两个圈画的状态表示终止状态,也就是表示匹配了一个单词。

     这是DFA的表示方法,当然按照正规的定义,还得有个“吸收”所以非法字符的状态,比如状态1碰到p之外的任何字符都会匹配失败,也就是会进入这个“吸收”状态,这个状态就像

     黑洞,进去之后就永远没有出头之日了——永远在那个状态跳转。

Trie的实现

     从上面可以知道,要表示一个Trie,关键就是一个跳转矩阵(DFA里的正式名字是状态转移表),比如上图可以这样表示    

1 2 3 4 ..
p 2 X X X
r X X X X
o X 3 X X
e X X 4 X
..

      X就表示那个“吸收”状态。

      从上表看出,如果有N个状态,并且字母表的大小是M,那么逻辑上就是一个N*M的数组。M一般很容易知道,比如对于英文单词,M可能是26或者52,对于汉字,可能有好几千。

      而N很显然和词典的大小有关系,词典越大,那么N一般也越大。另外也与词典数据有关,比如词典的词共有的前缀很多,那么N就越少;反之N就越大。

      可以看出,一般这个二维数组会比较稀疏,所以可以压缩空间。

      最容易想到的压缩方法当然是链表。比如把状态1可以接受的字符组成一个链表,但是链表的缺点是无法实现随机访问,这样效率会有问题。

      我们也可以把链表换成树的结构,比如红黑树,这样可以log(n)的速度。但是还是比不上数组的o(1)的速度。

      这时我们肯定想到了Hash,没错,使用Hash比不压缩的数组省空间(数组也可以理解为Hash),而且速度也慢不了很多。

      但是Hash总会是有冲突的(当然可以构造Perfect Hash,但是如果数据经常变化,那么就不好处理),能不能既有数组般的随机访问性能,又能节省大量空间的方法呢?

      这就是我们要讲到的Double Array Trie。不过先别急,我们先讨论Triple Array Trie。了解这种压缩的思路。

Triple Array Trie(TAT for short)

       TAT的思想很简单,由于每个状态接受的字符很有限,大家可以共享一个数组。比如字母表是a-z这26个英文字母,我们可以用0-25这26个数组表示它们。

       比如状态1接受“a,c,e”,那么我们可以把找一个“base”。可以把这个“base”理解成这个状态的Hash值。然后base,base+2,base+4就分配给状态1了。

       又假设状态2接受“b,d”,那么状态2也可能Hash到和状态1相同的base,然后把base+1,base+3分配给状态2。这样它们能够相安无事的共存。

       不过我怎么能知道base是属于状态1,而base+1是属于状态2呢?这就需要一个check的数组来标识了。

         《双数组后缀树 Double-Array Trie(DAT)》
          比如上图:状态s碰到字符c就变成状态t,那么首先从base里找到s的“hash地址”,这个地址指向base=base[s],然后base+c我们分配给c的地址,通过check[base+c]==s我们知道
          这个地址确实是分配给了s,所以我们读取next[base],它的值就是t。这样你给我s和c,我通过上面的过程就能告诉下一个状态就是t。
          我们来比较一下TAT和二维数组的时间和空间开销。

        时间

        二维数组:你给我s和c,我立马就能告诉你t,array[s*字母表大小+c],当然需要一次乘法和加法算下标。内存读取一次。
          TAT:给我s,首先读取base[s],然后计算base[s]+c,然后读取chk=check[base[s]+c],然后一次判断,如果chk==s,那么再读取一次next[base[s]+c]得到t。3次访问内存,一次加法

        空间

        二维数组:M*N*4(有一个32bit的int表示)

        TAT:状态个数+双数组的长度,这个值比较难估计,与词典的数据分布有关。我使用了一个随机生成的例子:字母表大小26,词典大小20,000,N=154825,使用DAT后next和check的大小是

        168505(因为没有实现TAT,所以我这里只能用DAT来估计,但TAT应该和DAT是差不多的。而且我目前使用的DAT使用了check压缩,这样导致双数组的大小会稍微大一些,check数

         组的压缩参考下面)。

         我们简单的比较一下:二维数组 26*150k*4=15M; TAT 150K*4+170K*8=2M,可以看出空间节省了多少!!如果像汉字这样字母表更大的词典,那么会节省的更多。

        问题

        从上面的分析我们看出,实现TAT的关键就是给每个状态一个合适的base,比如上面的例子,如果状态1的base是0,那么它就会占用next[0],next[2],next[4],如果我们不小心把状态1的

        base弄成了1,那么它会占用next[2],next[4],这样就“冲突”了,所以要避免这种情况。如果出现了,我们就必须给某个状态,比如状态2分配一个新的base。

         《双数组后缀树 Double-Array Trie(DAT)》

        上图就展示了由于冲突,我们需要修改base[s]的例子。我们需要找到原来的base,然后遍历next[oldBase+0…字母表大小-1],如果next值为s,说明这个next是属于s的,那么需要
          把它“搬”到合适的地方,然后原来的check从s变成none,新地址的check从none变成s。

Double Array Trie(DAT for short)

        还能压缩吗?

        看起来TAT已经很不错了,但是还是有冗余的信息。

        不过之前需要说明这样一个前提:Trie是一颗树,构造Trie时,只会增加状态;删除单词时,首先删除孩子,然后才能删除父亲。

        形式化一点:假设状态s遇到c变成状态t,那么就不会有另一个状态r遇到c变成状态t(否则一个节点有两个父亲,那就不是树了)。

        这有什么用呢?如果s遇到c变成t,s是t的父亲,t是s的孩子,那么t只能从s过来,那么就没有必要在next数组里指向base里,而可以直接让t=base[s]+c

        如果看上图,那么就是所有的next[i]=i,也就是不需要next数组了。

        这个可能有的绕,需要这样理解:状态只是一个数字,叫1还是2并不重要,反正是个唯一的标识就行了,比如原来状态0遇到c变成状态1,状态1遇到d变成状态2,那么我把状态1改成状

        态100完全是没有区别的:状态0遇到c变成状态100,状态100遇到d变成状态2。

          状态本身并不重要,重要的是它的base(可以理解为hash地址)

          它的搜索过程如下:给定s和c,直接检查chk=check[base[s]+c],如果chk==s,则t=base[s]+c,也就是把原来的base和next数组合并成为一个。

          也许你会有这样的担心(细心的读者),万一base[s]+c被别人用了呢?当然可以调整base[s],这时t也跟着s变化。有没有怎么调整也冲突的情况呢?

          考虑一下s遇到c变成t,已经r也遇到c变成t,这会怎么样?不论你怎么调整,因为base[s]=t-c=base[r],也就是s和r的base相同,这没什么,关键是check数组

          只能一个,要么s,要么r,这种情况没法处理。不过想想前面,Trie是一颗树,所以t只能有一个父亲节点,所以上面的例子是不可能出现的。

          《双数组后缀树 Double-Array Trie(DAT)》
          同样的,如果给s增加一个孩子t(通过字符c),那么万一base[s]+c已经被别人使用了check[base[s]+c]=other,那么就必须给s的base换个地方,参考下图:
                      《双数组后缀树 Double-Array Trie(DAT)》
          除了要修改t和t‘的check外,还需要把t’的base改成原来t的base。

后缀压缩

        比如前面的例子,pool,状态3的后代最多只有一个孩子,也就是一个链(没有更多分支),所以可以把状态4和5去掉,然后状态3做为叶子节点,用一个指针指向字符串“ol”。

DATrie的插入

         注意:这里的DATrie是指有后缀压缩的DATrie。如果没有后缀压缩,其实也类似。

         根据插入点的位置,可以分为两种情况。

         首先我们找到插入点,也就是在Trie树上不停的走,直到在非叶子节点遇到不能接受的字符或者遇到叶子或者所有的字符都走完了。

         第一和第三种情况可以合并成一种,它们唯一的不同时,前者的后缀不空,后缀的后缀为空(#)表示。 

                       《双数组后缀树 Double-Array Trie(DAT)》
            比如现在的trie树如上,
            我们要插入“pooch”,那么就是第二种情况,我们需要在状态3增加一个状态t, 3经过o变成t,然后t分成两个分支,一个是l,一个是c。
            如果要插入“poa“,那么是第一种情况,如果要插入”po“,那么是第三中情况。这两种情况都需要从3增加状态,但是原来的孩子不需要改变。
                    插入po,只需要给3增加一个孩子t,边上的字符是#,然后t是叶子,
                    插入poa,需要给3增加孩子t,边上的字符是a,a是叶子节点,指向#
            也就是说,第二种情况需要修改原来的tail(后缀压缩部分)

DATrie的删除

           删除一个词首先需要找到这个词的路径,然后反向一个一个删除状态,直到遇到某个状态——这个状态至少有两个分支(也就是删除当前分支后还有分支)。

           如果有后缀压缩的话,那么可以再压缩后缀(当然也可以不压缩)。比如上面的例子,删除“produce”,那么首先删除状态14,然后可以压缩状态15,13,12,11,让

           状态10直接指向ucer#

双数组的Pool分配

           我们这里讨论的DAT是一种动态数据结构,会不停的往里面插入删除单词。

           这个时候就需要动态管理双数组了。因为如果base和check被使用的话,那么它们的值会大于等于0,所以可以让没有使用的base和check的值为-1,比如需要找

           空闲的base时,我们可以从头开始扫描base,碰到-1就找到一个空闲的空间。

           这种办法简单容易实现,但缺点是时间复杂度比较高。如果对插入删除要求不高的话,那么这种方法就比较简单可行,比如后面我们讲到的Static的DAT【构建一次,永不修改】

           就可以使用这种方法。

           改进的办法就是把空闲的空间组织成链表。我们可以用负数代表空闲,然后它的绝对值代表下一个空闲单元的地址(下标)。

        check[0] = -r1

   check[ri] = -ri+1 ; 1 <= i <= cm-1

check[rcm] = -1

 

          这里只使用了check来表示空闲单元,其实check空闲,那么对应的base也是空闲的。那么其实可以也利用上,来组织成一个循环链表:

check[0] = -r1 check[ri] = -ri+1 ; 1 <= i <= cm-1 check[rcm] = 0 base[0] = -rcm base[r1] = 0 base[ri+1] = -ri ; 1 <= i <= cm-1

字母表的问题

           对于英语来说,一般只有26个字母(或者52个,如果考虑大小写)+一些数字等,一般一个字节就可以表示下来。然后可以使用比较简单的算法把它们映射成0开始的连续整数。

           比如只有字母和数字可以使用如下算法:

int getIndex(char c){ if(ch >='a' && ch <='z') return ch-'a'; else if(ch >='0' && ch <='9') return ch-'0'; else return -1; }

           如果字母表很大,比如汉字,那么可能需要一个HashMap<Character,Integer>来保存了。不过这样的速度可能有问题,由于一般字符编码都会是连续的区域,所以也可以参考上面的方

           法来实现,这样既省空间,又速度更快。

           对于汉字这种“宽”字符,还有一种办法,那就是先把它转成多个单字节的数组。比如“北京”的unicode是“\u5317\u4eac“,那么可以把它看出4个字节。这样字母表最多256,正好可以

           用一个字节表示。

libdatrie的用法

           http://linux.thai.net/~thep/datrie/datrie.html#AnImp 这里有个c语言的实现,使用了标准的DAT实现,有后缀压缩。可以嵌入到自己的c程序中,也可以做为独立的程序运行。

           下面介绍一下做为独立程序使用的方法。

          安装

           从网站下载,解压,标准的tar包,./configure && sudo make install安装。

           默认程序安装在/usr/local/bin/trietool-0.2,so安装在/usr/lib/libdatrie.so.1,可以使用man trietool-0.2 查看用法。

          示例
要构造一个trie 名字叫test,首先需要告诉它我们的字母表,创建一个test.abm,比如我们的词典只有大小写的英文字母

 
  
   
    [java]
     
    view plain
    copy
   
  
  
 
  1. lili@lili-desktop:~/datrie/libdatrie-0.2.4$ cat test.abm  
  2. [0x0041,0x005a]  
  3. [0x0061,0x007a]  
  4. lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test add abcd 0  
  5. lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test add abce  
  6. lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test add abcf  
  7. lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test add abcg 1  
  8. lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test query ab  
  9. query: Key 'ab' not found.  
  10. lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test query abce  
  11. -1  
  12. lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test query abcg  
  13. 1  
  14. lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test delete abcg  
  15. lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test query abcg  
  16. query: Key 'abcg' not found.  
  17. lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test delete abcg  
  18. No entry 'abcg'. Not deleted.  
  19. 当然一个一个添加词典很麻烦,可以指定一个词典文件,这个文件的格式是一行一个词。  
  20. 比如  
  21. lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test add-list /usr/share/dict/words  
  22. lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test query AOL  
  23. -1  
check数组的压缩

   在DAT里,如果s遇到c变成t,那么就是base[s]+c=t,check[t]=s,如果我们能保证任意两个状态的base都不相同,那么我们可以不用在check数组存s,而只需要存c。

   原来check数组里保存的是s,说明这个位置留给了s,base[s]+c=t,如果还有一个状态r,比如base[r]=base[s],那么根据check[t]=s可以判断是从s->t而不是r->t。

   如果我们做一个限制,让所有的状态的base都不同,那么我们就可以在check[t]里保存c而不是s,因为t-c就是s。

   这样做有什么好处呢?一般的应用中,字符数远远小于状态数。比如英语,字母数可能不到100,8位足以表示。比如汉语,字母数可能小于4k,12位就可以表示了。

   这样由于base的限制,虽然会导致base和check数组增大一些(我的随机实验这两个数组会稍微大一些,但是不会超过5%),但是这两个数组的大小会从8个字节变成

   5个字节(英文为例),那么节约的空间还是非常可观的。

   这种方法一般用作静态的(构造一次不再修改)DAT里,因为如果总是插入删除的话要保证base不重复代价更大。

   此外DAT除了用来判断前缀匹配之外,可能把它用作Map这样的数据结构,所以还可以用check节省下来的位数来保存一个下标(指针)。

参考资料

   1. http://linux.thai.net/~thep/datrie/datrie.html

   2. http://www.chokkan.org/software/dastrie/

文章来源:http://blog.csdn.net/fancyerii/article/details/7237565 

=====================================================================

双数组Trie(Double-Array Trie)

《双数组后缀树 Double-Array Trie(DAT)》

《双数组后缀树 Double-Array Trie(DAT)》

《双数组后缀树 Double-Array Trie(DAT)》

《双数组后缀树 Double-Array Trie(DAT)》

《双数组后缀树 Double-Array Trie(DAT)》

《双数组后缀树 Double-Array Trie(DAT)》

《双数组后缀树 Double-Array Trie(DAT)》

《双数组后缀树 Double-Array Trie(DAT)》

《双数组后缀树 Double-Array Trie(DAT)》

《双数组后缀树 Double-Array Trie(DAT)》

《双数组后缀树 Double-Array Trie(DAT)》

《双数组后缀树 Double-Array Trie(DAT)》

《双数组后缀树 Double-Array Trie(DAT)》

《双数组后缀树 Double-Array Trie(DAT)》

《双数组后缀树 Double-Array Trie(DAT)》

《双数组后缀树 Double-Array Trie(DAT)》

《双数组后缀树 Double-Array Trie(DAT)》

《双数组后缀树 Double-Array Trie(DAT)》

《双数组后缀树 Double-Array Trie(DAT)》

《双数组后缀树 Double-Array Trie(DAT)》

《双数组后缀树 Double-Array Trie(DAT)》

《双数组后缀树 Double-Array Trie(DAT)》

《双数组后缀树 Double-Array Trie(DAT)》

《双数组后缀树 Double-Array Trie(DAT)》\

来源:http://dwz.cn/zPAES 

    原文作者:Trie树
    原文地址: https://blog.csdn.net/Aiphis/article/details/49556023
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞