字典树(Trie)

数据结构与算法专题 · 高效字符串存储与检索

专题:数据结构与算法系统学习

关键词:数据结构, 算法, 字典树, Trie, 前缀树, Prefix Tree, 字符串匹配, 自动补全, 空间优化

一、概述

字典树(Trie),又称前缀树(Prefix Tree)或单词查找树,是一种用于高效存储和检索字符串集合的树形数据结构。它的核心思想是利用字符串之间的公共前缀来减少查询时间,最大限度地避免不必要的字符比较。Trie 的名字来源于"retrieval"(检索)的中间四个字母,由 Edward Fredkin 于 1960 年提出。

与平衡树和哈希表相比,Trie 在处理字符串前缀匹配、自动补全和拼写检查等场景中具有天然优势。在搜索引擎的自动补全提示、手机输入法的联想词、路由器的 IP 路由查找、以及基因序列分析等领域,Trie 及其变体都是不可或缺的核心数据结构。

核心特点:Trie 以空间换时间,利用字符的公共前缀合并存储路径,使具有相同前缀的字符串共享存储空间。查找任意一个长度为 m 的字符串,仅需 O(m) 时间,与集合中元素总数 N 无关。

形象理解:如果我们要存储 {"cat", "car", "dog"} 这三个单词,在 Trie 中,"ca" 这个前缀只存储一次,后面分叉为 "t" 和 "r" 两个分支。"dog" 则是另一条完全独立的路径。这种结构使得查找所有以 "ca" 开头的单词变得极其迅速——只需沿着路径找到 "ca" 节点,然后遍历其子树即可。

二、核心概念与节点设计

Trie 的每个节点代表一个字符(更准确地说,代表从根到当前节点的路径所对应的前缀)。根节点不包含任何字符,代表空字符串。每个节点通常包含两个核心部分:一个指向子节点的映射结构(children),以及一个标记当前节点是否为某个单词结尾的布尔值(is_end)。

节点定义

在 Python 中,Trie 节点的最典型实现使用字典(dict)来存储子节点映射,因为字典支持 O(1) 的键值查找,且可以动态增长,非常适合字符集不确定的场景。

class TrieNode: """字典树节点""" def __init__(self): # 子节点映射:字符 -> TrieNode self.children = {} # 标记当前节点是否为某个单词的结尾 self.is_end = False # 可选:记录经过该节点的单词数量(用于词频统计) self.count = 0 # 可选:存储该前缀对应的完整单词(用于自动补全) self.word = None

完整的 Trie 类骨架

基于上述节点定义,我们可以构建 Trie 类。下面展示完整的类结构,后续各节将逐一填充每个方法的实现细节。

class Trie: """字典树""" def __init__(self): self.root = TrieNode() def insert(self, word: str) -> None: """向 Trie 中插入一个单词""" pass # 详见 三、基本操作与实现 def search(self, word: str) -> bool: """搜索单词是否完整存在""" pass def starts_with(self, prefix: str) -> bool: """判断是否存在以 prefix 为前缀的单词""" pass def delete(self, word: str) -> bool: """从 Trie 中删除一个单词""" pass def get_all_words(self) -> list: """获取 Trie 中所有单词""" pass def autocomplete(self, prefix: str) -> list: """获取所有以 prefix 为前缀的单词""" pass

三、基本操作与实现

3.1 插入操作

插入操作的核心逻辑是:从根节点开始,逐字符遍历待插入的单词。对于每个字符,如果当前节点不存在对应字符的子节点,则创建一个新的 TrieNode 并将其添加到 children 字典中。然后移动到该子节点继续处理下一个字符。当单词的所有字符都处理完毕后,将最后一个节点的 is_end 标记设为 True,表示该单词已完整插入 Trie。

def insert(self, word: str) -> None: node = self.root for ch in word: # 如果当前字符不存在于子节点中,创建新节点 if ch not in node.children: node.children[ch] = TrieNode() # 移动到下一个节点 node = node.children[ch] # 标记当前节点为单词结尾 node.is_end = True

说明:如果单词已存在(即所有字符路径都存在且最后的 is_end 已为 True),重复插入不会创建新节点,只是将 is_end 再次设置为 True(幂等操作)。但如果路径不存在,插入会创建所需的全部路径节点。

