Trie 树的高效实现

原文: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:

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值