第 53 章 / 共 58 章 卷十 代码讲解

Trie 前缀树

当你在搜索引擎输入 "xla" 时,下拉框立即显示 "xlang"、"xlang tutorial"、"xlang download"——这就是 Trie(前缀树) 的典型应用。Trie 将字符串按字符逐层存储,使得前缀匹配的时间复杂度只与查询串长度有关,与词典大小无关。本章实现一个完整的 Trie,支持插入、查找、删除、前缀搜索和自动补全。

本章导读

Trie(读作 "try",来自 retrieval)是一种多叉树结构,每条边代表一个字符,从根到叶的路径拼出一个完整字符串。它的核心优势:前缀匹配 O(m)(m 为查询串长度),与存储的字符串总数 n 无关。这让它成为自动补全、拼写检查、IP 路由表的首选数据结构。

本章学习目标
  • 理解 Trie 的树形结构和字符→路径映射
  • 实现插入、精确查找、前缀查找
  • 实现自动补全(给定前缀,列出所有匹配词)
  • 实现删除操作(含懒删除和物理删除)
  • 实现词频统计
  • 对比 Trie 与 dict/set 的性能差异

53.1 Trie 的原理

存储 {"cat", "car", "card", "dog"} 的 Trie:

//        (root)
//       /      \
//      c        d
//     / \        \
//    a   a        o
//   /     \        \
//  t*     r*        g*
//         |
//         d*
//
// * 表示"词尾"(isEnd = true)
// "car" 和 "card" 共享前缀 "car"
// "cat" 和 "car" 共享前缀 "ca"

关键观察:

  • 共享前缀的字符串共享路径——节省空间
  • 查找 "car" 只需走 3 步(c→a→r),与词典大小无关
  • 前缀 "ca" 下的所有词就是 c→a 子树中的所有 isEnd 节点

53.2 节点设计

class TrieNode
    dict<int, TrieNode> children   // 字符(ASCII) → 子节点
    bool isEnd                      // 是否为某个词的结尾
    int count                       // 经过此节点的词数(用于统计)

    func init()
        children = dict<int, TrieNode>()
        isEnd = false
        count = 0
    end
end

class Trie
    TrieNode root

    func init()
        root = TrieNode()
        root.init()
    end
end

用 dict<int, TrieNode> 存储子节点映射(键为字符 ASCII 值)。对于纯小写字母,也可以用 array<TrieNode>(26) 获得 O(1) 查找,但浪费空间。

53.3 插入操作

func insert(string word)
    TrieNode node = root
    int i = 0
    while i < word.len()
        int ch = word[i]
        if not node.children.has(ch) then
            TrieNode child = TrieNode()
            child.init()
            node.children[ch] = child
        end
        node = node.children[ch]
        node.count += 1
        i += 1
    end
    node.isEnd = true
end

// 使用
Trie trie = Trie()
trie.init()
trie.insert("cat")
trie.insert("car")
trie.insert("card")
trie.insert("dog")

时间复杂度 O(m),m 为词长。每步检查子节点是否存在,不存在则创建。

53.5 前缀搜索

找到所有以指定前缀开头的词:

func collectWords(TrieNode node, string prefix, list<string> result)
    if node.isEnd then
        result.push(prefix)
    end
    // 遍历所有子节点(按字符排序)
    list<int> keys = node.children.keys()
    int i = 0
    while i < keys.len()
        int ch = keys[i]
        string newPrefix = prefix + string.char(ch)
        collectWords(node.children[ch], newPrefix, result)
        i += 1
    end
end

func wordsWithPrefix(string prefix) as list<string>
    list<string> result = list<string>()
    TrieNode node = findNode(prefix)
    if node == null then return result end
    collectWords(node, prefix, result)
    return result
end

// 测试
list<string> words = trie.wordsWithPrefix("car")
// → ["car", "card"]
list<string> allC = trie.wordsWithPrefix("c")
// → ["car", "card", "cat"]

53.6 自动补全

自动补全 = 前缀搜索 + 排序 + 限制数量:

