数据结构-高级结构-跳表

1 阅读45分钟

概述

在数据结构的演化史上,有序存储与高并发访问曾是一对难以调和的矛盾。平衡二叉搜索树(如红黑树、AVL 树)提供了确定的 O(log n) 有序操作,但其复杂的旋转和变色机制在并发环境下代价高昂——锁住一棵树远比锁住一个节点困难。哈希表虽能以 O(1) 实现点查,却丧失了有序性,无法应对范围查询、排名统计等需求。跳表的出现,以“概率平衡”这一哲学突破,巧妙化解了这对矛盾:它用随机数替代复杂的旋转,用局部指针修改替代全局结构调整,在保持期望 O(log n) 的同时,天然适配细粒度锁乃至无锁并发设计。

本文将遵循“是什么→怎么用→为什么→如何用得好”的认知路径,从跳表的核心思想与随机层高算法出发,严格推导其时间复杂度的概率来源,深入解析查找、插入、删除的完整机制,并在 Java ConcurrentSkipListMap、Redis ZSet 等工业实现中印证其设计权衡。无论你是备战大厂的面试者,还是在生产系统中做出数据结构选型的架构师,都能从这篇文章中获得专家级的理解深度。

  • 概率平衡的哲学:通过随机层高代替复杂的旋转/变色,在期望上保证 O(log n),实现简单、维护成本低。这是跳表最根本的设计思想。
  • 有序且并发:天然支持范围查找和有序遍历,插入仅修改局部指针,锁粒度可细至单个节点,支持 CAS 无锁实现。这是跳表在工业界立足的核心竞争力。
  • 性能特征:所有操作期望 O(log n),常数因子略高于红黑树(因多级索引访问),但并发场景下吞吐量可成倍领先。空间开销为期望 O(n),通常可接受。
  • 工程地位ConcurrentSkipListMap 是 Java 并发库的有序容器基石;Redis ZSet 选择跳表作为有序集合的内部结构;LevelDB/RocksDB 的 Memtable 也广泛使用跳表。
  • 适用场景:高并发有序映射、实时排行榜、分布式协调服务的有序存储、需要无锁有序遍历的任何场合。

文章组织架构

flowchart TB
    subgraph M1["① 概述与核心特性"]
        A1["定义与 ADT"]
        A2["核心特性清单"]
        A3["适用场景 ≥5"]
        A4["反模式 ≥3"]
        A5["工业界概览"]
    end
    
    subgraph M2["② 数据结构详解"]
        B1["分层结构模型"]
        B2["节点与索引节点分离"]
        B3["逻辑结构与物理实现"]
    end
    
    subgraph M3["③ 核心操作"]
        C1["查找:向右下潜"]
        C2["插入:随机层高"]
        C3["删除:逐层摘除"]
    end
    
    subgraph M4["④ 概率平衡与复杂度"]
        D1["随机层高几何分布"]
        D2["时间复杂度严格推导"]
        D3["空间复杂度分析"]
    end
    
    subgraph M5["⑤ 并发设计深度解读"]
        E1["CAS 无锁实现"]
        E2["标记节点与逻辑删除"]
        E3["与红黑树并发对比"]
    end
    
    subgraph M6["⑥ 工程实现与最佳实践"]
        F1["ConcurrentSkipListMap 使用"]
        F2["选型决策框架"]
        F3["Redis ZSet 实现"]
        F4["避坑清单"]
    end
    
    subgraph M7["⑦ 面试高频专题"]
        G1["10 道高频题"]
        G2["系统设计:股票排名"]
    end
    
    M1 --> M2 --> M3 --> M4 --> M5 --> M6 --> M7

图表说明

  • 图表主旨:展示本文七大模块的递进式学习路径,从感性认知到算法内核,再到并发设计与工程落地。
  • 逐层分解
    • ① 概述与核心特性:建立跳表的全局认知,包括它是什么、能做什么、不适合做什么。这是“感性认知”阶段。
    • ② 数据结构详解:深入跳表的物理构成——多层链表、索引节点、数据节点的分离设计。这是“物理认知”阶段。
    • ③ 核心操作:完整拆解查找、插入、删除的每一步,配以伪代码和图解。这是“算法认知”阶段。
    • ④ 概率平衡与复杂度:从随机层高的几何分布出发,严格推导 O(log n) 的概率来源。这是“数学认知”阶段。
    • ⑤ 并发设计:揭示 CAS 无锁实现的精妙之处,理解跳表为何是“并发友好”数据结构。这是“工程纵深”阶段。
    • ⑥ 工程实践:将理论映射到 Java ConcurrentSkipListMap、Redis ZSet 等真实实现,给出选型建议。这是“工程落地”阶段。
    • ⑦ 面试专题:集中攻克高频考点,含系统设计题。这是“知识检验”阶段。
  • 原理映射:跳表的学习天然需要这样一条路径——先理解“概率平衡”这个核心哲学,再层层深入算法细节,最后回到工程实践。
  • 场景关联:每个模块都能独立阅读,也可按序形成完整知识体系。如果你已有基础,可以直接跳到感兴趣的模块。
  • 关键结论跳表的精粹在于“概率平衡”——用随机性替代确定性,用局部修改替代全局调整,用空间换时间,最终在并发场景下实现了对平衡树的全面超越。

模块 1:跳表概述与核心特性

1.1 定义

跳表是一种基于多层有序链表随机化索引的概率性数据结构。它通过在原始有序链表之上建立多层稀疏索引,每层索引都是下一层的子集抽样,从而在查找时能够跳过大量无关节点,将平均时间复杂度从 O(n) 降至 O(log n)。同时,插入和删除操作仅需修改局部指针,无需像平衡树那样进行全局结构调整(旋转、变色),这使其在并发环境下具有天然的优越性。

跳表由 William Pugh 于 1990 年在论文《Skip Lists: A Probabilistic Alternative to Balanced Trees》中首次提出。Pugh 的核心洞见是:平衡树试图用复杂的确定性规则来维持完美平衡,而跳表接受了一种“足够好”的概率平衡,从而大幅降低了实现复杂度

1.2 抽象数据类型定义

跳表实现了一个有序映射的抽象数据类型,以下是其标准 ADT 接口:

/**
 * 有序映射的抽象数据类型
 * K 必须可比较(实现 Comparable 或提供 Comparator)
 */
public interface SortedMapADT<K, V> {
    // 核心写入操作
    V put(K key, V value);              // 插入或更新键值对
    V get(K key);                        // 按键查找
    V remove(K key);                     // 删除键值对
    boolean containsKey(K key);          // 判断键是否存在
    
    // 有序操作(跳表的核心价值)
    K firstKey();                        // 获取最小键
    K lastKey();                         // 获取最大键
    K lowerKey(K key);                   // 获取小于 key 的最大键
    K higherKey(K key);                  // 获取大于 key 的最小键
    K floorKey(K key);                   // 获取 ≤ key 的最大键
    K ceilingKey(K key);                 // 获取 ≥ key 的最小键
    
    // 范围查询(跳表的杀手级特性)
    SortedMapADT<K, V> subMap(K from, K to);  // 子映射视图
    
    // 元信息
    int size();                          // 元素数量
    boolean isEmpty();                   // 是否为空
}

关键理解:跳表的“有序性”来源于两个层面:

  1. 底层链表的有序性:第 0 层是一个完整的、按键排序的单向链表(或双向链表),包含所有数据节点。
  2. 多层索引的加速性:上层索引节点是下层节点的稀疏抽样,虽然索引本身不存储完整的键值对集合,但索引节点按相同顺序排列,从而保证了在任何层进行向右查找时,仍然遵循键的递增顺序

1.3 核心特性清单

