DSA 面试精讲 · Trie 前缀树:一文掌握字符串前缀匹配

摘要

Trie(前缀树/字典树)是字符串处理领域的核心数据结构,专为高效前缀搜索而设计。本文从 LeetCode 208(基础实现)到 211(通配符进阶),深入讲解 Trie 的节点设计、三大核心操作、DFS 回溯搜索,并提供 Python/Java/C++ 三语言代码实现。掌握 Trie,轻松应对字符串前缀匹配类面试题。


一、从堆到前缀树

第 17 篇我们聊了堆(Heap),它帮我们高效维护动态 Top K 集合。无论是求数据流中的第 K 大元素,还是合并 K 个有序链表,堆都用 O(log K) 的插入和删除成本优雅地解决了问题。

现在我们要面对一个完全不同的问题:如何高效地搜索和匹配具有公共前缀的字符串?

想想搜索引擎的输入框------当你输入 "app" 时,下拉菜单立刻弹出 "apple"、"application"、"appointment"。哈希表能做到 O(1) 精确查找,但让它查前缀------得遍历所有单词,O(N) 太慢了。二叉搜索树同样不行,按字典序排序后前缀搜索仍需遍历。

我们需要一个为前缀搜索而生的数据结构------Trie(前缀树,也叫字典树)。它把字符串集合组织成一棵多叉树,让前缀搜索的时间复杂度降到 O(L),其中 L 是字符串长度,和集合大小 N 无关。


二、Trie 是什么?

Trie 是一棵多叉树,每个节点代表一个字符,根节点为空。从根节点到某个节点的路径拼接起来,就是一个字符串。

下面是一棵包含 "cat"、"car"、"dog" 三个单词的 Trie:

三个关键特征

  1. 根节点不存储字符:它是空入口,所有单词从根的孩子开始

  2. 公共前缀共享路径:"cat" 和 "car" 共享 "ca" 路径,这是 Trie 节省空间的根本

  3. isEnd 标记:每个节点用布尔值标记"从根到当前节点是否构成一个完整单词"

方案对比:Trie vs HashSet vs BST

对比维度 HashSet BST Trie(✅ 最优)
精确匹配 O(1) O(L log N) O(L)
前缀搜索 ❌ O(N) ❌ O(N log N) ✅ O(L)
通配符搜索 ❌ 不支持 ❌ 不支持 ✅ 支持(DFS)
空间复杂度 O(N) O(N) O(ΣL)
实现复杂度

核心结论 :如果你只需要精确匹配,HashSet 更快。但一旦涉及前缀搜索,Trie 是唯一能在 O(L) 时间内完成的数据结构。


三、LeetCode 208:Implement Trie (Prefix Tree)

问题描述

实现一个 Trie,包含三个方法:

  • insert(word):将单词插入 Trie

  • search(word):返回单词是否在 Trie 中

  • startsWith(prefix):返回是否有任何单词以该前缀开头

TrieNode 设计

每个节点需要两个东西:一个指向子节点的数组,和一个布尔标记。因为题目限定小写字母,我们用一个长度为 26 的数组就够了。

复制代码
# Python
class TrieNode:
    def __init__(self):
        self.children = [None] * 26
        self.isEnd = False

// Java
class TrieNode {
    TrieNode[] children = new TrieNode[26];
    boolean isEnd = false;
}

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

三个核心方法

insert(插入) :逐字符遍历,从根节点开始。如果当前字符对应的子节点不存在,就创建一个新节点。遍历到最后一个字符时,标记 isEnd = true

复制代码
# Python
class Trie:
    def __init__(self):
        self.root = TrieNode()

    def insert(self, word: str) -> None:
        node = self.root
        for ch in word:
            idx = ord(ch) - ord('a')
            if not node.children[idx]:
                node.children[idx] = TrieNode()
            node = node.children[idx]
        node.isEnd = True

// Java
class Trie {
    private TrieNode root;

    public Trie() {
        root = new TrieNode();
    }

    public void insert(String word) {
        TrieNode node = root;
        for (char c : word.toCharArray()) {
            int idx = c - 'a';
            if (node.children[idx] == null) {
                node.children[idx] = new TrieNode();
            }
            node = node.children[idx];
        }
        node.isEnd = true;
    }
}

