在数据结构的世界中,各种高效的数据存储和检索方式层出不穷。其中,跳表(Skip List) 作为一种高效的动态查找数据结构,以其简洁的设计和良好的性能表现受到了广泛的关注。与传统的平衡树相比,跳表不仅实现了相似的时间复杂度,而且其插入、删除和查找操作更加直观易懂。
详解redis中跳表的设计与实现
1. 跳表的数据结构
我们先从跳表的每一个节点说起,为了保证跳表节点的有序性,跳表中的每一个节点都会用zskiplistNode 来维护节点信息:
- score来记录当前节点的数值,插入跳表时就会按照score进行升序排列。
- obj来存储当前节点实际要存储的元素值。
- backward记录当前节点的后一个节点,这个节点的score小于当前节点。
- level是一个数组,它记录当前节点有索引的层级,每个索引都有指向当前节点的前驱节点指针forward和当前节点与forward的跨度span构成。
如下所示,可以看到跳表默认情况下有个header节点作为首节点,每个节点level索引都会记录前驱节点的指针,而各个节点的backward则会指向自己的后继节点,而节点之间的跨度也是用span来记录:
注意:跳表的前驱后继节点与链表的区别,在链表中前驱指的是自左向右看,排在自己前面的节点,而后继节点指的是自左向右看排在自己右边的节点。而跳表中前驱指的是自右向左看排在自己左边的节点也就是小于自己的节点,而后继节点是自右向左看排在自己右边也就是大于自己的节点,这个概念会贯穿全文,希望读者可以理解这个概念后再阅读后续部分的源码分析。
对应的我们也给出跳表节点的源码,读者可基于描述自行理解阅读:
了解了节点的概念,我们再来串联一下跳表的逻辑结构,跳表本质上就是上述节点的串联:
- 通过header指针记录跳表的第一个节点。
- 通过tail指针记录跳表的尾节点。
- 为保证快速获取跳表的长度,它也会使用length来记录跳表中的节点数。
- 通过level记录当前跳表最高层级。
我们基于上图继续补充这些概念:
同时我们也给出跳表zskiplist 的源码:
2. 跳表初始化
有了上述的概念之后,对于跳表初始化的逻辑就可以很直观了解了,在redis中跳表初始化函数为zslCreate,其内部逻辑本质上就是初始化一个跳表,然后对跳表节点个数、头节点索引、数值、score进行初始化,逻辑比较简单,读者可以参照笔者的注释自行阅读理解:
3. 跳表节点插入操作的实现(重点)
跳表的插入操作是整个数据结构的核心,只要了解了跳表的插入操作,我们就可以理解整个跳表数据结构算法的思想,这里笔者就以插入一个元素x为例演示一下跳表的插入过程。
在进行插入操作前,跳表首先会初始化update数组和rank数组,update数组记录索引每一层中小于插入节点x的score的最大score对应的节点,例如我们要插入一个score为3.5的节点,当前跳表第二层索引分别有1、2、3、4、5,那么3就是update[1]要记录的值。又假设1-5之间跨度都为1,从1-3跨了两步,所以rank[1]的值就是2。
通过update和rank的配合,一轮O(logN)的遍历即可找到x每一层索引和节点的插入位置。
我们现在以下面这张图演示一下跳表的插入过程,可以看到笔者对每个节点的地址addr和score都进行标明:
假设我们要插入的节点x的score为1.5,从level 2开始看到第一个节点的后继节点为空,所以update[1](1代表level2)指针记录header节点的地址,也就是0x8943,然后索引向下一层走,走到第二个节点时发现前方节点的值2大于x的score,所以update[0]记录这一层小于x的最大值1也就是node-1的地址0x8944。
自此我们遍历完各层索引,下一步就是基于update和rank数组进行节点x插入:
重点来了,建议读者基于上一步的图片了解笔者这一步的表述,基于上一步update数组标注的元素指针,我们假设x创建的索引层级也是2,第2层则是指向第一个元素的,所以x的索引就插入到这个索引0的前面,同时我们需要计算这个索引的到后面一个节点的span,对应的结算方式如下:
- 索引1节点每个节点都有,所以跨度为0
- 索引2的节点0原本到NULL的跨度rank为0,即本层小于x的最大节点就是第一个
- 索引1到update数组节点跨度为1,即走一步就是小于x的最大节点
- 索引1的跨度-索引2的跨度得出新插入节点x到下一个节点距离为1,所以span为1
- 而索引2的第一个节点的span也要更新,同样是索引1的跨度-索引2的跨度=索引2还需跨几步到达x节点的前一个节点位置,然后再加1 就是走到节点x的跨度,对应的值也为2
最后新插入的节点x如果前方有值,则让前方节点的backward指针指向x,若没有则说明x是尾节点,直接用tail指针指向该节点即可,完成后结果大体如下图所示:
对应的我们也给出redis中对于跳表节点插入实现的代码,读者可参考上述讲解并结合参考了解过程:
4. 跳表查询操作的实现
有了上述查询操作的基础之后,对于跳表的查询操作就很好理解了,redis用跳表主要是进行范围查询,这里我们就以一个查询元素排名的实示例演示一下这个过程,以下面这张图为例,查找一下score为3的元素,除去头节点它就是第3个元素,所以跳表进行等级查询时返回结果就是3:
对应的搜索步骤为:
- 从header的2级索引开始,查看第一个节点的后继节点score为2,小于3,直接前进rank+2。
- level2层级后续没有节点了,索引向下。
- 来到level1级别的的结点2的索引位置,继续向前发现节点等于3直接前进,rank+1。
自此,整个搜索过程就完成了,最终返回的结果就是2+1即3:
对应的我们给出等级查询的源码,读者可参考上述步骤并结合笔者的注释了解过程:
5. 跳表的删除操作
跳表的节点删除操作主要是完成以下3件事:
- 删除节点。
- 将删除节点的前后节点关联,并维护两者之间的跨度。
- 更新跳表索引,如果索引上没有任何节点的索引,则直接删除。
我们以下面这张图为例,假设我们想删除score为1.5的节点,对应步骤为:
- 从最高层索引开始,找到各层索引小于1.5的最大值对应的节点,以笔者为例update[2]记录header,update[1]记录header地址,update[0]记录索引1的地址。
- 基于上述update数组,update[2]即3级索引中找到的指针header,但是其前驱节点并不是1.5,所以进行span减1的操作,表示后续1.5被删除之后跨度为2。
- 索引2级中小于1.5的最大值也是header,其前驱节点是1.5,此时我们就需要修改一下1.5索引前后的索引关系,让header指向节点2,跨度为header到node-1.5的距离加上1.5索引到2的距离得到当前header到node-2的距离,最后再减去1,即得到删除1.5后两者之间的距离。
- 1级索引处理步骤和步骤3差不多,这里就不多做强调了。
这里我们贴出跳表节点删除操作的源码,可以看到这段代码会通过update记录各层索引中小于被删节点的最大值对应的节点。然后调用zslDeleteNode处理这各层索引的删除,最后调用zslFreeNode将这个节点删除:
最后我们再贴出删除节点x时,对各级索引进行前后关系维护操作的源码:
结语
Redis 的跳表设计通过多层指针的巧妙运用,不仅实现了高效的查找、插入和删除操作,还保持了较低的空间开销。这种数据结构的优势在于它能够在平均时间复杂度为 O(log n) 的情况下完成上述操作,这使得 Redis 在处理大量数据时依然能够保持高性能。此外,跳表的设计简单直观,易于实现和维护,这也进一步增强了其在实际应用中的吸引力。总之,Redis 跳表的成功案例证明了合理选择和优化数据结构对于构建高效系统的重要性。