Redis有序集合底层实现:跳表(SkipList)深度解析
跳表概述
跳表(SkipList)是一种概率平衡的数据结构,它通过在有序链表的基础上建立多级索引,使得查找、插入和删除操作的时间复杂度都能达到O(log n)级别。Redis的有序集合(sorted set)在元素较多时就是采用跳表作为底层实现。
为什么Redis选择跳表
跳表在Redis中的应用
Redis的有序集合(sorted set)是一个非常重要的数据结构,它能够保证元素唯一且有序,常用于排行榜等场景。有序集合底层实现有两种:
- ziplist(压缩列表):当元素数量小于128且每个元素大小小于64字节时使用
- skiplist(跳表):当不满足上述条件时使用
跳表与其他数据结构的比较
面试中常被问到:"为什么Redis的有序集合底层用跳表而不用平衡树、红黑树或B+树?"
1. 跳表 vs 平衡树(AVL树)
- 时间复杂度:两者都是O(log n)
- 实现复杂度:跳表实现更简单
- 平衡维护:AVL树需要严格的旋转操作保持平衡,跳表通过概率平衡
- 范围查询:两者都支持高效的范围查询
2. 跳表 vs 红黑树
- 时间复杂度:两者都是O(log n)
- 实现复杂度:跳表代码更简单
- 并发性能:跳表更容易实现无锁并发
- 内存占用:跳表需要额外存储索引,但Redis中这个开销可以接受
3. 跳表 vs B+树
- 适用场景:B+树更适合磁盘存储系统,跳表更适合内存数据库
- 实现复杂度:跳表实现更简单
- 范围查询:B+树叶子节点形成链表,范围查询性能优异,但跳表也能很好支持
Redis选择跳表的主要原因
- 实现简单:跳表的实现比平衡树简单得多,代码更易维护
- 查询高效:跳表的查询效率与平衡树相当
- 范围查询:跳表天然支持高效的范围查询
- 内存友好:虽然跳表需要额外空间存储索引,但Redis作为内存数据库可以接受
- 并发友好:跳表更容易实现无锁并发操作
跳表实现原理
跳表基本结构
跳表通过在原始有序链表上建立多级索引来加速查找:
- 原始链表存储所有元素
- 一级索引包含约一半的元素
- 二级索引包含约四分之一的元素
- 以此类推...
这种结构使得查找时可以"跳过"大量不需要比较的节点。
查找过程
查找元素时从最高级索引开始:
- 在当前层级向右查找,直到找到大于等于目标值的节点
- 向下移动到下一级索引继续查找
- 重复上述过程直到原始链表
插入过程
插入新元素时:
- 随机决定该元素的"高度"(即建立多少级索引)
- 在每一层级找到插入位置的前驱节点
- 更新各层级的指针
删除过程
删除元素时:
- 在每一层级找到要删除节点的前驱节点
- 更新各层级的指针绕过被删除节点
- 调整跳表高度
手写跳表实现
下面我们通过Java实现一个简单的跳表来加深理解。
节点定义
class Node {
private int data = -1;
private Node[] forwards = new Node[MAX_LEVEL]; // 存储各层后继节点
private int maxLevel = 0; // 节点高度
@Override
public String toString() {
return "Node{data=" + data + ", maxLevel=" + maxLevel + "}";
}
}
随机高度生成
private int randomLevel() {
int level = 1;
// 有50%概率增加层级
while (Math.random() < PROB && level < MAX_LEVEL) {
level++;
}
return level;
}
插入操作
public void add(int value) {
int level = randomLevel();
Node newNode = new Node();
newNode.data = value;
newNode.maxLevel = level;
Node[] update = new Node[level];
Node p = h;
// 记录每层的前驱节点
for (int i = level - 1; i >= 0; i--) {
while (p.forwards[i] != null && p.forwards[i].data < value) {
p = p.forwards[i];
}
update[i] = p;
}
// 更新各层指针
for (int i = 0; i < level; i++) {
newNode.forwards[i] = update[i].forwards[i];
update[i].forwards[i] = newNode;
}
// 更新跳表高度
if (levelCount < level) {
levelCount = level;
}
}
查询操作
public Node get(int value) {
Node p = h;
// 从最高层开始查找
for (int i = levelCount - 1; i >= 0; i--) {
while (p.forwards[i] != null && p.forwards[i].data < value) {
p = p.forwards[i];
}
}
// 检查是否找到
if (p.forwards[0] != null && p.forwards[0].data == value) {
return p.forwards[0];
}
return null;
}
删除操作
public void delete(int value) {
Node[] update = new Node[levelCount];
Node p = h;
// 记录每层的前驱节点
for (int i = levelCount - 1; i >= 0; i--) {
while (p.forwards[i] != null && p.forwards[i].data < value) {
p = p.forwards[i];
}
update[i] = p;
}
// 如果找到要删除的节点
if (p.forwards[0] != null && p.forwards[0].data == value) {
// 更新各层指针
for (int i = levelCount - 1; i >= 0; i--) {
if (update[i].forwards[i] != null &&
update[i].forwards[i].data == value) {
update[i].forwards[i] = update[i].forwards[i].forwards[i];
}
}
}
// 更新跳表高度
while (levelCount > 1 && h.forwards[levelCount - 1] == null) {
levelCount--;
}
}
Redis跳表的特点
- 双向链表:Redis跳表是双向的,每个节点都有回退指针,便于反向遍历和删除操作
- 允许重复score:当score相同时,按照元素值(ele)的字典序排序
- 最大高度32:Redis跳表默认允许的最大层数是32,由
ZSKIPLIST_MAXLEVEL定义 - 概率平衡:不像平衡树那样需要严格的旋转操作来保持平衡
总结
跳表是一种简单高效的数据结构,特别适合作为内存数据库的有序集合实现。Redis选择跳表而非平衡树的主要原因包括:
- 实现简单,代码易于维护
- 查询效率与平衡树相当
- 天然支持高效的范围查询
- 更适合内存数据库的使用场景
- 更容易实现并发控制
通过手写实现一个简单的跳表,我们可以更深入地理解其工作原理和优势所在。理解跳表的实现原理不仅有助于我们更好地使用Redis的有序集合,也为我们设计高效的数据结构提供了新的思路。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