3.2 搜索操作

搜索操作和插入非常相似,同样是逐字符在 Trie 中查找。不同之处在于:如果在某个字符处发现 child 不存在,则单词肯定不在 Trie 中,返回 False。如果所有字符都成功匹配,还需要检查最后节点的 is_end 标记——只有 is_end 为 True,才说明该单词作为一个完整单词被插入过。

def search(self, word: str) -> bool: node = self.root for ch in word: # 字符路径不存在,单词不在 Trie 中 if ch not in node.children: return False node = node.children[ch] # 必须检查 is_end,因为该路径可能只是一个前缀 return node.is_end

注意:假设我们插入了 "apple" 但没有插入 "app"。搜索 "app" 时,路径是存在的(因为它是 "apple" 的前缀),但 "app" 的 is_end 为 False,所以 search("app") 返回 False。这就要求在设计时,如果需要同时保留前缀和完整单词,插入 "app" 时需要专门将其 is_end 设为 True。

3.3 前缀搜索

前缀搜索是 Trie 最核心的优势操作。它与搜索的区别在于:不需要检查最终节点的 is_end 标记,只要路径存在就返回 True。这意味着我们只关心是否存在以该字符串为前缀的任意单词,而不关心该字符串本身是否是一个完整单词。

def starts_with(self, prefix: str) -> bool: node = self.root for ch in prefix: if ch not in node.children: return False node = node.children[ch] # 路径存在即返回 True,不检查 is_end return True

3.4 删除操作

删除操作比插入和搜索更复杂,需要处理多种情况。基本思路是递归地进行删除:如果待删除的单词存在,将路径上最后一个节点的 is_end 设为 False。为了节省空间,可以进一步清理那些不再被其他单词使用的节点——即某个节点没有子节点且 is_end 为 False 时,可以安全地将其从父节点的 children 中删除。

def delete(self, word: str) -> bool: """删除单词,成功返回 True,单词不存在返回 False""" def _delete(node: TrieNode, word: str, depth: int) -> bool: """递归删除,返回是否应删除当前节点(剪枝)""" if depth == len(word): # 单词不存在 if not node.is_end: return False # 标记为非结尾 node.is_end = False # 如果没有子节点,可以删除此节点 return len(node.children) == 0 ch = word[depth] if ch not in node.children: return False # 单词不存在 # 递归处理下一个字符 should_delete_child = _delete(node.children[ch], word, depth + 1) if should_delete_child: del node.children[ch] # 如果当前节点也不是单词结尾且没有其他子节点,可以删除 return not node.is_end and len(node.children) == 0 return False return _delete(self.root, word, 0)

删除策略总结:(1)如果待删除单词是其他单词的前缀(如删除 "app" 但 "apple" 还在),只需将 is_end 设为 False,保留路径。(2)如果待删除单词没有与其他单词共享路径(如删除一个独立插入的 "dog"),可以回收整条路径上的无用节点。(3)如果待删除单词与其他单词共享部分前缀(如删除 "cat" 但 "car" 还在),只删除共享路径之后的独有节点。

3.5 获取所有单词

通过 DFS(深度优先搜索)遍历 Trie 的所有节点,每当遇到 is_end 为 True 的节点,就记录下从根到该节点路径上字符拼接而成的完整单词。

def get_all_words(self) -> list: """返回 Trie 中存储的所有单词""" result = [] def dfs(node: TrieNode, path: list): if node.is_end: result.append(''.join(path)) for ch, child in node.children.items(): path.append(ch) dfs(child, path) path.pop() dfs(self.root, []) return result

3.6 自动补全

自动补全是前缀搜索的扩展应用。先定位到前缀对应的最后一个节点,然后从该节点开始 DFS 遍历以该节点为根的子树,收集所有完整单词。

def autocomplete(self, prefix: str) -> list: """获取所有以 prefix 为前缀的单词""" node = self.root # 先定位到前缀末尾 for ch in prefix: if ch not in node.children: return [] # 前缀不存在 node = node.children[ch] # 从前缀末尾节点开始 DFS result = [] path = list(prefix) def dfs(node: TrieNode): if node.is_end: result.append(''.join(path)) for ch, child in node.children.items(): path.append(ch) dfs(child) path.pop() dfs(node) return result

