Redis-跳跃表(skip List)

什么是跳跃表(skip list)

跳表全称叫做跳跃表,简称跳表。跳表是一个随机化的数据结构,

跳跃表(skiplist)是一个用于有序元素序列快速搜索的随机化的数据结构,由美国计算机科学家William Pugh发明于1989年,论文《Skip lists: a probabilistic alternative to balanced trees》中提出。

它的效率和红黑树以及 AVL 树不相上下,但实现起来比较容易。是一种可以于平衡树媲美的层次化链表结构——查找、删除、添加等操作都可以在对数期望时间下完成

作者William Pugh是这样介绍Skip list的:

Skip lists are a probabilistic data structure that seem likely to supplant balanced trees as the implementation method of choice for many applications. Skip list algorithms have the same asymptotic expected time bounds as balanced trees and are simpler, faster and use less space.

Skip list是一个“概率型”的数据结构,可以在很多应用场景中替代平衡树。Skip list算法与平衡树相比,有相似的渐进期望时间边界,但是它更简单,更快,使用更少的空间。

Skip list是一个分层结构多级链表,最下层是原始的链表,每个层级都是下一个层级的“高速跑道”。

跳跃表(SkipList)是一种可以替代平衡树的数据结构。跳跃表让已排序的数据分布在多层次的链表结构中,默认是将Key值升序排列的,以 0-1 的随机值决定一个数据是否能够攀升到高层次的链表中。它通过容许一定的数据冗余,达到 “以空间换时间” 的目的。

跳跃表的效率和AVL相媲美,查找/添加/插入/删除操作都能够在O(LogN)的复杂度内完成。

以下是典型的跳跃表例子:

在这里插入图片描述
跳跃表主要由以下几部分构成:

  • 表头(head):负责维护跳跃表的节点指针。
  • 跳跃表节点:保存着元素值,以及多个层。
  • 层:保存着指向其他元素的指针。高层的指针越过的元素数量大于等于低层的指针,为了提高查找的效率,程序总是从高层先开始访问,然后随着元素值范围的缩小,慢慢降低层次。
  • 表尾:全部由 NULL 组成,表示跳跃表的末尾。

上一篇中我们提到了 Redis 的五种基本结构中,有一个叫做 有序列表 zset 的数据结构,它类似于 Java 中的 SortedSet 和 HashMap 的结合体,一方面它是一个 set 保证了内部 value 的唯一性,另一方面又可以给每个 value 赋予一个排序的权重值 score,来达到 排序 的目的。

它的内部实现就依赖了一种叫做 「跳跃列表」 的数据结构。

为什么使用跳跃表

上面所说的 zset 需要支持随机的插入和删除,所以它不宜使用数组来实现,关于排序问题,我们也很容易就想到红黑树/ 平衡树这样的树形结构,那为什么 Redis 不使用这样一些结构呢?主要两方面进行考虑:

  • 实现方面:在复杂度与红黑树相同的情况下,跳跃表实现起来更简单,看起来也更加直观,比如平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而 skip List 的插入和删除只需要修改相邻节点的指针,操作简单又快速。
  • 性能方面:在高并发的情况下,树形结构需要执行一些类似于 rebalance 这样的可能涉及整棵树的操作,相对来说跳跃表的变化只涉及局部。

基于以上的一些考虑,为了满足自身的功能需要, Redis 基于 William Pugh 论文中描述的跳跃表进行了一些改进后采用了 跳跃表 这样的结构。

skip List,顾名思义,首先它是一个list,并且是在有序链表的基础上发展起来的。

首先我们看一个普通的链表案例:

在这里插入图片描述
链表中,每个节点都指向下一个节点,想要访问下下个节点,必然要经过下个节点,即无法跳过节点访问,假设,现在要查找22,我们要先后查找 3->7->11->19->22,需要五次查找。

但是如果我们能够实现跳过一些节点访问,就可以提高查找效率了,所以对链表进行一些修改,如下图:

