第1章 字典树(Trie):从零开始构建你的单词搜索引擎

大家好,我是你们的老朋友。今天咱们来聊聊字典树——一个我工作中几乎天天打交道的结构。

说实话,我第一次接触 Trie 是在做英文单词联想输入的时候。当时用户敲几个字母,系统就得快速给出候选词。用哈希表?不行,前缀匹配太慢。用数组?更别提了。后来翻到《算法导论》里关于 Trie 的介绍,一拍大腿——就是它了。

1.1 字典树的定义与结构

字典树,也叫前缀树(Prefix Tree),是一种专门处理字符串集合的树形结构。它的核心思想是:利用字符串的公共前缀来节省存储空间,同时加速查找

你想想看,如果我们要存 "cat"、"car"、"dog" 这三个词,用哈希表得存三个独立的字符串。但用 Trie,它们共享 "ca" 这个前缀,只分叉到 't' 和 'r'。是不是很巧妙?

核心特点:
  • 根节点不包含字符,除根节点外每个节点包含一个字符
  • 从根节点到某个节点路径上的字符连接起来,就是该节点对应的字符串
  • 每个节点的所有子节点包含的字符都不相同

下面是我画的一张 Trie 结构图,展示了插入 "cat"、"car"、"dog" 后的样子:

c d a o t r g 根节点为空 ✓ 表示单词结束 "cat" 路径: 根→c→a→t "car" 路径: 根→c→a→r "dog" 路径: 根→d→o→g
小提示:每个节点通常需要两个字段:一个字符值(或字符映射),以及一个布尔标记表示「是否是一个完整单词的结尾」。我习惯用 is_end 来命名这个标记。

1.2 插入操作

插入操作其实很简单。从根节点开始,逐个字符往下走:

  • 如果当前字符对应的子节点存在,就沿着它继续
  • 如果不存在,就创建一个新节点
  • 处理完最后一个字符后,把当前节点的 is_end 标记设为 true

来看代码:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define ALPHABET_SIZE 26

typedef struct TrieNode {
    struct TrieNode *children[ALPHABET_SIZE];
    int is_end;  // 1 表示是一个完整单词的结尾
} TrieNode;

TrieNode* create_node() {
    TrieNode *node = (TrieNode*)malloc(sizeof(TrieNode));
    node->is_end = 0;
    for (int i = 0; i < ALPHABET_SIZE; i++) {
        node->children[i] = NULL;
    }
    return node;
}

void insert(TrieNode *root, const char *word) {
    TrieNode *cur = root;
    for (int i = 0; word[i] != '\0'; i++) {
        int idx = word[i] - 'a';
        if (cur->children[idx] == NULL) {
            cur->children[idx] = create_node();
        }
        cur = cur->children[idx];
    }
    cur->is_end = 1;
}

嗯,这里要注意:我假设输入都是小写字母。如果包含大写或特殊字符,你得调整索引映射。我在项目中遇到过有人直接拿 ASCII 码当索引,结果数组开得巨大无比,内存直接爆了。

1.3 查找操作

查找分两种:精确查找和前缀查找。

精确查找:沿着路径走完所有字符,最后检查 is_end 是否为 true。

前缀查找:沿着路径走完所有字符,只要路径存在就返回 true,不管 is_end。

// 精确查找:判断 word 是否在字典中
int search(TrieNode *root, const char *word) {
    TrieNode *cur = root;
    for (int i = 0; word[i] != '\0'; i++) {
        int idx = word[i] - 'a';
        if (cur->children[idx] == NULL) {
            return 0;  // 路径不存在
        }
        cur = cur->children[idx];
    }
    return cur->is_end;  // 必须是完整单词
}

// 前缀查找:判断是否有以 prefix 开头的单词
int starts_with(TrieNode *root, const char *prefix) {
    TrieNode *cur = root;
    for (int i = 0; prefix[i] != '\0'; i++) {
        int idx = prefix[i] - 'a';
        if (cur->children[idx] == NULL) {
            return 0;
        }
        cur = cur->children[idx];
    }
    return 1;  // 路径存在即返回真
}
避坑指南:我曾经在查找时忘了检查 is_end,结果 "app" 明明没插入过,但 "apple" 插入后,查 "app" 居然返回存在。后来排查了半天才发现——路径存在不代表单词存在!一定要记得检查结束标记。

1.4 字典树的应用场景

说白了,Trie 就是为字符串匹配而生的。我总结了一下,主要有三大应用:

1.4.1 单词统计

你可以用 Trie 来统计每个单词出现的次数。只需要在节点里加一个 count 字段,插入时每经过一个节点就加 1,最后在 is_end 节点记录完整单词的次数。

我在做英文词频统计时用过这个方案。相比哈希表,Trie 能同时支持前缀统计——比如统计所有以 "pre" 开头的单词出现次数,哈希表就做不到了。

1.4.2 前缀匹配

这是 Trie 的看家本领。搜索引擎的自动补全、输入法的联想词、IDE 的代码补全,底层基本都是 Trie。

举个例子:用户输入 "app",系统要快速给出 "apple"、"application"、"appointment" 等候选词。用 Trie 找到 "app" 节点后,DFS 遍历所有子节点就能拿到所有候选词。

1.4.3 字符串排序

Trie 天然支持字典序排序。你只需要先序遍历整棵树,按字符顺序访问子节点,输出的就是字典序排列。

我记得有一次需要给 10 万个英文单词排序。用快排?O(n log n)。用 Trie?O(n * L),L 是平均单词长度。对于长单词多的场景,Trie 明显更快。

// 先序遍历输出所有单词(字典序)
void print_sorted(TrieNode *root, char *buffer, int depth) {
    if (root->is_end) {
        buffer[depth] = '\0';
        printf("%s\n", buffer);
    }
    for (int i = 0; i < ALPHABET_SIZE; i++) {
        if (root->children[i] != NULL) {
            buffer[depth] = 'a' + i;
            print_sorted(root->children[i], buffer, depth + 1);
        }
    }
}
性能对比:
操作 Trie 时间复杂度 哈希表时间复杂度 备注
插入 O(L) O(L) L 为单词长度
精确查找 O(L) O(L) 哈希表需要计算哈希值
前缀查找 O(L) 不支持 Trie 的独家优势
字典序排序 O(n * L) O(n log n) n 为单词数量
我的建议:如果你的应用场景只需要精确查找,哈希表就够了。但一旦涉及前缀匹配、自动补全、字典序输出,Trie 就是更好的选择。我个人习惯在项目初期就用 Trie,因为后期加前缀功能时不用重构代码。

好了,字典树的基础内容就聊到这儿。代码虽然简单,但背后的思想很强大。下次你写搜索框自动补全的时候,不妨试试自己手写一个 Trie——相信我,比调第三方库有意思多了。


公众号:蓝海资料掘金营,微信 deep3321