# 跳表(SkipList)的时间复杂度与实现细节

# 标准答案

跳表(SkipList)是一种 支持快速插入、删除、查找的有序数据结构,本质上是 多层索引的链表,其查询、插入、删除的 平均时间复杂度为 ( O(\log n) ),最坏情况下为 ( O(n) ),但概率极低。Redis 的 有序集合(Sorted Set) 采用跳表实现,以保证高效的范围查询和排序操作。

# 答案解析

# 🔹 1. 跳表的核心原理

跳表本质上是一个多级索引的有序链表,通过 随机化算法 构建不同层级的索引,加速查找过程。

  • 普通单链表查找 需要 O(n) 时间复杂度,因为只能逐个遍历。
  • 跳表在链表基础上增加多层索引,降低查询时间复杂度到 O(log n),类似于 二分查找的分层索引

# 1.1 跳表的结构

跳表由多个层级的链表组成:

  • 最底层(Level 1) 是一个完整的有序链表,包含所有元素。
  • 上层索引(Level k) 只包含一部分元素,作为更高层次的跳跃点,加速查找。
  • 最高层索引 级数 ( k ) 受 随机概率分布 控制,一般不超过 ( O(\log n) )。

示例(4 层跳表):

Level 4:        1 ---------------> 20 ---------------> 50
Level 3:        1 ------> 10 -----> 20 ------> 40 ----> 50
Level 2:        1 --> 5 --> 10 --> 15 --> 20 --> 30 --> 40 --> 50
Level 1:  HEAD → 1 → 3 → 5 → 7 → 10 → 15 → 20 → 25 → 30 → 40 → 50
1
2
3
4

查询元素 30 时:

  • 从最高层 Level 4 开始,跳过 20,但 50 太大,回到 Level 3
  • Level 3 中,跳过 2040,但 40 太大,回到 Level 2
  • Level 2 中,找到 30,返回结果。

这样,查询只需 O(log n) 层级跳跃,而非线性遍历所有元素。

# 🔹 2. 跳表的时间复杂度分析

# 2.1 查询时间复杂度

在最坏情况下(所有数据只存在于底层链表),查询需要遍历 n 个元素,时间复杂度 O(n)
大部分情况下,跳表的层数满足对数分布,查询时间复杂度为: [ O(\log n) ] 因为:

  1. 每个元素以概率 p = 1/2 存入上一层,所以 最高层数 ≈ log₂(n)
  2. 查找时,从最高层开始,每次跳跃都减少一半元素,类似 二分查找

# 2.2 插入与删除时间复杂度

  • 插入

    1. 先执行 查询 定位插入点(O(log n))。
    2. 随机决定新元素的层数,如果新元素较高层次,需要调整索引结构。
    3. 时间复杂度 O(log n)(因为更新层级不会超过 log₂(n) 层)。
  • 删除

    1. 先执行 查询 定位删除点(O(log n))。
    2. 在每一层 删除该元素的指针
    3. 时间复杂度 O(log n)

总结:

  • 平均时间复杂度:
    • 查找:O(log n)
    • 插入:O(log n)
    • 删除:O(log n)
  • 最坏情况:
    • 若跳表退化成单链表,最坏复杂度 O(n),但概率极低。

# 🔹 3. Redis 跳表实现细节(源码解析)

Redis 的 有序集合(Sorted Set) 采用 跳表(skiplist)+ 哈希表 组合实现:

  • 跳表 负责范围查询(ZRANGE)。
  • 哈希表 负责 O(1) 级别的快速定位。

# 3.1 Redis 跳表结构

typedef struct zskiplistNode {
    double score;  // 排序用的分值
    sds ele;       // 存储实际元素
    struct zskiplistNode *backward;  // 指向前驱
    struct zskiplistLevel {
        struct zskiplistNode *forward;  // 指向下一个节点
        unsigned int span;  // 记录跨越的节点数
    } level[];
} zskiplistNode;
1
2
3
4
5
6
7
8
9
  • score:排序使用的权重,Redis 采用浮点数。
  • ele:存储的实际数据。
  • backward:双向链表,便于回溯查询(支持 ZREVRANGE)。
  • level[]:不同层级的前向指针和跨度(span 记录层级跨越的步长)。

# 3.2 关键操作

  1. 插入

    • 先确定元素 score 在跳表中的位置。
    • 随机决定层数,并调整指针结构。
    • 平均复杂度 O(log n)
  2. 删除

    • 先查找该元素的位置。
    • 从所有层级移除节点,调整指针结构。
    • 平均复杂度 O(log n)
  3. 查询

    • 从最高层索引开始,每层跳跃到 <= key 的最大值,直到找到目标值。
    • 平均复杂度 O(log n)

# 🔹 4. 常见错误

  1. 误以为跳表的查询一定是 O(log n)

    • 实际上,最坏情况下(所有数据只存在底层链表),查询复杂度可能是 O(n),但概率极低。
  2. 误以为跳表比 AVL 树、红黑树更优

    • 跳表 查询速度和平衡树相近,但 跳表的插入和删除实现更简单,不会因旋转造成额外消耗。
    • 在高并发环境下,跳表比树更适合 Redis

# 🔹 5. 最佳实践与优化

  1. 合理设置 p 值(层级分布概率)

    • Redis 采用 p = 0.25(25% 的节点进入上一层),保证索引层级不会太高,提高查询效率。
  2. 跳表 vs. 红黑树

    • 跳表更适用于内存存储(如 Redis),结构简单,插入删除无需旋转,操作效率高。
    • 红黑树适用于磁盘存储(如数据库索引),旋转开销小于磁盘 I/O。
  3. 避免 O(n) 退化

    • 确保足够大数据量时,随机化构建索引,避免极端情况导致查询降级。

# 🔍 深入追问

  1. 跳表为什么适用于 Redis,而不是红黑树?
  2. 跳表为什么查询效率接近二分查找?
  3. 跳表的 span 字段在 ZSKIPLIST 结构中起什么作用?
  4. Redis 的 ZADD 是如何优化跳表插入的?
  5. 如何保证跳表的多线程安全?

# 相关面试题

  • 为什么 Redis 不使用平衡树,而使用跳表?
  • Redis 有序集合(ZSET)如何支持高效的范围查询?
  • 跳表插入删除操作的详细过程?
  • 如何优化跳表的性能?
  • 如何在分布式存储中使用跳表?