跳跃表 (skiplist) 是一种有序数据结构, 它通过在每个节点中维持多个指向其他节点的指针, 从而达到快速访问节点的目的.
跳跃表支持平均 O(log N) 最坏 O(N) 复杂度的节点查找, 还可以通过顺序性操作来批量处理节点.
在大部分情况下, 跳跃表的效率可以和平衡树相媲美, 并且因为跳跃表的实现比平衡树要来得更为简单, 所以有不少程序都使用跳跃表来代替平衡树.
Redis 使用跳跃表作为有序集合键的底层实现之一: 如果一个有序集合包含的元素数量比较多, 又或者有序集合中元素的成员 (member) 是比较长的字符串时, Redis 就会使用跳跃表来作为有序集合键的底层实现.
和链表, 字典等数据结构被广泛地应用在 Redis 内部不同, Redis 只在两个地方用到了跳跃表, 一个是实现有序集合键, 另一个是在集群节点中用作内部数据结构, 除此之外, 跳跃表在 Redis 里面没有其他用途.
1. 跳跃表节点
跳跃表节点由 redis.h/zskiplistNode
结构定义:
typedef struct zskiplistNode {
// 后退指针
struct zskiplistNode *backward;
// 分值
double score;
// 成员对象
robj *obj;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
} zskiplistNode;
1.1 层
跳跃表节点的 level 数组可以包含多个元素, 每个元素都包含一个指向其他节点的指针, 程序可以通过这些层来加快访问其他节点的速度, 一般来说, 层的数量越多, 访问其他节点的速度就越快.
每次创建一个新跳跃表节点的时候, 程序都根据幂次定律 (power law,越大的数出现的概率越小) 随机生成一个介于 1 和 32 之间的值作为 level 数组的大小, 这个大小就是层的 “高度”.
1.2 前进指针
每个层都有一个指向表尾方向的前进指针 (level[i].forward 属性), 用于从表头向表尾方向访问节点.
1.3 跨度
层的跨度(level[i].span 属性)用于记录两个节点之间的距离:
- 两个节点之间的跨度越大, 它们相距得就越远.
- 指向 NULL 的所有前进指针的跨度都为 0 , 因为它们没有连向任何节点.
跨度用来计算排位 (rank) : 在查找某个节点的过程中, 将沿途访问过的所有层的跨度累计起来, 得到的结果就是目标节点在跳跃表中的排位.
1.4 后退指针
节点的后退指针 (backward) 用于从表尾向表头方向访问节点, 它每次只能后退至前一个节点.
1.5 分值和成员
节点的分值 (score) 是一个 double 类型的浮点数, 跳跃表中的所有节点都按分值 从小到大
排序.
节点的成员对象 (obj) 是一个指针, 它指向一个 字符串对象
, 而字符串对象则保存着一个 SDS 值.
在同一个跳跃表中, 各个节点保存的 成员对象必须是唯一的
, 但是多个节点保存的 分值却可以是相同的
: 分值相同的节点将按照成员对象在字典序中的大小来进行排序, 成员对象较小的节点会排在前面(靠近表头的方向), 而成员对象较大的节点则会排在后面(靠近表尾的方向).
2. 跳跃表
使用一个 zskiplist
结构来持有节点, 可以更方便地访问跳跃表的表头节点和表尾节点, 又或者快速地获取跳跃表节点
的数量 (也即是跳跃表的长度) 等信息.
zskiplist
结构的定义如下 (redis.h/zskiplist
):
typedef struct zskiplist {
// 表头节点和表尾节点
struct zskiplistNode *header, *tail;
// 表中节点的数量
unsigned long length;
// 表中层数最大的节点的层数
int level;
} zskiplist;
header
和 tail
指针分别指向跳跃表的表头和表尾节点, 程序定位表头节点和表尾节点的复杂度为 O(1) .
length
属性记录节点的数量, 可在 O(1) 复杂度内返回跳跃表的长度.
level
则用于在 O(1) 复杂度内获取跳跃表中层高最大的那个节点的层数量, 注意表头节点的层高并不计算在内.
3. 跳跃表 API
函数 | 作用 | 时间复杂度 |
---|---|---|
zslCreate | 创建一个新的跳跃表. | O(1) |
zslFree | 释放给定跳跃表,以及表中包含的所有节点. | O(N),N 为跳跃表的长度. |
zslInsert | 将包含给定成员和分值的新节点添加到跳跃表中. | 平均 O(log N),最坏 O(N),N 为跳跃表长度. |
zslDelete | 删除跳跃表中包含给定成员和分值的节点. | 平均 O(log N),最坏 O(N),N 为跳跃表长度. |
zslGetRank | 返回包含给定成员和分值的节点在跳跃表中的排位. | 平均 O(log N),最坏 O(N),N 为跳跃表长度. |
zslGetElementByRank | 返回跳跃表在给定排位上的节点. | 平均 O(log N),最坏 O(N),N 为跳跃表长度. |
zslIsInRange | 给定一个分值范围(range), 比如0 到15 ,20 到28 ,诸如此类, 如果给定的分值范围包含在跳跃表的分值范围之内, 那么返回1 ,否则返回0 . | 通过跳跃表的表头节点和表尾节点, 这个检测可以用 O(1) 复杂度完成. |
zslFirstInRange | 给定一个分值范围, 返回跳跃表中第一个符合这个范围的节点. | 平均 O(log N),最坏 O(N).N 为跳跃表长度. |
zslLastInRange | 给定一个分值范围, 返回跳跃表中最后一个符合这个范围的节点. | 平均 O(log N),最坏 O(N).N 为跳跃表长度. |
zslDeleteRangeByScore | 给定一个分值范围, 删除跳跃表中所有在这个范围之内的节点. | O(N),N 为被删除节点数量. |
zslDeleteRangeByRank | 给定一个排位范围, 删除跳跃表中所有在这个范围之内的节点. | O(N),N 为被删除节点数量. |
4. 总结
- 跳跃表是有序集合的底层实现之一, 除此之外它在 Redis 中没有其他应用;
- Redis 的跳跃表实现由 zskiplist 和 zskiplistNode 两个结构组成, 其中 zskiplist 用于保存跳跃表信息(比如表头节点, 表尾节点, 长度), 而 zskiplistNode 则用于表示跳跃表节点;
- 每个跳跃表节点的层高都是 1 至 32 之间的随机数;
- 在同一个跳跃表中, 多个节点可以包含相同的分值, 但每个节点的成员对象必须是唯一的;
- 跳跃表中的节点按照分值大小进行排序, 当分值相同时, 节点按照成员对象的大小进行排序.