当前位置: 首页 > backend >正文

【数据结构与算法】跳表实现详解

文章目录

  • Ⅰ. 前言
  • Ⅱ. 跳表(skiplist)
    • 一、什么是跳表
    • 二、跳表的发明历程
    • 三、跳表的搜索方式
  • Ⅲ. skiplist的算法性能分析
    • 一、理论准备
    • 二、性能分析(了解即可,主要记结论)
  • Ⅳ. skiplist与平衡树、哈希表的比较
  • Ⅴ. skiplist的实现
      • [ 设计跳表](https://leetcode.cn/problems/design-skiplist/)
    • 一、节点的搭建
    • 二、生成概率高度函数RandomLevel()
    • 三、查找函数search()
    • 四、插入函数add()
    • 五、删除函数erase()
  • Ⅵ. 关于Redis中的一些拓展知识
      • :sa: Redis为什么用skiplist而不用平衡树?
  • 附加:整体代码

在这里插入图片描述

Ⅰ. 前言

skiplist 是一种随机化的数据结构基于并联的链表,实现简单,插入、删除、查找的复杂度均为 O(logN)大多数情况下,因为是实现上是概率问题),因为其性能匹敌红黑树且实现较为简单,因此在很多著名项目都用 skiplist 来代替红黑树,例如 LevelDBRocksDBRedis 中的有序集合 zset 的底层存储结构就是用的 skiplist

​ 目前常用的 key-value 数据结构有三种:哈希表、红黑树、skiplist,它们各自有着不同的优缺点:

  • 哈希表:插入、查找最快,为 O(1);如使用链表实现则可实现无锁;数据有序化需要显式的排序操作,即哈希表是无序的。

  • 红黑树:插入、查找为 O(logn),但常数项较小;无锁实现的复杂性很高,一般需要加锁;数据天然有序

  • skiplist:插入、查找为 O(logn),但常数项比红黑树要大;底层结构为链表,可无锁实现;数据天然有序

下面我们就着重来讲解一下 skiplist,以下是参考文案 (为了防止链接失效,大部分内容已经cv在文中,仅供参考!)

Redis内部数据结构详解(6)——skiplist

浅析skiplist(跳表)

skiplist跳表–一种高性能数据结构

Ⅱ. 跳表(skiplist)

一、什么是跳表

skiplist 本质上也是一种查找结构,是一个“概率型”的数据结构,用于解决算法中的查找问题,即根据给定的 key,快速查到它所在的位置(或者对应的 value )。

​ 跳表是在 1989 年由 William Pugh 发明的,作者时年 29 岁。skiplist 发明的 初衷是为了克服平衡树的一些缺点,比如平衡树在节点插入时,需要额外的进行的树的转枝,剪枝操作,才能够达到树的平衡。而 skiplist 借助于其独特的设计,在数据插入过程中,不需要额外的数据排序过程,只需要找到其需要插入位置的前置节点,即可插入,插入之后,依然保持数据结构的数据平衡。

​ 他在论文《Skip lists: a probabilistic alternative to balanced trees》中详细介绍了跳表的数据结构和插入删除等操作。论文是这么介绍跳表的:

​ Skip lists are a data structure that can be used in place of balanced trees. Skip lists use probabilistic balancing rather than strictly enforced balancing and as a result the algorithms for insertion and deletion in skip lists are much simpler and significantly faster than equivalent algorithms for balanced trees.

​ 翻译:跳表是一种可以用来代替平衡树的数据结构。跳表使用概率平衡而不是严格强制的平衡,因此,跳表中的插入和删除算法比平衡树的等效算法简单得多,速度也快得多。

二、跳表的发明历程

skiplist,顾名思义,首先它是一个 list。实际上,它是在有序链表的基础上发展起来的。如果是一个有序的链表,查找数据的时间复杂度是 O(N)。所以 William Pugh 开始想出了下面的优化思路:

  1. 假如我们每相邻两个节点升高一层,增加一个指针,让指针指向下下个节点,如下图所示中的 b。这样所有新增加的指针连成了一个新的链表,但它包含的节点个数只有原来的一半。由于新增加的指针,我们不再需要与链表中每个节点逐个进行比较了,需要比较的节点数大概只有原来的一半。
  2. 以此类推,我们可以在第二层新产生的链表上,继续为每相邻的两个节点升高一层,增加一个指针,从而产生第三层链表。如下图中的c,这样搜索效率就进一步提高了。
  3. skiplist 正是受这种多层链表的想法的启发而设计出来的。实际上,按照上面生成链表的方式,上面每一层链表的节点个数,是下面一层的节点个数的一半,这样查找过程就非常类似二分查找,使得查找的时间复杂度可以降低到 O(log n)。但是这个结构在插入删除数据的时候有很大的问题插入或者删除一个节点之后,就会打乱上下相邻两层链表上节点个数严格的 2:1 的对应关系。如果要维持这种对应关系,就必须把新插入的节点后面的所有节点(也包括新插入的节点)重新进行调整,这会让时间复杂度重新蜕化成 O(n)

