题目描述:
Trie (发音类似 "try")或者说 前缀树 是一种树形数据结构,用于高效地存储和检索字符串数据集中的键。这一数据结构有相当多的应用情景,例如自动补全和拼写检查。
请你实现 Trie 类:
Trie()初始化前缀树对象。void insert(String word)向前缀树中插入字符串word。boolean search(String word)如果字符串word在前缀树中,返回true(即,在检索之前已经插入);否则,返回false。boolean startsWith(String prefix)如果之前已经插入的字符串word的前缀之一为prefix,返回true;否则,返回false。示例:
输入 ["Trie", "insert", "search", "search", "startsWith", "insert", "search"] [[], ["apple"], ["apple"], ["app"], ["app"], ["app"], ["app"]] 输出 [null, null, true, false, true, null, true] 解释 Trie trie = new Trie(); trie.insert("apple"); trie.search("apple"); // 返回 True trie.search("app"); // 返回 False trie.startsWith("app"); // 返回 True trie.insert("app"); trie.search("app"); // 返回 True
解题思路:
方法一:字典树 (26 叉树)
核心思路:
Trie 的结构:
root
├── a
│ └── p
│ └── p
│ └── l
│ └── e (isEnd = true)
└── b
└── ...
每个节点包含:
-
children[26]:26 个子节点指针(对应 a-z) -
isEnd:是否是某个单词的结尾
操作逻辑:
| 操作 | 逻辑 |
|---|---|
insert |
从根开始,逐个字符向下走,不存在就创建节点,最后标记 isEnd = true |
search |
从根开始,逐个字符向下走,走不到返回 false,走完检查 isEnd |
startsWith |
和 search 一样,但不需要检查 isEnd |
代码实现:
cpp
class Trie {
private:
struct TrieNode {
TrieNode* children[26];
bool isEnd;
TrieNode() {
for (int i = 0; i < 26; i++) {
children[i] = nullptr;
}
isEnd = false;
}
};
TrieNode* root;
public:
Trie() {
root = new TrieNode();
}
void insert(string word) {
TrieNode* node = root;
for (char c : word) {
int index = c - 'a';
if (node->children[index] == nullptr) {
node->children[index] = new TrieNode();
}
node = node->children[index];
}
node->isEnd = true;
}
bool search(string word) {
TrieNode* node = root;
for (char c : word) {
int index = c - 'a';
if (node->children[index] == nullptr) {
return false;
}
node = node->children[index];
}
return node->isEnd;
}
bool startsWith(string prefix) {
TrieNode* node = root;
for (char c : prefix) {
int index = c - 'a';
if (node->children[index] == nullptr) {
return false;
}
node = node->children[index];
}
return true;
}
};
复杂度分析:
设 L 是字符串长度。
| 操作 | 时间复杂度 | 空间复杂度 |
|---|---|---|
insert |
O(L) | O(L)(最坏情况新建 L 个节点) |
search |
O(L) | O(1) |
startsWith |
O(L) | O(1) |
| 整体 | - | O(总字符数 × 26) |
关键细节:
1. 为什么用数组而不是哈希表?
cpp
TrieNode* children[26]; // 数组
-
数组访问 O(1),比哈希表更快
-
题目限定小写字母,26 个足够
-
如果字符集更大,可以用
unordered_map<char, TrieNode*>
2. search 和 startsWith 的区别
cpp
// search: 必须是一个完整的单词
return node->isEnd;
// startsWith: 只要是前缀就行
return true;
关键 :search 检查 isEnd,startsWith 不检查。
3. 为什么用 isEnd 标记?
因为一个单词可能是另一个单词的前缀:
插入 "apple" 后,再插入 "app"
如果不标记 isEnd,search("app") 无法区分 "app" 是完整单词还是 "apple" 的前缀
4. 内存泄漏问题
cpp
~Trie() {
deleteNode(root);
}
void deleteNode(TrieNode* node) {
for (int i = 0; i < 26; i++) {
if (node->children[i]) {
deleteNode(node->children[i]);
}
}
delete node;
}
LeetCode 上不要求,但实际工程中需要释放内存。
方法二:哈希表
为什么用哈希表?
| 对比维度 | 数组 children[26] |
哈希表 unordered_map |
|---|---|---|
| 适用字符集 | 仅小写字母 | 任意字符集 |
| 空间占用 | 每个节点固定 26 个指针 | 只存实际存在的子节点 |
| 访问速度 | O(1) | O(1) 平均 |
| 代码复杂度 | 简单 | 中等 |
优势:如果字符集很大(如 Unicode),或者子节点很稀疏,哈希表更省空间。
代码实现:
cpp
class Trie {
private:
struct TrieNode {
unordered_map<char, TrieNode*> children; // 哈希表存子节点
bool isEnd;
TrieNode() : isEnd(false) {}
};
TrieNode* root;
public:
Trie() {
root = new TrieNode();
}
void insert(string word) {
TrieNode* node = root;
for (char c : word) {
// 如果子节点不存在,创建
if (node->children.find(c) == node->children.end()) {
node->children[c] = new TrieNode();
}
node = node->children[c];
}
node->isEnd = true;
}
bool search(string word) {
TrieNode* node = root;
for (char c : word) {
if (node->children.find(c) == node->children.end()) {
return false;
}
node = node->children[c];
}
return node->isEnd;
}
bool startsWith(string prefix) {
TrieNode* node = root;
for (char c : prefix) {
if (node->children.find(c) == node->children.end()) {
return false;
}
node = node->children[c];
}
return true;
}
};
更简洁的写法(用 count):
cpp
void insert(string word) {
TrieNode* node = root;
for (char c : word) {
if (!node->children.count(c)) {
node->children[c] = new TrieNode();
}
node = node->children[c];
}
node->isEnd = true;
}
bool search(string word) {
TrieNode* node = root;
for (char c : word) {
if (!node->children.count(c)) return false;
node = node->children[c];
}
return node->isEnd;
}
bool startsWith(string prefix) {
TrieNode* node = root;
for (char c : prefix) {
if (!node->children.count(c)) return false;
node = node->children[c];
}
return true;
}
具体过程示例:
插入 "apple" 后:
root
└── children['a'] → node_a
└── children['p'] → node_p1
└── children['p'] → node_p2
└── children['l'] → node_l
└── children['e'] → node_e (isEnd=true)
哈希表只存实际存在的字符,不像数组要开 26 个位置。
复杂度分析:
设 L 是字符串长度。
| 操作 | 时间复杂度 | 空间复杂度 |
|---|---|---|
insert |
O(L) | O(L) |
search |
O(L) | O(1) |
startsWith |
O(L) | O(1) |
| 整体 | - | O(总字符数) |
空间复杂度对比:
-
数组:O(总字符数 × 26)
-
哈希表:O(总字符数)
关键细节:
1. find vs count vs []
| 写法 | 行为 |
|---|---|
children.find(c) == children.end() |
判断是否存在 |
children.count(c) |
返回 0 或 1 |
children[c] |
不存在时会创建默认值,慎用 |
注意 :children[c] 在键不存在时会插入一个默认值 (nullptr),可能改变哈希表大小。
2. 为什么哈希表更省空间?
-
数组:每个节点固定 26 个指针,共 26 × 8 = 208 字节
-
哈希表:只存实际存在的子节点,稀疏时节省大量空间
3. 什么时候用数组,什么时候用哈希表?
| 场景 | 推荐 |
|---|---|
| 仅小写字母 | 数组(更快) |
| 字符集大(Unicode) | 哈希表 |
| 子节点稀疏 | 哈希表 |
| 追求极致速度 | 数组 |
数组 vs 哈希表对比:
| 对比维度 | 数组 | 哈希表 |
|---|---|---|
| 时间复杂度 | O(1) | O(1) 平均 |
| 空间复杂度 | O(26 × 节点数) | O(实际子节点数) |
| 适用字符集 | 固定小字符集 | 任意字符集 |
| 代码复杂度 | 简单 | 中等 |
| 推荐度 | ⭐⭐⭐⭐⭐(小写字母) | ⭐⭐⭐⭐(通用) |