1. 作用
跳跃表在Redis中主要运用在有序集合和集群节点用作内部数据结构
2. 数据结构
typedef struct zskiplist{
zskiplistNode *header; //跳跃表头节点
zskiplistNode *tail; //跳跃表尾节点
int level; //层数,跳跃表中节点最大层数
int length; //节点数
}
typedef struct zskiplistNode{
struct zskiplistNode *backward; //后退指针
double score; //分值
robj *obj; //成员对象
//层
struct zskiplistLevel{
struct zsklistNode *forward; //前进指针
unsigned int span; //跨度
}level[];
}
obj是该结点的成员对象指针,score是该对象的分值,是一个浮点数,跳跃表中的所有结点,都是根据score从小到大来排序的。
同一个跳跃表中,各个结点保存的成员对象必须是唯一的,但是多个结点保存的分值却可以是相同的:分值相同的结点将按照成员对象的字典顺序从小到大进行排序。
level数组是一个柔性数组成员,它可以包含多个元素,每个元素都包含一个层指针(level[i].forward),指向该结点在本层的后继结点。该指针用于从表头向表尾方向访问结点。可以通过这些层指针来加快访问结点的速度。
每次创建一个新跳跃表结点的时候,程序都根据幂次定律(power law,越大的数出现的概率越小)随机生成一个介于1和32之间的值作为level数组的大小,这个大小就是该结点包含的层数。
Redis中的跳跃表,与普通跳跃表的区别之一,就是包含了层跨度(level[i].span)的概念。这是因为在有序集合支持的命令中,有些跟元素在集合中的排名有关,比如获取元素的排名,根据排名获取、删除元素等。通过跳跃表结点的层跨度,可以快速得到该结点在跳跃表中的排名。
计算结点的排名,就是在查找某个结点的过程中,将沿途访问过的所有结点的层跨度累加起来,得到的结果就是目标结点在跳跃表中的排名。
层跨度用于记录本层两个相邻结点之间的距离,举个例子,如下图的跳跃表:
跳跃表头结点(header指向的节点)排名为0,之后的节点排名以此类推。在上图跳跃表中查找计算分值为3.0、成员对象为o3的结点的排名。查找过程只遍历了头结点的L5层就找到了,并且头结点该层的跨度为3,因此得到该结点在跳跃表中的排名为3。
如果要查找分值为2.0、成员对象为o2的结点,查找结点的过程中,首先经过头结点的L4层,然后是o1结点的L2层,也就是经过了两个层跨度为1的结点,因此得到目标结点在跳跃表中的排名为2。跨度算排名
header和tail指针分别指向跳跃表的表头结点和表尾结点,通过这两个指针,定位表头结点和表尾结点的复杂度为O(1)。表尾结点是表中最后一个结点。而表头结点实际上是一个伪结点,该结点的成员对象为NULL,分值为0,它的层数固定为32(层的最大值)。
length属性记录结点的数最,程序可以在O(1)的时间复杂度内返回跳跃表的长度。
level属性记录跳跃表的层数,也就是表中层高最大的那个结点的层数,注意,表头结点的层高并不计算在内。
Redis中的跳跃表,与普通跳跃表的另一个区别就是,每个结点还有一个前继指针backward。可用于从表尾向表头方向访问结点。通过结点的前继指针,组成了一个普通的链表。因为每个结点只有一个前继指针,所以只能依次访问结点,而不能跳过结点。
3. 特点
实现简单,大数据情况下,时间复杂度O(logn),比起红黑树实现简单
4. 实现
4.1. 随机算法
int zslRandomLevel(void) { //返回一个随机层数值
int level = 1;
//(random()&0xFFFF)只保留低两个字节的位值,其他高位全部清零,所以该值范围为0到0xFFFF
while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF)) //ZSKIPLIST_P(0.25)所以level+1的概率为0.25
level += 1; //返回一个1到ZSKIPLIST_MAXLEVEL(32)之间的值
return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}
#define ZSKIPLIST_MAXLEVEL 32 /* Should be enough for 2^32 elements */
#define ZSKIPLIST_P 0.25 /* Skiplist P = 1/4 */
在redis中,返回一个随机层数值,随机算法所使用的幂次定律。
- 含义是:如果某件事的发生频率和它的某个属性成幂关系,那么这个频率就可以称之为符合幂次定律。
- 表现是:少数几个事件的发生频率占了整个发生频率的大部分, 而其余的大多数事件只占整个发生频率的一个小部分。
也就是说,层数越高出现的概率越小
4.2. 新增节点
在实现新增节点逻辑中需要关注两个变量,rank数组和update数组,update数组是新增节点的前驱节点的数组,rank是这些节点的排名
上图是一个简化的跳跃表,每个结点只保留了分数、层指针和层跨度。所以,下图中表头结点排名为0,分数为1、3、10、15、20的结点,排名分别为1、2、3、4、5。
x = zsl->header; //获取跳跃表头结点地址,从头节点开始一层一层遍历
for (i = zsl->level-1; i >= 0; i--) { //遍历头节点的每个level,从下标最大层减1到0
/* store rank that is crossed to reach the insert position */
rank[i] = i == (zsl->level-1) ? 0 : rank[i+1]; //更新rank[i]为i+1所跨越的节点数,但是最外一层为0
//这个while循环是查找的过程,沿着x指针遍历跳跃表,满足以下条件则要继续在当层往前走
while (x->level[i].forward && //当前层的前进指针不为空且
(x->level[i].forward->score < score || //当前的要插入的score大于当前层的score或
(x->level[i].forward->score == score && //当前score等于要插入的score且
compareStringObjects(x->level[i].forward->obj,obj) < 0))) {//当前层的对象与要插入的obj不等
rank[i] += x->level[i].span; //记录该层一共跨越了多少节点 加上 上一层遍历所跨越的节点数
x = x->level[i].forward; //指向下一个节点
}
//while循环跳出时,用update[i]记录第i层所遍历到的最后一个节点,遍历到i=0时,就要在该节点后要插入节点
update[i] = x;
}
上面代码逻辑很简单,对照下面的图来看,从层数最高开始往下遍历,先算出这个层数的排名rank,然后放入update,
在上图的跳跃表中,假设现在要插入的结点分数为17,黄色虚线所标注的,就是插入新结点的位置。下面标注红色的,就是在每层找到的插入结点的前驱结点,记录在update[i]中,而rank[i]记录了update[i]在跳跃表中的排名,因此,rank[4] = 3, rank[3] = 3, rank[2] = 4, rank[1] = 4, rank[0] = 4。
然后
level = zslRandomLevel(); //获得一个随机的层数
if (level > zsl->level) { //如果大于当前所有节点最大的层数时
for (i = zsl->level; i < level; i++) {
rank[i] = 0; //将大于等于原来zsl->level层以上的rank[]设置为0
update[i] = zsl->header; //将大于等于原来zsl->level层以上update[i]指向头结点
update[i]->level[i].span = zsl->length; //update[i]已经指向头结点,将第i层的跨度设置为length
//length代表跳跃表的节点数量
}
zsl->level = level; //更新表中的最大成数值
}
如果新增的新节点层数比跳跃表最大层数要高,那么高出的部分就要赋值,就要新增前驱节点,更新update元素数量,可以明显看到rank和update都在增多,而且更新值为0和header,说明,高出部分前驱都是表头,表头跨度也就正好是节点数量,注意是原来节点数量,新增节点还没加入,这里表头直接指向null,跨度最长,后面这里还会再修改
然后继续
x = zslCreateNode(level,score,obj); //创建一个节点
for (i = 0; i < level; i++) { //遍历每一层
x->level[i].forward = update[i]->level[i].forward; //设置新节点的前进指针为查找时(while循环)每一层最后一个节点的的前进指针
update[i]->level[i].forward = x;//再把查找时每层的最后一个节点的前进指针设置为新创建的节点地址
/* update span covered by update[i] as x is inserted here */
x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]); //更新插入节点的跨度值
update[i]->level[i].span = (rank[0] - rank[i]) + 1; //更新插入节点前一个节点的跨度值
}
前两条语句先更新指向,这里和链表插入一样
后两条语句更新跨度,自己用上面的数组值带入即可明白
rank[0] - rank[i]等于距离,前一个节点的排名与当前前驱层数节点的的距离
/* increment span for untouched levels */
for (i = level; i < zsl->level; i++) { //如果插入节点的level小于原来的zsl->level才会执行
update[i]->level[i].span++; //因为高度没有达到这些层,所以只需将查找时每层最后一个节点的值的跨度加1
}
//设置插入节点的后退指针,就是查找时最下层的最后一个节点,该节点的地址记录在update[0]中
//如果插入在第二个节点,也就是头结点后的位置就将后退指针设置为NULL
x->backward = (update[0] == zsl->header) ? NULL : update[0];
if (x->level[0].forward) //如果x节点不是最尾部的节点
x->level[0].forward->backward = x; //就将x节点后面的节点的后退节点设置成为x地址
else
zsl->tail = x; //否则更新表头的tail指针,指向最尾部的节点x
zsl->length++; //跳跃表节点计数器加1
剩下就很简单了
因为比较感兴趣Redis跳跃表插入是怎么插入的,所以研究了下,图都是盗的,参考了些资料,总算搞明白了,Redis的跳跃表和传统跳跃表是有不同的,所以想搞懂一下
关键点还是在于两个数组,rank数组和update数组
5. 参考资料
https://blog.youkuaiyun.com/men_wen/article/details/70040026
https://blog.youkuaiyun.com/lz710117239/article/details/78408919