Redis 数据结构之跳跃表(skiplist)

2,255 阅读27分钟

前言

有序集合在我们的日常生活中非常常见,比如根据成绩对学生进行排名、根据得分对游戏玩家进行排名等。对于有序集合的底层实现,我们可以使用数组、链表、平衡树等结构。数组不便于元素的插入和删除;链表的查询效率低,需要遍历所有元素;平衡树或者红黑树等结构虽然效率高但实现复杂。

因此,Redis 采用了一种新的数据结构——跳跃表。跳跃表的效率堪比红黑树,但是其实现却远比红黑树简单。

下面就开始我们今天的学习之旅!

基础知识

按照惯例,我们将下文中的涉及到的一些概念,在这里做一下简单介绍,方便后面的学习。

&0xFFFF

0x 是一种标识,用来表示 16 进制。

F 是 16 进制中的 15,其二进制表示为 1111

FFFF 即 1111 1111 1111 1111

&0xFFFF 即与 0xFFFF 做位运算,只取低 16 位。

简介

跳跃表是 zset (有序集合)的基础数据结构。跳跃表可以高效的保持元素有序,并且实现相对平衡树简单、直观。Redis 的跳跃表是基于 William Pugh 在 《Skip lists: a probabilistic alternative to balanced trees》 中描述的算法实现的。做了一下几点改动:

  1. 允许重复分数;
  2. 比较不仅会涉及键,还可能涉及节点数据(键相等时)。
  3. 有一个后退指针,所以是一个双向链表,便于实现 zrevrange 等命令。

跳跃表的演变过程

skiplist,首先它是一个 list。实际上,它是在有序链表的基础上发展起来的。

普通有序链表

我们先来看一下有序链表,有序链表是所有元素以递增或递减方式有序排列的数据结构,其中每个节点又有指向下个节点的 next 指针,最后一个节点的 next 指针指向 NULL。递增有序链表示例如下:

递增有序链表

如图所示,如果我们想要查询值为 61 的元素,我们需要从第一个元素开始依次向后查找、比较才可以找到,查找的顺序为 1 -> 11 -> 21 -> 31 -> 41 -> 51 -> 61,共 7 次比较,时间复杂度为 O(N)。有序链表的插入和删除操作都需要先找到合适的位置再修改 next 指针,修改操作基本不消耗时间,所以插入、删除、修改有序链表的耗时主要在查找元素上。

普通有序链表的第一次演变

假如我们 每相邻两个节点增加一个指针,让指针指向下下节点,如下图所示:

二层有序递增链表

新增加的指针连成了一个新的链表,但是它包含的节点个数只有原来的一半(1,21,41,61)。现在当我们想要查找 61 的时候,我们就沿着这个新链表进行查找(绿色指针方向)。查找的顺序为 1 -> 21 -> 41 -> 61,共 4 次比较,需要比较的次数大概只有原来的一半

普通有序链表的第二次演变

利用同样的方式,我们可以在上层新产生的链表上,继续为每相邻的两个节点增加一个指针,从而查看第三层链表,如下图所示:

三层有序链表

新增加的指针连成了一个新的链表,它包含的节点个数只有第二层的一半(1,41)。现在当我们想要查找 61 的时候,我们沿着新链表进行查找(红色指针方向)。查找顺序为 1 -> 41,此时我们发现 41 的 next 指针指向 null,我们就开始从 41 节点的下一层开始查找(绿色指针方向),即 41 -> 61,连起来就是 1-> 41 -> 61,总共比较了 3 次,相比于上次查找又少了一次。当数据量大的时候,这种优势会更加明显

普通有序链表演变成 Redis 的 skiplist

skiplist 正是受这种 多层链表 的想法启发设计得来的。

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

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

skiplist 为了避免这一问题,它 不要求上下相邻两层链表之间的节点个数有严格的对应关系,而是为每个节点随机出一个层数(level),新插入的节点就会根据自己的层数决定该节点是否在这层的链表上

跳跃表节点与结构

从上面我们可以知道,跳跃表由多个节点构成,每个节点由很多层构成,每层都有指向本层的下个节点的指针。

