查找第一期 - 单词查找树
简介
单词查找树又称”字典树“、”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=5
root = [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;
}
}
}
本文使用 mdnice 排版