Redis 当一个有序集合包含的元素数量多,又或者有序集合中的元素的成员是比较长的字符串时,Redis就会使用跳跃表作为有序集合键的底层实现
跳跃表的基本思想
首先我们看一个普通的链表结构:
这个链表中,如果要搜索一个数,需要从头到尾比较每个元素是否匹配,直到找到匹配的数为止,即时间复杂度是 O(n)。同理,插入一个数并保持链表有序,需要先找到合适的插入位置,再执行插入,总计也是 O(n)的时间。
但假如我们每相邻两个节点之间就增加一个指针,让指针指向下一个节点,如下图:
如上图,我们新创建一个链表,它包含的元素为前一个链表的偶数个元素。这样在搜索一个元素时,我们先在上层链表进行搜索,当元素未找到时再到下层链表中搜索。例如搜索数字 19 时的路径如下图:
先在上层中搜索,到达节点 17 时发现下一个节点为 21,已经大于 19,于是转到下一层搜索,找到的目标数字 19。
利用同样的方式,我们可以在新产生的链表上,继续为每两个相邻的节点增加一个指针,从而产生第三层链表。同理,我们可以不断地增加层数,来减少搜索的时间:
在上面的 4 层链表中搜索 25,在最上层搜索时就可以直接跳过 21 之前的所有节点,因此十分高效。
更进一步的跳跃表
可以想象,当链表足够长,上面的多层链表结构可以帮助我们跳过很多下层节点,从而加快查找的效率。
但是,这种方法在插入数据的时候有很大的问题。新插入一个节点之后,就会打乱上下相邻两层链表上节点个数严格的 2:1 的对应关系。如果要维持这种对应关系,就必须把新插入的节点后面的所有节点 重新进行调整,这会让时间复杂度重新蜕化成 O(n)。删除数据也有同样的问题。
因此跳表(skip list)表示,我们就不强制要求 1:2 了,一个节点要不要被索引,建几层的索引,都在节点插入时由抛硬币决定。当然,虽然索引的节点、索引的层数是随机的,为了保证搜索的效率,要大致保证每层的节点数目与上节的结构相当。下面是一个随机生成的跳跃表:
每一个节点的层数是随机出来的,而且新插入一个节点并不会影响到其他节点的层数,因此,插入操作只需要修改节点前后的指针,而不需要对多个节点都进行调整,这就降低了插入操作的复杂度。
关于随机层数(抛硬币算法的redis实现)
对于每一个新插入的节点,都需要调用一个随机算法给它分配一个合理的层数,源码在 t_zset.c/zslRandomLevel(void) 中被定义
int zslRandomLevel(void) {
int level = 1;
while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
level += 1;
return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}
直观上期望的目标是 50% 的概率被分配到 Level 1,25% 的概率被分配到 Level 2,12.5% 的概率被分配到 Level 3,每一层的晋升率都是 50%。
小结
1.各种搜索结构提高效率的方式都是通过空间换时间得到的。
2.跳跃表最终形成的结构和搜索树很相似。
3.跳跃表通过随机的方式来决定新插入节点来决定索引的层数。
4.跳跃表搜索的时间复杂度是 O(logn),插入/删除也是。
二次解释
下图是一个有序链表:
如果将有序链表的部分节点分层,每层都是一个有序链表。查找时优先从最高层开始向后查找,当到达某节点时,如果next节点值大于要查找的值或next指针指向NULL,则从当前节点下降一层继续向后找,这种“跳过一些节点”的方式就是跳表查找的思路。如下图所示是一个有序分层链
除第0层外的其他层所有节点均有一个down指针指向下层节点
第0层包含了所有数据
其他层的每个节点一定都是从其下一层中选出来的
这种链表加多级索引的结构就是跳表。
查找过程
上图中,如果要查找值为51的节点,步骤如下:
(1)从最高层即第2层开始,1节点比51小,继续向后找
(2)21节点比51小,且21节点的next指针为null,跳到第1层查找
(3)41节点比51节点小,继续向后查找
(4)61节点比51节点大,跳到第1层查找
(5)51节点等于51,找到返回
跳表的思想就是:将有序集合的部分节点分层,由最上层一次向后查找,如果本层的next节点大于要查找的节点,或next值为null,则向下一层查找,直到找到或返回空。
Redis有序集合中的跳表
下图就是一个Redis有序集合的跳表结构:
跳表节点的结构体定义如下:
typedef struct zskiplistNode {
sds ele;
double score;
struct zskiplistNode *backward;
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned long span;
} level[];
} zskiplistNode;
ele:存储字符串类型的数据,如学生姓名
score:用于存储排序的分值,如学生成绩
backward:指向当前节点的前一个节点,头结点的backward值为null,用于从后往前遍历跳跃表
level:柔性数组,forward指向该节点对应的下一层节点
span:forward指向的节点与本节点之间的元素个数,span值越大,跳过的节点数越多
跳表结构:
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
} zskiplist;
header:指向跳表的头节点,头结点的level为64层,是Redis跳表的最大层数
tail:指向跳表的尾节点
length:存储跳表节点个数,不包括头结点
level:跳跃表的高度,即最大层数,不包括头结点
跳表的插入
【注:图片来自 https://blog.csdn.net/universsky2015/article/details/102728114】
上图展示了跳表插入的一个插入过程,跳表的插入过程也需要像平衡树一样维护“平衡性”,如果所有插入的数据都在11到37之间,则跳表的查询时间复杂度很快就会退化成在单链表上的查询时间复杂度了。在Redis有序集合中维护跳表“平衡性”采用的是产生一个随机数k,将要添加的节点添加至第1层至第k层,上图元素区域被拉长就是表明该元素同时也插入到了其他高层上。即zskiplistNode的level层数是随机产生的一个值。
时间复杂度分析
如果链表里有n个结点,会有多少级索引呢?
假设平均每隔两个结点会抽出一个结点作为上一级索引的结点,那第一级索引的结点个数大约就是n/2,第二级索引的结点个数大约就是n/4,第三级索引的结点个数大约就是n/8,依次类推,也就是说,第k级索引的结点个数是第k-1级索引的结点个数的1/2,那第k级索引结点的个数就是n/(2^ k)。
假设索引有h级,最高级的索引有2个结点,则n/(2^ h)=2得到h=log2n-1,所以包含原始数据这一层,跳表的高度就是log2n。假设每层都要遍历m个节点,则在跳表中查找一个数据的时间复杂度就是O(m*logn)。那m的值是多少呢?
按照上面的说法“假设平均每隔两个结点会抽出一个结点作为上一级索引的结点”,可以得到下面这张图:
假设我们要查找的数据是x。在第k级索引中遍历到y结点之后,发现x大于y,小于后面的结点z,所以我们通过y的down指针,从第k级索引下降到第k-1级索引。在第k-1级索引中,y和z之间只有3个结点(包含y和z),所以,我们在K-1级索引中最多只需要遍历3个结点,依次类推,每一级索引都最多只需要遍历3个结点。
因此,跳表查找的平均时间复杂度就是O(logn),和二分查找的平均时间复杂度一样。
空间复杂度分析
按照分析时间复杂度的假设: “假设平均每隔两个结点会抽出一个结点作为上一级索引的结点”,原始数据有n个,则第1层索引有n/2个节点、第2层索引有n/4个节点…、第k层有n/(2^ k)个节点,依次累加,得到n-2。所以跳表的空间复杂度是O(n),即n个数据用跳表的结构存储,还需要额外n个节点的存储空间。