🎯 跳表(Skip List):链表开了挂!Redis ZSet的秘密武器 🚀

56 阅读9分钟

"如果说链表是走路,那么跳表就是坐电梯!" 🛗


😮 什么是跳表?一个快递小哥的故事

📦 传统链表的烦恼

想象你是一个快递小哥,要给100号楼送快递。如果你只能从1号楼开始,一栋一栋地找:

1号 → 2号 → 3号 → ... → 98号 → 99号 → 100号 ✅

要走100步!太累了!😫

🚀 跳表的聪明之处

现在,如果小区规划了快速通道

  • 1层(基础层):每栋楼都有,1→2→3→...→100
  • 2层(快速层):每10栋楼一个标记,1→10→20→30→...→100
  • 3层(超快层):每50栋楼一个标记,1→50→100
第3层:  1 ─────────────────────────→ 50 ───────→ 100
         ↓                           ↓           ↓
第2层:  1 ────→ 10 → 20 → 30 → 40 → 50 → 60 ... → 100
         ↓       ↓    ↓    ↓    ↓    ↓    ↓       ↓
第1层:  1 → 2 → 10 ... 48 → 49 → 50 → 51 ... 98 → 100

找100号楼的路径

  1. 从第3层:1 → 50 → 100 ✅(只用3步!)

找65号楼的路径

  1. 第3层:1 → 50(发现50 < 65 < 100,往下跳)
  2. 第2层:50 → 60(发现60 < 65 < 70,往下跳)
  3. 第1层:60 → 61 → 62 → 63 → 64 → 65 ✅

从100步优化到了几步!这就是跳表的魔法!


🏗️ 跳表的结构原理

基本概念

跳表 = 多层链表 = 链表 + 二分查找

原始链表(单层):
Level 0:  1  2  3  4  5  6  7  8  null
查找8:需要8步 😰

跳表(多层):
Level 3:  1 ─────────────────────────→ 8  null
                                      
Level 2:  1 ─────────→ 4 ─────────→ 8  null
                                    
Level 1:  1 ────→ 2  4 ────→ 6  8  null
                                
Level 0:  1  2  3  4  5  6  7  8  null

查找8:1  8(只需2步!)⚡

节点结构

每个节点不仅有next指针,还有多层指针:

class SkipListNode {
    int value;                    // 数据
    SkipListNode[] forward;       // 多层指针数组
    
    public SkipListNode(int value, int level) {
        this.value = value;
        this.forward = new SkipListNode[level + 1];
    }
}

图解节点

节点3有3层:
┌─────────┐
│ value:3 │
│ ─────── │
│ Level 2 │───→ 指向更远的节点
│ Level 1 │───→ 指向中等距离的节点
│ Level 0 │───→ 指向下一个节点
└─────────┘

🎲 跳表的核心:随机层数

为什么要随机?

跳表的精妙之处在于:不需要像平衡树一样复杂的旋转操作,用随机化就能达到平衡!

// 抛硬币决定层数 🪙
private int randomLevel() {
    int level = 0;
    while (Math.random() < 0.5 && level < MAX_LEVEL) {
        level++;
    }
    return level;
}

想象一下抛硬币

  • 抛一次正面 → 层数+1
  • 抛一次反面 → 停止
抛硬币结果:正 正 反 → 层数 = 2
抛硬币结果:反 → 层数 = 0
抛硬币结果:正 正 正 正 反 → 层数 = 4

统计规律(概率为0.5):

  • 50% 的节点只在第0层
  • 25% 的节点在第0、1层
  • 12.5% 的节点在第0、1、2层
  • ...

这样就自然形成了金字塔结构!🏔️

Level 3:   ───────────────────→   null    (最少节点)
                                  
Level 2:   ────→  ────→  ───→   null
                                
Level 1:                null
                               
Level 0:                null    (最多节点)

💻 跳表的Java实现

public class SkipList {
    private static final int MAX_LEVEL = 16;  // 最大层数
    private static final double PROBABILITY = 0.5;  // 晋升概率
    
    private SkipListNode head;  // 头节点
    private int level;          // 当前最高层数
    
    // 节点定义
    class SkipListNode {
        int value;
        SkipListNode[] forward;  // 多层指针
        
        public SkipListNode(int value, int level) {
            this.value = value;
            this.forward = new SkipListNode[level + 1];
        }
    }
    
    // 构造函数
    public SkipList() {
        this.head = new SkipListNode(Integer.MIN_VALUE, MAX_LEVEL);
        this.level = 0;
    }
    
    // 🎲 随机生成层数
    private int randomLevel() {
        int lvl = 0;
        while (Math.random() < PROBABILITY && lvl < MAX_LEVEL) {
            lvl++;
        }
        return lvl;
    }
    