四、时间复杂度分析

在分析 Trie 的时间复杂度时,关键变量有两个:待操作字符串的长度 m 和 Trie 中存储的字符串总数 N。

操作时间复杂度说明
插入O(m)遍历字符串每个字符,每步查找/创建子节点均为 O(1)(哈希表)
搜索O(m)沿路径逐字符查找,检查 is_end
前缀搜索O(m)沿路径逐字符查找,无需检查 is_end
删除O(m)沿路径找到末尾后递归回退清理
获取所有单词O(N * L̄)DFS 遍历所有节点,N 为单词数,L̄ 为平均长度
自动补全O(m + k)m 为前缀查找,k 为补全结果总长度

时间复杂度对比:与哈希表相比,Trie 的前缀搜索操作(starts_with)是天然支持的 O(m) 操作,而哈希表需要遍历所有键执行前缀匹配,时间复杂度为 O(N * m)。但在完全匹配的搜索场景中,哈希表可以达到平均 O(1)(理想情况下优于 Trie 的 O(m))。然而哈希表有哈希冲突的潜在风险,且无法支持有序遍历。

五、空间优化

Trie 的主要缺点是空间消耗较大。在最坏情况下(如很少或没有公共前缀的字符串集合),每个字符都需要一个独立的 TrieNode,每个节点又需要维护一个 children 映射结构。以下是几种常见的空间优化策略。

5.1 哈希表 vs 固定数组

Trie 节点的 children 有两种主流实现方式:哈希表(dict)和固定长度数组。二者各有优劣。

哈希表实现(推荐)

  • 只存储实际出现的字符分支
  • 内存效率高,尤其适合稀疏场景
  • 支持任意 Unicode 字符集
  • Python 中更自然

固定数组实现

  • 预分配 26/52/256 个 slot
  • 访问更快(数组索引 O(1))
  • 字符集受限(如仅限小写字母)
  • 大量内存浪费(空 slot 占位)
# 固定数组实现(仅适用于小写字母 a-z) class ArrayTrieNode: def __init__(self): self.children = [None] * 26 # 预分配 26 个 slot self.is_end = False def _index(self, ch: str) -> int: return ord(ch) - ord('a') # 'a' -> 0, 'b' -> 1, ..., 'z' -> 25

5.2 压缩 Trie(Radix Tree / Patricia Trie)

压缩 Trie 的核心思想是:将连续单子节点(即某个节点只有一个子节点,且该节点不是单词结尾)合并为一条边上的字符串。这样显著减少了节点数量,大幅降低内存占用。

对比示例:存储 "abandon" 和 "abnormal" 时,普通 Trie 需要为 "a" → "b" → "a" → "n" 各创建独立节点,而压缩 Trie 将共享前缀 "ab" 存储为一条边,后面分叉为 "andon" 和 "normal" 两条边。节点数从 9 个减少到 4 个。

class RadixNode: """基数树(压缩Trie)节点""" def __init__(self): # 边:前缀字符串 -> RadixNode self.edges = {} self.is_end = False class RadixTree: """基数树 / 压缩字典树""" def __init__(self): self.root = RadixNode() def insert(self, word: str) -> None: # 实现略:需要处理边的分割和合并 # 核心逻辑:查找最长公共前缀,分割已有边,创建新分支 pass

5.3 其他优化技术

六、压缩变体:基数树(Radix Tree)

基数树(Radix Tree),又称 Patricia Trie(Practical Algorithm to Retrieve Information Coded in Alphanumeric),是 Trie 的一种重要变体。它将 Trie 中连续的单子节点路径压缩为一条边上的字符串标签,从而显著减少了节点数量和层级深度。

核心思想

在普通 Trie 中,路径上的每一个字符都对应一个独立的节点。如果存储的字符串之间共享的前缀很短(或没有共享前缀),Trie 的节点数几乎等于所有字符串的总字符数,空间效率很低。Radix Tree 通过"边压缩"策略,将连续的"单子节点链"合并为一个节点和一条字符串边,同一层级可以有多个字符作为边标签。

与普通 Trie 的对比

