跳表(Skip List)深度实战:从随机层数、概率平衡到 Redis zset 与 LevelDB MemTable 的工程全解

跳表(Skip List)是 1989 年 William Pugh 在论文《Skip Lists: A Probabilistic Alternative to Balanced Trees》里提出的一种概率性平衡的有序数据结构。它要解决的,是平衡二叉搜索树(AVL、红黑树、B 树)在工程里一个长期被忽视的痛点:为了维持"平衡",它们不得不在每次插入删除时做复杂的旋转(rotation)或再平衡(rebalance),代码晦涩、并发改造地狱、调试时极难追踪一棵树为何失衡。跳表用一种近乎"耍赖"的方式绕开了这个问题——它不做任何显式平衡,而是靠随机层数让结构"以概率的方式"趋向于平衡,平均查找复杂度仍是 O(log n),但实现只有二叉平衡树的零头。

这就是为什么今天我们能在 Redis 的有序集合(zset)、LevelDB/RocksDB 的内存表(MemTable)、Lucene 的倒排索引跳表、Java 的 ConcurrentSkipListMap、Linux 内核的 intervals 区间管理里反复看到它。本文从第一性原理出发,推导随机层数的概率保证,给出可插拔的工业级实现,并系统梳理它与平衡树的工程取舍、并发改造路径、以及在主流存储引擎里的落地形态与 12 项生产陷阱。


一、第一性原理:有序动态集合的困境

一个需要频繁插入、删除、按 key 查找、按范围扫描的有序动态集合,朴素做法有两种,却各有硬伤:

  • 有序数组:二分查找 O(log n) 很香,但插入/删除要搬移元素,退化成 O(n)。
  • 有序链表:插入/删除 O(1)(找到位置后改两个指针),但查找只能从头遍历,O(n)。

平衡二叉搜索树(BST)把两者统一到 O(log n),代价是再平衡逻辑复杂。Pugh 的核心洞察是:能不能给链表"加几层快速通道",使得查找时可以大步跨越,又不必为维持平衡而旋转? 跳表就是答案——它把"有序链表"复制成若干层,越往上的层节点越稀疏,查找时从最稀疏的顶层开始,能跳就跳,跳不过去再下潜一层,最终在底层精确命中。

关键直觉:跳表不保证最坏 O(log n),只保证期望 O(log n),但失败概率随层数指数衰减。在工程上,"以 1/2 概率维持平衡"比"以复杂旋转强制维持平衡"便宜太多,且对并发友好——这正是它被工业界大量采用的根本原因。


二、结构定义:多层链表与"快速通道"

跳表由若干层有序链表叠加而成,每一层都是底层(第 0 层)的一个"稀疏子集":

  • 第 0 层:包含所有节点,是完整的有序链表。
  • 第 1 层:包含约 1/2 的节点,是在第 0 层上"隔一个取一个"。
  • 第 2 层:包含约 1/4 的节点,依此类推。
  • 顶层:节点极稀疏,是快速通道的最高速档。

每个节点持有一组向右的 forward 指针(每层的下一个节点),以及可选的向下的指针(或按层数组组织)。头节点(head)拥有指向每一层起点的指针。一个典型的节点表示:


import random
from typing import Optional, List

class SkipNode:
    __slots__ = ("key", "value", "forward")
    def __init__(self, key, value=None, level=1):
        self.key = key
        self.value = value
        # forward[i] 指向第 i 层的下一个节点;层号从 0 开始
        self.forward: List[Optional["SkipNode"]] = [None] * level

结构示意(数字为 key,箭头表示同层 forward 指针):


第 3 层:  head ──────────────────────────────► 7 ──► NIL
第 2 层:  head ──────────► 3 ────────────────► 7 ──► NIL
第 1 层:  head ──► 1 ──► 3 ──────► 6 ────────► 7 ──► NIL
第 0 层:  head ──► 1 ──► 3 ──► 4 ──► 6 ──► 7 ──► 9 ──► NIL

