在刷LeetCode的过程中,前缀树(Trie)是一个高频且实用的数据结构,尤其在字符串的存储、检索、自动补全场景中发挥着巨大作用。今天我们就来手把手拆解 LeetCode 208. 实现 Trie (前缀树) 这道题,从原理理解到代码实现,再到细节优化,帮你彻底搞懂前缀树的核心逻辑。
一、什么是Trie(前缀树)?
Trie(发音类似“try”),也叫前缀树或字典树,是一种专门用于处理字符串匹配的数据结构。它的核心特点是利用字符串的公共前缀来节省存储空间,同时实现高效的插入、查询操作。
举个简单的例子:如果我们要存储 “apple”、“app”、“apricot” 这三个字符串,用Trie存储时,它们会共享 “ap” 这个公共前缀,而不是每个字符串单独存储完整字符,这样既能减少内存占用,也能让查询时更快定位到目标字符串。
Trie的应用场景非常广泛,比如:
-
搜索引擎的自动补全(输入“ap”,联想出“apple”“app”等)
-
拼写检查(判断输入的单词是否存在于词库中)
-
通讯录的快速检索(根据姓名前缀查找联系人)
二、题目要求拆解
题目要求我们实现一个Trie类,包含以下4个核心方法,我们先明确每个方法的职责:
-
Trie(): 初始化前缀树对象,相当于创建一个空的根节点。
-
insert(String word): 向前缀树中插入一个完整的字符串word,插入后该字符串会作为一个“完整单词”被标记。
-
search(String word): 检索字符串word是否已经插入到前缀树中(必须是完整的单词,而非前缀),存在则返回true,否则返回false。
-
startsWith(String prefix): 检索是否有已插入的字符串以prefix为前缀(不需要是完整单词),存在则返回true,否则返回false。
三、核心思路:Trie节点的设计
要实现Trie,首先要明确Trie节点的结构。每个Trie节点包含两个核心属性:
-
children(子节点集合): 存储当前节点的所有子节点,key是单个字符(比如'a'、'p'),value是对应的子Trie节点。这里用哈希表(Record<string, Trie>)来实现,能快速判断某个字符对应的子节点是否存在。
-
isEnd(是否为单词结尾): 一个布尔值,标记当前节点是否是某个完整单词的结尾。比如插入“apple”时,最后一个字符'e'对应的节点,isEnd会设为true,而“app”的最后一个字符'p'对应的节点,isEnd也会设为true。
根节点是一个特殊的节点,它本身不存储任何字符,children存储所有可能的首字符,isEnd默认为false(因为根节点不是任何单词的结尾)。
四、完整代码实现(TypeScript)
结合节点设计和题目要求,我们可以写出完整的TypeScript代码,每一步都添加了详细注释,方便理解:
class Trie {
// 子节点集合:key是单个字符,value是对应的子Trie节点
private children: Record<string, Trie>;
// 标记当前节点是否是某个完整单词的结尾
private isEnd: boolean;
// 初始化前缀树(根节点)
constructor() {
this.children = {}; // 初始化为空哈希表,无任何子节点
this.isEnd = false; // 根节点不是任何单词的结尾
}
/**
* 向前缀树插入字符串word
* @param word 要插入的完整字符串
*/
insert(word: string): void {
let node: Trie = this; // 从根节点开始遍历
for (const ch of word) { // 逐个字符遍历字符串
// 如果当前字符对应的子节点不存在,创建一个新的Trie节点
if (!node.children[ch]) {
node.children[ch] = new Trie();
}
// 移动到当前字符对应的子节点,继续处理下一个字符
node = node.children[ch];
}
// 遍历完所有字符后,标记当前节点为单词结尾
node.isEnd = true;
}
/**
* 辅助方法:查找前缀prefix对应的最后一个节点
* @param prefix 要查找的前缀
* @returns 前缀最后一个字符对应的节点(不存在则返回undefined)
*/
private searchPrefix(prefix: string): Trie | undefined {
let node: Trie = this; // 从根节点开始遍历
for (const ch of prefix) { // 逐个字符遍历前缀
// 如果当前字符对应的子节点不存在,说明前缀不存在,返回undefined
if (!node.children[ch]) {
return undefined;
}
// 移动到当前字符对应的子节点
node = node.children[ch];
}
// 遍历完前缀,返回最后一个字符对应的节点
return node;
}
/**
* 检索字符串word是否存在于前缀树中(必须是完整单词)
* @param word 要检索的完整字符串
* @returns 存在则返回true,否则返回false
*/
search(word: string): boolean {
// 先查找word对应的最后一个节点
const node = this.searchPrefix(word);
// 节点存在,且该节点是单词结尾(说明是完整单词)
return node !== undefined && node.isEnd === true;
}
/**
* 检索是否有字符串以prefix为前缀
* @param prefix 要检索的前缀
* @returns 存在则返回true,否则返回false
*/
startsWith(prefix: string): boolean {
// 只要前缀对应的最后一个节点存在,就说明有对应的前缀
return this.searchPrefix(prefix) !== undefined;
}
}
/**
* 示例用法
* var obj = new Trie()
* obj.insert(word)
* var param_2 = obj.search(word)
* var param_3 = obj.startsWith(prefix)
*/
五、关键方法解析
这道题的核心是复用逻辑,我们把“查找前缀”的逻辑抽成了辅助方法searchPrefix,这样search和startsWith方法都能直接复用,减少代码冗余,也更易维护。
1. insert 方法(插入)
插入的逻辑很简单,就是“从根到叶”逐个字符创建节点:
-
从根节点出发,遍历要插入的字符串的每个字符。
-
对于每个字符,判断当前节点的children中是否存在该字符对应的子节点:不存在则创建,存在则直接复用。
-
遍历完所有字符后,将最后一个节点的isEnd设为true,标记这是一个完整单词的结尾。
比如插入“apple”:根节点 → 'a'节点 → 'p'节点 → 'p'节点 → 'l'节点 → 'e'节点(isEnd=true)。
2. searchPrefix 方法(辅助查找前缀)
这是整个Trie的核心辅助方法,负责根据前缀查找对应的最后一个节点:
-
同样从根节点出发,遍历前缀的每个字符。
-
如果某个字符对应的子节点不存在,直接返回undefined(说明前缀不存在)。
-
遍历完前缀后,返回最后一个字符对应的节点。
3. search 方法(检索完整单词)
基于searchPrefix方法,判断两个条件:
-
前缀(即完整单词)对应的最后一个节点存在(说明单词的所有字符都存在)。
-
该节点的isEnd为true(说明这是一个完整单词,而非某个单词的前缀)。
比如:search("app") → 找到最后一个'p'节点,isEnd=true → 返回true;search("appl") → 找到最后一个'l'节点,isEnd=false → 返回false。
4. startsWith 方法(检索前缀)
比search方法更简单,只需要判断前缀对应的最后一个节点是否存在即可,不需要关注isEnd:
- 只要searchPrefix返回的节点不是undefined,就说明有字符串以该前缀开头。
比如:startsWith("app") → 找到'p'节点 → 返回true;startsWith("apr") → 找到'r'节点 → 返回true;startsWith("abc") → 返回undefined → 返回false。
六、复杂度分析
假设插入、查询的字符串长度为n,Trie的复杂度非常优秀:
-
时间复杂度:insert、search、startsWith方法的时间复杂度都是O(n),因为每个方法都只需要遍历字符串的n个字符,每个字符的操作(查找、创建子节点)都是O(1)(哈希表的查找/插入是O(1))。
-
空间复杂度:取决于插入的字符串总数和字符的重复程度。最坏情况下(所有字符串没有公共前缀),空间复杂度是O(m*n),其中m是插入的字符串数量,n是字符串的平均长度;如果有大量公共前缀,空间复杂度会大大降低。
七、常见疑问与注意点
1. 为什么用哈希表存储children?
因为哈希表可以快速判断某个字符对应的子节点是否存在,时间复杂度O(1)。如果用数组(比如大小为26,对应26个小写字母),虽然也能实现,但会浪费空间(比如很多字符用不到),而哈希表是“按需创建”,更节省内存。
2. isEnd的作用是什么?
isEnd是区分“完整单词”和“前缀”的关键。比如插入“app”和“apple”后,“app”的最后一个'p'节点isEnd=true,“apple”的最后一个'e'节点isEnd=true。这样search("app")会返回true,而search("appl")会返回false(因为'l'节点的isEnd是false)。
3. 这道题的边界情况有哪些?
-
插入空字符串?题目中word和prefix都是非空字符串,无需处理。
-
插入重复的单词?插入重复单词时,最后会再次将isEnd设为true,不影响结果(重复插入不改变Trie结构)。
-
前缀与完整单词相同?比如先插入“app”,再查询startsWith("app"),会返回true(符合预期)。
八、总结
LeetCode 208题是前缀树的基础实现题,核心在于理解Trie节点的结构(children和isEnd),以及“从根到叶”的遍历逻辑。通过抽离辅助方法searchPrefix,我们实现了代码的复用,让整个类的逻辑更清晰、更易维护。
掌握这道题后,你就能理解前缀树的核心原理,后续遇到更复杂的前缀树应用题(比如添加删除、模糊匹配),也能快速上手。建议大家动手敲一遍代码,模拟插入、查询的过程,加深对Trie的理解。