特性普通 TrieRadix Tree
节点数量≈ 所有字符串总字符数≈ 所有字符串数量 + 分叉点数量
边标签单个字符字符串(可包含多个字符)
插入复杂度O(m),简单O(m),需处理边分割,较复杂
空间效率低(节点过多)高(节点大幅减少)
查询效率O(m),逐字符O(m),按串匹配(实际比较更快)

典型应用:Linux 内核中的路由表使用 Radix Tree 进行 IP 路由查找;Redis 中的字典实现使用了压缩 Trie 的思想来加速键的查找;Nginx 中的地理位置数据库也采用类似的数据结构进行高效的前缀匹配。

七、应用场景

7.1 自动补全(Auto-complete)

这是 Trie 最经典的应用。搜索引擎(如 Google)、代码编辑器(如 VS Code、IntelliJ)、手机输入法都使用 Trie 来实现关键字或代码的自动补全提示。基本原理就是用 Trie 存储热门查询词或代码片段,用户输入前缀时快速检索所有匹配的候选项。

def top_k_autocomplete(self, prefix: str, k: int) -> list: """返回前 k 个最热门的前缀匹配建议""" candidates = self.autocomplete(prefix) # 按频率降序排列(需要节点中存储频率信息) candidates.sort(key=lambda w: self.get_frequency(w), reverse=True) return candidates[:k]

7.2 拼写检查(Spell Check)

利用 Trie 实现拼写检查时,首先将词典中的所有正确单词构建成 Trie。用户输入的每个单词通过 search 方法检查是否存在于 Trie 中。如果不存在,可以通过编辑距离(Levenshtein Distance)结合 Trie 来查找最相似的候选词——即从 Trie 中找出与输入单词编辑距离最小的若干单词。

def spell_check(self, word: str, max_distance: int = 2) -> list: """基于编辑距离的拼写纠错""" result = [] def dfs(node: TrieNode, path: list, i: int, edits: list): """带编辑距离计算的 Trie 递归搜索""" if i == len(word) and node.is_end: candidate = ''.join(path) dist = edits[-1][-1] if edits else 0 if dist <= max_distance: result.append((candidate, dist)) return # 剪枝:当前编辑距离已经超过阈值 # (具体实现涉及 DP 矩阵传递,此处为简化示意) for ch, child in node.children.items(): path.append(ch) dfs(child, path, i + 1 if i < len(word) and word[i] == ch else i, edits) path.pop() dfs(self.root, [], 0, None) return sorted(result, key=lambda x: x[1])

7.3 词频统计

在 Trie 节点中增加 count 字段,记录经过该节点的单词数量。插入单词时,路径上所有节点的 count 都加 1。这样不仅可以快速查询某个单词的词频,还可以统计以某个前缀开头的所有单词的总词频。

def insert_with_freq(self, word: str): """插入单词并更新路径上的词频计数""" node = self.root for ch in word: if ch not in node.children: node.children[ch] = TrieNode() node = node.children[ch] node.count += 1 # 经过该前缀的单词数加 1 node.is_end = True def prefix_frequency(self, prefix: str) -> int: """查询以 prefix 为前缀的单词总数""" node = self.root for ch in prefix: if ch not in node.children: return 0 node = node.children[ch] return node.count

7.4 IP 路由最长前缀匹配

在计算机网络中,IP 路由表使用 CIDR(无类别域间路由)表示路由规则。路由器需要根据目标 IP 地址在路由表中找到最长的匹配前缀。将路由前缀构建成 Trie(二进制 Trie),每个节点对应 IP 地址的一位(0 或 1),包含路由信息的节点标记为有效。查找时深度优先搜索,返回最深的有效匹配节点,即可实现最长前缀匹配。