跳跃表主要涉及 server.ht_zset.c 两个文件,其中在 server.h 中定义了跳跃表的数据结构,在 t_zset.c 中定义了跳跃表的节本操作。

接下来,让我们一起来看一下跳跃表具体是如何实现的。

跳跃表节点

typedef struct zskiplistNode {
    sds ele;
    double score;
    struct zskiplistNode *backward;
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned long span;
    } level[];
} zskiplistNode;

该结构体包含如下属性:

  • ele:SDS 类型,用于存储字符串类型的数据。
  • score:用于存储排序的分值。
  • backward:后退指针,只能指向当前节点最底层的前一个节点,头结点和第一个节点——backward 指向 NULL,从后向前遍历跳跃表使用。
  • level:节点层数,为 柔性数组,每个节点的数组长度不一样(因为层数不一样)。在生成跳跃表节点时,随机生成 1~64 的值,值越大出现的概率越低。

level 数组中的 每项 包含以下两个元素:

  • forward:指向本层的下一个节点,尾结点的 forward 指向 NULL。
  • span:forward 指向的节点与本节点之间的元素个数。span 值越大,跳过的节点个数越多。(相邻两个节点之间,前一个节点的 span 为 1)

跳跃表是 Redis 有序集合的底层实现方式之一。所以每个节点的 ele 存储有序集合的成员 member 值,score 存储成员 score 值。所有节点的分值是按从小到大的方式排序的,当有序集合的成员分值相同时,节点会按 member 的字典序进行排序。

跳跃表结构

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;
    int level;
} zskiplist;

该结构体中包含如下属性:

  • header:指向跳跃表头结点。头结点是跳跃表中的一个特殊节点,它的 level 数组元素个数为 64。头节点在有序集合中不存储任何 member 值和 score 值,ele 值为 NULL,score 值为 0;也不计入跳跃表总长度。头节点在初始化时,64 个元素的 forward 都指向 NULL,span 值都为 0
  • tail:指向跳跃表尾节点。
  • length:跳跃表长度,表示除头节点外的节点总数。
  • level:跳跃表的高度。

通过跳跃表结构体的属性我们可以看到,程序可以在 O(1) 的时间复杂度下,快速获取到跳跃表的头结点、尾节点、长度和高度。

基本操作

我们已经知道了跳跃表节点和跳跃表结构体的定义,接下来我们再看一下跳跃表的创建、插入、查找和删除操作。

创建跳跃表

zskiplist *zslCreate(void) {
    int j;
    zskiplist *zsl;

    zsl = zmalloc(sizeof(*zsl)); //初始化内存空间
    zsl->level = 1; //将层数设置为最小的 1 
    zsl->length = 0; //将长度设置为 0
    zsl->header = zslCreateNode(ZSKIPLIST_MAXLEVEL,0,NULL); //创建跳跃表头节点,层数为 ZSKIPLIST_MAXLEVEL=64 层
    for (j = 0; j < ZSKIPLIST_MAXLEVEL; j++) { //依次给头节点的每层赋值
        zsl->header->level[j].forward = NULL;
        zsl->header->level[j].span = 0;
    }
    zsl->header->backward = NULL; //头节点的回退指针设置为 NULL
    zsl->tail = NULL; //尾节点设置为 NULL
    return zsl;
}

可以看到,跳跃表的创建过程如下:

首先声明一块大小为 sizeof(zskiplist) 的内存空间。

然后将层高 level 设置为 1,将跳跃表长度 length 设置为 0。然后创建头节点 header,其中 ZSKIPLIST_MAXLEVEL 的定义如下:

#define ZSKIPLIST_MAXLEVEL 64 

代表层节点最高为 64 层,而我们的头结点正是最高的层数。

头节点是一个特殊的节点,不存储有序集合的 member 信息。头节点是跳跃表中第一个插入的节点,其 level 数组的每项 forward 都 为NULL,span 值都为 0。

接着将头节点的回退指针 backward 和尾指针 tail 设置为 NULL。

这些都很好理解,就是初始化内存,然后依次将跳跃表结构体各个成员设置默认值。

创建跳跃表节点

创建跳跃表节点代码如下:

