我是一个跳表


大家好,我的名字是跳表。
没有听说过的,心里肯定莫名其妙,这是什么鬼名字?且容我慢慢道来。


说说我的家族


相信你一定知道单向链表和双向链表,还有可能知道循环链表。为什么要提这些链表呢?因为我就是属于链表家族的。

我的家族最让人诟病的是,不支持随机访问(RandomAccess),因为它通常对外只暴露一个头(head)节点,只能一次一个的向后迭代访问。

所以随着链表长度的增加,访问元素需要迭代的次数也在增加。所以时间复杂度就是O(n)。这里的n就是链表的长度。

我的家族最让人称赞的是,可以在任意地方快速的插入/删除元素,而且只需要常量时间,即时间复杂度是O(1),因为只是修改了几个指针的指向而已。

这里的1表示是一个固定的时间,且该时间不随链表长度的增加而发生改变。这些都是基本的数据结构,相信每个人早已耳熟能详。所以我就不画图啦。

但是,在实际使用中却未必如此。虽然插入/删除元素只需要常量时间,可是它有一个大前提,就是已经定位到了插入点/删除点,这样才可以操作指针的指向。

可是这个定位(插入/删除)节点的过程难道就不是一个元素访问了吗?当然是了,所以仍需要O(n)时间。如果把这个考虑进去,插入/删除元素的总时间其实也是O(n),因为首先要找到它。

而且在插入元素时,还需要分配内存,可能也需要消耗一点点时间,等等。关于链表更多的内容,请参考我之前的文章《一篇图文彻底搞懂单链表,嗯,是有可能的》《List家族遗产继承PK赛(一)》《List家族遗产继承PK赛(二)》。

因此,对于我的家族来说,提高元素的访问速度非常重要,因为它同时对插入/删除元素也是有利的,可以降低花费的总时间。


提高访问速度的大前提


我的主人经常说,当要处理一个不熟悉的事物时,优先从身边熟悉的事物入手,仔细思考并逐步演化、类比。

书对于任何人来说都再熟悉不过了,而且几乎所有的人都看过书,基本上所有的书都会标页码。

标页码的真正目的是什么,其实我也不知道。但至少有一个作用,就是可以快速的定位到某一页。

不过这依然有一个大前提,那就是页码必须是有序的,无论升序降序都行。注意,不一定非得是连续的。

这样我们就不用一页一页的翻,可以一下子翻几十页或上百页,定位速度自然会快很多。

设想一下,如果页码是杂乱无序的,可能200多页在100多页的前面,那此时页码就没有快速定位的功能了。

只能回到最原始的方式,一页一页的翻,速度奇慢无比。这就是不按套路出牌,就像狄仁杰里的天字二号房间旁边并不是天字一号房间一样。

但是在编程方面,最好还是按套路走,毕竟铁打的公司流水的码农,总要有后人来接手你的代码,整太多“超常规”的话,后生会太痛苦的。

书的页码是一页接一页的,链表的元素也是一个接一个的,所以它们的本质其实比较相似。页码是有序的,所以链表的元素也必须是有序的。

因此,要想提高对链表元素的访问速度,元素之间必须是有序的才行。如果是无序的、杂乱无章的,那基本没有提高的可能性。


提高访问速度方法的探索


还是从书说起。如果一本书有500页,如何才能快速的找到250页左右呢?这个其实对于不爱看书的人也很容易。

那就是让书的侧面对着自己的眼睛,目测一下书的厚度,从中间劈开即可,绝对是250页左右。

如果要找300页呢?当然300页离250页不远,其实就是从中间稍往后偏一点即可。

那如果要找400页呢?如果要找100页呢?采用同样的方法,是不是精确度就要低一些了,因此花费的时间就要多一些。

假如书的总页数是1000页,要找700页,是不是难度就进一步加大了。如果要找730页,难度会更大。

这又该如何搞定呢?我们从熟悉的英文词典入手,由于英文有26个字母,所以所有单词的首字母也只能是26个中的一个。

这样把单词按首字母划分(分组),分为26组,有些词典就把每组印刷成一个颜色,这样从侧面很容易看到颜色交界处。

这些就是特殊的地方(页码),因为首字母在这里发生了变化。还可以把每个字母印刷到侧面上,使用字母加颜色来实现快速定位。

这就给了我们一个启发,要想在一本厚书里快速定位,必须找出特殊的页码。比如100页、200页。800页、900页等等,这些特殊地方。

我们可以在每整百页的地方夹上一个纸条,一端露在外面,并写上对应的整百数字。这样再找700页的时候直接找到这个整百纸条即可,速度超快的。

