原文:An efficient implementation of trie structures
摘要
本文提出一个 trie 树结构的实现,其内部采用数组结构,我们称之位双数组(double-array)。双数组结合了矩阵的高速存取性和列表的紧密度性。本文通过一些例子来介绍算法的检索、插入和删除操作。尽管插入操作仍然比较慢,但是在可接受的范围内。此外,删除和检索的时间可以通过列表的形式来进一步改善。多次的大数据量测试显示,双数组占用的空间相比列表要小 17%左右,并且其检索速度要比列表快3到5倍。
引言
在信息检索的过程中,有时候要采用trie搜索(trie search)对输入进行逐个字符的处理,比如分词器、文献检索、拼写检测等等。trie树中的每一个节点是一个存储着子节点的有序数组。节点内的元素可以是标志位、新节点的引用或者null。图1 给出了一个数组结构的trie树例子,集合K={baby,bachelor,badge,jar}。检索、删除、插入操作都很快,但是它话费了大量的空间。其空间复杂度是节点数和字符数的乘积。一个常见的压缩策略是用链表的方式列出节点之间的关系。图2展示了一个集合K的链表trie树的结构。链表结构能够减少数组tire树的空指针所占用的空间,然而如果一个节点有较多的应用,检索的速度会降低。
图1 K={bachelor,baby,badge,jar} 集合的数组trie树
本文描述另外一种名为“双数组”(double-array)的压缩方式。这种方式使用两个一维数组:BASE 和CHECK 数组。非空节点n 的位置通过BASE数组映射到CHECK数组,使得节点的两个非空指引都被映射到CHECK 中的不同位置。trie树中的每一个弧(arc)都能在 O(1) 的时间复杂度内检索出来,也就是说,检索一个关键词K 的最坏情况的时间复杂度为关键词K 的长度。大量的关键词集合会使trie树生成许多节点,因此双数组的紧凑性至关重要。为实现大数据集合的trie树,双数组仅存储trie树中关键词的共有前缀来进行压缩。而关键词的后面部分不再进行压缩,直接作为尾部(TAIL)存储。
TRIE树
Trie 是一种树结构,每个从根到叶子节点的路径表示一个关键词。每一个路径相当于关键词的每一个字符。为了避免混淆类似‘the’ 和 ‘then’ 这样有共有前缀的词,用个专门的标志符号 #,在每个词的末尾都会有这样的标志。
首先给出一些定义,以便解释文章后面的篇幅。K 是trie 树要存储的关键字集合。trie 树由节点(node)和弧(arc)表示。弧表示符号间的组合关系,即字符串。一个从节点n 到节点m 的弧a ,用 g(n, a)=m 表示。
集合K 中的一个关键词,若 g(n, a)=m 中的弧a 能将此关键词区别于集合K 中其他关键词,则称m 是一个特殊的节点。从m节点到末端节点称为m 的单字符串(single string),用 STR[m] 表示。集合K 的尾巴(tail)是多个单字符串的集合。从根节点到所有特殊节点组成的树称为精简trie树。
图3 展示了一个由集合K ={baby#, bachelor#, badge#, jar#} 构成的精简trie树。同时还显示了使用双数组外加一个存储tail数组表示的精简树形式。TAIL 数组中的问号(?)表示废弃位,它的用处将在分析插入、删除操作的时候讲解。
图中精简trie树和双数组有以下关联:
图3:集合K 的精简trie树和双数组
1. 如果在精简trie树中存在一个弧 g(n, a)=m,那么 BASE[n]+a=m,并且 CHECK[m]=n。{图3 中的弧:#=1, 'a'=2, 'b'=3, 'c'=4 ...}
2. 如果节点m是个特殊节点,且尾字符串为 STR[m]=b1,b2, ...bh,那么
a. BASE[m]<0;
b. p=- BASE[m], TAIL[p]=b1, TAIL[p+1]=b2, ..., TAIL[p+h-1]=bh;
这两个关系将会贯穿全文。
检索
使用双数组方式进行检索操作很简单。例如,在图3 的实例中检索 'bachelor#' ,执行以下步骤:
步骤1:根节点存储在BASE位置为1的地方,字符 'b' 的值为3,因此根据关系1,
BASE[n]+a=BASE[1]+'b'=BASE[1]+3=4+3=7
观察到 BASE[7]=1
步骤2:利用第一步所得的索引值 7,以及字符 'a' 的值,
BASE[7]+'a'=BASE[7]+2=1+2=3, 且 CHECK[3]=7
步骤3,4: 利用字符 'c' 的值,重复以上步骤:
BASE[3]+'c'=BASE[3]+4=1+4=5, 且 CHECK[5]=3
步骤5: BASE[5]的值为-1,一个负值表明其余的字符在TAIL数组中,且起始字符为 TAIL[ - BASE[5] ] = TAIL[1]。其他单词的检索可以使用类似的技巧,都是从根节点在1位置的BASE数组开始。
以上步骤可以看出,检索操作只包括数组的直接访问(不需要查找)与添加,这中实现方式显著的提高了检索的效率。
插入
双数组的插入操作也十分简单。在插入过程中,有以下情况:
1. 双数组为空时插入新词;
2. 没有任何冲突的情况下插入一个新词;
3. 插入新词时候发生冲突;在这种情况下,发生冲突的字符必须添加到BASE数组中,而该字符在TAIL数组中的必须删除,BASE数组不删除任何元素。
4.当添加一个新词的时候发生情况3的冲突,BASE数组中的值必须删除。
冲突表明两个不同字符有相同的索引值。以上说的四种情况会通过添加“bachelor”、“jar#”、“badge#”、“baby#” 时说明。如图4所示。定义双数组的DA_SIZE属性大小表示在CHECK非空的情况下的最大索引值,若BASE或CHECK 元素超过这个值,可以动态分配一些默认值为0的空间。
情况1:双数组为空时插入新词
以添加“bachelor”为例,过程如下:
step 1:从双数组中元素下标为1 的元素开始,字符‘b' 的值为3,因此:
BASE[1]+'b'=BASE[1]+3=4,and Check[4] = 0 ≠ 1
step 2:CHECK[4] 为0表示‘b'字符在双数组中第一,且该下标元素数据无冲突。因此直接将剩余子串’achelor#‘插入到TAIL数组中。
step 3:设置 BASE[4] = - POS = -1
表示’b'后面的子串存在在以POS下标开始的TAIL数组元素后。并且,设置CHECK[4] = 1,表示此节点的父节点的位置。
step 4:在TAIL添加子串后,设置TAIL的当前指针POS = 9,也就是下次添加子串的起始位置。
图5展示了以上显示了reduced trie 和 double-array 添加“bachelor”之后的状况。
情况2:不发生冲突的情况下添加新词
以添加“jar#”为例,过程如下:
step 1:从双数组中元素下标为1 的元素开始,字符‘j' 的值为11,因此:
BASE[1] + ‘j’ = BASE[1] + 11 = 12,且 CHECK[12] = 0 ≠ 1
step 2:CHECK[12]为0表示插入无冲突,剩余的子串“ar#”直接从TAIL数组中POS所指向的元素下标开始存储。
step 3:设置BASE[12] = -POS =-9 ,表示子串存储的位置。且设置 CHECK[12] = 1,POS = 12
可以看出,情况1和情况2的插入操作没有任何区别,仅仅只是在概念上不同。reduced tree 和 double array 的状态如图6所示。
在演示情况3、情况4之前,先介绍一下函数 X_CHECK(LIST) 。它返回一个整数q,q>0 使得对所有字符c来说, CHECK[q+c] = 0。q一般从1开始并且线性增长。
情况3:伴随冲突的插入
以“badge#”为例,过程如下:
step 1:从双数组中元素下标为1 的元素开始,字符‘b' 的值为3,因此:
BASE[1] + ‘b’ = BASE[1] + 3 = 4,且 CHECK[4] = 1
CHECK[4] 的非零值表示双数组内已存在一条由CHECK[4]定义的弧,即1到4节点。
step 2:在第一步中获取BASE的值进行得一切顺利,获取的4这个值是一个新的BASE下标,然而,BASE[4] = -1
这个负数表示查找已经结束,字符串的比较操作完成。
step 3:检索由 -BASE[4] 指向的TAIL元素,即1,其存储的子串“achelor#” 和现在剩余的子串即“adge”不相等。这是两个不同的字符串,因此需要将它们公共的前缀插入到双数组中,正如step 4、5、6所示。
step 4:保存目前的 -BASE[4]值到临时变量中:
TEMP = -BASE[4] = 1
step 5:计算X_CHECK[{'a’}],即“achelor#”和“adge”的公共前缀。
CHECK[q+a] = CHECK[1+'a'] = CHECK[1+2] = CHECK[3] = 0
计算中CHECK数组下标里的1是经过X_CHECK()计算得出的BASE[4]的新值,CHECK[3] = 0 表示该节点可用,因此:
step 6:在BASE[4]存入新值:
BASE[4] = q =1
并且,更新计算出来的节点的CHECK值:
CHECK[BASE[4] +'a'] = CHECK[1+2] = CHECK[3] =4
这表示一个从节点4到节点3的弧。
注意:由于这个例子中仅有一个公共节点,步骤5和步骤6不用重复,如果有2个或者更多的公共字符,这需要重复相应的次数。
step 7:为了存储剩余的字符串,即“chelor#” 和 “dge#”,计算出由BASE[3]指向的‘c'、’d'两个弧所应存储的位置。根据 X_CHECK({'c',‘d'})方法,如下所示:
’c‘:CHECK[q+'c'] = CHECK[1+4] =CHECK[5] = 0 ==>可用
'd':CHECK[q+'d'] = CHECK[1+5] = CHECK[6] = 0 ==>可用
因此 q=1是计算的结果,设 BASE[3] = 1
step 8:计算BASE节点的下标来引用“chelor#”,并且修改CHECK的值来从新指引到第四步临时变量里的值。
BASE[3] + 'c' = 1+4=5
BASE[5] <- -TEMP = -1 且 CHECK[5] <- 3
建立到TAIL元素的引用,以及弧(3,5)。
step 9:在TAIL中存储余下的子串“helor#”,从BASE[5] 的值(1)开始,而原来占有的TAIL[7] 和TAIL[8] 则弃用,如图7所示。
step 10:同样对另一个“dge#”做如下处理:
BASE [3]+‘d’=l+5=6
BASE [6] = – POS = – 12 and CHECK [6] = 3
存储“ge#” 子串到TAIL数组中,从TAIL数组的POS下标开始。
step 11:更新POS值,使其指向TAIL数组中最后存储的元素的下一位
POS = 12+length[‘ge# ’]=12+3=15
总的来说,当插入时有冲突发生,从TAIL数组中提起出公共的字符存入双数组中(BASE和CHECK)。The values within the double-array for the collisioned strings, including the new string, are moved to the nearest neighbour position available and adjusted to such new positions (see Figure 7 ).
情况4: