LeetCode 208. 实现 Trie (前缀树):从原理到代码落地

0 阅读8分钟

在刷LeetCode的过程中,前缀树(Trie)是一个高频且实用的数据结构,尤其在字符串的存储、检索、自动补全场景中发挥着巨大作用。今天我们就来手把手拆解 LeetCode 208. 实现 Trie (前缀树) 这道题,从原理理解到代码实现,再到细节优化,帮你彻底搞懂前缀树的核心逻辑。

一、什么是Trie(前缀树)?

Trie(发音类似“try”),也叫前缀树或字典树,是一种专门用于处理字符串匹配的数据结构。它的核心特点是利用字符串的公共前缀来节省存储空间,同时实现高效的插入、查询操作。

举个简单的例子:如果我们要存储 “apple”、“app”、“apricot” 这三个字符串,用Trie存储时,它们会共享 “ap” 这个公共前缀,而不是每个字符串单独存储完整字符,这样既能减少内存占用,也能让查询时更快定位到目标字符串。

Trie的应用场景非常广泛,比如:

  • 搜索引擎的自动补全(输入“ap”,联想出“apple”“app”等)

  • 拼写检查(判断输入的单词是否存在于词库中)

  • 通讯录的快速检索(根据姓名前缀查找联系人)

二、题目要求拆解

题目要求我们实现一个Trie类,包含以下4个核心方法,我们先明确每个方法的职责:

  1. Trie(): 初始化前缀树对象,相当于创建一个空的根节点。

  2. insert(String word): 向前缀树中插入一个完整的字符串word,插入后该字符串会作为一个“完整单词”被标记。

  3. search(String word): 检索字符串word是否已经插入到前缀树中(必须是完整的单词,而非前缀),存在则返回true,否则返回false。

  4. startsWith(String prefix): 检索是否有已插入的字符串以prefix为前缀(不需要是完整单词),存在则返回true,否则返回false。

三、核心思路:Trie节点的设计

要实现Trie,首先要明确Trie节点的结构。每个Trie节点包含两个核心属性:

  1. children(子节点集合): 存储当前节点的所有子节点,key是单个字符(比如'a'、'p'),value是对应的子Trie节点。这里用哈希表(Record<string, Trie>)来实现,能快速判断某个字符对应的子节点是否存在。

  2. 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的理解。