高级数据结构:Trie树、后缀数组、AC自动机、红黑树
说实话,嵌入式开发做到一定深度,你会发现光靠数组和链表根本不够用。字符串匹配、关键词过滤、海量数据排序——这些场景下,普通数据结构要么慢得离谱,要么内存炸裂。今天聊的这四个家伙,都是实战中能救命的硬核工具。
我个人习惯把这类数据结构叫做「带脑子的容器」。它们不是简单存数据,而是存了一种「关系」——字符之间的前后关系、模式之间的跳转关系、节点之间的平衡关系。理解了这一点,你才算真正入门。
1. Trie树(字典树)
Trie树,说白了就是一棵「按字符分叉」的树。每个节点代表一个字符,从根到叶子的一条路径,就是一个完整的字符串。
举个例子,你要存 "cat"、"car"、"dog" 三个词。普通做法是三个字符串分别存,查找 "cat" 要逐个字符比较。Trie树怎么做?根节点分出 'c' 和 'd' 两个分支,'c' 下面再分 'a','a' 下面分 't' 和 'r'。查找时顺着字符走,三步到位。
我在项目中遇到过用 Trie 树做命令解析器。一个嵌入式设备要识别上百条 AT 指令,每条指令长度不一。用 if-else 链?代码能写到吐。用 Trie 树?建好树之后,输入一个字符走一步,走到叶子节点就命中指令。清爽得很。
// Trie树节点定义(C语言)
#define MAX_CHILDREN 26 // 假设只有小写字母
typedef struct TrieNode {
struct TrieNode* children[MAX_CHILDREN];
int is_end; // 标记是否为单词结尾
} TrieNode;
TrieNode* create_node() {
TrieNode* node = (TrieNode*)malloc(sizeof(TrieNode));
node->is_end = 0;
for (int i = 0; i < MAX_CHILDREN; i++) {
node->children[i] = NULL;
}
return node;
}
// 插入单词
void insert(TrieNode* root, const char* word) {
TrieNode* cur = root;
while (*word) {
int idx = *word - 'a';
if (cur->children[idx] == NULL) {
cur->children[idx] = create_node();
}
cur = cur->children[idx];
word++;
}
cur->is_end = 1;
}
2. 后缀数组
后缀数组,名字听着吓人,其实思路很朴素。你把一个字符串的所有后缀拿出来,按字典序排个序,存下每个后缀的起始位置——这就是后缀数组。
比如 "banana" 的后缀有:banana, anana, nana, ana, na, a。排序后是:a, ana, anana, banana, na, nana。对应的起始位置数组就是 [5, 3, 1, 0, 4, 2]。
为什么要搞这么个东西?因为很多字符串问题——比如找最长重复子串、找两个字符串的最长公共子串——在后缀数组上都能高效解决。配合 LCP(最长公共前缀)数组,威力更大。
我记得有一次做 DNA 序列比对,要在几百万个碱基里找重复片段。暴力法 O(n²) 根本跑不动。用后缀数组 + LCP,O(n log n) 搞定。嗯,这就是算法的魅力。
// 后缀数组的倍增法构建(伪代码思路)
// 1. 对每个位置,按第一个字符排序
// 2. 按前2个字符排序(利用第一轮结果)
// 3. 按前4个字符排序(利用第二轮结果)
// 4. 重复直到覆盖整个字符串
// 每次排序用基数排序,总复杂度 O(n log n)
3. AC自动机
AC自动机,全称 Aho-Corasick 自动机。它解决的是「多模式串匹配」问题——给你一堆关键词,在一段长文本里找出所有出现的位置。
你想想看,如果关键词有 1000 个,每个都跑一遍 KMP?太慢了。AC自动机的做法是:先把所有关键词建成一棵 Trie 树,然后给每个节点加一个「失败指针」。匹配时,如果当前字符匹配不上,就顺着失败指针跳转,继续匹配。
这个失败指针,说白了就是「失配时最长的可匹配后缀」。它让 AC自动机在 O(n) 时间内完成所有关键词的匹配,n 是文本长度。
我曾经给一个网络设备做内容过滤,要同时匹配几千条 URL 黑名单规则。用 AC自动机,建好自动机之后,每个数据包进来,线性扫描一遍就出结果。CPU 占用不到 5%。换成逐个匹配?CPU 直接飙到 80%。
// AC自动机节点结构
typedef struct ACNode {
struct ACNode* children[MAX_CHILDREN];
struct ACNode* fail; // 失败指针
int output; // 是否匹配到模式串
} ACNode;
// 构建失败指针(BFS实现)
void build_fail(ACNode* root) {
Queue* q = create_queue();
// 第一层节点的失败指针指向根
for (int i = 0; i < MAX_CHILDREN; i++) {
if (root->children[i]) {
root->children[i]->fail = root;
enqueue(q, root->children[i]);
}
}
while (!is_empty(q)) {
ACNode* cur = dequeue(q);
for (int i = 0; i < MAX_CHILDREN; i++) {
if (cur->children[i]) {
ACNode* next = cur->children[i];
ACNode* f = cur->fail;
while (f && !f->children[i]) {
f = f->fail;
}
next->fail = f ? f->children[i] : root;
next->output |= next->fail->output;
enqueue(q, next);
}
}
}
}
4. 红黑树
红黑树,一种自平衡的二叉查找树。它保证在最坏情况下,插入、删除、查找的时间复杂度都是 O(log n)。
怎么做到的?靠颜色约束。每个节点要么红要么黑,加上五条规则:
- 节点是红色或黑色
- 根节点是黑色
- 叶子节点(NIL)是黑色
- 红色节点的子节点必须是黑色(不能有连续红节点)
- 从任一节点到其每个叶子的所有路径,包含相同数量的黑色节点
这五条规则保证了什么?保证了任何一条路径的长度,不会超过另一条路径的两倍。说白了就是「近似平衡」——不像 AVL 树那么严格,但插入删除的调整代价更小。
我记得在做一个任务调度器时,需要按优先级动态管理几百个任务。用红黑树做优先队列,插入和删除都稳定在微秒级。换成普通二叉查找树?最坏情况下退化成链表,直接崩了。
// 红黑树节点(简化版)
typedef enum { RED, BLACK } Color;
typedef struct RBNode {
int key;
Color color;
struct RBNode* left;
struct RBNode* right;
struct RBNode* parent;
} RBNode;
// 左旋操作(插入和删除的调整基础)
void left_rotate(RBNode** root, RBNode* x) {
RBNode* y = x->right;
x->right = y->left;
if (y->left != NULL) {
y->left->parent = x;
}
y->parent = x->parent;
if (x->parent == NULL) {
*root = y;
} else if (x == x->parent->left) {
x->parent->left = y;
} else {
x->parent->right = y;
}
y->left = x;
x->parent = y;
}
知识体系总览
这四个数据结构,各有各的用武之地。我画了张图帮你理清关系:
这四个数据结构,Trie树和AC自动机是「字符串处理」这条线上的,后缀数组是「子串分析」的利器,红黑树则是「有序数据管理」的通用方案。你不需要一次性全掌握,但遇到对应场景时,脑子里要有这根弦——「嗯,这个问题用XX结构能搞定」。
好了,这一章的内容就到这儿。代码示例我都尽量精简了,核心是让你理解「为什么用」和「怎么用」。至于「怎么写得完美」——那是你动手敲代码之后的事。