Trie树的基础知识的学习
1. Trie树的基本知识
Trie 树
(又叫「前缀树
」或「字典树
」)是一种用于快速查询「某个字符串/字符前缀
」是否存在的数据结构。
Trie 树的本质,就是利用字符串之间的公共前缀,将重复的前缀合并在一起。
Trie树是一种哈希树
的变种。
典型应用:用于统计和排序大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。
优点:最大限度地减少无谓的字符串比较,查询效率比哈希表高。
其核心是使用「边」来代表有无字符,使用「点」来记录是否为「单词结尾」以及「其后续字符串的字符是什么」。
它有3个基本性质:
- 根节点不包含字符,除根节点外每一个节点都只包含一个字符。
- 从根节点到某一节点,路径上经过的字符连接起来,为该节点对应的字符串。
- 每个节点的所有子节点包含的字符都不相同。
注:图片来源宫水三叶,链接在后面。
2. Trie树的实现
Trie树有两种实现方法,一种是直接使用二维数组
实现,一种是使用TrieNode节点
结构实现。
实现的功能包括Trie树的定义,插入,查找,前缀匹配。功能实现来源于leetcode题目:208. 实现 Trie (前缀树)。
2.1 二维数组实现
直接使用「二维数组」来实现 Trie 树。
- 使用二维数组 trie[] 来存储我们所有的单词字符。
- 使用 index 来自增记录我们到底用了多少个格子(相当于给被用到格子进行编号)。
- 使用 count[] 数组记录某个格子「被标记为结尾的次数」(当 idx 编号的格子被标记了 n 次,则有 cnt[idx]=n)。
Java代码实现:
class Trie {
int N = 100009; // 直接设置为十万级
int[][] trie;
int[] count;
int index;
public Trie() { // 定义二维数组
trie = new int[N][26];
count = new int[N];
index = 0;
}
public void insert(String s) { // 插入操作
int p = 0;
for (int i = 0; i < s.length(); i++) {
int u = s.charAt(i) - 'a';
if (trie[p][u] == 0) trie[p][u] = ++index;
p = trie[p][u];
}
count[p]++;
}
public boolean search(String s) { // 查找操作
int p = 0;
for (int i = 0; i < s.length(); i++) {
int u = s.charAt(i) - 'a';
if (trie[p][u] == 0) return false;
p = trie[p][u];
}
return count[p] != 0;
}
public boolean startsWith(String s) { // 前缀匹配
int p = 0;
for (int i = 0; i < s.length(); i++) {
int u = s.charAt(i) - 'a';
if (trie[p][u] == 0) return false;
p = trie[p][u];
}
return true;
}
}
2.2 TrieNode实现
相比二维数组,更加常规的做法是建立 TrieNode
结构节点。
随着数据的不断插入,根据需要不断创建 TrieNode 节点。
class Trie {
class TrieNode { // 定义TrieNode节点的
boolean end;
TrieNode[] tns = new TrieNode[26];
}
TrieNode root;
public Trie() {
root = new TrieNode();
}
public void insert(String s) { // 插入操作
TrieNode p = root;
for(int i = 0; i < s.length(); i++) {
int u = s.charAt(i) - 'a';
if (p.tns[u] == null) p.tns[u] = new TrieNode();
p = p.tns[u];
}
p.end = true;
}
public boolean search(String s) { // 搜索操作
TrieNode p = root;
for(int i = 0; i < s.length(); i++) {
int u = s.charAt(i) - 'a';
if (p.tns[u] == null) return false;
p = p.tns[u];
}
return p.end;
}
public boolean startsWith(String s) { // 前缀匹配
TrieNode p = root;
for(int i = 0; i < s.length(); i++) {
int u = s.charAt(i) - 'a';
if (p.tns[u] == null) return false;
p = p.tns[u];
}
return true;
}
}
2.3 方法对比
使用「二维数组」的好处是写起来飞快,同时没有频繁 new 对象的开销。但是需要根据数据结构范围估算我们的「二维数组」应该开多少行。
坏处是使用的空间通常是「TrieNode」方式的数倍,而且由于通常对行的估算会很大,导致使用的二维数组开得很大,如果这时候每次创建 Trie 对象时都去创建数组的话,会比较慢,而且当样例多的时候甚至会触发 GC(因为 OJ 每测试一个样例会创建一个 Trie 对象)。
因此还有一个小技巧是将使用到的数组转为静态,然后利用 index 自增的特性在初始化 Trie 时执行清理工作 & 重置逻辑。
这样的做法能够使评测时间降低一半,运气好的话可以得到一个与「TrieNode」方式差不多的时间。
2.4 问题分析
关于「二维数组」是如何工作 & 1e5 大小的估算
- 要搞懂为什么行数估算是 1e5,首先要搞清楚「二维数组」是如何工作的。
在「二维数组」中,我们是通过 index 自增来控制使用了多少行的。
当我们有一个新的字符需要记录,我们会将 index 自增(代表用到了新的一行),然后将这新行的下标记录到当前某个前缀的格子中。
举个🌰,假设我们先插入字符串 abc 这时候,前面三行会被占掉。
- 第 0 行 a 所对应的下标有值,值为 1,代表前缀 a 后面接的字符串会被记录在下标为 1 的行内
- 第 1 行 b 所对应的下标有值,值为 2,代表前缀 ab 后面接的字符串会被记录在下标为 2 的行内
- 第 2 行 c 所对应的下标有值,值为 3,代表前缀 abc 后面接的字符串会被记录在下标为 3 的行内
- 当再插入 abcl 的时候,这时候会先定位到 abl 的前缀行(第 3 行),将 l 的下标更新为 4,代表 abcl 被加入前缀树,并且前缀 abcl 接下来会用到第 4 行进行记录。
- 但当插入 abl 的时候,则会定位到 ab 的前缀行(第 2 行),然后将 l 的下标更新为 5,代表 abl 被加入前缀树,并且前缀abl 接下来会使用第 5 行进行记录。
- 当搞清楚了「二维数组」是如何工作之后,我们就能开始估算会用到多少行了。
调用次数为 10^4,传入的字符串长度为 10^3,假设每一次的调用都是 insert,并且每一次调用都会使用到新的 10^3 行。那么我们的行数需要开到 10^7。
但由于我们的字符集大小只有 26,因此不太可能在 10^4 次调用中都用到新的 10^3 行。
而且正常的测试数据应该是 search 和 startsWith 调用次数大于 insert 才有意义的,一个只有 insert 调用的测试数据,任何实现方案都能 AC。
因此我设定了 10^5 为行数估算,当然直接开到 10^6 也没有问题。
参考: