【转】double-array trie 译文+心得

原文:http://blog.csdn.net/zzran/article/details/8462002

 

概论

    下面将呈现一种新的内部数组结构,它便是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的一维数组中,以便更进一步的区分不同的单词。

图一

 

图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尽量存储关键字的前缀来压缩空间,但是还要能够把这些拥有公共前缀的关键字区分开,就要求这些关键字要有自己的特色,特色就是独立于其他关键字的节点。看上图中的字母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为例子作为检索吧。还是以下图为例。

 

    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。

 

第二种情况:插入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为空的时候插入的。还有造成他们插入操作相同的原因是没有发生碰撞。

在讲述第三种情况之前,先要说一个概念,考虑有这样一个函数   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#后的结果如图所示:

 

插入的第四种情况:插入'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中是否有存储此关键字。就像插入过程的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,这些空间可以供以后的插入字符使用。

posted @ 2016-11-02 10:50  VinoZhu  阅读(677)  评论(0编辑  收藏  举报