实战:跳表(Skip List)实现——概率数据结构
跳表这东西,我第一次接触是在做内存缓存系统的时候。当时项目里需要一个高性能的有序集合,既要支持快速查找,又要支持范围查询。红黑树当然可以,但实现起来太复杂了,调试起来更是噩梦。后来我翻到了 William Pugh 那篇经典论文,嗯,跳表——简单、高效、还容易实现。
说白了,跳表就是一种「用概率换性能」的数据结构。它通过多层链表来加速查找,每一层都是下一层的「快速通道」。你想想看,如果只有一层链表,查找一个元素得一个个往后走,O(n) 的复杂度,数据量一上来就扛不住了。但如果我们给链表加上「跳跃」的能力,让指针能一次跳过多个节点,那查找速度就能接近二分查找的 O(log n)。
跳表的核心思想
跳表的结构其实很直观。最底层是一个完整的有序链表,上面每一层都是下一层的「子集」。每个节点随机决定自己出现在多少层——这个随机性就是跳表的灵魂。
关键点:跳表通过「空间换时间」的方式,用多层索引来加速查找。每一层都是下一层的快速通道,查找时从最高层开始,快速定位到目标区间,然后逐层下降,最终在底层找到精确位置。
我习惯把跳表想象成地铁线路图。底层是普通线路,每站都停。上层是快线,只停大站。你要从 A 到 Z,先坐快线快速跨越大部分区间,再换乘普通线精确到达目的地。跳表就是这么干的。
跳表的结构设计
每个跳表节点需要存储:
- 键值对(key-value)
- 一个指针数组,指向不同层级的后继节点
节点层数的确定方式:
- 每个节点至少在第 0 层(最底层)出现
- 每向上提升一层,概率为 p(通常取 0.25 或 0.5)
- 最大层数通常限制为 MAX_LEVEL,比如 16 或 32
这里有个小坑。我曾经在项目里把 p 设成了 0.75,结果跳表变得「太扁平」,查找性能退化严重。后来我改回 0.25,效果就好多了。p 值越小,高层节点越少,空间开销小但查找步数可能增加;p 值越大,高层节点越多,查找更快但内存占用更大。0.25 是个不错的平衡点。
跳表的三种核心操作
1. 查找(Search)
查找是跳表最基础的操作。从最高层开始,沿着当前层向前走,直到下一个节点的 key 大于目标 key,然后下降到下一层继续。重复这个过程,直到到达第 0 层。
// 查找 key 对应的 value
int* skip_list_find(skip_list* list, int key) {
// 从最高层开始
skip_node* current = list->head;
// 逐层下降
for (int i = list->level - 1; i >= 0; i--) {
// 在当前层向前移动,直到下一个节点 key 大于等于目标 key
while (current->next[i] != nullptr &&
current->next[i]->key < key) {
current = current->next[i];
}
}
// 现在 current 在第 0 层,检查下一个节点
current = current->next[0];
if (current != nullptr && current->key == key) {
return ¤t->value;
}
return nullptr;
}
你想想看,这个查找过程其实很优雅。每次下降一层,搜索范围就缩小一圈。平均时间复杂度 O(log n),最坏情况 O(n)——但最坏情况出现的概率极低,因为层数是随机决定的。
2. 插入(Insert)
插入操作比查找多了一步:需要记录每一层中「最后一个小于目标 key」的节点,也就是前驱节点。这样在插入新节点时,才能正确更新各层的指针。
void skip_list_insert(skip_list* list, int key, int value) {
// 记录每一层的前驱节点
skip_node* prev[MAX_LEVEL];
skip_node* current = list->head;
// 查找插入位置,同时记录前驱
for (int i = list->level - 1; i >= 0; i--) {
while (current->next[i] != nullptr &&
current->next[i]->key < key) {
current = current->next[i];
}
prev[i] = current;
}
// 检查 key 是否已存在
current = current->next[0];
if (current != nullptr && current->key == key) {
current->value = value; // 更新值
return;
}
// 随机决定新节点的层数
int new_level = random_level();
// 如果新层数超过当前最大层数,需要更新 head 的指针
if (new_level > list->level) {
for (int i = list->level; i < new_level; i++) {
prev[i] = list->head;
}
list->level = new_level;
}
// 创建新节点
skip_node* new_node = create_node(key, value, new_level);
// 插入到各层
for (int i = 0; i < new_level; i++) {
new_node->next[i] = prev[i]->next[i];
prev[i]->next[i] = new_node;
}
}
个人经验:随机层数的生成函数很关键。我一般用 while (rand() < RAND_MAX * p && level < MAX_LEVEL) level++; 这种方式。注意要保证随机数质量,否则跳表的性能会受影响。我在一个项目里用过简单的线性同余生成器,结果跳表的分布不均匀,查找性能时好时坏。后来换成了梅森旋转算法,问题就解决了。
3. 删除(Delete)
删除操作和插入类似,也需要记录前驱节点。找到目标节点后,更新各层指针,然后释放内存。
bool skip_list_delete(skip_list* list, int key) {
skip_node* prev[MAX_LEVEL];
skip_node* current = list->head;
// 查找目标节点,记录前驱
for (int i = list->level - 1; i >= 0; i--) {
while (current->next[i] != nullptr &&
current->next[i]->key < key) {
current = current->next[i];
}
prev[i] = current;
}
current = current->next[0];
if (current == nullptr || current->key != key) {
return false; // 没找到
}
// 从各层中移除
for (int i = 0; i < list->level; i++) {
if (prev[i]->next[i] != current) {
break; // 更高层没有这个节点
}
prev[i]->next[i] = current->next[i];
}
// 释放节点
delete current;
// 更新最大层数(可选优化)
while (list->level > 1 &&
list->head->next[list->level - 1] == nullptr) {
list->level--;
}
return true;
}
注意:删除操作后,如果最高层已经没有节点了,记得更新 list->level。否则下次查找时,会从空层开始,白白浪费一次循环。我曾经因为这个疏忽,导致跳表在频繁插入删除后性能下降,排查了半天才发现是 level 没更新。
跳表的性能分析
| 操作 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 |
|---|---|---|---|
| 查找 | O(log n) | O(n) | — |
| 插入 | O(log n) | O(n) | O(n log n) 期望 |
| 删除 | O(log n) | O(n) | — |
| 范围查询 | O(log n + k) | O(n) | — |
注意看,范围查询是跳表的一大优势。找到起始节点后,沿着第 0 层的链表往后走就行,非常自然。红黑树做范围查询就得中序遍历,实现起来麻烦多了。
跳表 vs 红黑树
我经常被问到:「跳表和红黑树哪个好?」我的回答是:看场景。
- 实现难度:跳表完胜。红黑树的旋转、变色逻辑太复杂了,跳表的代码量大概只有红黑树的三分之一。
- 并发性能:跳表更适合并发环境。因为跳表的更新只影响局部节点,锁粒度可以很小。红黑树的旋转可能影响整棵树,锁粒度不好控制。
- 内存占用:红黑树更省内存。每个节点只需要两个指针(左、右),跳表每个节点平均需要 1/(1-p) 个指针,p=0.25 时平均 1.33 个,但最大层数可能到 32,所以实际分配时会有浪费。
- 性能稳定性:红黑树更稳定,最坏情况也是 O(log n)。跳表依赖随机性,虽然最坏情况概率极低,但理论上存在。
我的建议:如果你在写一个需要快速上线的功能,或者团队里其他人要维护你的代码,选跳表。如果你在做底层基础设施,对性能稳定性要求极高,选红黑树。我在做游戏服务器时用的跳表,因为迭代快、需求变化多;在做数据库引擎时用的红黑树,因为要保证最坏情况下的响应时间。
跳表的完整实现
下面是一个完整的跳表实现,包含了查找、插入、删除和打印功能。代码量不大,但麻雀虽小五脏俱全。
#include <iostream>
#include <cstdlib>
#include <ctime>
#include <cstring>
const int MAX_LEVEL = 16;
const float P = 0.25;
struct skip_node {
int key;
int value;
skip_node** next;
skip_node(int k, int v, int level)
: key(k), value(v) {
next = new skip_node*[level];
memset(next, 0, sizeof(skip_node*) * level);
}
~skip_node() {
delete[] next;
}
};
struct skip_list {
skip_node* head;
int level;
skip_list() {
head = new skip_node(INT_MIN, 0, MAX_LEVEL);
level = 1;
}
~skip_list() {
skip_node* cur = head->next[0];
while (cur) {
skip_node* tmp = cur;
cur = cur->next[0];
delete tmp;
}
delete head;
}
};
int random_level() {
int level = 1;
while ((rand() / (float)RAND_MAX) < P && level < MAX_LEVEL) {
level++;
}
return level;
}
int* find(skip_list* list, int key) {
skip_node* cur = list->head;
for (int i = list->level - 1; i >= 0; i--) {
while (cur->next[i] && cur->next[i]->key < key) {
cur = cur->next[i];
}
}
cur = cur->next[0];
if (cur && cur->key == key) {
return &cur->value;
}
return nullptr;
}
void insert(skip_list* list, int key, int value) {
skip_node* prev[MAX_LEVEL];
skip_node* cur = list->head;
for (int i = list->level - 1; i >= 0; i--) {
while (cur->next[i] && cur->next[i]->key < key) {
cur = cur->next[i];
}
prev[i] = cur;
}
cur = cur->next[0];
if (cur && cur->key == key) {
cur->value = value;
return;
}
int new_level = random_level();
if (new_level > list->level) {
for (int i = list->level; i < new_level; i++) {
prev[i] = list->head;
}
list->level = new_level;
}
skip_node* new_node = new skip_node(key, value, new_level);
for (int i = 0; i < new_level; i++) {
new_node->next[i] = prev[i]->next[i];
prev[i]->next[i] = new_node;
}
}
bool remove(skip_list* list, int key) {
skip_node* prev[MAX_LEVEL];
skip_node* cur = list->head;
for (int i = list->level - 1; i >= 0; i--) {
while (cur->next[i] && cur->next[i]->key < key) {
cur = cur->next[i];
}
prev[i] = cur;
}
cur = cur->next[0];
if (!cur || cur->key != key) {
return false;
}
for (int i = 0; i < list->level; i++) {
if (prev[i]->next[i] != cur) break;
prev[i]->next[i] = cur->next[i];
}
delete cur;
while (list->level > 1 &&
!list->head->next[list->level - 1]) {
list->level--;
}
return true;
}
void print(skip_list* list) {
for (int i = list->level - 1; i >= 0; i--) {
std::cout << "Level " << i << ": ";
skip_node* cur = list->head->next[i];
while (cur) {
std::cout << cur->key << " ";
cur = cur->next[i];
}
std::cout << std::endl;
}
}
跳表的结构示意图
下面这张图展示了跳表的多层结构。你可以看到,高层节点稀疏,底层节点密集。查找时从顶层开始,快速跳过大量元素。
实际应用场景
跳表在实际项目中用得挺多的。我整理了几个常见的场景:
- Redis 的有序集合(ZSET):Redis 的 ZSET 底层就用到了跳表。为什么不用红黑树?因为 Redis 的作者觉得跳表实现更简单,而且范围查询更方便。
- 内存缓存系统:需要快速查找和范围查询的场景,比如 LRU 缓存与有序索引的结合。
- 数据库索引:一些嵌入式数据库(如 LevelDB)的 MemTable 就用跳表来实现。
- 并发数据结构:跳表的并发版本比红黑树容易实现得多,很多无锁数据结构都基于跳表。
总结一下:跳表是一种用概率换性能的数据结构。它实现简单、性能优秀、并发友好。虽然最坏情况是 O(n),但实际中几乎不会出现。如果你需要一个有序的键值存储结构,又不想跟红黑树的旋转逻辑死磕,跳表绝对是个好选择。
嗯,跳表的内容就讲到这里。代码我已经贴出来了,你可以直接拿去用。记得调整 MAX_LEVEL 和 P 的值来适配你的场景。如果数据量不大,MAX_LEVEL 设成 8 就够了;如果数据量上亿,建议设到 32 以上。