🛣️ 跳表(Skip List):Redis的高速公路!

60 阅读11分钟

"有序链表 + 多级索引 = 跳表的魔力!" ⚡


📖 一、什么是跳表?从高速公路说起

1.1 生活中的场景

想象你要从北京开车到上海:

方案1:普通道路(单链表)

北京 → 天津 → 济南 → 徐州 → 南京 → 上海

每个城市都要经过:
- 经过100个城市
- 时间:10小时 😰
- 复杂度:O(n)

方案2:高速公路+普通道路(跳表)

顶层(高速):  北京 ========> 上海
                ↓               ↓
二层(国道):  北京 ---> 南京 ---> 上海
                ↓         ↓         ↓
底层(市道):  北京→天津→...→南京→...→上海

查找路线:
1. 从高速看:北京到上海之间
2. 下国道:找到南京附近
3. 下市道:找到具体位置
时间:1小时 ⚡
复杂度:O(log n)

1.2 专业定义

跳表(Skip List) 是一种有序链表的改进数据结构,通过维护多级索引来实现快速查找,平均时间复杂度为 O(log n)

核心思想:

  • ✅ 底层是有序链表
  • ✅ 上层是多级索引(快速通道)
  • ✅ 查找时从上往下,从左往右
  • 空间换时间的经典案例

为什么叫Skip?

Skip = 跳过
通过高层索引"跳过"大量节点,快速定位!

🎨 二、跳表的结构

2.1 基本结构图

4层跳表示例(查找17):

Level 4: 1 =================================> NIL
                                               
Level 3: 1 --------> 7 --------> 13 --------> NIL
                     ↓            ↓            
Level 2: 1 -> 4 ---> 7 --> 10 -> 13 --------> NIL
         ↓    ↓      ↓     ↓     ↓            
Level 1: 1 -> 4 -> 6 -> 7 -> 10 -> 13 -> 17 -> NIL
         ↓    ↓    ↓   ↓    ↓     ↓     ↓
Data:   [1]  [4]  [6] [7]  [10]  [13]  [17]

查找17的路径:
Level 4: 1 → (17 > 1,但下一个是NIL,下降)
Level 3: 1713 → (17 > 13,下降)
Level 2: 13 → (17 > 13,下降)
Level 1: 1317 (找到!)

只经过了5步!比遍历链表(7步)快得多!

2.2 节点结构

跳表节点:
┌─────────────────────┐
│    value: 10        │ ← 数据值
├─────────────────────┤
│    forward[4] ───┐  │ ← Level 4的下一个节点
│    forward[3] ───┤  │ ← Level 3的下一个节点
│    forward[2] ───┤  │ ← Level 2的下一个节点
│    forward[1] ───┤  │ ← Level 1的下一个节点
└─────────────────────┘

每个节点的层数是随机的!

Java实现:

class SkipListNode {
    int value;
    SkipListNode[] forward;  // 不同层级的next指针
    
    public SkipListNode(int value, int level) {
        this.value = value;
        this.forward = new SkipListNode[level + 1];
    }
}

2.3 随机层数

核心问题:新节点应该有几层?

答案:随机决定!(概率1/2)

随机算法:
Level 1: 100%的概率
Level 2: 50%的概率
Level 3: 25%的概率
Level 4: 12.5%的概率
...

代码实现:
int randomLevel() {
    int level = 1;
    while (Math.random() < 0.5 && level < MAX_LEVEL) {
        level++;
    }
    return level;
}

为什么用随机?
- 简单高效
- 不需要复杂的平衡操作(不像红黑树)
- 概率上保证O(log n)

💻 三、跳表的核心操作

3.1 查找操作

算法思路:

  1. 从最高层开始
  2. 在当前层水平前进,直到下一个节点 >= 目标值
  3. 下降一层,重复步骤2
  4. 直到找到或到达底层

完整代码:

public class SkipList {
    private static final int MAX_LEVEL = 16;
    private static final double P = 0.5;  // 概率
    
    private SkipListNode head;
    private int level;  // 当前最大层数
    
    public SkipList() {
        head = new SkipListNode(Integer.MIN_VALUE, MAX_LEVEL);
        level = 0;
    }
    
    // 查找
    public SkipListNode 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];
            }
        }
        
        // 移动到level 0的下一个节点
        current = current.forward[0];
        
        if (current != null && current.value == target) {
            return current;
        }
        return null;
    }
    
    // 测试查找
    public boolean contains(int target) {
        return search(target) != null;
    }
}

