概述

Trie树,字典树,又称单词查找树、前缀树、键树,一种树形结构,哈希树的一种变种。核心思想是空间换时间,利用字符串的公共前缀来降低查询时间的开销,提高效率。优点是可最大限度地减少无谓的字符串比较,查询效率比哈希表高。

Trie Tree的原理是将每个Key拆分成每个单位长度字符,然后对应到每个分支上,分支所在的结点对应为从根结点到当前结点的拼接出的key的值。

一个保存8个键的Trie树:A, to, tea, ted, ten, i, in, inn。

Trie树、Radix树_结点


每一个完整的英文单词对应一个特定的整数。在上图(搬自维基百科)中,键标注在结点中,值标注在结点之下。键不需要被显式地保存在结点中,图示中标注出完整的单词,只是为了演示Trie树的原理。

Trie树中的键通常是字符串,但也可以是其它的结构。Trie树的算法可以很容易地修改为处理其它结构的有序序列,如一串数字或形状的排列。如,Bitwise Trie中的键是一串比特,可以用于表示整数或者内存地址。

前缀树的3个基本性质:

  • 根结点不包含字符,除根结点外每一个结点都只包含一个字符
  • 从根结点到某一结点,路径上经过的字符连接起来,为该结点对应的字符串
  • 每个结点的所有子结点包含的字符都不相同

状态机

Trie树可以看作是一个确定的有限状态自动机,DFA,Deterministic Finite Automaton,尽管边上的符号一般是隐含在分支的顺序中的。

通常用转移矩阵表示。行表示状态,列表示输入字符,(行,列)位置表示转移状态。这种方式的查询效率很高,但由于稀疏的现象严重,空间利用效率很低。也可以采用压缩的存储方式即链表来表示状态转移,但由于要线性查询,会造成效率低下。

应用场景

应用场景如:

  • 统计和排序大量的字符串,不局限于字符串
  • 前缀匹配,文本词频统计,搜索引擎系统
  • 搜索提示,当输入一个网址,可以自动搜索出可能的选择。当没有完全匹配的搜索结果,可以返回前缀最相似的可能

面试题

  • 一个文本文件,大约有一万行,每行一个词,要求统计出其中最频繁出现的前10个词,请给出实现方案与时间复杂度分析

回答:可考虑使用Trie树来统计每个词出现的次数,时间复杂度是Trie树、Radix树_数组_02,其中Trie树、Radix树_数据结构_03表示单词的平均长度。也可以用堆来实现,时间复杂度是Trie树、Radix树_结点_04。总的时间复杂度,是Trie树、Radix树_数组_02Trie树、Radix树_结点_04中较大的哪一个。

  • 寻找热门查询

原题:搜索引擎会通过日志文件把用户每次检索使用的所有检索串都记录下来,每个查询串的长度为1-255字节。假设目前有一千万个记录,这些查询串的重复读比较高,虽然总数是1千万,但是如果去除重复和,不超过3百万个。一个查询串的重复度越高,说明查询它的用户越多,也就越热门。请你统计最热门的10个查询串,要求使用的内存不能超过1G。

提示:利用Trie树,关键字域存该查询串出现的次数,没有出现为0。最后用10个元素的最小堆来对出现频率进行排序。

实现

Trie树的几种实现:

  • 链表指针:最常规。每个结点对应一个字符,并有多个指针指向子结点,查找和插入从根结点按照指针的指向向下查询。这种方案,实现较为简单,但指针较多,较为浪费空间;树形结构,指针跳转,对缓存不够友好,结点数目上去之后,效率不够高。
  • 三数组Trie:Triple Array Trie,利用三个数组(base,next,check)来实现状态的转移,将前缀树压缩到三个数据里,能够较好的节省内存;数组的方式也能较好的利用缓存
  • 双数组Trie:Double Array Trie,在三数组的基础上,base数组重用next数组,即包含base和check两个数组。base数组的每个元素表示一个Trie结点,即一个状态;check数组表示某个状态的前驱状态。节省一个数组,并没有增加其他开销,内存使用和效率进一步提升。
  • HAT:
  • Burst Trie:

代码

一个最基础版本的Trie树的Java实现:

@Slf4j
@Data
public class Trie {
	private Trie[] nextNode = new Trie[26];
	/**
	 * 表示以该处结点之前的字符串为前缀的单词数量
	 */
	private int prefix;
	/**
	 * 表示以当前单词结尾的单词数量
	 */
	private int count;
	
	public static void main(String[] args) {
		Trie trie = new Trie();
		insert(trie, "johnny");
		insert(trie, "wang");
		insert(trie, "wong");
		insert(trie, "awesome");
		log.info("word:{}", search(trie, "johnny"));
		log.info("prefix:{}", searchPrefix(trie, "we"));
		log.info("size:{}", trie.toString().length());
	}
	
	public static void insert(Trie root, String str) {
		if(root == null || str.length() == 0) {
			return;
		}
		for (char c : str.toCharArray()) {
			// 如果该分支不存在,创建一个新结点
			if (root.nextNode[c - 'a'] == null) {
				root.nextNode[c - 'a'] = new Trie();
			}
			root = root.nextNode[c - 'a'];
			// 加在后面
			root.prefix++;
		}
		// 以该结点结尾的单词数+1
		root.count++;
	}
	
	/**
	 * 查找str是否存在,存在返回数量,不存在返回-1
	 */
	public static int search(Trie root, String str) {
		if (root == null || str.length() == 0) {
			return -1;
		}
		for (char c : str.toCharArray()) {
			// 如果该分支不存在,表名该单词不存在
			if (root.nextNode[c - 'a'] == null) {
				return -1;
			}
			// 如果存在,则继续向下遍历
			root = root.nextNode[c - 'a'];
		}
		// 如果count==0,说明该单词不存在
		return root.count == 0 ? -1 : root.count;
	}
	
	/**
	 * 查询以str为前缀的单词数量
	 */
	public static int searchPrefix(Trie root, String str) {
		if (root == null || str.length() == 0) {
			return -1;
		}
		for (char c : str.toCharArray()) {
			if (root.nextNode[c - 'a'] == null) {
				return -1;
			}
			root = root.nextNode[c - 'a'];
		}
		return root.prefix;
	}
}

打印输出:

word:1
prefix:-1
size:3910

insert仅4个单词,size已经达到3900个字符。截图:

Trie树、Radix树_数组_07

缺点

实际上,上面的截图已经给出一个结论:稀疏现象严重,对内存消耗较大,空间利用率较低。

Trie树把很多的公共前缀独立加以复用,避免重复存储。

比如上面代码里使用的4个单词是:johnny、wang、wong、awesome。只有2个单词是w开头,意味着,需要构建3个Trie树。这种情况比较极端。

下面再看看另外一组待存储的数据:

{
  "deck": "a",
  "did": "b",
  "doe": "c",
  "dog": "d",
  "doge": "e",
  "dogs": "f"
}

存储Key的Trie树可能是这样:

Trie树、Radix树_数据结构_08


其中deck这个分支没有发生分叉,did也是如此。作为样例,只有6条数据,多几个next指针,多几次查询,对性能几乎没有影响。如果数据量非常大,怎么办?有一些计算机天才就在想办法对其进行优化。像这样的没有分叉的分支,其实完全可以合并,也就是压缩:

Trie树、Radix树_数据结构_09


压缩(优化)后的树如上图所示,更节省空间,某些分支变矮后,查询效率更高。

这样的树,就是下面要讲的基数树。

Radix树

Compressed Trie Tree,压缩Trie树,压缩前缀树,Radix Tree,也叫基数树。Radix树基于Trie树,为了解决Trie树的空间利用率问题而引入的一种树。

