Trie 字典树:前缀匹配利器
1. 直观类比:按字母逐层翻找
查英文单词时你不会翻到词典中间去随机碰运气。你会先翻到首字母所在的章节,再按第二个字母缩小范围,逐层推进直到找到目标。Trie(字典树)的运作方式完全一样:从根节点开始,每次沿着一个字符向下走一层。不需要比较整个字符串,不需要哈希碰撞,也不依赖字符串长度。
2. 结构
Trie 是一棵多叉树。根节点不存字符,每条从根到叶子(或中间节点)的路径代表一个单词。每个节点通常存一个布尔值 isEnd 标记是否有单词在此结束,以及指向下一层子节点的指针集合。
往 Trie 插入 "cat"、"car"、"dog" 后的结构:
#mermaid-svg-fbxa3X5ZVJIpZqAj{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-fbxa3X5ZVJIpZqAj .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-fbxa3X5ZVJIpZqAj .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-fbxa3X5ZVJIpZqAj .error-icon{fill:#552222;}#mermaid-svg-fbxa3X5ZVJIpZqAj .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-fbxa3X5ZVJIpZqAj .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-fbxa3X5ZVJIpZqAj .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-fbxa3X5ZVJIpZqAj .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-fbxa3X5ZVJIpZqAj .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-fbxa3X5ZVJIpZqAj .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-fbxa3X5ZVJIpZqAj .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-fbxa3X5ZVJIpZqAj .marker{fill:#333333;stroke:#333333;}#mermaid-svg-fbxa3X5ZVJIpZqAj .marker.cross{stroke:#333333;}#mermaid-svg-fbxa3X5ZVJIpZqAj svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-fbxa3X5ZVJIpZqAj p{margin:0;}#mermaid-svg-fbxa3X5ZVJIpZqAj .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-fbxa3X5ZVJIpZqAj .cluster-label text{fill:#333;}#mermaid-svg-fbxa3X5ZVJIpZqAj .cluster-label span{color:#333;}#mermaid-svg-fbxa3X5ZVJIpZqAj .cluster-label span p{background-color:transparent;}#mermaid-svg-fbxa3X5ZVJIpZqAj .label text,#mermaid-svg-fbxa3X5ZVJIpZqAj span{fill:#333;color:#333;}#mermaid-svg-fbxa3X5ZVJIpZqAj .node rect,#mermaid-svg-fbxa3X5ZVJIpZqAj .node circle,#mermaid-svg-fbxa3X5ZVJIpZqAj .node ellipse,#mermaid-svg-fbxa3X5ZVJIpZqAj .node polygon,#mermaid-svg-fbxa3X5ZVJIpZqAj .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-fbxa3X5ZVJIpZqAj .rough-node .label text,#mermaid-svg-fbxa3X5ZVJIpZqAj .node .label text,#mermaid-svg-fbxa3X5ZVJIpZqAj .image-shape .label,#mermaid-svg-fbxa3X5ZVJIpZqAj .icon-shape .label{text-anchor:middle;}#mermaid-svg-fbxa3X5ZVJIpZqAj .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-fbxa3X5ZVJIpZqAj .rough-node .label,#mermaid-svg-fbxa3X5ZVJIpZqAj .node .label,#mermaid-svg-fbxa3X5ZVJIpZqAj .image-shape .label,#mermaid-svg-fbxa3X5ZVJIpZqAj .icon-shape .label{text-align:center;}#mermaid-svg-fbxa3X5ZVJIpZqAj .node.clickable{cursor:pointer;}#mermaid-svg-fbxa3X5ZVJIpZqAj .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-fbxa3X5ZVJIpZqAj .arrowheadPath{fill:#333333;}#mermaid-svg-fbxa3X5ZVJIpZqAj .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-fbxa3X5ZVJIpZqAj .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-fbxa3X5ZVJIpZqAj .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fbxa3X5ZVJIpZqAj .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-fbxa3X5ZVJIpZqAj .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fbxa3X5ZVJIpZqAj .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-fbxa3X5ZVJIpZqAj .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-fbxa3X5ZVJIpZqAj .cluster text{fill:#333;}#mermaid-svg-fbxa3X5ZVJIpZqAj .cluster span{color:#333;}#mermaid-svg-fbxa3X5ZVJIpZqAj div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-fbxa3X5ZVJIpZqAj .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-fbxa3X5ZVJIpZqAj rect.text{fill:none;stroke-width:0;}#mermaid-svg-fbxa3X5ZVJIpZqAj .icon-shape,#mermaid-svg-fbxa3X5ZVJIpZqAj .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fbxa3X5ZVJIpZqAj .icon-shape p,#mermaid-svg-fbxa3X5ZVJIpZqAj .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-fbxa3X5ZVJIpZqAj .icon-shape .label rect,#mermaid-svg-fbxa3X5ZVJIpZqAj .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fbxa3X5ZVJIpZqAj .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-fbxa3X5ZVJIpZqAj .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-fbxa3X5ZVJIpZqAj :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 根
c
a
t
r
(end)
(end)
d
o
g
(end)
"cat" 和 "car" 共享前缀 "ca"------这正是 Trie 最核心的资产:共享前缀。
3. 核心操作
- 插入 :从根出发,逐字符检查子节点是否存在,不存在则新建,到达最后一个字符时标记
isEnd = true。 - 搜索 :同样逐层查找,任何一层缺失则返回 false,走完所有字符后检查
isEnd。 - 前缀搜索 :和搜索几乎一样,只不过不需要检查
isEnd------只要路径存在就算匹配。
4. 两种实现
4.1 定长数组 Trie* children[26]
cpp
#include <string>
using namespace std;
class Trie {
private:
Trie* children[26];
bool isEnd;
public:
Trie() {
isEnd = false;
for (int i = 0; i < 26; ++i) children[i] = nullptr;
}
void insert(string word) {
Trie* node = this;
for (char c : word) {
int idx = c - 'a';
if (!node->children[idx]) node->children[idx] = new Trie();
node = node->children[idx];
}
node->isEnd = true;
}
bool search(string word) {
Trie* node = this;
for (char c : word) {
int idx = c - 'a';
if (!node->children[idx]) return false;
node = node->children[idx];
}
return node->isEnd;
}
bool startsWith(string prefix) {
Trie* node = this;
for (char c : prefix) {
int idx = c - 'a';
if (!node->children[idx]) return false;
node = node->children[idx];
}
return true;
}
};
4.2 HashMap 子节点 unordered_map<char, Trie*>
cpp
#include <string>
#include <unordered_map>
using namespace std;
class Trie {
private:
unordered_map<char, Trie*> children;
bool isEnd;
public:
Trie() : isEnd(false) {}
void insert(string word) {
Trie* node = this;
for (char c : word) {
if (!node->children.count(c))
node->children[c] = new Trie();
node = node->children[c];
}
node->isEnd = true;
}
bool search(string word) {
Trie* node = this;
for (char c : word) {
if (!node->children.count(c)) return false;
node = node->children[c];
}
return node->isEnd;
}
bool startsWith(string prefix) {
Trie* node = this;
for (char c : prefix) {
if (!node->children.count(c)) return false;
node = node->children[c];
}
return true;
}
};
数组版本通过下标 c - 'a' 直接寻址,每次插入/查询固定 O(1) 跳转,内存固定 26 指针 × 节点数,大量稀疏分支浪费空间。HashMap 版本按需分配,适合 Unicode 或大数据量稀疏场景,但哈希计算稍慢。
6. C++ 完整实现
以下实现同时提供数组和 HashMap 两种策略,通过模板参数选择:
cpp
#include <string>
#include <unordered_map>
using namespace std;
template <bool UseArray = true>
class Trie {
// 仅作演示,实际应通过特化分离
};
// 特化:数组版本
template <>
class Trie<true> {
private:
Trie* children[26];
bool isEnd;
public:
Trie() : isEnd(false) {
for (int i = 0; i < 26; ++i) children[i] = nullptr;
}
void insert(string word) {
Trie* node = this;
for (char c : word) {
int idx = c - 'a';
if (!node->children[idx]) node->children[idx] = new Trie();
node = node->children[idx];
}
node->isEnd = true;
}
bool search(string word) {
Trie* node = this;
for (char c : word) {
int idx = c - 'a';
if (!node->children[idx]) return false;
node = node->children[idx];
}
return node->isEnd;
}
bool startsWith(string prefix) {
Trie* node = this;
for (char c : prefix) {
int idx = c - 'a';
if (!node->children[idx]) return false;
node = node->children[idx];
}
return true;
}
};
5. 复杂度
| 操作 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| 插入 | O(len) | O(len) |
| 搜索 | O(len) | O(1) |
| 前缀搜索 | O(len) | O(1) |
| 整体空间 | --- | O(总字符数 × 字符集大小) |
len 为单词长度,与字典大小无关。这是 Trie 对比二叉搜索树和哈希表的核心优势:查询时间只取决于键的长度,不取决于键的数量。
7. 面试真题
7.1 实现 Trie(LeetCode 208)
就是第 4 节的完整实现。唯一变化是 LeetCode 要求类名和接口分别为 Trie、insert、search、startsWith,和上面一模一样。面试时注意:
- 数组要初始化 nullptr
search和startsWith的区别在于最后是否检查isEnd- 用
delete析构释放内存会给面试加分
7.2 单词搜索 II(LeetCode 212)------DFS + Trie 剪枝
在二维网格中找多个单词。暴力对每个单词做 DFS 会超时,正确做法是把所有单词建成 Trie,一次 DFS 同时匹配所有前缀:
cpp
#include <vector>
#include <string>
using namespace std;
class TrieNode {
public:
TrieNode* children[26];
string word;
TrieNode() : word("") {
for (int i = 0; i < 26; ++i) children[i] = nullptr;
}
};
class Solution {
private:
vector<string> result;
int dirs[5] = {0, 1, 0, -1, 0};
void dfs(vector<vector<char>>& board, int i, int j, TrieNode* node) {
char c = board[i][j];
if (c == '#' || !node->children[c - 'a']) return;
node = node->children[c - 'a'];
if (node->word != "") {
result.push_back(node->word);
node->word = "";
}
board[i][j] = '#';
for (int d = 0; d < 4; ++d) {
int ni = i + dirs[d], nj = j + dirs[d + 1];
if (ni >= 0 && ni < (int)board.size() && nj >= 0 && nj < (int)board[0].size())
dfs(board, ni, nj, node);
}
board[i][j] = c;
}
public:
vector<string> findWords(vector<vector<char>>& board, vector<string>& words) {
TrieNode* root = new TrieNode();
for (const string& w : words) {
TrieNode* cur = root;
for (char c : w) {
int idx = c - 'a';
if (!cur->children[idx]) cur->children[idx] = new TrieNode();
cur = cur->children[idx];
}
cur->word = w;
}
for (int i = 0; i < (int)board.size(); ++i)
for (int j = 0; j < (int)board[0].size(); ++j)
dfs(board, i, j, root);
return result;
}
};
为什么快? 一般的 DFS 每搜索一个单词就遍历一次全图。Trie 把单词的公共前缀合并,一次 DFS 走完一个前缀后,所有共享该前缀的单词都跟着前进了一步,相当于把 O(N × 4^L) 降到 O(4^L),N 是单词数,L 是单词平均长度。
7.3 最长公共前缀
在一组字符串中找到所有字符串共享的最长前缀:
cpp
#include <string>
#include <vector>
using namespace std;
class TrieNode {
public:
TrieNode* children[26];
bool isEnd;
TrieNode() : isEnd(false) {
for (int i = 0; i < 26; ++i) children[i] = nullptr;
}
};
string longestCommonPrefix(vector<string>& strs) {
if (strs.empty()) return "";
TrieNode* root = new TrieNode();
for (const string& s : strs) {
TrieNode* cur = root;
for (char c : s) {
int idx = c - 'a';
if (!cur->children[idx]) cur->children[idx] = new TrieNode();
cur = cur->children[idx];
}
cur->isEnd = true;
}
string res;
TrieNode* cur = root;
while (true) {
int cnt = 0, nxt = -1;
for (int i = 0; i < 26; ++i) {
if (cur->children[i]) { ++cnt; nxt = i; }
}
if (cnt != 1 || cur->isEnd) break;
res.push_back('a' + nxt);
cur = cur->children[nxt];
}
return res;
}
逻辑:建完 Trie 后从根开始,每次只能有且仅有一个子分支(否则说明某个位置已出现分叉),且当前节点不能是单词结尾(否则前面的单词已经结束,不能再纳入公共前缀)。满足这两个条件就往下走。
7.4 前缀词频统计
扩展 Trie 节点,记录有多少单词经过(pass)和有多少单词在此结束(end):
cpp
#include <string>
using namespace std;
class TrieNode {
public:
TrieNode* children[26];
int pass;
int end;
TrieNode() : pass(0), end(0) {
for (int i = 0; i < 26; ++i) children[i] = nullptr;
}
};
class Trie {
private:
TrieNode* root;
public:
Trie() { root = new TrieNode(); }
void insert(string word) {
TrieNode* node = root;
node->pass++;
for (char c : word) {
int idx = c - 'a';
if (!node->children[idx]) node->children[idx] = new TrieNode();
node = node->children[idx];
node->pass++;
}
node->end++;
}
int countPrefix(string prefix) {
TrieNode* node = root;
for (char c : prefix) {
int idx = c - 'a';
if (!node->children[idx]) return 0;
node = node->children[idx];
}
return node->pass;
}
int countWord(string word) {
TrieNode* node = root;
for (char c : word) {
int idx = c - 'a';
if (!node->children[idx]) return 0;
node = node->children[idx];
}
return node->end;
}
};
应用场景 :搜索框的自动补全候选词排序,给输入法做词频统计等。countPrefix("ap") 返回所有以 "ap" 开头的单词总数。
7.5 单词替换(LeetCode 648)
给定一个词典(词根列表)和一个句子,把句子中每个单词替换为词典里最短的能匹配上的词根:
cpp
#include <vector>
#include <string>
#include <sstream>
using namespace std;
class TrieNode {
public:
TrieNode* children[26];
bool isEnd;
TrieNode() : isEnd(false) {
for (int i = 0; i < 26; ++i) children[i] = nullptr;
}
};
class Solution {
public:
string replaceWords(vector<string>& dictionary, string sentence) {
TrieNode* root = new TrieNode();
for (const string& w : dictionary) {
TrieNode* cur = root;
for (char c : w) {
int idx = c - 'a';
if (!cur->children[idx]) cur->children[idx] = new TrieNode();
cur = cur->children[idx];
}
cur->isEnd = true;
}
stringstream ss(sentence);
string word, res;
while (ss >> word) {
if (!res.empty()) res += " ";
TrieNode* cur = root;
string prefix;
bool found = false;
for (char c : word) {
int idx = c - 'a';
if (!cur->children[idx]) break;
cur = cur->children[idx];
prefix += c;
if (cur->isEnd) { res += prefix; found = true; break; }
}
if (!found) res += word;
}
return res;
}
};
思路 :每个词根放入 Trie。遍历句子每个单词时,逐字符查 Trie,一旦遇到 isEnd 就说明找到了最短词根,立即替换。找不到就保留原词。
7.6 添加与搜索单词(支持通配符 .)(LeetCode 211)
支持 addWord 插入单词,search 查找单词且 . 可以匹配任意字符:
cpp
#include <string>
using namespace std;
class WordDictionary {
private:
WordDictionary* children[26];
bool isEnd;
bool dfs(string& word, int pos) {
if (pos == (int)word.size()) return isEnd;
char c = word[pos];
if (c == '.') {
for (int i = 0; i < 26; ++i)
if (children[i] && children[i]->dfs(word, pos + 1))
return true;
return false;
}
int idx = c - 'a';
if (!children[idx]) return false;
return children[idx]->dfs(word, pos + 1);
}
public:
WordDictionary() : isEnd(false) {
for (int i = 0; i < 26; ++i) children[i] = nullptr;
}
void addWord(string word) {
WordDictionary* node = this;
for (char c : word) {
int idx = c - 'a';
if (!node->children[idx]) node->children[idx] = new WordDictionary();
node = node->children[idx];
}
node->isEnd = true;
}
bool search(string word) {
return dfs(word, 0);
}
};
通配符处理 :遇到 . 时枚举 26 个孩子递归搜索。这个递归看起来 O(26^L) 很吓人,但在 Trie 剪枝下实际运行的路径数 = Trie 中存在的路径数,远小于指数上界。面试时一定要解释这个剪枝原理。
总结一句话:遇到前缀匹配类的问题,第一反应想想 Trie。哈希表做不到前缀搜索,二叉搜索树做不到 O(len) 查询,只有 Trie 用空间换时间,把字符串比较变成了逐层跳转。