1. 什么是跳跃表(skiplist)
跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。Redis
使用跳跃表作为有序集合键(ZSET)的底层实现之一,如果一个有序集合包含的元素数量比较多,又或者有序集合中元素的成员是比较长的字符串时,Redis
就会使用跳跃表来作为有序集合键的底层实现。Redis
只在两个地方用到了跳跃表(skiplist)
- 实现有序集合键
- 在集群节点中用作内部数据结构
1.1 为什么需要跳跃表(skiplist)?
对于一个单链表来讲,即便链表中存储的数据是有序的,如果我们要想在其中查找某个数据,也只能从头到尾遍历链表。这样查找效率就会很低,时间复杂度会很高,是 O(n)
。
如果我们想要提高其查找效率,可以考虑在链表上建索引的方式。每两个结点提取一个结点到上一级,我们把抽出来的那一级叫作索引。
这个时候,我们假设要查找节点 8
,我们可以先在索引层遍历,当遍历到索引层中值为 7
的结点时,发现下一个节点是 9
,那么要查找的节点 8
肯定就在这两个节点之间。我们下降到链表层继续遍历就找到了 8
这个节点。原先我们在单链表中找到 8
这个节点要遍历 8
个节点,而现在有了一级索引后只需要遍历五个节点。
从这个例子里,我们看出,加来一层索引之后,查找一个结点需要遍的结点个数减少了,也就是说查找效率提高了,同理再加一级索引。
从图中我们可以看出,查找效率又有提升。在例子中我们的数据很少,当有大量的数据时,我们可以增加多级索引,其查找效率可以得到明显提升。
像这种链表加多级索引的结构,就是跳跃表!
1.2 为什么只有在元素多/长的时候,使用跳跃表?
从上面我们可以知道,跳跃表在链表的基础上增加了多级索引以提升查找的效率,但其是一个空间换时间的方案,必然会带来一个问题——索引是占内存的。原始链表中存储的有可能是很大的对象,而索引结点只需要存储关键值值和几个指针,并不需要存储对象,因此当节点本身比较大或者元素数量比较多的时候,其优势必然会被放大,而缺点则可以忽略。
2. 跳跃表(skiplist)的数据结构
2.1 跳跃表节点(zskiplistNode)
typedef struct zskiplistNode {
// 后退指针
struct zskiplistNode *backward;
// 分值
double score;
// 成员对象
robj *obj;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
} zskiplistNode;
2.1.1 后退指针(backward)
节点的后退指针(backward 属性)用于从表尾向表头方向访问节点,跟可以一次跳过多个节点的前进指针不同,因为每个节点只有一个后退指针,所以每次只能后退至前一个节点。
具体使用流程如下图:
程序首先通过跳跃表的 tail
指针访问表尾节点,然后通过后退指针访问倒数第二个节点,以此类推,知道后退指针指向 NULL
,至此访问结束。
2.1.2 分值(score)
节点的分值(score 属性)是一个 double
类型的浮点数,跳跃表中的所有节点都按分值从小到大排序。例如后退指针图中的 1.0、2.0、3.0
。
如果两个成员对象的分值相同,则按照成员对象在字典序中的大小,从小到大进行排序。
2.1.3 成员对象(obj)
节点的成员对象(obj 属性)是一个指针,它指向一个字符串对象,而字符串对象则保存着一个 SDS 值。例如后退指针中的 obj#1、obj#2、obj#3
。
在同一个跳跃表中,各个节点保存的成员对象必须是唯一的。
2.1.4 层
skiplistNode
中的 level
数组可以包含多个 zskiplistLevel
元素,程序可以通过这些层来加快访问其他节点的速度,一般来说,level
数组包含的元素越多,访问其他节点的速度越快。
每次创建一个新的跳跃表节点的时候,程序都会随机生成一个介于 1-32
之间的值作为 level
数组的大小,这个大小就是层的“高度”。
2.1.4.1 前进指针(forward)
前进指针用于从表头向表尾方向访问节点,如图虚线和数字所示,图中只描述了其中一种遍历路线。
2.1.4.2 跨度
层的跨度(level[i].span 属性)用于记录两个节点之间的距离。
- 两个节点之间的跨度越大,它们相距的距离越远;
- 指向
NULL
的所有前进指针的跨度都为0
。
2.2 跳跃表(zskiplist)
使用跳跃表(zskiplist)可以在跳跃表节点的基础上,更方便地对整个跳跃表进行处理,比如:
- 快速访问跳跃表的表头节点和表尾节点
- 快速获取跳跃表节点的数量
- …
typedef struct zskiplist {
// 表头节点和表尾节点
struct skiplistNode *header, *tail;
// 表头节点的数量
unsigned long length;
// 表中层数最大的节点的层数
int level;
} zskiplist;
-
header
和tail
指针分别指向跳跃表的表头和表尾节点,通过这两个指针,程序定位表头节点和表尾节点的复杂度为O(1)
; -
length
:用于记录节点的数量; -
level
:用于获取跳跃表中层高最大的那个节点的层数量。
3. 跳跃表 API
函数 | 作用 | 时间复杂度 |
zslFree | 释放给定跳跃表,以及表中包含的所有节点 | O(N),N 为跳跃表的长度 |
zslInsert | 将新节点添加到跳跃表中 | 平均O(logN),最坏O(N),N 为跳跃表的长度 |
zslDelete | 将给定节点从跳跃表中删除 | 平均O(logN),最坏O(N),N 为跳跃表的长度 |
zslGetRank | 返回包含给定成员和分值的节点在跳跃表中的排位 | 平均O(logN),最坏O(N),N 为跳跃表的长度 |
zslGetElementByRank | 返回跳跃表在给定排位上的节点 | 平均O(logN),最坏O(N),N 为跳跃表的长度 |
zslFirstInRange | 给定一个分值范围,返回跳跃表中第一个符合这个范围的节点 | 平均O(logN),最坏O(N),N 为跳跃表的长度 |
zslLastInRange | 给定一个分值范围,返回跳跃表中最后一个符合这个范围的节点 | 平均O(logN),最坏O(N),N 为跳跃表的长度 |
zslDeleteRangeByScore | 给定一个分值范围,删除跳跃表中所有在这个范围之内的节点 | O(N),N 为被删除节点的数量 |
zslDeleteRangeByRank | 给定一个排位范围,删除跳跃表中所有在这个范围之内的节点 | O(N),N 为被删除节点的数量 |
时间复杂度 ≠ O(1) 的 API
4. 参考
- Redis数据结构 — 跳跃表
- 《Redis 设计与实现》