跳表SkipList:从原理到高性能实现,彻底搞懂Redis同款数据结构
引言
在数据结构的浩瀚星空中,平衡二叉搜索树(如AVL树、红黑树)长期主导着有序数据的快速增删改查场景。然而,它们复杂的旋转操作和多指针调整常常让开发者望而却步。有没有一种结构,既能保持对数级别的查找效率,又具备插入、删除逻辑的简洁性?跳表(SkipList)就是答案。
跳表由William Pugh在1990年提出,是一种基于随机化的层次化有序链表。它在工程中极为成功:Redis有序集合(Sorted Set)的底层实现之一就是跳表,LevelDB的MemTable也采用跳表加速查找。本文将从底层原理出发,手把手带你实现一个完整、可运行的跳表,并深入探讨其优化细节与常见陷阱。
一、核心概念:从链表到多层索引的蜕变
1.1 有序链表的瓶颈
假设我们有一个有序单链表,存储了 [3, 6, 9, 15, 21, 34]。如果要查找元素 21,最坏情况下需要遍历6个节点,时间复杂度 O(n)。链表虽然插入、删除只需修改指针,但查找必须线性扫描,效率低下。
跳表的精髓在于空间换时间——为链表增加多层“索引”,使查找能够跨越多个节点。

(图:多层索引构建的跳表结构)
如上图,原始链表在第0层(L0)包含所有元素。第1层(L1)提取部分节点作为索引,第2层(L2)再从L1提取,形成金字塔状结构。查询时从最高层开始,逐层下降,每次可以跳过大批节点。
- 查找
21的过程: - L2层:从3开始,下一个节点是9(小于21,前进),9后面是34(大于21,下降)。
- L1层:在9处,下一个是21(等于,完成),或者继续按规则判断。
- 总比较次数远小于
n,平均为O(log n)。
1.2 随机化确定层数
跳表没有严格的平衡条件,其平衡性由概率保证。每个节点插入时,通过抛硬币(随机数生成)决定它要出现在多少层中。常见的实现是:节点有50%概率出现在第1层(L1),25%出现在第2层(L2),以此类推,形成一个几何分布。这保证了高层节点稀疏、低层节点稠密,近似平衡树的形态。
1.3 时间复杂度与空间复杂度
- 查找/插入/删除:平均
O(log n),最坏O(n)(极低概率)。 - 空间复杂度:期望
O(n)。平均每个节点有2个指针(因为层数期望为2),所以额外空间开销可控。
相较于红黑树,跳表实现更简单,查找范围操作也更直观(直接走底层链表遍历区间)。这些优点使其在工业界备受青睐。
二、实战示例:手写一个完整的跳表
下面我们用 Python 实现一个支持泛型键值的跳表,包含 search、insert、delete 三个核心操作。代码结构清晰,注释详尽,可直接运行测试。
import random
from typing import Any, Optional, List
class SkipNode:
__slots__ = ('key', 'value', 'forward')
def __init__(self, key: Any, value: Any, level: int):
self.key = key # 键,用于比较大小
self.value = value # 存储的值
# forward 数组:forward[i] 表示该节点在第 i 层的下一个节点指针
self.forward: List[Optional[SkipNode]] = [None] * (level + 1)
class SkipList:
_MAX_LEVEL = 16 # 最大层数,可根据数据量调整(2^16 ≈ 65535)
_PROB = 0.5 # 提升概率
def __init__(self):
# 头节点:不存储实际数据,其 forward 数组作为各层的起始指针
self.head = SkipNode(None, None, self._MAX_LEVEL)
self.level = 0 # 当前跳表实际最高层数
self._size = 0
def _random_level(self) -> int:
"""随机生成节点层数:从0开始,每次以概率 PROB 上升一层"""
lvl = 0
while random.random() < self._PROB and lvl < self._MAX_LEVEL:
lvl += 1
return lvl
def search(self, key: Any) -> Optional[Any]:
"""查找 key 对应的 value,若不存在返回 None"""
cur = self.head
# 从最高层向下搜索
for i in range(self.level, -1, -1):
while cur.forward[i] and cur.forward[i].key < key:
cur = cur.forward[i] # 在同一层向右移动
# 此时 cur 位于第0层、目标位置的前一个节点
cur = cur.forward[0]
if cur and cur.key == key:
return cur.value
return None
def insert(self, key: Any, value: Any) -> bool:
"""插入键值对,若 key 已存在则更新 value 并返回 False"""
update = [None] * (self._MAX_LEVEL + 1) # 记录每层需要更新的前驱节点
cur = self.head
# 1. 从高层往低层找到每层的前驱位置,存入 update 数组
for i in range(self.level, -1, -1):
while cur.forward[i] and cur.forward[i].key < key:
cur = cur.forward[i]
update[i] = cur
# 2. 检查底层下一个节点是否已经存在 key
cur = cur.forward[0]
if cur and cur.key == key:
cur.value = value # 更新值
return False
# 3. 生成随机层数
new_level = self._random_level()
if new_level > self.level:
# 新节点层数超过当前最高层,将多余的层用头节点填充
for i in range(self.level + 1, new_level + 1):
update[i] = self.head
self.level = new_level
# 4. 创建新节点,并像插入链表一样修改各层指针
new_node = SkipNode(key, value, new_level)
for i in range(new_level + 1):
new_node.forward[i] = update[i].forward[i]
update[i].forward[i] = new_node
self._size += 1
return True
def delete(self, key: Any) -> bool:
"""删除指定 key,成功返回 True,不存在返回 False"""
update = [None] * (self._MAX_LEVEL + 1)
cur = self.head
for i in range(self.level, -1, -1):
while cur.forward[i] and cur.forward[i].key < key:
cur = cur.forward[i]
update[i] = cur
cur = cur.forward[0]
if not cur or cur.key != key:
return False
# 从底层向上逐层修改指针,移除节点
for i in range(self.level + 1):
if update[i].forward[i] != cur:
break # 若本层已经没有指向目标节点,更高层也肯定没有
update[i].forward[i] = cur.forward[i]
# 更新当前最高层数(可能会有空层下降)
while self.level > 0 and self.head.forward[self.level] is None:
self.level -= 1
self._size -= 1
return True
def __len__(self) -> int:
return self._size
def display(self) -> None:
"""打印跳表各层结构,便于调试"""
print(f"SkipList (level={self.level}, size={self._size})")
for i in range(self.level, -1, -1):
cur = self.head.forward[i]
line = f"L{i}: H"
while cur:
line += f" -> ({cur.key}:{cur.value})"
cur = cur.forward[i]
print(line if " ->" in line else line + " (empty)")
测试代码
if __name__ == "__main__":
sl = SkipList()
data = [5, 3, 9, 1, 7, 12, 6, 8]
for k in data:
sl.insert(k, k * 10)
sl.display()
print("\n查找 key=7:", sl.search(7)) # 70
print("删除 key=3:", sl.delete(3))
sl.display()
print("删除 key=100:", sl.delete(100))
运行后可以看到跳表的多层结构,直观感受索引的跃迁。
三、常见问题与注意事项
3.1 随机层数生成策略
_PROB 通常设为 0.5,每个节点出现在第 k 层的概率为 (1-PROB) * PROB^k,使得期望层数为 1/(1-PROB) = 2。也可以使用 PROB=0.25 来降低空间消耗,但会略微增长查询时间。切勿限制层数过低(例如 _MAX_LEVEL=3),否则退化为低效链表。一般 MAX_LEVEL 设为 log(1/PROB)(期望最大元素数),对于百万级数据,16 层足够。
3.2 并发访问与线程安全
本文实现的跳表是非线程安全的。实际生产环境(如Java的ConcurrentSkipListMap)常采用无锁设计,使用CAS(Compare And Swap)原子更新 forward 指针,并配合标记节点处理删除,实现高并发。如果需在多线程环境使用,简单加读写锁会极大损失性能,建议优先考虑成熟的并发库。
3.3 内存管理与对象开销
跳表的每个节点包含一个指针数组,对于小型对象,指针数组的额外空间可能显得突出。在内存敏感场景,可考虑:
- 使用灵活的层数分配(实际分配 random_level+1 个指针,而非 MAX_LEVEL),例如此例中 forward 为动态列表。
- 采用 非分层节点(所有节点在同一层链表,但通过标记/跨度优化范围查询),Redis 跳表实现即包含跨度字段,支持 ZRANK 等操作高效计算排名。
3.4 与红黑树的对比与选择
- 实现难度:跳表插入/删除只需修改相邻指针,无旋转,逻辑更直观。
- 范围查询:跳表找到起点后,直接在L0层向前遍历,非常高效;红黑树需要中序遍历,可能涉及栈或父指针。
- 内存占用:红黑树每个节点固定3个指针(左右孩子+颜色/父),跳表节点平均约2个指针,但可变。
- 性能稳定:红黑树是严格平衡的,最坏情况也是
O(log n);跳表最坏可能退化,但概率极低。
Redis选择了跳表,部分原因正是因为其范围操作高效、实现简单且容易在节点中扩展额外字段(如span跨度)。
四、总结
跳表以一种优雅的概率化方式,解决了有序链表快速查询的痛点。它用多层随机索引换来了对数平均查找时间,同时保留了链表插入删除的灵活性。本文从原理出发,提供了完整可运行的Python实现,并探讨了并发、内存等工程实践中的注意事项。
掌握跳表不仅能帮助你理解Redis、LevelDB等系统中高性能索引的实现,还能为你设计自己的数据结构提供灵感:当确定性平衡难以维护时,引入恰到好处的随机性,往往能化繁为简。 欢迎你基于此代码继续扩展,实现范围查询、迭代器等功能,动手深入体会跳表的魅力。

276

被折叠的 条评论
为什么被折叠?