// C++
class Trie {
private:
    TrieNode* root;

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

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

search(搜索) :逐字符查找,如果路径中断,直接返回 false。遍历完所有字符后,检查当前节点的 isEnd

startsWith(前缀搜索) :和 search 几乎一样,唯一的区别是不检查 isEnd

复制代码
# Python
def search(self, word: str) -> bool:
    node = self._find(word)
    return node is not None and node.isEnd

def startsWith(self, prefix: str) -> bool:
    return self._find(prefix) is not None

def _find(self, prefix: str):
    node = self.root
    for ch in prefix:
        idx = ord(ch) - ord('a')
        if not node.children[idx]:
            return None
        node = node.children[idx]
    return node

// Java
public boolean search(String word) {
    TrieNode node = find(word);
    return node != null && node.isEnd;
}

public boolean startsWith(String prefix) {
    return find(prefix) != null;
}

private TrieNode find(String prefix) {
    TrieNode node = root;
    for (char c : prefix.toCharArray()) {
        int idx = c - 'a';
        if (node.children[idx] == null) return null;
        node = node.children[idx];
    }
    return node;
}

// C++
bool search(string word) {
    TrieNode* node = find(word);
    return node != nullptr && node->isEnd;
}

bool startsWith(string prefix) {
    return find(prefix) != nullptr;
}

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

时间复杂度分析

操作 时间复杂度 空间复杂度
insert O(L) O(L)
search O(L) O(1)
startsWith O(L) O(1)

三个方法都是 O(L) 时间,L 是单词/前缀长度。插入和搜索的时间与 Trie 中存储的单词总数 N 无关

面试点睛

面试官常问的一个坑:searchstartsWith 的区别是什么?

答案是:search 要检查 isEndstartsWith 不用。插入 "apple" 后,search("app") 返回 false("app" 不是完整单词),但 startsWith("app") 返回 true(有单词以 "app" 开头)。


问题升级

现在题目升级了------搜索时支持通配符 .,它可以匹配任意一个字符。

例如,搜索 "c.t" 可以匹配 "cat"、"cot"、"cut"。搜索 "..t" 可以匹配所有以 't' 结尾的三字母单词。

插入逻辑不变

插入和 208 完全一样,直接复用。

搜索逻辑:Trie + DFS 回溯

遇到普通字符时,逻辑和 208 一样------精确查找子节点。但遇到 . 时,我们需要遍历当前节点的所有 26 个子节点,只要有一条路径能匹配剩余字符,就返回 true。

下面是一棵插入了 "bad" 和 "dad" 的 Trie:

DFS 递归搜索实现

复制代码
# Python
class WordDictionary:
    def __init__(self):
        self.root = TrieNode()

    def addWord(self, word: str) -> None:
        node = self.root
        for ch in word:
            idx = ord(ch) - ord('a')
            if not node.children[idx]:
                node.children[idx] = TrieNode()
            node = node.children[idx]
        node.isEnd = True

    def search(self, word: str) -> bool:
        return self._dfs(word, 0, self.root)

    def _dfs(self, word: str, idx: int, node: TrieNode) -> bool:
        if idx == len(word):
            return node.isEnd

        ch = word[idx]
        if ch == '.':
            for child in node.children:
                if child and self._dfs(word, idx + 1, child):
                    return True
            return False
        else:
            child = node.children[ord(ch) - ord('a')]
            if child:
                return self._dfs(word, idx + 1, child)
            return False

// Java
class WordDictionary {
    private TrieNode root;

    public WordDictionary() {
        root = new TrieNode();
    }

    public void addWord(String word) {
        TrieNode node = root;
        for (char c : word.toCharArray()) {
            int idx = c - 'a';
            if (node.children[idx] == null) {
                node.children[idx] = new TrieNode();
            }
            node = node.children[idx];
        }
        node.isEnd = true;
    }

    public boolean search(String word) {
        return dfs(word, 0, root);
    }

    private boolean dfs(String word, int idx, TrieNode node) {
        if (idx == word.length()) return node.isEnd;

        char c = word.charAt(idx);
        if (c == '.') {
            for (TrieNode child : node.children) {
                if (child != null && dfs(word, idx + 1, child)) {
                    return true;
                }
            }
            return false;
        } else {
            TrieNode child = node.children[c - 'a'];
            if (child == null) return false;
            return dfs(word, idx + 1, child);
        }
    }
}

// C++
class WordDictionary {
private:
    TrieNode* root;