查找过程图解:

查找 17:

L3: 1 ──────> 7 ──────> 13 ──────> NIL
    ↓         ↓         ↓
L2: 1 ─> 4 ─> 7 ─> 10 ─> 13 ──────> NIL
    ↓    ↓    ↓    ↓    ↓
L1: 1 ─> 4 ─> 6 ─> 7 ─> 10 ─> 13 ─> 17 ─> NIL

步骤:
1. L3: start=1, next=7 (7<17),前进到7
2. L3: current=7, next=13 (13<17),前进到13
3. L3: current=13, next=NIL,下降到L2
4. L2: current=13, next=NIL,下降到L1
5. L1: current=13, next=17 (17=17),找到!✅

比较次数:5次(链表需要7次)

3.2 插入操作

算法思路:

  1. 找到每一层的插入位置
  2. 随机生成新节点的层数
  3. 更新每一层的指针

完整代码:

// 插入
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);
    for (int i = 0; i <= newLevel; i++) {
        newNode.forward[i] = update[i].forward[i];
        update[i].forward[i] = newNode;
    }
}

// 随机层数
private int randomLevel() {
    int lvl = 0;
    while (Math.random() < P && lvl < MAX_LEVEL) {
        lvl++;
    }
    return lvl;
}

插入过程图解:

插入 9 (假设随机到3层):

原跳表:
L2: 1 ──────> 7 ──────> 13 ──────> NIL
    ↓         ↓         ↓
L1: 1 ─> 4 ─> 7 ─> 10 ─> 13 ──────> NIL
    ↓    ↓    ↓    ↓    ↓
L0: 1 ─> 4 ─> 7 ─> 10 ─> 13 ──────> NIL

步骤1:找到插入位置
update[2] = 7
update[1] = 7
update[0] = 7

步骤2:创建新节点(9, level=2)

步骤3:插入
L2: 1 ──────> 7 ──────> 9 ──> 13 ──> NIL
    ↓         ↓         ↓     ↓
L1: 1 ─> 4 ─> 7 ──────> 9 ─> 10 ─> 13 ──> NIL
    ↓    ↓    ↓         ↓     ↓    ↓
L0: 1 ─> 4 ─> 7 ──────> 9 ─> 10 ─> 13 ──> NIL

3.3 删除操作

算法思路:

  1. 找到要删除的节点及每层的前驱
  2. 更新每层的指针
  3. 如果删除后某些层为空,降低level

完整代码:

// 删除
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) {
        // 3. 更新每一层的指针
        for (int i = 0; i <= level; i++) {
            if (update[i].forward[i] != current) {
                break;
            }
            update[i].forward[i] = current.forward[i];
        }
        
        // 4. 降低层数(如果顶层为空)
        while (level > 0 && head.forward[level] == null) {
            level--;
        }
        
        return true;
    }
    
    return false;
}

🆚 四、跳表 vs 其他数据结构

4.1 跳表 vs 红黑树

特性跳表红黑树
查找O(log n)O(log n)
插入O(log n)O(log n)
删除O(log n)O(log n)
实现复杂度简单 ⭐复杂
范围查询快(链表扫描)⭐慢(需要遍历)
并发性友好(局部锁)⭐困难(旋转操作)
空间占用稍多(多级索引)较少
应用Redis ZSetJava TreeMap

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

  1. 实现简单:不需要复杂的旋转操作
  2. 范围查询快:底层是链表,直接扫描
  3. 并发友好:可以对不同层级加不同的锁

4.2 跳表 vs B+树

特性跳表B+树
适用场景内存数据 ⭐磁盘数据
节点大小小(单个元素)大(一页数据)
磁盘IO少 ⭐
范围查询
应用RedisMySQL

🎯 五、Redis中的跳表

5.1 ZSet的底层实现

Redis ZSet = 跳表 + 哈希表

跳表:维护有序性,支持范围查询
哈希表:O(1)查找member对应的score

数据结构:

// Redis ZSet节点
typedef struct zskiplistNode {
    sds ele;                  // member(成员)
    double score;             // score(分数)
    struct zskiplistNode *backward;  // 后退指针
    struct zskiplistLevel {
        struct zskiplistNode *forward;  // 前进指针
        unsigned long span;    // 跨度(用于rank)
    } level[];                // 层数组
} zskiplistNode;

