第 7 篇:跳表 (Skip List):简单务实的概率性选手
前面几篇我们都在探讨各种基于“树”结构的有序表实现,它们通过精巧的平衡策略(高度、颜色、大小)和核心的“旋转”操作来保证 O(log N) 的性能。今天,我们要介绍一位画风完全不同的选手——跳表 (Skip List)。它不依赖树形结构,也不需要复杂的旋转,而是巧妙地利用概率和多层链表,同样实现了平均 O(log N) 的高效查找、插入和删除。
核心思想:链表 + 多级“快车道”索引
跳表的设计灵感非常直观,可以类比我们之前提到的地铁系统:
- 基础层 (Level 0): 就是一条普通的有序链表,包含所有的数据元素。这是“站站停”的慢车线,保证了数据的完整性和有序性。
- 多级索引层 (Higher Levels): 在基础层之上,随机地抽取一部分节点,形成更高一层的“稀疏”链表,作为索引。层级越高,节点越稀疏,跨度越大,就像地铁的“快车线”、“特快线”。
Level 2: H --------------------------------> 50 ------------> null| |
Level 1: H --------> 25 ------------------> 50 --------> 75 -> null| | | |
Level 0: H --> 10 --> 25 --> 30 --> 38 --> 50 --> 60 --> 75 --> 90 --> null
(H 代表头节点 Head)
- 查找过程: 从最高层的“特快线” (Level 2) 开始,向右查找,直到找到最后一个小于目标值的节点(比如要找 65,在 Level 2 找到 50)。然后从该节点下降到下一层“快车线” (Level 1),继续向右查找(从 50 开始,找到 50)。再下降到“慢车线” (Level 0),继续向右查找(从 50 开始,找到 60)。发现下一个节点 75 大于 65,查找结束(如果需要精确查找,则检查 60 的下一个节点是否是 65)。
通过这种方式,高层索引帮助我们快速“跳过”大量节点,大幅减少了查找所需的比较次数。
平衡的“魔法”:随机化层高 (The Magic of Randomness)
跳表是如何决定哪些节点可以进入“快车道”,以及一个节点应该出现在多少层“快车道”上的呢?答案是——随机化 (Randomization)!
当插入一个新节点时:
- 它首先肯定会被插入到 Level 0 的基础链表中。
- 然后,进行一次“抛硬币”(比如生成一个 0 到 1 的随机数)。如果结果满足某个条件(比如小于概率 p,通常 p=0.5 或 p=0.25),那么这个节点也被提升到 Level 1,并插入到 Level 1 的链表中。
- 如果成功提升到 Level 1,就再次抛硬币,决定是否提升到 Level 2。
- 以此类推,直到抛硬币结果不再满足提升条件,或者达到了预设的最大层级 MAX_LEVEL。
这个随机化的过程,使得:
- 平均来看,大约有 p 比例的 Level i 的节点会出现在 Level i+1。
- 层级越高,节点越稀疏。
- 虽然单个节点的层高是随机的,但从整体上看,这种多层结构在概率上是平衡的,能够保证查找、插入、删除操作的平均时间复杂度达到 O(log N)。
权衡与优缺点
-
优点:
- 实现相对简单: 相比平衡树复杂的旋转逻辑,跳表的插入和删除主要涉及链表节点的指针修改,逻辑更清晰,代码更容易编写和理解。
- 插入/删除高效: 操作通常只影响局部节点,平均性能很好,且在并发场景下更容易实现高效的锁策略(相比整棵树可能需要调整的平衡树)。
- 范围查询友好: 底层是有序链表,执行范围查询非常自然和高效。
-
缺点:
- 概率性保证: 它的 O(log N) 性能是平均情况下的,虽然概率极低,但理论上存在性能退化到 O(n) 的最坏情况(比如所有节点都只停留在 Level 0)。而平衡树提供的是确定性的 O(log N) 最坏情况保证。
- 空间开销: 每个节点平均包含 1 / (1 - p) 个 right 指针(加上 down 指针),相比平衡树的固定 2 个子节点指针,其空间开销通常会稍大一些(但仍然是 O(n) 级别)。
一句话选型总结 (跳表)
跳表: 实现内存有序表时,若看重实现简单性、写性能和范围查询效率,且能接受概率性性能保证,跳表是优秀选择。
实际项目思考 (Java & Beyond)
- Redis 的 ZSet (Sorted Set): 这是跳表最著名、最成功的应用案例之一!ZSet 需要支持按 Score 排序、快速增删改成员、按 Score 范围查询、按排名查询等多种操作。跳表完美地契合了这些需求,其简单的实现和良好的综合性能是 Redis 选择它的重要原因。
- LevelDB / RocksDB 的 MemTable: 这些键值存储引擎在内存中使用跳表来组织数据(MemTable),因为跳表支持快速写入和高效的范围扫描,便于后续将数据刷写到磁盘。
- 高并发有序数据结构: 在一些需要高并发读写的有序场景下,跳表基于链表的操作特性使得其锁粒度更容易控制(例如,可以只锁住相关的节点),相比需要旋转可能影响更大范围节点的平衡树,更容易设计出高性能的并发实现。Java 的 java.util.concurrent.ConcurrentSkipListMap 和 ConcurrentSkipListSet 就是基于跳表实现的线程安全的有序集合。
跳表以其独特的设计哲学——用简单的概率机制代替复杂的确定性平衡算法——在数据结构的世界里占据了一席之地。它证明了在很多时候,“足够好”的概率性保证加上实现的简单性,比追求理论上的完美更具工程价值。
下一篇,我们将把目光投向处理海量数据、面向磁盘存储场景的王者——B/B+ 树。