SkipList 跳表

转载:https://blog.csdn.net/fw0124/article/details/42780679算法

 

为何选择跳表编程

提及跳表,咱们仍是要从二分查找开始。
二分查找的关键要求有两个,
1 数据可以按照某种条件进行排序。
2 能够经过某种方式,取出该数据集中任意子集的中间值。
可以知足的数据结构主要是有序数组,但对于数据量不断变化的场景来讲,有序数组很难可以高效的进行写入。
链表是一种最容易处理数据不断增长结构的有序数据结构,而且由于已经有了无锁完成多线程链表写入的算法,所以链表对于并发的支持度是很是好的然而链表却不可以进行二分查找,由于没法取到任意子集的中值。
因此人们又去想办法基于树来作可以既支持写入,又可以经过“预先找到中值并写到父节点”的方式来提早将中值准备好,这就是平衡有序二叉树。不过,不管是AVL仍是红黑树,这个预先找到中值并写入到父节点的操做的都是很是复杂的,对于复杂的操做来讲,想使用常见的无锁操做就几乎不可能了。

最后,综合一下,链表结构可以作到并发无锁的增长新节点,但不能很容易的访问到中值(由于链表只能从头部遍历或尾部遍历)。平衡有序二叉树则相反,虽然很容易能够访问到所有数据的中值,但没法作到并发无锁的增长新节点。
在90年代以前,人们一直以“这就是生活” 来安慰本身,认为鱼与熊掌不可兼得。但在90年代,William Pugh 在他的论文中提出了一种新的数据结构,很巧妙的解决了这个矛盾,另外也八卦一下,其实目前Java领域很流行的find bugs静态代码分析工具也是william发明的~
 数组

跳表是一种随机化的数据结构,目前开源软件 Redis 和 LevelDB 都有用到它,它的效率和红黑树以及 AVL 树不相上下,但跳表的原理至关简单,只要你能熟练操做链表,就能轻松实现一个 SkipList。数据结构

 

有序表的搜索

考虑一个有序表:
多线程

从该有序表中搜索元素 < 23, 43, 59 > ,须要比较的次数分别为 < 2, 4, 6 >,总共比较的次数并发

为 2 + 4 + 6 = 12 次。有没有优化的算法吗?  链表是有序的,但不能使用二分查找。相似二叉工具

搜索树,咱们把一些节点提取出来,做为索引。获得以下结构:性能

 这里咱们把 < 14, 34, 50, 72 > 提取出来做为一级索引,这样搜索的时候就能够减小比较次数了。优化

 咱们还能够再从一级索引提取一些元素出来,做为二级索引,变成以下结构:.net

 

  

这里元素很少,体现不出优点,若是元素足够多,这种索引结构就能体现出优点来了。

 

跳表

下面的结构是就是跳表:

 

 

跳表具备以下性质:

(1) 由不少层结构组成

(2) 每一层都是一个有序的链表

(3) 最底层(Level 1)的链表包含全部元素

(4) 若是一个元素出如今 Level i 的链表中,则它在 Level i 之下的链表也都会出现。

(5) 每一个节点包含两个指针,一个指向同一链表中的下一个元素,一个指向下面一层的元素。

 

跳表的搜索



 

例子:查找元素 117

(1) 比较 21, 比 21 大,日后面找

(2) 比较 37,   比 37大,比链表最大值小,从 37 的下面一层开始找

(3) 比较 71,  比 71 大,比链表最大值小,从 71 的下面一层开始找

(4) 比较 85, 比 85 大,从后面找

(5) 比较 117, 等于 117, 找到了节点。

能够看到,利用这种结构若是咱们可以比较准确的在链表里将数据排好序,而且level1中每两个元素中拿出一个元素推送到更高的层级level2中,而后在level2中也按照每两个元素拿出一个元素推送到更高层级的level3中…依此类推,就能够构建出一个查询时间复杂度为O(log2n)的查找数据结构了。

但这里有个关键的难在于:如何可以知道,当前写入的元素是否应该被推送到更高的层级呢?这也就对应了原来avl,红黑里面为何要作如此复杂的旋转的缘由。而在william的解决方案里,他选择了一条彻底不相同的路来作到这一点。

