数据结构 —— 字典树

字典树(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. 字符串排序

  • 按层遍历字典树,按字符顺序收集节点,直接得到字典序排列的字符串集合。
相关推荐
闪电悠米9 小时前
力扣hot100-41.缺失的第一个正数-原地哈希详解
数据结构·算法·哈希算法
WL学习笔记9 小时前
堆(数据结构)
数据结构·
wabs66613 小时前
关于图论【卡码网100.最大岛屿的面积的思考】
数据结构·算法·图论
yuannl1015 小时前
图的最短路径Dijkstra
数据结构
zander25816 小时前
138. 随机链表的复制
数据结构·算法·链表
过期动态16 小时前
【LeetCode 热题 100】找到字符串中所有字母异位词
java·数据结构·算法·leetcode·职场和发展·rabbitmq
来一碗刘肉面16 小时前
栈在递归中的应用
数据结构·算法
皓月斯语17 小时前
程序设计语言的特点
开发语言·数据结构·c++
邪修king17 小时前
C++ 进阶终章:异常机制与智能指针全解 —— 从错误处理到 RAII 资源管理,打通现代 C++ 的核心命脉
android·数据结构·c++
Adios7941 天前
设置交集大小至少为2
数据结构·算法·leetcode