mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2493 字
7 分钟
跳表(Skip List)
2026-06-13

一、为什么需要跳表#

假设你在设计一个排行榜系统,需要支持三种操作:按分数精确查找某个用户、插入新成绩、按分数范围查询前 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 层都有前向指针。

flowchart LR subgraph L3["第 3 层"] H3["header"] --> N3_1["3"] N3_1 --> N3_7["7"] N3_7 --> NIL3["NIL"] end subgraph L2["第 2 层"] H2["header"] --> N2_1["3"] N2_1 --> N2_5["5"] N2_5 --> N2_7["7"] N2_7 --> NIL2["NIL"] end subgraph L1["第 1 层"] H1["header"] --> N1_1["3"] N1_1 --> N1_4["4"] N1_4 --> N1_5["5"] N1_5 --> N1_7["7"] N1_7 --> NIL1["NIL"] end subgraph L0["第 0 层(完整链表)"] H0["header"] --> N0_1["3"] N0_1 --> N0_2["4"] N0_2 --> N0_3["5"] N0_3 --> N0_4["7"] N0_4 --> N0_5["9"] N0_5 --> NIL0["NIL"] end

3.1 查找过程#

以查找 key=5 为例:

  1. 从最高层(第 3 层)的 header 出发,3 < 5,前进到 3
  2. 3 的下一节点是 7,7 > 5,下降到第 2 层
  3. 第 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.5
func 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 为返回元素个数
Note

理论最坏 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 时考虑过红黑树,最终选择跳表,原因有三:

  1. 实现简单:跳表的插入删除不需要旋转,代码量少,bug 也少
  2. 范围查询高效:跳表的前向指针天然支持从某个节点开始顺序遍历,ZRANGE 类命令实现直接
  3. 并发潜力:虽然 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.5
func 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 选择跳表的核心原因:ZRANGEZRANGEBYSCORE 等范围查询命令只需沿第 0 层前向指针遍历,实现简单且高效。

6.2 LevelDB Memtable#

LevelDB 的 Memtable 用跳表存储内存中的写入数据,读取时通过跳表有序遍历,刷盘时顺序写出为 SSTable。

LevelDB 采用 SWMR(Single Writer Multiple Reader)模式:写操作加锁串行化,读操作通过原子指针无锁访问。跳表的前向指针是 std::atomic<Node*>,写者更新指针时用 release 语义,读者用 acquire 语义,保证读者看到一致的链表状态。这种无锁读设计比红黑树容易实现得多——红黑树的旋转会同时修改多个指针,无锁一致性几乎不可能保证。

6.3 RocksDB Memtable#

RocksDB 继承了 LevelDB 的跳表 Memtable,并增加了 InlineSkipList 变体,将 key 直接嵌入节点内存,减少一次指针跳转,提升缓存命中率。

七、小结#

何时使用跳表#

  • 内存有序存储 + 快速点查:需要 O(log n) 查找和范围查询,又不想写红黑树
  • 并发数据结构:无锁跳表比无锁红黑树简单一个数量级,CAS 操作只需更新单个前向指针
  • 数据库 Memtable:写入有序、范围扫描频繁,跳表天然支持顺序遍历
  • 范围查询场景:前向指针让「从某个 key 开始取 k 个」变得直接

何时不使用跳表#

  • 纯键值查找:不需要有序遍历时,哈希表 O(1) 更快更省内存
  • 需要确定性性能保证:跳表是概率平衡,虽然最坏 O(n) 极端罕见,但某些实时系统不允许任何理论风险
  • 内存受限:每个节点需要 O(log n) 个前向指针,比红黑树多约 50% 的内存开销
  • 持久化存储:磁盘 I/O 喜欢顺序读写,跳表的指针跳跃对缓存不友好,B+ 树更适合

八、参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

跳表(Skip List)
https://blog.souloss.cn/posts/programming/data-structures/data-structures-skip-list/
作者
Souloss
发布于
2026-06-13
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时