特性根源工程影响
期望 O(log n) 操作多层索引 + 随机层高的几何分布动态有序容器的高效保证,可在百万级数据下保持微秒级响应
自然支持有序遍历和范围查询底层是完整有序链表无需中序遍历即可顺序输出,范围查询只需 O(log n) 定位 + O(k) 遍历
无全局结构调整插入仅修改局部多层指针实现比平衡树简单约 40%(Pugh 论文估算),并发锁粒度可细至单节点
并发友好可无锁实现,无旋转阻塞,索引更新可滞后高并发下吞吐量近线性扩展,远超全局锁保护的平衡树
空间换时间额外索引节点,期望额外空间 O(n)内存占用比纯链表高约 1 倍(p=0.5),比红黑树高约 1.5–2 倍
概率性而非确定性层高由随机数决定极端情况下可能退化,但概率极低(n=10⁶ 时退化概率 < 2⁻¹⁰⁰⁰⁰)

1.4 适用场景详解

场景 1:高并发实时排行榜

问题:某竞技游戏需要维护全服玩家的实时积分排名,支持积分变更、查询玩家排名、获取 Top 100 榜单。每秒操作量 10 万+。

为什么跳表是最优解

  • 有序性天然支持排名:积分降序排列,firstKey() 获取最高分,通过 subMap 获取任意区间排名。
  • 高写入吞吐:跳表的 CAS 无锁插入使得大量积分更新并发完成,无需全局锁竞争。
  • 扩展排名计算:如在索引节点中存储 span(跨度),可将 getRank(key) 优化为 O(log n),这正是 Redis ZSet 的做法。

替代方案的不足:红黑树每次插入需旋转并加锁,竞争激烈时吞吐量急剧下降;堆仅支持取堆顶,无法高效获取任意排名。

场景 2:分布式协调服务的有序存储

问题:ZooKeeper 的持久顺序节点需要在内存中维护有序集合,支持查找下一个序列号、获取某一范围的所有节点。

为什么跳表是最优解

  • 顺序节点的键是递增的序列号,跳表的 higherKey()floorKey() 等操作天然匹配此需求。
  • 分布式协调服务需要高可用,跳表实现的简洁性降低了 bug 风险(相比之下平衡树的旋转边界条件复杂得多)。

场景 3:缓存系统中的过期时间排序

问题:一个本地缓存需要按过期时间排序,定期清理已过期的条目。需快速获取“最早过期的对象”并删除。

为什么跳表是最优解

  • 以过期时间为键存储缓存条目,firstKey() 返回最早过期时间,subMap(0, now) 获取所有已过期条目。
  • 删除操作 O(log n),且是局部修改,不影响其他缓存操作的并发执行。
  • 相比之下,使用定时轮询 + 哈希表需要 O(n) 扫描所有条目。

场景 4:全文检索引擎的倒排索引合并

问题:搜索引擎中,多个词的倒排索引(有序文档 ID 列表)需要取交集(AND 操作)。

为什么跳表是最优解

  • 多路归并时,跳表的“向前跳”能力可快速跳过不匹配的文档 ID,将合并复杂度从 O(n×m) 降至接近 O(n)。
  • 具体来说,在两个有序列表求交时,若列表 A 当前 ID 远小于列表 B,A 可以通过索引快速跳到 ≥ B 的位置,而非逐一遍历。

场景 5:内存数据库的有序集合

问题:Redis 的 Sorted Set 需要支持 ZADD、ZRANGE、ZRANK、ZSCORE 等操作,且必须是内存高效、操作快速的。

为什么跳表是最优解

  • Redis 选择跳表而非红黑树,核心原因有三:实现简单(Redis 是 C 语言编写,跳表约 200 行,红黑树约 400+ 行)、范围查询高效(底层双向链表直接遍历)、支持排名计算(索引节点中存储 span,实现 O(log n) 的 ZRANK)。
  • 详见模块 6.4。

1.5 反模式详解

反模式 1:微小数据集纯读取场景

表现:100 个元素,启动后仅读取不更新,却使用了 ConcurrentSkipListMap

问题分析

  • 跳表的索引建立开销和内存占用在极小数据集上不划算。有序数组的二分查找在 100 个元素时约 7 次比较,且缓存友好度极高。
  • 跳表的多级指针跳转破坏了 CPU 缓存局部性,在微小数据集上性能可能落后数组二分查找 2–5 倍。

正确做法:使用 ArrayList + Collections.binarySearch(),或直接 TreeMap(单线程下有优化的红黑树实现)。

反模式 2:纯点查场景,无需有序性

表现:仅需要 get(key)put(key),从不使用范围查询或有序遍历,却用了 ConcurrentSkipListMap

问题分析

  • ConcurrentHashMap 提供 O(1) 平均点查性能,而跳表是 O(log n)。在 100 万数据下,这是约 20 次比较 vs 1-2 次哈希运算的差距。
  • 并发哈希表的分段锁/CAS 设计同样优秀,在点查场景全面优于跳表。

正确做法:使用 ConcurrentHashMap

反模式 3:单线程且对插入延迟极度敏感

表现:单线程事件循环中高频插入,延迟预算极紧(如 P99 < 100μs),使用了跳表。

问题分析

  • 跳表的常数因子略高于红黑树:每次查找约需 20 次比较(p=0.5, n=10⁶),而红黑树约需 18 次。虽然差距不大,但在单线程无竞争时,精细实现的红黑树(如 TreeMap)通常更快。
  • 跳表的索引层分配涉及随机数生成和数组分配,这些开销在插入路径上比红黑树节点染色更重。

正确做法:单线程场景优先使用 TreeMap,多线程或需要无锁设计时再选跳表。

1.6 工业界使用现状概览

系统/库跳表用途实现语言特点
JDK ConcurrentSkipListMap并发有序映射JavaCAS 无锁,弱一致性迭代器,支持 NavigableMap 接口
JDK ConcurrentSkipListSet并发有序集合Java基于 ConcurrentSkipListMap 封装
Redis Sorted Set有序集合C底层双向链表 + 索引节点含 span 字段,支持 ZRANK
LevelDB Memtable内存表C++基于跳表,支持并发读写,key 有序
RocksDB Memtable内存表C++默认使用跳表,可选哈希链表
HBase CellSkipListSetMemStore 内部索引Java基于跳表的多版本 Cell 索引
Apache Lucene倒排索引合并Java多路归并使用跳表加速
WiredTiger(MongoDB 引擎)内存索引C跳表作为内存 B+ 树的替代

模块 2:数据结构详解:多层索引与节点

2.1 逻辑结构与物理实现的解耦

逻辑层面,跳表是一个有序映射:用户看到的是一个按键排序的键值对集合,支持 getputremovesubMap

物理层面,跳表是一个多层的链表结构:

  • 第 0 层(Level 0):一个包含所有数据节点的完全有序链表,是数据的唯一真实存储。
  • 第 i 层(Level i, i > 0):第 i-1 层的稀疏索引,其中每个节点指向第 i-1 层中相同键的节点(down 指针),并在本层形成有序链表(right 指针)。
  • 层数越高,节点越稀疏。当 p=0.5 时,第 1 层约有 n/2 个节点,第 2 层约有 n/4 个节点,以此类推。

这种逻辑与物理分离的设计,是跳表并发能力的根基:索引节点的更新可以滞后甚至丢失,而不影响数据节点的正确性——因为所有数据都在第 0 层,索引只是加速查找的“缓存”。

2.2 节点与索引节点的分离

在跳表的经典实现和 Java ConcurrentSkipListMap 中,数据节点索引节点是两种不同的对象:

classDiagram
    class Node~K~ {
        +K key
        +V value
        +Node~K~ next
        +void setValue(V value)
        +boolean casNext(Node cmp Node val)
        +boolean casValue(V cmp V val)
    }
    
    class Index~K~ {
        +Node~K~ node
        +Index~K~ down
        +Index~K~ right
        +boolean casRight(Index cmp Index val)
    }
    
    class HeadIndex~K~ {
        +int level
    }
    
    class SkipList~K~ {
        -HeadIndex~K~ head
        -int maxLevel
        -Comparator comparator
        +V get(K key)
        +V put(K key V value)
        +V remove(K key)
        -Node findPredecessor(K key)
        -int randomLevel()
    }
    
    Node <-- Index
    Index <|-- HeadIndex
    Index --> Index
    Index --> Index
    Node --> Node
    SkipList --> HeadIndex

