一、Trie 树定位

Trie 树(前缀树 / 字典树)是专为字符串设计的树形结构,核心价值是通过 “前缀共享” 减少内存冗余,同时实现 O (k)(k 为字符串长度)的插入 / 查询效率,尤其适合 “前缀相关场景”(如自动补全、拼写检查)。

二、核心结构:节点设计与前缀共享

1. 节点结构体

Trie 的最小单元是节点,需存储子节点映射单词结尾标记,C++ 中用结构体实现最简洁:

1
2
3
4
5
6
7
8
9
struct TrieNode {
// 子节点:两种实现方案(按需选择)
// 方案1:数组(仅适用于固定小字符集,如小写字母,速度快)
TrieNode* children[26] = {nullptr};
// 方案2:哈希表(字符集不确定时用,如含数字/符号,灵活)
// unordered_map<char, TrieNode*> children;

bool isEnd = false; // 标记当前节点是否为某单词的结尾(避免前缀误判)
};

根节点是TrieNode* root = new TrieNode(),不存储实际字符,仅作为所有字符串的入口。

2. 前缀共享机制(核心优势)

多个字符串的公共前缀共用一套节点,例如 “apple” 和 “app”:

  • 共用节点路径:root -> 'a' -> 'p' -> 'p'

  • “app” 在第三个'p'节点标记isEnd=true

  • “apple” 继续延伸:'p' -> 'l' -> 'e',在'e'标记isEnd=true

相比哈希表存储完整字符串,Trie 可大幅减少重复前缀的内存开销。

三、核心操作:插入与查询(O (k) 复杂度)

所有操作均按 “字符遍历路径” 执行,逻辑线性且无冲突。

1. 插入操作(Insert)

流程:按字符串字符逐个遍历,无节点则新建,最后标记单词结尾。

1
2
3
4
5
6
7
8
9
10
11
void insert(TrieNode* root, const string& word) {
TrieNode* curr = root;
for (char c : word) {
int idx = c - 'a'; // 仅适配方案1(数组子节点),方案2直接用c作为key
if (!curr->children[idx]) { // 路径不存在,新建节点
curr->children[idx] = new TrieNode();
}
curr = curr->children[idx]; // 移动到下一层节点
}
curr->isEnd = true; // 标记当前字符串结束
}

2. 查询操作(Search)

核心注意:需判断 “路径存在” 且 “终点是单词结尾”(避免将前缀误判为完整单词,如 “app” 是单词,“apple” 的前缀 “app” 不是完整单词)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 精准查询:判断word是否是Trie中的完整单词
bool search(TrieNode* root, const string& word) {
TrieNode* curr = root;
for (char c : word) {
int idx = c - 'a';
if (!curr->children[idx]) return false; // 路径断裂,不存在
curr = curr->children[idx];
}
return curr->isEnd; // 路径存在≠单词存在,需确认结尾标记
}

// 前缀查询:判断是否存在以prefix开头的单词(自动补全核心)
bool startsWith(TrieNode* root, const string& prefix) {
TrieNode* curr = root;
for (char c : prefix) {
int idx = c - 'a';
if (!curr->children[idx]) return false;
curr = curr->children[idx];
}
return true; // 只要路径存在,无论是否是单词结尾
}

四、与哈希表(unordered_map)的场景对比

Trie 不是哈希表的 “替代品”,而是 “互补品”,核心差异在前缀能力

特性 Trie 树 哈希表(unordered_map)
时间复杂度 O (k)(稳定,与数据量无关) 平均 O (1),最坏 O (n)(冲突时)
前缀查询 原生支持(高效遍历子节点) 不支持(需遍历所有 key,低效)
内存占用 前缀共享,小字符集占优 存储完整 key,冗余度高
冲突风险 无冲突(路径唯一) 需处理哈希冲突(链地址 / 红黑树)
适用场景 前缀相关(自动补全、拼写检查、敏感词过滤) 精准单键查询(如配置项读取、缓存)

五、C++ 实现注意事项

内存管理

动态节点(new创建)需手动销毁,避免内存泄漏,建议写递归销毁函数:

1
2
3
4
5
6
7
8
9
void destroy(TrieNode* node) {
if (!node) return;
// 方案1:遍历数组销毁子节点
for (int i = 0; i < 26; ++i) destroy(node->children[i]);
// 方案2:遍历哈希表销毁子节点
// for (auto& [_, child] : node->children) destroy(child);
delete node;
}
// 使用后调用:destroy(root);

字符集适配

  • 固定小字符集(如小写字母、数字)用数组子节点(速度快);

  • 复杂字符集(如含符号、多语言)用unordered_map(灵活,但稍慢)。

空节点优化