func autocomplete(string prefix, int maxResults) as list<string>
    list<string> all = wordsWithPrefix(prefix)
    // 按词频排序(count 越大越热门)或按字母序
    // 这里简单取前 maxResults 个
    list<string> result = list<string>()
    int i = 0
    while i < all.len() and i < maxResults
        result.push(all[i])
        i += 1
    end
    return result
end

// 模拟搜索框
list<string> suggestions = trie.autocomplete("ca", 5)
int i = 0
while i < suggestions.len()
    println("  " + suggestions[i])
    i += 1
end
// 输出:
//   car
//   card
//   cat

53.7 删除操作

删除需要处理共享前缀的情况:

// 懒删除:只标记 isEnd = false
func deleteLazy(string word)
    TrieNode node = findNode(word)
    if node != null then
        node.isEnd = false
    end
end

// 物理删除:回收无用节点
func deletePhysical(string word) as bool
    return deleteHelper(root, word, 0)
end

func deleteHelper(TrieNode node, string word, int depth) as bool
    if depth == word.len() then
        if not node.isEnd then return false end
        node.isEnd = false
        return node.children.len() == 0   // 无子节点则可删除
    end

    int ch = word[depth]
    if not node.children.has(ch) then return false end

    bool shouldDelete = deleteHelper(node.children[ch], word, depth + 1)
    if shouldDelete then
        node.children.remove(ch)
        return not node.isEnd and node.children.len() == 0
    end
    return false
end

// 测试
trie.deletePhysical("card")
assert trie.search("card") == false
assert trie.search("car") == true    // "car" 不受影响

53.8 词频统计

记录每个词被插入的次数:

class TrieWithFreq
    TrieNode root
    dict<string, int> freq   // 词 → 频率

    func insert(string word)
        // ... 同 Trie.insert ...
        if freq.has(word) then
            freq[word] = freq[word] + 1
        else
            freq[word] = 1
        end
    end

    func getFreq(string word) as int
        if freq.has(word) then return freq[word] end
        return 0
    end

    // 热门补全:按频率降序
    func hotComplete(string prefix, int n) as list<string>
        list<string> all = wordsWithPrefix(prefix)
        // 按 freq 降序排序(简单选择排序)
        int i = 0
        while i < all.len()
            int maxIdx = i
            int j = i + 1
            while j < all.len()
                if getFreq(all[j]) > getFreq(all[maxIdx]) then
                    maxIdx = j
                end
                j += 1
            end
            if maxIdx != i then
                string tmp = all[i]
                all[i] = all[maxIdx]
                all[maxIdx] = tmp
            end
            i += 1
        end
        // 取前 n 个
        list<string> result = list<string>()
        int k = 0
        while k < all.len() and k < n
            result.push(all[k])
            k += 1
        end
        return result
    end
end

53.9 完整实现

// trie.xl — 完整测试
Trie trie = Trie()
trie.init()

// 插入词典
trie.insert("apple")
trie.insert("app")
trie.insert("application")
trie.insert("apply")
trie.insert("banana")
trie.insert("band")
trie.insert("bat")

// 精确查找
assert trie.search("app")
assert trie.search("apple")
assert not trie.search("ap")
assert trie.startsWith("ap")

// 前缀搜索
list<string> apps = trie.wordsWithPrefix("app")
println("app* → " + apps.len().toString() + " 个词")
// app, apple, application, apply

list<string> bas = trie.wordsWithPrefix("ba")
println("ba* → " + bas.len().toString() + " 个词")
// banana, band, bat

// 删除
trie.deletePhysical("app")
assert not trie.search("app")
assert trie.search("apple")    // 共享前缀不受影响

// 自动补全
list<string> suggestions = trie.wordsWithPrefix("appl")
println("补全 appl: " + suggestions.len().toString() + " 个")
// apple, application, apply

53.10 性能对比

操作Triedict/set排序数组
精确查找O(m)O(m) 平均O(m·log n)
前缀搜索O(m + k)O(n·m)O(m·log n + k)
插入O(m)O(m) 平均O(n·m)
空间O(n·m) 最坏O(n·m)O(n·m)

m = 字符串长度,n = 字符串数量,k = 匹配结果数。Trie 的核心优势在前缀搜索:O(m+k) vs 暴力扫描的 O(n·m)。