图表说明

  • 图表主旨:展示跳表中三种核心类——数据节点、索引节点、头索引节点的职责分离与引用关系。
  • 逐层分解
    • Node(数据节点):存储真实的键值对,通过 next 指针形成第 0 层的完整有序链表。提供 CAS 操作(casNextcasValue)支持无锁并发修改。
    • Index(索引节点):不存储值,仅持有对数据节点的引用 node,以及 right(本层向右)和 down(向下一层)两个指针。索引节点形成上层稀疏索引。
    • HeadIndex(头索引节点):继承自 Index,额外记录当前索引层高度 level。跳表通过 head 字段持有最高层头节点,作为所有查找操作的入口。
    • SkipList(跳表容器):持有头节点引用、最大层数限制、比较器等元信息,提供 getputremove 等对外接口。
  • 原理映射:节点与索引节点的分离,使得索引的更新可以完全独立于数据节点。插入时,可以先在底层链表插入数据节点,然后“懒加载”地逐层添加索引——索引无论是否添加完成,查找都能正确进行(只不过可能退化为更底层的查找)。
  • 场景关联:在并发场景下,两个线程可以同时修改同一数据节点附近的索引,互不干扰。
  • 工程实现对应:Java ConcurrentSkipListMap 中,NodeIndexHeadIndex 正是以上三个内部类,所有字段均为 volatile,所有修改通过 sun.misc.Unsafe 的 CAS 操作完成。
  • 关键结论节点与索引节点的分离是实现无锁并发的核心设计之一——数据节点的更新与索引节点的更新解耦,索引可以滞后或丢失,但数据一致性由底层链表的 CAS 保证。

2.3 分层结构示意图

flowchart TB
    subgraph L3["Level 3 (顶层)"]
        H3["Head"] --> N3_1["15"] --> N3_2["63"]
    end
    
    subgraph L2["Level 2"]
        H2["Head"] --> N2_1["7"] --> N2_2["15"] --> N2_3["31"] --> N2_4["63"] --> N2_5["95"]
    end
    
    subgraph L1["Level 1"]
        H1["Head"] --> N1_1["3"] --> N1_2["7"] --> N1_3["11"] --> N1_4["15"] --> N1_5["23"] --> N1_6["31"] --> N1_7["47"] --> N1_8["63"] --> N1_9["79"] --> N1_10["95"]
    end
    
    subgraph L0["Level 0 (底层完整链表)"]
        H0["Head"] --> N0_1["3"] --> N0_2["4"] --> N0_3["7"] --> N0_4["9"] --> N0_5["11"] --> N0_6["14"] --> N0_7["15"] --> N0_8["18"] --> N0_9["23"] --> N0_10["27"] --> N0_11["31"] --> N0_12["38"] --> N0_13["47"] --> N0_14["52"] --> N0_15["63"] --> N0_16["71"] --> N0_17["79"] --> N0_18["86"] --> N0_19["95"] --> N0_20["99"]
    end
    
    N3_1 -.-> N2_2
    N3_1 -.-> N1_4
    N3_1 -.-> N0_7
    N3_2 -.-> N2_4
    N3_2 -.-> N1_8
    N3_2 -.-> N0_15
    N2_1 -.-> N1_2
    N2_1 -.-> N0_3
    N2_2 -.-> N1_4
    N2_2 -.-> N0_7
    N2_3 -.-> N1_6
    N2_3 -.-> N0_11
    N2_4 -.-> N1_8
    N2_4 -.-> N0_15
    N2_5 -.-> N1_10
    N2_5 -.-> N0_19

图表说明

  • 图表主旨:展示一个包含 20 个元素的跳表(键值 3–99)的完整三层索引结构,直观呈现“层越高越稀疏”的特点。
  • 逐层分解
    • Level 0(底层):包含全部 20 个数据节点,按键有序排列。这是数据的唯一真实存储,即使所有上层索引被移除,跳表仍能正确工作(只是退化为 O(n) 链表查找)
    • Level 1:约 10 个节点(每 2 个抽 1 个),形成了对底层链表的稀疏索引。
    • Level 2:约 5 个节点,进一步稀疏化。
    • Level 3(顶层):仅 2 个节点(15 和 63),作为查找的入口加速层。
    • 虚线连接:表示索引节点通过 down 指针垂直连接至下一层的同名节点。例如 Level 3 的节点 15 向下指向 Level 2、Level 1、Level 0 的节点 15。
  • 原理映射:跳表的高度不是固定的,而是由随机算法动态决定。当 p=0.5 时,每向上一层,节点数期望减半。20 个节点的期望层高约为 log₂(20)+1 ≈ 5,此图中最高到 Level 3 是合理的(因为实际层高取决于每个节点独立掷硬币的结果)。
  • 场景关联:查找键 52 时,从 Level 3 Head 出发,向右到 15(15 < 52),再到 63(63 ≥ 52,太多了),然后下潜到 Level 2 的 15,向右经 31 到 63,再下潜,最终在底层找到 52。
  • 关键结论跳表的本质是在有序链表之上叠加了“快速公交车道”——越高的层越稀疏,能跨越的节点越多;下潜意味着切换到更密集的“慢车道”,精确定位。

模块 3:核心操作详解

3.1 查找操作

查找是跳表所有操作的基础,插入和删除都依赖查找来定位位置。

伪代码

function find(key):
    // 从最高层头节点开始
    current = head
    // 从最高层向下遍历
    for level from maxLevel down to 0:
        // 在当前层向右滑动,直到下一个节点的 key >= 目标 key
        while current.right != null and current.right.key < key:
            current = current.right
        // 如果找到目标键
        if current.right != null and current.right.key == key:
            return current.right.value  // 数据节点的值
        // 否则下潜一层
        current = current.down
    return null  // 未找到

查找过程图解

flowchart LR
    subgraph Step1["步骤1:从顶层 Head 出发"]
        S1_H["Head(L3)"] -->|"right"| S1_15["15 < 52 ✓"]
    end
    
    subgraph Step2["步骤2:继续向右"]
        S2_15["15(L3)"] -->|"right"| S2_63["63 ≥ 52 ✗"]
        S2_15 -->|"down"| S2_15d["15(L2)"]
    end
    
    subgraph Step3["步骤3:下潜至 L2 向右"]
        S3_15["15(L2)"] -->|"right"| S3_31["31 < 52 ✓"]
        S3_31 -->|"right"| S3_63["63 ≥ 52 ✗"]
        S3_31 -->|"down"| S3_31d["31(L1)"]
    end
    
    subgraph Step4["步骤4:下潜至 L1 向右"]
        S4_31["31(L1)"] -->|"right"| S4_47["47 < 52 ✓"]
        S4_47 -->|"right"| S4_63["63 ≥ 52 ✗"]
        S4_47 -->|"down"| S4_47d["47(L0)"]
    end
    
    subgraph Step5["步骤5:下潜至 L0 精确定位"]
        S5_47["47(L0)"] -->|"next"| S5_52["52(L0) ✓ 找到!"]
    end
    
    Step1 --> Step2 --> Step3 --> Step4 --> Step5

图表说明

  • 图表主旨:演示查找键 52 的完整路径——从最高层向右跨越、到临界点时下潜、最终在底层精确定位。
  • 逐层分解
    • 步骤 1:从 Level 3 头节点出发,向右看到节点 15,15 < 52,向右移动。
    • 步骤 2:在 15(L3) 向右看到 63,63 ≥ 52,停止向右。从 15 向下潜入 Level 2。
    • 步骤 3:在 15(L2) 向右到 31(31 < 52),再向右到 63(63 ≥ 52,停止)。从 31 向下潜入 Level 1。
    • 步骤 4:在 31(L1) 向右到 47(47 < 52),再向右到 63(63 ≥ 52,停止)。从 47 向下潜入 Level 0。
    • 步骤 5:在 47(L0) 的 next 恰好是 52,找到目标节点。
  • 原理映射“向右下潜”规则——若右边节点键值小于目标,向右移动;否则向下移动。这一规则保证了:① 不会越过目标;② 每次下潜时,当前节点是当前层中小于目标键的最大节点。
  • 场景关联:n=20 时,查找 52 经过了 5 步。若在纯链表中需 15 步。规模越大,加速效果越明显。
  • 关键结论查找的路径长度 = 每层的向右步数之和。每层期望向右步数为 1/p(几何分布均值),总层数期望为 log₁/p(n),因此总期望比较次数 = O(log n / p)。

