Redis数据结构之跳跃表和整数集合
跳跃表(skiplist)是一种有序数据结构,它通过在每个节点维持多个指向其他节点的指针实现快速访问节点的目的。跳跃表支持平均O(logN),最坏O(N)时间复杂度的查找,大多数情况下跳跃表的查找效率和可以和平衡二叉树媲美,而且实现简单,因此不少程序都使用跳跃表来替代平衡二叉树。
Redis使用 跳跃表来作为有序集合键的底层实现之一,如果一个有序集合包含的元素数量比较多,又或者元素的成员是比较长的字符串时,Redis就会使用跳跃表作为有序集合键的底层实现。另一个作用是在集群节点中用作内部数据结构。
1 跳跃表的实现
Redis的跳跃表由Redis.h/zskiplistNode和Redis.h/zskiplist两个结构来定义,前一个表示跳跃表节点,后一个表示跳跃表节点的相关信息。
1.1 跳跃表节点
在Redis.h/zskiplistNode定义:
typedef struct zskiplistNode {
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned int span;
} level[];
struct zskiplistNode *backward;
double score;
robj *obj;
}
跳跃表的level数组包含了多个指向其他跳跃表节点的指针,跳跃表通过访问这些层来加快访问其他节点速度,一般来说,层的数量越多,访问其他节点的速度越快。每次创建一个跳跃表节点,程序就会根据幂次定律(大的数概率小)随机生成1到32之间的数作为level数组的大小,即层的高度。
前进指针level[i].forward用于从表头向表尾方向访问指针。
跨度level[i].span用于记录两个节点之间的距离,两个节点之间的跨度越大,距离越远,指向NULL的前进指针的跨度都为0,因为它们没有连向任何节点。跨度是用来计算排位(rank)的,将查找一个节点经历的所有跨度相加,得到的就是目标节点在跳跃表中的排位。
后退指针backward用于从表尾向表头访问指针,由于每个节点只有一个后退指针,因此每次只能后退至前一个节点,在回退指针指向NULL时,程序就知道遍历结束。
分值(score)是一个浮点数,每个节点都按分值从小到大排列。
成员对象(obj)指向一个字符串对象,字符串对象保存了一个SDS值。
同一跳跃表中,各节点保存的成员对象是唯一的,但是分值可以是重复的:分值相同的节点按成员对象在字典中的大小来排列,较小的节点放在前面,较大的节点放在后面。
1.2 跳跃表
仅靠多个跳跃表节点就可以形成跳跃表,但是如果用zskiplist结构来持有跳跃表,程序会更加方便地对整个跳跃表进行处理,比如快速访问表头节点和表尾节点,快速获取节点中的数量等信息。
Redis.h/zskiplist结构如下
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
} zskiplist;
header指针和tail指针可以快速定位表头节点和表尾节点,程序查询的时间复杂度为O(1)。
length属性记录节点的数量,level属性记录层高最高的节点的层数量,表头节点的层高不计算在内。
仅有跳跃表节点组成的跳跃表:
由跳跃表节点和zskiplist组成的跳跃表:
1.3 跳跃表API
函数 | 作用 | 时间复杂度 |
---|---|---|
zslCreate | 创建一个新的跳跃表 | O(1) |
zslFree | 释放给定跳跃表,以及表中包含的所有节点 | O(N),N为跳跃表的长度 |
zslInsert | 包含给定成员和分值的新节点添加到跳跃表中 | 平均O(IogN),最坏O(N),N为跳跃表长度 |
zslDelete | 删除跳跃表中包含给定成员和分值的节点 | 平均O(IogN),最坏O(N),N为跳跃表长度 |
zslGetRank | 返回包含给定成员和分值的节点在跳跃表中的排位 | 平均O(IogN),最坏O(N),N为跳跃表长度 |
zslGetElementByRank | 返回跳跃表在给定排位上的节点 | 平均O(IogN),最坏O(N),N为跳跃表长度 |
zslIsInRange | 给定一个分值范围(range),比如0到15,20到28,诸如此类,如果跳跃表中有至少一个节点的分值在这个范围之内,那么返回1,否则返回0 | 通过跳跃表的表头节点和表尾节点,这个检测可以用O(1)复杂度完成 |
zslFirstInRange | 给定一个分值范围,返回跳跃表中第一个符合这个范围的节点 | 平均O(IogN),最坏O(N),N为跳跃表长度 |
zslLastInRange | 给定一个分值范围,返回跳跃表中最后一个符合这个范围的节点 | 平均O(IogN),最坏O(N),N为跳跃表长度 |
zslDeleteRangeByScore | 给定一个分值范围,删除跳跃表中所有在这个范围之内的节点 | O(N),N为被删除节点数量 |
zslDeleteRangeByRank | 给定一个排位范围,删除跳跃表中所有在这个范围之内的节点 | O(N),N为被删除节点数量 |
2 整数集合
整数集合(intset)是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis会用整数集合作为集合键的底层实现。
举个例子:
redis> SADD numbers 1 3 5 7 9
(Integer) 5
redis> OBJECT ENCODING numbers
"intset"
2.1 整数集合的实现
整数集合是Redis保存整数值的集合抽象数据结构,保存类型为int16_t,int32_t,int64_t。每个inset.h/inset结构表示一个整数集合:
typedef struct intset {
uint32_t encoding;
uint32_t length;
int8_t contents[];
} intset;
contents数组是整数集合底层的实现:整数集合的每个元素都是contents数组的一个数组项,每个项按值的大小从小到大有序地排列,并且数组中不包含重复项。
length属性记录了元素个数,也就是contents数组的长度。虽然contents属性声明为int8_t,但是contents数组的真正类型取决于encoding属性的值。
- 如果encoding属性值为INSERT_ENC_INT16,那么contents属性就是int16_t类型的数组(-32768~32767)
- 如果encoding属性值为INSERT_ENC_INT32,那么contents属性就是int32_t类型的数组(-2147483648~2147483647)
- 如果encoding属性值为INSERT_ENC_INT64,那么contents属性就是int64_t类型的数组(-372036854775808~372036854775807)
例子如图所示:
2.2 升级
每当我们要把新元素添加到整数集合里面时,且新元素类型比旧元素类型要长时,整数集合先进行升级(upgrade),然后才能将新元素添加到集合里面。如上图所示。虽然1,3,5都可以用int16_t类型来表示,但是-2675256175807981027需要用int64_t类型才能表示,因此该整数集合就进行了升级。升级整数集合并添加新元素分为三步进行:
- 根据新元素的类型,拓展整数集合底层数组的大小,并未新元素分配空间
- 将底层数组原有元素类型都转换称新元素类型,将类型转换后的元素放到相应的位置上,在放置元素过程中,维持底层数组的有序性质不变
- 将新元素添加到底层数组中
例子:假如有图中的整数集合,类型是int16_t,占用空间为48比特,这时要将65535添加到集合里面,65535至少要int32_t才能表示。
首先根据新元素类型对底层数组进行空间重分配
为了保证元素的有序性,先把元素3往后移,放到64位至95位
接着,把元素2和元素1进行移动
将新元素65535添加到96位至127位
最后,程序将encoding属性改成INTSET_ENC_INT32,并将length属性改成4。容易发现,向整数集合添加元素的时间复杂度为O(N)。而且注意到,新元素如果不能用旧元素的类型表示,那么插入新元素的位置索引要么是0,要么是length-1。
2.2.1 升级的好处
- 提升灵活性
C语言是静态类型语言,为了避免类型错误,通常不会把不同类型的值放到同一数据结构中。而整数集合可以通过升级底层数组来适应新元素,不必担心出现类型错误,这种做法非常灵活。 - 节约内存
要让一个数组可以同时保存int16_t、int32_t、int64_t类型的整数,可以直接使底层数组类型为int64_t,但这样的话,在整数集合只保存了int16_t类型元素,还没有插入int32_t、int64_t数据时,出现浪费内存的空间。
整数集合既让集合能同时保存三种类型的整数,又能确保升级操作只在有需要时进行,尽量节省内存。
2.3 降级?NO
整数集合不支持降级操作,一旦进行了升级,编码就会一直保持升级后的状态。
2.4 整数集合API
函数 | 功能 | 时间复杂度 |
---|---|---|
intsetNew | 创建一个新的压缩列表 | O(1) |
insetAdd | 将给定元素添加到整数集合里面 | O(N) |
insetRemove | 从整数集合中移除给定元素 | O(N) |
insetFind | 检查给定值是否存在于集合 | 因为底层数组有序,可以通过二分查找进行,时间复杂度O(logN) |
insetRandom | 从整数集合中随机返回一个元素 | O(1) |
insetGet | 取出底层数组在给定索引上的元素 | O(1) |
insetLen | 返回整数集合包含的元素个数 | O(1) |
insetBlobLen | 返回整数集合占用的内存字节数 | O(1) |