这也是skiplist里面一个最大的创新点,就是引入了一个新条件:几率。与传统的根据临近元素的来决定是否上推的avl或红黑树相比。Skiplist则使用几率这个彻底不须要依托集合内其余元素的因素来决定这个元素是否要上推。这种方式的最大好处,就是可让每一次的插入都变得更“独立”,而不须要依托于其余元素插入的结果。 这样就可以让冲突只发生在数据真正写入的那一步操做上,而咱们已经在前面的文章里面知道了,对于链表来讲,数据的写入是可以作到无锁的写入新数据的,因而,利用skiplist,就能成功的作到无锁的有序平衡“树”(多层级)结构。

咱们能够把skiplist的写入分为两个步骤,第一个步骤是找到元素在整个顺序列表中要写入的位置,这个步骤与咱们上面讲到的读取过程是一致的。
而后下一个步骤是决定这个数据是否须要从当前层级上推到上一个层级,具体的作法是从最低层级level1开始,写入用户须要写入的值,并计算一个随机数,若是是0,则不上推到高一层,而若是是1,则上推到高一个层,而后指针跳跃到高一个层级,重复进行随机数计算来决定是否须要推到更高的层级,若是最高层中只有本身这个元素的时候,则也中止计算随机数(由于不须要再推到更高层了)。

最后,还有个问题就是如何解决并发写入的问题,为了阐述清楚如何可以作到并发写,咱们须要先对什么叫”一致性的写”,进行一下说明。
通常的人理解数据的一致性写的定义多是:若是写成功了你就让我看到,而若是没写成功,你就不让我看到呗。
但实际上这个定义在计算机里面是没法操做的,由于咱们以前也提到过,计算机其实就是个打字机,一次只能进行一个操做,针对复杂的操做,只能经过加锁来实现一致性。但加锁自己的代价又很大,这就造成了个悖论,如何可以既保证性能,又可以实现一致性呢?
这时候就须要咱们对一致性的定义针对多线程环境进行一下完善:在以前的定义,咱们是把写入的过程分为两个时间点的,一个时间点是调用写入接口前,另外一个时间点是调用写入接口后。但其实在多线程环境下,应该分为三个时间点,第一个是调用写入接口前,第二个是调用写入接口,但还未返回结果的那段时间,第三个是调用写入接口,返回结果后。
而后咱们来看看,针对这三个时间点应该如何选择,才能保证数据的一致性:
对于第一个时间点,由于尚未调用写入接口,因此全部线程(包含调用写入的线程)都不该该可以从这个映射中读取到待写入的数据。
第二个时间点,也就是写入操做过程当中,咱们须要可以保证:若是数据已经被其余线程看到过了,那么再这个时间点以后的全部时间点,数据应该都可以被其余线程看到,也就是说不能出现先被看到但又被删掉的状况。
第三个时间点,这个写入的操做应该可以被全部人看到。

已经定义好了一致性的规范,下面就来看看这个无锁并发的skiplist是如何处理好并发一致性的。
首先咱们须要先了解一下链表是如何可以作到无锁写入的:
对于链表类的数据结构来讲,若是想作到无锁,主要就是解决如下的问题,如何可以让当前线程知道,目前要插入新元素的位置,是否有其余人正在插入? 若是有的话,那么就自旋等待,若是没有,那么就插入。利用这个原理,把原来的多步指针变动操做利用compare and set的方式转换为一个伪原子操做。这样就能够有效的减小锁致使的上下文切换开销,在争用不频繁的状况下,极大的提高性能。(这只是思路,关于linkedlist的无锁编程细节,能够参照A pragmatic implementation of non-blocking linked lists,这篇文章)
利用上面链表的无锁写入,咱们就可以保证,数据在每个level内的写是保证无锁写入的。而且,由于每一次有新的数据写入的时候其余尝试写入的线程也都能感知的到,因此这些并行写入的数据能够经过不断相互比较的方式来了解到,本身这个要写入的数据与其余并行写入的数据之间的大小关系,从而能够动态的进行调整以保证在每一层内,数据都是绝对有序的。
同一个level的一致性解决了,那么不一样level之间的一致性是如何获得解决的呢?这就与咱们刚才定义的一致性规范紧密相关了。由于数据的写入是从低层级开始,一层一层的往更高的层级推送的。而数据读取的时候,则是从最高层级往下读取的。又由于数据是绝对有序的,那么咱们就必定能够认为,只要最低层级(level0)内存在了的数据,那么他就必定可以被全部线程看到。而若是在上推的过程当中出现了任何异常,其实都是不要紧的,由于上推的惟一目的在于加快检索速度,因此就算由于异常没有上推,也只是下降了查询的效率,对数据的可见性彻底没有影响。
这个设计确实是很是的巧妙~

