Redis有序集合底层实现:跳表(SkipList)深度解析

Redis有序集合底层实现:跳表(SkipList)深度解析

跳表概述

跳表(SkipList)是一种概率平衡的数据结构,它通过在有序链表的基础上建立多级索引,使得查找、插入和删除操作的时间复杂度都能达到O(log n)级别。Redis的有序集合(sorted set)在元素较多时就是采用跳表作为底层实现。

为什么Redis选择跳表

跳表在Redis中的应用

Redis的有序集合(sorted set)是一个非常重要的数据结构,它能够保证元素唯一且有序,常用于排行榜等场景。有序集合底层实现有两种:

  1. ziplist(压缩列表):当元素数量小于128且每个元素大小小于64字节时使用
  2. skiplist(跳表):当不满足上述条件时使用

跳表与其他数据结构的比较

面试中常被问到:"为什么Redis的有序集合底层用跳表而不用平衡树、红黑树或B+树?"

1. 跳表 vs 平衡树(AVL树)
  • 时间复杂度:两者都是O(log n)
  • 实现复杂度:跳表实现更简单
  • 平衡维护:AVL树需要严格的旋转操作保持平衡,跳表通过概率平衡
  • 范围查询:两者都支持高效的范围查询
2. 跳表 vs 红黑树
  • 时间复杂度:两者都是O(log n)
  • 实现复杂度:跳表代码更简单
  • 并发性能:跳表更容易实现无锁并发
  • 内存占用:跳表需要额外存储索引,但Redis中这个开销可以接受
3. 跳表 vs B+树
  • 适用场景:B+树更适合磁盘存储系统,跳表更适合内存数据库
  • 实现复杂度:跳表实现更简单
  • 范围查询:B+树叶子节点形成链表,范围查询性能优异,但跳表也能很好支持

Redis选择跳表的主要原因

  1. 实现简单:跳表的实现比平衡树简单得多,代码更易维护
  2. 查询高效:跳表的查询效率与平衡树相当
  3. 范围查询:跳表天然支持高效的范围查询
  4. 内存友好:虽然跳表需要额外空间存储索引,但Redis作为内存数据库可以接受
  5. 并发友好:跳表更容易实现无锁并发操作

跳表实现原理

跳表基本结构

跳表通过在原始有序链表上建立多级索引来加速查找:

  1. 原始链表存储所有元素
  2. 一级索引包含约一半的元素
  3. 二级索引包含约四分之一的元素
  4. 以此类推...

这种结构使得查找时可以"跳过"大量不需要比较的节点。

查找过程

查找元素时从最高级索引开始:

  1. 在当前层级向右查找,直到找到大于等于目标值的节点
  2. 向下移动到下一级索引继续查找
  3. 重复上述过程直到原始链表

插入过程

插入新元素时:

  1. 随机决定该元素的"高度"(即建立多少级索引)
  2. 在每一层级找到插入位置的前驱节点
  3. 更新各层级的指针

删除过程

删除元素时:

  1. 在每一层级找到要删除节点的前驱节点
  2. 更新各层级的指针绕过被删除节点
  3. 调整跳表高度

手写跳表实现

下面我们通过Java实现一个简单的跳表来加深理解。

节点定义

class Node {
    private int data = -1;
    private Node[] forwards = new Node[MAX_LEVEL]; // 存储各层后继节点
    private int maxLevel = 0; // 节点高度
    
    @Override
    public String toString() {
        return "Node{data=" + data + ", maxLevel=" + maxLevel + "}";
    }
}

随机高度生成

private int randomLevel() {
    int level = 1;
    // 有50%概率增加层级
    while (Math.random() < PROB && level < MAX_LEVEL) {
        level++;
    }
    return level;
}

插入操作

public void add(int value) {
    int level = randomLevel();
    Node newNode = new Node();
    newNode.data = value;
    newNode.maxLevel = level;
    
    Node[] update = new Node[level];
    Node p = h;
    
    // 记录每层的前驱节点
    for (int i = level - 1; i >= 0; i--) {
        while (p.forwards[i] != null && p.forwards[i].data < value) {
            p = p.forwards[i];
        }
        update[i] = p;
    }
    
    // 更新各层指针
    for (int i = 0; i < level; i++) {
        newNode.forwards[i] = update[i].forwards[i];
        update[i].forwards[i] = newNode;
    }
    
    // 更新跳表高度
    if (levelCount < level) {
        levelCount = level;
    }
}

查询操作

public Node get(int value) {
    Node p = h;
    // 从最高层开始查找
    for (int i = levelCount - 1; i >= 0; i--) {
        while (p.forwards[i] != null && p.forwards[i].data < value) {
            p = p.forwards[i];
        }
    }
    
    // 检查是否找到
    if (p.forwards[0] != null && p.forwards[0].data == value) {
        return p.forwards[0];
    }
    
    return null;
}

删除操作

public void delete(int value) {
    Node[] update = new Node[levelCount];
    Node p = h;
    
    // 记录每层的前驱节点
    for (int i = levelCount - 1; i >= 0; i--) {
        while (p.forwards[i] != null && p.forwards[i].data < value) {
            p = p.forwards[i];
        }
        update[i] = p;
    }
    
    // 如果找到要删除的节点
    if (p.forwards[0] != null && p.forwards[0].data == value) {
        // 更新各层指针
        for (int i = levelCount - 1; i >= 0; i--) {
            if (update[i].forwards[i] != null && 
                update[i].forwards[i].data == value) {
                update[i].forwards[i] = update[i].forwards[i].forwards[i];
            }
        }
    }
    
    // 更新跳表高度
    while (levelCount > 1 && h.forwards[levelCount - 1] == null) {
        levelCount--;
    }
}

Redis跳表的特点

  1. 双向链表:Redis跳表是双向的,每个节点都有回退指针,便于反向遍历和删除操作
  2. 允许重复score:当score相同时,按照元素值(ele)的字典序排序
  3. 最大高度32:Redis跳表默认允许的最大层数是32,由ZSKIPLIST_MAXLEVEL定义
  4. 概率平衡:不像平衡树那样需要严格的旋转操作来保持平衡

总结

跳表是一种简单高效的数据结构,特别适合作为内存数据库的有序集合实现。Redis选择跳表而非平衡树的主要原因包括:

  1. 实现简单,代码易于维护
  2. 查询效率与平衡树相当
  3. 天然支持高效的范围查询
  4. 更适合内存数据库的使用场景
  5. 更容易实现并发控制

通过手写实现一个简单的跳表,我们可以更深入地理解其工作原理和优势所在。理解跳表的实现原理不仅有助于我们更好地使用Redis的有序集合,也为我们设计高效的数据结构提供了新的思路。

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值