    bool dfs(const string& word, int idx, TrieNode* node) {
        if (idx == word.length()) return node->isEnd;

        char c = word[idx];
        if (c == '.') {
            for (int i = 0; i < 26; i++) {
                if (node->children[i] && dfs(word, idx + 1, node->children[i])) {
                    return true;
                }
            }
            return false;
        } else {
            TrieNode* child = node->children[c - 'a'];
            if (!child) return false;
            return dfs(word, idx + 1, child);
        }
    }

public:
    WordDictionary() {
        root = new TrieNode();
    }

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

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

DFS 搜索 "c.t" 的回溯过程

下面用流程图展示搜索 "c.t" 时 DFS 的完整回溯路径。假设 Trie 中已插入 "cat"、"cot"、"cut":

时间复杂度分析

操作 时间复杂度
addWord O(L)
search(无通配符) O(L)
search(有通配符) 最坏 O(26^K),K = 通配符数量

最坏情况:搜索 "......"(6 个通配符),每个位置都要尝试 26 种可能,即 26^6 ≈ 3 亿次。但实际中 Trie 的结构会限制分支数------如果某个节点只有 3 个子节点,就只遍历 3 条路径,远小于 26。所以实际运行时间远好于最坏情况


五、面试延伸

1. Word Break(139)--- Trie + DP

给定一个字符串和一个单词集合,判断字符串能否被分割成单词序列。

用 Trie 存储单词集合,在 DP 中判断每个子串是否在 Trie 中。朴素 DP 的 O(n²) 子串查找可以用 Trie 优化为 O(n × maxLen)。

在二维字符矩阵中找出所有出现在单词集合中的单词。

用 Trie 存储目标单词集合,在矩阵中进行 DFS 搜索时,同步沿着 Trie 的路径移动。如果当前路径不在 Trie 中,立即剪枝。比用 HashSet 暴力搜索快一个数量级,是面试中的高频 Hard 题。

3. 自动补全(Autocomplete)--- Trie + DFS + 优先级队列

搜索引擎的输入框自动补全功能,底层就是 Trie。实现思路:

  1. 用 Trie 存储所有搜索词和频率

  2. 用户输入前缀时,沿着 Trie 找到前缀节点

  3. 从该节点开始 DFS,收集所有完整单词及其频率

  4. 用最小堆维护 Top K 高频词

4. 工程应用

场景 说明
搜索引擎 Google 搜索框的自动补全
拼写检查 检查单词是否在词典中,给出建议词
IP 路由 CIDR 前缀匹配,路由器查路由表
输入法 拼音到汉字的映射,词频排序
基因序列 DNA 子串模式匹配

5. Trie 的变体

  • 压缩 Trie(Radix Tree):合并只有一个子节点的路径,节省空间。Linux 内核的 radix tree 就是这种结构。

  • 双数组 Trie(Double-Array Trie):用两个数组实现 Trie,省去指针开销,内存效率极高,常用于中文分词。

  • 后缀树(Suffix Tree):Trie 的变体,用于子串搜索,可在 O(L) 时间内查找任意子串。


六、总结

Trie(前缀树)是处理字符串前缀问题的终极武器 。它的核心思想是空间换时间------用多叉树结构存储字符串集合,让公共前缀共享路径,从而将前缀查询的时间复杂度从 O(N) 降低到 O(L)。

三个核心操作:insert、search、startsWith,都是 O(L) 时间。

两个关键细节

  • search 检查 isEnd,startsWith 不检查

  • 通配符搜索需要 DFS 回溯,遇到 '.' 时遍历所有子节点

一个核心思维:遇到"字符串集合 + 前缀匹配"的问题,第一时间想到 Trie。


参考资料

  1. LeetCode 208 - Implement Trie (Prefix Tree) - 官方题解

  2. LeetCode 211 - Design Add and Search Words Data Structure - 官方题解

  3. Wikipedia: Trie (Prefix Tree)

  4. 《算法导论》第 14 章:数据结构的扩张

  5. 《剑指 Offer》第 2 版:Trie 相关题目

相关推荐
重生之后端学习2 小时前
239. 滑动窗口最大值[困难]✅
java·数据结构·算法·leetcode·职场和发展
笨笨饿4 小时前
#111_关于FreeRTOS面试的一些题目
linux·ubuntu·面试·职场和发展·centos·rtos
蒸蒸yyyyzwd6 小时前
AI软件开发面试gpt模拟学习笔记
人工智能·gpt·面试
码匠许师傅8 小时前
【C++ 面试真题】35. 聊聊 C++ 的万能引用(T&&)和完美转发(std::forward)
java·c++·面试
烤羊腿的程序员9 小时前
前端面试知识点大全(带答案)
前端·面试·职场和发展
牛油果子哥q9 小时前
C++模板万字深度精讲:函数/类模板特化、全特化与偏特化、SFINAE机制、类型萃取、模板元编程入门、工程实战落地
开发语言·c++·面试
胡萝卜术9 小时前
权限系统的四道防线:从数据库事务锁到操作级保护规则的完整设计
前端·javascript·面试
JAVA面经实录91711 小时前
Kafka面试题标准答案(面试背诵版)
分布式·面试·kafka
吴声子夜歌12 小时前
Java面试——Spark原理及应用(二)
java·面试·spark