3.2 插入操作

插入操作分三步:查找前置节点、随机生成层高、逐层插入。

伪代码

function put(key, value):
    // Step 1: 在各层记录"前驱节点"
    // predecessors[i] = 第 i 层中小于 key 的最大节点
    predecessors[] = 在各层查找并记录前驱节点
    
    // Step 2: 检查键是否已存在
    if predecessors[0].next.key == key:
        // 更新值
        predecessors[0].next.value = value
        return oldValue
    
    // Step 3: 随机生成新节点的层高
    newNodeLevel = randomLevel()
    
    // Step 4: 创建数据节点,插入第 0 层
    newNode = Node(key, value)
    newNode.next = predecessors[0].next
    predecessors[0].next = newNode
    
    // Step 5: 从第 1 层到 newNodeLevel 层,逐层插入索引节点
    for level from 1 to newNodeLevel:
        // 如果当前层不存在(跳表增高),创建新的头索引层
        if level > currentMaxLevel:
            createNewHeadLevel()
        // 在每层插入索引节点
        indexNode = Index(newNode)
        indexNode.next = predecessors[level].next
        indexNode.down = previousLevelIndexNode
        predecessors[level].next = indexNode

随机层高算法

// ConcurrentSkipListMap 的精简实现原理
int randomLevel() {
    int level = 1;
    // 以概率 p 递增层高,默认 p = 0.5 (即 random() & 1)
    while ((ThreadLocalRandom.current().nextInt() & 1) == 0) {
        level++;
    }
    // 限高 31(或 32),防止过度增长
    return Math.min(level, MAX_LEVEL);
}

层高分布

  • 层高 = 1 的概率:1/2(第一次随机就失败)
  • 层高 = 2 的概率:1/4(成功一次后失败)
  • 层高 = k 的概率:(1/2)^k
  • 期望层高 = 1/(1-p) = 2(当 p=0.5 时)

插入过程图解

flowchart TB
    subgraph L2["Level 2"]
        direction LR
        PH2["前驱节点(Level 2)"] -->|"插入新索引"| NI2["新索引节点(30)"]
    end
    
    subgraph L1["Level 1"]
        direction LR
        PH1["前驱节点(Level 1)"] -->|"插入新索引"| NI1["新索引节点(30)"]
    end
    
    subgraph L0["Level 0"]
        direction LR
        PH0["前驱节点(27)"] -->|"插入数据节点"| NN["新数据节点(30, value)"]
        NN -->|"next"| ORIGNEXT["原后继(31)"]
    end
    
    NI2 -.->|"down"| NI1
    NI1 -.->|"down"| NN
    
    subgraph RandomDecision["随机决策点"]
        RD["randomLevel() = 2"]
    end
    
    RandomDecision -->|"决定了索引层数"| NI1

图表说明

  • 图表主旨:展示插入键 30、层高为 2 的完整过程——从随机决策到逐层索引插入。
  • 逐层分解
    • 随机决策点:调用 randomLevel() 返回 2,意味着新节点需要第 0 层(数据层)和第 1 层(一层索引)。
    • Level 0(数据层):在前驱节点 27 之后插入数据节点 30,30.next 指向原后继 31。无论索引层是否插入成功,数据节点必须在第 0 层插入
    • Level 1(索引层):在前驱节点之后插入索引节点 30(Index 对象),其 node 引用指向数据节点,down 为 null(因为下面没有同名索引了),right 指向原后继索引。
  • 原理映射:插入只影响各层中前驱节点的后继指针,不涉及任何全局结构调整。被影响的节点数是 O(log n) 级别的——层高期望 2,实际需要修改的层数 = 随机层高。
  • 场景关联:在并发环境下,多个线程可以在跳表的不同键值位置同时插入,互不干扰(只要前驱节点不重叠)。
  • 关键结论插入的局部性是跳表并发优势的根源——插入只修改 O(层高) 个节点的指针,且索引层的更新可以“后置”甚至丢失,不影响正确性(仅影响查找性能)。

3.3 删除操作

伪代码

function remove(key):
    // 在各层查找前驱节点
    predecessors[] = 在各层记录前驱
    
    if predecessors[0].next == null or predecessors[0].next.key != key:
        return null  // 不存在
    
    targetNode = predecessors[0].next
    
    // 从顶层到底层,逐层摘除索引
    for level from targetNode.topLevel down to 0:
        while predecessors[level].next != targetNode (或其索引):
            // 前驱可能被其他线程修改,需重新定位
            adjust predecessor
        predecessors[level].next = targetNode.next (在 level 层)
    
    return targetNode.value

删除的精髓

  • 删除是从上到下逐层进行的,先移除高层索引,再移除底层数据节点。
  • 在并发实现中(如 ConcurrentSkipListMap),删除采用逻辑删除→物理删除两步走:先在节点上打标记(marker),后续遍历和清理操作会自动跳过并回收被标记节点。这一设计保证了并发查找的正确性。

模块 4:概率平衡与复杂度分析

4.1 随机层高的数学基础

跳表使用几何分布来生成节点层高。设 p 为“升级概率”(通常 p = 0.5):

  • 每个节点在加入跳表时,执行以下“抛硬币”过程:
    • 起始层高 = 1
    • 以概率 p 决定是否增加一层(硬币正面:升级;反面:停止)
    • 重复直到第一次出现“反面”或达到最大层高

层高分布律

P(level = k) = p^(k-1) × (1-p),k ≥ 1

验证:所有概率之和 = (1-p) + p(1-p) + p²(1-p) + ... = (1-p)/(1-p) = 1 ✓

期望层高

E[level] = Σ k × P(level = k) = Σ k × (1-p) × p^(k-1) = 1/(1-p)

当 p = 0.5 时,E[level] = 2。这意味着平均每个节点在 2 层中存在(第 0 层 + 期望 1 层索引)。

4.2 时间复杂度推导

引理 1:第 k 层节点数的期望

第 0 层:n 个节点(所有节点) 第 1 层:n × p 个节点(约) 第 2 层:n × p² 个节点 第 k 层:n × p^k 个节点

当 n × p^k < 1 时,该层不再需要。解 n × p^k ≥ 1 得 k ≤ log₁/p(n)。

因此,最大层数期望值 = ⌈log₁/p(n)⌉。当 p=0.5, n=10⁶ 时,期望最大层数 ≈ log₂(10⁶) ≈ 20。

引理 2:每层查找的期望比较次数

在任意层 i,从一个节点开始向右移动,直到找到 ≥ 目标键的节点。由于该层是第 i+1 层中节点的“折射”,每次向右移动,目标与当前位置之间的节点数期望递减。

严格分析(参考 Pugh 论文):

  • 在第 i 层,期望比较次数 = 1/p(几何分布的无记忆性)

总时间复杂度

总查找成本 = Σ(每层的比较次数) × (层数)

= O(1/p × log₁/p(n))

当 p=0.5,总查找成本 = O(2 × log₂(n)) = O(log n),常数因子约 2。

最坏情况分析

  • 理论上,如果所有节点的随机层高都是 1(概率 = (1-p)^n),跳表退化为一条普通链表,查找复杂度 O(n)。
  • 对于 n=10⁶ 个节点同时 layer=1 的概率 = (0.5)^(10⁶) ≈ 10^(-301030),这是真正的“宇宙热寂”级概率,工程上不可能发生
  • Java ConcurrentSkipListMap 还通过限制最大层高(31 或 64)来硬性防止过度退化。

