Trie 字典树:前缀匹配利器

Trie 字典树:前缀匹配利器

1. 直观类比:按字母逐层翻找

查英文单词时你不会翻到词典中间去随机碰运气。你会先翻到首字母所在的章节,再按第二个字母缩小范围,逐层推进直到找到目标。Trie(字典树)的运作方式完全一样:从根节点开始,每次沿着一个字符向下走一层。不需要比较整个字符串,不需要哈希碰撞,也不依赖字符串长度。

2. 结构

Trie 是一棵多叉树。根节点不存字符,每条从根到叶子(或中间节点)的路径代表一个单词。每个节点通常存一个布尔值 isEnd 标记是否有单词在此结束,以及指向下一层子节点的指针集合。

往 Trie 插入 "cat"、"car"、"dog" 后的结构:
#mermaid-svg-fbxa3X5ZVJIpZqAj{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-fbxa3X5ZVJIpZqAj .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-fbxa3X5ZVJIpZqAj .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-fbxa3X5ZVJIpZqAj .error-icon{fill:#552222;}#mermaid-svg-fbxa3X5ZVJIpZqAj .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-fbxa3X5ZVJIpZqAj .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-fbxa3X5ZVJIpZqAj .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-fbxa3X5ZVJIpZqAj .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-fbxa3X5ZVJIpZqAj .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-fbxa3X5ZVJIpZqAj .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-fbxa3X5ZVJIpZqAj .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-fbxa3X5ZVJIpZqAj .marker{fill:#333333;stroke:#333333;}#mermaid-svg-fbxa3X5ZVJIpZqAj .marker.cross{stroke:#333333;}#mermaid-svg-fbxa3X5ZVJIpZqAj svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-fbxa3X5ZVJIpZqAj p{margin:0;}#mermaid-svg-fbxa3X5ZVJIpZqAj .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-fbxa3X5ZVJIpZqAj .cluster-label text{fill:#333;}#mermaid-svg-fbxa3X5ZVJIpZqAj .cluster-label span{color:#333;}#mermaid-svg-fbxa3X5ZVJIpZqAj .cluster-label span p{background-color:transparent;}#mermaid-svg-fbxa3X5ZVJIpZqAj .label text,#mermaid-svg-fbxa3X5ZVJIpZqAj span{fill:#333;color:#333;}#mermaid-svg-fbxa3X5ZVJIpZqAj .node rect,#mermaid-svg-fbxa3X5ZVJIpZqAj .node circle,#mermaid-svg-fbxa3X5ZVJIpZqAj .node ellipse,#mermaid-svg-fbxa3X5ZVJIpZqAj .node polygon,#mermaid-svg-fbxa3X5ZVJIpZqAj .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-fbxa3X5ZVJIpZqAj .rough-node .label text,#mermaid-svg-fbxa3X5ZVJIpZqAj .node .label text,#mermaid-svg-fbxa3X5ZVJIpZqAj .image-shape .label,#mermaid-svg-fbxa3X5ZVJIpZqAj .icon-shape .label{text-anchor:middle;}#mermaid-svg-fbxa3X5ZVJIpZqAj .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-fbxa3X5ZVJIpZqAj .rough-node .label,#mermaid-svg-fbxa3X5ZVJIpZqAj .node .label,#mermaid-svg-fbxa3X5ZVJIpZqAj .image-shape .label,#mermaid-svg-fbxa3X5ZVJIpZqAj .icon-shape .label{text-align:center;}#mermaid-svg-fbxa3X5ZVJIpZqAj .node.clickable{cursor:pointer;}#mermaid-svg-fbxa3X5ZVJIpZqAj .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-fbxa3X5ZVJIpZqAj .arrowheadPath{fill:#333333;}#mermaid-svg-fbxa3X5ZVJIpZqAj .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-fbxa3X5ZVJIpZqAj .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-fbxa3X5ZVJIpZqAj .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fbxa3X5ZVJIpZqAj .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-fbxa3X5ZVJIpZqAj .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fbxa3X5ZVJIpZqAj .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-fbxa3X5ZVJIpZqAj .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-fbxa3X5ZVJIpZqAj .cluster text{fill:#333;}#mermaid-svg-fbxa3X5ZVJIpZqAj .cluster span{color:#333;}#mermaid-svg-fbxa3X5ZVJIpZqAj div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-fbxa3X5ZVJIpZqAj .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-fbxa3X5ZVJIpZqAj rect.text{fill:none;stroke-width:0;}#mermaid-svg-fbxa3X5ZVJIpZqAj .icon-shape,#mermaid-svg-fbxa3X5ZVJIpZqAj .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fbxa3X5ZVJIpZqAj .icon-shape p,#mermaid-svg-fbxa3X5ZVJIpZqAj .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-fbxa3X5ZVJIpZqAj .icon-shape .label rect,#mermaid-svg-fbxa3X5ZVJIpZqAj .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fbxa3X5ZVJIpZqAj .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-fbxa3X5ZVJIpZqAj .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-fbxa3X5ZVJIpZqAj :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 根
c
a
t
r
(end)
(end)
d
o
g
(end)

