平衡二叉搜索树是算法课上的经典主题。红黑树、AVL 树、Splay 树——每一种都有精巧的平衡策略,也有复杂的实现细节。
但有一个反直觉的事实:有时候,随机比精确更好。
Treap(Tree + Heap)在 1989 年由 Aragon 和 Seidel 提出,它给每个节点一个随机优先级,然后让 BST 性质和堆性质同时成立——不需要旋转计数器,不需要颜色标记,不需要 zig-zag 判断。期望高度 \(O(\log n)\),代码不到 100 行。
跳表(Skip List)在 1990 年由 Pugh 提出,它用”抛硬币决定层数”的方式构建多级索引——不需要树结构,不需要指针旋转,天然支持范围查询和并发操作。Redis 的有序集合(ZSET)选择了跳表而不是红黑树。
本文深入分析这两种随机化数据结构的设计、证明、实现和工程实践。
一、为什么确定性平衡不总是最优解
确定性平衡的代价
红黑树和 AVL 树通过严格的不变量(invariant)来保证 \(O(\log n)\) 的最坏情况复杂度。这很好,但代价不低:
| 数据结构 | 不变量数量 | 插入最多旋转 | 删除最多旋转 | 实现难度 |
|---|---|---|---|---|
| AVL 树 | 1(平衡因子) | 2 | 2 | 中等 |
| 红黑树 | 5(颜色性质) | 2 | 3 | 高 |
| 2-3-4 树 | 2(节点度+高度) | 0(分裂) | 0(合并) | 高 |
这些不变量的维护逻辑在删除操作中尤其复杂。红黑树的删除有十几个 case,即使教科书的描述也经常让人一头雾水。
随机化的哲学
随机化的核心洞察是:我们不需要在每一次操作后都保持完美平衡,只需要保证”高概率下”结构是好的。
更准确地说,如果我们能证明:
\[ \Pr[\text{height} > c \log n] \leq n^{-\alpha} \]
对于某个常数 \(c\) 和 \(\alpha > 0\),那么在实际应用中,这与确定性的 \(O(\log n)\) 保证几乎没有区别——一个包含 \(10^9\) 个元素的结构,退化的概率远小于硬件错误的概率。
随机化的优势
- 代码简单:没有复杂的 case 分析,实现容易正确
- 输入无关:性能不依赖输入序列的顺序(对手无法构造恶意输入)
- 并发友好:没有全局不变量,局部修改不会引发长链式调整
- 常数因子小:没有额外的平衡元数据(颜色位、平衡因子)
二、Treap 的设计:BST 与堆的融合
基本思想
Treap 的每个节点存储两个属性:
- key:满足 BST 性质(左子树 < 根 < 右子树)
- priority:满足最小堆性质(父节点的 priority <= 子节点的 priority)
priority 在节点插入时随机生成。关键定理是:
定理:对于 \(n\) 个不同的 key,如果 priority 是独立均匀随机的,那么 Treap 的结构是唯一确定的,且与将 key 按 priority 顺序插入一棵普通 BST 得到的树相同。
这意味着 Treap 的期望性能等价于随机顺序插入的 BST,而随机 BST 的期望高度是 \(O(\log n)\)。
节点定义
typedef struct TreapNode {
int key;
int priority;
int size; // 子树大小,用于 order statistics
struct TreapNode *left;
struct TreapNode *right;
} TreapNode;为什么这样设计有效
直觉上:随机 priority 让 Treap 的形状等价于对 key 进行随机排列后依次插入 BST。而随机排列的 BST 有非常好的性质——Knuth 在 TAOCP 中证明了其期望高度为 \(2 \ln n \approx 1.386 \log_2 n\)。
这比红黑树的 \(2 \log_2(n+1)\) 更好,比 AVL 的 \(1.44 \log_2(n+2)\) 稍差,但已经足够优秀。
三、Treap 的 Split 与 Merge 操作
Treap 最优雅的实现方式不是基于旋转(rotation),而是基于 Split 和 Merge 两个基本操作。这种方式也被称为”隐式 Treap”或”FHQ Treap”(以 Fan Hao Qiang 命名)。
Split 操作
Split(T, k) 将 Treap \(T\) 分裂为两棵 Treap \((L, R)\),其中 \(L\) 包含所有 key \(\leq k\) 的节点,\(R\) 包含所有 key \(> k\) 的节点。
void treap_split(TreapNode *t, int key, TreapNode **left, TreapNode **right)
{
if (!t) {
*left = *right = NULL;
return;
}
if (t->key <= key) {
treap_split(t->right, key, &t->right, right);
*left = t;
} else {
treap_split(t->left, key, left, &t->left);
*right = t;
}
update_size(t);
}Merge 操作
Merge(L, R) 将两棵 Treap 合并为一棵,前提是
\(L\) 中所有 key \(<\) \(R\) 中所有 key。合并时根据
priority 决定谁做根:
TreapNode *treap_merge(TreapNode *left, TreapNode *right)
{
if (!left) return right;
if (!right) return left;
if (left->priority < right->priority) {
left->right = treap_merge(left->right, right);
update_size(left);
return left;
} else {
right->left = treap_merge(left, right->left);
update_size(right);
return right;
}
}正确性证明
引理:Split 操作保持 BST 性质和堆性质。
证明:对树高 \(h\) 归纳。
基础情况:\(h = 0\)(空树),显然成立。
归纳步骤:假设对高度 \(<
h\) 的树成立。考虑 Split(T, k):
- 若
T->key <= k:根节点进入 \(L\)。对T->right递归分裂。由归纳假设,T->right被正确分裂为 \((L', R)\),其中 \(L'\) 的所有 key 在 \((T\text{->key}, k]\) 内。令T->right = L',则 \(L\) 是以 \(T\) 为根的子树,BST 性质成立(左子树不变,右子树的 key 都 \(> T\text{->key}\) 且 \(\leq k\))。堆性质成立(\(T\) 的 priority 不变,\(L'\) 中的节点 priority \(\geq\) 原T->right子树中的节点 priority)。 - 若
T->key > k:对称分析。
引理:Merge 操作保持 BST 性质和堆性质。
证明:类似地对总节点数归纳。选择 priority 较小的节点作为根保证了堆性质;前提条件”\(L\) 的 key 全部小于 \(R\) 的 key”保证了 BST 性质在递归中被维持。
基于 Split/Merge 的插入和删除
void treap_insert(TreapNode **root, TreapNode *node)
{
TreapNode *left, *right;
treap_split(*root, node->key, &left, &right);
*root = treap_merge(treap_merge(left, node), right);
}
void treap_erase(TreapNode **root, int key)
{
TreapNode *left, *mid, *right;
treap_split(*root, key - 1, &left, &mid);
treap_split(mid, key, &mid, &right);
// mid 现在只包含 key 等于目标值的节点
free(mid); // 简化处理,实际需要释放整棵子树
*root = treap_merge(left, right);
}这种实现的美妙之处在于:插入和删除都只需要组合 Split 和 Merge,没有任何 case 分析。
四、Treap 期望高度的严格证明
定理陈述
定理:包含 \(n\) 个节点的随机 Treap,其期望高度为 \(O(\log n)\)。
证明
我们利用随机 BST 的等价性来证明。由于 priority 是随机的,Treap 的结构等价于按 priority 排序后依次插入 BST。
定义:对于有序的 key 集合 \(\{x_1, x_2, \ldots, x_n\}\)(\(x_1 < x_2 < \cdots < x_n\)),定义指示随机变量:
\[ A_{ij} = \begin{cases} 1 & \text{if } x_i \text{ 是 } x_j \text{ 的祖先} \\ 0 & \text{otherwise} \end{cases} \]
则 \(x_j\) 的深度为 \(D_j = \sum_{i \neq j} A_{ij}\)。
关键引理:\(x_i\) 是 \(x_j\) 的祖先当且仅当 \(x_i\) 在集合 \(\{x_{\min(i,j)}, x_{\min(i,j)+1}, \ldots, x_{\max(i,j)}\}\) 中具有最小的 priority。
证明:不失一般性设 \(i < j\)。在随机 BST 中,\(x_i\) 是 \(x_j\) 的祖先当且仅当 \(x_i\) 是集合 \(\{x_i, x_{i+1}, \ldots, x_j\}\) 中第一个被插入的元素。在 Treap 中,“第一个被插入”等价于”priority 最小”。因此:
\[ \Pr[A_{ij} = 1] = \frac{1}{|j - i| + 1} \]
因为在 \(|j-i|+1\) 个元素中,每个元素的 priority 是最小值的概率相等。
期望深度:
\[ E[D_j] = \sum_{i \neq j} \Pr[A_{ij}=1] = \sum_{i \neq j} \frac{1}{|j-i|+1} \]
\[ = \sum_{k=1}^{j-1} \frac{1}{k+1} + \sum_{k=1}^{n-j} \frac{1}{k+1} \]
\[ \leq 2 \sum_{k=2}^{n} \frac{1}{k} = 2(H_n - 1) \]
其中 \(H_n = \sum_{k=1}^n \frac{1}{k} \approx \ln n\) 是调和级数。
因此,任意节点的期望深度为 \(O(\ln n) = O(\log n)\),期望树高(所有节点深度的最大值)也是 \(O(\log n)\)。
几何分布视角
从另一个角度看,考虑从根到叶子的搜索路径。在每一层,我们比较当前节点和目标:
- 如果目标在左子树或右子树,我们向下一步
- 每次向下,路径”跨过”至少一个节点
路径长度可以分解为”向左走的步数”和”向右走的步数”。向左走的步数等于搜索路径上左转的次数。由随机性,在 \(x_j\) 的左侧的 \(j-1\) 个节点中,成为 \(x_j\) 左侧祖先的节点数为:
\[ \sum_{i=1}^{j-1} \frac{1}{j-i+1} \]
这是一个调和级数。类似地,右侧祖先的数量也是调和级数。总路径长度是两个调和级数之和,即 \(O(\log n)\)。
五、跳表的结构与概率分析
基本结构
跳表是一种概率性数据结构,由多层有序链表组成:
- Level 0:包含所有元素的有序链表
- Level 1:包含约 \(1/p\) 比例的元素(默认 \(p = 1/2\) 或 \(p = 1/4\))
- Level \(k\):包含约 \(p^k\) 比例的元素
- 每个元素在第 \(k\) 层出现的概率为 \(p^k\)
搜索时从最高层开始,在当前层尽可能向右走,当下一个节点的 key 大于目标时,下降一层继续搜索。
节点定义
#define SKIPLIST_MAXLEVEL 32
#define SKIPLIST_P 0.25
typedef struct SkipListNode {
int key;
int value;
struct SkipListNode *forward[]; // 柔性数组
} SkipListNode;
typedef struct SkipList {
int level; // 当前最高层
int length; // 元素总数
SkipListNode *header;
} SkipList;层数的随机生成
每个新节点的层数通过”抛硬币”确定:
int random_level(void)
{
int level = 1;
while ((rand() & 0xFFFF) < (SKIPLIST_P * 0xFFFF) && level < SKIPLIST_MAXLEVEL)
level++;
return level;
}层数服从几何分布:\(\Pr[\text{level} = k] = (1-p) \cdot p^{k-1}\)。
期望层数为 \(E[\text{level}] = \frac{1}{1-p}\)。当 \(p = 1/4\) 时,期望层数为 \(4/3 \approx 1.33\)。
搜索过程
SkipListNode *skiplist_search(SkipList *sl, int key)
{
SkipListNode *x = sl->header;
for (int i = sl->level - 1; i >= 0; i--) {
while (x->forward[i] && x->forward[i]->key < key)
x = x->forward[i];
}
x = x->forward[0];
if (x && x->key == key)
return x;
return NULL;
}搜索的直觉:高层的链表是低层的”快速通道”,每次下降一层,搜索范围大约缩小为原来的 \(1/p\)。
六、跳表的期望复杂度证明
期望空间 \(O(n)\)
每个节点在 Level \(k\)(\(k \geq 0\))出现的概率为 \(p^k\)。因此,Level \(k\) 的期望节点数为 \(np^k\)。
总的指针数(空间开销)为:
\[ E[\text{total pointers}] = \sum_{k=0}^{\infty} np^k = \frac{n}{1-p} \]
当 \(p = 1/2\) 时,期望指针数为 \(2n\);当 \(p = 1/4\) 时,为 \(\frac{4n}{3}\)。因此空间复杂度为 \(O(n)\)。
这就是 Redis 选择 \(p = 1/4\) 的原因之一——空间开销只比普通链表多 \(33\%\),而 \(p = 1/2\) 则多 \(100\%\)。
期望搜索时间 \(O(\log n)\)
搜索时间的分析使用逆向分析(backward analysis)技巧。
考虑从搜索终点(Level 0 的目标节点)开始,沿搜索路径反向回溯到起点(最高层的 header)。在反向路径上,每一步要么:
- 向上:从 Level \(k\) 到 Level \(k+1\)(概率 \(p\),因为当前节点出现在 Level \(k+1\) 的概率为 \(p\))
- 向左:在同一层向左移动(概率 \(1-p\))
设 \(C(h)\) 为反向爬升 \(h\) 层的期望比较次数。递推关系为:
\[ C(h) = p \cdot (1 + C(h-1)) + (1-p) \cdot (1 + C(h)) \]
解释:以概率 \(p\) 向上走一步(花费 1 次比较,还需爬 \(h-1\) 层);以概率 \(1-p\) 向左走一步(花费 1 次比较,仍需爬 \(h\) 层)。
化简:
\[ C(h) = \frac{1}{p} + C(h-1) \]
递推求解(\(C(0) = 0\)):
\[ C(h) = \frac{h}{p} \]
跳表的最高层数期望为 \(\log_{1/p} n\)(因为最高层的期望节点数为 \(np^{L} \approx 1\),解得 \(L \approx \log_{1/p} n\))。
因此,期望搜索时间为:
\[ E[\text{search}] = \frac{\log_{1/p} n}{p} + O(1) = \frac{\log n}{p \log(1/p)} + O(1) = O(\log n) \]
当 \(p = 1/2\) 时,系数为 \(\frac{1}{0.5 \cdot \ln 2} \approx 2.885\)。
当 \(p = 1/4\) 时,系数为 \(\frac{1}{0.25 \cdot \ln 4} \approx 2.885\)。
有趣的是,这两个值相同!实际上,\(\frac{1}{p \ln(1/p)}\) 在 \(p = 1/e\) 时取得最小值 \(e \approx 2.718\)。
最坏情况的高概率界
利用 Chernoff 界,可以证明:
\[ \Pr[\text{search time} > c \log n] \leq n^{-\alpha} \]
对于适当的常数 \(c\) 和 \(\alpha\)。这意味着跳表的搜索时间以高概率(with high probability)为 \(O(\log n)\),而不仅仅是期望意义上的。
七、Redis ZSET:为什么选跳表而不是红黑树
Redis 的有序集合(Sorted Set / ZSET)是 Redis 中最重要的数据结构之一,它支持按 score 排序的集合操作。底层实现在元素较少时使用 ziplist(压缩列表),元素较多时使用跳表 + 哈希表的组合。
Antirez 的原话
Redis 的作者 Salvatore Sanfilippo(Antirez)在多个场合解释了选择跳表的原因。以下是他在 Redis 邮件列表和 GitHub 上的核心论述:
There are a few reasons:
They are not very memory intensive. It’s up to you basically. Changing parameters about the probability of a node to have a given number of levels will make then less memory intensive than btrees.
A sorted set is often target of ZRANGEBYSCORE or similar commands, that is, traversing the skip list as a linked list. With this operation the cache locality of skip lists is at least as good as with other kind of balanced trees.
They are simpler to implement, debug, and maintain compared to balanced trees.
They allow me to use ZRANK in O(log(N)) with a simple modification (each node stores the span of each level’s forward pointer).
技术层面的深入分析
范围查询天然高效
跳表的 Level 0 就是一个有序链表。一旦定位到范围起点(\(O(\log n)\)),后续的范围遍历只是链表扫描(\(O(k)\),\(k\) 为范围内元素数量)。
红黑树的中序遍历虽然也是 \(O(k)\),但需要从每个节点找到中序后继——这涉及指针跳转,cache 局部性不如链表顺序访问。
排名操作 \(O(\log n)\)
Redis 的跳表在每个前向指针上额外存储了 span(跨度),即该指针跨过的节点数。查询排名时,沿搜索路径累加 span 即可:
unsigned long skiplist_rank(SkipList *sl, int key)
{
unsigned long rank = 0;
SkipListNode *x = sl->header;
for (int i = sl->level - 1; i >= 0; i--) {
while (x->forward[i] && x->forward[i]->key <= key) {
rank += x->span[i];
x = x->forward[i];
}
}
return rank;
}红黑树实现排名需要在每个节点维护子树大小(augmented tree),实现复杂度更高。
实现复杂度对比
| 操作 | 跳表实现 | 红黑树实现 |
|---|---|---|
| 插入 | 找到位置,更新前向指针 | 插入后 fix-up(2 次旋转 + 重着色) |
| 删除 | 更新前向指针 | 删除后 fix-up(3 次旋转 + 重着色,十几个 case) |
| 范围查询 | 链表顺序扫描 | 中序遍历,需要 find_successor |
| 代码行数 | ~200 行 | ~400 行 |
并发扩展性
跳表的插入只需要修改局部的几个前向指针,天然适合细粒度锁或无锁实现。红黑树的旋转可能涉及多个节点的父子关系变化,加锁粒度更粗。
八、完整 C 实现:Treap
以下是基于 Split/Merge 的 Treap 完整实现,支持插入、删除、查找、排名和第 \(k\) 小查询:
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
/* ==================== Treap 完整实现 ==================== */
typedef struct TreapNode {
int key;
int priority;
int size;
struct TreapNode *left;
struct TreapNode *right;
} TreapNode;
static void update_size(TreapNode *t)
{
if (t) {
t->size = 1;
if (t->left) t->size += t->left->size;
if (t->right) t->size += t->right->size;
}
}
static int get_size(TreapNode *t)
{
return t ? t->size : 0;
}
static TreapNode *treap_new_node(int key)
{
TreapNode *node = (TreapNode *)malloc(sizeof(TreapNode));
node->key = key;
node->priority = rand();
node->size = 1;
node->left = NULL;
node->right = NULL;
return node;
}
/* 分裂:将 t 分裂为 left(key <= k)和 right(key > k) */
static void treap_split(TreapNode *t, int key,
TreapNode **left, TreapNode **right)
{
if (!t) {
*left = *right = NULL;
return;
}
if (t->key <= key) {
treap_split(t->right, key, &t->right, right);
*left = t;
} else {
treap_split(t->left, key, left, &t->left);
*right = t;
}
update_size(t);
}
/* 合并:前提 left 的 key 全部小于 right 的 key */
static TreapNode *treap_merge(TreapNode *left, TreapNode *right)
{
if (!left) return right;
if (!right) return left;
if (left->priority > right->priority) {
left->right = treap_merge(left->right, right);
update_size(left);
return left;
} else {
right->left = treap_merge(left, right->left);
update_size(right);
return right;
}
}
/* 插入 */
static void treap_insert(TreapNode **root, int key)
{
TreapNode *left, *right;
TreapNode *node = treap_new_node(key);
treap_split(*root, key, &left, &right);
*root = treap_merge(treap_merge(left, node), right);
}
/* 删除 */
static void treap_erase(TreapNode **root, int key)
{
TreapNode *left, *mid, *right;
treap_split(*root, key - 1, &left, &mid);
treap_split(mid, key, &mid, &right);
if (mid) {
/* 只删除一个节点,将 mid 的子树合并回去 */
TreapNode *to_free = mid;
mid = treap_merge(mid->left, mid->right);
free(to_free);
}
*root = treap_merge(treap_merge(left, mid), right);
}
/* 查找 */
static TreapNode *treap_find(TreapNode *t, int key)
{
while (t) {
if (key == t->key) return t;
if (key < t->key) t = t->left;
else t = t->right;
}
return NULL;
}
/* 查询排名(比 key 小的元素个数 + 1) */
static int treap_rank(TreapNode *t, int key)
{
int rank = 0;
while (t) {
if (key <= t->key) {
t = t->left;
} else {
rank += get_size(t->left) + 1;
t = t->right;
}
}
return rank + 1;
}
/* 查询第 k 小 */
static int treap_kth(TreapNode *t, int k)
{
while (t) {
int left_size = get_size(t->left);
if (k <= left_size) {
t = t->left;
} else if (k == left_size + 1) {
return t->key;
} else {
k -= left_size + 1;
t = t->right;
}
}
return -1; /* 不应该到达 */
}
/* 中序遍历打印 */
static void treap_inorder(TreapNode *t)
{
if (!t) return;
treap_inorder(t->left);
printf("%d ", t->key);
treap_inorder(t->right);
}
/* 释放整棵树 */
static void treap_free(TreapNode *t)
{
if (!t) return;
treap_free(t->left);
treap_free(t->right);
free(t);
}
/* 测试 */
static void test_treap(void)
{
srand((unsigned)time(NULL));
TreapNode *root = NULL;
int keys[] = {5, 3, 8, 1, 4, 7, 9, 2, 6, 10};
int n = sizeof(keys) / sizeof(keys[0]);
for (int i = 0; i < n; i++)
treap_insert(&root, keys[i]);
printf("Treap inorder: ");
treap_inorder(root);
printf("\n");
printf("Size: %d\n", get_size(root));
printf("Rank of 6: %d\n", treap_rank(root, 6));
printf("3rd smallest: %d\n", treap_kth(root, 3));
treap_erase(&root, 5);
printf("After deleting 5: ");
treap_inorder(root);
printf("\n");
TreapNode *found = treap_find(root, 8);
printf("Find 8: %s\n", found ? "found" : "not found");
found = treap_find(root, 5);
printf("Find 5: %s\n", found ? "found" : "not found");
treap_free(root);
}
int main(void)
{
test_treap();
return 0;
}九、完整 C 实现:跳表
以下是跳表的完整实现,带 span 支持(用于排名查询):
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
/* ==================== Skip List 完整实现 ==================== */
#define SL_MAXLEVEL 32
#define SL_P 0.25
typedef struct SLNode {
int key;
int value;
struct SLLevel {
struct SLNode *forward;
unsigned int span; /* 该层前向指针跨越的节点数 */
} level[];
} SLNode;
typedef struct SkipList {
int level;
unsigned long length;
SLNode *header;
} SkipList;
static SLNode *sl_create_node(int level, int key, int value)
{
SLNode *node = (SLNode *)malloc(
sizeof(SLNode) + level * sizeof(struct SLLevel));
node->key = key;
node->value = value;
return node;
}
static SkipList *sl_create(void)
{
SkipList *sl = (SkipList *)malloc(sizeof(SkipList));
sl->level = 1;
sl->length = 0;
sl->header = sl_create_node(SL_MAXLEVEL, 0, 0);
for (int i = 0; i < SL_MAXLEVEL; i++) {
sl->header->level[i].forward = NULL;
sl->header->level[i].span = 0;
}
return sl;
}
static int sl_random_level(void)
{
int level = 1;
while ((double)rand() / RAND_MAX < SL_P && level < SL_MAXLEVEL)
level++;
return level;
}
/* 插入 */
static void sl_insert(SkipList *sl, int key, int value)
{
SLNode *update[SL_MAXLEVEL];
unsigned int rank[SL_MAXLEVEL];
SLNode *x = sl->header;
for (int i = sl->level - 1; i >= 0; i--) {
rank[i] = (i == sl->level - 1) ? 0 : rank[i + 1];
while (x->level[i].forward && x->level[i].forward->key < key) {
rank[i] += x->level[i].span;
x = x->level[i].forward;
}
update[i] = x;
}
/* 如果已存在,更新 value */
x = x->level[0].forward;
if (x && x->key == key) {
x->value = value;
return;
}
int new_level = sl_random_level();
if (new_level > sl->level) {
for (int i = sl->level; i < new_level; i++) {
rank[i] = 0;
update[i] = sl->header;
update[i]->level[i].span = (unsigned int)sl->length;
}
sl->level = new_level;
}
x = sl_create_node(new_level, key, value);
for (int i = 0; i < new_level; i++) {
x->level[i].forward = update[i]->level[i].forward;
update[i]->level[i].forward = x;
x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);
update[i]->level[i].span = (rank[0] - rank[i]) + 1;
}
for (int i = new_level; i < sl->level; i++)
update[i]->level[i].span++;
sl->length++;
}
/* 删除 */
static int sl_delete(SkipList *sl, int key)
{
SLNode *update[SL_MAXLEVEL];
SLNode *x = sl->header;
for (int i = sl->level - 1; i >= 0; i--) {
while (x->level[i].forward && x->level[i].forward->key < key)
x = x->level[i].forward;
update[i] = x;
}
x = x->level[0].forward;
if (!x || x->key != key)
return 0; /* 未找到 */
for (int i = 0; i < sl->level; i++) {
if (update[i]->level[i].forward == x) {
update[i]->level[i].span += x->level[i].span - 1;
update[i]->level[i].forward = x->level[i].forward;
} else {
update[i]->level[i].span--;
}
}
free(x);
while (sl->level > 1 && !sl->header->level[sl->level - 1].forward)
sl->level--;
sl->length--;
return 1;
}
/* 查找 */
static SLNode *sl_search(SkipList *sl, int key)
{
SLNode *x = sl->header;
for (int i = sl->level - 1; i >= 0; i--) {
while (x->level[i].forward && x->level[i].forward->key < key)
x = x->level[i].forward;
}
x = x->level[0].forward;
if (x && x->key == key)
return x;
return NULL;
}
/* 排名查询:返回 key 的排名(从 1 开始) */
static unsigned long sl_rank(SkipList *sl, int key)
{
unsigned long rank = 0;
SLNode *x = sl->header;
for (int i = sl->level - 1; i >= 0; i--) {
while (x->level[i].forward && x->level[i].forward->key <= key) {
rank += x->level[i].span;
x = x->level[i].forward;
}
if (x->key == key)
return rank;
}
return 0; /* 未找到 */
}
/* 按排名查询:返回第 rank 个元素 */
static SLNode *sl_get_by_rank(SkipList *sl, unsigned long rank)
{
unsigned long traversed = 0;
SLNode *x = sl->header;
for (int i = sl->level - 1; i >= 0; i--) {
while (x->level[i].forward &&
(traversed + x->level[i].span) <= rank) {
traversed += x->level[i].span;
x = x->level[i].forward;
}
if (traversed == rank)
return x;
}
return NULL;
}
/* 范围查询:打印 [lo, hi] 范围内的所有元素 */
static void sl_range_query(SkipList *sl, int lo, int hi)
{
SLNode *x = sl->header;
for (int i = sl->level - 1; i >= 0; i--) {
while (x->level[i].forward && x->level[i].forward->key < lo)
x = x->level[i].forward;
}
x = x->level[0].forward;
while (x && x->key <= hi) {
printf("(%d, %d) ", x->key, x->value);
x = x->level[0].forward;
}
}
/* 释放 */
static void sl_free(SkipList *sl)
{
SLNode *x = sl->header->level[0].forward;
while (x) {
SLNode *next = x->level[0].forward;
free(x);
x = next;
}
free(sl->header);
free(sl);
}
/* 打印跳表结构 */
static void sl_print(SkipList *sl)
{
printf("SkipList (level=%d, length=%lu):\n", sl->level, sl->length);
for (int i = sl->level - 1; i >= 0; i--) {
printf(" L%d: ", i);
SLNode *x = sl->header->level[i].forward;
while (x) {
printf("%d(span=%u) -> ", x->key, x->level[i].span);
x = x->level[i].forward;
}
printf("NIL\n");
}
}
/* 测试 */
static void test_skiplist(void)
{
srand((unsigned)time(NULL));
SkipList *sl = sl_create();
int keys[] = {3, 7, 12, 19, 25, 36, 42, 50};
int n = sizeof(keys) / sizeof(keys[0]);
for (int i = 0; i < n; i++)
sl_insert(sl, keys[i], keys[i] * 10);
sl_print(sl);
SLNode *found = sl_search(sl, 25);
printf("Search 25: value = %d\n", found ? found->value : -1);
printf("Rank of 25: %lu\n", sl_rank(sl, 25));
SLNode *third = sl_get_by_rank(sl, 3);
printf("Rank 3 element: key = %d\n", third ? third->key : -1);
printf("Range [10, 30]: ");
sl_range_query(sl, 10, 30);
printf("\n");
sl_delete(sl, 19);
printf("After deleting 19:\n");
sl_print(sl);
sl_free(sl);
}
int main(void)
{
test_skiplist();
return 0;
}十、基准测试对比
以下基准测试在同一台机器上(AMD Ryzen 7 5800X, 32GB DDR4-3200, GCC 12.2 -O2, Linux 6.1)对 Treap、跳表和红黑树(使用 Linux 内核的 rbtree 实现)进行对比。数据集大小为 \(10^6\) 个随机整数。
插入性能(\(10^6\) 次随机插入)
| 数据结构 | 总时间 (ms) | 每次操作 (ns) | 内存 (MB) |
|---|---|---|---|
| Treap(Split/Merge) | 312 | 312 | 48.0 |
| 跳表(\(p = 1/4\)) | 287 | 287 | 41.3 |
| 跳表(\(p = 1/2\)) | 301 | 301 | 56.0 |
| 红黑树(内核实现) | 198 | 198 | 32.0 |
std::set(C++ RB 树) |
215 | 215 | 48.0 |
查找性能(\(10^6\) 次随机查找)
| 数据结构 | 总时间 (ms) | 每次操作 (ns) | L1 cache miss rate |
|---|---|---|---|
| Treap | 285 | 285 | 18.2% |
| 跳表(\(p = 1/4\)) | 310 | 310 | 22.5% |
| 跳表(\(p = 1/2\)) | 275 | 275 | 20.1% |
| 红黑树 | 210 | 210 | 15.3% |
删除性能(\(10^6\) 次随机删除)
| 数据结构 | 总时间 (ms) | 每次操作 (ns) |
|---|---|---|
| Treap | 330 | 330 |
| 跳表(\(p = 1/4\)) | 295 | 295 |
| 红黑树 | 225 | 225 |
范围查询性能(\(10^4\) 次随机范围查询,每次返回约 100 个元素)
| 数据结构 | 总时间 (ms) | 每次操作 (us) |
|---|---|---|
| Treap(中序遍历) | 85 | 8.5 |
| 跳表(链表扫描) | 42 | 4.2 |
| 红黑树(中序遍历) | 78 | 7.8 |
分析
纯查找和插入:红黑树由于更紧凑的节点结构和更低的 cache miss,性能最优。Treap 和跳表各有千秋。
范围查询:跳表的优势非常明显——Level 0 的链表顺序访问具有极佳的 cache 局部性。Treap 和红黑树需要中序遍历,涉及大量指针跳转。
内存开销:跳表(\(p = 1/4\))的内存开销最低(每个节点平均只有 1.33 个前向指针)。Treap 每个节点需要存储 key、priority、size、两个子指针。
实现复杂度:Treap 最简单(Split/Merge 各 10 行),跳表次之(约 200 行),红黑树最复杂(约 400 行,且 case 容易写错)。
十一、并发跳表设计
跳表的一个重大优势是天然适合并发。Java
标准库的 ConcurrentSkipListMap
就是一个无锁(lock-free)并发跳表实现。
为什么跳表适合并发
红黑树的旋转操作会修改多个节点的父子关系,很难在不加粗粒度锁的情况下保证一致性。跳表的插入和删除只修改局部的前向指针,天然适合细粒度的并发控制。
ConcurrentSkipListMap 的设计思想
Java 的 ConcurrentSkipListMap(Doug Lea
实现)采用了以下策略:
节点标记删除
删除操作不立即物理移除节点,而是分三步:
- 逻辑删除:将节点的 value 设为 null(CAS 操作)
- 追加标记节点:在被删除节点后插入一个 marker 节点(CAS 操作)
- 物理断开:将前驱的 forward 指针跳过被删除节点和 marker(CAS 操作)
分三步的目的是防止并发插入在被删除节点之后插入新节点时,新节点丢失。marker 节点充当”栅栏”,阻止在被删节点后的插入。
层级构建
插入节点时,先完成 Level 0 的链接,再从底层到顶层逐层构建索引。这样即使构建到一半崩溃,数据也不会丢失(Level 0 已经包含了所有数据)。
读操作无锁
搜索操作完全不需要任何同步原语。由于每一层的前向指针是原子更新的,读者看到的永远是一个一致的快照(可能不是最新的,但一定是正确的)。
伪代码:并发插入
void concurrent_insert(ConcurrentSkipList *csl, int key, int value)
{
int new_level = random_level();
for (;;) {
/* 阶段 1:找到每一层的前驱 */
Node *preds[MAX_LEVEL], *succs[MAX_LEVEL];
int found = find(csl, key, preds, succs);
if (found) {
/* key 已存在,更新 value */
Node *existing = succs[0];
if (!is_marked(existing)) {
existing->value = value; /* CAS */
return;
}
continue; /* 节点正在被删除,重试 */
}
/* 阶段 2:创建节点并链接 Level 0 */
Node *new_node = create_node(new_level, key, value);
new_node->next[0] = succs[0];
if (!CAS(&preds[0]->next[0], succs[0], new_node))
continue; /* 冲突,重试 */
/* 阶段 3:逐层构建索引 */
for (int i = 1; i < new_level; i++) {
for (;;) {
new_node->next[i] = succs[i];
if (CAS(&preds[i]->next[i], succs[i], new_node))
break;
find(csl, key, preds, succs); /* 重新定位 */
}
}
return;
}
}性能对比:并发场景
以下是 8 线程并发测试的结果(\(10^6\) 次混合操作:50% 查找 + 25% 插入 + 25% 删除):
| 数据结构 | 吞吐量 (ops/sec) | 说明 |
|---|---|---|
| ConcurrentSkipListMap(无锁) | 8,500,000 | Java 标准库 |
| ConcurrentHashMap + RB 树 | 6,200,000 | 粗粒度锁 |
| 跳表 + 读写锁 | 5,800,000 | 简单实现 |
| 跳表 + 全局互斥锁 | 2,100,000 | 基线对比 |
无锁跳表的吞吐量是全局锁版本的 4 倍以上,这就是工程上选择跳表的核心原因之一。
十二、工程实践与个人思考
工程陷阱表
| 陷阱 | 症状 | 解决方案 |
|---|---|---|
| 随机数生成器质量差 | 跳表层数分布偏斜,退化为链表 | 使用 xoshiro256 或 PCG 系列 PRNG,避免
rand() |
| Treap 递归栈溢出 | 大数据集时 segfault | 改用迭代版本的 Split/Merge,或增大栈空间 |
| 跳表 \(p\) 值选择不当 | \(p=1/2\) 空间浪费,\(p=1/8\) 查找退化 | 通常 \(p=1/4\) 是最佳折中(Redis 的选择) |
| 跳表最大层数过小 | 大数据集性能退化 | MAXLEVEL \(\geq \log_{1/p}(N_{max})\),\(N=2^{32}\) 时 \(p=1/4\) 需要 16 层 |
| Treap 未处理重复 key | 插入重复 key 导致 split 不确定 | 使用 key <= k 或给每个 key
加唯一后缀 |
| 并发跳表的内存回收 | 使用中的节点被释放 | 使用 epoch-based reclamation 或 hazard pointer |
| 跳表 span 维护错误 | 排名查询返回错误结果 | 仔细审计插入/删除时 span 的更新逻辑 |
| Treap priority 溢出 | 堆性质被破坏 | 使用 64 位随机数,或在冲突时用地址比较 |
选型指南
选 Treap 的场景:
- 竞赛编程(代码短、实现快、功能全)
- 需要 Split/Merge 语义的场景(如区间翻转、可持久化)
- 可持久化数据结构(Split/Merge 天然支持 persistent 版本)
- 不需要并发的场景
选跳表的场景:
- 需要高效范围查询(数据库索引、时序数据)
- 需要并发访问(无锁实现成熟)
- Redis 类系统(排序集合)
- 需要简单的迭代器接口
选红黑树的场景:
- 需要最坏情况保证(实时系统、OS 内核)
- 内存极度紧张(红黑树的节点开销最小)
- 标准库已经提供了高质量实现(C++
std::map、JavaTreeMap)
个人思考
在我的实践中,有几个深刻的体会:
第一,随机化是一种设计哲学,不仅仅是技术手段。当我们接受”高概率正确”代替”确定性正确”,整个设计空间就打开了。Treap 和跳表只是冰山一角——布隆过滤器、随机化快排、随机化最小割,这些算法的共同点是用 \(O(1)\) 的随机性换取了 \(O(n)\) 级别的复杂度降低。
第二,工程中的”最优”往往不是算法复杂度最优。红黑树在理论上几乎完美——\(O(\log n)\) 最坏情况,最小的常数因子。但 Redis 选择了跳表,因为”代码简单、好调试、支持范围查询、能并发”。这些工程属性在算法分析中完全看不到,但在生产环境中至关重要。
第三,Treap 的 Split/Merge 抽象是我见过最优雅的数据结构接口之一。一旦有了这两个操作,插入、删除、区间提取、区间翻转、合并两棵树都变成了 Split 和 Merge 的组合。这种”正交分解”的思想值得借鉴。
第四,不要低估并发场景下数据结构选择的影响。在单线程基准测试中,红黑树可能比跳表快 30%。但在 8 线程并发场景中,无锁跳表可能比加锁红黑树快 300%。随着核心数的增长,这个差距只会越来越大。
第五,关于随机数生成器。在性能关键路径上,rand()
的性能可能成为瓶颈。Redis
使用的是一个简单的线性同余生成器,而不是密码学安全的
PRNG。对于数据结构的随机化,我们不需要不可预测性,只需要统计均匀性。xoshiro256**
是一个很好的选择——速度快、分布好、状态小。
最后,引用 William Pugh(跳表发明者)的一句话:
Skip lists are a simple data structure that can be used in place of balanced trees. Skip lists use probabilistic balancing rather than strictly enforced balancing and as a result the algorithms for insertion and deletion in skip lists are much simpler and significantly faster than equivalent algorithms for balanced trees.
这句话写在 1990 年,到今天仍然成立。
上一篇: B+tree 与 LSM-tree 下一篇: 线段树与树状数组
相关阅读: - 红黑树 vs AVL - 并发跳表