zskiplistNode *zslCreateNode(int level, double score, sds ele) {
    zskiplistNode *zn =
        zmalloc(sizeof(*zn)+level*sizeof(struct zskiplistLevel)); //申请 zskiplistNode + 柔型数组(多层)大小的空间
    zn->score = score; //设置节点分支
    zn->ele = ele; //设置节点数据
    return zn;
}

创建跳跃表节点的代码也很好理解。

首先分配内存空间,这个空间大小为 zskiplistNode 的大小和 level 数组的大小。

zskiplistNode 结构体的最后一个元素为柔性数组,申请内存时需要指定柔性数组的大小,一个节点占用的内存大小为 zskiplistNode 的内存大小与 levelzskiplistLevel 的内存大小之和。

再将节点的 scoreele 分别赋值。

插入节点

插入节点这块比较重要,也比较难懂,我们仔细学习一下。

首先附上插入节点代码。

zskiplistNode *zslInsert(zskiplist *zsl, double score, sds ele) {
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x; // update[] 数组用于存储被插入节点每层的前一个节点
    unsigned int rank[ZSKIPLIST_MAXLEVEL]; // rank[] 数组记录当前层从 header 节点到 update[i] 节点所经历的步长。
    int i, level;

    serverAssert(!isnan(score));
    x = zsl->header; //遍历的节点,由于查找被插入节点每层的前一个节点
    for (i = zsl->level-1; i >= 0; i--) { //从上到下遍历
        rank[i] = i == (zsl->level-1) ? 0 : rank[i+1]; //给rank[] 数组初始值赋值,最上层从 header 节点开始,所以为 0,下面的每层都是从上一层走到的最后一个节点开始,所以为 rank[i+1]
        while (x->level[i].forward &&
                (x->level[i].forward->score < score ||
                    (x->level[i].forward->score == score &&
                    sdscmp(x->level[i].forward->ele,ele) < 0))) //前进的规则存在 forward 节点且(forward 节点评分小于待插入节点评分 || (forward 节点评分等于待插入节点评分 && forward 节点元素字典值小于待插入节点元素字典值))
        {
            rank[i] += x->level[i].span; //加上 x 的跨度
            x = x->level[i].forward; //节点向前前进
        }
        update[i] = x; // 将被插入节点当前层的前一个节点记录在 update[] 数组中
    }
    level = zslRandomLevel(); //随机生成一个层高
    if (level > zsl->level) { //新生成节点的层高比当前跳跃表层高大事
        for (i = zsl->level; i < level; i++) { //只更新高出的部分
            rank[i] = 0; //因为是头结点,所以为 0
            update[i] = zsl->header; //该层只有头结点
            update[i]->level[i].span = zsl->length; //因为 forward 指向 NULL,所以跨度应该是跳跃表所有的节点,所以 span 为跳跃表的长度
        }
        zsl->level = level; //更新跳跃表的层高
    }
    x = zslCreateNode(level,score,ele); // x 被赋值成新创建的节点
    for (i = 0; i < level; i++) {
        x->level[i].forward = update[i]->level[i].forward; //更新 x 节点的 level[i] 层的 forward 指针
        update[i]->level[i].forward = x; //更新 update[i] 节点的 level[i] 层的 forward 指针

        x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]); //更新 x 节点的 level[i] 层的跨度 span
        update[i]->level[i].span = (rank[0] - rank[i]) + 1; //更新 update[i] 节点的 level[i] 层的跨度 span
    }

    for (i = level; i < zsl->level; i++) { //当新插入节点的层高比跳跃表的层高小时,需要更新少的几层的 update[] 节点的跨度,即 +1
        update[i]->level[i].span++;
    }

    x->backward = (update[0] == zsl->header) ? NULL : update[0]; //更新 x 的 backward 指针,如果 update[0] 是头结点则为 NULL,否则为 update[0]
    if (x->level[0].forward) // 更新 x 节点第 0 层有后续节点,则后面节点的 backward 指向 x 节点,否则的话 x 节点为最后一个节点,需要将 tail 指针指向 x  节点
        x->level[0].forward->backward = x;
    else
        zsl->tail = x;
    zsl->length++; //跳跃表的长度 +1
    return x;
}