"cat" 和 "car" 共享前缀 "ca"------这正是 Trie 最核心的资产:共享前缀

3. 核心操作

  • 插入 :从根出发,逐字符检查子节点是否存在,不存在则新建,到达最后一个字符时标记 isEnd = true
  • 搜索 :同样逐层查找,任何一层缺失则返回 false,走完所有字符后检查 isEnd
  • 前缀搜索 :和搜索几乎一样,只不过不需要检查 isEnd------只要路径存在就算匹配。

4. 两种实现

4.1 定长数组 Trie* children[26]

cpp 复制代码
#include <string>
using namespace std;

class Trie {
private:
    Trie* children[26];
    bool isEnd;

public:
    Trie() {
        isEnd = false;
        for (int i = 0; i < 26; ++i) children[i] = nullptr;
    }

    void insert(string word) {
        Trie* node = this;
        for (char c : word) {
            int idx = c - 'a';
            if (!node->children[idx]) node->children[idx] = new Trie();
            node = node->children[idx];
        }
        node->isEnd = true;
    }

    bool search(string word) {
        Trie* node = this;
        for (char c : word) {
            int idx = c - 'a';
            if (!node->children[idx]) return false;
            node = node->children[idx];
        }
        return node->isEnd;
    }

    bool startsWith(string prefix) {
        Trie* node = this;
        for (char c : prefix) {
            int idx = c - 'a';
            if (!node->children[idx]) return false;
            node = node->children[idx];
        }
        return true;
    }
};

4.2 HashMap 子节点 unordered_map<char, Trie*>

cpp 复制代码
#include <string>
#include <unordered_map>
using namespace std;

class Trie {
private:
    unordered_map<char, Trie*> children;
    bool isEnd;

public:
    Trie() : isEnd(false) {}

    void insert(string word) {
        Trie* node = this;
        for (char c : word) {
            if (!node->children.count(c))
                node->children[c] = new Trie();
            node = node->children[c];
        }
        node->isEnd = true;
    }

    bool search(string word) {
        Trie* node = this;
        for (char c : word) {
            if (!node->children.count(c)) return false;
            node = node->children[c];
        }
        return node->isEnd;
    }

    bool startsWith(string prefix) {
        Trie* node = this;
        for (char c : prefix) {
            if (!node->children.count(c)) return false;
            node = node->children[c];
        }
        return true;
    }
};

数组版本通过下标 c - 'a' 直接寻址,每次插入/查询固定 O(1) 跳转,内存固定 26 指针 × 节点数,大量稀疏分支浪费空间。HashMap 版本按需分配,适合 Unicode 或大数据量稀疏场景,但哈希计算稍慢。


6. C++ 完整实现

以下实现同时提供数组和 HashMap 两种策略,通过模板参数选择:

cpp 复制代码
#include <string>
#include <unordered_map>
using namespace std;

template <bool UseArray = true>
class Trie {
    // 仅作演示,实际应通过特化分离
};

// 特化:数组版本
template <>
class Trie<true> {
private:
    Trie* children[26];
    bool isEnd;
public:
    Trie() : isEnd(false) {
        for (int i = 0; i < 26; ++i) children[i] = nullptr;
    }
    void insert(string word) {
        Trie* node = this;
        for (char c : word) {
            int idx = c - 'a';
            if (!node->children[idx]) node->children[idx] = new Trie();
            node = node->children[idx];
        }
        node->isEnd = true;
    }
    bool search(string word) {
        Trie* node = this;
        for (char c : word) {
            int idx = c - 'a';
            if (!node->children[idx]) return false;
            node = node->children[idx];
        }
        return node->isEnd;
    }
    bool startsWith(string prefix) {
        Trie* node = this;
        for (char c : prefix) {
            int idx = c - 'a';
            if (!node->children[idx]) return false;
            node = node->children[idx];
        }
        return true;
    }
};

5. 复杂度

操作 时间复杂度 空间复杂度
插入 O(len) O(len)
搜索 O(len) O(1)
前缀搜索 O(len) O(1)
整体空间 --- O(总字符数 × 字符集大小)

len 为单词长度,与字典大小无关。这是 Trie 对比二叉搜索树和哈希表的核心优势:查询时间只取决于键的长度,不取决于键的数量

7. 面试真题

7.1 实现 Trie(LeetCode 208)