高频场景可改用 “节点池”(预先分配一批节点)减少new/delete开销,但基础实现无需过度设计。

六、典型应用场景

搜索引擎关键词提示:输入 “cloud”,快速遍历 “cloud” 节点的所有子节点,返回 “cloud computing”“cloud storage” 等;

单词拼写检查:输入 “appel”,查询时路径断裂,提示 “是否要输入 apple?”;

多模式匹配:敏感词过滤(一次遍历文本,匹配所有敏感词,比多正则匹配高效)。

总结

Trie 树的核心是 “前缀共享 + 路径遍历”,C++ 实现的关键是节点设计(数组 / 哈希表子节点)和内存管理。它在 “前缀相关场景” 中无可替代,与哈希表搭配可覆盖大部分字符串处理需求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
class Trie {
private:
// 1. 嵌套定义Trie节点结构体(仅类内部可见)
struct TrieNode {
TrieNode* children[26] = {nullptr}; // 子节点:适配小写字母(a-z)
bool isEnd = false; // 标记当前节点是否为单词结尾
};

TrieNode* root; // 根节点(所有单词的入口)

// 2. 辅助函数:递归销毁节点(用于析构和删除操作)
void destroyNode(TrieNode* node) {
if (!node) return; // 空节点直接返回
// 先递归销毁所有子节点(后序遍历)
for (int i = 0; i < 26; ++i) {
destroyNode(node->children[i]);
}
delete node; // 销毁当前节点,避免内存泄漏
}

// 3. 辅助函数:递归删除单词(核心逻辑:判断节点是否可回溯删除)
// 返回值:true=当前节点可删除(无其他子节点且不是其他单词结尾),false=不可删除
bool eraseHelper(TrieNode* node, const string& word, int index) {
// 递归终止条件1:已处理完单词所有字符
if (index == word.size()) {
if (!node->isEnd) return false; // 单词不存在,直接返回
node->isEnd = false; // 取消单词结尾标记

// 检查当前节点是否有子节点:有则不可删除
for (int i = 0; i < 26; ++i) {
if (node->children[i]) return false;
}
return true; // 无子孙节点,可删除
}

// 递归处理:计算当前字符的数组索引
int charIdx = word[index] - 'a';
// 递归终止条件2:路径断裂(单词不存在)
if (!node->children[charIdx]) return false;

// 递归删除子节点,并判断子节点是否可删除
bool canDeleteChild = eraseHelper(node->children[charIdx], word, index + 1);
if (canDeleteChild) {
// 子节点可删除:释放内存并置空指针
delete node->children[charIdx];
node->children[charIdx] = nullptr;

// 判断当前节点是否可删除:无其他子节点 + 不是其他单词结尾
if (!node->isEnd) {
for (int i = 0; i < 26; ++i) {
if (node->children[i]) return false;
}
return true;
}
}

return false; // 子节点不可删除,或当前节点是其他单词结尾
}

public:
// 4. 构造函数:初始化根节点
Trie() {
root = new TrieNode();
}

// 5. 析构函数:释放所有节点内存
~Trie() {
destroyNode(root);
}

// 6. 插入单词(支持仅小写字母,含非法字符容错)
void insert(const string& word) {
TrieNode* curr = root;
for (char c : word) {
int charIdx = c - 'a';
// 容错:仅允许小写字母(避免数组越界)
if (charIdx < 0 || charIdx >= 26) {
throw invalid_argument("Trie only supports lowercase letters (a-z).");
}
// 路径不存在则新建节点
if (!curr->children[charIdx]) {
curr->children[charIdx] = new TrieNode();
}
// 移动到下一层节点
curr = curr->children[charIdx];
}
curr->isEnd = true; // 标记单词结尾
}

// 7. 精准查询:判断单词是否完整存在于Trie中
bool search(const string& word) {
TrieNode* curr = root;
for (char c : word) {
int charIdx = c - 'a';
if (charIdx < 0 || charIdx >= 26) return false; // 非法字符直接返回不存在
if (!curr->children[charIdx]) return false; // 路径断裂,单词不存在
curr = curr->children[charIdx];
}
return curr->isEnd; // 路径存在≠单词存在,需验证结尾标记
}

// 8. 前缀查询:判断是否存在以prefix开头的单词
bool startsWith(const string& prefix) {
TrieNode* curr = root;
for (char c : prefix) {
int charIdx = c - 'a';
if (charIdx < 0 || charIdx >= 26) return false;
if (!curr->children[charIdx]) return false;
curr = curr->children[charIdx];
}
return true; // 只要路径存在,前缀就存在
}

// 9. 删除单词:返回是否成功删除(单词不存在则返回false)
bool erase(const string& word) {
// 从根节点开始,从第0个字符递归删除
return eraseHelper(root, word, 0);
}
};