我们来看一下跳跃表实现过程示意图:

跳跃表实现过程示意图

假设我们想插入一个节点 45。我们首先需要找到插入的位置,然后再更改因为节点插入导致受影响的位置,比如跳跃表的 level,前一个节点的每层的 forward 指针等等。

在下图中,我用红色标出哪些位置受了影响需要修改。

插入节点后跳跃表受影响位置

因此我们把插入节点的步骤总为如下几点:

  1. 查找要插入的位置;
  2. 调整跳跃表高度;
  3. 插入节点,并调整受影响节点每层的 forward 指针和 span;
  4. 调整 backward。

现在我们来思考如下几个问题:

  1. 为什么需要先查找要插入的位置,然后再调整跳跃表的高度?

    因为我们是根据跳跃表高度来查找节点的,首先我们要找到最高的一层,然后一层一层向下查找,直到找到节点。当新插入的节点的 level 比跳跃表的 level 大的时候,如果先调整跳跃表高度,然后我们就会以调整后的高度为起点,然后向后查找,但是该层的 forward 指针指向 NULL,我们是找不到节点的。

  2. 如何调整受影响节点和新插入节点每层的 forward 指针和 span?

    1. 我们应该按层来寻找受影响节点,即插入节点之前每层的最后一个节点,受我们需要把这些节点记录下来,方便后面修改,代码中记录为 update[] 数组;
    2. 新插入节点每层的 forward 指针指向该层前一个节点的 forward 指针指向的节点;
    3. 每层受影响节点的 forward 指针则指向新插入的节点;
    4. 我们需要利用 span 的值,需要能够计算 update[] 节点与新插入节点 X 之间的距离,这个不好算的话,我们就计算 update[] 节点与新插入节点 X 的前一个节点 X-1 之间的距离,再加 1 就是到 X 的距离。
      1. 这个距离怎么算呢,我们可以以 header 为基准,计算 update[] 节点到 header 节点之间的距离,相减就得到了 update[i] 与 update[0] 之间的距离。

按照上述思路,接下来让我们逐步研究插入节点代码。

变量定义

    zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
    unsigned int rank[ZSKIPLIST_MAXLEVEL];
    int i, level;

定义两个数组:

  • update[]:插入节点时,需要更新被插入节点每层的前一个节点。由于每层更新的节点不一样,所以讲每层需要更新的节点记录在 update[i] 中。
  • rank[]:记录当前层从 header 节点到 update[i] 节点所经历的步长,在更新 update[i] 的 span 和设置新插入节点的 span 时用到。

查找插入位置

    x = zsl->header;
    for (i = zsl->level-1; i >= 0; i--) { //从最高层开始向下遍历
        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 &&
                    sdscmp(x->level[i].forward->ele,ele) < 0)))
        {
            rank[i] += x->level[i].span;
            x = x->level[i].forward;
        }
        update[i] = x;
    }

按照上述代码逻辑,值为 25、35、45 的节点查找插入位置的查找路线如下图所示:

节点查找路线

接下来我们一步一步分析代码。

    for (i = zsl->level-1; i >= 0; i--) 

for 循环的起始值为 zsl->level-1,正验证了上面我们所说的,节点查询要从最高层开始查找,查找不到再从下一层开始查询。

        rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];

rank[] 数组的作用是记录当前层从 header 节点到 update[i] 节点所经历的步长。

从上图我们可以看到,节点查找路线是 “右->下->右->下” 这种的。

在最高层的时候,我们的起始位置肯定是 header 节点,此时该节点与 header 节点之间的距离为 0,所以 rank[zsl->level-1] 的值肯定为 0。

当我们向下层走的时候,实际上是从上面一层查到的最后一个节点下来的,比如上图中查找值为 45 的节点的时候,当我们从第四层下到第三层的时候,是从 41 节点开始查的,rank[2] 的值同第四层的值 rank[3]。

        while (x->level[i].forward &&
                (x->level[i].forward->score < score ||
                    (x->level[i].forward->score == score &&
                    sdscmp(x->level[i].forward->ele,ele) < 0)))
        {
            rank[i] += x->level[i].span;
            x = x->level[i].forward;
        }
        update[i] = x;