就是第 4 节的完整实现。唯一变化是 LeetCode 要求类名和接口分别为 TrieinsertsearchstartsWith,和上面一模一样。面试时注意:

  • 数组要初始化 nullptr
  • searchstartsWith 的区别在于最后是否检查 isEnd
  • delete 析构释放内存会给面试加分

7.2 单词搜索 II(LeetCode 212)------DFS + Trie 剪枝

在二维网格中找多个单词。暴力对每个单词做 DFS 会超时,正确做法是把所有单词建成 Trie,一次 DFS 同时匹配所有前缀:

cpp 复制代码
#include <vector>
#include <string>
using namespace std;

class TrieNode {
public:
    TrieNode* children[26];
    string word;
    TrieNode() : word("") {
        for (int i = 0; i < 26; ++i) children[i] = nullptr;
    }
};

class Solution {
private:
    vector<string> result;
    int dirs[5] = {0, 1, 0, -1, 0};

    void dfs(vector<vector<char>>& board, int i, int j, TrieNode* node) {
        char c = board[i][j];
        if (c == '#' || !node->children[c - 'a']) return;
        node = node->children[c - 'a'];
        if (node->word != "") {
            result.push_back(node->word);
            node->word = "";
        }
        board[i][j] = '#';
        for (int d = 0; d < 4; ++d) {
            int ni = i + dirs[d], nj = j + dirs[d + 1];
            if (ni >= 0 && ni < (int)board.size() && nj >= 0 && nj < (int)board[0].size())
                dfs(board, ni, nj, node);
        }
        board[i][j] = c;
    }

public:
    vector<string> findWords(vector<vector<char>>& board, vector<string>& words) {
        TrieNode* root = new TrieNode();
        for (const string& w : words) {
            TrieNode* cur = root;
            for (char c : w) {
                int idx = c - 'a';
                if (!cur->children[idx]) cur->children[idx] = new TrieNode();
                cur = cur->children[idx];
            }
            cur->word = w;
        }
        for (int i = 0; i < (int)board.size(); ++i)
            for (int j = 0; j < (int)board[0].size(); ++j)
                dfs(board, i, j, root);
        return result;
    }
};

为什么快? 一般的 DFS 每搜索一个单词就遍历一次全图。Trie 把单词的公共前缀合并,一次 DFS 走完一个前缀后,所有共享该前缀的单词都跟着前进了一步,相当于把 O(N × 4^L) 降到 O(4^L),N 是单词数,L 是单词平均长度。

7.3 最长公共前缀

在一组字符串中找到所有字符串共享的最长前缀:

cpp 复制代码
#include <string>
#include <vector>
using namespace std;

class TrieNode {
public:
    TrieNode* children[26];
    bool isEnd;
    TrieNode() : isEnd(false) {
        for (int i = 0; i < 26; ++i) children[i] = nullptr;
    }
};

string longestCommonPrefix(vector<string>& strs) {
    if (strs.empty()) return "";
    TrieNode* root = new TrieNode();
    for (const string& s : strs) {
        TrieNode* cur = root;
        for (char c : s) {
            int idx = c - 'a';
            if (!cur->children[idx]) cur->children[idx] = new TrieNode();
            cur = cur->children[idx];
        }
        cur->isEnd = true;
    }
    string res;
    TrieNode* cur = root;
    while (true) {
        int cnt = 0, nxt = -1;
        for (int i = 0; i < 26; ++i) {
            if (cur->children[i]) { ++cnt; nxt = i; }
        }
        if (cnt != 1 || cur->isEnd) break;
        res.push_back('a' + nxt);
        cur = cur->children[nxt];
    }
    return res;
}

逻辑:建完 Trie 后从根开始,每次只能有且仅有一个子分支(否则说明某个位置已出现分叉),且当前节点不能是单词结尾(否则前面的单词已经结束,不能再纳入公共前缀)。满足这两个条件就往下走。

7.4 前缀词频统计

扩展 Trie 节点,记录有多少单词经过(pass)和有多少单词在此结束(end):

cpp 复制代码
#include <string>
using namespace std;

class TrieNode {
public:
    TrieNode* children[26];
    int pass;
    int end;
    TrieNode() : pass(0), end(0) {
        for (int i = 0; i < 26; ++i) children[i] = nullptr;
    }
};

class Trie {
private:
    TrieNode* root;
public:
    Trie() { root = new TrieNode(); }

    void insert(string word) {
        TrieNode* node = root;
        node->pass++;
        for (char c : word) {
            int idx = c - 'a';
            if (!node->children[idx]) node->children[idx] = new TrieNode();
            node = node->children[idx];
            node->pass++;
        }
        node->end++;
    }

    int countPrefix(string prefix) {
        TrieNode* node = root;
        for (char c : prefix) {
            int idx = c - 'a';
            if (!node->children[idx]) return 0;
            node = node->children[idx];
        }
        return node->pass;
    }

