跳跃表 ( skiplist ) 为一种随机化的数据结构,以有序的方式在层次化的链表中保存元素,通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问的目的。
跳跃表在 Redis 主要是作为 有序集合ZSET 类型数据 的底层实现之一,如果有序集合里面包含的数量比较多,或者 成员元素的 键比较长(必须大于 zset-max-ziplist-value 设置的值 默认 64,否则使用 ziplist );
假如有个至少数十万的数据需要进行按顺序存放,并且能按照最低复杂度 增删改查。一般情况下都是使用线性的 链表 和 数组 实现,但是无论数组还是链表在 写入的时候都是一个效率很低的操作:遍历获取存入位置,最快的方法就是使用 二分查找,链表不能使用 二分查找,但是呢,数组又需要 插入位置之后的所有元素后移;总结来说,数组的插入复杂度是 O(n),链表的查找复杂度是 O(n),所以使用普通的线性表是一个恐怖的操作。
so,这个时候 跳跃表 ( skiplist ) 就出现了…
跳跃表 ( skiplist ) 在 增删改查 的复杂度上平均能达到 O(log n),最坏O(n)
,至于为什么 Redis 使用了 skiplist 而不是各种比如 红黑树 的平衡树呢,且看下面详解 < 挖坑 跑 >~~~
跳跃表 对比于 平衡表 如下:
- 在做范围查找的时候,平衡树要比 skiplist 复杂得多,在平衡树中,我们找到指定范围的小值以后,还需要中序遍历顺序查找其他其他不超过大值得节点,这样的话就还需要对平衡树进行改造,否则这种中序遍历并不容易实现;但是在 skiplist 中就比较简单了,找到 小值的时候直接 继续挨着遍历就行了
- 如果进行 增删 操作的时候,平衡树可能会引起 rebalance 将子树的重新排列,以保持平衡;但是 skiplist 就更加简单了,只需要修改相邻节点指针即可,相当便利
- 一定情况下 skiplist 的内存占用会更有优势一些,Redis 里,平均一个 节点包含 1.33个指针,而平衡树需要 2个指针
- 最后就是最重要的一点,skiplist 在实现上简单很多很多
跳跃表的推导
由于数组的特性,几乎无法避免写入时候的元素大面积移动,所以我们从 链表入手,主要就是 解决 链表元素的 快速定位问题,假如有一组有序数字(-INF、-10、3、10、21、36、43、49、59)组成的有序链表:
因为链表无法使用二分法查找,我们要查找到 59 这个值,不包括 -INF 负无穷大,需要 8 步,那么如何用更少的路径去查找到目的值呢 ?
所以我们开辟一条路径试试,从 L2 来找的话,只需要 4步 就能找到 59 这个值;获取 49 我们只 需要查找 5次,因为 找到 43 的时候 还不到,而到 59 的时候显然已经超过了,所以往回找 与 43之间的 下一层(L1),很幸运我们只需要走 1步;而如果我们还需要再加快 查找 59呢,根据上面的经验,我们可以另外再增加一层
这样我们通过 L3 只需要查找 两次即可,那么按照这个思路我们最终的 结构就是这样了
如果查找 59 我们只需要一次查找,查找 36 需要 5次,即 L4(-INF -> 59),L3(-INF -> 21、21 -> 59)、L2(21 -> 43)、L1(21 -> 36),由于数量比较少可能看不出来,如果数量上 十万百万 效率就比较清楚了,我们进行推导一下:
如果有 n 个元素,因为是 2 分,所以层数就应该是 log n 层( 本文所有 log 都是以 2 为底),再加上自身的 1 层。以上图为例,如果是4个元素,那么分层为 L3和 L4,再加上本身的 L2,一共 3 层;如果是 8 个元素,那么就是 3 + 1 层。最耗时间的查询自然是访问所有层数,耗时 logn + logn,即 2logn。为什么是2倍的logn呢 ?我们以上图中的 49 为例,查询到 49 要访问所有的分层,每个分层都要访问 2 个元素,中间元素和最后一个元素。所以时间复杂度为 O(log n)
当然,我们上述为一种比较理想的 跳跃表,而实际上的 跳跃表 上级 跳跃点 (L2、L3、L4)的点都是 随机的,如果链表中 又新增了一个 节点,那么 将采用 抛硬币
的方法来判定 是否 提拔这个 节点为跳跃点,这里我们后续在 代码的逻辑中也会说到
Redis 跳跃表结构
Redis 跳跃表的 数据结构如下:
假如下面有一个 ZSET 类型数据使用了 跳跃表(假定符合跳跃表的条件…)
那么这个数据的图形结构如下(这里需要注意
实际程序中 level 是从 0 开始的,图例中从 1 开始仅仅是为了方便查看):
zskiplist 部分
header
指向跳跃表表头地址tail
指向跳跃表表尾地址length
表示 跳跃表长度, 也是 跳跃表目前包含节点的数量(不包括表头节点)level
最大层级(不包括表头节点的层级)
zskiplistNode 部分,又分为 表头节点 和 普通数据节点
表头节点
主要包含所有 层级指针地址,默认最高 32层(所以 集合中最大的成员数为 232 - 1 = 4294967295,每个集合可存储40多亿个成员)ele
和score
分别表示成员对象 和 分值;成员对象为 简单字符串 sds 类型数据,分值存储的为 double 浮点数,跳跃表里面的所有节点都是根据 分值 从小到大排序backward
指向前一个节点地址,用于从 表尾向表头 访问节点,不过只能一次遍历一个,不像 level 里面的 跳级访问后续节点level 层级
- skiplistNode 里面的 level 数组属性包含多个 zskiplistLevel 类型的层级元素,每个 元素 都包含一个 指向下一个节点的 指针,程序可以通过这些层快速访问其他节点,一般层数越多,访问其他节点的速度就越快;每次创建一个新的 数据节点,程序都根据幂次定律(power law,越大的数出现的概率越小)随机生成一个介于 1 ~ 32 的值作为 level 数组的大小,就是 level 层级数
- forward 每一层都有一个指向下一个节点的指针地址,用于 从表头到表尾 遍历所有 节点,遍历到 指向节点是 null 的时候表示到结尾
- span 跨度,用于记录两个节点之间的跨度值,两个节点的跨度越大,代表距离越远;当 forward 指向 NULL 的时候,span 为 0;
乍看 跨度是用来 快速遍历的,实际上 快速遍历只需要用 forward 的,实际上 跨度是用来 表示 顺序rank 的,从开始到 目标节点的 跨度之和其实就是 顺序值
下面我们就 Redis 跳跃表一些 典型的 API 进行解析~~~ 不过为了表示 效率,我们还会带上相关 API 的复杂度数据
源码解析
由于有些方法里的代码量比较大,我们这里按照 典型的代码片段进行解析,同志们可以根据文章提示的代码位置 和 代码里面的关键词 在源码中搜素,可能数据结构一些元素 看不太懂什么意思,没关系,先混个脸熟,后面看完回头再看过来就明白了
跳跃表 创建函数 zslCreate()
和 zslCreateNode()
,逻辑比较简单,主要是 申请内存空间、初始化属性;时间复杂度为 O(1)
跳跃表插入函数 zslInsert()
,具体逻辑为:查找插入点、插入节点、更新前后节点的 forward、backward 属性、更新 跳跃表属性 等;不过主要的难点还是查找该在何处插入节点,跳跃表的顺序是按照 score 分值进行排列的,一般查找是从最高层 level 开始遍历查找,如果当前节点的 score 小于新 score 则继续往 forward 方向查找,否则,则降低一层继续往 forward 方向查找,直到找到 小于 score 但是最接近的节点;时间复杂度为 平均 O(log n),最坏 O(n),n 代表跳跃表长度
|
|
跳跃表节点的删除分为三种情况
zslDelete()
删除 指定成员、指定分值 的节点;时间复杂度为平均 O(log n),最坏 O(n),n 代表跳跃表长度
zslDeleteRangeByScore()
删除 指定分值区间范围 内的所有节点;时间复杂度为平均 O(log n),最坏 O(n),n 代表跳跃表长度
zslDeleteRangeByRank()
删除 指定排位范围 内的所有节点;时间复杂度为平均 O(log n),最坏 O(n),n 代表跳跃表长度
然而这几个函数的核心删除节点函数都是 zslDeleteNode()
,区别就是查找需要删除节点的逻辑不一样,我们这次以 zslDelete()
函数作为示例,不过 这个函数实际查找节点的逻辑也是 元素值ele 以及 分值score 配合使用的
删除跳跃表节点的时候,除了传递 节点地址以外,一般还会传递 这个节点在 每层的 前一个节点地址
获取节点排位函数 zslGetRank()
和 获取指定排位的节点函数 zslGetElementByRank()
,这两个函数的逻辑实际比较简单,也比较类似,查找逻辑跟 zslDelete() 函数比较相像,不过区别在于利用了 rank 属性的值,我们在上面 zskiplistNode结构部分的介绍中,提到 rank 其实就是用来表示 排名的,header 到 目标节点之间 每层 level 查找路径的 span 跨度其实就是 排位;两个函数的时间复杂度为 平均 O(log n),最坏 O(n),n 代表跳跃表长度
ZSET 的区间操作API有两个:
zslFirstInRange()
获取在跳跃表中符合 给定范围分值的 第一个节点;时间复杂度为平均 O(log n),最坏 O(n),n 代表跳跃表长度
zslLastInRange()
获取在跳跃表中符合 给定范围分值的 最后一个节点;时间复杂度为平均 O(log n),最坏 O(n),n 代表跳跃表长度
|
|
本文作者: wettper
本文链接: http://www.web-lovers.com/redis-source-skiplist.html
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 许可协议。转载请注明出处!