// Redis ZSet
typedef struct zset {
    dict *dict;              // 哈希表:member → score
    zskiplist *zsl;          // 跳表:按score排序
} zset;

5.2 Redis命令对应的操作

# 添加元素(跳表插入 + 哈希表插入)
ZADD myzset 90 Alice 85 Bob 95 Charlie

# 查询排名(跳表查找 + 计算span)
ZRANK myzset Bob  # 返回1(第2名)

# 范围查询(跳表扫描)
ZRANGEBYSCORE myzset 80 90  # [Bob, Alice]

# 删除元素(跳表删除 + 哈希表删除)
ZREM myzset Alice

# 查询分数(哈希表O(1)查询)
ZSCORE myzset Bob  # 返回85

5.3 完整Java实现

public class SkipList {
    private static final int MAX_LEVEL = 32;
    private static final double P = 0.25;
    
    class Node {
        int value;
        Node[] forward;
        
        Node(int value, int level) {
            this.value = value;
            this.forward = new Node[level + 1];
        }
    }
    
    private Node head;
    private int level;
    
    public SkipList() {
        head = new Node(Integer.MIN_VALUE, MAX_LEVEL);
        level = 0;
    }
    
    // 查找
    public boolean search(int target) {
        Node current = head;
        for (int i = level; i >= 0; i--) {
            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) {
        Node[] update = new Node[MAX_LEVEL + 1];
        Node current = head;
        
        for (int i = level; i >= 0; i--) {
            while (current.forward[i] != null && 
                   current.forward[i].value < value) {
                current = current.forward[i];
            }
            update[i] = current;
        }
        
        int newLevel = randomLevel();
        if (newLevel > level) {
            for (int i = level + 1; i <= newLevel; i++) {
                update[i] = head;
            }
            level = newLevel;
        }
        
        Node newNode = new Node(value, newLevel);
        for (int i = 0; i <= newLevel; i++) {
            newNode.forward[i] = update[i].forward[i];
            update[i].forward[i] = newNode;
        }
    }
    
    // 删除
    public boolean delete(int value) {
        Node[] update = new Node[MAX_LEVEL + 1];
        Node current = head;
        
        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];
        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];
            }
            while (level > 0 && head.forward[level] == null) {
                level--;
            }
            return true;
        }
        return false;
    }
    
    // 范围查询
    public List<Integer> range(int start, int end) {
        List<Integer> result = new ArrayList<>();
        Node current = head;
        
        // 找到start位置
        for (int i = level; i >= 0; i--) {
            while (current.forward[i] != null && 
                   current.forward[i].value < start) {
                current = current.forward[i];
            }
        }
        current = current.forward[0];
        
        // 收集[start, end]范围的值
        while (current != null && current.value <= end) {
            result.add(current.value);
            current = current.forward[0];
        }
        
        return result;
    }
    
    // 随机层数
    private int randomLevel() {
        int lvl = 0;
        while (Math.random() < P && lvl < MAX_LEVEL) {
            lvl++;
        }
        return lvl;
    }
    
    // 打印跳表
    public void display() {
        for (int i = level; i >= 0; i--) {
            Node node = head.forward[i];
            System.out.print("Level " + i + ": ");
            while (node != null) {
                System.out.print(node.value + " ");
                node = node.forward[i];
            }
            System.out.println();
        }
    }
    
    // 测试
    public static void main(String[] args) {
        SkipList skipList = new SkipList();
        
        // 插入数据
        int[] values = {3, 6, 7, 9, 12, 17, 19, 21, 25};
        System.out.println("=== 插入数据 ===");
        for (int val : values) {
            skipList.insert(val);
        }
        
        // 显示跳表
        System.out.println("\n=== 跳表结构 ===");
        skipList.display();
        
        // 查找
        System.out.println("\n=== 查找测试 ===");
        System.out.println("查找17: " + skipList.search(17));
        System.out.println("查找15: " + skipList.search(15));
        
        // 范围查询
        System.out.println("\n=== 范围查询 ===");
        System.out.println("范围[7, 19]: " + skipList.range(7, 19));
        
        // 删除
        System.out.println("\n=== 删除9 ===");
        skipList.delete(9);
        skipList.display();
    }
}

🎓 六、经典面试题

面试题1:跳表的时间和空间复杂度?

答案:

  • 时间复杂度
    • 查找、插入、删除:O(log n) 平均,O(n) 最坏
  • 空间复杂度:O(n)
    • 期望:每个节点平均层数 = 1/(1-p) = 2(p=0.5时)
    • 总节点数 ≈ 2n

