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属性),用于从表头向表尾方向访问节点。下图用虚线表示出了程序从表头向表尾方向,遍历跳跃表中所有节点的路径:

迭代程序首先访问跳跃表的第一个节点(表头),然后从第四层的前进指针移动到表中的第二个节点。
在第二个节点时,程序沿着第二层的前进指针移动到表中的第三个节点。
在第三个节点时,程序同样沿着第二层的前进指针移动到表中的第四个节点。
当程序再次沿着第四个节点的前进指针移动时,它碰到一个NULL,程序知道这时已经到达了跳跃表的表尾,于是结束这次遍历。
在这里插入图片描述

跨度

层的跨度(level[i].span 属性)用于记录两个节点之间的距离:

  • 两个节点之间的跨度越大, 它们相距得就越远。
  • 指向 NULL 的所有前进指针的跨度都为 0 , 因为它们没有连向任何节点。

    初看上去, 很容易以为跨度和遍历操作有关, 但实际上并不是这样 —— 遍历操作只使用前进指针就可以完成了, 跨度实际上是用来计算排位(rank)的: 在查找某个节点的过程中, 将沿途访问过的所有层的跨度累计起来, 得到的结果就是目标节点在跳跃表中的排位。

    举个例子, 图 5-4 用虚线标记了在跳跃表中查找分值为 3.0 、 成员对象为 o3 的节点时, 沿途经历的层: 查找的过程只经过了一个层, 并且层的跨度为 3 , 所以目标节点在跳跃表中的排位为 3 。
    在这里插入图片描述

    随机层数

    对于每一个新插入的节点,都需要调用一个随机算法给它分配一个合理的层数,源码在 t_zset.c/zslRandomLevel(void) 中被定义:

    //此处假设插入节点的成员对象不存在于当前跳跃表内,即不存在重复的节点
    //随机生成一个level值
    int zslRandomLevel(void) {
        int level = 1;
        while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
            level += 1;
        return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
    }
    

    直观上期望的目标是 50% 的概率被分配到 Level 1,25% 的概率被分配到 Level 2,12.5% 的概率被分配到 Level 3,以此类推…有 2-63 的概率被分配到最顶层,因为这里每一层的晋升率都是 50%。

    Redis 跳跃表默认允许最大的层数是 32,被源码中 ZSKIPLIST_MAXLEVEL 定义,当 Level[0] 有 264 个元素时,才能达到 32 层,所以定义 32 完全够用了。

    新建跳跃表

    主要是初始化描述符对象和初始化头结点。这里需要注意的是,头结点默认层数是为32的,但描述符中指示跳跃表层数初始化为1。

    zskiplist *zslCreate(void) {
        int j;
        zskiplist *zsl;
    
        //申请内存空间
        zsl = zmalloc(sizeof(*zsl));
        // 初始化跳跃表属性,层数初始化为1,长度初始化为0
        zsl->level = 1;
        //初始化长度为 0
        zsl->length = 0;
        //创建一个层数为32,分值为0,成员对象为NULL的表头结点
        zsl->header = zslCreateNode(ZSKIPLIST_MAXLEVEL,0,NULL);
        
        //跳跃表头节点初始化
        for (j = 0; j < ZSKIPLIST_MAXLEVEL; j++) {
            // 将跳跃表头节点的所有前进指针 forward 设置为 NULL
            zsl->header->level[j].forward = NULL;
            // 将跳跃表头节点的所有跨度 span 设置为 0
            zsl->header->level[j].span = 0;
        }
        // 跳跃表头节点的后退指针 backward 置为 NULL
        zsl->header->backward = NULL;
        // 表头指向跳跃表尾节点的指针置为 NULL
        zsl->tail = NULL;
        return zsl;
    }
    

    执行完之后创建了如下结构的初始化跳跃表:
    在这里插入图片描述

    添加元素

    这里我们结合示意图来看,更加便于理解。

    在这里插入图片描述
    在这里插入图片描述

    代码如下:

    zskiplistNode *zslInsert(zskiplist *zsl, double score, robj *obj) {
       // update存储每一层位于插入节点的前一个节点
        zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
       // rank存储每一层位于插入节点的前一个节点的排名
        unsigned int rank[ZSKIPLIST_MAXLEVEL];
        int i, level;
        //nan无法排序,此处做一个判断
        serverAssert(!isnan(score));
        // 在各个层查找节点的插入位置
        x = zsl->header;
        //由高层向低层,在每层寻找新节点的前驱节点x,完成插入后,前驱节点的后继就是新插入节点
        /*因为节点层数高的概率小,所以层数高的节点少,高层级节点间跨度大,从上层开始找时,可跳过部分节点,所以看似O(n^2)的实现,实际O(NlogN)*/
        for (i = zsl->level-1; i >= 0; i--) {
            /* store rank that is crossed to reach the insert position */
            //当位于顶层节点时,运行到此处,x还指向头结点,未跨过任何节点,跨度为0
            //当不位于顶层节点时,说明当上一层寻找完毕,x刚由上一层切换到本层,由于是同一个节点,所以排名是一样的,记录下来,后续在本层进行寻找时使用
            rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];
            // 沿着前进指针遍历跳跃表
            // 若分值相同,则比较字符串对象
            while (x->level[i].forward &&
                (x->level[i].forward->score < score ||      
                    (x->level[i].forward->score == score &&
                    compareStringObjects(x->level[i].forward->obj,obj) < 0))) { //二进制安全对比
                // 记录沿途跨越了多少个节点
                rank[i] += x->level[i].span;
                // 移动至下一指针
                x = x->level[i].forward;
            }
            //将i层里找到的前驱节点记录,等待后续插入使用
            update[i] = x;
            //i层查找结束,继续从x节点的下一层开始往后寻找
        }
        //计算随机值,后续作为新节点的层数
        level = zslRandomLevel();   
        //新节点的层数超出了skiplist原有节点中最大已有层数,头结点超出部分的层数将会被使用,将其记录到update供后续使用
        if (level > zsl->level) {
            for (i = zsl->level; i < level; i++) {
                rank[i] = 0;
                update[i] = zsl->header;
                update[i]->level[i].span = zsl->length; 
            }
            // 更新表中节点最大层数
            zsl->level = level;
        }
         // 创建新节点
        x = zslCreateNode(level,score,obj);
        // 将前面记录的指针指向新节点,并做相应的设置
        for (i = 0; i < level; i++) {
            // 设置新节点的 forward 指针
            x->level[i].forward = update[i]->level[i].forward;
            // 将沿途记录的各个节点的 forward 指针指向新节点
            update[i]->level[i].forward = x;
    
             // 计算新节点跨越的节点数量
            x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);
           //由于新节点插入,前驱节点需增加1
            update[i]->level[i].span = (rank[0] - rank[i]) + 1;
        }
    
        // 高层节点跨越了新节点,跨度加1
        for (i = level; i < zsl->level; i++) {
            update[i]->level[i].span++;
        }
        // 设置新节点的后退指针
        x->backward = (update[0] == zsl->header) ? NULL : update[0];
        if (x->level[0].forward)
        //x的后一个节点的后退指针
            x->level[0].forward->backward = x;  
        else
            zsl->tail = x;  
            // 跳跃表的节点计数增一
        zsl->length++;  
        return x;
    }
    

    大致步骤如下:

    • update数组存储每一层位于插入节点前的节点
    • rank数组存储每一层位于插入节点的前一个节点的排名
    • 创建节点X,调整X的level数组,调整update数组中节点的span,forward
    • 设置新节点后退指针,zsl长度加1,返回

    计算节点最大层数

    在为节点计算层数时,每次循环有0.25的概率将层数增加1
    选择0.25而不是0.5是因为前者能提供更好的常数因子
    在这里插入图片描述
    注意:排名rank指的就是当前节点是第几个节点

    删除节点

    删除节点时,对节点x每层的前驱节点的查找过程与插入节点一致,由于可能有多个节点有同样的score,需要再对比一下object对象是否一致才能进行节点删除操作,删除时先将节点从跳表中移除,再释放节点占用的内存。

    int zslDelete(zskiplist *zsl, double score, robj *obj) {
        zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
        int i;
    
        x = zsl->header;
        for (i = zsl->level-1; i >= 0; i--) {
            while (x->level[i].forward &&
                (x->level[i].forward->score < score ||
                    (x->level[i].forward->score == score &&
                    compareStringObjects(x->level[i].forward->obj,obj) < 0)))
                x = x->level[i].forward;
            update[i] = x;
        }
        /* We may have multiple elements with the same score, what we need
         * is to find the element with both the right score and object. */
        x = x->level[0].forward;
        if (x && score == x->score && equalStringObjects(x->obj,obj)) {
            zslDeleteNode(zsl, x, update);    //将节点x从跳表中移出
            zslFreeNode(x);    //释放x
            return 1;
        }
        return 0; /* not found */
    }
    

    移除待删节点

    通过前面的删除函数,我们得到了待删节点x, x在每层的前一个节点数组update
    移除时,更新前一个节点在每层的跨度、前进指针,如果x不是尾节点,更新其后一个节点的后退指针,否则重设尾节点,最后更新最大层数(当最高层没有连接到任何节点时)及节点个数

    void zslDeleteNode(zskiplist *zsl, zskiplistNode *x, zskiplistNode **update) {
        int i;
        for (i = 0; i < zsl->level; i++) {
            if (update[i]->level[i].forward == x) {
                update[i]->level[i].span += x->level[i].span - 1;
                update[i]->level[i].forward = x->level[i].forward;
            } else {
                update[i]->level[i].span -= 1;
            }
        }
        if (x->level[0].forward) {
            x->level[0].forward->backward = x->backward;
        } else {
            zsl->tail = x->backward;
        }
        while(zsl->level > 1 && zsl->header->level[zsl->level-1].forward == NULL)
            zsl->level--;
        zsl->length--;
    }
    

    释放节点

    释放时,减少被存储对象的引用计数,并释放节点本身占用的内存

    void zslFreeNode(zskiplistNode *node) {
        decrRefCount(node->obj);
        zfree(node);
    }
    
    • 23
      点赞
    • 86
      收藏
      觉得还不错? 一键收藏
    • 4
      评论

    “相关推荐”对你有帮助么?

    • 非常没帮助
    • 没帮助
    • 一般
    • 有帮助
    • 非常有帮助
    提交
    评论 4
    添加红包

    请填写红包祝福语或标题

    红包个数最小为10个

    红包金额最低5元

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

    抵扣说明:

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

    余额充值