查找 9 时:第 3 层直接到 7(9 在 7 之后,本层到头)→ 下潜;第 2 层从 7 出发到 NIL → 下潜;第 1 层从 7 出发到 NIL → 下潜;第 0 层从 7 遍历到 9,命中。原本要 6 步的链表遍历,被压缩成 4 次"跨层 + 小步前进"。


三、随机层数:一枚硬币与几何分布

跳表的灵魂是随机层数(random level)。插入一个新节点时,不人为决定它出现在哪几层,而是反复抛一枚公平硬币(p = 1/2),正面就升一层,直到反面或达到上限 MAX_LEVEL:


def random_level(self, p: float = 0.5, max_level: int = 16) -> int:
    level = 1
    while random.random() < p and level < max_level:
        level += 1
    return level

这意味着一个节点出现在第 k 层(k ≥ 1)的概率是 (1-p)·p^(k-1)——典型的几何分布。于是:

  • 约 1/2 的节点在第 0 层之上还出现 1 次(第 1 层);
  • 约 1/4 出现在第 2 层;
  • 约 1/2^k 出现在第 k 层。

这种自相似的稀疏性,正是查找能"指数跨越"的概率基础。MAX_LEVEL 的选取取决于预期节点数 n:MAX_LEVEL ≥ log_{1/p}(n) 即可让最高层几乎不会浪费。对 n = 2^32 取 p=1/2,MAX_LEVEL = 32 绰绰有余;Redis 用 32,LevelDB 用 12(因其 MemTable 规模有限)。

致命细节:随机层数必须基于每次插入独立抛硬币,绝不能"固定每第 2^k 个节点升层"。后者会退化成确定性的静态结构,一旦插入顺序有偏(比如 key 单调递增),平衡性立刻崩塌。随机性不是装饰,是正确性的前提。


四、查找:从顶层俯冲而下

查找的核心是先"尽量在高层跳",跳不过去就下潜。最关键的是:在每一层都要先停在当前能到达的最远节点,再决定下潜还是右移——不能在某一层一口气冲到底再统一下潜,否则会错过更上层能提供的跨越机会。标准实现返回"查找路径上每一层的前驱节点",供插入/删除复用:


def _search(self, key):
    """返回 update[]:每层最后的小于 key 的节点(前驱),以及是否命中。"""
    update = [None] * self.level
    x = self.head
    for i in range(self.level - 1, -1, -1):   # 从最高层往下
        while x.forward[i] is not None and x.forward[i].key < key:
            x = x.forward[i]
        update[i] = x                          # 第 i 层的前驱
    x = x.forward[0]                            # 落到底层,看是否精确命中
    found = x is not None and x.key == key
    return update, x if found else None

查找路径天然给出"插入时新节点该接在谁的后面"。注意循环条件用 < key(严格小于),把"等于"留给最后一步精确判定,避免重复key在多层被错误处理。


五、插入:先搜后插,逐层掷硬币

插入 = 先 _search 拿到每层的 update 前驱 + 随机层数;若层数超过当前跳表高度,先把 head 在新增的层上接到现有最高节点;再把新节点像普通链表一样挂到每一层的 update[i] 之后:


def insert(self, key, value):
    update, node = self._search(key)
    if node is not None:
        node.value = value          # 已存在则更新 value,不重复插入
        return
    new_level = self.random_level(self.p, self.max_level)
    if new_level > self.level:
        for i in range(self.level, new_level):
            update[i] = self.head    # 新层的前驱统一是 head
        self.level = new_level
    new = SkipNode(key, value, new_level)
    for i in range(new_level):
        new.forward[i] = update[i].forward[i]
        update[i].forward[i] = new
    self.size += 1