4.3 空间复杂度

额外空间来自索引节点。期望额外节点数:

额外节点数 = n × (p + p² + p³ + ...) = n × p/(1-p)

当 p=0.5 时,额外节点数 = n,总节点数期望 = 2n = O(n)。

不同 p 值的权衡

p 值期望层高期望最大层数 (n=10⁶)查找常数因子额外空间
0.251.33~10~40.33n
0.52~20~2n
0.754~40~1.333n

p = 0.5 是查找性能和空间开销的甜蜜点,大多数实现(包括 Java、Redis)都使用此值。

4.4 概率平衡 vs 确定性平衡

为什么跳表选择随机化而非确定性平衡?

维度确定性平衡(红黑树)概率平衡(跳表)
维护代价每次插入/删除后需检查并执行旋转/变色,最坏 O(log n) 次旋转插入仅需在随机层数内修改指针,无需任何检查
实现复杂度红黑树约 400 行核心代码(含 5 种旋转场景)跳表约 200 行核心代码
并发友好度旋转影响祖先路径,锁范围大仅修改局部指针,天然细粒度
最坏情况保证确定的 O(log n)概率的 O(log n),退化概率极低
调试难度高(旋转后的红黑性质需严格维护)低(结构直观,错误影响局部)

核心哲学:跳表用“随机化的运气”替代“确定性的复杂”。这类似于快速排序(随机化选主元)vs 归并排序的哲学差异——前者在概率上很好,实现简单;后者确定性地好,但需要额外空间。


模块 5:并发设计深度解读

5.1 为什么跳表天然适合并发

在并发数据结构设计中,锁粒度是关键指标。越细的锁粒度意味着越少的竞争和越高的并行度。

  • 红黑树的并发困境:插入一个节点可能触发旋转,旋转影响的范围从插入节点一直到根节点。若用全局锁保护整棵树,并发吞吐量等于单线程吞吐量;若用细粒度锁(如锁耦合),实现极其复杂且容易死锁。
  • 跳表的并发优势:插入一个节点只影响 O(log n) 个前驱节点的指针,且这些指针的修改是独立的——修改 Level 2 的指针不依赖 Level 1 的修改结果。因此,可以用 CAS(Compare-And-Swap)无锁实现这些修改。

5.2 ConcurrentSkipListMap 的无锁实现机制

CAS 操作

ConcurrentSkipListMap 使用 sun.misc.Unsafe 提供的 CAS 操作来原子地修改引用:

// 原子地将 node.next 从 cmp 更新为 val
boolean casNext(Node<K,V> cmp, Node<K,V> val) {
    return UNSAFE.compareAndSwapObject(this, nextOffset, cmp, val);
}

CAS 是无锁编程的基石:如果当前值与期望值一致,则更新为新值;否则说明其他线程已修改,需要重试。

插入的并发实现

sequenceDiagram
    participant T1 as 线程1 (插入30)
    participant T2 as 线程2 (插入52)
    participant L0 as Level 0 链表
    participant L1 as Level 1 索引
    
    Note over T1,T2: 两个线程并发执行插入
    
    T1->>L0: 查找30的前驱节点(27)
    T2->>L0: 查找52的前驱节点(47)
    
    T1->>L0: CAS: 27.next 从31改为30
    L0-->>T1: CAS 成功!
    
    T2->>L0: CAS: 47.next 从63改为52
    L0-->>T2: CAS 成功!
    
    Note over T1,T2: 两个插入互不影响,因前驱不同
    
    T1->>L1: lazy插入Level 1索引
    T2->>L1: lazy插入Level 1索引
    
    Note over T1,L1: 即使索引插入失败,数据已正确存储

图表说明

  • 图表主旨:演示两个线程并发插入不同键时,各自的 CAS 操作互不干扰,实现真正的并行。
  • 逐层分解
    • 查找阶段:线程 1 和线程 2 分别独立查找各自键的前驱节点。由于键不同(30 vs 52),它们的前驱节点完全不同(27 vs 47),不存在竞争。
    • CAS 插入阶段:线程 1 使用 CAS 将节点 27 的 next 从 31 改为 30;线程 2 同时将节点 47 的 next 从 63 改为 52。这两个 CAS 操作针对不同的内存地址,可以在硬件层面真正并行执行
    • 索引更新阶段:数据节点插入后,两个线程分别“懒加载”地在更高层插入索引节点。即使某个线程的索引插入因竞争失败,也不影响数据正确性——这也是跳表与平衡树的关键区别。
  • 原理映射:CAS 的哲学是“乐观操作”——假设没有竞争,直接修改;如果发现有人抢先,就重试。配合跳表的局部修改特性,CAS 失败的可能性很低。
  • 关键结论跳表 + CAS = 几乎完美的无锁有序映射。在写密集型场景,ConcurrentSkipListMap 的吞吐量可以比全局锁保护的 TreeMap 高 3-10 倍,且随线程数增加几乎线性扩展。

删除的并发实现:标记节点

ConcurrentSkipListMap 的删除采用三段式

  1. 标记(Mark):将目标节点的 value CAS 设为 null,表示此节点已逻辑删除。
  2. 插入标记节点(Marker):在目标节点后插入一个特殊的标记节点(其 value 指向自己)。
  3. 物理删除:CAS 将前驱节点的 next 跳过目标和标记节点。

查找操作在遇到 value == null 的节点时,会自动协助清理(help delete),这是典型的 Lock-Free 协作模式

5.3 并发红黑树 vs 并发跳表

对比维度全局锁红黑树细粒度锁红黑树跳表 (CAS)
读并发度1(共享锁可能多个)高(读写锁)极高(完全无锁读)
写并发度1中(路径锁耦合)高(仅局部 CAS 竞争)
实现复杂度极高
死锁风险有(锁排序)无(无锁)
旋转影响全局等待路径锁定无旋转
范围操作需锁保护需锁保护弱一致性,无锁遍历

核心结论:在单线程或低并发场景,红黑树的常数优势使其略快;但当线程数 ≥ 4 且存在写入时,跳表几乎总是更优选择。


模块 6:工程实现与最佳实践

6.1 ConcurrentSkipListMap 核心用法

import java.util.concurrent.ConcurrentSkipListMap;
import java.util.Map;

public class SkipListDemo {
    
    public static void main(String[] args) {
        // 1. 创建跳表(自然顺序或自定义比较器)
        ConcurrentSkipListMap<String, Double> leaderboard = 
            new ConcurrentSkipListMap<>();
        
        // 2. 基本操作
        leaderboard.put("Alice", 9850.0);
        leaderboard.put("Bob", 10200.0);
        leaderboard.put("Charlie", 8700.0);
        leaderboard.put("Diana", 10200.0);   // 相同分数,按名字排序
        leaderboard.put("Eve", 9100.0);
        
        // 3. 有序操作——跳表的核心价值
        System.out.println("=== 排行榜 ===");
        // firstEntry: 分数最低(按自然序)
        System.out.println("最低分: " + leaderboard.firstEntry());
        // lastEntry: 分数最高
        System.out.println("最高分: " + leaderboard.lastEntry());
        
        // 4. 范围查询——subMap
        System.out.println("\n=== 9000-10000 分段 ===");
        Map<String, Double> midRange = leaderboard.subMap("9000", "10000");
        midRange.forEach((k, v) -> System.out.println(k + ": " + v));
        
        // 5. 导航操作:高于指定键的最小键
        System.out.println("\nBob 的下一位: " + 
            leaderboard.higherKey("Bob"));
        System.out.println("分数刚好低于 10000 的: " + 
            leaderboard.lowerEntry("10000"));
        
        // 注意:由于键是 String,范围查询按字典序而非数值序!
        // 如果需要按数值排序,应使用自定义 Comparator
    }
}

并发环境下的弱一致性迭代

