高级数据结构(三):Trie树、后缀树与后缀数组、KD树
说实话,讲到这一章,我心里有点小激动。为什么?因为这几个数据结构,是我在实际项目中真正“救过火”的利器。Trie树帮我搞定过千万级词库的快速匹配,后缀数组让我在基因序列比对时没被性能拖垮,KD树则是我做空间索引时的首选。
嗯,咱们一个一个来。别急,我会把每个结构的核心思想、适用场景、还有我踩过的坑,都掰开揉碎了讲给你听。
一、Trie树(字典树)
Trie树,说白了就是一棵专门为字符串设计的树。它的核心思想是:利用字符串的公共前缀来节省存储空间和查询时间。
我习惯叫它“前缀树”。你想想看,如果我们要存"cat"、"car"、"dog"这三个词,用哈希表得存三个完整的字符串。但用Trie树呢?"ca"这个前缀只存一次,后面分叉成"t"和"r"。省空间,更省时间。
核心特性:
- 根节点不包含字符,除根节点外每个节点包含一个字符
- 从根节点到某个节点路径上的字符连接起来,就是该节点对应的字符串
- 每个节点的所有子节点包含的字符都不相同
我在项目中遇到过这样一个场景:需要实现一个敏感词过滤系统,词库有几十万条。用哈希表?内存爆炸。用二分查找?速度太慢。最后我选了Trie树,查询时间复杂度稳定在O(L),L是待查字符串长度,跟词库大小无关。爽不爽?
Trie树的C语言实现
#define MAX_CHAR 26 // 假设只处理小写字母
typedef struct TrieNode {
struct TrieNode *children[MAX_CHAR];
int isEnd; // 标记是否为单词结尾
} TrieNode;
TrieNode* createNode() {
TrieNode *node = (TrieNode*)malloc(sizeof(TrieNode));
node->isEnd = 0;
for (int i = 0; i < MAX_CHAR; 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]) {
cur->children[idx] = createNode();
}
cur = cur->children[idx];
word++;
}
cur->isEnd = 1;
}
int search(TrieNode *root, const char *word) {
TrieNode *cur = root;
while (*word) {
int idx = *word - 'a';
if (!cur->children[idx]) return 0;
cur = cur->children[idx];
word++;
}
return cur != NULL && cur->isEnd;
}
我的小技巧:如果内存紧张,可以用数组代替指针,或者用双数组Trie(Double-Array Trie)。我曾经把一个占用500MB的Trie树优化到只剩80MB,靠的就是这个。
注意:Trie树虽然查询快,但每个节点都要存26个指针(如果是全字母表),内存开销不小。我曾经见过有人用Trie树存中文词库,每个节点开几千个指针...那叫一个惨不忍睹。中文场景建议用双数组Trie或者AC自动机。
二、后缀树与后缀数组
后缀树和后缀数组,这俩是处理字符串匹配问题的“核武器”。
后缀树,就是把一个字符串的所有后缀都塞进一棵Trie树里。比如"banana"的后缀有:"banana"、"anana"、"nana"、"ana"、"na"、"a"。把这些后缀建成一棵树,就是后缀树。
后缀数组呢?更省空间。它就是一个数组,存的是所有后缀按字典序排序后的起始位置。
我为什么说它们是核武器?因为有了它们,很多原本复杂的问题变得异常简单:
- 查找一个子串是否出现:O(L)时间,L是子串长度
- 查找最长重复子串:线性时间搞定
- 查找两个字符串的最长公共子串:也是线性时间
我记得有一次做基因序列分析,需要在一段几百万字符的DNA序列中查找重复片段。用暴力法?跑一天都出不来结果。后来我用后缀数组+最长公共前缀(LCP)数组,几分钟就搞定了。
后缀数组的构建(倍增法)
// 简化版:计算后缀数组的排名
void buildSA(const char *s, int *sa, int n) {
int *rk = (int*)malloc(n * sizeof(int));
int *tmp = (int*)malloc(n * sizeof(int));
// 初始化:按第一个字符排序
for (int i = 0; i < n; i++) {
rk[i] = s[i] - 'a';
sa[i] = i;
}
// 倍增过程
for (int k = 1; k < n; k *= 2) {
// 按(rk[i], rk[i+k])双关键字排序
// 这里省略了排序的具体实现,实际用基数排序
// ...
}
free(rk);
free(tmp);
}
关键理解:后缀数组本身只是一个排序后的索引数组,真正强大的是配合LCP(最长公共前缀)数组一起使用。LCP[i]表示排名第i的后缀和排名第i-1的后缀的最长公共前缀长度。有了LCP,很多问题就迎刃而解了。
避坑指南:我曾经在实现后缀数组时,排序部分用了快速排序,结果数据量一大就超时。后来换成基数排序,速度提升了10倍不止。记住:后缀数组的构建,排序是关键,一定要用O(n)的基数排序。
三、KD树
KD树,全称K-Dimensional Tree,是一种用于组织K维空间点数据的数据结构。说白了,就是多维空间的二叉搜索树。
它的核心思想是:轮流按不同维度进行划分。比如二维空间,第一层按x坐标划分,第二层按y坐标划分,第三层又按x坐标划分...如此交替。
我为什么需要它?因为工作中经常遇到这样的问题:给定一个点,找离它最近的K个点(K近邻搜索)。如果数据量小,暴力遍历就行。但数据量一上来,比如几十万个点,暴力法就扛不住了。KD树可以把搜索时间从O(n)降到O(log n)。
KD树的应用场景
- 最近邻搜索:找离目标点最近的点
- 范围搜索:找某个矩形区域内的所有点
- K近邻搜索:找离目标点最近的K个点
我在做地理信息系统时,需要快速查找某个坐标附近的兴趣点。几百万个点,用KD树做索引,每次查询只需要几十微秒。嗯,这个性能,客户很满意。
KD树的构建与搜索
typedef struct KDNode {
double point[2]; // 二维点
struct KDNode *left;
struct KDNode *right;
int depth; // 当前深度,用于决定按哪个维度划分
} KDNode;
// 构建KD树(简化版)
KDNode* buildKDTree(Point *points, int n, int depth) {
if (n <= 0) return NULL;
int axis = depth % 2; // 0: x轴, 1: y轴
// 按axis维度排序,取中位数作为分割点
sortByAxis(points, n, axis);
int mid = n / 2;
KDNode *node = (KDNode*)malloc(sizeof(KDNode));
node->point[0] = points[mid].x;
node->point[1] = points[mid].y;
node->depth = depth;
node->left = buildKDTree(points, mid, depth + 1);
node->right = buildKDTree(points + mid + 1, n - mid - 1, depth + 1);
return node;
}
// 最近邻搜索(伪代码)
void nearestSearch(KDNode *node, Point target, Point *best, double *bestDist) {
if (!node) return;
double dist = distance(node->point, target);
if (dist < *bestDist) {
*bestDist = dist;
*best = node->point;
}
int axis = node->depth % 2;
double diff = target[axis] - node->point[axis];
// 先搜索可能更近的一侧
KDNode *first = (diff < 0) ? node->left : node->right;
KDNode *second = (diff < 0) ? node->right : node->left;
nearestSearch(first, target, best, bestDist);
// 检查另一侧是否需要搜索
if (fabs(diff) < *bestDist) {
nearestSearch(second, target, best, bestDist);
}
}
注意:KD树在高维空间(比如超过20维)性能会急剧下降,这就是所谓的“维度灾难”。如果你要处理高维数据,建议改用球树(Ball Tree)或者近似最近邻搜索(如LSH)。
知识体系总览
下面这张图,是我自己梳理的这三个数据结构的核心脉络。你看一眼,心里就有数了。
总结一下
这三个数据结构,各有各的用武之地:
- Trie树:字符串前缀匹配的首选,简单粗暴有效
- 后缀树/后缀数组:字符串子串问题的终极武器,但实现稍复杂
- KD树:多维空间搜索的利器,但要注意维度灾难
我个人建议,初学者先把Trie树吃透,它最直观也最常用。后缀数组可以慢慢啃,一旦掌握了,很多字符串问题在你眼里就变成了“模板题”。KD树嘛,等你真正遇到空间搜索的需求时再深入也不迟。
好了,这一章就到这里。代码我都给了,建议你亲手敲一遍。数据结构这东西,光看是学不会的,得动手。
公众号:蓝海资料掘金营,微信deep3321