这段代码说明了我们寻找节点插入位置的两条比较原则:

  1. 当前节点本层存在下一个节点 && 下一节点的评分小于待插入节点的评分
  2. 当前节点本层存在下一个节点 && 下一节点的评分等于待插入节点的评分 && 下一节点的值的字段排序小于待插入节点的值的字典排序

即我们提到的,评分不相等时比较评分,评分相等值比较值的字典排序,不会出现两个都相等的情况。

接着记录步长 rank[i]rank[i] 的值即为当前节点的步长 rank[i] 加上该节点到下一节点的跨度 x->level[i].span

节点向前移动到下一个节点。

当一层走完循环之后,此时应该满足两种情况:

  1. x->forward == NULL
  2. x->forward != NULL && (x->forward.score > score || (x->forward.score == score && sdscmp(x->level[i].forward->ele,ele) > 0))

此时我们应该向从下一层开始寻找了,那么我们应该记住受影响的节点,也是插入节点每层的前一个节点 update[i] = x

循环直到第一层结束,此时我们已经找到了要插入的位置,并将插入节点每层的前一个节点记录在 update[] 数组中,并将 update[] 数组中每个节点到 header 节点所经历的步长也记录了下来。

我们以 length=3 level=2 的一个跳跃表插入节点为例,update 和 rank 赋值后跳跃表如下:

update 和 rank 赋值后的跳跃表

获取新节点层高

    level = zslRandomLevel();

每个节点的层高是随机生成的,即所谓的 概率平衡,而不是 强制平衡,因此,对于插入和删除节点比传统上的平衡树算法更为简洁高效

生成方法如下:

int zslRandomLevel(void) {
    int level = 1;
    while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF)) //ZSKIPLIST_P=0.25
        level += 1;
    return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}

上述代码中,level 的初始值为 1,通过 while 循环,每次生成一个随机值,取这个值的低 16 位作为 x,当 x 小于 0.25 倍的 0XFFFFFF 时,level 的值加 1;否则退出 while 循环,最终返回 level 和 ZSKIPLIST_MAXLEVEL 两者中的最小值。

下面计算节点的期望层高。假设 p = ZSKIPLIST_P;

  1. 节点层高为 1 的概率为 (1-p)。
  2. 节点层高为 2 的概率为 p(1-p)。
  3. 节点层高为 3 的概率为 p^2(1-p)。
  4. ……
  5. 节点层高为 n 的概率为 p^n-1(1-p)。

所以节点的期望层高为:

跳跃表节点层数生成概率

当 p=0.25 时,跳跃表节点的期望层高为 1/(1-0.25)≈1.33。

更新跳跃表层高以及 update[]、rank[] 数组

    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;
    }

只有当待插入节点的层高比当前跳跃表的层高大时,才会进行该操作。

zsl->level = level; 跳跃表的层高赋值为最高的层高,这是没有问题的。

我们接着以该图为例:

update 和 rank 赋值后的跳跃表

第 0 层和第 1 层我们已经更新过了,因此我们只需要从未更新过的层开始即可,即 i = zsl->level;,从第 2 层开始。第 2 层只需要更新 header 节点,所以 update[i] = zsl->header。而 rank[i] 则为 0。

update[2]->level[2].span 的值先赋值为跳跃表的总长度,后续在计算新插入节点 level[2]span 时会用到此值。在更新完新插入节点 level[2]span 之后会对 update[2]->level[2].span 的值进行重新计算赋值。

至于为什么将 update[2]->level[2].span 的值设置为跳跃表的总长度,我们可以从 span 的定义来思考。span 的含义是 forward 指向的节点与本节点之间的元素个数。而 update[2]->level[2].forward 指向的是 NULL 节点,中间隔着的是跳跃表的所有节点,因此赋值为跳跃表的总长度。

调整高度后的跳跃表如下图所示:

调整高度后的跳跃表

插入节点

    x = zslCreateNode(level,score,ele);
    for (i = 0; i < level; i++) {
        x->level[i].forward = update[i]->level[i].forward; 
        update[i]->level[i].forward = x;

        x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);
        update[i]->level[i].span = (rank[0] - rank[i]) + 1;
    }
    
    for (i = level; i < zsl->level; i++) {
        update[i]->level[i].span++;
    }

