【LeetCode Hot100 刷题日记 (53/100)】208. 实现 Trie (前缀树) —— 字典树(Trie)🌳

11 阅读6分钟

📌 题目链接:208. 实现 Trie (前缀树) - 力扣(LeetCode)

🔍 难度:中等 | 🏷️ 标签:设计字典树(Trie)字符串

⏱️ 目标时间复杂度:插入/搜索/前缀查询均为 O(L) ,其中 L 为字符串长度

💾 空间复杂度:O(N × L × Σ) ,N 为插入单词总数,L 为平均长度,Σ = 26(小写字母)


🌳在海量字符串处理场景中,如搜索引擎的自动补全、拼写检查、IP 路由表查找等,Trie(前缀树 / 字典树) 是一种极其高效的数据结构。它通过共享公共前缀的方式,显著减少存储开销并加速查询。本题要求我们从零实现一个支持插入、精确搜索和前缀匹配的 Trie,是理解高级字符串结构的基石。

💡 面试高频考点:Trie 不仅常出现在系统设计题(如“设计一个单词补全系统”),也是考察面向对象设计能力指针/引用管理的经典题目。务必掌握其结构、操作逻辑及内存优化思路。


🔧 题目分析

我们需要实现一个 Trie 类,支持以下三个核心操作:

  1. insert(word) :将一个单词插入到 Trie 中。
  2. search(word) :判断该单词是否完整存在于 Trie 中(即曾被插入过)。
  3. startsWith(prefix) :判断是否存在某个已插入单词以 prefix 为前缀(不要求完整匹配)。

关键区别:

  • search 要求路径终点节点的 isEnd == true
  • startsWith 只需路径存在即可,不要求 isEnd

示例回顾

insert("apple") → search("apple") = true
search("app") = false(因为 "app" 未被完整插入)
startsWith("app") = true(因为 "apple" 以 "app" 开头)
insert("app") → search("app") = true

🧠 核心算法及代码讲解:字典树(Trie)

📚 什么是 Trie?

Trie 是一棵多叉树,每个节点代表一个字符。从根到某节点的路径构成一个字符串前缀。典型结构包含:

  • children[26] :指向 26 个小写字母子节点的指针数组(可用 vector<Trie*> 或固定数组实现)。
  • isEnd:布尔标志,标记当前节点是否为某个单词的结尾。

🎯 为什么用 Trie?

  • 普通哈希表(如 unordered_set)虽然 search 快,但无法高效支持前缀查询
  • Trie 的前缀共享特性使得空间更紧凑(如 "apple" 和 "app" 共享前三个节点)。
  • 所有操作时间复杂度仅与字符串长度相关,与总词数无关!

🔍 辅助函数:searchPrefix

为避免代码重复,我们封装一个私有函数 searchPrefix,用于遍历前缀路径并返回终点节点(若存在):

Trie* searchPrefix(string prefix) {
    Trie* node = this; // 从当前对象(根)开始
    for (char ch : prefix) {
        ch -= 'a'; // 转换为 0~25 的索引
        if (node->children[ch] == nullptr) {
            return nullptr; // 路径中断,前缀不存在
        }
        node = node->children[ch]; // 移动到子节点
    }
    return node; // 返回前缀终点节点
}

⚠️ 注意:此函数不检查 isEnd,仅验证路径是否存在。

➕ 插入操作 insert

逐字符遍历 word,若子节点不存在则新建,最后标记 isEnd = true

void insert(string word) {
    Trie* node = this;
    for (char ch : word) {
        ch -= 'a';
        if (node->children[ch] == nullptr) {
            node->children[ch] = new Trie(); // 动态创建新节点
        }
        node = node->children[ch];
    }
    node->isEnd = true; // 标记单词结束
}

💡 内存管理提示:实际工程中应考虑析构函数释放内存(本题 LeetCode 不要求,但面试可提及)。

🔎 精确搜索 search

调用 searchPrefix 获取终点节点,再检查 isEnd

bool search(string word) {
    Trie* node = this->searchPrefix(word);
    return node != nullptr && node->isEnd; // 路径存在 + 是单词结尾
}

🔤 前缀匹配 startsWith

只需路径存在即可:

bool startsWith(string prefix) {
    return this->searchPrefix(prefix) != nullptr;
}

🧩 解题思路(分步拆解)

  1. 定义 Trie 节点结构

    • 成员变量:vector<Trie*> children(26, nullptr) + bool isEnd = false
    • 构造函数初始化 childrenisEnd
  2. 实现通用前缀查找器 searchPrefix

    • 遍历字符串每个字符
    • 若对应子节点为空,返回 nullptr
    • 否则沿指针向下移动
    • 返回最终节点指针
  3. 实现 insert

    • 复用 searchPrefix 的遍历逻辑
    • 遇空则新建节点
    • 最后设置 isEnd = true
  4. 实现 searchstartsWith

    • search:调用 searchPrefix + 检查 isEnd
    • startsWith:仅调用 searchPrefix 并判非空

