单词查找树
简介
单词查找树又称”字典树“、”Trie 树“,是一种树形结构。单词查找树示意图如下所示

单词查找树的作用
单词的快速查找(不然为啥叫单词查找树嘛),当然也可以在单词查找的基础上进行扩展,例如进行词频统计,存取数据等等,这个会在后面详细介绍
单词查找树的特性
- 根节点不存在字符,其他每个节点只存在一个字符。其根节点的存在只是为了简化查询操作,并无特殊意义
- 从根节点到某个节点的路径连接起来时,就是一个完整的字符串
如上图所示,整个路径连一起为字符串 “tree” - 每个节点的子节点所包含在字符唯一。不会出现一下情况

- 每个节点都存在 R 条链接,R 为字符的范围。举个例子,如果是 ASCII 编码,则 R 为 256。R 向单词查找树可能会存在很多的空键,所以在绘制时一般忽略空键。示意图如下所示

字母表的大小为 R,在一棵由 N 个键构造的单词查找树中,未命中查找平均所需检查的数量为~,链接总数在 RN 到 RNw 之间(w 为键的平均长度),所以 R 向单词查找树适用于字母范围和键长比较小的情况(《算法 4》有证明)
单词查找树优势
- 查找命中所需要的时间与查找键的长度成正比
- 查找未命中只需要查找若干个字符,M 为未命中所需要查询的次数(M<=L),未命中平均查找字符数为 《算法 4 有证明》