如果我要找770页的话,依然是找到700对应的纸条和800对应的纸条,因为770一定在它们中间而且是靠近800的。


可能有人会想,我干脆也把50页、150页。850页、950页等等,这些整五十页的地方也夹上纸条不就得了。

这样在定位770的时候精度更高些,直接找到750和800,在它们之间寻找肯定比在700和800之间要快些。

这种想法(思路)是对的,虽然区间越小越精确,但是不要忘记了,这将使特殊页码变的更多,继而夹的纸条就更多,这样一来看这些纸条反而成了越来越大的负担。

想象一下,如果在每一页上都夹一个纸条,那和压根儿不夹纸条又有什么区别呢?

所以这是一个权衡的问题,其实就是一个颗粒度的问题。实际是可以根据相关参数计算出来的。


快快快,该我上场了


哎呀,终于轮到我了,为了使大家更容易读懂我,还是以循序渐进的方式展开吧。

首先准备一个有序链表,如下图01:


其中
H表示头节点,N表示NULL。

假如要查询节点11,那么需要经过11次才能找到。因为我们只能拿到头节点,而头节点只指向了第一个节点。

链表的访问特点是无法改变的,只能通过指针一次次向后迭代。所以如果要想更快的找到节点11,唯一的方法就是改变切入点。

即不从第一个节点开始向后遍历,而直接从中间的某个节点开始向后遍历,可问题是我们没有指向中间某个节点的指针啊。

这好办,那就加一个呗,我们让头节点除了指向第一个节点外,再额外的指向中间的某个节点,如下图02:


可以认为两个头节点是同一个节点,两个NULL节点也是同一个,两个6节点也是同一个。


所以这个图表达的含义就是,我们又增加了一个指针,它指向了节点6。这样一来这个链表就有了两个指针了。

此时从头节点开始,一下子就可以到节点6,接着再向后遍历到节点11,这样一共需要6次,比上回少了5次。

这就相当于在一本书的正中间加了一个书签,可以一步定位到正中间。可是加了一个书签后,人们并不满足。

于是他们就在前半部分的中间和后半部分的中间再各加一个书签,这样三个书签等于把书平均分成四份了。

那么对应到链表中,就是再用两个指针分别指向前半部分的中间节点和后半部分的中间节点。如下图03:


竖直方向上的三个H节点其实是一个节点,同理,3节点、6节点、9节点和N节点也是这样子的。

这里的指针需要注意,指向3节点的指针依然是从H节点发出,但是指向9节点的指针则是从6节点发出的。

这样从H节点一步定位到6节点,然后再一步定位到9节点,接着再向后遍历到11节点,这样一共用去4次,比上回又少了2次。

这样的效果随着链表的增长会变得越来越明显,链表越长,减少的次数就会越多。


更加容易的来理解跳表


上面展示的跳表看明白了吗?哈哈,相信大家都看懂了,但是好像又没有get到本质,那就继续吧。

我们先以一个简单好理解的方式来逐步的构建跳表吧。

首先,准备一个有序链表,如下图01:


其次,从这个链表中均匀的抽出部分节点组成一个新的链表,并放到原链表上方,如下图04:


再次,从刚刚的新链表中均匀的抽出部分节点再组成一个新的链表,并放到原链表上方,如下图05:


最后,重复上述过程,如下图06:


这样看来,这个跳表是由四层链表组成的,其中最底层就是原始链表,包含全量的节点。

从底层往上走,每一层新的链表的节点数都在逐步减少,所以最上层的节点数最少。

由于每一层的链表都是从它的下一层构建出来的,所以每一层都是它的下一层的子集。

即每一层的节点必须在它的下一层中也存在,这很好理解,因为这些节点就是从它的下一层中抽出来的嘛。

就这个跳表而言,假如最底层是0级,那么最高层就是3级。下面来做些练习,看看数据的查找过程是怎样的。

下面是查找节点11的过程:

在第3级上,从H节点向右定位到6节点,由于11大于6,所以继续向右,6节点的右边是NULL,所以就沿着6节点向下进入第2级中。

在第2级上,从6节点向右定位到10节点,由于11大于10,所以继续向右,10节点的右边是NULL,所以沿着10节点向下进入第1级中。

在第1级上,10节点的右边是NULL,所以沿着10节点向下进入第0级中。

在第0级上,从10节点向右定位到11节点,于是节点11就找到了。

下面是查找节点5的过程:

在第3级上,从H节点向右定位到6节点,发现6大于5,所以就沿着H节点向下进入第2级中。