class BinaryTrieNode: """二进制Trie节点(用于IP路由)""" def __init__(self): self.children = [None, None] # 0 和 1 self.is_route = False self.next_hop = None # 下一跳信息 class IPRoutingTable: """基于二进制 Trie 的路由表""" def __init__(self): self.root = BinaryTrieNode() def add_route(self, ip: str, prefix_len: int, next_hop: str): """添加路由规则(如 192.168.1.0/24 -> gateway)""" node = self.root binary_ip = self._ip_to_bits(ip, prefix_len) for bit in binary_ip: if not node.children[bit]: node.children[bit] = BinaryTrieNode() node = node.children[bit] node.is_route = True node.next_hop = next_hop def lookup(self, ip: str) -> str: """最长前缀匹配查找""" node = self.root result = None binary_ip = self._ip_to_bits(ip, 32) for bit in binary_ip: if not node.children[bit]: break node = node.children[bit] if node.is_route: result = node.next_hop # 更新为更长的匹配 return result def _ip_to_bits(self, ip: str, length: int) -> list: parts = list(map(int, ip.split('.'))) bits = [] for p in parts: for i in range(7, -1, -1): bits.append((p >> i) & 1) return bits[:length]

7.5 其他应用

八、Trie vs 哈希表对比

在字符串存储和检索的场景中,Trie 和哈希表(Hash Table / 哈希集合)是两种最常用的数据结构。了解它们的适用场景对于做出正确的设计选择至关重要。

维度Trie哈希表
单字符串搜索O(m),稳定O(1) 平均,O(N) 最坏(冲突)
前缀搜索O(m),天然支持O(N * m),需遍历所有键
有序遍历O(N * L̄),天然有序(字典序)O(N log N),需额外排序
空间效率较差(尤其短字符串)较好,但有空槽浪费
哈希冲突无(路径唯一)有,需解决冲突
删除操作O(m),需处理剪枝O(1) 平均
字符集支持灵活(Unicode 任意)灵活(键值 hashable 即可)
动态扩展自然支持需 rehash/扩容

选择建议:应用场景涉及前缀匹配或需要按字典序遍历字符串时,应优先选择 Trie。仅需要精确的键值查询且对空间有要求的场景,应优先选择哈希表。如果数据量极小(如少于 100 个短字符串),哈希表通常是更简单高效的方案。

"Trie 的真正价值不在于它能做哈希表也能做的事情,而在于它能做哈希表做不了的事情——前缀匹配。"

九、完整代码示例

下面给出一个整合了所有核心功能的 Trie 完整实现,包含类型注解和详细的文档字符串,可以直接在项目中使用。

class TrieNode: def __init__(self): self.children = {} self.is_end = False self.count = 0 self.word = None class Trie: """字典树(Trie / Prefix Tree)完整实现""" def __init__(self): self.root = TrieNode() def insert(self, word: str) -> None: """向 Trie 中插入一个单词""" node = self.root for ch in word: if ch not in node.children: node.children[ch] = TrieNode() node = node.children[ch] node.count += 1 node.is_end = True node.word = word def search(self, word: str) -> bool: """查询单词是否完整存在于 Trie 中""" node = self.root for ch in word: if ch not in node.children: return False node = node.children[ch] return node.is_end def starts_with(self, prefix: str) -> bool: """判断是否存在以 prefix 为前缀的单词""" node = self.root for ch in prefix: if ch not in node.children: return False node = node.children[ch] return True def delete(self, word: str) -> bool: """从 Trie 中删除单词(成功返回 True)""" def _delete(node, w, d): if d == len(w): if not node.is_end: return False node.is_end = False node.word = None return len(node.children) == 0 ch = w[d] if ch not in node.children: return False should_del = _delete(node.children[ch], w, d + 1) if should_del: del node.children[ch] return not node.is_end and len(node.children) == 0 return _delete(self.root, word, 0) def autocomplete(self, prefix: str) -> list: """获取所有以 prefix 为前缀的单词(自动补全)""" node = self.root for ch in prefix: if ch not in node.children: return [] node = node.children[ch] result = [] def dfs(n, path): if n.is_end: result.append(''.join(path)) for ch, child in n.children.items(): path.append(ch) dfs(child, path) path.pop() dfs(node, list(prefix)) return result def count_prefix(self, prefix: str) -> int: """统计以 prefix 为前缀的单词数量""" node = self.root for ch in prefix: if ch not in node.children: return 0 node = node.children[ch] return node.count def __contains__(self, word: str) -> bool: return self.search(word) def __len__(self) -> int: return self.root.count

使用示例