    int countWord(string word) {
        TrieNode* node = root;
        for (char c : word) {
            int idx = c - 'a';
            if (!node->children[idx]) return 0;
            node = node->children[idx];
        }
        return node->end;
    }
};

应用场景 :搜索框的自动补全候选词排序,给输入法做词频统计等。countPrefix("ap") 返回所有以 "ap" 开头的单词总数。

7.5 单词替换(LeetCode 648)

给定一个词典(词根列表)和一个句子,把句子中每个单词替换为词典里最短的能匹配上的词根:

cpp 复制代码
#include <vector>
#include <string>
#include <sstream>
using namespace std;

class TrieNode {
public:
    TrieNode* children[26];
    bool isEnd;
    TrieNode() : isEnd(false) {
        for (int i = 0; i < 26; ++i) children[i] = nullptr;
    }
};

class Solution {
public:
    string replaceWords(vector<string>& dictionary, string sentence) {
        TrieNode* root = new TrieNode();
        for (const string& w : dictionary) {
            TrieNode* cur = root;
            for (char c : w) {
                int idx = c - 'a';
                if (!cur->children[idx]) cur->children[idx] = new TrieNode();
                cur = cur->children[idx];
            }
            cur->isEnd = true;
        }
        stringstream ss(sentence);
        string word, res;
        while (ss >> word) {
            if (!res.empty()) res += " ";
            TrieNode* cur = root;
            string prefix;
            bool found = false;
            for (char c : word) {
                int idx = c - 'a';
                if (!cur->children[idx]) break;
                cur = cur->children[idx];
                prefix += c;
                if (cur->isEnd) { res += prefix; found = true; break; }
            }
            if (!found) res += word;
        }
        return res;
    }
};

思路 :每个词根放入 Trie。遍历句子每个单词时,逐字符查 Trie,一旦遇到 isEnd 就说明找到了最短词根,立即替换。找不到就保留原词。

7.6 添加与搜索单词(支持通配符 .)(LeetCode 211)

支持 addWord 插入单词,search 查找单词且 . 可以匹配任意字符:

cpp 复制代码
#include <string>
using namespace std;

class WordDictionary {
private:
    WordDictionary* children[26];
    bool isEnd;

    bool dfs(string& word, int pos) {
        if (pos == (int)word.size()) return isEnd;
        char c = word[pos];
        if (c == '.') {
            for (int i = 0; i < 26; ++i)
                if (children[i] && children[i]->dfs(word, pos + 1))
                    return true;
            return false;
        }
        int idx = c - 'a';
        if (!children[idx]) return false;
        return children[idx]->dfs(word, pos + 1);
    }

public:
    WordDictionary() : isEnd(false) {
        for (int i = 0; i < 26; ++i) children[i] = nullptr;
    }

    void addWord(string word) {
        WordDictionary* node = this;
        for (char c : word) {
            int idx = c - 'a';
            if (!node->children[idx]) node->children[idx] = new WordDictionary();
            node = node->children[idx];
        }
        node->isEnd = true;
    }

    bool search(string word) {
        return dfs(word, 0);
    }
};

通配符处理 :遇到 . 时枚举 26 个孩子递归搜索。这个递归看起来 O(26^L) 很吓人,但在 Trie 剪枝下实际运行的路径数 = Trie 中存在的路径数,远小于指数上界。面试时一定要解释这个剪枝原理。


总结一句话:遇到前缀匹配类的问题,第一反应想想 Trie。哈希表做不到前缀搜索,二叉搜索树做不到 O(len) 查询,只有 Trie 用空间换时间,把字符串比较变成了逐层跳转。

相关推荐
zh_xuan1 小时前
c++ std::Any 用法
开发语言·c++·any
小杍随笔1 小时前
【Rust全栈项目接入UUID主键实战:从选型到PostgreSQL适配(含v7优化)】
开发语言·postgresql·rust
binbin_522 小时前
Flutter 调用鸿蒙原生组件:MethodChannel 与 PlatformView 的选择和落地
开发语言·深度学习·flutter·harmonyos
无限的鲜花10 小时前
反射(原创推荐)
java·开发语言
yongche_shi11 小时前
ragas官方文档中文版(五十)
开发语言·python·ai·ragas·如何评估和改进 rag 应用
一路向北he11 小时前
字节钢铁军团--“提供情境,而非控制”
java·开发语言·前端
AI行业学习12 小时前
Notepad++ 官方下载 + 完整安装 + 全套优化配置(2026最新)
开发语言·人工智能·python·前端框架·html·notepad++
大圣编程13 小时前
Python中continue语句的用法是什么?
开发语言·前端·python
upgrador13 小时前
基础知识:C++ STL构造函数的左闭右开惯例及其实现原理
开发语言·c++