forward 值的修改很好理解,就是简单的链表插入节点。

那么如何理解 update[i]->level[i].span - (rank[0] - rank[i])(rank[0] - rank[i]) + 1 呢?

我们对照下图来深入理解一下 span 赋值过程。

span 赋值示意图

首先,我们应该对一下几点有所了解:

  1. rank[i] 表示当前层从 header 节点到 update[i] 节点所经历的步长
  2. rank[0] 表示第 0 层 从 header 节点到 update[0] 节点所经历的步长,上图 rank[0] = 2。
  3. rank[1] 表示第 1 层 从 header 节点到 update[1] 节点所经历的步长,上图 rank[1] = 1。
  4. level[0] 中的 span 应该总为 1(可以理解为 1 指的是包括 forward 指向的节点,不包括本身节点)

我们以 update[1] 节点举例,其他节点原理也是如此。

所以,rank[0] - rank[1] 实际上就是节点 update[1]update[0] 的距离(score=1 的节点到 score=21 的节点的距离)

update[1]->level[1].span 的值表示在第一层 update[1] 节点与指向的节点之间的跨度,从上图我们可以看到,这段距离中包含 update[1]update[0] 的距离,剩下的距离就是 新插入节点到 update[1]->level[1].forward 节点之间的距离

因为新插入的节点是在 update[0] 后面插入的,因此 update[0]新插入节点 之间的距离为 1,rank[0] - rank[1] + 1 即为 update[1]->level[1].span 的值。

我们把问题抽象化一下:

假设有节点 A 和 B,在他们中间插入 X,

  • rank[0] - rank[i] 计算的就是 A 到 X 的前一个节点 X-1 的距离;
  • update[i]->level[i].span 计算的就是 A 到 B 的距离;
  • update[i]->level[i].span - (rank[0] - rank[i]) 计算的就是 X 到 B 的距离。
  • update[i]->level[i].span = (rank[0] - rank[i]) + 1 计算的是 A 到 X-1 再 +1,表示的是 A 到 X 的距离。

计算的原则是 左开右闭

按照上述算法,我们来实际走一遍插入过程。level 的值为 3,所以可以执行三次 for 循环,插入过程如下:

  1. 第一次 for 循环

    1. x 的 level[0] 的 forward 为 update[0] 的 level[0] 的 forward 节点,即 x->level[0].forward 为 score=41 的节点。
    2. update[0] 的 level[0] 的下一个节点为新插入的节点。
    3. rank[0]-rank[0]=0,update[0]->level[0].span=1,所以 x->level[0].span=1。
    4. update[0]->level[0].span=0+1=1。

    插入节点并更新第 0 层后的跳跃表如下图所示:

    插入节点后并更新第 0 层后的跳跃表

  2. 第二次 for 循环

    1. x 的 level[1] 的 forward 为 update[1] 的 level[1] 的 forward 节点,即 x->level[1].forward 为 NULL。
    2. update[1] 的 level[1] 的下一个节点为新插入的节点。
    3. rank[0]-rank[1]=1,update[1]->level[1].span=2,所以 x->level[1].span=1。
    4. update[1]->level[1].span=1+1=2。

    插入节点并更新第 1 层后的跳跃表如下图所示:

    插入节点后并更新第 1 层后的跳跃表

  3. 第三次 for 循环

    1. x 的 level[2] 的 forward 为 update[2] 的 level[2] 的 forward 节点,即 x->level[2].forward 为 NULL。
    2. update[2] 的 level[2] 的下一个节点为新插入的节点。
    3. rank[0]-rank[2]=2,因为 update[2]->level[2].span=3,所以 x->level[2].span=1。
    4. update[2]->level[2].span=2+1=3。

    插入节点并更新第 2 层后的跳跃表如下图所示:

    插入节点后并更新第 2 层后的跳跃表

新插入节点的高度大于原跳跃表高度,所以下面代码不会运行。但如果新插入节点的高度小于原跳跃表高度,则从 level 到 zsl->level-1 层的 update[i] 节点 forward 不会指向新插入的节点,所以不用更新 update[i] 的 forward 指针,只将这些 level 层的 span 加 1 即可。

    for (i = level; i < zsl->level; i++) {
        update[i]->level[i].span++;
    }