如上图所示,查询字符串 “te” 时,在 t 节点的子节点匹配失败,无法匹配 “e”
单词查找树与其他查找方式比较
和遍历相比
引入一个新华词典查找汉字 ”树“ 的例子
- 第一种查询方式:从新华词典第一个页开始翻,每一页看下是否存在“树”,直到第 n 页后,查找到”树“
- 第二种查询方式:”树“拼音为”shu“
- 先在字典目录中查询”s“的页码,然后翻到对应的页码
- 再根据”h“翻到对应的页码;
- 再根据“u”翻到对应的页码
- 最后根据“shu”拼音确认一个大概的范围,再在这个范围内寻找“树”
第一种方式类比遍历,第二种方式类比单词查找树。无需多说,第一个方式比第二种方式慢太多了。如果想查询个“字典树”这三个字,按照第一种方式估计得半天时间
和散列表相比
单纯的对于字符串查询,散列表可以做到 O(1),难道还有比 O(1)更快的算法?那单词查找树还能有啥优势?
其实散列表相较于单词查找树存在两个弊端
- 空间占用:散列表需要显示得存储每个字符,而单词查找树可以压缩空间。单词查找树对于相同前缀的字符串,是存储到一起的,例如 tree,和 trie,tr 占用同一个空间。而散列表需要分别存储 tree 和 trie
- 前缀匹配:散列表无法查询前缀。例如我现在存储的是 “tree”和”trie”,但是我想查询的是 “是否包含”tr*“的字符”,此时散列表就无能为力了
单词查找树代码实现
此处实现代码,假设所有字符为英文小写字母,即 R=26
定义单词查找树方法
class TrieTree { match(str: string): boolean; // 查询字符串是否存在与单词查找树中 insert(str: string): void; // 插入 delete(str: string): void; // 删除}定义树节点
class TrieTreeNode { value: number; next: (TrieTreeNode | null)[];
constructor(value: number, R: number) { this.value = value; this.next = new Array(R).fill(null); }}这里值得一提的是,既然我们都已经知道了 R 向单词查找树的特性每个节点都含有 R 条链接,那我们这里为什么不直接使用数组来存储呢?例如如下存储方式
// 为了展示方便,R就不取26了,实在是太长了,假设R=5root = [null, null, null, null, null];
// 当存储 "ab" 时const root = [ [null, [null, null, null, null, null], null, null, null], null, null, null, null,];由于 root[0] !== null,则表示 “a” 字符存在,root[0][1] !== null,则表示 “a” 字符下 “b” 字符存在,合一起则为 “ab”
那为什么不用这种方式存储呢?这里需要考虑到一个问题,就是删除操作。举个例子

我们看向这该单词查找树右侧,假设右侧存储 “tr”,“tree”,“trie” 字符串,此时我想删除 “tr”,既然要删除 “tr” 那么必然的需要删除掉,t 节点、r 节点,但是删除 t、r 节点的后果就是再也无法访问到后续的 e 节点和 i 节点,表示 e 节点和 i 节点也莫名其妙被删除了,这显然是错误的
所以,我们需要存储一个表示当前节点是否可用,所以使用 value 来存储当前节点的频率,当此节点频率为 0 时,则表示此节点是不存在的,之所以未删除是为了后续节点可以正常访问
初始化
class TrieTree { private root: TrieTreeNode; private readonly R: number;
constructor(R: number) { this.R = R; this.root = new TrieTreeNode(0, this.R); }
static getCharCodeBaseA(s: string): number { return s.charCodeAt(0) - 97; }}查找(匹配)
单词查找树的每个节点都存储了,下一个节点所包含的所有可能的字符链接。拿 TrieTreeNode 来说,next 存储了下一个节点所有可能存在的字符
在查询时,可能遇到如下情况
- 节点 next 不包含字符,则此次为非命中查询,直接结束,为”不匹配“。例如查询 “te”,“t”可以匹配,但”e”无法匹配
- 节点 next 包含字符,则此次为命中查询,继续下一次查询。例如查询 “te”,“t” 匹配,还需要继续匹配 “e”
- 键遍历完成,结束查询。例如查询 “tr”,“t、r” 都可以匹配
- 完成查询后,如果当前节点频率为 0,则为”不匹配“,否则为”匹配“
class TrieTree { ... match(key: string): boolean { let i: number = 0; let node: TrieTreeNode = this.root;
while (i < key.length) { const code: number = TrieTree.getCharCodeBaseA(key[ i ]); if (node.next[ code ] === null) { return false; }
node = node.next[ code ]!; i++; } return node.value > 0; }}插入
在插入时,首先也是进行查找操作,可能出现如下情况
- 插入时,单词查找树不存在该字符,则需要创建一个新的节点来保存该字符,此时词频为 1
- 插入时,单词查找树中已经存在此字符,则进入下一步,此时词频+1
class TrieTree { ... insert(key: string): void { let i: number = 0; let node: TrieTreeNode = this.root;
while (i < key.length) { const code: number = TrieTree.getCharCodeBaseA(key[ i ]); const nextNode: TrieTreeNode | null = node.next[ code ];
if (nextNode === null) { node = node.next[ code ] = new TrieTreeNode(1, this.R); } else { nextNode.value++; node = nextNode; } i++; } }}删除
删除时,可能出现如下情况
- 字符串无法匹配,则表示字符串不存在于该单词查找树,则不做任何操作
- 字符串匹配
- 词频为 0,则表示字符串不存在于单词查找树,则不做任何操作
- 词频大于 0,则涉及到所有字符的词频减 1
class TrieTree { ... delete(key: string): void { this.del(key, 0, this.root); }
private del(key: string, i: number, node: TrieTreeNode): boolean { if (i === key.length) { return true; }
const code: number = TrieTree.getCharCodeBaseA(key[ i ]); const nextNode: TrieTreeNode | null = node.next[ code ];
if (nextNode !== null && this.del(key, i + 1, nextNode)) { nextNode.value--; return true; }
return false; }}扩展 1-词频率统计
统计字符出现的频率,可能出现以下情况
- 查询字符不匹配,则词频为 0
- 查询字符匹配,则返回存储的值
class TrieTree { private getTreeNode(key: string): TrieTreeNode | null { let i: number = 0; let node: TrieTreeNode = this.root;
while (i < key.length) { const code: number = TrieTree.getCharCodeBaseA(key[i]); if (node.next[code] === null) { return null; } i++; node = node.next[code]!; } return node; }
getRank(key: string): number { const node: TrieTreeNode | null = this.getTreeNode(key); if (node === null) return 0; return node.value; }}扩展 2-存取数据
有一个需求,要求实现使用单词查找树存储如下数据{ name: "Tom",age: 24 },并且可以根据 key 值取出对应的值,表示为 [[Set]]("name","Tom"); [[GET]]("name") === "Tom"
我们看向 TrieTreeNode
class TrieTreeNode { value: number; next: (TrieTreeNode | null)[];
constructor(value: number, R: number) { this.value = value; this.next = new Array(R).fill(null); }}我们原来是将 value 用来存储词频,那同理我们也可以使用 value 来存储值,我们对 TrieTreeNode 扩展
class TrieTreeNode { value: any; // 存储任意值 next: (TrieTreeNode | null)[];
constructor(value: number, R: number) { this.value = value; this.next = new Array(R).fill(null); }}存取数据实现
class TrieTree { ... // [[SET]] setValue(key: string, value: any): void { let i: number = 0; let node: TrieTreeNode = this.root;
while (i < key.length) { const code: number = TrieTree.getCharCodeBaseA(key[i]); const nextNode: TrieTreeNode | null = node.next[code];
if (nextNode === null) { node = node.next[code] = new TrieTreeNode(null, this.R); } else { node = nextNode; } i++; } node.value = value; }
// [[GET]] getValue(key: string): any | null { const node: TrieTreeNode | null = this.getTreeNode(key); return node === null ? null : node.value; }
// [[DELETE]] deleteValue(key: string): void { const node: TrieTreeNode | null = this.getTreeNode(key); if (node === null) return; node.value = null; }}三向单词查找树
现在又有另一个需求,我想快速查询的不是英文字符了,而是 某个地区的人名称。根据百度得中文常用字为 3500 字(不考虑生僻字哈),如果按照 R 向单词查找树的话,R=3500,假设 w=3,这将是巨大的空间消耗
由于 R 向单词树对于长键和大字符范围较大的情况下,空间消耗比较大,如果你能满足如此大的空间消耗,则它的性能是极好的。那么有没有一种性能较好,但空间消耗不是这么夸张的呢?接下来让我们介绍 三向单词查找树(TST)
三向单词查找树的一个节点包含一个键,一个值,三个链小于该键的左链,等于该键的中链,大于该键的右链

如上图所示的三向单词查找树,举个例子查找 “cn”
- 匹配”c”:“c” 与 c 节点的 KEY 匹配,则进入 c 节点的中链
- 匹配 “n”:“n” 与 o 节点 KEY 不匹配,且”n” < “o”,则进入 o 节点左链
- 匹配”n”:“n”与 n 节点 KEY 匹配,则 “cn”匹配完成,获取到 VALUE 为 “google”
三向单词查找树的特性
- 三向单词查找树最重要的性质就是每个节点只含三个链,因此它的空间远小于单词查找树
- 树的节点表示取决于键的插入顺序(R 向单词查找树则与键插入顺序无关)
- 根节点也包含字符了
- 查找成本,由 N 个随机字符串构造的三向单词查找树,未命中平均需要查找~,除此外一次插入或命中查找会比较一次键的所有字符《算法 4 证明》
三向单词查找树实现
定义三向单词查找树的方法
class ThreeTrieTree { getValue(key: string): any | null; setValue(key: string, value: any): void; deleteValue(key: string): void;}根据三向单词查找树特性定义树节点
class ThreeTrieTreeNode { key: string; value: any; left: ThreeTrieTreeNode | null = null; mid: ThreeTrieTreeNode | null = null; right: ThreeTrieTreeNode | null = null;
constructor(key: string, value: any) { this.key = key; this.value = value; }}初始化
class ThreeTrieTree { private root: ThreeTrieTreeNode | null;
constructor() { this.root = null; }}查找
查找时可能出现如下情况
- 节点为 null 时,匹配结束,未查找到结果
- 节点不为 null,
- 字符等于节点 KEY,则进入中链,继续比较
- 字符小于节点 KEY,则进入左链,继续比较
- 字符大于节点 KEY,则进入右链,继续比较
- 当字符遍历完成且节点不为 null,则查找到结果
class ThreeTrieTree { ... getValue(key: string): any | null { const node: ThreeTrieTreeNode | null = this.getTreeNode(key); return node === null ? null : node.value; }
private getTreeNode(key: string): ThreeTrieTreeNode | null { let i: number = 0; let node: ThreeTrieTreeNode | null = this.root;
while (i < key.length - 1) { if (node === null) return null;
if (key[i] === node.key) { node = node.mid!; i++; } else if (key[i] < node.key) { // 进入left node = node.left!; } else { // 进入 right node = node.right; } } return node; }}插入
插入也是需要先查找
- 当节点为 null,新建节点,并进入新节点的中键
- 节点不为 null
- 字符等于节点 KEY,则进入中链,继续比较
- 字符小于节点 KEY,则进入左链,继续比较
- 字符大于节点 KEY,则进入右链,继续比较
- 当键遍历完成后,则设置当前节点的值
class ThreeTrieTree {
... setValue(key: string, value: any): void { this.root = this.setTreeNode(this.root, key, 0, value); }
private setTreeNode(node: ThreeTrieTreeNode | null, key: string, i: number, value: any): any { if (node === null) { node = new ThreeTrieTreeNode(key[ i ], null); }
if (i === key.length - 1) { // 此时匹配完成了,该赋值了 node.value = value; return node; }
if (key[ i ] === node.key) { node.mid = this.setTreeNode(node.mid, key, i + 1, value); } else if (key[ i ] > node.key) { node.right = this.setTreeNode(node.right, key, i, value); } else { node.left = this.setTreeNode(node.left, key, i, value); } return node; }}删除
删除也是需要先查找
- 当查询出的节点为 null,则不做任何操作
- 当查询处的节点不为 null,则清除该节点的值
class ThreeTrieTree { deleteValue(key: string): void { const node: ThreeTrieTreeNode | null = this.getTreeNode(key); if (node !== null) { node.value = null; } }}