这样,虽然每一个元素的具体可以到达哪一个层级是随机的,但从宏观上来看,低层元素的个数基本上是高层元素个数的一倍。从宏观上来看,若是按照咱们上面定义的自最高层级依次往下遍历的读取模式,那么整个查询的时间复杂度就是O(log2n)。

下面来介绍一些优化的思路,由于进行随机数的运算自己也是个很消耗cpu的操做,因此,一种最多见的优化就是,若是在插入的时候就能直接算出这个数据应该往高层推的总次数,那么就不须要算那么屡次随机数了,每次写入只须要算一次就好了。
第二个优化的思路是如何可以实现一个高性能的随机数算法。

Skiplist是一个很好的数据结构,由于它足够简单,性能又好,除了运气很是差的时候效率很低,其余时候都能作到很好的查询效率,赌博什么的最喜欢了~~~最重要的是,它还足够简单和容易理解!


跳表的插入

 

先肯定该元素要占据的层数 K(采用丢硬币的方式,这彻底是随机的)

而后在 Level 1 ... Level K 各个层的链表都插入元素。

例子:插入 119, K = 2

 

若是 K 大于链表的层数,则要添加新的层。

例子:插入 119, K = 4

 

 

丢硬币决定 K

 

插入元素的时候,元素所占有的层数彻底是随机的,至关于作屡次丢硬币的实验,若是遇到正面,继续丢,遇到反面,则中止,

用实验中丢硬币的次数 K 做为元素占有的层数。显然随机变量 K 知足参数为 p = 1/2 的几何分布,

K 的指望值 E[K] = 1/p = 2. 就是说,各个元素的层数,指望值是 2 层。

  

跳表的高度。

n 个元素的跳表,每一个元素插入的时候都要作一次实验,用来决定元素占据的层数 K,

跳表的高度等于这 n 次实验中产生的最大 K

 

跳表的空间复杂度分析

根据上面的分析,每一个元素的指望高度为 2, 一个大小为 n 的跳表,其节点数目的

指望值是 2n。

 

跳表的删除

在各个层中找到包含 x 的节点,使用标准的 delete from list 方法删除该节点。

例子:删除 71

 

下面咱们使用一些通用的标准对skiplis进行一下简单的评价:1. 是否支持范围查找由于是有序结构,因此可以很好的支持范围查找。2. 集合是否可以随着数据的增加而自动扩展能够,由于核心数据结构是链表,因此是能够很好的支持数据的不断增加的3. 读写性能如何由于从宏观上能够作到一次排除一半的数据,而且在写入时也没有进行其余额外的数据查找性工做,因此对于skiplist来讲,其读写的时间复杂度都是O(log2n)4. 是否面向磁盘结构磁盘要求顺序写,顺序读,一次读写必须是一整块的数据。而对于skiplist来讲,查询中每一次从高层跳跃到底层的操做,都会对应一次磁盘随机读,而skiplist的层数从宏观上来看必定是O(log2n)层。所以也就对应了O(log2n)次磁盘随机读。所以这个数据结构不适合于磁盘结构。5. 并行指标终于来到这个指标了, skiplist的并行指标是很是好的,只要不是在同一个目标插入点插入数据,全部插入均可以并行进行,而就算在同一个插入点,插入自己也可使用无锁自旋来提高写入效率。所以skiplist是个并行度很是高的数据结构。6. 内存占用与平衡二叉树的内存消耗基本一致。