调整 backward

    x->backward = (update[0] == zsl->header) ? NULL : update[0];
    if (x->level[0].forward)
        x->level[0].forward->backward = x;
    else
        zsl->tail = x;
    zsl->length++;
    return x;

根据 update 的赋值过程,新插入节点的前一个节点一定是 update[0],由于每个节点的后退指针只有一个,与此节点的层数无关,所以当插入节点不是最后一个节点时,需要更新被插入节点的 backward 指向 update[0]。如果新插入节点是最后一个节点,则需要更新跳跃表的尾结点为新插入节点。插入及诶单后,更新跳跃表的长度加 1.

插入新节点后的跳跃表如下图所示:

插入新节点后的跳跃表

删除节点

有了上面插入节点的学习,对于节点的删除,我们应该更容易理解了。

我们把删除节点简单的分为两步:

  1. 查找需要删除的节点;
  2. 设置 span 和 forward。

删除节点代码如下:

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] 的 forward 节点是 x 的情况,需要更新 span 和 forward
            update[i]->level[i].span += x->level[i].span - 1;
            update[i]->level[i].forward = x->level[i].forward;
        } else {// update[i].level[i] 的 forward 节点不是 x 的情况,只需要更新 span
            update[i]->level[i].span -= 1;
        }
    }
    if (x->level[0].forward) { // 如果 x 不是尾节点,更新 backward 节点
        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--; //更新跳跃表 level
    zsl->length--; // 更新跳跃表长度
}

查找需要删除的节点

查找需要删除的节点要借助 update 数组,数组的赋值方式与 插入节点 中的 update 的赋值方式相同,不再赘述。查找完毕之后,update[2]=header,update[1] 为 score=1 的节点,update[0] 为 score=21 的节点。删除节点前的跳跃表如下图所示:

删除节点前的跳跃表

设置 span 和 forward

设置 span 和 forward 的代码如下:

    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;
        }
    }

我们先来看 span 的赋值过程。删除节点时 span 的赋值如下图所示:

删除节点时 span 赋值解释

假设我们想要删除 score=21 的节点,那么 update[0] 和 update[1] 应该为 score=1 的节点,update[2] 应该为头节点。

现更新节点的 span 和 forward 分为两种情况:

  1. update[i] 的第 i 层的 forward 节点指向 x(如上图 update[0]->level[0])

    1. update[0].level[0].span 是 update[0] 到 x 的距离;
    2. x.level[0].span 是 x 到 x.level[0].forward 之间的距离;
    3. update[0].level[0].span + x.level[0].span 是 update[0] 到 x.level[0].forward 之间的距离;
    4. update[0].level[0].span + x.level[0].span - 1 是删除 x 节点后 update[0] 到 x.level[0].forward 之间的距离;
    5. update[0].level[0].forward 即为 x.level[0].forward。
  2. update[i] 的第 i 层的 forward 节点指向 x(如上图 update[1]->level[1])

    1. 此时 update[i].level[i].forward 指向 x 节点后面的节点或 NULL;
    2. 说明 update[i] 的层高比 x 节点的层高高,因此不需要修改 forward 值,只需要将 span - 1 即可。

设置 span 和 forward 后的跳跃表如下图所示:

设置 span 和 forward 后的跳跃表

update 节点更新完毕之后,需要更新 backward 指针、跳跃表高度和长度、如果 x 不为最后一个节点,之间将第 0 层后一个节点的 backward 赋值为 x 的backward 即可;否则,将跳跃表的尾指针指向 x 的 backward 节点即可。代码如下:

    if (x->level[0].forward) {
        x->level[0].forward->backward = x->backward;
    } else {
        zsl->tail = x->backward;
    }

当删除的 x 节点是跳跃表的最高节点,并且没有其他节点与 x 节点的高度相同时,需要将跳跃表的高度减 1。

由于删除了一个节点,跳跃表的长度需要减 1。

删除节点后的跳跃表如下图所示:

删除节点后的跳跃表

删除跳跃表

