LeetCode 208 是我用 JavaScript 刷算法题到第十九天时遇到的一道结构题。在这之前我脑子里对 Trie 的印象只是“字典树”“可以做前缀查找”,但真到要求自己在几十行代码里实现它,我发现有几个点没想清楚根本写不顺:节点上该存字符还是边存字符?路径走到头之后靠什么确认“这是一个完整单词”?搜索单词和搜索前缀到底差在哪一行?这篇分享就用 JavaScript 把 208 完整做一遍,连同那些题解里很少展开的容器选型、删除扩展和自动补全一起聊透。不管你是准备面试,还是想在项目里做输入框联想,都可以拿这篇文章当参考。
1. 为什么前缀匹配用“一张词表”会力不从心
1.1 只存完整单词:查单词很快,查前缀很慢
很多人看到 208 的第一反应是:我直接用 Set 把所有单词存起来不就行了吗?查询某个单词是否存在是 O(1),为什么不这样做?
单独看“单词是否存在”,Set 确实没问题。但题目明确要求了 startsWith(prefix),也就是判断是否存在某个以指定前缀开头的单词。这时候问题就暴露了:Set 只保存完整的单词,它没有办法回答“这个前缀片段后面还可能接什么”。
在没有额外索引的情况下,你只能遍历整张词表:
javascript复制class WordStore {
constructor(words) {
this.words = new Set(words);
}
hasWord(word) {
return this.words.has(word);
}
hasPrefix(prefix) {
for (const word of this.words) {
if (word.startsWith(prefix)) return true;
}
return false;
}
}
这个实现本身没问题,问题在复杂度上。如果词表里有 10 万个词,每次 hasPrefix 最坏都要遍历 10 万次,每次还要做一次字符串前缀比较,整体就是 O(词表长度 × 单词平均长度)。在 LeetCode 的测试数据面前,这个复杂度几乎不可能通过。
更重要的是,真实业务里的输入框联想不是只问“有没有这个前缀”,而是要把“所有以这个前缀开头的词”都列出来。用 Set 配合遍历虽然也能做到,但代价更大,而且代码写起来很别扭。
1.2 把“所有前缀”也塞进 Set 是一条歧路
如果只追求前缀匹配快,还有一个看起来很聪明的思路:插入单词的时候,顺便把它的所有前缀都存进 Set。
例如插入 apple,就存 a、ap、app、appl、apple。这样查询前缀时依然是哈希查找,O(1) 就出结果。
javascript复制class PrefixSet {
constructor() {
this.map = new Set();
}
insert(word) {
for (let i = 1; i <= word.length; i++) {
this.map.add(word.slice(0, i));
}
}
search(word) {
return this.map.has(word);
}
startsWith(prefix) {
return this.map.has(prefix);
}
}
这个方案有一个致命问题:每个词的存储量从 O(L) 变成了 O(L²)。词典里 10 万个平均长度 10 的单词,会产生差不多 550 万个前缀串。这些字符串本身要占用内存,Set 内部的哈希结构还会有额外开销。数据量稍微大一点,内存就会非常难看。
另外,这个方案依旧回答不了“把所有以 app 开头的词打印出来”这种需求,因为你只存了前缀,没有把“前缀 → 完整词集合”的关系维护下来。
而 Trie 解决这两个问题的思路很直接:有共同前缀的单词共享同一条路径,查询时只需要顺着路径走一遍。刚才提到的两个问题,一个被共享结构消化了,一个被树的 DFS 遍历解决了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 手写 Trie 第一步:节点设计、插入、搜索和前缀匹配各做什么
2.1 节点里到底放什么:children 与 isEnd
网上很多 Trie 教程会把节点写得很花哨,其实核心只有两个字段:
- children:保存当前节点下面挂着的子节点。键是字符,值是下一个 Trie 节点。
- isEnd:标记“从根节点走到这里,是否构成了一个完整单词”。
有一个新手很容易卡住的问题:节点里要不要保存自己的字符?
答案是不需要。路径本身已经代表字符。比如根节点往下走一条 a 的边,再走一条 p 的边,那当前节点代表的前缀就是 ap。它不需要在自己身上再存一个 p,因为字符写在父节点到子节点的连接上,节点只需要知道“我下面有哪些分支”。
你可以把 Trie 理解成一棵树,每个单词是一根从根出发的树枝。树上的分叉点就是字符不一样的位置,分叉点之间的每段路径,都是若干个单词共享的前缀。
用 JavaScript 最通用的写法,节点长这样:
javascript复制class TrieNode {
constructor() {
this.children = new Map();
this.isEnd = false;
}
}
关于 children 用什么容器,我后面会专门展开讲。这里先继续理顺操作逻辑。
2.2 插入:沿着路径找到或造出节点,最后打上“单词结束”标记
插入 word 的逻辑很直观:
- 从根节点出发。
- 遍历
word的每个字符。 - 如果当前节点的 children 里没有这个字符,就新建一个 TrieNode 放进去。
- 如果已经有,就直接走过去。
- 遍历结束后,把当前节点的
isEnd设为 true。
这步可以对照刚才的 PrefixSet 方案理解:Trie 不是把每个前缀复制一份,而是让相同前缀都走同一条路径。每个节点只需要创建一次,后面来多少带相同前缀的单词,都只是沿着既有路径继续往下走而已。
javascript复制insert(word) {
let node = this.root;
for (const ch of word) {
if (!node.children.has(ch)) {
node.children.set(ch, new TrieNode());
}
node = node.children.get(ch);
}
node.isEnd = true;
}
这里有一个很容易忽视的点:isEnd = true 只放在最后一个字符对应的节点上,不能在插入过程中顺手把所有经过节点都标成 true。
假如你插入 apple 时把 a、p、p、l、e 这些路径节点全部标成 true,那么后面 search("app") 就会错误地返回 true。因为 app 虽然存在路径,但它并不是你真正插入过的完整单词。
2.3 搜索单词:必须验证“结束标记”
搜索逻辑和插入前半段一样,也是从根向下走。如果某个字符在 children 中不存在,说明树里根本没有这个单词。
关键区别在最后:走到单词末尾字符对应节点后,不能直接返回 true,还要检查这个节点的 isEnd。
javascript复制search(word) {
let node = this.root;
for (const ch of word) {
if (!node.children.has(ch)) return false;
node = node.children.get(ch);
}
return node.isEnd === true;
}
为什么必须检查 isEnd?因为 Trie 中有路径不代表路径末端是一个单词。插入 apple 之后,app 这段路径是存在的,但 app 本身不是一个输入过的单词。如果不检查 isEnd,search("app") 会被误判成存在。
2.4 匹配前缀:和搜索单词只差一行判断
startsWith(prefix) 的实现和 search 几乎一样,唯一区别是最后不需要检查 isEnd。只要前缀对应的路径能够顺利走完,就可以认为存在至少一个以该前缀开头的单词。
javascript复制startsWith(prefix) {
let node = this.root;
for (const ch of prefix) {
if (!node.children.has(ch)) return false;
node = node.children.get(ch);
}
return true;
}
我用一个例子把三个操作的关系讲清楚。假设只执行 insert("apple"):
| 操作 | 结果 | 原因 |
|---|---|---|
| search("apple") | true | 路径存在,且 apple 节点 isEnd 为 true |
| search("app") | false | 路径存在,但 app 节点 isEnd 为 false |
| startsWith("app") | true | 路径存在,不需要检查 isEnd |
这组区别就是 208 的核心考点。很多代码跑不过,不是 Trie 写错了,而是在 search 里忘了那个结束标记。
3. 一个很多人忽略的分水岭:children 用对象、Map 还是数组
3.1 Map 版本:通用且天然避开原型链问题
前文代码用的是 Map,这也是我在非 LeetCode 场景下最喜欢的实现方式。Map 最大的优势有三个:
- key 可以是任意字符,不局限于小写字母。
- 不会把
Object.prototype上的属性误当成 Trie 子节点。 - 自带
size,判断“当前节点有没有子节点”特别方便,这对后续实现删除操作很重要。
如果你在写一个处理中英文混杂词汇、URL、用户自定义 ID 的通用前缀匹配模块,直接用 Map 最稳,不需要额外考虑字符范围限制。
Map 版本的搜索也需要注意 API 使用:判断子节点存在要用 has,取子节点用 get,新增用 set。很多人刚上手时会下意识写 node.children.get(ch) 然后判断非空,但严谨的写法是先 has 再 get,这样思路更清晰,也不会出现把 TrieNode 之外的值当节点用的风险。
3.2 数组版本:LeetCode 208 里最直接的做法
LeetCode 208 的题目约束里明确写了:所有字符只包含小写英文字母。既然字母范围固定成 26 个,就可以直接用数组做 children,用字符的 ASCII 码做下标。
charCodeAt 是 JavaScript 里取字符编码的方法。'a'.charCodeAt(0) 是 97,所以任意一个小写字母的索引就是 char.charCodeAt(0) - 97:
javascript复制class TrieNode {
constructor() {
this.children = new Array(26).fill(null);
this.isEnd = false;
}
}
class Trie {
constructor() {
this.root = new TrieNode();
}
charToIndex(ch) {
return ch.charCodeAt(0) - 97;
}
insert(word) {
let node = this.root;
for (const ch of word) {
const index = this.charToIndex(ch);
if (!node.children[index]) {
node.children[index] = new TrieNode();
}
node = node.children[index];
}
node.isEnd = true;
}
search(word) {
let node = this.root;
for (const ch of word) {
const index = this.charToIndex(ch);
if (!node.children[index]) return false;
node = node.children[index];
}
return node.isEnd === true;
}
startsWith(prefix) {
let node = this.root;
for (const ch of prefix) {
const index = this.charToIndex(ch);
if (!node.children[index]) return false;
node = node.children[index];
}
return true;
}
}
数组版本的查询完全依赖数组下标,访问速度比 Map 或者对象哈希更快。每个新节点都固定开 26 个槽位,虽然看起来有点浪费,但对只有 26 个字母的场景来说,这个开销完全可控,而且结构简单,不容易出现原型链问题。
我在 LeetCode 上提交时,大多数情况下会用数组版本。内存占用不会成为瓶颈,代码运行时间也更稳定。
如果题目没有限定字符集,比如单词里可能有大写、数字、空格甚至中文,那数组版按字符编码硬套就会很别扭。遇到这种情况请回到 Map 版本。
3.3 非要用普通对象 {} 也可以,但要提前把责任分清
很多教程喜欢把 children 写成普通对象:
javascript复制class TrieNode {
constructor() {
this.children = {};
this.isEnd = false;
}
}
代码也能跑。但由于普通对象继承了 Object.prototype,如果你查询的键恰好和原型链上的属性名冲突,就可能出现误判。虽然 208 里只涉及 a-z 单字符键,不容易触发这个问题,可一旦你把这个 Trie 拿去做更通用的匹配,风险就真实存在了。
一个更安全的折中方案是用 Object.create(null),创建一个完全没有原型链的干净对象:
javascript复制this.children = Object.create(null);
这样写能绕开继承属性带来的干扰,同时保持着对象写法那种轻量感。但对象方案还要面对另一个麻烦:判断当前节点是否没有子节点时,对象没有 Map 那样的 size,只能
