数据结构与算法之美——跳表分析

无情 阅读:199 2021-06-15 11:20:42 评论:0

一、引言

给定一个有序数组,如何根据元素的值进行高效率查找?

       查找元素值:20

                                                           

可以使用二分查找,那如何使用二分查找呢?首先根据数组下标,定位到数组的中间元素。

                                                           

由于要查找的元素20,大于中间元素12。再次定位到数组半部分的中间元素。  

                                                            

这一次定位到的元素正好是20,查找成功。

       把问题换一下:给定一个有序链表,如何根据元素的值进行高效率查找

一、概述

       二分查找算法:底层依赖的是数组随机访问的特性,所以只能用数组来实现。如果数据存储在有序链表中,就真的没法用二分查找算法了吗?

       实际上,我们只需要对链表稍加改造,就可以支持类似“二分”的查找算法。改造之后的数据结构叫做跳表(Skip list)。它是一种各方面性能都比较优秀的动态数据结构,可以支持快速的插入、删除、查找操作,写起来也不复杂,甚至可以替代红黑树(Red-black tree)。

       Redis中的有序集合(Sorted Set)就是用跳表来实现的。那么Redis为什么会选择用跳表来实现有序集合呢

二、如何理解跳表

       对于一个单链表而言,即使链表中存储的数据是有序的,如果我们想要在其中查找某个数据,也只能从头到尾遍历链表,这样查找效率就会很低,时间复杂度为O(n)。

       那么怎么来提高查找效率呢?像下图中那样,对链表建立一级“索引”,查找起来是不是就会更快一些呢?每两个结点提取一个结点到上一级,把抽出来的那一层叫作“索引”或“索引层”。图中down表示down指针,指向下一级结点。

                     

       现在要查找值为16的结点。我们现在索引层遍历,当遍历到索引层中指为13的结点时,我们发现下一个结点时17,那要查找的结点16肯定就在这两个结点之间。然后通过索引层结点的down指针,下降到原始链表这一层,继续遍历。此时,我们只需要再遍历2个结点,就可以找到值等于16的这个结点了。这样,原来如果要查找16,需要遍历10个结点,现在只需要遍历7个结点。

       从这个例子,加来一层索引之后,查找一个结点需要遍历的结点个数减少了,也就是说查找效率提高了。那如果再建立一个索引呢?效率会不会提升更多呢?

       跟前面建立第一级索引的方式相似,我们在第一级索引的基础之上,每两个结点就抽出一个结点到第二级索引。现在我们再来查找16,只需要遍历6个结点,需要遍历的结点数量又减少了。

                          

     像这种链表加多级索引的结构,就是跳表。前面例子展示了跳表是如何减少查询次数的。接下来定量分析一下,用跳表查询到底有多块。

二、跳表查询效率

       众所周知,算法的执行效率可以通过时间复杂度来度量,这里依旧可以用。在单链表中查询某个数据的时间复杂度是O(n)。那在一个具有多级索引的跳表中,查询某个数据的时间复杂度是多少呢?

      每两个结点会抽出一个结点作为上一级索引的结点,那第一级索引的结点个数大约就是n/2,第二级索引的结点个数大约就是n/4,第三级索引的结点个数是n/8,依次类推,第k级索引的结点个数是第k-1级索引的结点个数的1/2,那第k级索引结点的个数就是n/(2^k)。

      假设索引有h级,最高级的索引有2个结点。通过上述公式得知n/(2^h)=2。从而h=log₂n-1。如果包含原始链表,整个跳表的高度就是log₂n。我们在跳表中查询某个数据时,每一层都要遍历m个结点,那在跳表中查询一个数据的时间复杂度就是O(m*logn)。在跳表中查询任意数据的时间复杂度是O(logn)。这个查找的时间复杂度跟二分查找是一样的。换句话说,是基于单链表实现了二分查找。这种查询效率的提升,前提是建立了很多级索引,也就是用空间换时间。

三、跳表是不是很浪费内存

       比起单纯的单链表,跳表需要存储多级索引,肯定要消耗更多的存储空间。那到底需要消耗多少额外的存储空间呢?我们来分析一下跳表的空间复杂度

       假设原始链表大小为n,第一级索引大约为n/2个结点,第二级索引大约有n/4个结点,以此类推,每上升一级就减少一半,直到剩下2个结点。如果我们把每层索引的结点树写出类,就是一个等比数列。

                 

这几级索引的结点总和就是n/2+n/4+n/8...+8+4+2=n-2。所以跳表的空间复杂度是O(n)。也就是说,也就是说,需要将包含n个结点的单链表改造成跳表,我们需要额外再用接近n个结点的存储空间,那我们有没有什么办法降低索引占用的内存空间呢?

       我们前面都是每两个结点抽一个结点到上级索引,如果我们每三个结点或五个结点,抽一个结点到上级索引,是不是就不用那么多索引结点了呢?如下图每三个结点抽一个的示意图。

                                

       从图中可以看出,第一级索引需要大约n/3个结点,第二级索引需要大约n/9个结点。每往上一级,索引结点个数都除以3.为了方便计算,我们假设最高一级的索引结点个数是1.我们把每级索引的结点个数都写下来。也是一个等比数列。n/3+n/9+n/27+...+9+3+1=n/2。尽管空间复杂度还是O(n),但比上面每两个结点抽一个结点的索引构建方法,要减少一半的索引结点存储空间。

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

四、高校地动态插入和删除

       跳表这个动态数据结构,不仅支持查找操作,还支持动态的插入、删除操作,而且插入、删除操作的时间复杂度也是O(logn)。

       如何在跳表中插入一个数据,以及它是如何做到O(logn)的时间复杂度的?

       在单链表中,一旦定位好要插入的位置,插入结点的时间复杂度是很低的,就是O(1)。但是,这里为了保证原始链表中数据的有序性,我们需要先找到要插入的位置,这个查找操作就会比较耗时。

       对于单链表,需要遍历每个结点,来找到插入的位置。但是,对于跳表来说,查找某个结点的时间复杂度是O(logn),所以这里查找某个数据应该插入的位置,方法也是类似的,时间复杂度也是O(logn)。如下图所示。

                                

       接下来,我们再来看删除操作。

       如果这个结点在索引中也有出现,我们除了要删除原始链表中的结点,还要删除索引中的。因为单链表中的删除操作需要拿到药删除的前驱结点,然后通过指针操作完成删除。所以在查找要删除的结点的时候,一定要获取前驱结点。当然,如果我们用的是双向链表,就不需要考虑这个问题了。

五、跳表索引动态更新

       当我们不停地往跳表中插入数据时,如果我们不更新索引,就有可能出现某2个索引结点之间数据非常多的情况。极端情况下,跳表还会退化成单链表。

                                  

       作为一种动态数据结构,我们需要某种手段来维护索引与原始链表大小之间的平衡,也就是如果链表中结点增多了,索引结点就相应地增加一些,避免复杂度退化,以及查找、插入、删除操作性能下降。

       如果你了解红黑树、AVL树这样平衡二叉树,你就知道它们是通过左右旋的方式保持左右子树的大小平衡,而跳表是通过随机函数来维护前面提到的“平衡性”。

       


标签:程序员
声明

1.本站遵循行业规范,任何转载的稿件都会明确标注作者和来源;2.本站的原创文章,请转载时务必注明文章作者和来源,不尊重原创的行为我们将追究责任;3.作者投稿可能会经我们编辑修改或补充。

发表评论
搜索
KIKK导航

KIKK导航

排行榜
关注我们

一个IT知识分享的公众号