Java版LeetCode热题100之实现 Trie(前缀树):从原理到实战的深度解析
摘要:本文将深入剖析 LeetCode 热题 100 中的经典题目——实现 Trie(前缀树)。我们将从原题出发,逐步拆解其背后的算法思想、数据结构设计,并提供完整可运行的 Java 实现代码。文章涵盖时间/空间复杂度分析、常见面试问题、实际应用场景、相关题目推荐等模块,旨在帮助读者全面掌握 Trie 的核心原理与工程实践。
一、原题回顾
题目名称:实现 Trie(前缀树)
题目编号:LeetCode 208
难度等级:中等
题目描述
Trie(发音类似 “try”)或者说 前缀树 是一种树形数据结构,用于高效地存储和检索字符串数据集中的键。这一数据结构有相当多的应用情景,例如自动补全和拼写检查。
请你实现 Trie 类:
- Trie() 初始化前缀树对象。
- void insert(String word) 向前缀树中插入字符串 word。
- boolean search(String word) 如果字符串 word 在前缀树中,返回 true(即,在检索之前已经插入);否则,返回 false。
- boolean startsWith(String prefix) 如果之前已经插入的字符串 word 的前缀之一为 prefix,返回 true;否则,返回 false。
示例
输入:
["Trie", "insert", "search", "search", "startsWith", "insert", "search"]
[[], ["apple"], ["apple"], ["app"], ["app"], ["app"], ["app"]]
输出:
[null, null, true, false, true, null, true]
解释:
Trie trie = new Trie();
trie.insert("apple");
trie.search("apple"); // 返回 True
trie.search("app"); // 返回 False
trie.startsWith("app"); // 返回 True
trie.insert("app");
trie.search("app"); // 返回 True
提示
- 1 <= word.length, prefix.length <= 2000
- word 和 prefix 仅由小写英文字母组成
- insert、search 和 startsWith 调用次数总计不超过 3 * 10^4 次
二、原题分析
本题要求我们实现一个支持三种基本操作的数据结构:插入字符串、精确查找字符串、判断是否存在以某前缀开头的字符串。
乍看之下,这似乎可以用哈希表(HashMap)解决:插入时存入 key,查找时直接判断 key 是否存在。但这种方法无法高效支持“前缀匹配”操作(startsWith)。例如,若已插入 "apple"、"application"、"apply",当查询前缀 "app" 时,哈希表需要遍历所有 key 才能判断是否存在匹配项,效率低下。
而 Trie(前缀树) 正是为解决此类问题而生的数据结构。它通过共享公共前缀的方式组织字符串,使得前缀匹配可以在 O(L) 时间内完成(L 为前缀长度),无需遍历整个数据集。
三、答案构思
3.1 Trie 的核心思想
Trie 是一棵多叉树,每个节点代表一个字符。从根节点到任意节点的路径构成一个字符串前缀。关键特性如下:
- 根节点不存储字符,仅作为起点。
- 每个节点包含若干子节点,对应可能的下一个字符。
- 标记结束节点:某些节点被标记为“单词结尾”,表示从根到该节点的路径构成一个完整单词。
例如,插入 "app" 和 "apple" 后的 Trie 结构如下(简化图示):
(root)
|
a
|
p
|
p ← isEnd = true ("app")
|
l
|
e ← isEnd = true ("apple")
可以看到,两个单词共享了前缀 "app",节省了存储空间,同时支持高效的前缀查询。
3.2 数据结构设计
我们需要定义一个 Trie 类,包含以下成员:
- children: 长度为 26 的数组,children[i] 表示当前节点的第 i 个子节点(对应 'a' + i 字符)。
- isEnd: 布尔值,标记当前节点是否为某个单词的结尾。
为什么用数组而不是 Map?
因为题目限定字符仅为小写字母(共 26 个),使用固定大小数组访问更快(O(1)),且内存连续性更好。若字符集更大或不确定,可改用 HashMap<Character, Trie>。
3.3 操作逻辑
插入(insert)
- 计算索引 index = ch – 'a'
- 若 children[index] == null,则新建一个子节点
- 移动到该子节点
查找完整单词(search)
判断前缀存在(startsWith)
辅助方法:searchPrefix
- 从根开始,逐字符向下遍历
- 若某字符对应的子节点为空,返回 null
- 否则返回最终到达的节点
四、完整答案(Java 实现)
class Trie {
private Trie[] children;
private boolean isEnd;
/**
* 初始化 Trie 根节点
*/
public Trie() {
// 26 个小写字母
children = new Trie[26];
isEnd = false;
}
/**
* 插入一个单词到 Trie 中
*/
public void insert(String word) {
Trie node = this; // 从根节点开始
for (int i = 0; i < word.length(); i++) {
char ch = word.charAt(i);
int index = ch – 'a'; // 转换为 0~25 的索引
// 如果该字符对应的子节点不存在,创建新节点
if (node.children[index] == null) {
node.children[index] = new Trie();
}
// 移动到子节点
node = node.children[index];
}
// 标记当前节点为单词结尾
node.isEnd = true;
}
/**
* 查找单词是否存在于 Trie 中(必须是完整单词)
*/
public boolean search(String word) {
Trie node = searchPrefix(word);
// 节点存在 且 是单词结尾
return node != null && node.isEnd;
}
/**
* 判断是否存在以 prefix 开头的单词
*/
public boolean startsWith(String prefix) {
// 只需判断前缀路径是否存在
return searchPrefix(prefix) != null;
}
/**
* 辅助方法:查找 prefix 对应的最后一个节点
* @return 若存在则返回节点,否则返回 null
*/
private Trie searchPrefix(String prefix) {
Trie node = this;
for (int i = 0; i < prefix.length(); i++) {
char ch = prefix.charAt(i);
int index = ch – 'a';
// 路径中断,前缀不存在
if (node.children[index] == null) {
return null;
}
node = node.children[index];
}
return node;
}
}
✅ 该实现完全符合题目要求,可通过 LeetCode 全部测试用例。
五、代码分析
5.1 类结构
- Trie 类既是节点类型,也是整棵树的入口(根节点)。
- 每个 Trie 实例代表树中的一个节点。
- 使用 this 作为根节点进行操作,符合面向对象设计。
5.2 关键方法解析
insert
- 时间复杂度:O(L),L 为 word 长度
- 每次只处理一个字符,最多创建 L 个新节点
- 最后设置 isEnd = true 是关键,区分“前缀”和“完整单词”
search vs startsWith
- 两者都依赖 searchPrefix
- 区别在于:search 要求节点是单词结尾,startsWith 只要求路径存在
searchPrefix
- 封装了公共逻辑,避免代码重复
- 返回节点而非布尔值,便于上层判断
5.3 边界情况处理
- 空字符串?题目规定长度 ≥1,无需处理
- 重复插入?多次插入同一单词,isEnd 仍为 true,无副作用
- 查询不存在的前缀?searchPrefix 返回 null,上层安全处理
六、时间复杂度与空间复杂度分析
6.1 时间复杂度
| Trie() 初始化 | O(1) | 仅分配 26 个引用(未创建子节点) |
| insert(word) | O(L) | L = word.length(),每个字符处理一次 |
| search(word) | O(L) | 同上 |
| startsWith(prefix) | O(L) | L = prefix.length() |
所有操作的时间复杂度仅与字符串长度相关,与已插入单词总数无关!这是 Trie 相比哈希表的最大优势。
6.2 空间复杂度
-
最坏情况:所有插入的单词无公共前缀(如 “a”, “b”, “c”, …)
- 每个单词需独立路径
- 总节点数 ≈ 所有单词长度之和
- 每个节点占用 26 * 引用大小 + 1 * boolean
- 空间复杂度:O(N × L × Σ),其中:
- N:单词数量
- L:平均单词长度
- Σ:字符集大小(本题 Σ=26)
-
最好情况:所有单词共享长前缀(如 “app”, “apple”, “application”)
- 节点复用率高,空间节省显著
实际应用中,英文单词通常有大量公共前缀,Trie 的空间效率较高。
七、常见问题解答(FAQ)
Q1: 为什么不用 HashMap 存储子节点?
答:本题字符集固定(26 小写字母),数组访问更快(直接索引 vs 哈希计算),且内存更紧凑。若字符集大(如 Unicode)或稀疏,可用 Map<Character, Trie> 节省空间。
Q2: 如何删除一个单词?
答:LeetCode 本题不要求,但可扩展:
- 先查找单词是否存在
- 从尾部向上回溯,若某节点无子节点且非其他单词结尾,可删除
- 需注意:不能破坏其他共享前缀的单词
Q3: Trie 能处理中文吗?
答:可以,但需调整:
- 字符集变大(常用汉字约 3500+)
- 改用 Map<Character, Trie> 或 Trie[] 动态扩容
- 内存开销显著增加
Q4: isEnd 的作用是什么?
答:区分“路径存在”和“完整单词”。例如:
- 插入 “app” 后,“a”、“ap” 路径存在,但不是单词
- 只有 “app” 的末节点 isEnd = true
八、优化思路
8.1 空间优化:使用 Map 替代数组
class Trie {
private Map<Character, Trie> children = new HashMap<>();
private boolean isEnd = false;
public void insert(String word) {
Trie node = this;
for (char ch : word.toCharArray()) {
node.children.putIfAbsent(ch, new Trie());
node = node.children.get(ch);
}
node.isEnd = true;
}
// 其他方法类似…
}
优点:
- 节省内存(尤其字符集大或稀疏时)
- 支持任意字符
缺点:
- 哈希计算开销
- 无法利用字符连续性
8.2 压缩 Trie(Radix Tree / Patricia Trie)
将单链路径压缩为一个节点,例如:
原始: a -> p -> p -> l -> e
压缩: "apple"
适用场景:长单词、低分支度(如 IP 路由表)
8.3 添加计数功能
在节点中增加 int count,记录以该节点为前缀的单词数量,支持:
- 统计前缀出现次数
- 实现 Top-K 自动补全
九、数据结构与算法基础知识点回顾
9.1 什么是 Trie(前缀树)?
- 定义:一种有序树,用于存储动态集合,其中键通常是字符串。
- 别名:字典树、单词查找树
- 核心思想:空间换时间,通过共享前缀加速查询
9.2 Trie 的典型性质
| 根节点 | 不包含字符 |
| 路径 | 从根到节点的路径 = 字符串前缀 |
| 分支 | 每个节点的子节点代表不同字符 |
| 叶子 | 不一定是单词结尾(因单词可为另一单词前缀) |
9.3 与其他数据结构对比
| HashSet | O(1) | O(1) | O(N×L) | 低 |
| TreeSet | O(L log N) | O(L log N) | O(L + K) | 中 |
| Trie | O(L) | O(L) | O(L) | 高 |
注:TreeSet 的前缀匹配需中序遍历,K 为匹配结果数
9.4 Trie 的局限性
- 内存消耗大:每个节点需存储 Σ 个指针
- 缓存不友好:节点分散,缓存命中率低
- 不适合短单词集:若单词少且无公共前缀,不如哈希表
十、面试官提问环节(模拟)
Q1: 你能手写一个 Trie 吗?重点讲讲 search 和 startsWith 的区别。
答:当然。两者都调用 searchPrefix 找到前缀末尾节点。区别在于:
- search 要求该节点 isEnd == true,即是一个完整插入的单词
- startsWith 只要路径存在即可,不要求是完整单词
例如,插入 “apple” 后:
- search("app") → false(“app” 未被插入)
- startsWith("app") → true(“apple” 以 “app” 开头)
Q2: 如果字符集包含大写字母、数字、符号,如何修改?
答:有几种方案:
推荐方案 2,灵活且节省空间。
Q3: Trie 在搜索引擎中如何用于自动补全?
答:步骤如下:
可进一步优化:
- 节点存储热门词计数
- 使用优先队列限制结果数量
Q4: 如何统计以某前缀开头的单词数量?
答:可在节点中增加 int prefixCount 字段:
- insert 时,路径上每个节点 prefixCount++
- 查询时直接返回该节点的 prefixCount
例如插入 “app”, “apple”:
- ‘a’ 节点 count=2
- ‘p’ 节点 count=2
- 第二个 ‘p’ 节点 count=2
- ‘l’ 节点 count=1
十一、这道算法题在实际开发中的应用
11.1 搜索引擎自动补全(Autocomplete)
- 用户输入时实时提示可能的搜索词
- Trie 支持毫秒级前缀匹配
- 结合用户历史、热门词提升体验
11.2 拼写检查(Spell Checker)
- 构建正确单词的 Trie
- 对输入单词,查找编辑距离 ≤1 的候选词(需结合其他算法)
11.3 IP 路由表(Longest Prefix Match)
- 将 IP 地址视为二进制字符串
- 使用压缩 Trie(Patricia Trie)高效匹配最长前缀路由
11.4 敏感词过滤
- 将敏感词库构建成 Trie
- 对文本逐字符匹配,发现前缀即触发过滤
11.5 生物信息学:DNA 序列匹配
- DNA 序列由 A/T/C/G 组成(Σ=4)
- Trie 用于快速查找基因片段
十二、相关题目推荐
| 211 | 添加与搜索单词 – 数据结构设计 | 中等 | Trie + 通配符 ‘.’ |
| 212 | 单词搜索 II | 困难 | Trie + DFS 网格搜索 |
| 421 | 数组中两个数的最大异或值 | 中等 | 二进制 Trie |
| 648 | 单词替换 | 中等 | Trie 前缀匹配替换 |
| 677 | 键值映射 | 中等 | Trie + 计数 |
| 720 | 词典中最长的单词 | 简单 | Trie + 排序 |
| 745 | 前缀和后缀搜索 | 困难 | 双 Trie 或后缀 Trie |
建议学习路径:先掌握本题 → 211(通配符)→ 212(综合应用)→ 421(二进制 Trie)
十三、总结与延伸
13.1 核心收获
- Trie 是处理字符串前缀问题的利器
- 时间复杂度稳定 O(L),不受数据规模影响
- 设计关键:节点结构(children + isEnd)、路径遍历逻辑
- 权衡:空间换时间,适用于前缀密集场景
13.2 延伸思考
- 持久化 Trie:如何将 Trie 存入数据库?
- 并发安全:多线程插入/查询如何加锁?(读写锁 or 无锁 Trie)
- 分布式 Trie:海量词库如何分片存储?
- 与 AC 自动机结合:多模式串匹配(如敏感词检测)
13.3 学习建议
结语:Trie 虽然不是最常用的数据结构,但在特定场景下(尤其是涉及前缀操作时)具有不可替代的优势。掌握它,不仅是为了通过一道 LeetCode 题,更是为了在真实工程中多一把“利器”。希望本文能助你深入理解 Trie 的精髓,从容应对面试与实战!
网硕互联帮助中心





评论前必须登录!
注册