手搓Redis之为Zset而作的跳表实现逻辑梳理与面试问题解析

跳表实现逻辑梳理与面试问题解析

跳表简介

跳表是一种基于链表的数据结构,通过引入多层索引提升查询效率。其核心思想是通过随机层数的节点构建多级索引,类似于平衡树的效果,但实现更简单。时间复杂度平均为 O(log n),空间复杂度为 O(n)。

数据结构定义

  • Node 类:每个节点包含分数(score)、成员(member)和指向后继节点的数组(next),数组长度为最大层数 MAX_LEVEL(32)。
  • SkipList 类:包含头节点(head)、当前层数(level)、随机数生成器(random)、节点数量(size)等字段。
  • 关键参数
    • MAX_LEVEL = 32:最大层数限制。
    • P = 0.25:节点晋升概率,影响索引层数分布。

核心操作逻辑梳理

1. 插入节点(insert 方法)

逻辑步骤
  1. 查找插入位置
    • 从最高层(level - 1)开始,向下遍历每一层。
    • 在每层中,沿着 next[i] 前进,直到遇到 score 更大或相等但 member 更大的节点。
    • 记录每层的"前驱节点"到 update 数组中。
  2. 检查是否已存在
    • 检查第 0 层的后继节点(cur.next[0])是否与插入的 member 相等。
    • 若存在,则更新其 score 并返回 false(表示更新而非插入)。
  3. 生成随机层数
    • 调用 randomLevel(),基于概率 P(0.25)生成新节点的层数(newLevel)。
    • 若 newLevel 超过当前 level,则更新 level,并将新层的前驱设为 head。
  4. 插入新节点
    • 创建新节点(newNode),将其插入到每一层(0 到 newLevel-1)。
    • 对于每层 i,将 newNode.next[i] 指向 update[i].next[i],然后将 update[i].next[i] 指向 newNode。
    • size 自增,返回 true。
代码片段
java 复制代码
int newLevel = randomLevel();
if (newLevel > level) {
    for (int i = level; i < newLevel; i++) {
        update[i] = head;
    }
    level = newLevel;
}
Node newNode = new Node(score, member);
for (int i = 0; i < newLevel; i++) {
    newNode.next[i] = update[i].next[i];
    update[i].next[i] = newNode;
}

2. 删除节点(delete 方法)

逻辑步骤
  1. 查找删除位置
    • 从最高层(level - 1)开始,向下遍历每一层。
    • 在每层中,沿着 next[i] 前进,直到遇到 member 更大的节点。
    • 记录每层的"前驱节点"到 update 数组中。
  2. 确认删除目标
    • 检查第 0 层的后继节点(cur.next[0])是否与目标 member 相等。
    • 若不匹配,返回 false。
  3. 删除节点
    • 从第 0 层到 level-1 层,检查 update[i].next[i] 是否为目标节点 cur。
    • 若匹配,将 update[i].next[i] 更新为 cur.next[i],断开与 cur 的连接。
    • 若不匹配(因更高层可能无此节点),跳出循环。
  4. 调整层数
    • 检查最高层(level-1)是否为空,若为空则 level 自减,直到非空或 level 降至 1。
    • size 自减,返回 true。
代码片段
java 复制代码
if (cur != null && cur.member.equals(member)) {
    for (int i = 0; i < level; i++) {
        if (update[i].next[i] != cur) {
            break;
        }
        update[i].next[i] = cur.next[i];
    }
    while (level > 1 && head.next[level - 1] == null) {
        level--;
    }
    size--;
    return true;
}

面试问题与解答

Q1: 为什么插入时需要记录 update 数组?

:update 数组记录了每一层插入位置的前驱节点。在跳表中,节点按 score 和 member 排序,插入新节点时需要更新前驱的 next 指针指向新节点。update 数组保存了这些前驱,便于在随机生成的层数内快速调整指针。如果不记录,需要重新遍历查找前驱,效率降低到 O(n)。


Q2: randomLevel 的概率 P 如何影响性能?

:P(默认 0.25)决定了节点晋升到更高层的概率。P 越大,层数越高,索引越稀疏,查询效率接近 O(log n),但空间开销增加;P 越小,层数越低,索引稠密,查询退化为 O(n)。P = 0.25 是经验值,平衡了时间和空间复杂度,类似于 Redis 的跳表实现。


Q3: 删除时为什么需要调整 level?

:删除节点后,某些高层可能变为空(即 head.next[i] == null)。保留空层会浪费空间且无意义,因此需要自顶向下检查并减少 level,直到遇到非空层或 level 降至 1。这确保跳表层数与实际数据分布一致。


Q4: 如何处理并发插入和删除?

:当前代码未加锁,不支持并发操作。并发环境下可能出现指针混乱(如插入时另一线程删除前驱节点)。解决方法是:

  1. 加全局锁(如 synchronized),简单但性能差。
  2. 使用无锁算法(如 CAS),在更新 next 指针时确保原子性,但实现复杂。
  3. 借鉴 Redis 的 zskiplist,使用读写锁优化并发读写。

Q5: 跳表与平衡树的区别和优劣?

  • 区别
    • 跳表基于链表加随机索引,平衡树(如 AVL、红黑树)基于树结构。
    • 跳表层数随机生成,平衡树通过旋转保持平衡。
  • 优劣
    • 跳表实现简单,插入删除只需调整指针;平衡树调整逻辑复杂。
    • 跳表查询平均 O(log n),最坏 O(n);平衡树保证 O(log n)。
    • 跳表适合范围查询(如 getRangeByScore),平衡树需中序遍历。

总结

跳表的插入和删除逻辑通过多层索引和前驱记录(update 数组)实现高效操作。插入时随机层数动态扩展索引,删除时自适应调整层高。理解这些细节有助于在面试中清晰阐述实现原理,并应对延伸问题。跳表作为一种轻量级数据结构,在 Redis 等场景中广泛应用,值得深入学习。

相关推荐
bobz9657 分钟前
supervisord 的使用
后端
大道无形我有型8 分钟前
📖 Spring 事务机制超详细讲解(哥们专属)
后端
Re27510 分钟前
springboot源码分析--自动配置流程
spring boot·后端
Piper蛋窝13 分钟前
Go 1.2 相比 Go1.1 有哪些值得注意的改动?
后端·go
努力的搬砖人.16 分钟前
java爬虫案例
java·经验分享·后端
海风极客27 分钟前
一文搞懂JSON和HJSON
前端·后端·面试
南雨北斗29 分钟前
2.单独下载和配置PHP环境
后端
海风极客30 分钟前
一文搞懂Clickhouse的MySQL引擎
后端·面试·架构
都叫我大帅哥32 分钟前
遍历世界的通行证:迭代器模式的导航艺术
java·后端·设计模式
yezipi耶不耶1 小时前
Rust入门之迭代器(Iterators)
开发语言·后端·rust