关键思想复用逻辑!通过 searchPrefix 避免三处重复遍历代码。


📊 算法分析

操作时间复杂度空间复杂度(单次)
初始化O(1)O(26) ≈ O(1)
insertO(L)O(L × 26)(最坏)
searchO(L)O(1)
startsWithO(L)O(1)
  • L:输入字符串长度
  • 总空间复杂度:O(T × Σ),T 为所有插入字符串的总字符数,Σ = 26

🌟 优势:相比哈希表 O(1) 平均查找,Trie 在前缀操作上具有不可替代性,且无哈希冲突问题。


💻 代码实现

C++

#include <bits/stdc++.h>
using namespace std;
using ll = long long;

class Trie {
private:
    vector<Trie*> children;
    bool isEnd;

    // 辅助函数:查找 prefix 对应的终点节点,若不存在则返回 nullptr
    Trie* searchPrefix(string prefix) {
        Trie* node = this; // 从当前节点(根)开始
        for (char ch : prefix) {
            ch -= 'a'; // 将字符映射到 0~25
            if (node->children[ch] == nullptr) {
                return nullptr; // 路径中断
            }
            node = node->children[ch]; // 移动到子节点
        }
        return node; // 返回前缀终点
    }

public:
    // 构造函数:初始化 26 个子节点为空,isEnd 为 false
    Trie() : children(26), isEnd(false) {}

    // 插入单词:逐字符创建节点,最后标记 isEnd
    void insert(string word) {
        Trie* node = this;
        for (char ch : word) {
            ch -= 'a';
            if (node->children[ch] == nullptr) {
                node->children[ch] = new Trie(); // 新建节点
            }
            node = node->children[ch];
        }
        node->isEnd = true; // 标记单词结束
    }

    // 精确搜索:路径存在 且 是单词结尾
    bool search(string word) {
        Trie* node = this->searchPrefix(word);
        return node != nullptr && node->isEnd;
    }

    // 前缀匹配:只需路径存在
    bool startsWith(string prefix) {
        return this->searchPrefix(prefix) != nullptr;
    }
};

// 测试
signed main(){
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cout.tie(nullptr);

    // 示例测试用例
    Trie trie;
    trie.insert("apple");
    cout << (trie.search("apple") ? "true" : "false") << "\n";     // true
    cout << (trie.search("app") ? "true" : "false") << "\n";       // false
    cout << (trie.startsWith("app") ? "true" : "false") << "\n";   // true
    trie.insert("app");
    cout << (trie.search("app") ? "true" : "false") << "\n";       // true

    return 0;
}

JavaScript

var Trie = function() {
    this.children = new Array(26).fill(null);
    this.isEnd = false;
};

/** 
 * @param {string} word
 * @return {void}
 */
Trie.prototype.insert = function(word) {
    let node = this;
    for (let ch of word) {
        const idx = ch.charCodeAt(0) - 'a'.charCodeAt(0);
        if (node.children[idx] === null) {
            node.children[idx] = new Trie();
        }
        node = node.children[idx];
    }
    node.isEnd = true;
};

/**
 * @param {string} word
 * @return {boolean}
 */
Trie.prototype.search = function(word) {
    const node = this.searchPrefix(word);
    return node !== null && node.isEnd;
};

/**
 * @param {string} prefix
 * @return {boolean}
 */
Trie.prototype.startsWith = function(prefix) {
    return this.searchPrefix(prefix) !== null;
};

// 辅助方法:查找前缀路径
Trie.prototype.searchPrefix = function(prefix) {
    let node = this;
    for (let ch of prefix) {
        const idx = ch.charCodeAt(0) - 'a'.charCodeAt(0);
        if (node.children[idx] === null) {
            return null;
        }
        node = node.children[idx];
    }
    return node;
};

// 测试(可在浏览器或 Node.js 中运行)
const trie = new Trie();
trie.insert("apple");
console.log(trie.search("apple"));     // true
console.log(trie.search("app"));       // false
console.log(trie.startsWith("app"));   // true
trie.insert("app");
console.log(trie.search("app"));       // true

🌟 本期完结,下期见!🔥

👉 点赞收藏加关注,新文更新不迷路。关注专栏【算法】LeetCode Hot100刷题日记,持续为你拆解每一道热题的底层逻辑与面试技巧!

💬 欢迎留言交流你的解法或疑问!一起进步,冲向 Offer!💪

📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!