摘要
Trie(前缀树/字典树)是字符串处理领域的核心数据结构,专为高效前缀搜索而设计。本文从 LeetCode 208(基础实现)到 211(通配符进阶),深入讲解 Trie 的节点设计、三大核心操作、DFS 回溯搜索,并提供 Python/Java/C++ 三语言代码实现。掌握 Trie,轻松应对字符串前缀匹配类面试题。
一、从堆到前缀树
第 17 篇我们聊了堆(Heap),它帮我们高效维护动态 Top K 集合。无论是求数据流中的第 K 大元素,还是合并 K 个有序链表,堆都用 O(log K) 的插入和删除成本优雅地解决了问题。
现在我们要面对一个完全不同的问题:如何高效地搜索和匹配具有公共前缀的字符串?
想想搜索引擎的输入框------当你输入 "app" 时,下拉菜单立刻弹出 "apple"、"application"、"appointment"。哈希表能做到 O(1) 精确查找,但让它查前缀------得遍历所有单词,O(N) 太慢了。二叉搜索树同样不行,按字典序排序后前缀搜索仍需遍历。
我们需要一个为前缀搜索而生的数据结构------Trie(前缀树,也叫字典树)。它把字符串集合组织成一棵多叉树,让前缀搜索的时间复杂度降到 O(L),其中 L 是字符串长度,和集合大小 N 无关。
二、Trie 是什么?
Trie 是一棵多叉树,每个节点代表一个字符,根节点为空。从根节点到某个节点的路径拼接起来,就是一个字符串。
下面是一棵包含 "cat"、"car"、"dog" 三个单词的 Trie:

