Trie树实现剖析

本文深入解析Trie树的概念、实现方法及其应用场景。从概念层面阐述Trie树的优势,如稳定查询效率与空间节省。通过具体实现展示如何通过结构定义和操作函数构建Trie树。介绍朴素实现、DATrie实现的特点与查询效率,以及libdatrie的使用方法、查询效率和空间占用情况。讨论添加和删除操作的效率、尾缀压缩技术及其对查询性能的影响,并提及libdatrie的中文支持问题。最后,强调理解Trie树对于处理敏感词过滤、多模匹配算法设计的重要性。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

文章转载 : http://tech.weibo.com/?p=2218

概念层面

Trie树是一个非常实用的索引结构,它可以稳定的以O(c)的时间复杂度(c为关键词的长度,与词典大小无关)从词典中查询出关键词对应的值,相比Hash Table,二者查询效率相当,但Trie树的某些实现可以更节省空间

假设一个应用场景:用昵称来判断A是否为B的好友,那么你可以把B所有好友的昵称作为数据源来建立一个Trie树索引,然后将A的昵称作为关键词进行查询,如果存在,则说明A是B的好友

下面是Trie树的概念图,图中的Trie树存储了pool、prize、preview等6个单词,可以看出单词的公共前缀是共享相同节点的,这也是Trie树节省空间的原因

图1

Trie树也是一个FSM(有限状态机),理解FSM的理论非常有助于理解算法实现,按FSM的理论来描述:树节点表示状态,边表示状态转移,查询时根据当前所在状态和输入的字符来确定下一个状态,到达最终状态(双圆圈)则说明关键词存在于词典中,按状态转移表的方式表示,如下

图2

按FSM理论Trie树也可以表示成一个五元组,也就是说它是由五种对象构成

接下来看看具体实现

朴素实现

Trie树的朴素实现很简单,以下是结构定义

#define ALPHABET_SIZE (26)  // 字母表大小,视字符集和编码而定,英文词典为字母数26
 
// trie树节点(状态)
typedef struct trie_node trie_node_t;
struct trie_node
{
    int value;  // 词(最终状态)对应的值,假设用trie树存储姓名和年龄,可以将年龄保存于此
    trie_node_t *children[ALPHABET_SIZE];  // 转移表
};
 
// trie树
typedef struct trie trie_t;
struct trie
{
    trie_node_t *root;  // 树的根节点(起始状态)
    int count;  // 词(最终状态)总数
};

添加一个词

// 将输入的字符转换为转移表的下标
#define CHAR_TO_INDEX(c) ((int)c - (int)'a')
 
void insert(trie_t *pTrie, char *key, int value)
{
    int level;
    int length = strlen(key);
    int index;
    trie_node_t *pTrieNode;
 
    pTrie->count++;
    pTrieNode = pTrie->root;
 
    // 按字逐个插入
    for(level = 0; level < length; level++)     {         index = CHAR_TO_INDEX(key[level]);         // 在当前节点(状态)上,判断下一个状态不存在         if(!pTrieNode->children[index])
        {
            pTrieNode->children[index] = getNode();  // 如果不存在,生成一个新状态
        }
 
        pTrieNode = pTrieNode->children[index];
    }
 
    pTrieNode->value = value;  // 设定词(最终状态)对应的值
}

查询一个词

int search(trie_t *pTrie, char *key)
{
    int level;
    int length = strlen(key);
    int index;
    trie_node_t *pTrieNode;
 
    pTrieNode = pTrie->root;
 
    for(level = 0; level < length; level++)     {         index = CHAR_TO_INDEX(key[level]);         if(!pTrieNode->children[index])
        {
            return 0;  // 中途退出表明词不存在
        }
        pTrieNode = pTrieNode->children[index];
    }
 
    return pTrieNode->value;  // 返回词(最终状态)对应的值
}

从以上代码中可以看到,无论一个节点实际存在几个状态转移,在生成新节点时都会分配一个字母表大小(这里是26)的数组做转移表,空间浪费比较严重,存储图1上的6个单词理论上本只该占用28 byte内存(28个状态),但实际却会占用28*26 byte内存(状态数*字母表大小),如下

图3

也可以考虑将字母表改用链表实现以避免空间浪费,但在判断是否存在状态转移时需要遍历链表,这样会带来额外的复杂度

DATrie实现

DATrie是Double Array Trie的缩写,字面理解就是用两个数组实现的Trie树,它的查询过程示意图如下

图4

双数组就是图中的base和check,base是状态(节点)的集合,check用于状态跳转校验,查询时,先从当前状态取出base值(下标对应的值),与当前输入字符相加得到下一个状态的位置(base数组的下标)后,取check对应位置的值进行判断,如果值等于当前状态就说明可以跳转到下一个状态,持续这个过程,直到最后一个输入字符,或者没有下一个状态可以跳转,也就是中途退出