在第2级上,从H节点向右定位到2节点,发现2小于5,继续向右定位6节点,发现6大于5,所以沿着2节点向下进入第1级中。

在第1级上,从2节点向右定位到4节点,发现4小于5,继续向右定位6节点,发现6大于5,所以沿着4节点向下进入第0级中。

在第0级上,从4节点向右定位到5节点,于是节点5就找到了。

其实整个工作原理和二叉树差不多,首先从最顶层开始,可以理解为树的根。

如果要查找的节点比当前节点大,那就在右边部分找,如果没有那就进入下一级中,继续在右边找。

如果要查找的节点比当前节点小,则直接进入下一级中,继续在右边找。

只要重复这个过程,就会逐级下降,如果这个节点存在的话,最后一定会找到。

其实除了最底层的原始链表外,其余的上层链表都可以认为是构建的索引,正是利用了这些索引,才加快了查找。

利用索引的效果就是我们可以跳过一些节点来快速定位,因此这就是跳表名称的由来吧。

很明显这是在用空间换取时间,所以查找、插入和删除操作的平均时间复杂度就由O(n)降为O(logn)了。

如果一个链表的长度是n的话,在不考虑头尾节点的情况下,共需要n个指针。

在构建跳表时,如果每一层的节点数目都是它的下一层的一半的话,那么总共需要2n个指针,即翻了一倍。

由于全部层级的元素节点都是共用的,所以节点数目没有变化。因此空间复杂度和抽取节点的比例有关,且只对指针数目产生影响。

作者是工作超过10年的码农,现在任架构师。喜欢研究技术,崇尚简单快乐。追求以通俗易懂的语言解说技术,希望所有的读者都能看懂并记住。


      

 

>>> 热门文章集锦 <<<

 

毕业10年,我有话说

我是一个协程

线程池开门营业招聘开发人员的一天

【面试】我是如何面试别人List相关知识的,深度有点长文

我是如何在毕业不久只用1年就升为开发组长的

爸爸又给Spring MVC生了个弟弟叫Spring WebFlux

【面试】我是如何在面试别人Spring事务时“套路”对方的

【面试】Spring事务面试考点吐血整理(建议珍藏)

【面试】我是如何在面试别人Redis相关知识时“软怼”他的

【面试】吃透了这些Redis知识点,面试官一定觉得你很NB(干货 | 建议珍藏)

【面试】如果你这样回答“什么是线程安全”,面试官都会对你刮目相看(建议珍藏)

【面试】迄今为止把同步/异步/阻塞/非阻塞/BIO/NIO/AIO讲的这么清楚的好文章(快快珍藏)

【面试】一篇文章帮你彻底搞清楚“I/O多路复用”和“异步I/O”的前世今生(深度好文,建议珍藏)

【面试】如果把线程当作一个人来对待,所有问题都瞬间明白了

Java多线程通关———基础知识挑战

品Spring:帝国的基石

 

>>> 玩转SpringBoot系列文章 <<<

 

【玩转SpringBoot】配置文件yml的正确打开姿势

【玩转SpringBoot】用好条件相关注解,开启自动配置之门

【玩转SpringBoot】给自动配置来个整体大揭秘

【玩转SpringBoot】看似复杂的Environment其实很简单

【玩转SpringBoot】翻身做主人,一统web服务器

【玩转SpringBoot】让错误处理重新由web服务器接管

【玩转SpringBoot】SpringBoot应用的启动过程一览表

【玩转SpringBoot】通过事件机制参与SpringBoot应用的启动过程

【玩转SpringBoot】异步任务执行与其线程池配置

 

>>> 品Spring系列文章 <<<

 

品Spring:帝国的基石

品Spring:bean定义上梁山

品Spring:实现bean定义时采用的“先进生产力”

品Spring:注解终于“成功上位”

品Spring:能工巧匠们对注解的“加持”

品Spring:SpringBoot和Spring到底有没有本质的不同?

品Spring:负责bean定义注册的两个“排头兵”

品Spring:SpringBoot轻松取胜bean定义注册的“第一阶段”

品Spring:SpringBoot发起bean定义注册的“二次攻坚战”

品Spring:注解之王@Configuration和它的一众“小弟们”

品Spring:bean工厂后处理器的调用规则

品Spring:详细解说bean后处理器

品Spring:对@PostConstruct和@PreDestroy注解的处理方法

品Spring:对@Resource注解的处理方法

品Spring:对@Autowired和@Value注解的处理方法

品Spring:真没想到,三十步才能完成一个bean实例的创建

品Spring:关于@Scheduled定时任务的思考与探索,结果尴尬了