删除跳跃表就比较简单了。获取到跳跃表对象之后,从头节点的第 0 层开始,通过 forward 指针逐步向后遍历,没遇到一个节点便将其释放内存。当所有节点的内存都被释放之后,释放跳跃表对象,即完成了跳跃表的删除操作。代码如下

void zslFree(zskiplist *zsl) {
    zskiplistNode *node = zsl->header->level[0].forward, *next;

    zfree(zsl->header);
    while(node) {
        next = node->level[0].forward;
        zslFreeNode(node);
        node = next;
    }
    zfree(zsl);
}

跳跃表的应用

在 Redis 中,跳跃表主要应用于有序集合的底层实现(有序集合的另一种实现方式为压缩列表)。

redis.conf 有关于有序集合底层实现的两个配置:

zset-max-ziplist-entries 128 // zset 采用压缩列表时,元素个数最大值。默认值为 128。
zset-max-ziplist-value 64 // zset 采用压缩列表时,每个元素的字符串长度最大值,默认为 64。

zset 添加元素的主要逻辑位于 t_zset.czaddGenericCommand 函数中。zset 插入第一个元素时,会判断下面两种条件:

  • zset-max-ziplist-entries 的值是否等于 0;
  • zset-max-ziplist-value 小于要插入元素的字符串长度。

满足任一条件 Redis 就会采用跳跃表作为底层实现,否则采用压缩列表作为底层实现方式。

        if (server.zset_max_ziplist_entries == 0 ||
            server.zset_max_ziplist_value < sdslen(c->argv[scoreidx+1]->ptr))
        {
            zobj = createZsetObject(); //创建跳跃表结构
        } else {
            zobj = createZsetZiplistObject(); //创建压缩列表结构
        }

一般情况下,不会将 zset_max_ziplist_entries 配置成 0,元素的字符串长度也不会太长,所以在创建有序集合时,默认是有压缩列表的底层实现。zset 新插入元素时,会判断以下两种条件:

  • zset 中元素个数大于 zset_max_ziplist_entries
  • 插入元素的字符串的长度大于 zset_max_ziplist_value

当慢如任一条件时,Redis 便会将 zset 的底层实现由压缩列表转为跳跃表,代码如下:

            if (zzlLength(zobj->ptr) > server.zset_max_ziplist_entries ||
                sdslen(ele) > server.zset_max_ziplist_value)
                zsetConvert(zobj,OBJ_ENCODING_SKIPLIST);

值得注意的是,zset 在转为跳跃表之后,即使元素被逐渐删除,也不会重新转为压缩列表。

总结

本章我们介绍了跳跃表的演变过程、基本原理、和实现过程。

演变过程就是在链表的基础上,间隔抽取一些点,在上层形成一个新的链表,类似于二分法,达到时间减半的效果,但是又不同于二分法,因为新插入的节点的层高是随机生成的,即所谓的 概率平衡,这样保证了跳跃表的查询、插入、删除的平均复杂度都为 O(logN)。

跳跃表的实现过程,我们着重讲了插入节点,其中我们引入了两个数组,update[] 和 rank[] 数组,我们需要对这两个数组特别理解,才能理解插入过程。

看到这了,我们不妨问自己几个问题:

  1. 什么是跳跃表?跳跃表是如何从有序链表演化过来?时间复杂度是多少?
  2. 跳跃表是如何维持链表的平衡的?(关键点:随机函数产生层数,层数越高概率越低)
  3. 跳跃表是如何插入节点的?(关键点:update[] 和 rank[] 数组,update[] 数组记录插入节点前每层的节点,rank[] 数组记录头结点到 update[] 节点之间的距离)
  4. 跳跃表的结构?(关键点:length、level、header、tail)
  5. 跳跃表节点结构?(关键点:score、backward、ele、level)
  6. redis 中 zset 的实现用到了哪些数据结构?什么时候用到压缩列表?什么时候用到跳跃表?(关键点:entries=0,value>128)
  7. redis 为什么选择跳跃表而不选择其他平衡结构?(关键点:效率堪比红黑树,实现却更简单)

如果大家能够对这些问题解答出来,相信大家已经对跳跃表了如指掌了。

参考文档