数据结构之跳表(八)

知识共享许可协议 版权声明:署名,允许他人基于本文进行创作,且必须基于与原先许可协议相同的许可协议分发本文 (Creative Commons

前言

一. 什么是跳表

二. 跳表的基本概念

  1. 跳表的创建过程

  2. 跳表的时空复杂性分析

  3. 更新数据时跳表的维护

三. 跳表的实际应用场景

四. 学习过程中的疑问点小记


前言

   有序数组运用数组随机读取的特性,通过二分查找法可实现快速查找,检索的时间复杂度为O(logN)。由于是对数的时间复杂度,因此随着数据量的增加其检索效率更凸显。例如在42亿的数据集下查找一个数据,也仅需要查询32次,可见其查询效率有多高了。注:42亿约为2的32次方。

   单纯从查询来看二分查找确实算是很高效的数据结构了,但是当往该有序数组中插入或删除数据那就比较低效了,因为涉及到要保证数组的有序因而需要移动大量数据,时间复杂度为O(N)。

   对于上述的问题,有没有什么方法可以实现即能快速查找又能高效更新呢?

  在前面的学习中知道数组适合快速检索,链表适合快速插入或删除,但对于单链表不管是否有序,其检索数据都是需要遍历所有数据,时间复杂度为O(N)。那有没有什么方法可将数组二分查找的思想用到有序链表中,进而实现快速检索并高效更新呢? 此处该跳表闪亮登场了。

一. 什么是跳表

    跳表由William Pugh 1989年发明。他的论文《Skip lists: a probabilistic alternative to balanced trees》中详细介绍了跳表的数据结构和插入删除等操作。论文是这么介绍跳表的:

   Skip lists  are data structures  that use probabilistic  balancing rather  than  strictly  enforced balancing.

   As a result, the algorithms  for insertion  and deletion in skip lists  are much simpler and significantly  faster  than   equivalent  algorithms  for balanced trees.

    译文:跳跃列表是使用概率平衡而不是严格强制平衡的数据结构。因此,在跳跃表中插入和删除的算法要比平衡树中的等价算法简单得多,而且明显快得多。

   一图胜千言,见下图吧

   对于单链表来说,无论是否有序,其检索数据时必须是从头开始遍历,时间复杂度为O(N)。

图一

    但如果我们在上图的有序链表挑选出一些节点,在挑选的节点中添加一个 down 指针,创建如下图所示的链表,我们将这样新添加的链表称为索引,这时检索数据效率会发生那些变化呢?

图二

当我们再对上图进一步挑选节点,创建如下图的二级索引,这时检索效率又会发生什么变化呢?

图三

    对于单链表,检索数据总是需要遍历所有的数据,也就是说就算创建多少层索引,在查找数据时对于最上一层的索引都是要做整个链表的遍历的,因此对于最上层索引总是保持节点数据尽可能少。

     这里以遍历一个数据为例进行说明吧,假如我们要检索的数据是16。

    直接在原始链表中进行检索,那么需要从第一个节点开始遍历直到找到16为止,遍历的总节点数是 10个节点。

    但如果是通过拥有两层索引的图三来进行检索,需要遍历多少个节点呢?

    答案是7次,遍历的过程:

        1. 遍历第二级索引,一共遍历3个节点,1、7、13,发现待检索的数据大于13,也就是说待检索的数据在13的右边,那么通过13节点的down 指针进入第一级索引。

        2. 由于链表是有序的,那么对于第一级索引直接从13号节点开始遍历便可,这时遍历的节点是13、17,一共遍历了2个节点。发现16比13大,但比17小,因此还是通过13节点的down指针进入下一级(这里的下一级就是原始链表)。

        3. 进入原始链表后,同样由于链表是有序的,同理从13开始遍历,这时遍历的节点是13、16,一共遍历了2个节点。找到遍历节点,检索完毕。

       通过上述的遍历过程,通过有多级索引的方式遍历数据,发现总共是遍历了7个节点。

       通过上述的遍历过程,对于每两个节点取一个节点做为索引点的情况,我们是否发现每一层索引最多遍历多少个节点呢?

       解答:答案是最多遍历3个节点;对于任何一层索引,我们发现该层索引两个节点之间对应的下层索引最多包含3个节点。

       如下图,第二级索引中的节点 7 与 13 对应的下一层节点为 7、9、13,很明显最多只能是3个节点。而在检索数据时,如果数据在 7 和 13 之间,那么进入下层索引再进行比较也必须是在 7、9、13 这三个节点之间。

       以此类堆,对于任意的索引层都一样。如果每3个节点选一个那么每层最多遍历的是4个节点...

       因此,可以得出结论,每隔 X 个节点选一个作为索引节点,那么在检索时,每一层遍历的最大节点数为 X+1 个节点。

    直接在原始链表中遍历的次数是 12 次,创建多层索引遍历的次数是 7 次,乍一看感觉提升不算大。下图,以一个64节点的来说明吧,假如要遍历数字62,通过下图可知只需要遍历11次。其实,这跟二分查找类似,如果数据量太小,还真感受不到其查找速度的高效。

这上面讲述的有序链表与多级索引的组合正是跳表的一个形象的呈现了。

     现在我们可以知道,跳表(Skip List)也称跳跃表,是一种基于在有序链表上挑选关键点来创建多层索引的动态数据结构。

     通过层层挑选关键点来创建多层索引,然后通过索引进行检索数据,在进行数据删除或插入时,同理通过索引来找到具体位置。由于索引在内存空间是随机存取的,因而没有移动数据这一步。

     从上文内容可以从感性上认识到跳表的时间复杂度还是挺不错的。其实,从时空上来说,这里相当于是运用了以空间换取时间的思想。

     原理:跳表实质就是一种可进行二分查找的有序链表。

     跳表其实可以说是链表结合二分查找算法的一个扩展,或者说是二叉树的一个变种,有点类似于散列表扩展于数组的感觉。当然,这只是笔者个人的理解。

     通过上文的学习,表示很好奇:

     跳表是如何挑选节点的呢?

     当插入或删除数据时如何更新索引?

     由于一层层的创建索引,那么跳表的空间复杂度会不会很高呢?其时间复杂度为多少呢?

二. 跳表的基本概念

1. 跳表的创建过程

     通俗地说,是通过在原始链表上通过一层层的创建索引而创建的。

     这里的关键问题是,应该怎么找构建索引的点呢?

     其实这并没有一定的明文规定,我们可以像上文的例子那样,每两个节点抽一个节点出来做上一级索引节点,这样对于有n个数据节点的链表,第一级索引节点数量为 n/2,第二级索引节点数量为 n/4... 以此类推,那么第 k 级索引节点数则为 n 除以2的 k 次方了。当然,我们也可以根据自己的业务需求,设计每三个节点选一个做为上一级索引节点。

2. 跳表的时空复杂性分析

  • 注:这里暂且以每两节点中选一个做为上一级索引节点。

      时间复杂度分析: 假设有 h 级索引,最高级索引有两个节点,通过上面的公式可知 n/2^{h} = 2 ,故

    那么如果在检索数据的时候,每级索引访问 m 个数据,总共需要访问的节点数是 m * log(n)

    故其时间复杂度为 O(m * logn);

    由时间复杂度的特点,如果这里的 m 为常量那么就可以不计入总的时间复杂度;由上文的结论可知,每隔 X 个节点选一个作为索引节点,那么在检索时,每一层遍历的最大节点数为 X+1 个节点。

    因此对于跳表的时间复杂度为O(logn),要知道这个时间复杂度可是跟本文开篇的有序数组二分查找一样了。

    检索的时间复杂度上都这么高效,那么空间复杂度会不会特别高呢,毕竟额外创建了那么多的索引节点 ?

    空间复杂度:对于跳表的空间复杂度分析其实就是计算其额外创建了多少个索引节点。

    在这里还是以每2个节点中挑选一个节点做为上层索引节点为例,假设原始链表有N个节点,从上文的分析可知,第一层为索引为 N/2,第二层为 N/4,第三层为 N/8 ..... 第k层为 N/2的次方。这很明显是一个等比数列,对其求和就可以了。

如果懒得用等比数列公式,可直接用如下图来求和(哈哈,没想到十几年前学的奥数在这里用上了)

   通过上图可以很直观的得出 1/2 + 1/4 + 1/8 + ... + 1/2^{^{k}} 的次方的和最大为 1;因此 N * (1/2 + 1/4 + 1/8 + ... 1/2k的次方) 最大值为 N,由于最后剩下两个节点就不需要再进行索引创建了,因此需要的额外空间为 N-2。

   因此对于每两个节点选一个节点做为索引节点的情况空间复杂度为O(N)。那么对于每3个或更多个节点选一个做为索引节点的情况,其需要创建的额外空间会更小,因此总的来说跳表的空间复杂度为O(N)。

   实际上,在软件开发中,我们不必太在意索引占用的额外空间。在讲数据结构和算法时,我们习惯性地把要处理的数据看成整数,但是在实际的软件开发中,原始链表中存储的有可能是很大的对象,而索引结点只需要存储关键值和几个指针,并不需要存储对象,所以当对象比索引结点大很多时,那索引占用的额外空间就可以忽略了。

3. 更新数据时跳表的维护

    类似于数据库的索引,当往数据库中添加数据或删除数据都需要维护索引,也就是说对于跳表,在对数据进行更新时,跳表对应的多层索引也需要更新。如何更新呢 ?更新的时间复杂度怎么样呢 ?

  • a. 插入数据

     对于单纯的链表来说,在知道插入位置的情况下,时间复杂度为O(1)。由于跳表是有序链表,为了插入时维持其有序性,需要先遍历找到插入的位置,故对于插入的耗时点主要在于查找。结合前文查询的时间复杂度可得出其插入时间复杂度为O(logN)。

  • b. 删除数据

   对于删除节点与插入类似也是需要查找到对应的节点,总的来说自上而下,查找第一次出现节点的索引,并逐层找到每一层对应的节点,删除每一层查找到的节点,如果该层只剩下1个节点,删除整个一层(原链表除外)。总体上,跳跃表删除操作的时间复杂度是O(logN)。

    当我们不断的插入数据,如果不更新索引,那么很有可能就会造成如下图所示的跳表,两个节点之间有很多的节点。

 对于插入与删除时索引节点是如何维护的呢?

    当我们往跳表中插入数据的时候,我们可以选择同时将这个数据插入到部分索引层中。如何选择加入哪些索引层呢?

    关于这一点,跳跃表的设计者采用了一种有趣的办法: 【抛硬币】。也就是通过随机函数随机决定新节点是否提拔,每向上提拔一层的几率是50%。

至于为什么采用随机的方式呢?

    因为跳跃表删除和添加的节点是不可预测的,很难用一种有效的算法来保证跳表的索引分布始终均匀。从概率上来说,随机抛硬币的方法虽然不能保证索引绝对均匀分布,却可以让其大体趋于均匀。学到这里,笔者感觉有一种大道至简的感觉。

总结:

  1. 跳表是一种使用空间换时间的动态数据结构,在时间复杂度方面对于查询、删除、插入都是O(logN)、空间复杂度为O(N)
  2. 它最大的优势是原理简单、容易实现、方便扩展、效率更高。因此在一些热门的项目里用来替代平衡树,如 redis, leveldb 等。跳表在理论已经近乎可以替代红黑树了,但由于红黑树出现的更早,很多语言都有现成的容器已经实现了,因此对于做业务开发时,我们可以直接拿来即用就好了。
  3. 由于其采用概率随机对索引进行平衡,因此使得其在代码的实现以及可读性方面比红黑树之类的平衡树显得更具有优势。

三. 跳表的实际应用场景

    在很多现成的产品中可以见到它的身影:

  1.  Leveldb,一个google实现的非常高效的kv数据库;
  2. Redis SortedSet,Redis 通过跳表实现有序集合;
  3. 实现类似漫画:什么是跳跃表?中对应的排序需求;
  4. Java 容器对应的 ConcurrentSkipListMap;部分源码截图如下:

  对于跳表的Java 代码实现,如里有兴趣可以直接看 ConcurrentSkipListMap容器对应的代码就好了,如下所示:

四. 学习过程中的疑问点小记:

  • 疑问一:跳表中使用的是双链表还是单链表呢?

    答案可以是单链表也可以是双链表;

   在刚开始接触跳表时,只知道跳表是通过在有序的线性表结构上不断挑选关键点进行层层索引的创建,因为链表是有序的,第一反应是需要双链表实现么?于是,便自己在纸上随意画画跳表查询时大概的实现过程,这一画便明朗了,所以说在学习过程中时刻准备一个本子跟笔是相当重要的(当然这只是笔者个人的一个习惯)。

  • 疑问二:跳表的历史由来?

   对于一个东西,我一般都会比较好奇它的发由来,奈何依然没能找官方说法的由来。

  • 疑问三:跳跃表和二叉查找树或红黑树的区别是什么呢?

    跳跃表的优点是维持结构平衡的成本比较低,完全依靠随机。而二叉查找树或红黑树在多次插入删除后,需要 Rebalance 来重新调整结构平衡。在保证CURD操作的效率的同时,其代码的实现上会更加简洁。

该系列博文为笔者学习《数据结构与算法之美》的个人学习笔记小结 

参考

漫画:什么是跳跃表?

跳表:为什么Redis一定要用跳表来实现有序集合?

猜你喜欢

转载自blog.csdn.net/u013850277/article/details/93322632
今日推荐