这里有一个常被忽略的点:当 new_level > self.level 时,新增层的 update[i] 必须指向 head,因为新层上目前只有 head 和(可能)少量高层节点,新节点应当成为该层 head 后的第一个节点。漏掉这一步会让新增层形成悬空指针。


六、删除:逐层摘除与内存回收

删除与插入对称:先 _search;若命中,从第 0 层到第 (node.level-1) 层依次把 update[i].forward[i] 从指向 node 改回 node.forward[i],逐层"绕过"被删节点;最后若最高几层被清空,要下调跳表高度 self.level,否则查找会空转在全是 head→NIL 的空层:


def delete(self, key):
    update, node = self._search(key)
    if node is None:
        return False
    for i in range(node.forward.__len__()):
        if update[i].forward[i] is not node:
            break                     # 更高层本就没有指向 node,提前结束
        update[i].forward[i] = node.forward[i]
    while self.level > 1 and self.head.forward[self.level - 1] is None:
        self.level -= 1               # 收缩空层,避免查找空转
    self.size -= 1
    return True

内存视角:在 C/C++/Rust 里删除节点后必须显式 free/drop,否则内存泄漏;在带 GC 的语言里虽无泄漏,但跳表高度若不及时收缩,会让后续每次查找都白走若干空层,累积成可观的尾延迟。


七、复杂度推导:为什么期望 O(log n)

设 L 为单个节点的层数,则 P(L ≥ k) = p^(k-1)(从 1 层起算),期望层数 E[L] = 1/(1-p)。取 p = 1/2,期望层数 2;取 p = 1/4,期望层数 4/3。

查找的代价等于"遍历过的节点数"。Pugh 证明:在含 n 个节点的跳表里,一次查找期望访问的节点数为 O(log_{1/p} n),具体是 (1/p)·log_{1/p} n + O(1)。直觉证明(反向视角):从目标节点沿 forward 往回走,每一步"上跳一层"的概率约 p,"平走一步"的概率约 1-p,于是从目标回到 head 的期望步数 ≈ 在几何游走里的反向步数,量级正是 log_{1/p} n。

同时,跳表的最大高度 max_level 期望为 O(log_{1/p} n)(n 个节点各自独立抛硬币,最高者层数近似 log_{1/p} n + 常数),这意味着查找的层数循环本身也是 O(log n)。综合两层,插入、删除、查找的期望时间复杂度均为 O(log n),且空间复杂度为 O(n)(期望节点指针总数 = n·E[L] = n/(1-p),即常数倍于 n)。

这是期望界而非最坏界:理论上存在"每次都抛到 MAX_LEVEL"的极小概率坏情况,但概率 ≤ p^(MAX_LEVEL),在工程参数下(如 p=1/2, MAX_LEVEL=32)约为 2^-32,比硬件比特翻转还罕见,可忽略。


八、可插拔工业级 Python 实现

把上述拼装成一个可直接落地的工具类,覆盖查找、插入、删除、范围扫描:


class SkipList:
    def __init__(self, p: float = 0.5, max_level: int = 16):
        self.p = p
        self.max_level = max_level
        self.level = 1
        self.size = 0
        self.head = SkipNode(None, None, max_level)   # 哨兵,key=None 永不命中

    def _search(self, key):
        update = [None] * self.max_level
        x = self.head
        for i in range(self.level - 1, -1, -1):
            while x.forward[i] is not None and x.forward[i].key < key:
                x = x.forward[i]
            update[i] = x
        x = x.forward[0]
        return update, (x if x is not None and x.key == key else None)

    def random_level(self):
        lvl = 1
        while random.random() < self.p and lvl < self.max_level:
            lvl += 1
        return lvl

    def insert(self, key, value=None):
        update, node = self._search(key)
        if node is not None:
            node.value = value
            return
        lvl = self.random_level()
        if lvl > self.level:
            for i in range(self.level, lvl):
                update[i] = self.head
            self.level = lvl
        new = SkipNode(key, value, lvl)
        for i in range(lvl):
            new.forward[i] = update[i].forward[i]
            update[i].forward[i] = new
        self.size += 1

    def delete(self, key):
        update, node = self._search(key)
        if node is None:
            return False
        for i in range(len(node.forward)):
            if update[i].forward[i] is not node:
                break
            update[i].forward[i] = node.forward[i]
        while self.level > 1 and self.head.forward[self.level - 1] is None:
            self.level -= 1
        self.size -= 1
        return True

    def range_query(self, lo, hi):
        """[lo, hi] 闭区间范围扫描,O(log n + k)。"""
        _, _ = self._search(lo)
        # 从 lo 的底层前驱出发,沿第 0 层前进
        x = self.head
        while x.forward[0] is not None and x.forward[0].key < lo:
            x = x.forward[0]
        x = x.forward[0]
        out = []
        while x is not None and x.key <= hi:
            out.append((x.key, x.value))
            x = x.forward[0]
        return out