# ============ 使用示例 ============ trie = Trie() # 插入单词 words = ["apple", "app", "application", "apricot", "banana"] for w in words: trie.insert(w) # 搜索 print(trie.search("apple")) # True print(trie.search("app")) # True(单独插入过) print(trie.search("apt")) # False # 前缀搜索 print(trie.starts_with("app")) # True print(trie.starts_with("appl")) # True print(trie.starts_with("xy")) # False # 自动补全 print(trie.autocomplete("app")) # ['app', 'apple', 'application'] # 删除 trie.delete("app") print(trie.search("app")) # False print(trie.search("apple")) # True(不受影响) # 前缀统计 print(trie.count_prefix("ap")) # 2(apple, application) # 遍历 print(trie.autocomplete("")) # ['apple', 'application', 'apricot', 'banana']

十、核心要点总结

知识卡片

  • 本质定义:Trie 是一种多叉树结构,利用字符串公共前缀减少查询时间,将具有相同前缀的字符串共享路径存储。
  • 节点设计:每个节点包含 children 映射(哈希表/数组)和 is_end 标记。必要时可增加 count、word 等辅助字段以支持词频统计和快捷返回。
  • 核心操作:插入和搜索都是 O(m) 时间,其中 m 为字符串长度;前缀搜索是 Trie 的标志性优势操作,同样是 O(m) 时间。
  • 空间权衡:Trie 以空间换时间,节点数庞大时内存开销较高。可通过固定数组、压缩 Trie(Radix Tree)、双数组 Trie 等方式优化。
  • 适用场景:自动补全(前缀匹配)、拼写检查(编辑距离+Trie)、词频统计、IP 路由(最长前缀匹配)、敏感词过滤、基因组模式匹配。
  • 选型决策:需要前缀匹配或有序遍历时选 Trie,仅需要精确查询且对空间敏感时选哈希表。
  • 压缩变体:Radix Tree / Patricia Trie 将连续单子节点路径压缩为字符串边,大幅减少节点数,是工业界的常用优化方案。

十一、进一步思考

Trie 虽然优雅且高效,但在大规模数据场景下仍需慎重设计。以下是一些值得深入思考的方向:

工程实践中的权衡:在构建一个覆盖百万级别词条的搜索引擎自动补全系统时,需要考虑以下问题:(1)内存限制:百万词条的原生 Trie 可能消耗数百 MB 甚至数 GB 内存,需要评估 Radix Tree 或 Double-Array Trie 的适用性。(2)动态更新:如果词典需要频繁增删(如在线学习系统),Trie 的动态更新开销是否可接受?(3)并发安全:多线程环境下 Trie 的读写是否需要加锁?能否采用无锁设计?(4)持久化:如何将 Trie 序列化到磁盘并在启动时快速加载?

扩展研究方向:(1)Succinct Trie(简洁字典树)——使用位图等紧凑表示法将 Trie 的空间开销压缩到接近信息论下界。(2)HAT-Trie —— 结合哈希表和 Trie 的混合数据结构,在缓存效率和查询速度上做出更好的平衡。(3)Adaptive Radix Tree(ART,自适应基数树)——根据节点密度动态选择不同的内部表示,在空间和速度之间取得最佳平衡,是近年来被广泛研究的高性能索引结构。

"理解 Trie 不仅仅是学会一种数据结构,更是理解"如何利用数据的结构特征来加速计算"这一计算机科学的核心思想。字符串的公共前缀正是数据的结构特征,Trie 将其显式地编码到了数据结构中。"

LeetCode 推荐练习

为巩固对 Trie 的理解,推荐按以下顺序练习 LeetCode 相关题目:

  1. LeetCode 208 - 实现 Trie(前缀树):基础题,实现 insert、search、startsWith 三个核心方法。
  2. LeetCode 720 - 词典中最长的单词:综合利用 Trie 的插入和搜索操作。
  3. LeetCode 676 - 实现一个魔法字典:Trie + 编辑距离搜索。
  4. LeetCode 211 - 添加与搜索单词:支持通配符 "." 的搜索,考察 Trie + DFS 的结合。
  5. LeetCode 212 - 单词搜索 II:Trie + 回溯的经典组合,高频面试题。
  6. LeetCode 421 - 数组中两个数的最大异或值:利用二进制 Trie 求解最大异或对。
  7. LeetCode 745 - 前缀和后缀搜索:双 Trie 结合的设计题。