三个关键特征:
-
根节点不存储字符:它是空入口,所有单词从根的孩子开始
-
公共前缀共享路径:"cat" 和 "car" 共享 "ca" 路径,这是 Trie 节省空间的根本
-
isEnd 标记:每个节点用布尔值标记"从根到当前节点是否构成一个完整单词"
方案对比:Trie vs HashSet vs BST
| 对比维度 | HashSet | BST | Trie(✅ 最优) |
|---|---|---|---|
| 精确匹配 | O(1) | O(L log N) | O(L) |
| 前缀搜索 | ❌ O(N) | ❌ O(N log N) | ✅ O(L) |
| 通配符搜索 | ❌ 不支持 | ❌ 不支持 | ✅ 支持(DFS) |
| 空间复杂度 | O(N) | O(N) | O(ΣL) |
| 实现复杂度 | 低 | 中 | 中 |
核心结论 :如果你只需要精确匹配,HashSet 更快。但一旦涉及前缀搜索,Trie 是唯一能在 O(L) 时间内完成的数据结构。
三、LeetCode 208:Implement Trie (Prefix Tree)
问题描述
实现一个 Trie,包含三个方法:
-
insert(word):将单词插入 Trie -
search(word):返回单词是否在 Trie 中 -
startsWith(prefix):返回是否有任何单词以该前缀开头
TrieNode 设计
每个节点需要两个东西:一个指向子节点的数组,和一个布尔标记。因为题目限定小写字母,我们用一个长度为 26 的数组就够了。
# Python
class TrieNode:
def __init__(self):
self.children = [None] * 26
self.isEnd = False
// Java
class TrieNode {
TrieNode[] children = new TrieNode[26];
boolean isEnd = false;
}
// C++
struct TrieNode {
TrieNode* children[26];
bool isEnd;
TrieNode() : isEnd(false) {
for (int i = 0; i < 26; i++) children[i] = nullptr;
}
};
三个核心方法
insert(插入) :逐字符遍历,从根节点开始。如果当前字符对应的子节点不存在,就创建一个新节点。遍历到最后一个字符时,标记 isEnd = true。
# Python
class Trie:
def __init__(self):
self.root = TrieNode()
def insert(self, word: str) -> None:
node = self.root
for ch in word:
idx = ord(ch) - ord('a')
if not node.children[idx]:
node.children[idx] = TrieNode()
node = node.children[idx]
node.isEnd = True
// Java
class Trie {
private TrieNode root;
public Trie() {
root = new TrieNode();
}
public void insert(String word) {
TrieNode node = root;
for (char c : word.toCharArray()) {
int idx = c - 'a';
if (node.children[idx] == null) {
node.children[idx] = new TrieNode();
}
node = node.children[idx];
}
node.isEnd = true;
}
}
// C++
class Trie {
private:
TrieNode* root;
public:
Trie() {
root = new TrieNode();
}
void insert(string word) {
TrieNode* node = root;
for (char c : word) {
int idx = c - 'a';
if (!node->children[idx]) {
node->children[idx] = new TrieNode();
}
node = node->children[idx];
}
node->isEnd = true;
}
};
search(搜索) :逐字符查找,如果路径中断,直接返回 false。遍历完所有字符后,检查当前节点的 isEnd。
startsWith(前缀搜索) :和 search 几乎一样,唯一的区别是不检查 isEnd。
# Python
def search(self, word: str) -> bool:
node = self._find(word)
return node is not None and node.isEnd
def startsWith(self, prefix: str) -> bool:
return self._find(prefix) is not None
def _find(self, prefix: str):
node = self.root
for ch in prefix:
idx = ord(ch) - ord('a')
if not node.children[idx]:
return None
node = node.children[idx]
return node
// Java
public boolean search(String word) {
TrieNode node = find(word);
return node != null && node.isEnd;
}
public boolean startsWith(String prefix) {
return find(prefix) != null;
}
private TrieNode find(String prefix) {
TrieNode node = root;
for (char c : prefix.toCharArray()) {
int idx = c - 'a';
if (node.children[idx] == null) return null;
node = node.children[idx];
}
return node;
}
// C++
bool search(string word) {
TrieNode* node = find(word);
return node != nullptr && node->isEnd;
}
bool startsWith(string prefix) {
return find(prefix) != nullptr;
}
TrieNode* find(string prefix) {
TrieNode* node = root;
for (char c : prefix) {
int idx = c - 'a';
if (!node->children[idx]) return nullptr;
node = node->children[idx];
}
return node;
}
时间复杂度分析
| 操作 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| insert | O(L) | O(L) |
| search | O(L) | O(1) |
| startsWith | O(L) | O(1) |
三个方法都是 O(L) 时间,L 是单词/前缀长度。插入和搜索的时间与 Trie 中存储的单词总数 N 无关。
面试点睛
面试官常问的一个坑:search 和 startsWith 的区别是什么?
答案是:search 要检查 isEnd,startsWith 不用。插入 "apple" 后,search("app") 返回 false("app" 不是完整单词),但 startsWith("app") 返回 true(有单词以 "app" 开头)。
四、LeetCode 211:Design Add and Search Words Data Structure
问题升级
现在题目升级了------搜索时支持通配符 .,它可以匹配任意一个字符。
例如,搜索 "c.t" 可以匹配 "cat"、"cot"、"cut"。搜索 "..t" 可以匹配所有以 't' 结尾的三字母单词。
插入逻辑不变
插入和 208 完全一样,直接复用。
搜索逻辑:Trie + DFS 回溯
遇到普通字符时,逻辑和 208 一样------精确查找子节点。但遇到 . 时,我们需要遍历当前节点的所有 26 个子节点,只要有一条路径能匹配剩余字符,就返回 true。
下面是一棵插入了 "bad" 和 "dad" 的 Trie:
DFS 递归搜索实现
# Python
class WordDictionary:
def __init__(self):
self.root = TrieNode()
def addWord(self, word: str) -> None:
node = self.root
for ch in word:
idx = ord(ch) - ord('a')
if not node.children[idx]:
node.children[idx] = TrieNode()
node = node.children[idx]
node.isEnd = True
def search(self, word: str) -> bool:
return self._dfs(word, 0, self.root)
def _dfs(self, word: str, idx: int, node: TrieNode) -> bool:
if idx == len(word):
return node.isEnd
ch = word[idx]
if ch == '.':
for child in node.children:
if child and self._dfs(word, idx + 1, child):
return True
return False
else:
child = node.children[ord(ch) - ord('a')]
if child:
return self._dfs(word, idx + 1, child)
return False
// Java
class WordDictionary {
private TrieNode root;
public WordDictionary() {
root = new TrieNode();
}
public void addWord(String word) {
TrieNode node = root;
for (char c : word.toCharArray()) {
int idx = c - 'a';
if (node.children[idx] == null) {
node.children[idx] = new TrieNode();
}
node = node.children[idx];
}
node.isEnd = true;
}
public boolean search(String word) {
return dfs(word, 0, root);
}
private boolean dfs(String word, int idx, TrieNode node) {
if (idx == word.length()) return node.isEnd;
char c = word.charAt(idx);
if (c == '.') {
for (TrieNode child : node.children) {
if (child != null && dfs(word, idx + 1, child)) {
return true;
}
}
return false;
} else {
TrieNode child = node.children[c - 'a'];
if (child == null) return false;
return dfs(word, idx + 1, child);
}
}
}
// C++
class WordDictionary {
private:
TrieNode* root;
bool dfs(const string& word, int idx, TrieNode* node) {
if (idx == word.length()) return node->isEnd;
char c = word[idx];
if (c == '.') {
for (int i = 0; i < 26; i++) {
if (node->children[i] && dfs(word, idx + 1, node->children[i])) {
return true;
}
}
return false;
} else {
TrieNode* child = node->children[c - 'a'];
if (!child) return false;
return dfs(word, idx + 1, child);
}
}
public:
WordDictionary() {
root = new TrieNode();
}
void addWord(string word) {
TrieNode* node = root;
for (char c : word) {
int idx = c - 'a';
if (!node->children[idx]) {
node->children[idx] = new TrieNode();
}
node = node->children[idx];
}
node->isEnd = true;
}
bool search(string word) {
return dfs(word, 0, root);
}
};
DFS 搜索 "c.t" 的回溯过程
下面用流程图展示搜索 "c.t" 时 DFS 的完整回溯路径。假设 Trie 中已插入 "cat"、"cot"、"cut":
时间复杂度分析
| 操作 | 时间复杂度 |
|---|---|
| addWord | O(L) |
| search(无通配符) | O(L) |
| search(有通配符) | 最坏 O(26^K),K = 通配符数量 |
最坏情况:搜索 "......"(6 个通配符),每个位置都要尝试 26 种可能,即 26^6 ≈ 3 亿次。但实际中 Trie 的结构会限制分支数------如果某个节点只有 3 个子节点,就只遍历 3 条路径,远小于 26。所以实际运行时间远好于最坏情况。
五、面试延伸
1. Word Break(139)--- Trie + DP
给定一个字符串和一个单词集合,判断字符串能否被分割成单词序列。
用 Trie 存储单词集合,在 DP 中判断每个子串是否在 Trie 中。朴素 DP 的 O(n²) 子串查找可以用 Trie 优化为 O(n × maxLen)。
2. Word Search II(212)--- Trie + 矩阵 DFS
在二维字符矩阵中找出所有出现在单词集合中的单词。
用 Trie 存储目标单词集合,在矩阵中进行 DFS 搜索时,同步沿着 Trie 的路径移动。如果当前路径不在 Trie 中,立即剪枝。比用 HashSet 暴力搜索快一个数量级,是面试中的高频 Hard 题。
3. 自动补全(Autocomplete)--- Trie + DFS + 优先级队列
搜索引擎的输入框自动补全功能,底层就是 Trie。实现思路:
-
用 Trie 存储所有搜索词和频率
-
用户输入前缀时,沿着 Trie 找到前缀节点
-
从该节点开始 DFS,收集所有完整单词及其频率
-
用最小堆维护 Top K 高频词
4. 工程应用
| 场景 | 说明 |
|---|---|
| 搜索引擎 | Google 搜索框的自动补全 |
| 拼写检查 | 检查单词是否在词典中,给出建议词 |
| IP 路由 | CIDR 前缀匹配,路由器查路由表 |
| 输入法 | 拼音到汉字的映射,词频排序 |
| 基因序列 | DNA 子串模式匹配 |
5. Trie 的变体
-
压缩 Trie(Radix Tree):合并只有一个子节点的路径,节省空间。Linux 内核的 radix tree 就是这种结构。
-
双数组 Trie(Double-Array Trie):用两个数组实现 Trie,省去指针开销,内存效率极高,常用于中文分词。
-
后缀树(Suffix Tree):Trie 的变体,用于子串搜索,可在 O(L) 时间内查找任意子串。
六、总结
Trie(前缀树)是处理字符串前缀问题的终极武器 。它的核心思想是空间换时间------用多叉树结构存储字符串集合,让公共前缀共享路径,从而将前缀查询的时间复杂度从 O(N) 降低到 O(L)。
三个核心操作:insert、search、startsWith,都是 O(L) 时间。
两个关键细节:
-
search检查 isEnd,startsWith不检查 -
通配符搜索需要 DFS 回溯,遇到 '.' 时遍历所有子节点
一个核心思维:遇到"字符串集合 + 前缀匹配"的问题,第一时间想到 Trie。
参考资料
-
LeetCode 211 - Design Add and Search Words Data Structure - 官方题解
-
Wikipedia: Trie (Prefix Tree)
-
《算法导论》第 14 章:数据结构的扩张
-
《剑指 Offer》第 2 版:Trie 相关题目