该实现用哨兵 head(key=None)统一边界,避免每处判断 head 是否为空;range_query 给出跳表相对平衡树的一大卖点——范围扫描天然 O(log n + k),无需中序遍历整棵子树。


九、与平衡树的工程对比

维度 跳表 红黑树 / AVL / B 树
平均查找 O(log n) 期望 O(log n) 最坏
插入/删除 改若干指针,无旋转 旋转/分裂合并,逻辑复杂
范围扫描 O(log n + k) 极简 中序遍历,需迭代器状态
代码量 数十行,易写对 数百行,易写错
并发改造 分层加锁/无锁CAS友好 旋转使锁粒度极难做细
缓存局部性 节点分散,较差 B 树节点紧凑,较好
最坏保证 仅期望(概率极高) 严格最坏
典型落地 Redis zset、LevelDB、Lucene、JUC 内核、数据库索引、语言运行时有序容器

结论很清晰:当场景需要高并发写入、范围扫描、且能接受"期望而非最坏"的复杂度时,跳表几乎总是更优的工程选择;当需要严格最坏界或极致缓存局部性(如数据库磁盘页),平衡树/B 树仍不可替代。二者不是替代关系,是分工关系。


十、并发跳表:从粗锁到无锁 CAS

单线程跳表进了生产第一件事就是并发。改造难度远低于平衡树,因为跳表没有"旋转"这种会波及整棵结构的操作——一次插入只动 new_level 个局部指针。

方案 A:全局粗锁。 最简单,insert/delete/search 全加一把互斥锁。正确但吞吐随核数线性下降,只适合低并发。

方案 B:细粒度分层锁。 借鉴"hand-over-hand locking":从顶层往下探路时,持有当前层节点的锁再去看下一层,释放上层锁。能把锁冲突面压到局部,但实现繁琐、易死锁。

方案 C:乐观无锁(CAS)。 工业主流。以 Java ConcurrentSkipListMap 为范本:

  • 查找用纯读(无锁),找到目标层的前驱/后继;
  • 插入时,先用 CAS 把新节点"挂"到最底层(pred.casNext(null, newNode)),再用 CAS 逐层把上层 forward 指过去;任何一层 CAS 失败就重试整个插入(帮助完成别人正在做的操作);
  • 删除用逻辑删除(mark node as deleted)而非立刻物理摘除:先把节点 marked,再把 forward 指针绕过它,最后由后续操作惰性回收,避免"正在读这个节点的线程突然看到指针消失"的 ABA/悬垂问题。

并发跳表的最大坑是 "查找路径在插入期间失效":你探路时记录的前驱,可能在你 CAS 之前被别的线程删了。解决方案是"帮助式重试"——一旦发现前驱变了或已被 mark,就重跑 _search。这是无锁跳表正确性的核心,绝不能偷懒用全局锁糊弄。


十一、工业落地:从缓存到存储引擎

