一、为什么需要跳表
假设你在设计一个排行榜系统,需要支持三种操作:按分数精确查找某个用户、插入新成绩、按分数范围查询前 100 名。最直觉的方案是平衡树——红黑树能保证 O(log n) 的查找和插入,但实现起来让人头疼:左旋、右旋、变色,插入和删除各有七八种情况要处理,代码动辄几百行,调试时旋转逻辑错一步整棵树就废了。
用有序数组加二分查找?查找是 O(log n),但插入和删除要移动元素,O(n) 的代价不可接受。用链表?插入删除 O(1),但查找只能从头遍历,O(n)。
有没有一种数据结构,既能像平衡树那样 O(log n) 查找,又像链表那样插入删除简单,还不用写那些让人崩溃的旋转代码?
跳表就是答案。它用概率平衡代替严格的旋转平衡,实现复杂度远低于红黑树,平均性能却同样优秀。Redis 的 Sorted Set、LevelDB 的 Memtable 都选择了跳表而非红黑树,原因很简单:简单本身就是竞争力。
二、现实类比
想象一条地铁线路,从 A 站到 Z 站共 26 站。如果只有一条慢车线,每站都停,从 A 到 Z 要坐 25 站。现在加一条快车线,只停 A、G、M、S、Z 五站,从 A 到 Z 只需坐 4 站。如果再加一条特快线,只停 A、M、Z 三站,那就更快了。
跳表的结构完全一样:最底层是完整的有序链表(慢车线,每站都停),上面的每一层是下层的「快车线」,跳过部分节点。查找时从最高层开始,遇到目标节点在当前层的前一个节点就往下走,逐层缩小范围,最终在最底层找到目标。
关键区别在于:地铁的快车线路是人工规划的,跳表的「快车线」是随机生成的——每个节点以概率 p=0.5 被提升到上一层。这种随机性看似不靠谱,实际上数学期望保证了层数为 O(log n),查找路径长度也是 O(log n)。就像抛硬币,单次结果不可预测,但抛一万次正反面各约五千次,概率的稳定性远比直觉可靠。
三、核心思想
跳表的本质是一个多级链表。最底层(第 0 层)包含所有节点,按 key 有序排列。每一层是下一层的「索引」,只保留部分节点。每个节点有一个随机的层数 level,它在第 0 到 level-1 层都有前向指针。
3.1 查找过程
以查找 key=5 为例:
- 从最高层(第 3 层)的 header 出发,3 < 5,前进到 3
- 3 的下一节点是 7,7 > 5,下降到第 2 层
- 第 2 层:3 的下一节点是 5,5 == 5,找到
整个过程只走了 2 步,而底层链表需要走 3 步。节点越多,差距越明显。
3.2 随机层数生成
每个新节点插入时,通过抛硬币决定它的层数:每抛一次,正面就层数加一继续抛,反面就停止。p=0.5 时,约 50% 的节点只有 1 层,25% 有 2 层,12.5% 有 3 层……最高层节点数期望为 n/2^L,L 为层数。
// 随机层数生成,p = 0.5func randomLevel(maxLevel int) int { level := 1 for rand.Float64() < 0.5 && level < maxLevel { level++ } return level}3.3 复杂度
| 操作 | 平均复杂度 | 最坏复杂度 | 说明 |
|---|---|---|---|
| 查找 | O(log n) | O(n) | 最坏情况:所有节点都在同一层,退化为链表 |
| 插入 | O(log n) | O(n) | 包含查找 + 更新前向指针 |
| 删除 | O(log n) | O(n) | 包含查找 + 更新前向指针 |
| 范围查询 | O(log n + k) | O(n + k) | k 为返回元素个数 |
理论最坏 O(n) 看起来很吓人,但发生概率极低——所有节点都随机到同一层的概率是 (1/2)^(n-1),n=100 时约为 6.3×10^-31,比连续中彩票还难。这和哈希表最坏 O(n) 是一个道理:理论上可能,实际上不会遇到。
四、变体与对比
4.1 跳表 vs 红黑树 vs B+ 树
| 特性 | 跳表 | 红黑树 | B+ 树 |
|---|---|---|---|
| 查找 | O(log n) 平均 | O(log n) 确定 | O(log n) 确定 |
| 插入/删除 | O(log n) 平均 | O(log n) 确定 | O(log n) 确定 |
| 范围查询 | O(log n + k),沿前向指针遍历 | O(log n + k),中序遍历 | O(log n + k),叶子链表遍历 |
| 实现复杂度 | 低(~200 行) | 高(~500 行,旋转逻辑复杂) | 中(节点分裂合并) |
| 并发友好 | 高(无锁变体简单) | 低(旋转影响范围大) | 中(需锁粒度控制) |
| 内存开销 | 较大(每节点多个前向指针) | 较小(每节点 2-3 个指针) | 可控(节点内多 key 共享指针) |
| 磁盘适配 | 差(指针跳跃,缓存不友好) | 差(同上) | 好(页对齐,顺序扫描) |
| 平衡方式 | 概率平衡 | 严格旋转平衡 | 节点分裂合并 |
4.2 Redis 为什么选跳表不选红黑树
Redis 作者 antirez 在设计 Sorted Set 时考虑过红黑树,最终选择跳表,原因有三:
- 实现简单:跳表的插入删除不需要旋转,代码量少,bug 也少
- 范围查询高效:跳表的前向指针天然支持从某个节点开始顺序遍历,
ZRANGE类命令实现直接 - 并发潜力:虽然 Redis 是单线程的,但跳表的无锁变体比红黑树的无锁变体简单得多,为未来留了余地
五、多语言实现
package skiplist
import ( "math/rand")
const maxLevel = 16 // 最大层数,2^16 个节点足够
// 节点type node struct { key int value any next []*node // 每层的前向指针}
// 跳表type SkipList struct { header *node level int // 当前最大层数 size int // 节点数}
func New() *SkipList { return &SkipList{ header: &node{next: make([]*node, maxLevel)}, level: 1, }}
// 随机层数,p = 0.5func randomLevel() int { lv := 1 for rand.Float64() < 0.5 && lv < maxLevel { lv++ } return lv}
// 查找func (sl *SkipList) Get(key int) (any, bool) { cur := sl.header // 从最高层往下找 for i := sl.level - 1; i >= 0; i-- { for cur.next[i] != nil && cur.next[i].key < key { cur = cur.next[i] } } cur = cur.next[0] // 移到第 0 层的下一个节点 if cur != nil && cur.key == key { return cur.value, true } return nil, false}
// 插入func (sl *SkipList) Put(key int, value any) { // 记录每层的前驱节点 prev := make([]*node, maxLevel) cur := sl.header for i := sl.level - 1; i >= 0; i-- { for cur.next[i] != nil && cur.next[i].key < key { cur = cur.next[i] } prev[i] = cur }
// key 已存在则更新 if nxt := cur.next[0]; nxt != nil && nxt.key == key { nxt.value = value return }
lv := randomLevel() if lv > sl.level { // 新层数高于当前层数,header 作为新层的前驱 for i := sl.level; i < lv; i++ { prev[i] = sl.header } sl.level = lv }
nd := &node{key: key, value: value, next: make([]*node, lv)} for i := 0; i < lv; i++ { nd.next[i] = prev[i].next[i] // 新节点指向后继 prev[i].next[i] = nd // 前驱指向新节点 } sl.size++}const MAX_LEVEL = 16;
class SkipNode { key: number; value: string; next: (SkipNode | null)[];
constructor(key: number, value: string, level: number) { this.key = key; this.value = value; this.next = new Array(level).fill(null); }}
class SkipList { private header: SkipNode; private level: number = 1; private size: number = 0;
constructor() { // header 不存数据,仅作为每层起点 this.header = new SkipNode(-Infinity, "", MAX_LEVEL); }
// 随机层数,p = 0.5 private randomLevel(): number { let lv = 1; while (Math.random() < 0.5 && lv < MAX_LEVEL) lv++; return lv; }
// 查找 get(key: number): string | null { let cur = this.header; for (let i = this.level - 1; i >= 0; i--) { while (cur.next[i] !== null && cur.next[i]!.key < key) { cur = cur.next[i]!; } } const target = cur.next[0]; return target !== null && target.key === key ? target.value : null; }
// 插入 put(key: number, value: string): void { const prev: (SkipNode | null)[] = new Array(MAX_LEVEL).fill(null); let cur = this.header; for (let i = this.level - 1; i >= 0; i--) { while (cur.next[i] !== null && cur.next[i]!.key < key) { cur = cur.next[i]!; } prev[i] = cur; }
const nxt = cur.next[0]; if (nxt !== null && nxt.key === key) { nxt.value = value; // 已存在则更新 return; }
const lv = this.randomLevel(); if (lv > this.level) { for (let i = this.level; i < lv; i++) prev[i] = this.header; this.level = lv; }
const nd = new SkipNode(key, value, lv); for (let i = 0; i < lv; i++) { nd.next[i] = prev[i]!.next[i]; prev[i]!.next[i] = nd; } this.size++; }}六、生产验证
6.1 Redis Sorted Set
Redis 的有序集合(Sorted Set)底层使用跳表 + 哈希表实现。跳表负责按分数有序存储和范围查询,哈希表负责 O(1) 的成员查找。
- 仓库:redis/redis
- 跳表节点定义:
src/server.h#L1763-L1773——zskiplistNode包含score、backward回退指针和level[]前向指针数组 - 跳表结构定义:
src/server.h#L1775-L1780——zskiplist包含header、tail、length和level - 插入实现:
src/t_zset.c#L326——zslInsert函数,查找前驱 + 随机层数 + 更新指针
Redis 选择跳表的核心原因:ZRANGE、ZRANGEBYSCORE 等范围查询命令只需沿第 0 层前向指针遍历,实现简单且高效。
6.2 LevelDB Memtable
LevelDB 的 Memtable 用跳表存储内存中的写入数据,读取时通过跳表有序遍历,刷盘时顺序写出为 SSTable。
- 仓库:google/leveldb
- 跳表类定义:
db/skiplist.h#L49——SkipList模板类 - 节点定义:
db/skiplist.h#L213——Node结构,前向指针使用std::atomic保证并发安全 - 插入实现:
db/skiplist.h#L317——Insert函数
LevelDB 采用 SWMR(Single Writer Multiple Reader)模式:写操作加锁串行化,读操作通过原子指针无锁访问。跳表的前向指针是 std::atomic<Node*>,写者更新指针时用 release 语义,读者用 acquire 语义,保证读者看到一致的链表状态。这种无锁读设计比红黑树容易实现得多——红黑树的旋转会同时修改多个指针,无锁一致性几乎不可能保证。
6.3 RocksDB Memtable
RocksDB 继承了 LevelDB 的跳表 Memtable,并增加了 InlineSkipList 变体,将 key 直接嵌入节点内存,减少一次指针跳转,提升缓存命中率。
- 仓库:facebook/rocksdb
- 跳表实现:
memtable/inlineskiplist.h——InlineSkipList模板类
七、小结
何时使用跳表
- 内存有序存储 + 快速点查:需要 O(log n) 查找和范围查询,又不想写红黑树
- 并发数据结构:无锁跳表比无锁红黑树简单一个数量级,CAS 操作只需更新单个前向指针
- 数据库 Memtable:写入有序、范围扫描频繁,跳表天然支持顺序遍历
- 范围查询场景:前向指针让「从某个 key 开始取 k 个」变得直接
何时不使用跳表
- 纯键值查找:不需要有序遍历时,哈希表 O(1) 更快更省内存
- 需要确定性性能保证:跳表是概率平衡,虽然最坏 O(n) 极端罕见,但某些实时系统不允许任何理论风险
- 内存受限:每个节点需要
O(log n)个前向指针,比红黑树多约 50% 的内存开销 - 持久化存储:磁盘 I/O 喜欢顺序读写,跳表的指针跳跃对缓存不友好,B+ 树更适合
八、参考资料
- Skip Lists: A Probabilistic Alternative to Balanced Trees - William Pugh, 1990, 跳表原始论文
- Redis Sorted Set 内部实现 - Redis 跳表实现,zslInsert 等核心函数
- LevelDB SkipList - LevelDB 跳表实现,SWMR 并发模型
- Why Redis Sorted Set Uses Skip List Instead of Balanced Tree - antirez 在 Hacker News 上的解释
- Art of Problem Solving: Skip List - 维基百科跳表词条,概率分析推导
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






