Redis 跳表(Skip List)实现

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 层/节点)
 * - 范围查询简单高效
 * - 并发修改时更容易加锁(局部性更好)
 * - 代码简单,便于维护
 * ============================================================
 */
相关推荐
AOwhisky2 小时前
Redis 学习笔记(第一期):概述、安装配置与核心理论
运维·数据库·redis·笔记·学习·云计算
AOwhisky2 小时前
Redis 学习笔记(第四期):高可用与集群(哨兵 + Cluster + 容器化)
linux·运维·数据库·redis·笔记·学习·缓存
叫我:松哥3 小时前
基于Flask框架的校园二手书籍交易平台,注重校园场景的特殊需求,通过学号认证保障用户真实性
后端·python·sqlite·flask·bootstrap
IT策士5 小时前
Redis 从入门到精通:事务与 Lua 脚本
redis·junit·lua
胡小禾9 小时前
Redis哨兵模式下主从同步的偏差
数据库·redis·缓存
zzqssliu10 小时前
Taocarts接口限流实操:基于Redis实现API防刷与流量管控
数据库·redis·缓存
啦啦啦啦啦zzzz10 小时前
redis的持久化操作和主从复制与集群的关系及其应用
数据库·redis
IT策士10 小时前
Redis 从入门到精通:分片之道 —— Redis Cluster
数据库·redis·缓存
AOwhisky11 小时前
学习自测与解析:Redis系列第一期与第二期核心知识点详解
运维·数据库·redis·学习·云计算
Java爱好狂.11 小时前
阿里1658页2026最新Java面试题总结(含答案)
数据库·redis·程序员·java面试·java面试题·java编程·java八股文