skiplist介绍
跳表是一种数据结构,它使得包含了n个元素的有序序列的查找和插入的平均时间复杂度都是O(logn),优于数组的O(n)复杂度,快速的查找是通过维护多层次的链表实现的,且与前一层(下面一层)链表的数量相比,每一层的链表元素数量更少
简单来讲跳表就是基于链表实现的有序列表,通过维护一个多层级的链表实现了快速查找的效果,且插入跟删除效率也特别高
为何引入跳表
常见的在一个特定数据集查找指定数据,有一些几种算法
- 有序数组,插入时可以先对数组排序,查询时可以根据二分法降低查找的复杂度,缺点是插入和删除时,为了维护保持元素的有序性,需要进行大量元素的移动操作
- 二叉查找树,既支持高效的二分查找算法,又能快速的插入和删除,但在某些极端条件下,二叉查找树有可能会变成线性链表,即退化为链表结构
- 平衡二叉树,基于二叉查找树的优点,对其缺点进行了改进,引入了平衡二叉树,根据平衡算法的实现具体实现有AVL树、B树(B-Tree)、B+树(B+Tree)、红黑树,但是平衡算法的实现比较复杂且难理解
跳表同样支持高效的查找,并且插入和删除数据的时间复杂度能和平衡二叉树媲美,最重要的是跳表的实现比平衡二叉树简单,
跳表的实现
由于跳表是基于有序链表,所以删除和添加的时候都需要先查询元素,先来看链表的查找逻辑,比如这里有个有序链表-10,8,15,65,86,98,99,在这里面指定数据65,只能从头沿着链表依次比较查找
链表不能像数组那样根据索引访问元素,只能沿着指针依次访问,所以不能使用二分查找进行查询,这个时候如果在原先列表上面加入一层,如果可以实现跳过一些列表查询,就可以提高查询效率了
在上面一层链表中,都会保存指向下个节点的指针,这样我们就可以跳过某些节点进行查找,在数据量比较大的时候,如果层级还是两个层级,效率可能提高的不多,所以可以适量增加层级,效率会直接翻倍,这里最下面一层链表会存储所有的元素,所以新维护的链表只是保存了下一节点的指针数据,在数据量大的情况下,这点空间可以忽略不记,以下是两个层级的跳跃列表
当进行查询元素92的时候,会首先查询第2层也就是最上面一层,
如果查找元素比目标元素还有大,则进行链表下移,不需要遍历当面连边的下面元素,从下一层级开始查找
如果查找元素比右边的元素还有大,则会往右遍历,如果比右边的小,则会从下一层查找
可以看到查找的时候速度是很快的,下面是插入元素144的过程,首先的查找,找到中间位置,并且修改左右元素的指针,
但是棘手的问题来了,也是跳表这个结构查找速度快的前提,插入元素的时候如何判断是否在上面层级插入元素,如果是两个层级中间节点的数量严格按照比例来分配的话,也不现实,因为插入或删除元素的话当前节点以后的节点都得重新调整一遍,这样虽然查询没受影响,但是插入和删除效率会低很多,所以这里的层级是通过随机数生成的,也不是那么的随机,存在概率的问题,这个是论文中计算层级的代码:
如果节点在第i层出现,在第i+1层中出现的概率为p,同时跳表有最大层数限制MaxLevel,而在redis中这个结构p为1/4,最大层级为32,random为0-1之间的数
public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
int level = 1;
Random random = new Random();
while (random.nextFloat() < 0.25f && level < 64) {
level += 1;
}
System.out.println(level);
}
}
经过测试发现level = 1的概率还是比较大的,产生越高节点层数,概率越低,p为1/4,所以这个skiplist相对来说比较扁平
- level=1的概率为1-p
- level>=2的概率为p
- level=2的概率为p(1-p)
上图为节点的平均指针数,因为skiplist的空间换时间的结构
通过以上可以总结中skiplist的几个特性:
- skiplist包含多个层级每层称为一个level,level从0开始递增
- skiplist0层,也就是最底层,应该包含所有的元素
- 每个level是一个有序链表
- level小的层包含level大的层,如果第i层有这个元素,那么第i-1层都有这个元素
Redis中跳表的实现
还得从redis的数据结构说起,众所周知redis的sorted set类型也就是zset,可以进行zadd,zrange,zrangebyscore,并且里面的元素是有序的,去重的,可以从小到大,也可以从大到小
首先来看redis中的实现
obj是当前key的对象。如果为null,需要新建,在新建的过程中会根据redis.conf里面的内容,如果配置文件zset-max-ziplist-entries为0或者当前需要插入的元素大小(序列化之后的字符串长度)超过zset-max-ziplist-value则用zset,否则用ziplist
zset-max-ziplist-entries表示ziplist最大的元素个数,默认值为128
zset-max-ziplist-value表示单个元素的最大值的阈值,单位为字节,默认值为64
可以看出Redis中Sorted Set有两种实现方式,一种是直接创建的zset数据类型,另一种是直接使用ziplist,第一种直接创建zset的时候还创建了一个字段dict,同时创建了skiplist
下面是zset以及skiplist、skiplistnode的数据类型
对象创建完了然后看zsetAdd方法,里面的实现逻辑同样区分是skiplist还是ziplist,这里先主要看skiplist的实现逻辑
首先是根据dictFind查找是否存在元素,但是这个查找不是从skiplist里面查找的,而是从dict里面查找的,如果不存在这个元素,并且选择不是xx,才会选择插入新的元素到skiplist
插入逻辑中首先插入skiplist,然后插入dict,首先看zslInsert
首先会查找每个层级需要插入的位置,根据score大小或者,然后生成随机的level,然后更新插入的每个层级的前进指针(forward)与后退指针(backward)及跨度span,这个累加的span就是rank排名
ziplist
redis的zset在长度较小时采用的是压缩列表而不是跳跃列表,链表是一个个指针指向,内存不连续而且指针还要占用额外的内存,由此引出了ziplist,是由一系列特殊编码的连续内存块组成,可以在任意一端进行压入和弹出操作,
所以ziplist是一种特殊的“双端链表”,结构如下:
zlbytes:记录总的字节数,占用4个字节
zltail:尾偏移量,通过这个可以直接获取尾结点的地址,占用4个字节
zllen:entery的长度,占用2个字节
entry:具体数据,长度由内容确定
zlend:结束标志,固定值:0xff,占用1个字节
ziplist可以看做特殊的双端链表是因为ziplistentry不像普通的链表那样记录前后指针,因为记录两个指针需要占用16个字节,浪费内存,所以使用下面的结构
previous_entry_length:前一节点的长度,占用1个或者5个字节(如果前一节点的长度小于254字节,则用1个字节表示长度,如果前一节点大于254个字节,则用5个字节表示)
encoding:编码属性,记录content的数据类型(字符串或者整数)以及长度,占用1个,2个或者5个字节
content: 负责保存节点的数据,可以是字符串或整数
ziplist中的寻址方式:
正序寻址:当前元素加上自己的Previous_entry_length+encoding+content的字节长度,就是下一个元素的地址
倒序寻址:当前元素减去Previous_entry_length的前一节点的长度,就是上一个元素的地址
ziplist的连锁更新:
当删除压缩列表zl1位置p1的元素entryX,或者在压缩列表zl2的p2位置插入元素entryY会发生什么了?
zl1:由于entryx为128字节,所以entryX+1的previous_entry_length为1个字节,entryX+1为253字节,所以entryX+2的previous_entry_length为1个字节,当删除entryX的时候,元素entryX+1的前一个节点就会变成元素entryX-1,长度为512字节,此时entryX+1的previous_entry_length需要5个字节才能存储entryX-1的长度,则元素entryX+1扩至157字节,而由于entryX+1的长度改变,所以entryX+2的previous_entry_length需要改变,以此类推,由于删除了entryX,导致后面的entryX+1、entryX+2的长度都必须要扩展,而每次扩展都会内存重新分配,效率是很低的,在zl2中插入元素也是同样的道理
标签:字节,ziplist,元素,Redis,列表,链表,查找,entryX From: https://www.cnblogs.com/LiuFqiang/p/18396067参考文档
https://www.cl.cam.ac.uk/teaching/2005/Algorithms/skiplists.pdf
https://redis.io/glossary/redis-ziplist/