面试题2:为什么跳表用随机层数而不是严格平衡?

答案:

  1. 简单:不需要复杂的平衡操作(旋转、重新着色)
  2. 高效:插入删除不需要全局调整
  3. 概率保证:随机算法期望性能是O(log n)
  4. 并发友好:局部修改,容易加锁

面试题3:跳表和红黑树的选择?

答案:

选择跳表的场景:

  • 需要范围查询
  • 并发环境
  • 内存数据结构
  • 实现简单优先

选择红黑树的场景:

  • 空间敏感
  • 确定性性能要求
  • 不需要范围查询

面试题4:Redis为什么用跳表实现ZSet?

答案:

  1. 范围查询快:ZRANGE等命令高效
  2. 实现简单:代码易维护
  3. 并发友好:可以分段加锁
  4. 内存效率:相比B+树更适合内存

面试题5:如何实现并发安全的跳表?

答案:

// 方案1:全局锁(简单但性能差)
public synchronized void insert(int value) {
    // ...
}

// 方案2:分段锁(性能好)
// 不同层级、不同范围使用不同的锁

// 方案3:CAS无锁(最复杂,性能最好)
// 使用原子操作更新指针

🎪 七、趣味小故事

故事:高速公路的诞生

从前,有个国家只有一条普通公路连接所有城市。

没有跳表的日子:

老王要从城市A到城市Z:

A → B → C → D → ... → Z

问题:
- 经过25个城市
- 每个城市都要停车检查
- 时间:5小时 😰

交通部长的创新(跳表):

聪明的部长小李想了个办法:

建设三层道路系统:

顶层(高速):  A ========> Z
                ↓           ↓
中层(国道):  A ---> M ---> Z
                ↓      ↓      ↓
底层(市道):  AB→C→...→M→...→Z

规则:
1. 先走高速,能跳就跳
2. 高速不通就下国道
3. 国道不通就下市道

老王再次出发:

1. 上高速:A → (Z太远,下高速)
2. 上国道:A → M → (Z近了,下国道)
3. 上市道:M → ... → Z

只停了3次!
时间:30分钟 ⚡

随机修路策略:

有人问:"为什么不是每个城市都有高速入口?"

小李答:"成本太高!我们随机选择:

  • 100%的城市有市道
  • 50%的城市有国道
  • 25%的城市有高速

这样既省钱,又能保证平均速度!"

这就是跳表的智慧——概率性的多级索引!🎯


📚 八、知识点总结

核心要点 ✨

  1. 定义:有序链表 + 多级索引
  2. 核心思想:空间换时间
  3. 层数:随机生成(概率1/2)
  4. 时间复杂度:O(log n) 期望
  5. 应用:Redis ZSet

记忆口诀 🎵

跳表好似高速路,
多层索引帮你跳。
底层链表存数据,
上层索引当快道。
查找插入对数快,
随机层数是诀窍。
Redis用它做ZSet,
范围查询效率高!

对比总结 📊

数据结构查找插入删除范围查询并发
数组O(log n)O(n)O(n)O(k)
链表O(n)O(1)O(1)O(n+k)
红黑树O(log n)O(log n)O(log n)O(log n+k)
跳表O(log n)O(log n)O(log n)O(log n+k) ⭐✅⭐

🌟 九、总结彩蛋

恭喜你!🎉 你已经掌握了跳表这个优雅的数据结构!

记住:

  • 🛣️ 有序链表 + 多级索引
  • 🎲 随机层数保证性能
  • ⚡ O(log n)的快速操作
  • 🔴 Redis ZSet的核心

最后送你一张图

    L3: ═══════════>
    L2: ═══╗═══════>
    L1: ═╗═╬═╗═════>
    L0: ═╬═╬═╬═╬═╬=>
             
        数据链表
        
    层层跳跃,步步登高!

下次见,继续加油! 💪😄


📖 参考资料

  1. Skip Lists: A Probabilistic Alternative to Balanced Trees (原始论文)
  2. Redis设计与实现 - 第5章
  3. 《算法(第4版)》- 跳表
  4. Redis源码 - zskiplist

作者: AI算法导师
最后更新: 2025年11月
难度等级: ⭐⭐⭐⭐ (中高级)
预计学习时间: 3-4小时

💡 温馨提示:跳表是理解Redis ZSet的关键,建议结合Redis命令一起学习!