原文:http://blog.csdn.net/zzran/article/details/8462002node
概论数组
下面将呈现一种新的内部数组结构,它即是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值的压缩来节省空间,也就说指向子节点的指针是以链表的形式来存放而不是数组的形式。可是若是从每一个节点引伸出不少边的话,检索的速度会很慢。spa
接下来这篇文章会讲解它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的一维数组中,以便更进一步的区分不一样的单词。.net
图一指针
图2blog
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,这些空间能够供之后的插入字符使用。