在这里插入图片描述

  1. skiplist 的设计为了避免这种问题,做了一个大胆的处理,不再严格要求对应比例关系,而是插入一个节点的时候随机出一个层数。这样每次插入和删除都不需要考虑其他节点的层数,这样就好处理多了。细节过程入下图:
    在这里插入图片描述

​ 上图中插入 17 这个元素,其实就相当于是放了一堵墙,将 912 两个元素的指针挡住了,接到了 17 上面,这其实是不影响这些节点的,所以插入过程对其他节点是很稳定的!

​ ♻️ 其中最底层的链表,即包含了所有元素节点的链表是 L1 层,或称基础层基础层包括所有的元素。除此以外的所有链表层都称为跳跃层

三、跳表的搜索方式

​ 我们先来看一个有序链表,如下图(最左侧的灰色节点表示一个空的头结点):
在这里插入图片描述

​ 在这样一个链表中,如果我们要查找某个数据,那么需要从头开始逐个进行比较,直到找到包含数据的那个节点,或者找到第一个比给定数据大的节点为止(没找到)。也就是说,时间复杂度为 O(n)。同样,当我们要插入新数据的时候,也要经历同样的查找过程,从而确定插入位置。

​ 假如我们每相邻两个节点增加一个指针,让指针指向下下个节点,如下图:
在这里插入图片描述

​ 这样所有新增加的指针连成了一个新的链表,但它包含的节点个数只有原来的一半(上图中是7, 19, 26)。现在当我们想查找数据的时候,可以 先沿着这个新链表进行查找,当碰到比待查数据大的节点时,再回到原来的链表中进行查找(也就是向下走)。比如,我们想查找 23,查找的路径是沿着下图中标红的指针所指向的方向进行的:
在这里插入图片描述

  • 23 首先和 7 比较,再和 19 比较,比它们都大,继续向右比较。
  • 2326 比较的时候,比 26 要小,因此要向下走,与 22 比较。
  • 2322 要大,继续向右和 26 比较。2326 小,说明待查数据 23 在原链表中不存在,而且它的插入位置应该在 2226 之间。

​ 后面若增加了高度,也是一样的遍历方法!
在这里插入图片描述

Ⅲ. skiplist的算法性能分析

一、理论准备

​ 如果是第一次接触 skiplist,那么一定会产生一个疑问:节点插入时随机出一个层数,仅仅依靠这样一个简单的随机数操作而构建出来的多层链表结构,能保证它有一个良好的查找性能吗?为了回答这个疑问,我们需要分析 skiplist 的统计性能!

​ 在分析之前,我们还需要着重指出的是,执行插入操作时计算随机数的过程,是一个很关键的过程,它对 skiplist 的统计特性有着很重要的影响。这并不是一个普通的服从均匀分布的随机数,它的计算过程如下:

  • 首先,每个节点肯定都有第一层指针
  • 如果一个节点有第 i 层 (i>=1) 指针(即节点已经在第 1 层到第 i 层链表中),那么它有i + 1 层指针的概率为 p
  • 节点最大的层数不允许超过一个最大值,记为 MaxLevel

这个计算随机层数的伪代码如下所示:
在这里插入图片描述

randomLevel() 的伪代码中包含两个参数,一个是 p,一个是 MaxLevel。在 Redisskiplist 实现中,这两个参数的取值为:

p = 1/4
MaxLevel = 32

二、性能分析(了解即可,主要记结论)

​ 根据前面 randomLevel() 的伪码,我们很容易看出,产生越高的节点层数,概率是越低的。定量的分析如下:
在这里插入图片描述

​ 因此,一个节点的平均层数(也即包含的平均指针数目),计算如下:
在这里插入图片描述

