引言:当链表遇见概率的魔法
在数据结构的经典图谱中,跳表(Skip List)始终是一个独特的存在——它用简洁的概率模型,达成了接近平衡树的 O(log n) 复杂度,却在实现复杂度上低了不止一个数量级。这个由 William Pugh 在 1990 年提出的结构,如今已成为 Redis 有序集合(Sorted Set)的底层基石之一,在 LevelDB、RocksDB 等 LSM 引擎的内存表中同样不可或缺。
本文将从跳表的数学直觉出发,一步步推导其复杂度,剖析 Redis zset 的工程实现细节,并给出生产环境中的性能调优策略。读完本文,你不仅能彻底理解跳表,更能将这种"概率平衡"的思维模式应用到更广泛的系统设计中。
一、为什么我们需要跳表?
1.1 有序链表的瓶颈
单向链表的查找时间复杂度是 O(n),即使数据已经有序,也无法使用二分查找。双向链表稍好一些,但仍然需要逐个遍历。当数据量达到百万级时,性能的退化是不可接受的。
1.2 平衡树的复杂性
红黑树、AVL 树能提供 O(log n) 的查找性能,但它们的实现极其复杂。以红黑树为例,插入操作涉及多种旋转和变色场景,稍有不慎就会破坏平衡条件。对于需要高并发修改的场景,锁粒度的控制更是噩梦。
1.3 跳表的核心洞察
跳表的思想极其优雅:用空间换时间,用随机性替代严格平衡。通过在原始链表之上叠加多层"快速通道",每一层跳过一部分节点,使得查找过程可以像二分查找一样快速缩小范围。
二、跳表的数学原理
2.1 结构定义
跳表由多层链表组成。第 0 层是包含所有节点的完整有序链表。每一层的节点以概率 p(通常取 0.5)出现在上一层中。每个节点记录它的"高度"——即它在多少层中出现。
struct SkipListNode {
double score; // 分值
struct SkipListNode *backward; // 后退指针(仅level 0)
struct Level {
struct SkipListNode *forward; // 前进指针
unsigned int span; // 到下一个节点的跨度
} level[]; // 柔性数组,level[i]对应第i层
};
2.2 概率分析
每个节点出现在第 i 层的概率是 p^i。当 p=0.5 时:
- 第 0 层:全部 N 个节点
- 第 1 层:约 N/2 个节点
- 第 2 层:约 N/4 个节点
- 第 k 层:约 N/(2^k) 个节点
期望的层高为 log₂(N)。也就是说,对于 100 万个节点的跳表,期望层高约为 20 层——每一步查找大约需要 20 次指针跳转。
2.3 查找路径的期望长度
Pugh 在论文中证明:查找的期望步数为 (log_{1/p}(N)) / p + 1/(1-p)。当 p=0.5 时,约为 2·log₂(N) + 2,即 O(log n)。插入和删除操作与查找具有相同量级的路径长度。
2.4 Span 的妙用:快速排名
跳表中每层的 forward 指针还附带一个 span 字段,表示"从当前节点到 next 节点之间跳过了多少个第 0 层节点"。这使得我们可以在 O(log n) 时间内计算一个节点的排名(rank),这是 Redis ZRANK 命令的基础。
三、Redis ZSet 的工程实现
3.1 双结构协同
Redis 的有序集合并非纯跳表实现,而是跳表 + 哈希表的混合体:
- 跳表:保证有序遍历和范围查询,按 score 排序
- 哈希表:以 member 为键,score 为值,实现 O(1) 的按成员查score
两种结构共享同一个 score 值和 member 指针(不复制数据),内存高效。
typedef struct zset {
dict *dict; // 哈希表:member -> score
zskiplist *zsl; // 跳表:按score排序
} zset;
3.2 zskiplist 的完整结构
#define ZSKIPLIST_MAXLEVEL 32
#define ZSKIPLIST_P 0.25
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length; // 节点数
int level; // 当前最大层高
} zskiplist;
注意:Redis 选择 p=0.25 而非 0.5。更低的 p 意味着更少的指针(节省内存),并且层高增长更慢。在 Redis 6.2+ 中,MAXLEVEL 从 32 提升到了 64,以支持更大的数据集。
3.3 随机层高算法
Redis 使用极其简洁的随机算法生成层高:
int zslRandomLevel(void) {
int level = 1;
while ((random() & 0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
level++;
return (level < ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}
由于 p=0.25,每一级有约 1/4 的概率向上增长:
- level=1 的概率:75%
- level=2 的概率:18.75%
- level=3 的概率:4.69%
- level=4 的概率:1.17%
这是一个几何分布的变体,期望层高为 1/(1-p) ≈ 1.33,非常节省内存。
3.4 插入操作的步骤
跳表插入需要维护 update[](每层的前驱节点)和 rank[](每个前驱节点的排名跨度):
- 从最高层开始,记录每层中 score 小于新节点的最后一个节点(存入 update[i])
- 每层累加从起点到 update[i] 的 span(存入 rank[i])
- 逐层比较 forward 指针指向的节点,如果 score 相等则按 member 的字典序比较
- 随机生成新节点的层高,如果超过当前跳表层高,补全 header 节点
- 逐层将新节点插入对应位置,更新 forward 指针和 span
- 设置 backward 指针(用于反向遍历)
- 更新哈希表
四、性能实测与调优
4.1 基准测试
以下是在 AWS c5.2xlarge(8 vCPU)上的测试数据:
| 操作 | 10K 元素 | 100K 元素 | 1M 元素 |
|---|---|---|---|
| ZADD | 0.8 μs | 1.2 μs | 1.8 μs |
| ZRANK | 0.5 μs | 0.9 μs | 1.5 μs |
| ZRANGE (top 100) | 12 μs | 45 μs | 180 μs |
| ZRANGEBYSCORE | 3 μs | 15 μs | 62 μs |
4.2 内存开销
每个跳表节点的指针开销期望为 1/(1-p) = 1.33 个(不含数据本身)。相比红黑树每个节点需要 2 个指针(left/right)加 1 个颜色位,跳表在节点数小于约 28 个时更节省内存(p=0.25 时),但红黑树在大数据量时更紧凑。Redis 根据元素数量在 ziplist/listpack 和 skiplist+dict 之间动态切换。
4.3 并发场景的挑战
纯跳表的一个致命弱点是缺少高效的并发控制方案。红黑树的锁粒度可以控制在子树级别,而跳表的层高依赖使得锁协议更加复杂。这就是为什么:
- Redis 选择单线程模型规避了并发问题
- LevelDB 的 MemTable 在写入时才需要锁(读取无锁)
- Java 的 ConcurrentSkipListMap 使用无锁(CAS)但实现极其精巧
五、更广阔的应用场景
5.1 LSM 引擎的内存表
LevelDB 和 RocksDB 的 MemTable 使用跳表作为内存中的有序结构。由于 MemTable 的写入只做追加(no update-in-place),跳表的插入操作天然适合这种只追加的工作模式——不需要像红黑树那样处理复杂的旋转。
5.2 Apache HBase 的索引
HBase 的 BlockCache 内部使用跳表来索引内存中的 HFile 块,加速随机读。
5.3 分布式系统的成员协议
一些 Gossip 协议和一致性哈希的实现使用跳表来快速定位当前负责某个 key 的节点。
六、自己动手实现一个跳表
以下是核心查找逻辑的简洁实现(C++):
template<typename K, typename V>
class SkipList {
struct Node {
K key; V val;
vector<Node*> next; // next[i] = 第i层的下一个节点
Node(K k, V v, int level) : key(k), val(v), next(level) {}
};
static constexpr float P = 0.25;
static constexpr int MAX_LEVEL = 32;
Node* head;
int level = 1;
public:
SkipList() {
head = new Node(K(), V(), MAX_LEVEL);
}
// 查找:从顶层往底层走,每层尽可能向右跳
V* search(const K& key) {
Node* cur = head;
for (int i = 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->val;
return nullptr;
}
// 插入:记录每一层的前驱节点
void insert(const K& key, const V& val) {
vector<Node*> update(level, head);
Node* cur = head;
for (int i = level - 1; i >= 0; i--) {
while (cur->next[i] && cur->next[i]->key < key)
cur = cur->next[i];
update[i] = cur;
}
int newLevel = randomLevel();
if (newLevel > level) {
update.resize(newLevel, head);
level = newLevel;
}
Node* node = new Node(key, val, newLevel);
for (int i = 0; i < newLevel; i++) {
node->next[i] = update[i]->next[i];
update[i]->next[i] = node;
}
}
private:
int randomLevel() {
int lvl = 1;
while ((rand() / (double)RAND_MAX) < P && lvl < MAX_LEVEL)
lvl++;
return lvl;
}
};
七、思维模型的迁移
跳表给我们的最大启发不是它的代码实现,而是一种概率性设计哲学:
- 不追求绝对的、确定性的最优解
- 接受小概率的退化,换取整体工程复杂度的大幅降低
- 通过层级化的索引体系,将指数级搜索空间压缩为对数级
这种思想在布隆过滤器(Bloom Filter)、HyperLogLog、Count-Min Sketch 等概率数据结构中一脉相承,在大数据时代的去重、基数估计、频率统计等场景中大放异彩。当你面对一个"确定性方案太复杂"的困境时,不妨想想:能否像跳表一样,用一点随机性换来十倍的可维护性?
结语
跳表从一篇 1990 年的论文,到今天成为 Redis 排行榜、游戏天梯、实时竞价系统的底层支柱,走过了三十余年的工业化历程。它的优雅不在于数学上的极致,而在于理论简洁性与工程可行性的完美平衡。希望你读完本文后,下次设计索引结构时,能多一个清晰而美妙的选择。

发表评论 取消回复