关于libdatrie

libdatrie是DATrie的一个实现,接下来我们以它为对象来说明DATrie的一些特点

以下所有测试所用机器的配置是:8 * Intel(R) Xeon(R) CPU E5620  @ 2.40GHz,12G内存,硬盘为15K SCSI硬盘无Raid

基本使用方法

以libdatrie-0.2.4为例,点击这里可以下载,测试用的词典可以在这里下载,在configure; make; make install后,会生成一个可执行文件“trietool-0.2”,-h可以查看所有选项的用法,要建立索引,首先新建一个.abm文件说明字母表的数值范围,因为都是英文单词,所以这里设置为0×00-0xff,然后使用add-list将词典添加到索引后就可以开始查询了,操作命令如下

? View Code SHELL
$ echo "[0x00,0xff]" > test.abm
$ ./trietool-0.2 test add-list keyword.txt
$ ls
keyword.txt  test.abm  test.tri  trietool-0.2
$ ./trietool-0.2 test query zone
8969
$ ./trietool-0.2 test add lalalala 2012
$ ./trietool-0.2 test query lalalala
2012

需要注意的是,在建立abm文件时,例如test.abm,那么test就是索引的名称,在使用trietool-0.2命令时,索引名要先于所有选项第一个被指定,test.tri保存着索引从内存dump到磁盘的数据

查询时如果词存在于词典中则返回行号,不存在则什么都不返回

查询效率

从图4可以看出libdatrie在每次状态转换判断时增加了一次指针运算与数值比较操作,但被后面会讲到的尾缀压缩抵消掉一部分后,最终带来的额外复杂度几乎可以忽略不计

这里做一个测试,我们先往索引里添加一个30个字符的词:“abc abc abc abc abc abc abc abc abc abc”,再看看查找它的耗时如何,这里要提醒的是“trietool-0.2”命令每次都会先从磁盘读出索引,查询完成后再写回磁盘,所以测试结果是包含了这些IO时间的,另外,为了避免尾缀压缩的情况,我们将30个字符的词逐字添加到索引中,操作命令如下

? View Code SHELL
$ cat add.sh
#!/bin/sh
 
word=abcabcabcabcabcabcabcabcabcabc
 
for ((i=1; i

空间占用

这里再做一个测试,刚才测试的词典,词典本身大小为128,146 byte,采用朴素算法建立索引,字母表大小为26,总共耗费了 1,937,520 byte内存,采用libdatrie建立索引,字母表取值范围为[ox00,0xff],占用了379,358 byte内存,只有朴素实现的20%,空间浪费问题得到了很好的解决

添删效率

有得必有失,libdatrie在添加新词时就变得比较复杂了,因为存在状态冲突的可能,也就是说有两种条件都可以转移到同一个状态,相当于一个树节点出现了两个父节点的情况,所以在发生冲突时需要改变当前的状态base值,并更新当前状态所有的下一级状态和对应的check值,冲突过程示意图如下

图5

但考虑到索引本来就是写少读多,所以添删时复杂度上升大多数时候都不是关键问题,具体就视应用场景而定了

尾缀压缩

假设现在词典里面以c打头的除了“china”之外就再也没有别的单词了,那么libdatrie在添加“china”时会优先使用尾缀压缩,如果再次出现以c打头的单词(例如“change”)时才会将公共前缀添加到base和check数组中,在尾缀被压缩的情况下,存储的实际结构如下

图6

从上图可以看出,压缩可以减小树的深度,从而提高查询效率

如果需要了解libdatrie的更多细节,可以通过作者的这篇论文进一步了解,代码有800多行,也希望读完我这篇文章能使你理解得更快

关于中文支持问题

libdatrie默认是不支持中文的,一开始时我还不太确定,因为从代码表面看是支持的,但实际测试又有很多小问题,后来google发现有人问过作者是否考虑支持中文,作者的回答是:从来没考虑过,以后有时间再说

最后

因为中国特色的互联网环境,如果你需要的是一个高效的敏感词过滤方案,那么Trie树还不足以解决问题,因为它只能快速响应词的查找,而实际应用中要过滤的往往是文章、帖子这种大段的文字,也就是从一个很长的字符串中快速识别多个模式(关键词),你可能会想到先将大段的文字进行机械分词后再利用Trie树进行判断,但这个思路是不对的,因为这引入了分词这个更麻烦的问题,而AC等多模匹配算法才是解决之道,但AC也是以Trie树为基础演化而来,所以理解好Trie这个经典数据结构也是十分必要的


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值