跳跃列表是什么
跳跃列表 skiplist 是一种有序的数据结构。它在设计上,是通过每个节点中维持多个指向其他节点的指针,达到快速访问节点的效果。
跳跃列表可以在时间复杂度为平均 O(logN) 或者最坏 O(N)两种情况下去查找节点,而且可以通过顺序性操作来批量处理了节点。
跳跃列表应用场景
双向链表、SDS、字典等数据结构都被较广泛地应用在了 Redis 的不同地方,而 Redis 中使用到跳跃列表的地方只有两个,一个是 Redis 的 Zset 数据类型,另一个是作为 Redis 集群节点中用作内部数据结构。
跳跃列表节点结构内容
在看跳跃列表结构内容之前,我们先来看跳跃列表节点结构内容:
具体结构代码:
typedef struct zskiplistNode {
// 成员对象
robj *obj;
// 分值
double score;
// 后退指针
struct zskiplistNode *backward;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
} zskiplistNode;
图中的红框就是三个跳跃列表节点
层
跳跃列表节点的 level 属性为一个数组,数组中每个元素都包含了指向其他节点的前进指针,数组的最大长度为32。程序可以通过这些层里面的前进指针加快访问其他节点的速度。
一般情况下,层的数量越多,访问其他节点的速度就越快。
每次创建一个新的跳跃列表节点的时候,程序会根据幂次定律 ( 越大的数出现的概率越小 )随机生成一个在 1 和 32 之间的值作为 level 数组的大小,也是层的高度。
前进指针
每个层 level 里面的前进指针 forward ,用于从表头向表尾访问访问节点。一般前进指针是指向下一个跳跃表节点的某个层,否则就是 NULL 值。
跨度
每个层 level 里面的 span 属性就是跨度,用于记录两个跳跃列表节点之间的距离。当前进指针指向 NULL 值的时候,此时的跨度都是为0。
跨度这个属性的作用在于:当在查找某个节点的过程中,将沿途访问过的所有层的跨度累加起来,得到的结果就是目标节点在跳跃表中的排位,即是排名第几个。
后退指针
跳跃列表节点中的 backward 属性就是后退指针,用于从表尾向表头访问访问跳跃列表节点。
后进指针只有在第一个跳跃列表节点的时候,指向是 NULL 值,其他节点基本是指向了前一个节点的内存地址。
和层里面的前进指针不同的是,每个节点的后退指针只有一个且每次只能退至前一个节点,而前进指针在每个节点里面可以是多个且可以每次可以前进span 个节点。
分值
跳跃列表节点中的 score 属性就是分值,它是一个 double 类型的浮点数,跳跃列表中的所有节点排序规则就是按照分值从小到大排序的。
成员对象
跳跃列表节点中的 obj 属性就是成员对象,它是一个指向了一个字符串对象的指针,这个字符串对象就是保存着一个 SDS 结构的值。
在同个跳跃列表里面,每个节点保存的成员对象必须保证是唯一的,但是多个节点的分值是可以一样的,当分值相同的节点,它们将按照成员对象在字典序中的大小来排序,小的在跳跃列表的节点前面,大的在后面。
跳跃列表结构内容
在上面多个的跳跃列表节点结构组成下,就可以组成一个跳跃列表了。
跳跃列表结构具体代码:
typedef struct zskiplist {
// 表头节点和表尾节点
struct zskiplistNode *header, *tail;
// 表中节点的数量
unsigned long length;
// 表中层数最大的节点的层数
int level;
} zskiplist;
一个具体跳跃列表结构大概情况图:
属性 header、tail 都是指针,分别指向了跳跃列表的表头节点以及表尾节点,通过这两个属性,让查找表头表尾的节点时间复杂度为 O(1)
属性 length 代表了该跳跃列表有多少个节点,查找跳跃列表的长度的时候,时间复杂度也为 O(1)
属性 level 则保存了跳跃列表节点中层高最大的那个节点的层数量,这里的节点中是不包括表头节点的。获取节点中层高最大的那个节点的层数量时间复杂度依旧是 O(1)
跳跃列表的相关实现
创建跳跃列表
创建跳跃列表的时候,程序为调用 zmalloc 为跳跃列表分配内存空间,然后调用 zslCreate 函数,初始化层数为1,长度为0,tail 指针指向为 NULL ,然后调用 zslCreateNode 函数创建一个跳跃列表节点,其为头节点,接着将头节点的后退指针设置为 NULL ,为头节点里面的32个层,每个层的前进指针设置为 NULL ,跨度设置为0
zslCreateNode 函数的创建过程为先为头节点分配内存空间,然后设置初始化相关属性值,分值为0,成员对象为NULL ,并且生成32个层。
zskiplistNode *zslCreateNode(int level, double score, robj *obj) {
// 分配空间
zskiplistNode *zn = zmalloc(sizeof(*zn)+level*sizeof(struct
zskiplistLevel));
// 设置属性
zn->score = score;
zn->obj = obj;
return zn;
}
zskiplist *zslCreate(void) {
int j;
zskiplist *zsl;
// 分配空间
zsl = zmalloc(sizeof(*zsl));
// 设置高度和起始层数
zsl->level = 1;
zsl->length = 0;
// 初始化表头节点
// T = O(1)
zsl->header = zslCreateNode(ZSKIPLIST_MAXLEVEL,0,NULL);
for (j = 0; j < ZSKIPLIST_MAXLEVEL; j++) {
zsl->header->level[j].forward = NULL;
zsl->header->level[j].span = 0;
}
zsl->header->backward = NULL;
// 设置表尾
zsl->tail = NULL;
return zsl;
}
创建好的一个跳跃列表示意图:
插入跳跃列表节点
插入跳跃列表节点可以分为大概4个步骤:
- 在各个层寻找新节点的插入的位置
- 获取一个随机值作为新节点的层数
- 创建新节点并插入跳跃列表
- 额外信息更新
zskiplistNode *zslInsert(zskiplist *zsl, double score, robj *obj) {
zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
unsigned int rank[ZSKIPLIST_MAXLEVEL];
int i, level;
redisAssert(!isnan(score));
// 1. 在各个层查找新节点的插入位置
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 &&
compareStringObjects(x->level[i].forward->obj,obj) < 0){
rank[i] += x->level[i].span;
x = x->level[i].forward;
}
update[i] = x;
}
// 2.获取一个随机值作为新节点的层数
level = zslRandomLevel();
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;
}
// 3. 创建新节点并插入跳跃列表
x = zslCreateNode(level,score,obj);
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++;
}
// 4. 额外信息的更新
// 设置新节点的后退指针
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;
}
具体讲解:
1. 在各个层寻找新节点的插入的位置
从跳跃列表的最大层数 level 值开始遍历,从表头的对应level 层的前进指针向第一个节点开始遍历,如果节点的分值比新节点的分值大或者分值相同,但节点的成员对象的字典值比新的大,就会把这个节点的前一节点记录到 update 数组里面,以及记录跨度的值到 rank 数组里面。
2. 获取一个随机值作为新节点的层数
为新的节点生成一个随机值,作为新节点的层数。如果这个层数值比其他节点的层数都要大的话,那么将遍历大于原来 level 值的每个层,将初始化这里每个层的跨度为当前 length 的值,并记录到 update 数组里面。也初始化 rank 数组里面对应下标的跨度值。
最后将这个新的层数更新到跳跃列表的 level 属性里面去。
3. 创建新节点并插入跳跃列表
调用 zslCreateNode 函数创建新的节点,并将相关的属性初始值设置好。
遍历新的 level 值,将修改新节点每个层的前进指针为指向之前update数组对应下标记录好的节点,然后将 update 数组对应下标记录好的节点的前进指针指向新节点,以及记录好各节点的新跨度值。
4. 额外信息更新
调整好新节点的后退指针,以及将跳跃列表的 length 长度值加1
跳跃列表的查找
单链表结构的查找,即使链表中的存储数据是有序的,但是它的查找时间复杂度依然是 O(n) ,从表头查到表尾。
这样的结果显然是非常不好的,跳跃列表为了解决查找效率的问题,通过在每个节点上的层上建立前进指针,可以理解为一个索引,直接从第一个节点可能查找到第三第四个节点的值,这样能让原本 O(n) 的效率提高很多。
这样是一种以空间换时间的设计。索引的建立意味着要占更多的内存空间,但是时间查找效率是提高的了。
具体查找过程:
在跳跃列表要查找分值为17的节点的路线如红色箭头所示。
首先跳跃列表都是从最大的层数往小的遍历,先获取第二层的第一个节点,分值为1,比17小,所以继续往第一个节点第二层的前进指针的位置遍历,此时找到分值为9的节点,然后因为还是比17小,继续遍历,获取到分值为21的节点,因为21比17大,此时往分值为9的第一层走,往前进指针方向遍历,最终获取到了分值为17的节点。
如果跳跃列表的层数越高的话,可能查找效率是更快提升的。
如图所示,查找分值为17的节点的时候,可以直接从第一个节点的第三层前进指针获取到分值为17的节点。
通过以上的说明,跳跃列表这样的查找,有点像二分查找法,通过每个层的前进指针直接获取非下一个节点的值,然后对比值大小,加快查找效率。这样的查找效率可以变成时间复杂度为平均 O(logN)
总结
通过对 Redis 跳跃列表的概念,具体结构内容,具体相关操作详细实现的学习,了解到跳跃列表的具体存在,在 Redis 中主要为 Zset 数据结构的实现。
参考:《 Redis设计与实现 》