对于基数树的每个结点,如果该结点是确定的子树的话,就和父结点合并。基数树可用来构建关联数组。

用于IP路由。信息检索中用于文本文档的倒排索引。

基数树可看做是以二进制位串为关键字的Trie树,是一种多叉树形结构,同时又类似多层索引表,每个中间结点包含指向多个子结点的指针数组,叶子结点包含指向实际的对象的指针(由于对象不具备树结点结构,因此将其父结点看做叶结点)。基数树也被设计成多道树,以提高磁盘交互性能。同时,基数树也是按照字典序来组织叶结点的,这种特点使之适合持久化改造,加上它的多道特点,灵活性较强,适合作为区块链的基础数据结构,构建持久性区块时较好地映射各类数据集合上。基数树支持插入、删除、查找操作。查找包括完全匹配、前缀匹配、前驱查找、后继查找。所有这些操作都是Trie树、Radix树_数据结构_10复杂度,其中Trie树、Radix树_数据结构_11是所有字符串中最大的长度。

Redis

Redis实现不定长压缩前缀的Radix树,用在集群模式下存储slot对应的所有Key信息,Redis 5.0引入的Stream使用Radix树来存储Key。

Redis作者antirez基于ANSI C语言给出Radix Tree的实现,并命名为Rax

raxNode是Radix Tree的核心数据结构:

typedef struct raxNode {    
   uint32_t iskey:1; // 是否包含key,
   uint32_t isnull:1; // 是否有存储value值,比如存储元数据就只有key,没有value值。value值也是存储在data中
   uint32_t iscompr:1; // 是否前缀压缩
   uint32_t size:29; // 该结点存储的字符个数
   unsigned char data[]; // 存储子结点的信息
} raxNode;

字段详细解释:

  • iskey:表示这个结点是否包含key
  • 0:没有key
  • 1:表示从头部到其父结点的路径完整的存储key,查找的时候按子结点iskey=1来判断key是否存在
  • isnull:是否有存储value值,比如存储元数据就只有key,没有value值。value值也是存储在data中
  • iscompr:是否有前缀压缩,决定data存储的数据结构
  • size:该结点存储的字符个数
  • data:存储子结点的信息
  • iscompr=0:非压缩模式下,数据格式是:[header strlen=0][abc][a-ptr][b-ptr][c-ptr](value-ptr?),有size个字符,紧跟着是size个指针,指向每个字符对应的下一个结点。size个字符之间互相没有路径联系
  • iscompr=1:压缩模式下,数据格式是:[header strlen=3][xyz][z-ptr](value-ptr?),只有一个指针,指向下一个结点。size个字符是压缩字符片段

Redis中的rax在结构上不是标准的Radix Tree,如果一个中间结点有多个叶子结点,路由键就是一个字符;如果只有一个叶子结点,路由键就是一个字符串。只有一个叶子结点时,就是压缩结点(多个字符压在一起的字符串)

iscompr记录这个结点是不是压缩的,压缩或不压缩会直接反映在data的结构里。

raxNode有三种类型,根结点、叶子结点、中间结点。有些中间结点带value,有些是结构性需要,没有value。

操作

C语言源码分析,TODO。

插入
比较复杂,

删除
删除一个key的流程比较简单,找到iskey的结点后,向上遍历父结点删除非iskey的结点。如果是非压缩的父结点且size大于1,表示还有其他非相关的路径存在,则需要按删除子结点的模式去处理这个父结点,主要是做memove和realloc。

合并
删除一个key之后需要尝试做一些合并,以收敛树的高度。合并的条件:

  • iskey=1的结点不能合并
  • 子结点只有一个字符
  • 父结点只有一个子结点(如果父结点是压缩前缀的结点,那么只有一个子结点,满足条件。如果父结点是非压缩前缀的结点,那么只能有一个字符路径才能满足条件)

参考