import java.util.Iterator;
import java.util.concurrent.ConcurrentSkipListMap;
import java.util.concurrent.CountDownLatch;

public class WeakConsistencyDemo {
    
    public static void main(String[] args) throws InterruptedException {
        ConcurrentSkipListMap<Integer, String> map = 
            new ConcurrentSkipListMap<>();
        
        // 预填充数据
        for (int i = 0; i < 10; i++) {
            map.put(i, "value-" + i);
        }
        
        CountDownLatch startLatch = new CountDownLatch(1);
        CountDownLatch endLatch = new CountDownLatch(2);
        
        // 线程1:在遍历过程中插入新元素
        Thread writer = new Thread(() -> {
            try {
                startLatch.await();
                for (int i = 10; i < 200; i++) {
                    map.put(i, "new-value-" + i);
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                endLatch.countDown();
            }
        });
        
        // 线程2:遍历跳表
        Thread reader = new Thread(() -> {
            try {
                startLatch.await();
                int count = 0;
                Iterator<Integer> it = map.keySet().iterator();
                while (it.hasNext()) {
                    it.next();
                    count++;
                    Thread.sleep(1); // 模拟慢速遍历
                }
                System.out.println("遍历到的元素数: " + count);
                System.out.println("实际元素数: " + map.size());
                System.out.println("注意:遍历数可能 < 实际数(弱一致性)");
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                endLatch.countDown();
            }
        });
        
        writer.start();
        reader.start();
        startLatch.countDown();
        endLatch.await();
    }
}

运行结果解读:遍历过程中插入的新元素可能被看到,也可能被忽略,这体现了弱一致性——迭代器返回的是迭代器创建时某个时间点的快照,且不抛 ConcurrentModificationException

6.2 跳表 vs TreeMap vs ConcurrentHashMap 选型决策

flowchart TD
    START["需要键值存储"] --> Q1{"是否需要有序操作?"}
    
    Q1 -->|"否"| HASH["使用 HashMap<br/>(单线程)或<br/>ConcurrentHashMap(多线程)"]
    
    Q1 -->|"是(范围查询、排名等)"| Q2{"是否多线程环境?"}
    
    Q2 -->|"否,单线程"| Q3{"写操作是否频繁?"}
    Q3 -->|"以读为主"| TM_READ["TreeMap<br/>(常数因子更优,缓存友好)"]
    Q3 -->|"读写均衡或写多"| TM_WRITE["TreeMap<br/>(确定性的 O(log n),<br/>单线程下无竞争开销)"]
    
    Q2 -->|"是,多线程"| Q4{"读/写比例如何?"}
    Q4 -->|"读 >> 写"| Q5{"范围查询是否频繁?"}
    Q5 -->|"否"| CHM["ConcurrentHashMap<br/>+ 定期排序(如果可接受)"]
    Q5 -->|"是"| CSLM_READ["ConcurrentSkipListMap<br/>(完全无锁读,范围操作自然支持)"]
    
    Q4 -->|"写密集或均衡"| CSLM_WRITE["ConcurrentSkipListMap<br/>(几乎线性扩展的写吞吐量)"]
    
    style CSLM_READ fill:#4CAF50,color:#fff
    style CSLM_WRITE fill:#4CAF50,color:#fff
    style HASH fill:#2196F3,color:#fff
    style CHM fill:#2196F3,color:#fff
    style TM_READ fill:#FF9800,color:#fff
    style TM_WRITE fill:#FF9800,color:#fff

图表说明

  • 图表主旨:提供一个基于“线程安全需求”和“有序性需求”的选型决策树,帮助在 TreeMapConcurrentHashMapConcurrentSkipListMap 之间做出选择。
  • 逐层分解
    • 第一层判断——“是否需要有序”:这是最根本的区分。无序场景直接导向哈希表系列。
    • 第二层判断——“是否多线程”:单线程场景下 TreeMap 的常数优势使其成为更优选择。
    • 第三层判断——“读写比例”:写密集型多线程场景是 ConcurrentSkipListMap 的绝对优势区。
  • 关键结论ConcurrentSkipListMap 的黄金场景 = 需要有序 + 多线程 + 存在写入。三者缺一都应重新评估选型。

6.3 与 TreeMap 的性能对比

以下是一个简单的并发写入性能对比框架(实际精确测试应使用 JMH):

import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

public class PerformanceComparison {
    
    static final int THREAD_COUNT = 8;
    static final int OPERATIONS_PER_THREAD = 100_000;
    
    public static void main(String[] args) throws Exception {
        // 测试 ConcurrentSkipListMap
        ConcurrentSkipListMap<Integer, String> skipList = 
            new ConcurrentSkipListMap<>();
        long skipTime = benchmark(skipList);
        
        // 测试 全局锁 TreeMap
        NavigableMap<Integer, String> syncTreeMap = 
            Collections.synchronizedNavigableMap(new TreeMap<>());
        long treeTime = benchmark(syncTreeMap);
        
        System.out.println("ConcurrentSkipListMap 耗时: " + skipTime + "ms");
        System.out.println("Synchronized TreeMap 耗时: " + treeTime + "ms");
        System.out.printf("跳表吞吐量是 TreeMap 的 %.1f 倍%n", 
            (double) treeTime / skipTime);
    }
    
    static long benchmark(Map<Integer, String> map) throws Exception {
        ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
        CountDownLatch latch = new CountDownLatch(THREAD_COUNT);
        AtomicInteger counter = new AtomicInteger();
        
        long start = System.currentTimeMillis();
        
        for (int t = 0; t < THREAD_COUNT; t++) {
            executor.submit(() -> {
                for (int i = 0; i < OPERATIONS_PER_THREAD; i++) {
                    int key = counter.incrementAndGet();
                    map.put(key, "value-" + key);
                    // 混合 20% 读取
                    if (i % 5 == 0) {
                        map.get(key / 2);
                    }
                }
                latch.countDown();
            });
        }
        
        latch.await();
        executor.shutdown();
        return System.currentTimeMillis() - start;
    }
}

典型结果参考(4 核 CPU):

  • ConcurrentSkipListMap: ~800ms
  • Synchronized TreeMap: ~3500ms
  • 跳表吞吐量约为全局锁 TreeMap 的 4 倍

6.4 Redis ZSet 的跳表实现点睛

Redis 有序集合在以下条件时使用跳表:

  • 元素数量 > 128 或
  • 任一元素长度 > 64 字节

Redis 跳表的关键增强——span 字段

// Redis 源码中的跳表节点(简化版)
typedef struct zskiplistNode {
    sds ele;                    // 元素成员(字符串)
    double score;               // 分值
    struct zskiplistNode *backward;  // 后退指针(双向链表)
    struct zskiplistLevel {
        struct zskiplistNode *forward;  // 前进指针
        unsigned long span;            // 跨度
    } level[];                  // 柔性数组,每层一个
} zskiplistNode;

span 字段的作用:记录从当前节点到本层下一个节点之间,在 Level 0 上跨越的节点数。这使得 ZRANK(获取排名)操作成为 O(log n):从顶层向下查找目标节点,累计各层的 span 值即为排名。

例如,查找排名时:

rank = 0
从顶层 head 开始
每向右移动一步,rank += current.level[i].span  // 跳过 span 个节点
下潜时 rank 不变
最终 rank + 1 即为目标元素的排名

这展示了跳表的强大扩展能力——只需在索引节点中增加少量元数据,即可支持新的有序操作,而平衡树要实现同样的扩展可能涉及复杂的子树大小维护。

6.5 工程避坑清单

陷阱表现原因解决方案
单线程滥用跳表性能不如 TreeMap跳表的多层索引访问 + 随机数生成开销单线程场景使用 TreeMap
误依赖迭代器强一致性size()isEmpty() 返回值变化,迭代器可能遗漏近期插入跳表迭代器是弱一致性的理解弱一致性语义,必要时使用显式快照或加锁
忽略 CAS 自旋的 CPU 消耗极高写入竞争时 CPU 飙升CAS 失败 → 自旋重试 → 循环识别热点键,业务层分散写入(如添加随机前缀后聚合)
自定义比较器不一致元素无法查找或重复compare(a,b) != -compare(b,a) 或与 equals 不一致确保 Comparator 满足反对称性和传递性
将跳表用作队列/栈频繁头尾操作性能退化跳表优化的是随机查找,不是头尾操作使用 ConcurrentLinkedQueue/Deque 等专用结构
忽略内存开销内存占用远超预期p=0.5 时额外索引节点 ≈ n,每个 Index 对象有对象头开销评估内存预算,考虑 Chronicle Map 等更紧凑的替代方案

模块 7:面试高频专题

7.1 什么是跳表?它的基本结构是怎样的?

标准回答:跳表是一种基于多层有序链表的概率性数据结构,通过在原始有序链表上建立多层稀疏索引来加速查找。第 0 层包含所有元素的完整有序链表,每向上一层都是下一层的子集抽样(通常每 2 个抽 1 个),查找时从最高层向右下潜,将时间复杂度从 O(n) 降至期望 O(log n)。

追问 1:为什么层数越高越稀疏?层高是如何确定的? 回答:每个节点在插入时通过随机函数独立决定自己的层高(p=0.5 的几何分布)。期望层高为 2,因此第 1 层约有 n/2 个节点,第 2 层 n/4 个,自然形成稀疏结构。不需要全局维护层间比例。

追问 2:如果所有节点的随机层高都是 1,会发生什么? 回答:跳表退化为普通有序链表,查找复杂度 O(n)。但概率极低——对 100 万个节点,概率约 10⁻³⁰¹⁰³⁰,宇宙年龄内不可能发生。

加分回答:跳表的层高分布与“抛硬币直到出现反面”等价,这是几何分布的无记忆性在数据结构设计中的直接应用。William Pugh 在 1990 年的论文中证明了这种随机化方法在期望上等价于完美平衡的 B 树。


7.2 如何实现跳表的查找、插入、删除?时间复杂度是多少?

标准回答

  • 查找:从最高层头节点出发,若右邻节点的键 < 目标键则向右移动,否则向下移动,直到第 0 层找到目标或确认不存在。时间 O(log n)。
  • 插入:先执行查找记录各层前驱节点,随机生成新节点的层高,创建节点后在 0 到 level 层的各层前驱之后插入新节点(或索引节点)。时间 O(log n)。
  • 删除:在各层找到目标节点的前驱,将它们的目标后继(目标节点或索引节点)从链表中摘除。时间 O(log n)。

追问 1:插入时为什么从底层向上插入? 回答:目的是保证查找的正确性。如果先插入上层索引后插入底层数据,并发查找可能从上层索引指向一个尚未存在的底层节点,导致空指针异常。从底层向上插入保证了“向下引用永远指向已存在的节点”。

追问 2:层高有上限吗?为什么? 回答:有,通常设为 31 或 64。理由有二:① 对于任何可处理的数据规模(如 n ≤ 2³²),log₁/p(n) 都不会超过此上限;② 防止恶意数据触发极端层高导致内存溢出。

加分回答:在并发实现(Java ConcurrentSkipListMap)中,索引节点的插入是“懒”且“后置”的——底层数据节点通过 CAS 插入成功后,逐层尝试 CAS 插入索引节点。如果某层 CAS 失败,说明其他线程已修改,直接放弃该层索引(不影响正确性,仅略影响查找性能)。这种“尽力而为而非保证”的设计是无锁编程的典型哲学。


7.3 跳表的“概率平衡”是什么意思?

标准回答:跳表不通过旋转等确定性操作来保证树形结构的平衡,而是依赖随机层高算法使节点层高服从几何分布。在期望意义上,第 i 层有 n×pⁱ 个节点,跳表高度约为 log₁/p(n),查找时需要比较的次数期望为 O(log n)。它不是保证“每次操作都是 O(log n)”,而是保证“每次操作的期望是 O(log n),且方差很小”。

追问 1:为什么不直接维护完美平衡(如每 2 个抽 1 个)? 回答:维护完美平衡需要在每次插入/删除后调整所有相关节点的层高和索引,代价与重建相当。随机化的优势在于每个节点的层高决策完全独立,插入时只需修改局部指针,不需要检查或调整其他节点的层高。

追问 2:p 值的选择有什么影响? 回答:p 越小,额外空间越小,但查找路径中的层数虽少、每层比较次数增多;p 越大则相反。p=0.5 是查找性能与空间开销的最优折中(也是 Pugh 论文推荐值)。

加分回答:跳表是“拉斯维加斯算法”(结果正确、运行时间随机)在数据结构领域的经典应用。与快速排序类似,它用随机化换取了简洁性和平均性能,但提供了极强的概率保证——退化概率随 n 指数级下降。


7.4 跳表和红黑树相比各有什么优缺点?为什么跳表更适合并发?

标准回答

维度跳表红黑树
平均时间复杂度O(log n)O(log n)
最坏时间复杂度概率 O(log n)(退化概率趋近 0)确定 O(log n)
实现复杂度低(~200 行核心代码)高(~400 行核心代码,5 种旋转场景)
范围查询天然高效(底层链表遍历)中序遍历(需递归/栈)
空间开销O(n)(额外索引节点)O(n)(仅数据节点)
并发友好度极高(局部修改,可无锁)(旋转影响路径,锁范围大)
单线程性能略低(多级指针跳转)略高(缓存局部性好)

跳表更适合并发的根本原因:插入和删除只修改局部指针,无需全局结构调整,天然适配 CAS 无锁实现;红黑树的旋转会影响从插入点到根的路径,难以无锁实现。

追问 1:如果业务确定是单线程,选哪个? 回答TreeMap(红黑树)略优。单线程下无锁开销,且红黑树常数因子稍小。但对于需要频繁范围查询的场景,跳表的链表遍历仍有优势。

加分回答:Doug Lea 在设计 Java 并发包时,曾评估过并发红黑树的实现可能性,结论是细粒度锁红黑树的复杂度远高于跳表,且正确性难以保证。ConcurrentSkipListMap 最终成为 JDK 1.6 加入的并发有序容器。


7.5 ConcurrentSkipListMap 如何实现无锁并发?CAS 在哪些环节使用?

标准回答ConcurrentSkipListMap 使用 Unsafe 类的 CAS 操作实现所有引用修改,主要包括:

  • 数据节点插入:CAS 更新第 0 层前驱节点的 next 引用。
  • 索引节点插入:CAS 更新各层索引节点的 right 引用。
  • 逻辑删除:CAS 将节点的 value 设为 null
  • 物理删除:CAS 将前驱的 next 跳过已标记节点。
  • 节点值更新:CAS 更新已存在节点的 value

追问 1:如果 CAS 失败怎么办? 回答:采用自旋重试(或在更高层放弃索引插入)。由于跳表的局部性,CAS 失败的场景有限(仅当其他线程修改同一前驱的后继),通常 1-2 次重试即成功。

追问 2:删除时为什么先标记再摘除? 回答:这是为了并发查找的正确性。查找线程可能正在遍历被标记的节点,此时若直接摘除,查找线程会丢失引用。标记节点充当“墓碑”,告知后续线程跳过此节点,物理摘除可以稍后安全执行。

加分回答ConcurrentSkipListMap 还实现了一种“帮助删除”机制:查找过程中发现标记节点时,会主动协助完成物理删除。这减少了标记节点存留时间,也体现了 Lock-Free 算法的“协作”哲学——线程不仅完成自己的工作,还顺手帮助其他线程的未完成工作。


7.6 跳表在 Redis ZSet 中如何使用?如何实现 ZRANK?

标准回答:Redis 有序集合在元素较多时使用跳表作为核心数据结构。Redis 跳表的关键增强是在每个索引节点的每层添加 span 字段,记录从当前节点到本层下一个节点之间在底层跨越的节点数。执行 ZRANK 时,从顶层头节点开始,累计路径上各层的 span 值,找到目标元素后,累计的 span 即为该元素排名(从 0 开始)。

追问 1:为什么 Redis 选择跳表而非红黑树? 回答:三原因:① 实现简单(Redis 是 C 语言,跳表约 200 行,红黑树 400+ 行);② 范围查询高效(底层双向链表,ZRANGE 直接遍历即可);③ 易于扩展排名功能(添加 span 字段即可,红黑树扩展开销大)。

追问 2:Redis ZSet 在元素较少时用什么结构? 回答:使用压缩列表,这是一种紧凑的连续内存结构,在小数据量下内存效率和缓存局部性更好。当元素数量超过 128 或任一元素长度超过 64 字节时,自动转换为跳表。

加分回答:Redis 跳表的底层是双向链表(有 backward 指针),这使其支持反向遍历,如 ZREVRANGE(倒序获取范围)。这是通用跳表的设计增强,体现了数据结构在工程中的灵活适应。


7.7 跳表的空间复杂度是多少?额外索引节点影响大吗?

标准回答:跳表期望总节点数 = n(数据节点)+ n×p/(1-p)(索引节点)。当 p=0.5 时,额外索引节点约等于 n,总空间 O(2n) = O(n)。影响评估:

