在阅读《Redis设计与实现》的时候,发现它在阐述跳跃表的原理的时候是简略而过,出于对技术的深究性,我决定还是深入理解一下跳表的原理,并整理讲述给大家听
Redis中的跳表
在Redis的有序集合中,它的底层数据结构是跳表+字典,字典用于存储键与值的映射关系,可以在查找键对应的值的时候使时间复杂度降到o(1)。而跳表的数据结构是为了可以实现ZRANGE等范围查询功能,因为跳表在范围查询里面效率非常高,这是为什么呢,下面我们就将一一剖析跳表这一数据结构
从单链表到跳表
对于一个单链表来讲,即便链表中存储的数据是有序的,如果我们要想在其中查找某个数据,也只能从头到尾遍历链表。这样查找效率就会很低,时间复杂度会很高,是O(n)。
建立索引优化
现在假如我们每两个节点提取一个节点建立一个索引,作为该链表的上一层,那么我们的查询效率会大大增加
比如以下的场景,比如在原来的链表中,我们查询9这个节点,需要逐个比较从1->3->6->7->8->9总共比较6个节点。但是假如有新的链表辅助,我们在新链表中从1->6->8->10的时候发现9在8->10这个区间内,所以我们就可以再利用8从原来的链表中进行查询,所以流程为1->6->8->9,总共需要比较4个节点,从而利用消耗空间换时间假如我们再多建立n层索引,那么我们需要查找的节点将会降低,所以消耗的时间也会降低
在这样的索引情况下,我们只需要按照1->8->9的过程进行查找,总共只需要比较3个节点
随着节点数的增多,跳表实现的效率会大大提升,那么下面我们就研究一下跳表的插入、查询、删除的时间复杂度
插入、查询、删除的时间复杂度
在进行时间复杂度的解析之前,我们需要了解在一个有n个节点的跳表中,总共建立了多个条索引,它们的情况是怎么样的?
如果两个结点会抽出一个结点作为上一级索引的结点,那第一级索引的结点个数大约就是n/2,第二级索引的结点个数大约就是n/4,第三级索引的结点个数大约就是n/8,依次类推,也就是说,第k级索引的结点个数是第k-1级索引的结点个数的1/2,那第k级索引结点的个数就是n/(2^k)。
假设索引有h级,最高级的索引有2个结点。通过上面的公式,我们可以得到n/(2^h)=2,从而求得h=log2(n-1)
如果每一层都要遍历m个结点,那在跳表中查询一个数据的时间复杂度就是O(m*logn),但实际情况是我们每一层的遍历的元素个数不会超过3个。这是因为我们是每两个节点建立一个索引,所以遍历超过3个节点的情况是不存在,如果遍历超过了3个,那就是因为上一层遍历的过程出错了,我们可以看下面的例子:
我们考虑一种情况,就是我们在查找10这个节点时,在L2以1->6->8->10的过程查询,在L2层遍历了4个节点。实际上这种情况是不可能存在的,因为我们在L3的时候已经排除了6这个节点,也就是L3这一层的存在保证了L2这一层只会从8->13这个范围进行遍历,保证了L2遍历的节点个数不会超过3个,所以在每一层中遍历节点超过4个的情况就不可能存在了。
所以可以得出我们的结论,跳跃表查询一个节点的时间复杂度是O(logn)
在解决了查询的问题之后我们来察看一下插入的时间复杂度
在单链表中,一旦定位好要插入的位置,插入结点的时间复杂度为O(1)。但是,这里为了保证原始链表中数据的有序性,我们需要先找到要插入的位置,这个查找操作为o(n),所以在单链表中插入的耗时为o(n)
但是对于跳表来说,我们查找某个结点的的时间复杂度是O(logn),所以这里查找某个数据应该插入的位置,方法也是类似的,时间复杂度也是O(logn)。
但是在插入之后我们还需要维护跳表的索引,假如不更新索引,那么我们的跳表就失去它存在的意义了,还可能会退化成单链表。所以我们需要更新我们的索引。
索引的添加策略:当我们往跳表中插入数据的时候,我们可以选择同时将这个数据插入到部分索引层中。我们通过一个随机函数,来决定将这个结点插入到哪几级索引中,比如随机函数生成了值K,那我们就将这个结点添加到第一级到第K级这K级索引中。
所以我们插入一个节点的时间复杂度也是o(logn)
同样的,删除一个节点情况与插入一个节点的情况是相类似的,我们在寻找对应节点的过程中顺便删除对应的节点,复杂度与查找一个节点的复杂度是一样的,所以删除一个节点的时间复杂度也是o(logn)
那为什么Redis没有用红黑树替代跳跃表实现呢?我认为的原因有以下几个:
- 在Redis中会有大量的范围查询的功能需求,红黑树在范围查找这方面的效率不如跳跃表,所以在范围查询方面使用跳跃表更优
- 红黑树的数据结构更为复杂,红黑树的插入和删除操作可能会引发子树的调整,逻辑复杂,而skiplist的插入和删除只需要修改相邻节点的指针,操作简单又快速
- 一般来说,红黑树每个节点包含2个指针,而skiplist每个节点包含的指针数目平均为1/(1-p),具体取决于参数p的大小。如果像Redis里的实现一样,取p=1/4,那么平均每个节点包含1.33个指针,比平衡树更有优势。