在这里插入图片描述
我们每一个节点,都会保存指向下下个节点的指针,这样我们就能跳过某个节点进行访问,这样,我们其实是构造了两个链表,新的链表之后原来链表的一半。

我们称原链表为第一层,新链表为第二层,第二层是在第一层的基础上隔一个取一个。假设,现在还是要查找22,我们先从第二层查找,从7开始,7小于22,再往后,19小于22,再往后,26大于22,所以从节点19转到第一层,找到了22,先后查找 7->19->26->22,只需要四次查找。

以此类推,如果再提取一层链表,查找效率岂不是更高,如下图:
在这里插入图片描述
现在,又多了第三层链表,第三层是在第二层的基础上隔一个取一个,假设现在还是要查找22,我们先从第三层开始查找,从19开始,19小于22,再往后,发现是空的,则转到第二层,19后面的26大于22,转到第一层,19后面的就是22,先后查找 19->26>22,只需要三次查找。

由上例可见,在查找时,跳过多个节点,可以大大提高查找效率,跳跃表 skip List 就是受到这种多层链表结构的启发而设计出来的。

按照上面生成链表的方式,上面每一层链表的节点个数,是下面一层的节点个数的一半,这样查找过程就非常类似于一个二分查找,使得查找的时间复杂度可以降低到 O(logn)。

但是,这种方法在插入数据的时候有很大的问题。新插入一个节点之后,就会打乱上下相邻两层链表上节点个数严格的 2:1 的对应关系。如果要维持这种对应关系,就必须把新插入的节点后面的所有节点 (也包括新插入的节点) 重新进行调整,这会让时间复杂度重新蜕化成 O(n)。删除数据也有同样的问题。

跳表为了解决插入和删除节点时造成的后续节点重新调整的问题,引入了随机层数的做法。相邻层数之间的节点个数不再是严格的2:1的结构,而是为每个新插入的节点赋予一个随机的层数。下图展示了如何通过一步步的插入操作从而形成一个跳表:

在这里插入图片描述
从上面的创建和插入的过程中可以看出,每一个节点的层数(level)是随机出来的,而且新插入一个节点并不会影响到其他节点的层数,因此,插入操作只需要修改节点前后的指针,而不需要对多个节点都进行调整,这就降低了插入操作的复杂度。

上图最后的跳表中,我们需要查找节点22,则遍历到的节点依次是:7->37->19->22,可见,这种随机层数的跳表的查找时可能没有2:1结构的效率,但是却解决了插入/删除节点的问题。

跳跃表的实现

Redis 中的跳跃表由 server.h/zskiplistNode 和 server.h/zskiplist 两个结构定义,前者为跳跃表节点,后者则保存了跳跃节点的相关信息,同之前的集合 list 结构类似,其实只有 zskiplistNode 就可以实现了,但是引入后者是为了更加方便的操作:

/* ZSETs use a specialized version of Skiplists */
typedef struct zskiplistNode {
   
   

    //后退指针
    struct zskiplistNode *backward;

    //分值:用于排序,跳跃表中的所有节点都按分值大小进行排序
    double score;

    //成员对象:即真正要往链表中存放的对象
    robj *obj;

    //层:每个节点都包含很多层,每一层指向的都是同一个对象
    struct zskiplistLevel {
   
   

        //前进指针
        struct zskiplistNode *forward;

        //跨度
        unsigned int span;

    } level[];

} zskiplistNode;

typedefstruct zskiplist {
   
   
    //表头节点和表尾节点
    struct zskiplistNode *header, *tail;
    //表中节点的数量
    unsignedlong length;
    //表中层数最大的节点的层数
    int level;
} zskiplist;

在这里插入图片描述

上图最左边的是 zskip List 结构, 该结构包含以下属性:

  • header :指向跳跃表的表头节点。
  • tail :指向跳跃表的表尾节点。
  • level :记录目前跳跃表内,层数最大的那个节点的层数(表头节点的层数不计算在内)。
  • length :记录跳跃表的长度,也即是,跳跃表目前包含节点的数量(表头节点不计算在内)。