  • 相比链表(O(n)),多约 1 倍空间
  • 相比红黑树(每个节点两个子指针 + 颜色 + 父指针),实际相差不大(Java 中 TreeMap.Entry 有 5 个字段,ConcurrentSkipListMap.Node + Index 平均也约 5-6 个字段)
  • 对于百万级数据,额外内存约 16-32 MB,在现代硬件下可接受

追问:如何减少空间开销? 回答:降低 p 值(如 p=0.25)可减少索引节点,但会增大查找常数。另一思路是将索引节点数组化(每个数据节点内嵌固定长度索引数组),但会损失灵活性。


7.8 什么情况用跳表而非哈希表或平衡树?

标准回答

  • 选跳表而非哈希表:需要有序操作(范围查询、排名、前驱/后继查找)时。哈希表在这些操作上是 O(n) 的。
  • 选跳表而非平衡树:多线程并发环境,特别是存在写入的场景。跳表的无锁实现可提供近乎线性扩展的吞吐量。
  • 具体场景:实时排行榜、有序事件时间线、带过期的缓存、分布式协调的顺序节点存储。

追问:如果是单线程 + 需要有序,选跳表还是 TreeMap? 回答:TreeMap。单线程下无并发竞争,TreeMap 的常数因子和缓存局部性更好。跳表的优势在于并发,单线程场景无法发挥。


7.9 跳表的迭代器是强一致的吗?

标准回答:不是。ConcurrentSkipListMap 的迭代器是弱一致性的,它反映的是迭代器创建时某个时间点的快照,且不会抛出 ConcurrentModificationException。在遍历过程中,其他线程的插入可能被看到,也可能被忽略;删除的节点如果已被标记但未物理摘除,可能仍被遍历。

追问:如果需要强一致性快照怎么办? 回答:① 自行加读锁保护整个遍历(丧失并发优势);② 使用 CopyOnWrite 思想,在遍历前复制整个跳表(空间开销大);③ 设计上接受弱一致性(大多数实时系统可容忍)。

加分回答:弱一致性是高性能并发容器的共性设计选择——ConcurrentHashMapConcurrentLinkedQueue 等均采用了类似语义。这不是 bug,而是设计权衡:牺牲遍历时刻的完全准确性,换取无锁的高吞吐量。


7.10 系统设计:实时股票排名系统

题目:设计一个支持以下操作的实时股票交易系统:

  • 查询某股票当前价格在全部股票中的排名
  • 实时更新股票最新成交价(每秒数百次写入)
  • 快速获取 Top 10 最活跃股票(按成交量排序)

分析跳表、堆、排序数组的优劣,并说明最终选择。


标准回答

方案对比

方案更新排名查询排名获取 Top 10并发支持
排序数组 + 二分O(n)(插入移动元素)O(log n)O(1)(取前 10)差(全局锁)
二叉堆O(log n)(仅能维护堆顶)不支持(堆无序)O(1)(堆顶)+ O(n) 取 10
跳表O(log n)O(log n)O(1)+O(10)极好(无锁)
红黑树O(log n)O(log n)O(log n)+O(10)差(全局锁)

最终选择跳表,理由

  1. 高并发写入:每秒数百次价格更新,跳表的 CAS 无锁设计使其吞吐量随核数线性增长。
  2. 排名查询:通过索引节点存储 span(类似 Redis ZSet),可实现 O(log n) 排名查询。
  3. Top 10:获取最大 10 个只需从尾部向前遍历 10 个节点,O(1) 定位尾部 + O(10) 遍历。
  4. 实现可行性:可直接使用 Java ConcurrentSkipListMap(自定义 Comparator 按成交量排序),无需从头实现。

追问 1:如何处理同一股票多次更新? 回答:使用 put 操作的覆盖语义,新成交量直接替换旧值。如果需要记录历史,可以将跳表值设为交易记录列表。

追问 2:如果数据量极大(数万股票),如何扩展? 回答:可以按行业或交易所分片,每个分片独立跳表,查询全局排名时合并各分片结果(类似 MapReduce 思想)。

加分回答:实际系统中还可以结合 优先队列 + 跳表 的混合方案:跳表维护全量排名,优先队列缓存 Top 10 热点(降低频繁 Top 10 查询对跳表的压力)。另外,如果价格更新过于频繁(如高频交易场景),可考虑使用 环形缓冲区 + 批量更新 减少跳表的 CAS 竞争次数。


延伸阅读

  1. William Pugh. Skip Lists: A Probabilistic Alternative to Balanced Trees. Communications of the ACM, 1990.
    跳表的原始论文,必读经典。清晰阐述了概率平衡的哲学、随机层高的几何分布证明,以及与平衡树的对比实验。论文仅 12 页,但信息密度极高。

  2. Doug Lea. ConcurrentSkipListMap 源码注释(JDK 源码 java.util.concurrent 包). ConcurrentSkipListMap.java 中的 Javadoc 和内部注释是学习无锁跳表实现的最佳材料。Doug Lea 在注释中详细解释了设计决策(如为何使用标记节点、索引节点与数据节点的分离等),是 Lock-Free 数据结构的教科书级实现。

  3. 黄健宏. Redis 设计与实现(第 4 章:跳表). 机械工业出版社, 2014. 深入剖析 Redis 跳表的 C 语言实现,包括 span 字段的排名计算、插入/删除操作的代码级讲解。学习跳表如何在工程中被“魔鬼细节”优化。

  4. Maurice Herlihy, Nir Shavit. The Art of Multiprocessor Programming(第 14 章:Skiplist-Based Synchronization). Morgan Kaufmann, 2012. 从多处理器编程理论角度分析跳表,涵盖了基于锁的并发跳表和 Lock-Free 跳表的完整实现,并提供了正确性证明框架。是并发数据结构领域的权威参考。

  5. Fomitchev, Ruppert. Lock-Free Linked Lists and Skip Lists. Proceedings of PODC, 2004. 实现了一个完全无锁的跳表(包含无锁的索引插入/删除),相比 JDK 的“索引懒更新”更进了一步。适合对 Lock-Free 算法有深入研究兴趣的读者。


结语

跳表是数据结构领域“少即是多”哲学的完美体现。它没有红黑树那般精巧繁复的平衡规则,却以概率平衡化繁为简;它没有哈希表 O(1) 的直接,却在有序性上提供了不可替代的价值。更重要的是,在并发成为常态的今天,跳表以局部修改的天性,优雅地拥抱了无锁并发的浪潮。

从 William Pugh 1990 年的论文,到 Doug Lea 在 JDK 中的精心实现,再到 Redis、LevelDB 等基础设施的广泛采用,跳表用三十多年的工程实践证明:有时,接受一点点不确定性,换来的是架构上的极大简化与性能上的巨大飞跃。