现在很容易计算出:

  • p=1/2 时,每个节点所包含的平均指针数目为 2
  • p=1/4 时,每个节点所包含的平均指针数目为 1.33。这也是 Redis 里的 skiplist 实现在空间上的开销。

也可以看出了,我们 取的概率越小,那么产生越高节点的层数的几率就越小!可能有点抽象,画个图帮助理解一下:

在这里插入图片描述

​ 接下来,为了分析时间复杂度,我们计算一下 skiplist平均查找长度。查找长度指的是查找路径上跨越的跳数,而查找过程中的比较次数就等于查找长度加 1。以前面图中标出的查找 23 的查找路径为例,从左上角的头结点开始,一直到结点 22,查找长度为 6

​ 我们注意到,每个节点插入的时候,它的层数是由随机函数 randomLevel() 计算出来的,而且随机的计算不依赖于其它节点,每次插入过程都是完全独立的。所以,从统计上来说,一个 skiplist 结构的形成与节点的插入顺序无关。

​ 这样的话,为了计算查找长度,我们可以将查找过程倒过来看,从右下方第 1 层上最后到达的那个节点开始,沿着查找路径向左向上回溯,类似于爬楼梯的过程。我们假设当回溯到某个节点的时候,它才被插入,这虽然相当于改变了节点的插入顺序,但从统计上不影响整个 skiplist 的形成结构。

​ 现在假设我们从一个 层数i节点x 出发,需要向左向上攀爬 k。这时我们有两种可能:

  • 如果节点 x 有第 i+1 层指针,那么我们需要向上走。这种情况概率为 p
  • 如果节点 x 没有第 i+1 层指针,那么我们需要向左走。这种情况概率为 1-p

这两种情形如下图所示:
在这里插入图片描述

​ 用 C(k) 表示向上攀爬 k 个层级所需要走过的平均查找路径长度(概率期望),那么:
在这里插入图片描述

​ 代入,得到一个差分方程并化简:
在这里插入图片描述

​ 这个结果的意思是,我们每爬升 1 个层级,需要在查找路径上走 1/p 步。而我们总共需要攀爬的层级数等于整个 skiplist的总层数 - 1

​ 那么接下来我们需要分析一下当 skiplist 中有 n 个节点的时候,它的总层数的概率均值是多少。这个问题直观上比较好理解。根据节点的层数随机算法,容易得出:
在这里插入图片描述

​ 所以,从第 1 层到最高层,各层链表的平均节点数是一个指数递减的等比数列。容易推算出,总层数的均值为 l o g 1 / p n log_{1/p}n log1/pn,而最高层的平均节点数为 1/p

综上,粗略来计算的话,平均查找长度约等于:

  • C( l o g 1 / p n − 1 log_{1/p}n-1 log1/p
http://www.xdnf.cn/news/3358.html

相关文章:

  • Windows结合WSL之ext4.vhdx不断增大问题
  • 第九节:文件操作
  • C++漫游指南——字符串篇与内存分配篇
  • ganesha-DBUS
  • 人形机器人的 “灵动密码”:动作捕捉与 AI 如何为其注入活力
  • BOSS的收入 - 华为OD机试(A卷,Java题解)
  • React-Native Android 多行被截断
  • Ubuntu 22.04 的 ROS 2 和 Carla 设置指南(其一)
  • Multicore-TSNE
  • 如何用GPU Instancing来优化树木草石重复模型
  • Kubernetes 配置中的 Selector 详解
  • GPU集群搭建步骤
  • 基础术语说明
  • 前端项目问题:TypeError: Failed to fetch dynamically imported module
  • 数据结构---【二叉搜索树】
  • Canvas基础篇:图形绘制
  • 工业质检领域相关近期顶会论文汇总CVPR2025
  • SALOME源码分析: SMESH模块
  • 2025-04-30 AIGC-如何做短片视频
  • 科学数据可视化工具库visIt安装和使用
  • 阿里云短信接入实现示例
  • IsaacLab最新2025教程(7)-创建Interactive Scene
  • Socket-UDP
  • Day.js一个2k轻量级的时间日期处理库
  • Modbus转PROFIBUS网关:电动机保护新突破!
  • [CPCTF 2025] Crypto
  • YOLOv11改进:视觉变换器SwinTransformer目标检测网络
  • C 语言链表详解
  • 第 11 届蓝桥杯 C++ 青少组中 / 高级组省赛 2020 年真题答和案解析
  • 测试 用例篇