java
/**
* ============================================================
* Redis 跳表(Skip List)完整实现 - 详细注释版
* ============================================================
*
* 【什么是跳表?】
* 跳表是一种基于有序链表的数据结构,通过在链表上建立多层"索引"来加速查找。
* 普通链表查找是 O(n),跳表通过多层索引将查找效率提升到平均 O(log n)。
*
* 【跳表的层次示意图】
*
* Level 3: header --------------------------> [score=50] ---------> null
* Level 2: header ---------> [score=20] ----> [score=50] -> [score=80] -> null
* Level 1: header -> [s=10] -> [s=20] -> [s=30] -> [s=50] -> [s=70] -> [s=80] -> null
*
* 查找 score=70 时:
* - 从最高层 Level 3 开始,跳过大段距离
* - 在 Level 2 发现 50 < 70 < 80,降层
* - 在 Level 1 从 50 走到 70,找到!
* 比逐个遍历快很多!
*
* 【Redis 为什么用跳表?】
* Redis 的有序集合(ZSet)底层就用跳表实现,因为跳表:
* 1. 查找/插入/删除平均都是 O(log n)
* 2. 实现比红黑树简单
* 3. 支持范围查询(按分数区间获取数据)
* 4. 顺序遍历很方便
*
* 【本代码结构】
* - RedisSkipListDemo(外部类):跳表的主体,包含所有操作方法
* - Level(静态内部类):表示某个节点在某一层的指针信息
* - Node(静态内部类):跳表的节点,存储分数、成员名和各层指针
* ============================================================
*/
public class RedisSkipListDemo {
// ============================================================
// 【常量定义】
// ============================================================
/**
* 跳表允许的最大层数,与 Redis 源码保持一致。
*
* 为什么是 32 层?
* 每层节点数量大约是下一层的 1/P(P=0.25 时约 1/4)。
* 32 层理论上可以高效处理约 4^32 ≈ 1800 亿个节点,完全够用。
* 层数越多,索引占用内存越大;32 是性能与内存的最佳平衡点。
*/
private static final int MAX_LEVEL = 32;
/**
* 随机层数的概率因子,与 Redis 源码保持一致,值为 0.25(即 25%)。
*
* 含义:每个节点在当前层存在的情况下,晋升到上一层的概率是 25%。
* 例如:
* Level 1(最底层):100% 的节点都有
* Level 2:约 25% 的节点有
* Level 3:约 6.25% 的节点有
* Level 4:约 1.56% 的节点有
* ...以此类推
*
* 为什么选 0.25 而不是 0.5?
* - 0.5 理论查找性能稍好,但节点平均层数更高(约 2 层 vs 约 1.33 层)
* - 0.25 在内存占用和查找速度之间取得更好的平衡
* - Redis 实测 0.25 综合性能更优
*/
private static final double P = 0.25D;
// ============================================================
// 【成员变量】
// ============================================================
/**
* 当前跳表的实际最大层数(动态变化)。
*
* 初始为 1(只有底层链表),随着数据插入,高层节点出现后会增大。
* 查找时从这一层开始往下找,避免从空的高层浪费时间。
*/
private int level = 1;
/**
* 跳表中实际存储的节点数量(不含头节点 header)。
* 对应 Redis 中 zset 的元素个数,通过 ZCARD 命令可查到这个值。
*/
private long length = 0;
/**
* 跳表的头节点(哨兵节点/虚拟节点)。
*
* 头节点不存储真实数据,其作用是:
* 1. 作为所有层的起始点,统一查找入口
* 2. 避免处理链表为空时的特殊情况,简化代码逻辑
*
* 头节点有 MAX_LEVEL(32)层,分数设为 -1(不代表真实含义,仅占位)。
*/
private final Node header = new Node(MAX_LEVEL, -1, null);
/**
* 随机数生成器,用于 randomLevel() 方法中决定新节点的层数。
* 使用 Java 内置的伪随机数生成器。
*/
private final Random random = new Random();
// ============================================================
// 【内部类:Level(层级指针)】
// ============================================================
/**
* Level 类表示跳表节点在某一层的指针信息。
*
* 每个节点都有一个 Level[] 数组,数组长度等于该节点的层数。
* levels[0] 对应第 1 层(底层),levels[k] 对应第 k+1 层。
*
* 真实的 Redis 实现中,Level 还包含 span(跨度)字段,
* 用于支持按排名查找(ZRANK 命令),本代码简化了该字段。
*
* 示意图(一个 3 层节点):
* node.levels[2].forward --> 第3层的下一个节点
* node.levels[1].forward --> 第2层的下一个节点
* node.levels[0].forward --> 第1层的下一个节点(相邻节点)
*/
static class Level {
/**
* 指向本层的下一个节点。
* null 表示本层已无后继节点(到达链表尾部)。
*/
Node forward;
}
// ============================================================
// 【内部类:Node(跳表节点)】
// ============================================================
/**
* Node 类表示跳表中的一个节点,存储实际数据和多层指针。
*
* 对应 Redis ZSet 中的一个成员,例如:
* ZADD leaderboard 99.5 "Alice"
* → score = 99.5, member = "Alice"
*
* 节点结构示意图(假设该节点有 3 层):
* ┌──────────────────────────────────────────┐
* │ member: "Alice" | score: 99.5 │
* │ backward: 指向前一个节点(底层双向链表) │
* │ levels[2].forward → 第3层下一节点 │
* │ levels[1].forward → 第2层下一节点 │
* │ levels[0].forward → 第1层下一节点 │
* └──────────────────────────────────────────┘
*/
static class Node {
/**
* 节点的分数(排序依据)。
* 跳表按 score 从小到大排列,score 相同时 Redis 会按 member 字典序排序
* (本代码简化,未实现同分时按 member 排序)。
*/
double score;
/**
* 节点的成员名(唯一标识)。
* 对应 Redis ZSet 中的 member 字符串,如用户名、商品ID等。
*/
String member;
/**
* 指向前一个节点的指针(仅在第 1 层/底层存在)。
* 这使得底层链表是一个双向链表,便于 Redis 实现范围查询时的反向遍历
* (例如 ZREVRANGEBYSCORE 命令需要从后往前遍历)。
*
* 注意:header.backward = null(头节点没有前驱),
* 第一个数据节点的 backward = header。
*/
Node backward;
/**
* 该节点在各层的指针数组。
* 数组长度等于该节点的随机层数(1 到 MAX_LEVEL 之间)。
* levels[i] 存储第 i+1 层的前进指针。
*/
Level[] levels;
/**
* 节点构造方法。
*
* @param level 该节点的层数(由 randomLevel() 随机决定)
* @param score 节点的分数(排序键)
* @param member 节点的成员名
*
* 构造时为每一层创建一个 Level 对象(此时 forward 默认为 null)。
*/
Node(int level, double score, String member) {
this.score = score;
this.member = member;
// 初始化层级数组,长度为该节点的层数
// 例如:层数为 3,则 levels[0]、levels[1]、levels[2] 各创建一个 Level 对象
this.levels = new Level[level];
for (int i = 0; i < level; i++) {
levels[i] = new Level();
// 此时 levels[i].forward = null,即暂无后继节点
}
}
/**
* 返回节点的字符串描述,方便调试输出。
* 例如:Node{score=88888.0, member='member_88888'}
*/
@Override
public String toString() {
return "Node{" +
"score=" + score +
", member='" + member + '\'' +
'}';
}
}
// ============================================================
// 【方法:randomLevel() - 随机生成层数】
// ============================================================
/**
* 随机生成新节点的层数,模拟 Redis 源码中的 zslRandomLevel() 函数。
*
* 【算法原理】
* 从第 1 层开始,每次以概率 P(25%)决定是否晋升到更高一层,
* 直到随机数 >= P 或达到 MAX_LEVEL 为止。
*
* 【概率分布】
* 层数 = 1 的概率:75%(第一次就不晋升)
* 层数 = 2 的概率:25% * 75% = 18.75%
* 层数 = 3 的概率:25% * 25% * 75% = 4.69%
* 层数 = k 的概率:P^(k-1) * (1-P)
*
* 这种分布保证了:
* - 大多数节点只有 1~2 层(节省内存)
* - 少数节点有更多层(充当高层索引,加速查找)
*
* 【期望层数】
* 每个节点的平均层数 = 1/(1-P) = 1/(1-0.25) ≈ 1.33 层
* 相比 P=0.5 时的 2 层,内存占用更小。
*
* @return 随机生成的层数,范围 [1, MAX_LEVEL]
*/
private int randomLevel() {
int level = 1;
// 每次循环:以 P 的概率继续增加层数,以 (1-P) 的概率停止
// random.nextDouble() 返回 [0.0, 1.0) 之间的随机数
// 若随机数 < P(0.25),则 level++;否则停止
while (random.nextDouble() < P && level < MAX_LEVEL) {
level++;
}
return level;
}
// ============================================================
// 【方法:insert() - 插入节点】
// ============================================================
/**
* 向跳表中插入一个新节点。
*
* 对应 Redis 命令:ZADD key score member
* 例如:ZADD leaderboard 100 "Alice"
*
* 【插入步骤概述】
* 第一步:从最高层往下,找到每一层中"新节点应该插在哪个节点后面"
* → 记录到 update[] 数组(前驱节点数组)
* 第二步:随机决定新节点的层数 newLevel
* 第三步:如果 newLevel 超过当前跳表层数,补充高层的 update[] 指向 header
* 第四步:创建新节点,并在每一层中将它"接入"链表
* → 就像链表插入一样:new.forward = update[i].forward; update[i].forward = new
*
* 【update[] 数组的作用】
* update[i] 表示第 i+1 层中,新节点的前驱节点。
* 插入时需要修改前驱节点的 forward 指针,所以要事先记录这些前驱。
*
* 【举例说明】
* 当前跳表(只显示 score,3 层):
* L3: header -----------------> [50] -> null
* L2: header -----> [20] ------> [50] -> null
* L1: header -> [10] -> [20] -> [30] -> [50] -> null
*
* 插入 score=25 的节点(假设随机到 2 层):
* 查找 update[]:
* L3: header(50>25,不走,update[2]=header)
* L2: header走到20(20<25,继续),20走不了(50>25),update[1]=节点20
* L1: header走到10,10走到20(20<25,继续),20走不了(30>25),update[0]=节点20
*
* 插入后:
* L2: ... -> [20] -> [25(new)] -> [50] -> null
* L1: ... -> [20] -> [25(new)] -> [30] -> [50] -> null
*
* @param score 要插入节点的分数(排序键)
* @param member 要插入节点的成员名(标识符)
*
* 注意:本实现不检查 member 是否已存在(简化版)。
* 真实 Redis 中,若 member 已存在则更新其 score。
*/
public void insert(double score, String member) {
// update[i] 记录第 i+1 层中,新节点插入位置的前驱节点
// 最多有 MAX_LEVEL 层,所以数组大小为 MAX_LEVEL
Node[] update = new Node[MAX_LEVEL];
// current:查找时的当前指针,从头节点出发
Node current = header;
// ---- 第一步:从最高层往下,寻找每层的插入前驱 ----
// 从当前最高层(level-1,因为数组从0开始)往下到第1层(索引0)
for (int i = level - 1; i >= 0; i--) {
// 在第 i+1 层向右移动,直到:
// 1. 下一个节点为 null(已到达本层末尾),或
// 2. 下一个节点的 score >= 要插入的 score(找到了合适的插入位置)
// 这里用 < 而不是 <=,意味着相同 score 的节点也会被插入(允许同分)
while (current.levels[i].forward != null
&& current.levels[i].forward.score < score) {
// 继续向右移动
current = current.levels[i].forward;
}
// 记录第 i+1 层的前驱节点
// 新节点将被插入到 update[i] 的后面
update[i] = current;
}
// 经过上面的循环,update[0..level-1] 已填好
// ---- 第二步:为新节点随机生成层数 ----
int newLevel = randomLevel();
// ---- 第三步:如果新节点层数超过当前跳表层数,扩充高层的 update[] ----
if (newLevel > level) {
// 对于超出部分的层(level 到 newLevel-1),
// 这些层目前为空(只有 header),所以前驱节点就是 header
for (int i = level; i < newLevel; i++) {
update[i] = header;
// 注意:此时 header.levels[i].forward 应该为 null
// (这些层还没有任何真实节点)
}
// 更新跳表的当前最大层数
level = newLevel;
}
// ---- 第四步:创建新节点,并逐层接入链表 ----
Node node = new Node(newLevel, score, member);
// 遍历新节点所在的每一层(从第 1 层到第 newLevel 层)
for (int i = 0; i < newLevel; i++) {
// 标准的链表插入操作(在 update[i] 后面插入 node):
//
// 插入前:update[i] --> update[i].levels[i].forward
// 插入后:update[i] --> node --> update[i].levels[i].forward(原来的下一个)
//
// 第一步:新节点的 forward 指向原来 update[i] 的 forward
node.levels[i].forward = update[i].levels[i].forward;
// 第二步:update[i] 的 forward 指向新节点
update[i].levels[i].forward = node;
}
// 注意:本实现省略了 backward 指针的更新(简化版)。
// 真实 Redis 中,还需要更新 node.backward 和 node 后继节点的 backward。
// ---- 第五步:更新节点计数 ----
length++;
// 插入完成!时间复杂度平均 O(log n)
}
// ============================================================
// 【方法:search() - 查找节点】
// ============================================================
/**
* 在跳表中查找指定 score 的节点。
*
* 对应 Redis 命令(近似):ZSCORE key member(通过分数查找)
*
* 【查找步骤概述】
* 从最高层开始,每层尽可能向右移动(跳过尽量多的节点),
* 直到下一节点的 score >= 目标 score,再降到下一层继续。
* 最终在第 1 层(底层)检查是否找到目标节点。
*
* 【举例说明(查找 score=30)】
* 跳表示意:
* L3: header --------------------------> [50] -> null
* L2: header -----> [20] ----------> [50] -> null
* L1: header -> [10] -> [20] -> [30] -> [50] -> null
*
* 查找过程:
* 从 L3 出发:50 >= 30,不走,降层
* 在 L2 出发:20 < 30,走到 20;下一个是 50 >= 30,停止,降层
* 在 L1 从 20 出发:30 >= 30,不走(注意是 <,所以30不走)
* 降到底层后:current.levels[0].forward = 节点30,检查 score == 30,找到!
*
* @param score 要查找的分数值
* @return 找到则返回对应节点,未找到则返回 null
*
* 时间复杂度:平均 O(log n),最坏 O(n)(退化为链表时)
*/
public Node search(double score) {
// 从头节点开始查找
Node current = header;
// 从最高层往下逐层查找(同 insert 中的查找逻辑)
for (int i = level - 1; i >= 0; i--) {
// 在当前层向右移动,只要下一节点的 score < 目标 score
// 注意:用 < 而不是 <=,因为我们要停在"刚好小于目标"的位置
while (current.levels[i].forward != null
&& current.levels[i].forward.score < score) {
current = current.levels[i].forward;
}
// 当前层无法继续前进(下一节点 score >= 目标,或已到末尾)
// 下沉到低一层继续查找(for 循环 i-- 自动完成)
}
// 经过上面的循环,current 停在第 1 层中"最后一个 score < 目标"的节点上
// 目标节点(如果存在)就是 current 在第 1 层的下一个节点
current = current.levels[0].forward;
// 判断是否真的找到了(节点存在 且 score 精确匹配)
if (current != null && current.score == score) {
// 找到了,返回该节点
return current;
}
// 没找到(节点不存在,或 score 不匹配),返回 null
return null;
}
// ============================================================
// 【方法:size() - 获取节点数量】
// ============================================================
/**
* 返回跳表中存储的节点数量(不含头节点)。
*
* 对应 Redis 命令:ZCARD key
* 时间复杂度:O(1)(直接返回计数器,无需遍历)
*
* @return 跳表中的节点总数
*/
public long size() {
return length;
}
// ============================================================
// 【main 方法:演示程序入口】
// ============================================================
/**
* 主方法:演示跳表的插入和查找功能,并进行性能测试。
*
* 测试内容:
* 1. 向跳表中插入 10 万条数据(score 从 1 到 100000,member 为 "member_N")
* 2. 统计插入耗时(毫秒级)
* 3. 查找 score=88888 的节点
* 4. 统计查找耗时(纳秒级,因为单次查找极快)
*
* @param args 命令行参数(本程序未使用)
*/
public static void main(String[] args) {
// 创建一个新的跳表实例
RedisSkipListDemo skipList = new RedisSkipListDemo();
// ---- 性能测试:插入 10 万条数据 ----
System.out.println("开始插入10万条数据...");
// 记录插入开始时间(毫秒级时间戳)
long startInsert = System.currentTimeMillis();
// 循环插入 1 到 100000,每个元素的 score = i,member = "member_i"
// 例如:score=1, member="member_1"
// score=2, member="member_2"
// ...
// score=100000, member="member_100000"
for (int i = 1; i <= 100000; i++) {
skipList.insert(i, "member_" + i);
}
// 记录插入结束时间
long endInsert = System.currentTimeMillis();
// 输出插入耗时(毫秒)
System.out.println("插入完成,耗时:" + (endInsert - startInsert) + " ms");
// 输出跳表总节点数(应该是 100000)
System.out.println("总节点数:" + skipList.size());
System.out.println();
// ---- 性能测试:查找 score=88888 的节点 ----
System.out.println("开始查询...");
// 记录查找开始时间(纳秒级,查找很快,毫秒级精度不够)
long startSearch = System.nanoTime();
// 在跳表中查找 score=88888 的节点
// 期望找到 Node{score=88888.0, member='member_88888'}
Node node = skipList.search(88888);
// 记录查找结束时间
long endSearch = System.nanoTime();
// 输出查找结果(调用 Node 的 toString() 方法)
System.out.println(node);
// 输出查找耗时(纳秒)
// 跳表查找 10 万节点,通常只需几微秒(几千纳秒)
System.out.println("查询耗时:" + (endSearch - startSearch) + " ns");
}
}
/*
* ============================================================
* 附录:跳表 vs 其他数据结构对比
* ============================================================
*
* | 操作 | 数组(有序)| 链表 | 平衡树(红黑树)| 跳表 |
* |-------------|------------|-----------|--------------|-------------|
* | 查找 | O(log n) | O(n) | O(log n) | O(log n) |
* | 插入 | O(n) | O(1)* | O(log n) | O(log n) |
* | 删除 | O(n) | O(1)* | O(log n) | O(log n) |
* | 范围查询 | O(log n+k) | O(n) | O(log n+k) | O(log n+k) |
* | 实现复杂度 | 简单 | 简单 | 复杂 | 中等 |
* *链表插入/删除不含查找时间
*
* 跳表的核心优势:
* 1. 与平衡树性能相当,但实现简单得多
* 2. 天然支持顺序遍历(底层是有序链表)
* 3. 范围查询效率高(找到起点后直接沿底层链表遍历)
* 4. 无需像红黑树那样复杂的旋转操作来维持平衡
*
* Redis ZSet 使用跳表的原因(官方解释):
* - 内存占用可接受(平均 1.33 层/节点)
* - 范围查询简单高效
* - 并发修改时更容易加锁(局部性更好)
* - 代码简单,便于维护
* ============================================================
*/