前言
有序集合在我们的日常生活中非常常见,比如根据成绩对学生进行排名、根据得分对游戏玩家进行排名等。对于有序集合的底层实现,我们可以使用数组、链表、平衡树等结构。数组不便于元素的插入和删除;链表的查询效率低,需要遍历所有元素;平衡树或者红黑树等结构虽然效率高但实现复杂。
因此,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》 中描述的算法实现的。做了一下几点改动:
- 允许重复分数;
- 比较不仅会涉及键,还可能涉及节点数据(键相等时)。
- 有一个后退指针,所以是一个双向链表,便于实现
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.h 和 t_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
的内存大小与 level
个 zskiplistLevel
的内存大小之和。
再将节点的 score
和 ele
分别赋值。
插入节点
插入节点这块比较重要,也比较难懂,我们仔细学习一下。
首先附上插入节点代码。
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 指针等等。
在下图中,我用红色标出哪些位置受了影响需要修改。
因此我们把插入节点的步骤总为如下几点:
- 查找要插入的位置;
- 调整跳跃表高度;
- 插入节点,并调整受影响节点每层的 forward 指针和 span;
- 调整 backward。
现在我们来思考如下几个问题:
-
为什么需要先查找要插入的位置,然后再调整跳跃表的高度?
因为我们是根据跳跃表高度来查找节点的,首先我们要找到最高的一层,然后一层一层向下查找,直到找到节点。当新插入的节点的 level 比跳跃表的 level 大的时候,如果先调整跳跃表高度,然后我们就会以调整后的高度为起点,然后向后查找,但是该层的 forward 指针指向 NULL,我们是找不到节点的。
-
如何调整受影响节点和新插入节点每层的 forward 指针和 span?
- 我们应该按层来寻找受影响节点,即插入节点之前每层的最后一个节点,受我们需要把这些节点记录下来,方便后面修改,代码中记录为 update[] 数组;
- 新插入节点每层的 forward 指针指向该层前一个节点的 forward 指针指向的节点;
- 每层受影响节点的 forward 指针则指向新插入的节点;
- 我们需要利用 span 的值,需要能够计算 update[] 节点与新插入节点 X 之间的距离,这个不好算的话,我们就计算 update[] 节点与新插入节点 X 的前一个节点 X-1 之间的距离,再加 1 就是到 X 的距离。
- 这个距离怎么算呢,我们可以以 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;
这段代码说明了我们寻找节点插入位置的两条比较原则:
- 当前节点本层存在下一个节点 && 下一节点的评分小于待插入节点的评分
- 当前节点本层存在下一个节点 && 下一节点的评分等于待插入节点的评分 && 下一节点的值的字段排序小于待插入节点的值的字典排序
即我们提到的,评分不相等时比较评分,评分相等值比较值的字典排序,不会出现两个都相等的情况。
接着记录步长 rank[i]
,rank[i]
的值即为当前节点的步长 rank[i]
加上该节点到下一节点的跨度 x->level[i].span
。
节点向前移动到下一个节点。
当一层走完循环之后,此时应该满足两种情况:
x->forward == NULL
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 赋值后跳跃表如下:
获取新节点层高
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-p)。
- 节点层高为 2 的概率为 p(1-p)。
- 节点层高为 3 的概率为 p^2(1-p)。
- ……
- 节点层高为 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;
跳跃表的层高赋值为最高的层高,这是没有问题的。
我们接着以该图为例:
第 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 赋值过程。
首先,我们应该对一下几点有所了解:
- rank[i] 表示当前层从 header 节点到 update[i] 节点所经历的步长。
- rank[0] 表示第 0 层 从 header 节点到 update[0] 节点所经历的步长,上图 rank[0] = 2。
- rank[1] 表示第 1 层 从 header 节点到 update[1] 节点所经历的步长,上图 rank[1] = 1。
- 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 循环,插入过程如下:
-
第一次 for 循环
- x 的 level[0] 的 forward 为 update[0] 的 level[0] 的 forward 节点,即 x->level[0].forward 为 score=41 的节点。
- update[0] 的 level[0] 的下一个节点为新插入的节点。
- rank[0]-rank[0]=0,update[0]->level[0].span=1,所以 x->level[0].span=1。
- update[0]->level[0].span=0+1=1。
插入节点并更新第 0 层后的跳跃表如下图所示:
-
第二次 for 循环
- x 的 level[1] 的 forward 为 update[1] 的 level[1] 的 forward 节点,即 x->level[1].forward 为 NULL。
- update[1] 的 level[1] 的下一个节点为新插入的节点。
- rank[0]-rank[1]=1,update[1]->level[1].span=2,所以 x->level[1].span=1。
- update[1]->level[1].span=1+1=2。
插入节点并更新第 1 层后的跳跃表如下图所示:
-
第三次 for 循环
- x 的 level[2] 的 forward 为 update[2] 的 level[2] 的 forward 节点,即 x->level[2].forward 为 NULL。
- update[2] 的 level[2] 的下一个节点为新插入的节点。
- rank[0]-rank[2]=2,因为 update[2]->level[2].span=3,所以 x->level[2].span=1。
- update[2]->level[2].span=2+1=3。
插入节点并更新第 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.
插入新节点后的跳跃表如下图所示:
删除节点
有了上面插入节点的学习,对于节点的删除,我们应该更容易理解了。
我们把删除节点简单的分为两步:
- 查找需要删除的节点;
- 设置 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 的赋值如下图所示:
假设我们想要删除 score=21 的节点,那么 update[0] 和 update[1] 应该为 score=1 的节点,update[2] 应该为头节点。
现更新节点的 span 和 forward 分为两种情况:
-
update[i] 的第 i 层的 forward 节点指向 x(如上图 update[0]->level[0])
update[0].level[0].span
是 update[0] 到 x 的距离;x.level[0].span
是 x 到 x.level[0].forward 之间的距离;update[0].level[0].span + x.level[0].span
是 update[0] 到 x.level[0].forward 之间的距离;update[0].level[0].span + x.level[0].span - 1
是删除 x 节点后 update[0] 到 x.level[0].forward 之间的距离;update[0].level[0].forward
即为 x.level[0].forward。
-
update[i] 的第 i 层的 forward 节点指向 x(如上图 update[1]->level[1])
- 此时 update[i].level[i].forward 指向 x 节点后面的节点或 NULL;
- 说明 update[i] 的层高比 x 节点的层高高,因此不需要修改 forward 值,只需要将 span - 1 即可。
设置 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.c 的zaddGenericCommand
函数中。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[] 数组,我们需要对这两个数组特别理解,才能理解插入过程。
看到这了,我们不妨问自己几个问题:
- 什么是跳跃表?跳跃表是如何从有序链表演化过来?时间复杂度是多少?
- 跳跃表是如何维持链表的平衡的?(关键点:随机函数产生层数,层数越高概率越低)
- 跳跃表是如何插入节点的?(关键点:update[] 和 rank[] 数组,update[] 数组记录插入节点前每层的节点,rank[] 数组记录头结点到 update[] 节点之间的距离)
- 跳跃表的结构?(关键点:length、level、header、tail)
- 跳跃表节点结构?(关键点:score、backward、ele、level)
- redis 中 zset 的实现用到了哪些数据结构?什么时候用到压缩列表?什么时候用到跳跃表?(关键点:entries=0,value>128)
- redis 为什么选择跳跃表而不选择其他平衡结构?(关键点:效率堪比红黑树,实现却更简单)
如果大家能够对这些问题解答出来,相信大家已经对跳跃表了如指掌了。
参考文档
- redis 跳跃表(skiplist)的实现
- 《Redis 5 设计与源码分析》—— 陈雷编著
- 《Redis 的设计与实现》—— 黄健宏著