    // 🔍 查找元素
    public boolean search(int target) {
        SkipListNode current = head;
        
        // 从最高层往下找
        for (int i = level; i >= 0; i--) {
            // 在当前层向右移动,直到下一个节点 >= target
            while (current.forward[i] != null && current.forward[i].value < target) {
                current = current.forward[i];
            }
        }
        
        // 移动到最底层的下一个节点
        current = current.forward[0];
        
        // 检查是否找到
        return current != null && current.value == target;
    }
    
    // ➕ 插入元素
    public void insert(int value) {
        SkipListNode[] update = new SkipListNode[MAX_LEVEL + 1];
        SkipListNode current = head;
        
        // 1. 找到每层的插入位置
        for (int i = level; i >= 0; i--) {
            while (current.forward[i] != null && current.forward[i].value < value) {
                current = current.forward[i];
            }
            update[i] = current;  // 记录每层的前驱节点
        }
        
        // 2. 随机生成新节点的层数
        int newLevel = randomLevel();
        
        // 3. 更新跳表的最高层
        if (newLevel > level) {
            for (int i = level + 1; i <= newLevel; i++) {
                update[i] = head;
            }
            level = newLevel;
        }
        
        // 4. 创建新节点
        SkipListNode newNode = new SkipListNode(value, newLevel);
        
        // 5. 插入每一层
        for (int i = 0; i <= newLevel; i++) {
            newNode.forward[i] = update[i].forward[i];
            update[i].forward[i] = newNode;
        }
        
        System.out.println("✅ 插入 " + value + ",层数: " + newLevel);
    }
    
    // ❌ 删除元素
    public boolean delete(int value) {
        SkipListNode[] update = new SkipListNode[MAX_LEVEL + 1];
        SkipListNode current = head;
        
        // 1. 找到每层的前驱节点
        for (int i = level; i >= 0; i--) {
            while (current.forward[i] != null && current.forward[i].value < value) {
                current = current.forward[i];
            }
            update[i] = current;
        }
        
        current = current.forward[0];
        
        // 2. 如果找到了,删除所有层的引用
        if (current != null && current.value == value) {
            for (int i = 0; i <= level; i++) {
                if (update[i].forward[i] != current) {
                    break;
                }
                update[i].forward[i] = current.forward[i];
            }
            
            // 3. 更新最高层
            while (level > 0 && head.forward[level] == null) {
                level--;
            }
            
            System.out.println("🗑️ 删除 " + value);
            return true;
        }
        
        return false;
    }
    
    // 🖨️ 打印跳表
    public void printSkipList() {
        System.out.println("\n━━━━━━ 跳表结构 ━━━━━━");
        for (int i = level; i >= 0; i--) {
            System.out.print("Level " + i + ": ");
            SkipListNode node = head.forward[i];
            while (node != null) {
                System.out.print(node.value + " → ");
                node = node.forward[i];
            }
            System.out.println("null");
        }
        System.out.println("━━━━━━━━━━━━━━━━━━━━\n");
    }
}

// 测试代码
public class Main {
    public static void main(String[] args) {
        SkipList skipList = new SkipList();
        
        // 插入元素
        int[] values = {3, 6, 7, 9, 12, 19, 17, 26, 21, 25};
        for (int val : values) {
            skipList.insert(val);
        }
        
        skipList.printSkipList();
        
        // 查找
        System.out.println("查找 19: " + skipList.search(19));  // true
        System.out.println("查找 15: " + skipList.search(15));  // false
        
        // 删除
        skipList.delete(19);
        skipList.printSkipList();
    }
}

🎬 跳表操作动画演示

查找过程

查找 19

Step 1: 从最高层开始
Level 2:  3 ───────────→ 12 ─────→ 21  null
           当前位置     (12 < 19 < 21,往下跳)

Step 2: 在Level 1继续
Level 1:  3 ────→ 7  12  17  21  null
                                (17 < 19 < 21,往下跳)

Step 3: 在Level 0找到
Level 0:  3  6  7  9  12  17  19  21  null
                                     找到了!✅

插入过程

插入 15(假设随机层数为2)

Step 1: 找到插入位置
Level 2:  3 ──────→ 12 ─────→ 21    (12 < 15 < 21)
Level 1:  3  7  12  17  21      (12 < 15 < 17)
Level 0:  3  ...  12  17  ...   (12 < 15 < 17)

Step 2: 插入新节点15(层数2)
Level 2:  3 ──────→ 12  15 ──→ 21
Level 1:  3  7  12  15  17  21
Level 0:  3  ...  12  15  17  ...

📊 跳表的性能分析

时间复杂度

操作平均最坏说明
查找O(log n) ⚡O(n)类似二分查找
插入O(log n) ⚡O(n)查找位置 + 插入
删除O(log n) ⚡O(n)查找位置 + 删除

为什么是 O(log n)?

假设有 n 个元素,每层晋升概率是 1/2:

  • Level 0:n 个节点
  • Level 1:n/2 个节点
  • Level 2:n/4 个节点
  • Level k:n/(2^k) 个节点

