跳表是一种神奇的数据结构,由于几乎全部版本的大学本科教材上都没有跳表这种数据结构,并且神书《算法导论》、《算法第四版》这两本书中也没有介绍跳表。可是跳表插入、删除、查找元素的时间复杂度跟红黑树都是同样量级的,时间复杂度都是O(logn),并且跳表有一个特性是红黑树没法匹敌的(具体什么特性后面会提到)。因此在工业中,跳表也会常常被用到。废话很少说了,开始今天的跳表学习。java
经过本文,你能 get 到如下知识:git
友情提示:下文在跳表插入数据时,会讲述如何动态维护索引,实现比较简单,逻辑比较绕,不要放弃,加油!!!若是一遍看不懂不要紧,能够选择暂时性的跳过,毕竟这块偏向于源码。可是读者必须知道跳表的查找、插入、删除的时间复杂度都是 O(logn),并且能够按照范围区间查找元素,当工做中遇到某些场景时,须要想到可使用跳表解决问题便可。毕竟平时的工做都是直接使用封装好的跳表,例如:java.util.concurrent 下的 ConcurrentSkipListMap()。github
下图是一个简单的有序单链表,单链表的特性就是每一个元素存放下一个元素的引用。即:经过第一个元素能够找到第二个元素,经过第二个元素能够找到第三个元素,依次类推,直到找到最后一个元素。面试
如今咱们有个场景,想快速找到上图链表中的 10 这个元素,只能从头开始遍历链表,直到找到咱们须要找的元素。查找路径:一、三、四、五、七、八、九、10。这样的查找效率很低,平均时间复杂度很高O(n)。那有没有办法提升链表的查找速度呢?以下图所示,咱们从链表中每两个元素抽出来,加一级索引,一级索引指向了原始链表,即:经过一级索引 7 的down指针能够找到原始链表的 7 。那如今怎么查找 10 这个元素呢?redis
先在索引找 一、四、七、9,遍历到一级索引的 9 时,发现 9 的后继节点是 13,比 10 大,因而不日后找了,而是经过 9 找到原始链表的 9,而后再日后遍历找到了咱们要找的 10,遍历结束。有没有发现,加了一级索引后,查找路径:一、四、七、九、10,查找节点须要遍历的元素相对少了,咱们不须要对 10 以前的全部数据都遍历,查找的效率提高了。算法
那若是加二级索引呢?以下图所示,查找路径:一、七、九、10。是否是找 10 的效率更高了?这就是跳表的思想,用“空间换时间”,经过给链表创建索引,提升了查找的效率。数据库
可能同窗们会想,从上面案例来看,提高的效率并不明显,原本须要遍历8个元素,优化了半天,还须要遍历 4 个元素,实际上是由于咱们的数据量太少了,当数据量足够大时,效率提高会很大。以下图所示,假若有序单链表如今有1万个元素,分别是 0~9999。如今咱们建了不少级索引,最高级的索引,就两个元素 0、5000,次高级索引四个元素 0、2500、5000、7500,依次类推,当咱们查找 7890 这个元素时,查找路径为 0、5000、7500 ... 7890,经过最高级索引直接跳过了5000个元素,次高层索引直接跳过了2500个元素,从而使得链表可以实现二分查找。由此能够看出,当元素数量较多时,索引提升的效率比较大,近似于二分查找。bash
到这里你们应该已经明白了什么是跳表。跳表是能够实现二分查找的有序链表。微信
既然跳表能够提高链表查找元素的效率,那查找一个元素的时间复杂度究竟是多少呢?查找元素的过程是从最高级索引开始,一层一层遍历最后下沉到原始链表。因此,时间复杂度 = 索引的高度 * 每层索引遍历元素的个数。数据结构
先来求跳表的索引高度。以下图所示,假设每两个结点会抽出一个结点做为上一级索引的结点,原始的链表有n个元素,则一级索引有n/2 个元素、二级索引有 n/4 个元素、k级索引就有 n/2k个元素。最高级索引通常有2个元素,即:最高级索引 h 知足 2 = n/2h,即 h = log2n - 1,最高级索引 h 为索引层的高度加上原始数据一层,跳表的总高度 h = log2n。
咱们看上图中加粗的箭头,表示查找元素 x 的路径,那查找过程当中每一层索引最多遍历几个元素呢?
图中所示,如今到达第 k 级索引,咱们发现要查找的元素 x 比 y 大比 z 小,因此,咱们须要从 y 处降低到 k-1 级索引继续查找,k-1级索引中比 y 大比 z 小的只有一个 w,因此在 k-1 级索引中,咱们遍历的元素最多就是 y、w、z,发现 x 比 w大比 z 小以后,再降低到 k-2 级索引。因此,k-2 级索引最多遍历的元素为 w、u、z。其实每级索引都是相似的道理,每级索引中都是两个结点抽出一个结点做为上一级索引的结点。 如今咱们得出结论:当每级索引都是两个结点抽出一个结点做为上一级索引的结点时,每一层最多遍历3个结点。
跳表的索引高度 h = log2n,且每层索引最多遍历 3 个元素。因此跳表中查找一个元素的时间复杂度为 O(3*logn),省略常数即:O(logn)。
跳表经过创建索引,来提升查找元素的效率,就是典型的“空间换时间”的思想,因此在空间上作了一些牺牲,那空间复杂度究竟是多少呢?
假如原始链表包含 n 个元素,则一级索引元素个数为 n/二、二级索引元素个数为 n/四、三级索引元素个数为 n/8 以此类推。因此,索引节点的总和是:n/2 + n/4 + n/8 + … + 8 + 4 + 2 = n-2,空间复杂度是 O(n)。
以下图所示:若是每三个结点抽一个结点作为索引,索引总和数就是 n/3 + n/9 + n/27 + … + 9 + 3 + 1= n/2,减小了一半。因此咱们能够经过较少索引数来减小空间复杂度,可是相应的确定会形成查找效率有必定降低,咱们能够根据咱们的应用场景来控制这个阈值,看咱们更注重时间仍是空间。
But,索引结点每每只须要存储 key 和几个指针,并不须要存储完整的对象,因此当对象比索引结点大不少时,索引占用的额外空间就能够忽略了。举个例子:咱们如今须要用跳表来给全部学生建索引,学生有不少属性:学号、姓名、性别、身份证号、年龄、家庭住址、身高、体重等。学生的各类属性只须要在原始链表中存储一份便可,咱们只须要用学生的学号(int 类型的数据)创建索引,因此索引相对原始数据而言,占用的空间能够忽略。
插入数据看起来也很简单,跳表的原始链表须要保持有序,因此咱们会向查找元素同样,找到元素应该插入的位置。以下图所示,要插入数据6,整个过程相似于查找6,整个的查找路径为 一、一、一、四、四、5。查找到第底层原始链表的元素 5 时,发现 5 小于 6 可是后继节点 7 大于 6,因此应该把 6 插入到 5 以后 7 以前。整个时间复杂度为查找元素的时间复杂度 O(logn)。
以下图所示,假如一直往原始列表中添加数据,可是不更新索引,就可能出现两个索引节点之间数据很是多的状况,极端状况,跳表退化为单链表,从而使得查找效率从 O(logn) 退化为 O(n)。那这种问题该怎么解决呢?咱们须要在插入数据的时候,索引节点也须要相应的增长、或者重建索引,来避免查找效率的退化。那咱们该如何去维护这个索引呢?
比较容易理解的作法就是彻底重建索引,咱们每次插入数据后,都把这个跳表的索引删掉所有重建,重建索引的时间复杂度是多少呢?由于索引的空间复杂度是 O(n),即:索引节点的个数是 O(n) 级别,每次彻底从新建一个 O(n) 级别的索引,时间复杂度也是 O(n) 。形成的后果是:为了维护索引,致使每次插入数据的时间复杂度变成了 O(n)。
那有没有其余效率比较高的方式来维护索引呢?假如跳表每一层的晋升几率是 1/2,最理想的索引就是在原始链表中每隔一个元素抽取一个元素作为一级索引。换种说法,咱们在原始链表中随机的选 n/2 个元素作为一级索引是否是也能经过索引提升查找的效率呢? 固然能够了,由于通常随机选的元素相对来讲都是比较均匀的。以下图所示,随机选择了n/2 个元素作为一级索引,虽然不是每隔一个元素抽取一个,可是对于查找效率来说,影响不大,好比咱们想找元素 16,仍然能够经过一级索引,使得遍历路径较少了将近一半。若是抽取的一级索引的元素刚好是前一半的元素 一、三、四、五、七、8,那么查找效率确实没有提高,可是这样的几率过小了。咱们能够认为:当原始链表中元素数量足够大,且抽取足够随机的话,咱们获得的索引是均匀的。咱们要清楚设计良好的数据结构都是为了应对大数据量的场景,若是原始链表只有 5 个元素,那么依次遍历 5 个元素也没有关系,由于数据量太少了。因此,咱们能够维护一个这样的索引:随机选 n/2 个元素作为一级索引、随机选 n/4 个元素作为二级索引、随机选 n/8 个元素作为三级索引,依次类推,一直到最顶层索引。这里每层索引的元素个数已经肯定,且每层索引元素选取的足够随机,因此能够经过索引来提高跳表的查找效率。
那代码该如何实现,才能使跳表知足上述这个样子呢?能够在每次新插入元素的时候,尽可能让该元素有 1/2 的概率创建一级索引、1/4 的概率创建二级索引、1/8 的概率创建三级索引,以此类推,就能知足咱们上面的条件。如今咱们就须要一个几率算法帮咱们把控这个 1/二、1/四、1/8 ... ,当每次有数据要插入时,先经过几率算法告诉咱们这个元素须要插入到几级索引中,而后开始维护索引并把数据插入到原始链表中。下面开始讲解这个几率算法代码如何实现。
咱们能够实现一个 randomLevel() 方法,该方法会随机生成 1~MAX_LEVEL 之间的数(MAX_LEVEL表示索引的最高层数),且该方法有 1/2 的几率返回 一、1/4 的几率返回 二、1/8的几率返回 3,以此类推。
因此,经过 randomLevel() 方法,咱们能够控制整个跳表各级索引中元素的个数。重点来了:randomLevel() 方法返回 2 的时候会创建一级索引,咱们想要一级索引中元素个数占原始数据的 1/2,可是 randomLevel() 方法返回 2 的几率为 1/4,那是否是有矛盾呢?明明说好的 1/2,结果一级索引元素个数怎么变成了原始链表的 1/4?咱们先看下图,应该就明白了。
假设咱们在插入元素 6 的时候,randomLevel() 方法返回 1,则咱们不会为 6 创建索引。插入 7 的时候,randomLevel() 方法返回3 ,因此咱们须要为元素 7 创建二级索引。这里咱们发现了一个特色:当创建二级索引的时候,同时也会创建一级索引;当创建三级索引时,同时也会创建一级、二级索引。因此,一级索引中元素的个数等于 [ 原始链表元素个数 ] * [ randomLevel() 方法返回值 > 1 的几率 ]。由于 randomLevel() 方法返回值 > 1就会建索引,凡是建索引,不管几级索引必然有一级索引,因此一级索引中元素个数占原始数据个数的比率为 randomLevel() 方法返回值 > 1 的几率。那 randomLevel() 方法返回值 > 1 的几率是多少呢?由于 randomLevel() 方法随机生成 1~MAX_LEVEL 的数字,且 randomLevel() 方法返回值 1 的几率为 1/2,则 randomLevel() 方法返回值 > 1 的几率为 1 - 1/2 = 1/2。即经过上述流程实现了一级索引中元素个数占原始数据个数的 1/2。
同理,当 randomLevel() 方法返回值 > 2 时,会创建二级或二级以上索引,都会在二级索引中增长元素,所以二级索引中元素个数占原始数据的比率为 randomLevel() 方法返回值 > 2 的几率。 randomLevel() 方法返回值 > 2 的几率为 1 减去 randomLevel() = 1 或 =2 的几率,即 1 - 1/2 - 1/4 = 1/4。OK,达到了咱们设计的目标:二级索引中元素个数占原始数据的 1/4。
以此类推,能够得出,遵照如下两个条件:
就能够知足咱们想要的结果,即:一级索引中元素个数应该占原始数据的 1/2,二级索引中元素个数占原始数据的 1/4,三级索引中元素个数占原始数据的 1/8 ,依次类推,一直到最顶层索引。
可是问题又来了,怎么设计这么一个 randomLevel() 方法呢?直接撸代码:
// 该 randomLevel 方法会随机生成 1~MAX_LEVEL 之间的数,且 :
// 1/2 的几率返回 1
// 1/4 的几率返回 2
// 1/8 的几率返回 3 以此类推
private int randomLevel() {
int level = 1;
// 当 level < MAX_LEVEL,且随机数小于设定的晋升几率时,level + 1
while (Math.random() < SKIPLIST_P && level < MAX_LEVEL)
level += 1;
return level;
}
复制代码
上述代码能够实现咱们的功能,并且,咱们的案例中晋升几率 SKIPLIST_P 设置的 1/2,即:每两个结点抽出一个结点做为上一级索引的结点。若是咱们想节省空间利用率,能够适当的下降代码中的 SKIPLIST_P,从而减小索引元素个数,Redis 的 zset 中 SKIPLIST_P 设定的 0.25。下图所示,是Redis t_zset.c 中 zslRandomLevel 函数的实现:
Redis 源码中 (random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF)
在功能上等价于我代码中的 Math.random() < SKIPLIST_P
,只不过 Redis 做者 antirez 使用位运算来提升浮点数比较的效率。
总体思路你们应该明白了,那插入数据时维护索引的时间复杂度是多少呢?元素插入到单链表的时间复杂度为 O(1),咱们索引的高度最多为 logn,当插入一个元素 x 时,最坏的状况就是元素 x 须要插入到每层索引中,因此插入数据到各层索引中,最坏时间复杂度是 O(logn)。
过程大概理解了,再经过一个例子描述一下跳表插入数据的全流程。如今咱们要插入数据 6 到跳表中,首先 randomLevel() 返回 3,表示须要建二级索引,即:一级索引和二级索引须要增长元素 6。该跳表目前最高三级索引,首先找到三级索引的 1,发现 6 比 1大比 13小,因此,从 1 下沉到二级索引。
下沉到二级索引后,发现 6 比 1 大比 7 小,此时须要在二级索引中 1 和 7 之间加一个元素6 ,并从元素 1 继续下沉到一级索引。
下沉到一级索引后,发现 6 比 1 大比 4 大,因此日后查找,发现 6 比 4 大比 7 小,此时须要在一级索引中 4 和 7 之间加一个元素 6 ,并把二级索引的 6 指向 一级索引的 6,最后,从元素 4 继续下沉到原始链表。
整个插入过程的路径与查找元素路径相似, 每层索引中插入元素的时间复杂度 O(1),因此整个插入的时间复杂度是 O(logn)。
跳表删除数据时,要把索引中对应节点也要删掉。以下图所示,若是要删除元素 9,须要把原始链表中的 9 和第一级索引的 9 都删除掉。
跳表中,删除元素的时间复杂度是多少呢?
删除元素的过程跟查找元素的过程相似,只不过在查找的路径上若是发现了要删除的元素 x,则执行删除操做。跳表中,每一层索引其实都是一个有序的单链表,单链表删除元素的时间复杂度为 O(1),索引层数为 logn 表示最多须要删除 logn 个元素,因此删除元素的总时间包含 查找元素的时间 加 删除 logn个元素的时间 为 O(logn) + O(logn) = 2 O(logn),忽略常数部分,删除元素的时间复杂度为 O(logn)。
跳表是能够实现二分查找的有序链表;
每一个元素插入时随机生成它的level;
最底层包含全部的元素;
若是一个元素出如今level(x),那么它确定出如今x如下的level中;
每一个索引节点包含两个指针,一个向下,一个向右;(笔记目前看过的各类跳表源码实现包括Redis 的zset 都没有向下的指针,那怎么从二级索引跳到一级索引呢?留个悬念,看源码吧,文末有跳表实现源码)
跳表查询、插入、删除的时间复杂度为O(log n),与平衡二叉树接近;
Redis 中的有序集合(zset) 支持的操做:
插入一个元素
删除一个元素
查找一个元素
有序输出全部元素
按照范围区间查找元素(好比查找值在 [100, 356] 之间的数据)
其中,前四个操做红黑树也能够完成,且时间复杂度跟跳表是同样的。可是,按照区间来查找数据这个操做,红黑树的效率没有跳表高。按照区间查找数据时,跳表能够作到 O(logn) 的时间复杂度定位区间的起点,而后在原始链表中顺序日后遍历就能够了,很是高效。
在博客上历来没有见过有同窗讲述 HBase MemStore 的数据结构,其实 HBase MemStore 内部存储数据就使用的跳表。为何呢?HBase 属于 LSM Tree 结构的数据库,LSM Tree 结构的数据库有个特色,实时写入的数据先写入到内存,内存达到阈值往磁盘 flush 的时候,会生成相似于 StoreFile 的有序文件,而跳表刚好就是自然有序的,因此在 flush 的时候效率很高,并且跳表查找、插入、删除性能都很高,这应该是 HBase MemStore 内部存储数据使用跳表的缘由之一。HBase 使用的是 java.util.concurrent 下的 ConcurrentSkipListMap()。
Google 开源的 key/value 存储引擎 LevelDB 以及 Facebook 基于 LevelDB 优化的 RocksDB 都是 LSM Tree 结构的数据库,他们内部的 MemTable 都是使用了跳表这种数据结构。
后期笔者还会输出一篇深刻剖析 LSM Tree 的博客,到时候再结合场景分析为何使用跳表。
参考:
欢迎关注笔者的博客,后续持续更新数据结构与算法、大数据、Flink实战以及原理性的文章