不知道你有没有这种感觉,一听到 "前缀树""字典树" 这种听起来很专业的名词,就先怂了一半。我之前刷到这道题的时候,盯着 "Trie" 这个单词看了半天,连发音都不确定,迟迟不敢下手。真硬着头皮写完才发现,害,就这?
题目说啥呢
题目的要求很简单,让我们自己实现一个 Trie 类。就三个功能:往里面插单词、查某个单词存不存在、查有没有单词以某个前缀开头。
看着平平无奇,用普通集合还真不好高效实现。
我一开始的偷懒写法
刚看题我还想钻空子。心想不就是存字符串吗,用个 Set 全装起来不就完了?search 直接调用 has 方法,startsWith 就遍历所有单词挨个判断。
小用例跑起来确实没问题,但仔细看数据规模,调用次数最多三万次。每次查前缀都遍历一遍所有单词,时间直接爆炸,而且完全偏离了这道题的考点。老老实实写正解吧。
前缀树到底是啥
其实前缀树的逻辑特别好懂。说白了就是一棵树,每个节点代表一个字母。从根节点往下走,一条路径拼起来就是一个单词。比如插入 "apple",就是根节点 → a → p → p → l → e 这么一条路。
光有路径还不够,得区分 "完整单词" 和 "前缀"。比如存了 "apple" 之后,"app" 是前缀但不是完整单词。所以每个节点还要加个小标记,告诉我们走到这里是不是一个单词的结尾。
这样不管是插入还是查询,顺着单词的字母一个个往下走就行,效率特别高。
代码实现
我用对象来存子节点,写起来好懂,不容易写错。
javascript
var Trie = function() {
// 初始化根节点,存子节点集合 + 是否是单词结尾
this.root = {
children: {},
isEnd: false
};
};
Trie.prototype.insert = function(word) {
// 每次操作都从根节点出发,别写外面去了,我踩过这个坑
let node = this.root;
for (let c of word) {
// 没有这个字母的子节点,就新建一个
if (!node.children[c]) {
node.children[c] = {
children: {},
isEnd: false
};
}
// 走到下一个节点
node = node.children[c];
}
// 走完整个单词,标记当前是单词结尾
node.isEnd = true; // 别问我怎么知道的,忘写这行我debug了十分钟
};
Trie.prototype.search = function(word) {
let node = this.root;
for (let c of word) {
// 走不通了,说明根本没这个单词
if (!node.children[c]) {
return false;
}
node = node.children[c];
}
// 走完字母还不够,必须是单词结尾才算匹配
// 比如存了apple,搜app能走完路径,但不是完整单词
return node.isEnd;
};
Trie.prototype.startsWith = function(prefix) {
let node = this.root;
for (let c of prefix) {
if (!node.children[c]) {
return false;
}
node = node.children[c];
}
// 前缀不用管结尾,能走完所有字母就说明存在
return true;
};
我踩过的两个坑
说两个我写的时候犯的傻错误,你们别再踩了。第一个就是 insert 最后忘加 isEnd 标记。结果搜什么单词都返回 false,我对着代码看了好几圈都没发现问题。最后反应过来的时候,真想给自己一下。
第二个坑更傻,我把当前节点 node 写到了构造函数里,当成全局变量了。插完一个单词,下一次插入就从上一个单词的结尾开始走,结果全乱套。记住,每次操作都要从 root 重新开始遍历。
复杂度分析
时间复杂度挺好算的,插入、搜索、查前缀都是 O (n),n 就是单词或者前缀的长度。空间的话最坏情况就是所有单词都没有公共前缀,存下所有字符就行。
最后唠两句
说真的,这题就是标准的 "纸老虎"。名字听起来吓人,实则逻辑特别清晰,写完一遍就能记住。它也是前缀树的模板题,搞懂了之后,什么自动补全、拼写检查的底层逻辑,你就都有数了。
你第一次写前缀树的时候踩过什么有意思的坑?或者有更简洁的写法?评论区聊聊呗,我都会回来看的。如果觉得这篇对你有用,点个赞让更多小伙伴看到呀~