深入细节

深入 1:压缩 Trie(Radix Tree)

普通 Trie 中,如果一条路径上每个节点都只有一个子节点(如 "application" 的 a→p→p→l→i→c→a→t→i→o→n),这些节点可以合并为一个边(存储子串 "application")。这就是 Radix Tree / Patricia Trie,大幅减少节点数和内存占用。Linux 内核的路由表就使用 Radix Tree。

深入 2:Trie 的内存开销

每个节点存储一个 dict(子节点映射),对于大量短词(如所有英文单词),节点数可能达到数十万。优化方案:① 子节点用排序数组代替 dict(节省哈希表开销);② 双数组 Trie(DAT)用两个紧凑数组表示整棵树;③ LOUDS 编码用位序列表示树结构。

深入 3:Unicode 与多字节字符

本章按字节(ASCII)建 Trie。对于中文等多字节字符,需要按 Unicode 码点建边。xlang 的字符串索引返回字节值,处理中文需要先解码为码点序列。另一种方案:按 UTF-8 字节建 Trie(每个汉字 3 条边),查找时同样按字节走,逻辑不变但树更深。

⚠️ 常见错误

❌ 错误 1:search 和 startsWith 混淆

search("app") 要求 isEnd==true("app" 是完整词);startsWith("app") 只要求路径存在。插入 "apple" 后,startsWith("app") 为 true 但 search("app") 为 false(除非也插入了 "app")。

❌ 错误 2:删除时破坏共享前缀

删除 "card" 时不能删除 c→a→r 路径上的节点,因为 "car" 也在使用。物理删除只能回收"没有其他词经过且不是词尾"的节点。

❌ 错误 3:空字符串处理

insert("") 会将 root.isEnd 设为 true。search("") 返回 true。这通常是期望行为(空串是任何串的前缀),但需要明确处理。

本章小结

主题核心要点
结构多叉树,边=字符,根到 isEnd 节点=完整词
插入逐字符走/建节点,末尾标记 isEnd
查找逐字符走,search 要求 isEnd,startsWith 只要求路径存在
前缀搜索走到前缀节点,DFS 收集子树中所有 isEnd
自动补全前缀搜索 + 排序 + 限制数量
删除懒删除(isEnd=false)或物理删除(递归回收无用节点)
词频额外 dict 记录频率,热门补全按频率排序
复杂度所有操作 O(m),前缀搜索 O(m+k),与词典大小 n 无关

复习题

  1. Trie 中 "car" 和 "card" 如何存储?它们共享哪些节点?
  2. search("app") 和 startsWith("app") 的区别是什么?
  3. 前缀搜索的时间复杂度为什么与词典大小无关?
  4. 物理删除 "card" 时,为什么不能删除 c→a→r 路径?
  5. Trie 相比 dict 的核心优势是什么?劣势是什么?
  6. 压缩 Trie(Radix Tree)解决什么问题?
  7. 如何用 Trie 实现拼写检查(编辑距离 ≤ 1 的建议)?
  8. 对于中文词典,Trie 的边应该用什么作为键?

编程练习

练习 1:拼写检查器

给定一个词典(100 个英文单词),实现拼写检查:输入一个词,如果不在词典中,输出编辑距离 ≤ 1 的所有建议(插入一个字母、删除一个字母、替换一个字母)。

参考思路:对输入词的每个位置尝试 26 种替换、删除、插入,检查是否在 Trie 中。

练习 2:最长前缀匹配

实现 IP 路由表:插入 CIDR 前缀(如 "192.168.1"→"LAN"),查询 IP 时返回最长匹配的路由。如 "192.168.1.100" 匹配 "192.168.1" 而非 "192"。

参考思路:按 . 分割 IP 段建 Trie;查询时逐段走,记录最后一个 isEnd 节点的值。

练习 3:词频统计器

读取一段文本(硬编码),将所有单词插入带频率的 Trie,然后输出:① 出现频率最高的 10 个词;② 给定前缀的热门补全(按频率排序)。

参考思路:split 分词 → 转小写 → insert;hotComplete 按频率排序。