数据结构 —— 字典树

字典树(Trie ,也译作 "前缀树"或"单词查找树")是一种树形数据结构 ,核心用于高效存储和检索字符串集合,其设计思想是利用字符串的公共前缀 共享存储空间,大幅降低字符串匹配的时间和空间开销。它广泛应用于搜索引擎、输入法、拼写检查、IP 路由等场景,是处理字符串前缀匹配的最优数据结构之一。

一、核心概念

1.基本定义

Trie 树的每个节点代表一个字符(或空),从根节点到某一叶子 / 标记节点的路径拼接起来,构成一个完整的字符串;节点额外存储 "是否为单词结尾" 的标记,用于区分 "前缀" 和 "完整单词"。

2.核心特点

特点 说明
根节点无字符 根节点不存储任何字符,仅作为所有字符串的起始入口
子节点字符唯一性 每个节点的所有子节点代表的字符互不重复(如节点 A 的子节点不能同时有两个 'b')
路径表示字符串 从根到任意节点的路径拼接,即为该节点对应的前缀;标记节点对应完整单词
公共前缀共享节点 拥有相同前缀的字符串,共享前缀部分的节点(如 "app" 和 "apple" 共享 "a-p-p" 节点)
操作效率稳定 插入 / 查询 / 删除的时间复杂度均为 O (k)(k 为字符串长度),与字符串集合大小无关

二、字典树的核心操作

1. 插入(Insert)

目标:将一个字符串插入到 Trie 树中,利用公共前缀共享节点。

步骤

  1. 从根节点开始,遍历字符串的每个字符;
  2. 对当前字符,检查当前节点的子节点中是否存在该字符:
    • 存在:移动到该子节点,继续处理下一个字符;
    • 不存在:创建新节点存储该字符,添加为当前节点的子节点,再移动到新节点;
  3. 遍历完所有字符后,将最后一个节点标记为 "单词结尾"(如设置 isEnd = true)。

2. 查询(Search)

目标:判断某个字符串是否完整存在于 Trie 树中(区别于前缀)。

步骤

  1. 从根节点开始,遍历字符串的每个字符;
  2. 对当前字符,检查当前节点的子节点中是否存在该字符:
    • 不存在:直接返回 false(字符串不存在);
    • 存在:移动到该子节点,继续处理下一个字符;
  3. 遍历完所有字符后,检查最后一个节点是否标记为 "单词结尾":
    • 是:返回 true(字符串存在);
    • 否:返回 false(仅为前缀,非完整单词)。

3. 前缀查询(StartsWith)

目标:判断 Trie 树中是否存在以指定字符串为前缀的单词。

步骤

  1. 流程与 "查询" 基本一致,但无需检查最后一个节点的 "单词结尾" 标记;
  2. 只要遍历完所有前缀字符且每一步都存在对应子节点,即返回 true

4. 删除(Delete,可选)

目标:从 Trie 树中移除指定字符串,需避免误删共享前缀的节点。

步骤(分三种情况):

  1. 待删除字符串是唯一路径(无共享前缀):从最后一个节点向上删除所有单孩子节点,直到根节点;
  2. 待删除字符串是其他字符串的前缀(如删除 "app",但存在 "apple"):仅取消最后一个节点的 "单词结尾" 标记;
  3. 待删除字符串包含其他字符串的前缀(如删除 "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. 字符串排序

  • 按层遍历字典树,按字符顺序收集节点,直接得到字典序排列的字符串集合。
相关推荐
液态不合群2 小时前
查找算法详解
java·数据结构·算法
LYFlied3 小时前
【每日算法】LeetCode 105. 从前序与中序遍历序列构造二叉树
数据结构·算法·leetcode·面试·职场和发展
重生之我是Java开发战士3 小时前
【数据结构】Java对象的比较
java·jvm·数据结构
历程里程碑3 小时前
C++ 16:C++11新特化
c语言·开发语言·数据结构·c++·经验分享
_dindong3 小时前
算法杂谈:回溯路线
数据结构·算法·动态规划·bfs·宽度优先
DanyHope3 小时前
LeetCode 283. 移动零:双指针双解法(原地交换 + 覆盖补零)全解析
数据结构·算法·leetcode
山土成旧客4 小时前
【Python学习打卡-Day24】从不可变元组到漫游文件系统:掌握数据结构与OS模块
数据结构·python·学习
LYFlied4 小时前
【每日算法】LeetCode 114. 二叉树展开为链表:从树结构到线性结构的优雅转换
数据结构·算法·leetcode·链表·面试·职场和发展
cpp_25015 小时前
P8723 [蓝桥杯 2020 省 AB3] 乘法表
数据结构·c++·算法·蓝桥杯·题解·洛谷