字典树(Trie ,也译作 "前缀树"或"单词查找树")是一种树形数据结构 ,核心用于高效存储和检索字符串集合,其设计思想是利用字符串的公共前缀 共享存储空间,大幅降低字符串匹配的时间和空间开销。它广泛应用于搜索引擎、输入法、拼写检查、IP 路由等场景,是处理字符串前缀匹配的最优数据结构之一。
一、核心概念
1.基本定义
Trie 树的每个节点代表一个字符(或空),从根节点到某一叶子 / 标记节点的路径拼接起来,构成一个完整的字符串;节点额外存储 "是否为单词结尾" 的标记,用于区分 "前缀" 和 "完整单词"。
2.核心特点
| 特点 | 说明 |
|---|---|
| 根节点无字符 | 根节点不存储任何字符,仅作为所有字符串的起始入口 |
| 子节点字符唯一性 | 每个节点的所有子节点代表的字符互不重复(如节点 A 的子节点不能同时有两个 'b') |
| 路径表示字符串 | 从根到任意节点的路径拼接,即为该节点对应的前缀;标记节点对应完整单词 |
| 公共前缀共享节点 | 拥有相同前缀的字符串,共享前缀部分的节点(如 "app" 和 "apple" 共享 "a-p-p" 节点) |
| 操作效率稳定 | 插入 / 查询 / 删除的时间复杂度均为 O (k)(k 为字符串长度),与字符串集合大小无关 |
二、字典树的核心操作
1. 插入(Insert)
目标:将一个字符串插入到 Trie 树中,利用公共前缀共享节点。
步骤:
- 从根节点开始,遍历字符串的每个字符;
- 对当前字符,检查当前节点的子节点中是否存在该字符:
- 存在:移动到该子节点,继续处理下一个字符;
- 不存在:创建新节点存储该字符,添加为当前节点的子节点,再移动到新节点;
- 遍历完所有字符后,将最后一个节点标记为 "单词结尾"(如设置
isEnd = true)。
2. 查询(Search)
目标:判断某个字符串是否完整存在于 Trie 树中(区别于前缀)。
步骤:
- 从根节点开始,遍历字符串的每个字符;
- 对当前字符,检查当前节点的子节点中是否存在该字符:
- 不存在:直接返回
false(字符串不存在); - 存在:移动到该子节点,继续处理下一个字符;
- 不存在:直接返回
- 遍历完所有字符后,检查最后一个节点是否标记为 "单词结尾":
- 是:返回
true(字符串存在); - 否:返回
false(仅为前缀,非完整单词)。
- 是:返回
3. 前缀查询(StartsWith)
目标:判断 Trie 树中是否存在以指定字符串为前缀的单词。
步骤:
- 流程与 "查询" 基本一致,但无需检查最后一个节点的 "单词结尾" 标记;
- 只要遍历完所有前缀字符且每一步都存在对应子节点,即返回
true。
4. 删除(Delete,可选)
目标:从 Trie 树中移除指定字符串,需避免误删共享前缀的节点。
步骤(分三种情况):
- 待删除字符串是唯一路径(无共享前缀):从最后一个节点向上删除所有单孩子节点,直到根节点;
- 待删除字符串是其他字符串的前缀(如删除 "app",但存在 "apple"):仅取消最后一个节点的 "单词结尾" 标记;
- 待删除字符串包含其他字符串的前缀(如删除 "apple",但存在 "app"):删除 "apple" 独有的节点(l、e),保留共享的 "app" 节点。
三、代码实现
java
/**
* 字典树(Trie)实现
* 支持:插入字符串、查询完整字符串、前缀匹配、删除字符串
*/
public class Trie {
// 字典树节点定义
static class TrieNode {
// 子节点:存储26个小写字母(以索引0=a,1=b...25=z)
TrieNode[] children;
// 标记当前节点是否为某个单词的结尾
boolean isEnd;
// 统计以该节点结尾的单词出现次数(词频统计用)
int count;
public TrieNode() {
children = new TrieNode[26]; // 初始化26个空节点
isEnd = false;
count = 0;
}
}
private final TrieNode root; // 根节点(无字符)
// 初始化字典树
public Trie() {
root = new TrieNode();
}
/**
* 插入字符串到字典树
* @param word 要插入的字符串(仅小写字母)
*/
public void insert(String word) {
if (word == null || word.isEmpty()) {
return;
}
TrieNode current = root;
// 遍历字符串的每个字符
for (char c : word.toCharArray()) {
int index = c - 'a'; // 字符转索引(a→0,b→1...)
// 若当前字符对应的子节点不存在,创建新节点
if (current.children[index] == null) {
current.children[index] = new TrieNode();
}
// 移动到子节点
current = current.children[index];
}
// 标记当前节点为单词结尾
current.isEnd = true;
current.count++; // 词频+1
}
/**
* 查询字符串是否完整存在于字典树中
* @param word 要查询的字符串
* @return true=存在,false=不存在(仅前缀/无此字符串)
*/
public boolean search(String word) {
if (word == null || word.isEmpty()) {
return false;
}
TrieNode current = root;
// 遍历字符串的每个字符
for (char c : word.toCharArray()) {
int index = c - 'a';
// 字符对应的子节点不存在,直接返回false
if (current.children[index] == null) {
return false;
}
current = current.children[index];
}
// 必须是单词结尾才返回true(避免仅匹配前缀)
return current.isEnd;
}
/**
* 查询字典树中是否存在以指定前缀开头的字符串
* @param prefix 前缀字符串
* @return true=存在,false=不存在
*/
public boolean startsWith(String prefix) {
if (prefix == null || prefix.isEmpty()) {
return false;
}
TrieNode current = root;
// 遍历前缀的每个字符
for (char c : prefix.toCharArray()) {
int index = c - 'a';
if (current.children[index] == null) {
return false;
}
current = current.children[index];
}
// 只要前缀路径存在,无需检查是否为单词结尾
return true;
}
/**
* 删除字典树中的指定字符串(递归实现)
* @param word 要删除的字符串
* @return true=删除成功,false=字符串不存在
*/
public boolean delete(String word) {
if (word == null || word.isEmpty()) {
return false;
}
// 递归删除核心逻辑
return deleteRecursive(root, word, 0);
}
/**
* 递归删除辅助方法
* @param current 当前节点
* @param word 要删除的字符串
* @param depth 当前遍历到的字符索引
* @return true=当前节点可删除(无其他子节点),false=不可删除(有共享前缀)
*/
private boolean deleteRecursive(TrieNode current, String word, int depth) {
// 递归终止条件:遍历完所有字符
if (depth == word.length()) {
// 若当前节点不是单词结尾,说明字符串不存在
if (!current.isEnd) {
return false;
}
// 取消结尾标记,词频置0
current.isEnd = false;
current.count = 0;
// 若当前节点无任何子节点,可删除(返回true)
return Arrays.stream(current.children).allMatch(node -> node == null);
}
int index = word.charAt(depth) - 'a';
TrieNode child = current.children[index];
// 子节点不存在,字符串不存在
if (child == null) {
return false;
}
// 递归处理下一个字符
boolean canDeleteChild = deleteRecursive(child, word, depth + 1);
// 若子节点可删除,释放该子节点
if (canDeleteChild) {
current.children[index] = null;
// 若当前节点不是单词结尾且无其他子节点,可继续向上删除
return !current.isEnd && Arrays.stream(current.children).allMatch(node -> node == null);
}
// 子节点不可删除(有共享前缀),返回false
return false;
}
}
四、字典树的优缺点
1. 优点
- 高效的字符串操作:插入 / 查询 / 前缀匹配的时间复杂度均为 O (k)(k 为字符串长度),远优于哈希表(哈希冲突时效率下降)和红黑树(O (logn));
- 前缀共享节省空间:对于大量拥有公共前缀的字符串(如词典、日志),空间利用率远高于直接存储所有字符串;
- 支持字典序排序:按层遍历 Trie 树可直接得到按字典序排列的字符串集合;
- 无哈希冲突:基于字符路径匹配,无需处理哈希表的冲突问题。
2. 缺点
- 空间消耗大:若字符集大(如 Unicode)或字符串前缀差异大,每个节点需存储大量子节点指针,空节点占比高(如示例中 26 字母数组大部分为 None);
- 仅适用于字符串场景:无法直接处理数值、对象等非字符串类型;
- 删除操作复杂:需判断节点是否被共享,逻辑比插入 / 查询繁琐。
五、常见应用场景
1. 字符串检索
- 词典 / 通讯录查询:如手机通讯录按姓名前缀快速查找联系人;
- 日志分析:快速检索包含指定前缀的日志字符串。
2. 前缀匹配与自动补全
- 搜索引擎:输入 "app" 后自动补全 "apple""application" 等;
- 输入法联想:输入拼音前缀后推荐候选词。
3. 拼写检查
- 检测输入单词是否存在拼写错误(如输入 "appl",提示是否想输入 "apple")。
4. IP 路由(最长前缀匹配)
- 路由器转发数据包时,匹配 IP 地址的最长子网前缀(如 192.168.1.1 匹配 192.168.1.0/24 而非 192.168.0.0/16)。
5. 词频统计
- 在节点中增加 "count" 字段,记录以该节点结尾的字符串出现次数,用于统计单词频率(如统计文本中 "apple" 出现的次数)。
6. 字符串排序
- 按层遍历字典树,按字符顺序收集节点,直接得到字典序排列的字符串集合。