总层数约为 log₂n,每层最多遍历2个节点(期望值),所以时间复杂度是 O(log n)

空间复杂度

O(n) - 虽然有多层,但统计上每个节点平均只在2层

期望的总指针数 = n × (1/2 + 1/4 + 1/8 + ...) = n × 1 = n
所以空间复杂度还是 O(n)

🆚 跳表 vs 平衡树(红黑树/AVL树)

对比项跳表 🎯红黑树 🌲
实现难度😊 简单😰 复杂(旋转操作)
查找O(log n)O(log n)
插入O(log n)O(log n)
删除O(log n)O(log n)
并发😊 容易实现😰 复杂
空间稍多少一些
范围查询⚡ 快(有序)⚡ 快

跳表的优势

  • ✅ 实现简单,不需要复杂的旋转
  • ✅ 并发友好,可以局部加锁
  • ✅ 范围查询友好(底层是有序链表)

为什么Redis选择跳表而不是红黑树?

  1. 实现简单 - 代码量少,维护成本低
  2. 并发友好 - Redis是单线程,但未来可能多线程
  3. 范围查询 - ZSet的ZRANGE命令需要范围查询

🚀 Redis中的跳表应用

ZSet(有序集合)的底层实现

Redis的ZSet(Sorted Set)底层就是用跳表 + 哈希表实现的!

ZSet = Skip List + Hash Table

跳表:维护元素的有序性,支持范围查询
哈希表:快速查找元素的分数

例子:游戏排行榜 🏆

// Redis命令
ZADD leaderboard 1000 "Player1"
ZADD leaderboard 1500 "Player2"
ZADD leaderboard 1200 "Player3"

// 获取排名前10
ZRANGE leaderboard 0 9 WITHSCORES

// 获取某个分数范围的玩家
ZRANGEBYSCORE leaderboard 1000 1500

底层跳表结构

Score 分数(升序):
Level 2:  1000 ─────────→ 1500  null
                        
Level 1:  1000 ────→ 1200  1500  null
                         
Level 0:  1000  1200  1500  null
          Player1  Player3  Player2

🎯 跳表的应用场景

✅ 适合使用跳表的场景

  1. 需要有序性 + 快速查找

    • 游戏排行榜 🏆
    • 成绩排名 📊
  2. 范围查询频繁

    // 查询分数在[60, 80]之间的学生
    findRange(60, 80);
    
  3. 并发环境

    • 可以对不同区域加不同的锁 🔒
  4. 不想写复杂的平衡树

    • 跳表代码简单得多!😊

❌ 不适合使用跳表的场景

  1. 不需要有序性 - 用哈希表就好
  2. 内存极度敏感 - 跳表有额外的指针开销
  3. 随机访问 - 数组更好

💡 经典面试题

1. 为什么Redis用跳表而不是红黑树?

答案

  1. 实现简单 - 跳表代码量远少于红黑树
  2. 范围查询友好 - ZSet的ZRANGE命令很常用
  3. 并发友好 - 跳表的局部性更好
  4. 内存占用相当 - 实际测试差不多

2. 跳表的随机层数为什么用1/2概率?

答案

  • 1/2是理论最优值,既保证了查找效率,又不会让层数过多
  • 如果概率太大(如0.9),会有太多高层,浪费空间
  • 如果概率太小(如0.1),高层太少,查找退化

3. 跳表的时间复杂度如何分析?

答案

  • 层数期望为 log n
  • 每层期望遍历 1/p 个节点(p=0.5时为2个)
  • 总时间:O(log n) × O(1/p) = O(log n)

📝 总结

🎓 记忆口诀

跳表就是多层链表,
随机层数保平衡。
查找插删O(log n),
Redis用它做排行榜。
抛硬币决定层数高,
正面继续反面停。
代码简单易实现,
胜过红黑树复杂!

核心要点

特性说明符号
本质多层链表🏢
平衡随机化(不需要旋转)🎲
查找O(log n)🔍
插入O(log n)
删除O(log n)
空间O(n)💾
应用Redis ZSet🎯

🚀 下一步学习

掌握了跳表,接下来可以学习:

  1. 红黑树 - 另一种平衡查找树 🌲
  2. B+树 - MySQL索引的实现 🗂️
  3. 哈希表 - 另一种快速查找结构 #️⃣

恭喜你!🎉 你已经掌握了Redis的秘密武器——跳表!

记住:跳表 = 链表开了挂 = 给链表加了高速公路! 🛣️

简单、高效、优雅,这就是跳表的魅力!💪


📌 小练习:尝试实现一个简化版的跳表,支持插入和查找操作!

🤔 思考题:如果把晋升概率从0.5改成0.25,会有什么影响?

(提示:层数会变多,但每层的节点会更少,空间会节省,但查找可能稍慢)