Grape
所有视频:https://segmentfault.com/a/11...面试
你们想象一下下面这种场景:redis
面试官:咱们有一个有序的数组2,5,6,7,9,咱们要去查7,设计一个算法。 考生:第一眼看到相信你们都会看出来是二分查找,O(logN)就完事了。 面试官:那么接下来咱们把这个数组换成链表呢(2->5->6->7->9)? 考生:这简单,二叉树,一样logN。 面试官:那么请手写一下完整代码! 考生:卒
想象一下,给你一张草稿纸,一只笔,一个编辑器,你能当即实现一颗红黑树,或者AVL树出来吗? 很难吧,这须要时间,要考虑不少细节,要参考一堆算法与数据结构之类的树,还要参考网上的代码,至关麻烦。算法
回去以后,小明很难过,又不想被二叉树所折磨,想要找一个方法来代替二叉树,在他的不懈努力之下,终于,找出了替代红黑树的方法,它叫作skiplist。segmentfault
怎么解决的呢?
首先,表是处于一个初始状态的,没有任何一个元素,相似于下图:
那么,咱们继续插入一个元素2,那么它就变成了这样。
而后咱们抛硬币,结果是正面,那么咱们要将2插入到L2层,以下图:
继续抛硬币,结果是反面,那么元素2的插入操做就中止了,插入后的表结构就是上图所示。接下来,咱们插入元素5,跟元素2的插入同样,如今L1层插入5,以下图:
接下来继续抛硬币,是正面的话就上升一层,不然就终止,继续插入其余新的元素。
那么最后,咱们建形成的样子就以下图所示。
这样子就构形成了skiplist。固然由于规模小,结果极可能不是一个理想的跳跃表。可是若是元素个数n的规模很大,学过几率论的同窗都知道,最终的表结构确定很是接近于理想跳跃表。
这样是否是很简单?
回归正题,咱们如何查找到6呢?很简单,咱们看首先和6比较,发现7大于6,咱们就向后走,发现相等就找到了节点7.固然,若是咱们找5的话就是和6比完以后降到L2,而后和2比,比2大比6小,继续降级,找到5。
小明同窗是一个很会触类旁通的人,既然都知道查找这么简单了,就看看插入吧,等把增删改查都解决了,妈妈就不再用担忧个人红黑树了。数组
接下来咱们就看看插入,咱们要插入一个4,怎么办呢?
从最高层开始找到每一层比4大的节点的前一个值,而后投硬币,随机选择层数后插入,举个例子这个值为4.那么插入以后就是下图所示。数据结构
咱们发现,他会新增一层,而且会在同层级之间进行链接。而后就完成了插入操做。编辑器
删除操做:
删除操做相似于插入操做,包含以下3步:一、查找到须要删除的结点 二、删除结点 三、调整指针。性能
到此,Skiplist的增删改查就很明确了,可是知其然咱们也得知其因此然,小明同窗不抛弃不放弃,想要知道他是怎么样实现的,以及在上边过程当中本身的问题。学习
1. 为何要投硬币?
咱们先解释一下投硬币这个流程:跳跃表节点的层数限制在了64(在redis5.0以前是32),若想超过64层得连续64次抛硬币都获得正面,这得有足够多的节点,redis限定了抛硬币正面的几率为1/4,因此到达64层的几率为(1/2)^128,通常一台64位的计算机能拥有的最大内存也没法存储这么多zskiplistNode,因此对于基本使用 64层的上限已经足够高了,再高也不必 浪费头节点的内存。因此,投硬币是为了让数据尽可能都在低的层级以达到节省内存的目的。spa
2. 跳跃表是什么?在哪用?
跳跃表( skiplist) 是一种有序的数据结构, 它经过在每一个节点中维持多个指向其余节点的指针,从而达到快速访问节点的目的。跳跃表支持平均O(logN),最坏O(N)复杂度的节点查找. 大部分状况下,跳跃表的效率能够和平衡树想媲美,而且跳跃表的实现比平衡树更为简单。
Redis 使用跳跃表做为有序集合键的底层实现之一, 若是一个有序集合包含的元素数量较多,或者有序集合中元素的成员是比较长的字符串, Redis 会使用跳跃表来做为有序集合的底层实现。
那跳表这么棒在Redis中用到的地方确定很是多吗?答案是否认的,Redis 只在两个地方用到了跳跃表,一个是实现有序集合键,另外一个是在集群节点中用做内部数据结构, 除此以外,跳跃表在 Redis 中没有其余用途。
3. 跳跃表是怎么实现的?
咱们来看一看skiplist的源码:
typedef struct zskiplistNode { sds ele; //元素 double score; //分值 struct zskiplistNode *backward; //后退指针,后退指针用于从表尾向表头访问节点,跟能够一次跳过多个节点的前进指针不一样,每一个节点只有一个后退指针 struct zskiplistLevel { struct zskiplistNode *forward; //前进指针,每一个层都有一个指向表尾方向的指针.用于从表头向表尾方向访问节点 unsigned long span; //跨度,层的跨度用于记录两个节点之间的距离. 两个节点之间的跨度越大,它们距离越远;指向 NULL 的节点的跨度为0 } level[]; } zskiplistNode; //跳跃表的 level 数组能够包含多个元素,每一个元素都包含一个指向其余节点的指针,程序能够经过这些指针加快访问速度 //通常来讲,层的数量越多,访问其余节点的速度越快 //每次建立一个新跳跃表节点时,程序会根据幂次定律(越大的数出现的几率越小)随机生成一个介于1 和 64 之间的值做为 level 数组的大小,这个大小就是层的高度 typedef struct zskiplist { struct zskiplistNode *header, *tail; //表头和表尾指针 unsigned long length; //节点的数量 int level; //层数最大的节点的层数 } zskiplist;
由此咱们能够得出skiplist内存结构图以下:
抽象内存结构图以下:
另外呢? 咱们在gdb有序集合zset代码的时候,发现程序会在建立skiplist的以前会先建立一个字典dict。那么,这个dict的做用是什么呢?dict的做用呢是一个hashtable,用来映射元素与zset中分值score的关系。拥有这个映射表,咱们去查找一个元素的分值时间复杂度就变成了O(1)。
4. redis使用跳跃表而不是平衡树的缘由
skiplist和各类平衡树(如AVL、红黑树等)的元素是有序排列的,而哈希表不是有序的。所以,在哈希表上只能作单个key的查找,不适宜作范围查找。所谓范围查找,指的是查找那些大小在指定的两个值之间的全部节点。
在作范围查找的时候,平衡树比skiplist操做要复杂。在平衡树上,咱们找到指定范围的小值以后,还须要以中序遍历的顺序继续寻找其它不超过大值的节点。若是不对平衡树进行必定的改造,这里的中序遍历并不容易实现。而在skiplist上进行范围查找就很是简单,只须要在找到小值以后,对第1层链表进行若干步的遍历就能够实现。
平衡树的插入和删除操做可能引起子树的调整,逻辑复杂,而skiplist的插入和删除只须要修改相邻节点的指针,操做简单又快速。
从内存占用上来讲,skiplist比平衡树更灵活一些。通常来讲,平衡树每一个节点包含2个指针(分别指向左右子树),而skiplist每一个节点包含的指针数目平均为1/(1-p),具体取决于参数p的大小。若是像Redis里的实现同样,取p=1/4,那么平均每一个节点包含1.33个指针,比平衡树更有优点。
查找单个key,skiplist和平衡树的时间复杂度都为O(log n),大致至关;而哈希表在保持较低的哈希值冲突几率的前提下,查找时间复杂度接近O(1),性能更高一些。因此咱们日常使用的各类Map或dictionary结构,大都是基于哈希表实现的。
从算法实现难度上来比较,skiplist比平衡树要简单得多。
最后,学以至用,知道skiplist是怎么回事,咱们还须要知道它的老东家在怎么用它,你们能够想一下Redis中的ZADD,ZRANGE,ZRANGEBYSCORE等命令是怎么用到它的。
若是想要了解有关跳跃表源码更具体的分析,建议阅读【Redis学习笔记】2018-05-29 redis源码学习之跳跃表。