跳表不是学院玩具,而是藏在无数基础设施里的主力:

  • Redis 有序集合(zset): 当元素较多或含大 score 时,zset 底层由 listpack/ziplist 切换为 skiplist + dict 的双结构——dict 提供 O(1) 按成员查 score,skiplist 提供 O(log n) 按 score 排序与 ZRANGE/ZREVRANGE 范围扫描。Redis 的 skiplist 还额外维护一个反向回溯指针(backward),支持从尾到头的逆序遍历。
  • LevelDB / RocksDB MemTable: 内存中的可变有序表,所有写入先落 MemTable(skiplist 实现),Get 与区间 Iterator 都依赖它 O(log n + k) 的特性;MemTable 写满后整体冻结、转储为 SSTable。LevelDB 用 MAX_LEVEL=12, p=1/4(期望层数 4/3),契合内存表规模。
  • Lucene / Elasticsearch 倒排索引: 用跳表加速 nextDoc()/advance() 等术语词典(Term Dictionary)的跳跃,使 skipTo 不必线性扫描。
  • Java ConcurrentSkipListMap / ConcurrentSkipListSet: JUC 里唯一支持高并发的有序 Map 实现,内部即无锁乐观跳表。
  • Linux 内核: mm/pagewalk、interval tree 等区间管理里能看到跳表思想变种,用于稀疏区间的快速定位。

这些落地的共性:需要有序 + 高并发写 + 范围扫描,且工程团队不想维护一棵会旋转的树。


十二、12 项生产陷阱

  1. 随机层数与插入顺序耦合: 用"每 2^k 个升层"代替抛硬币,遇到单调递增 key 直接退化成链表。
  2. MAX_LEVEL 选太小: 大数据量下最高层几乎为空,查找被迫走底层,退化为 O(n)。
  3. 插入新层忘了把 update[i] 设为 head: 新增层形成悬空指针,后续查找在高层迷路。
  4. 删除后不收缩 level: 空层越积越多,每次查找都白走若干层,尾延迟悄悄上升。
  5. 用递归 find 导致爆栈: 深链场景下递归路径压缩/查找触发 RecursionError(Python 默认 1000)。
  6. 并发下前驱失效未重试: CAS 插入前前驱被别人删除/标记,不重跑 _search 会写坏结构。
  7. 逻辑删除后物理回收时机错误: 过早 free 被其他线程持有的节点,造成悬垂指针(use-after-free)。
  8. p 取值与业务访问模式错配: 读多写少用大 p(更扁、查找更快);写多读少用小 p(更省空间)。
  9. score 相等时的 tie-break 缺失: 有序集合里 score 相同必须再按 member 字典序二级排序,否则去重/排序语义错误。
  10. 范围扫描没从 lo 前驱出发: 直接从 head 走会漏掉边界,或重复遍历。
  11. 内存碎片: 每个节点独立分配、层数不定,长期高频增删产生碎片;C++ 可用节点池/内存池缓解。
  12. 把"期望 O(log n)"当"最坏 O(log n)": SLA 极严苛的场景需配监控,警惕那 2^-32 概率的坏情况被硬件噪声放大。

十三、结语

跳表是"用概率换简单"的教科书级范例:它放弃了平衡树那种"每次都强制最优"的执念,转而用一枚硬币换取几乎总是最优,换来的是数十行就能写对、并发改造轻松、范围扫描天然的工程友好性。它与本站已发布的布隆过滤器、Count-Min Sketch、HyperLogLog、布谷鸟哈希、并查集、Trie、基数树、线段树/树状数组、KD-Tree、堆、单调栈、快速/归并排序共同构成"数据结构工程全谱系"——前者以概率与近似换空间与速度,后者以确定性换精确,二者在工程栈里互补共存。下一步可继续补全 B+ 树、LSM-Tree 合并策略、以及跳表在持久化内存(PMEM)下的失效排序(persistency)改造,把"有序动态集合"这条主线彻底打通。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿
网站二维码

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部