1.前言
自己学跳跃表是因为当初听人说想要找一份高薪工作, Redis跳跃表是要知道的. 当时学的时候也是网上的文章反复看, 花了几个晚上才彻底弄明白, 所以在此记录一下吧, 为了下次面试好回顾
2.跳跃表基本概念准备
跳跃表是有序集合(zset)的底层实现之一。
2.1跳跃表的数据结构
跳跃表zskiplist定义在server.h中
header; 跳跃表的表头节点
tail: 指向跳跃表的表尾节点
level: 记录目前跳跃表内, 层数最大的那个节点的层数(表头节点的层数不计算在内. 因为它的层数为level31: 从level0开始, 所以是32层)
length: 记录跳跃表的长度, 也就是, 跳跃表目前包含节点的数量(表头节点不计算在内)
2.2跳跃表节点的数据结构
跳跃表的节点zskiplistNode定义在server.h中, 定义如下
robj: RedisObject的别名, 在跳跃表中它的类型是sds字符串
score: 浮点类型的数值
backward: 后退指针, 指向跳跃表当前节点的前一个节点的指针
level[]: 数组中的一个元素包含下列两项
forward: 前进指针
span: 当前层跨越的节点数量
节点按分值从低到高排列, 分值相同时按 robj字典顺序排列, 而不是按对象本身的大小
一个节点在每一层都有一个forward指针(各层的forward指针可能相同, 可能不同)
如图:
那么对应节点8而言:
节点8的level0的forward为节点9, span值为1
节点8的level1的forward为节点12, span值为4
节点8的level2的forward为节点16, span值为8
所以, forward指针指向的是当前节点的当前level层所能指向的最右的节点(其最大的level层 >= 当前节点的当前level层), 而span就是这个过程中跨越的节点数. 将上述节点8的三个level层依次带入, 应该就能理解上面这句话了.
2.3Rank
它代表每个节点在跳跃表中的相对位置(类似数组下标)
如上图中节点8的rank值为 8 , 从第一个节点开始(不包含头节点) rank = 1, 然后依次类推.
3.跳跃表的创建
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;
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;
}
其中:
ZSKIPLIST_MAXLEVEL,这个是跳跃表的最大层数,源码里通过宏定义设置为了32,也就是说,节点再多,也不会超过32层(level31)
header节点的初始化: 创建跳跃表时, 初始化的header节点的level数组是有32层(level31)的, 且每一层的forward指向的都是null, 这里的知识在后面节点插入时会用到.
而一般节点的level层数是在节点插入到跳跃表时 随机给定的, 根据一个随机算法:
上图是随机给定每一层的概率
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));
x = zsl->header;
// 记录沿途访问的节点,并计数 span 等属性
// 平均 O(log N) ,最坏 O(N)
for (i = zsl->level-1; i >= 0; i--) {
/* store rank that is crossed to reach the insert position */
rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];
// 右节点不为空
while (x->level[i].forward &&
// 右节点的 score 比给定 score 小
(x->level[i].forward->score < score ||
// 右节点的 score 相同,但节点的 member 比输入 member 要小
(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;
}
/* we assume the key is not already inside, since we allow duplicated
* scores, and the re-insertion of score and redis object should never
* happpen since the caller of zslInsert() should test in the hash table
* if the element is already inside or not. */
// 因为这个函数不可能处理两个元素的 member 和 score 都相同的情况,
// 所以直接创建新节点,不用检查存在性
// 计算新的随机层数
level = zslRandomLevel();
// 如果 level 比当前 skiplist 的最大层数还要大
// 那么更新 zsl->level 参数
// 并且初始化 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;
}
// 创建新节点
x = zslCreateNode(level,score,obj);
// 根据 update 和 rank 两个数组的资料,初始化新节点
// 并设置相应的指针
// O(N)
for (i = 0; i < level; i++) {
// 设置指针
x->level[i].forward = update[i]->level[i].forward;
update[i]->level[i].forward = x;
/* update span covered by update[i] as x is inserted here */
// 设置 span
x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);
update[i]->level[i].span = (rank[0] - rank[i]) + 1;
}
/* increment span for untouched levels */
// 更新沿途访问节点的 span 值
for (i = level; i < zsl->level; i++) {
update[i]->level[i].span++;
}
// 设置后退指针
x->backward = (update[0] == zsl->header) ? NULL : update[0];
// 设置 x 的前进指针
if (x->level[0].forward)
x->level[0].forward->backward = x;
else
// 这个是新的表尾节点
zsl->tail = x;
// 更新跳跃表节点数量
zsl->length++;
return x;
}
那么如何理解上述函数就是关键了.
4.1两个关键的数组
这两个数组一定要理解!!!
update[]: 数组的每个元素update[i] --> XXX节点: XXX节点的第i层的forward指向插入节点
rank[]: 每一层中XXX节点的rank值(后面用来计算span的)
update[]: 记录寻找元素过程中, 每层能到达的最左节点(XXX节点)
rank[]: 最左节点的rank
补充: 最左节点是对插入节点而言, 如果根据后面介绍的, 从header开始遍历, 寻找update[i], 则可以看成最右节点.
4.2插入一个新节点时涉及的操作
前面说过, 跳跃表插入节点时, level是随机给的, 我们这里假设插入节点的分值为9.5, 随机生成的层数是2, 那么此时跳跃表如图:
对跳跃表而言, 插入一个新节点所涉及的操作有:
插入节点每一层的forward和span获取
每一层最左节点的forward和span更新
插入节点9.5之后:
level0:
插入节点的forward可以根据score排序得到, span为1
update[0]是插入节点的backward, span为1
level1:
8 ~ 12之间的span =
s
p
a
n
8
span_{8}
span8 + 1 =>
8 ~ 9.5 + 9.5 ~ 12 =
s
p
a
n
8
span_{8}
span8 + 1 =>
求出8 ~ 9.5,也就同样能求出 9.5 ~ 12
8 ~ 9.5 = 8 ~ 9 + 1
=rank(节点9) - rank(节点8) + 1
s p a n 8 span_{8} span8表示的是插入之前, 节点8在level1的span值
对上述公式的解释:
我要更新插入节点的level1层的最左节点的forward和span, forward为update[1], span就需要根据上述公式计算得到, 而刚好节点9为update[0], 节点8为update[1], 所以:
=rank(update[0]) - rank(update[1]) + 1
同理: 对于更新插入节点的第i层的最左节点的span值
抽象为: rank(update[0]) - rank(update[level.i]) + 1
=rank(0) - rank(i) + 1
这个公式求得的是: 插入节点的第i层对应的最左节点的第i层span值.
此时我们再来看看这段:
每一层对应的最左节点的forward更新为update[i], span可以根据rank(0) - rank(i) + 1计算来更新
插入节点第i层的forward为update[i]在插入之前的forward(插入之后update[i]的第i层forward指向的是插入节点), span值获取: “8 ~ 9.5根据公式计算得到了, 9.5 ~ 12自然也就知道了”. 这么说不知道能不能理解.
而这个计算公式在源码中也有出处:
所以关键就是找到插入节点的update[] 和 rank[].
4.2遍历,记录update和rank
对应源码:
// 记录寻找元素过程中,每层能到达的最右节点
zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
// 记录寻找元素过程中,每层所跨越的节点数
unsigned int rank[ZSKIPLIST_MAXLEVEL];
int i, level;
redisAssert(!isnan(score));
x = zsl->header;
// 记录沿途访问的节点,并计数 span 等属性
// 平均 O(log N) ,最坏 O(N)
for (i = zsl->level-1; i >= 0; i--) {
/* store rank that is crossed to reach the insert position */
rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];
// 右节点不为空
while (x->level[i].forward &&
// 右节点的 score 比给定 score 小
(x->level[i].forward->score < score ||
// 右节点的 score 相同,但节点的 member 比输入 member 要小
(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;
}
update和rank数组的值可以通过一次逐层的遍历确定
从跳跃表当前的最大层数开始遍历, 遍历到最底层为止.
update[] rank[]
length - 1层:
X的head的level-1层(从0层开始, 这里的level是指跳跃表中当前的最大层数)指向节点X1(当前跳跃表内最高层), 判断:
1.X1的level-1层的forward是否为null
如:从 X1到表尾的所有节点的level层数都小于 X1的level层数, 此时length - 1 层的forward为null
2.X1的score是否比插入节点的大
满足其中之一 --> 跳出循环
update[length -1] = header, rank[length - 1] = 0
不满足其中之一 -->
找到X1的length-1层的forward指向的节点X2(能到这 说明: X1与X2的level层数相等的), 判断:
是否满足上述条件之一
满足 --> 跳出循环
update[length - 1] = X1, rank[length - 1] = X1在表中的rank值
length - 2层:
X1的次高层的forward指向节点X3, 判断:
…
不满足 -->
X3的最高层forward指向的节点 X4满不满足, X4的最高层forward指向的节点满不满足 假设满足:
update[length - 2] = X4, rank[length - 2] = X4在跳跃表的rank值
length -3层:
X4的次高层…
此时我们得到完整的update[] 和 rank[]
结合图示理解:
保姆级说明:
结合上述两幅图, 在插入节点后是如何通过一次遍历获取update[], rank[]数组的:
1.首先在插入9.5节点之前, 跳跃表中level的值为3, 所以我们来到header节点的level-1层, 也就是level2, 其中的forward指向的是节点8(为什么header的level2中会有值, 下面会说明, 别急)
2.判断循环条件, 不满足, 继续. 节点8的level2层的forward指向节点16, 满足. 此时update[2] = 节点8, rank[2] = 8
3.从节点8的次高层level1出发, forward指向的是节点12, 判断循环条件, 满足. 此时update[1] = 节点8, rank[1] = 8
4.从节点8的次次高层level0出发, forward指向的是节点9, 判断循环条件, 不满足, 继续, 节点9的最高层forward指向节点10, 判断循环条件, 满足, update[0] = 节点9, rank[0] = 9
如果上述内容都理解了, 相信跳跃表的插入流程在脑子里大致是能跑同了, 但感觉还有些模糊地带, 接下来我们就来探索这些模糊地带, 全面理解Redis跳跃表.
5.如果插入节点随机生成的层数比当前跳跃表的最大层数大
上文中提到过: 为什么header的level2中会有值. 接下来我们就来解释这个问题
当插入节点随机生成的层数(j)比当前跳跃表的最大层数(level)大, 对于update[], rank[]中 数组下标≤ level 的依然可以通过一次遍历得到, 而对于数组下标: level <数组下标 ≤ j的, 此时显然:
update[level + …] = header, rank[level+ …] = 0
多出来的这些层的header的span:
= rank[0] - rank[i] + 1
=rank[0] + 1
而对于插入节点的那些 大于等于level层的forward = null, 所以也就没有span值
6.更新未涉及到的层
如果随机生成的层数小于之前跳跃表中的层数, 那么大于随机生成的层数在创建新节点的过程中就没有被操作到(比如上述中, update[2] = 节点8 就没有被使用到,), 对于这些没有操作到的层, 里面的update节点对应的span应当+1(因为插入了一个新节点), forward不变.
至此: 更新每一个update[i]的forward和span完成
“物理的科学大厦已经建成, 剩下的只有大厦旁的两朵乌云, 后人只需在此基础上修补, 完善”. 原话找不到了, 凑合用吧!
7.设置后继指针
针对每一层的调整已经全部完成了, 也就是level数组已经搞定, 接下来, 处理一个backward指针, 首先新节点的backward要指向前一个节点, 然后, 新节点的下一个节点要将backward指向新节点.
8.更新跳跃表节点个数
最后, 全部搞定, 把跳跃表个数加1即可, 理解了插入, 对跳跃表可以说是基本掌握了. 因为掌握了插入, 也就掌握了跳跃表的其他操作(如 删除).
其实就是觉得为了面试学到这, 用来装逼应该足够了. 当然时间够的还可以了解一下Redis的一致性hash, Redis的内存淘汰机制, 这两个理解起来会容易很多.
Redis的一致性hash:
https://blog.csdn.net/qq_21125183/article/details/90019034?
Redis的内存淘汰机制: 第9题有稍微说明
https://blog.csdn.net/weixin_43179522/article/details/109318370
9.补充
跳跃表是一种随机化的数据结构, 在查找, 插入和删除这些字典操作上, 其效率可比拟于平衡二叉树(如红黑树), 大多数操作只需要O(log n)平均时间. 为什么时间能从O(n)提升到O(log n)
用下图举个例子吧:
当我们要查找新插入的节点9.5时, 如果不是用的跳跃表, 那么需要从节点1开始, 找到节点2, 然后一直找到节点9.5, 花费的时间为10.
而如果是跳跃表结构, 我们从header的level-1层出发, 根据span和forward来到节点8, 发现节点8的score < 节点9.5的score, 然后我们接着从节点8的level2出发, 来到节点16, 发现节点16score > 节点9.5的score, 所以我们重新从节点8的level1出发, 来到节点9.5. 找到节点, 返回结果. 时间花费: header -> 节点8 -> 节点16, 节点8 -> 节点9.5 = 3
总结: 跳跃表根据level数组, 相当于为跳跃表构建了多级索引, 每一层level都是一级索引, 从而减少了查询时间.
10.参考链接
https://blog.csdn.net/u013536232/article/details/105476382?
https://blog.csdn.net/weixin_30398227/article/details/94981429?
https://blog.csdn.net/universe_ant/article/details/51134020?
在此对这三篇博客的作者提供的思路表示感谢.
对了, 本来学这个是想面试中装逼用的, 结果愣是没人问. 我真的! 哎, 就像沈佳宜说的: “人生本来很多努力都是徒劳无功的”. 至少它很难, 但你学会了!!!
最后, 如果觉得通过这篇博客理解了Redis跳跃表, 求个点赞, 收藏. 写博客后才发现, 码字也不是个轻松活.