zskip List 结构右边的是四个 zskipListNode 结构, 该结构包含以下属性:

  • 层(level):节点中用 L1 、 L2 、 L3 等字样标记节点的各个层, L1 代表第一层, L2 代表第二层,以此类推。每个层都带有两个属性:前进指针和跨度。前进指针用于访问位于表尾方向的其他节点,而跨度则记录了前进指针所指向节点和当前节点的距离。在上面的图片中,连线上带有数字的箭头就代表前进指针,而那个数字就是跨度。当程序从表头向表尾进行遍历时,访问会沿着层的前进指针进行。
  • 后退(backward)指针:节点中用 BW 字样标记节点的后退指针,它指向位于当前节点的前一个节点。后退指针在程序从表尾向表头遍历时使用。
  • 分值(score):各个节点中的 1.0 、 2.0 和 3.0 是节点所保存的分值。在跳跃表中,节点按各自所保存的分值从小到大排列。
  • 成员对象(obj):各个节点中的 o1 、 o2 和 o3 是节点所保存的成员对象。

注意表头节点和其他节点的构造是一样的: 表头节点也有后退指针、分值和成员对象, 不过表头节点的这些属性都不会被用到, 所以图中省略了这些部分, 只显示了表头节点的各个层。

跳跃表节点

分值和成员

节点的分值(score属性)是一个double类型的浮点数,跳跃表中的所有节点都按分值从小到大来排序。

节点的成员对象(obj属性)是一个指针,它指向一个字符串对象,而字符串对象则保存着一个SDS值。

在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值却可以是相同的:分至相同的节点将按照成员对象在字典中的大小来进行排序,成员对象较小的节点会排在前面(靠近表头的方向),而成员对象较大的节点则会排在后面(靠近表尾的方向)。

举个例子,在下图中所示的跳跃表中,三个跳跃表节点都保存了相同的分值10086.0,但保存成员对象 o1 的节点却排在保存成员对象 o2 和 o3 的节点的前面,而保存成员对象 o2 的节点又排在保存成员对象 o3 的节点之前,由此可见,o1、o2、o3 三个成员对象在字典中的排序为 o1<=o2<=o3 。

在这里插入图片描述

后退指针

节点的后退指针 ( backward 属性 ) 用于从表尾向表头方向访问节点:跟可以一次跳过多个节点的前进指针不同,因为每个节点只有一个后退指针,所以每次只能后退至前一个节点。

下图用虚线展示了如何从表尾向表头遍历跳跃表中的所有节点:程序首先通过跳跃表的tail指针访问表尾节点,然后通过后退指针访问倒数第二个节点,之后再沿着后退指针访问倒数第三个节点,再之后遇到指向 NULL 的后退指针,于是访问结束。
在这里插入图片描述

跳跃表节点的 level 数组可以包含多个元素,每个元素都包含一个指向其他节点的指针,程序可以通过这些层来加快访问其他节点的速度,一般来说,层的数量越多,访问其他节点的速度就越快。

每次创建一个新跳跃表节点的时候,程序根据幂次定律 ( power law,越大的数出现的概率越小)随机生成一个介于 1 和 32 之间的值作为 level 数组的大小,这个大小就是层的“高度”。

下图分别展示了三个高度为1层、3层和5层的节点,因为 C 语言的数组索引总是从 0 开始的,所以节点的第一层是 level[0],而第二层是 level[1],依次类推。
在这里插入图片描述

前进指针

每个层都有一个指向表尾方向的前进指针(level[i].forward属性),用于从表头向表尾方向访问节点。下图用虚线表示出了程序从表头向表尾方向,遍历跳跃表中所有节点的路径:

迭代程序首先访问跳跃表的第一个节点(表头),然后从第四层的前进指针移动到表中的第二个节点。
在第二个节点时,程序沿着第二层的前进指针移动到表中的第三个节点。

评论 6
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值