11# 跳表──没听过但很犀利的数据结构
22
3- 跳表(skip list) 对标的是平衡树(AVL Tree),是一种 插入/删除/搜索 都是 ` O(log n) ` 的数据结构。它最大的优势是原理简单、容易实现、方便扩展、效率更高。因此在一些热门的项目里用来替代平衡树,如 redis, leveldb 等。
3+ ![ ] ( https://tva1.sinaimg.cn/large/008i3skNly1grm8yuyywxj30zk0qomyg.jpg )
44
5- ## 跳表的基本思想
5+ > Redis 是怎么想的:用跳表来实现有序集合?
66
7- 首先,跳表处理的是有序的链表(一般是双向链表,下图未表示双向),如下:
7+ 干过服务端开发的应该都知道 Redis 的 ZSet 使用跳表实现的(当然还有压缩列表),我就不从 1990 年的那个美国大佬 William Pugh 发表的那篇论文开始了,直接开跳
88
9- ![ Linked List ] ( https://lotabout.me/2018/skip-list/linked-list.svg )
9+ ![ 马里奥 ] ( https://i03piccdn.sogoucdn.com/bbdcce2d04b2bd83 )
1010
11- 这个链表中,如果要搜索一个数,需要从头到尾比较每个元素是否匹配,直到找到匹配的数为止,即时间复杂度是 $O(n)$。同理,插入一个数并保持链表有序,需要先找到合适的插入位置,再执行插入,总计也是 $O(n)$的时间。
11+ 文章拢共两部分
1212
13- 那么如何提高搜索的速度呢?很简单,做个索引:
13+ - 跳表是怎么搞的
14+ - Redis 是怎么想的
1415
15- ![ Linked List With 2 level] ( https://lotabout.me/2018/skip-list/linked-list-2.svg )
1616
17- 如上图,我们新创建一个链表,它包含的元素为前一个链表的偶数个元素。这样在搜索一个元素时,我们先在上层链表进行搜索,当元素未找到时再到下层链表中搜索。例如搜索数字 ` 19 ` 时的路径如下图:
1817
19- ![ Linked List Search Path] ( https://lotabout.me/2018/skip-list/linked-list-search-path.svg )
18+ ## 一、跳表
19+
20+ ### 跳表的简历
21+
22+ 跳表,英文名:Skip List。
23+
24+ 父亲:从英文名可以看出来,它首先是个 List,实际上,它是在有序链表的基础上发展起来的。
25+
26+ 竞争对手:跳表(skip list) 对标的是平衡树(AVL Tree),
27+
28+ 优点:是一种 插入/删除/搜索 都是 ` O(log n) ` 的数据结构。它最大的优势是原理简单、容易实现、方便扩展、效率更高。
29+
30+
31+
32+ ### 跳表的基本思想
33+
34+ 一如往常,采用循序渐进的手法带你窥探 William Pugh 的小心思~
35+
36+ 前提:跳表处理的是有序的链表,所以我们先看个不能再普通了的有序列表(一般是双向链表)
37+
38+ ![ ] ( https://cdn.jsdelivr.net/gh/Jstarfish/picBed/datastrucutre/linkedlist.png )
39+
40+ 如果我们想查找某个数,只能遍历链表逐个比对,时间复杂度 $O(n)$,插入和删除操作都一样。
41+
42+ 为了提高查找效率,我们对链表做个”索引“
43+
44+ ![ ] ( https://cdn.jsdelivr.net/gh/Jstarfish/picBed/datastrucutre/skip-index.png )
45+
46+ 像这样,我们每隔一个节点取一个数据作为索引节点,比如我们要找 31 直接在索引链表就找到了(遍历 3 次),如果找 16 的话,在遍历到 31的时候,发现大于目标节点,就跳到下一层,接着遍历~ (蓝线表示搜索路径)
47+
48+ > 恩,如果你数了下遍历次数,没错,加不加索引都是 4 次遍历才能找到 16,这是因为数据量太少,数据量多的话,我们也可以多建几层索引
49+
50+ ![ ] ( https://cdn.jsdelivr.net/gh/Jstarfish/picBed/datastrucutre/skip-list.png )
51+
52+ 每家一层索引,我们搜索的时间复杂度就降为原来的 $O(n/2)$
53+
54+ 加了几层索引,查找一个节点需要遍历的节点个数明线减少了,效率提高不少,bingo~
55+
56+
57+
58+ ** 那到底提高了多少呢?要加多少层索引呢?**
59+
2060
21- 先在上层中搜索,到达节点 ` 17 ` 时发现下一个节点为 ` 21 ` ,已经大于 ` 19 ` ,于是转到下一层搜索,找到的目标数字 ` 19 ` 。
2261
2362我们知道上层的节点数目为 $n/2$,因此,有了这层索引,我们搜索的时间复杂度降为了:$O(n/2)$。同理,我们可以不断地增加层数,来减少搜索的时间:
2463
@@ -111,6 +150,84 @@ C(k) = k/p
111150
112151P.S. 这里我们用到的是最大层数,原论文证明时用到的是 $L(n)$,然后再考虑从 $L(n)$ 层到最高层的平均节点个数。这里为了理解方便不再详细证明。
113152
153+
154+
155+ ### 空间复杂度
156+
157+ 比起单纯的单链表,跳表需要存储多级索引,肯定要消耗更多的存储空间。那到底需要消耗多少额外的存储空间呢?
158+
159+ 跳表的空间复杂度分析并不难,我在前面说了,假设原始链表大小为 n,那第一级索引大约有 n/2 个结点,第二级索引大约有 n/4 个结点,以此类推,每上升一级就减少一半,直到剩下 2 个结点。如果我们把每层索引的结点数写出来,就是一个等比数列。
160+
161+ ![ img] ( https://static001.geekbang.org/resource/image/10/55/100e9d6e5abeaae542cf7841be3f8255.jpg )
162+
163+ 这几级索引的结点总和就是 n/2+n/4+n/8…+8+4+2=n-2。所以,跳表的空间复杂度是 O(n)。也就是说,如果将包含 n 个结点的单链表构造成跳表,我们需要额外再用接近 n 个结点的存储空间。那我们有没有办法降低索引占用的内存空间呢?
164+
165+ 我们前面都是每两个结点抽一个结点到上级索引,如果我们每三个结点或五个结点,抽一个结点到上级索引,是不是就不用那么多索引结点了呢?我画了一个每三个结点抽一个的示意图,你可以看下。
166+
167+ ![ img] ( https://static001.geekbang.org/resource/image/0b/f7/0b0680ecf500f9349fc142e1a9eb73f7.jpg )
168+
169+ 从图中可以看出,第一级索引需要大约 n/3 个结点,第二级索引需要大约 n/9 个结点。每往上一级,索引结点个数都除以 3。为了方便计算,我们假设最高一级的索引结点个数是 1。我们把每级索引的结点个数都写下来,也是一个等比数列。
170+
171+ ![ img] ( https://static001.geekbang.org/resource/image/19/95/192c480664e35591360cee96ff2f8395.jpg )
172+
173+ 通过等比数列求和公式,总的索引结点大约就是 n/3+n/9+n/27+...+9+3+1=n/2。尽管空间复杂度还是 O(n),但比上面的每两个结点抽一个结点的索引构建方法,要减少了一半的索引结点存储空间。
174+
175+ 实际上,在软件开发中,我们不必太在意索引占用的额外空间。在讲数据结构和算法时,我们习惯性地把要处理的数据看成整数,但是在实际的软件开发中,原始链表中存储的有可能是很大的对象,而索引结点只需要存储关键值和几个指针,并不需要存储对象,所以当对象比索引结点大很多时,那索引占用的额外空间就可以忽略了。
176+
177+
178+
179+ ### 高效的动态插入和删除
180+
181+ 跳表长什么样子我想你应该已经很清楚了,它的查找操作我们刚才也讲过了。实际上,跳表这个动态数据结构,不仅支持查找操作,还支持动态的插入、删除操作,而且插入、删除操作的时间复杂度也是 O(logn)。
182+
183+
184+
185+ ### 跳表索引动态更新
186+
187+ 当我们不停地往跳表中插入数据时,如果我们不更新索引,就有可能出现某 2 个索引结点之间数据非常多的情况。极端情况下,跳表还会退化成单链表。
188+
189+ ![ img] ( https://static001.geekbang.org/resource/image/c8/c5/c863074c01c26538cf0134eaf8dc67c5.jpg )
190+
191+ 作为一种动态数据结构,我们需要某种手段来维护索引与原始链表大小之间的平衡,也就是说,如果链表中结点多了,索引结点就相应地增加一些,避免复杂度退化,以及查找、插入、删除操作性能下降。
192+
193+ 如果你了解红黑树、AVL 树这样平衡二叉树,你就知道它们是通过左右旋的方式保持左右子树的大小平衡(如果不了解也没关系,我们后面会讲),而跳表是通过随机函数来维护前面提到的“平衡性”。
194+
195+ 当我们往跳表中插入数据的时候,我们可以选择同时将这个数据插入到部分索引层中。如何选择加入哪些索引层呢?
196+
197+ 我们通过一个随机函数,来决定将这个结点插入到哪几级索引中,比如随机函数生成了值 K,那我们就将这个结点添加到第一级到第 K 级这 K 级索引中。
198+
199+ ![ img] ( https://static001.geekbang.org/resource/image/a8/a7/a861445d0b53fc842f38919365b004a7.jpg )
200+
201+ 随机函数的选择很有讲究,从概率上来讲,能够保证跳表的索引大小和数据大小平衡性,不至于性能过度退化。至于随机函数的选择,我就不展开讲解了。
202+
203+ 如果你感兴趣的话,可以看看我在 GitHub 上的代码或者 Redis 中关于有序集合的跳表实现。
204+
205+
206+
207+
208+
209+ ## 二、Redis 为什么选择跳表?
210+
211+ 为什么 Redis 要用跳表来实现有序集合,而不是红黑树?
212+
213+ Redis 中的有序集合是通过跳表来实现的,严格点讲,其实还用到了散列表。不过散列表我们后面才会讲到,所以我们现在暂且忽略这部分。
214+
215+ 如果你去查看 Redis 的开发手册,就会发现,Redis 中的有序集合支持的核心操作主要有下面这几个:
216+
217+ - 插入一个数据;
218+ - 删除一个数据;
219+ - 查找一个数据;
220+ - 按照区间查找数据(比如查找值在[ 100, 356] 之间的数据);
221+ - 迭代输出有序序列。
222+
223+ 其中,插入、删除、查找以及迭代输出有序序列这几个操作,红黑树也可以完成,时间复杂度跟跳表是一样的。但是,按照区间来查找数据这个操作,红黑树的效率没有跳表高。
224+
225+ 对于按照区间查找数据这个操作,跳表可以做到 O(logn) 的时间复杂度定位区间的起点,然后在原始链表中顺序往后遍历就可以了。这样做非常高效。
226+
227+ 当然,Redis 之所以用跳表来实现有序集合,还有其他原因,比如,跳表更容易代码实现。虽然跳表的实现也不简单,但比起红黑树来说还是好懂、好写多了,而简单就意味着可读性好,不容易出错。还有,跳表更加灵活,它可以通过改变索引构建策略,有效平衡执行效率和内存消耗。
228+
229+
230+
114231## 小结
115232
1162331 . 各种搜索结构提高效率的方式都是通过空间换时间得到的。
@@ -120,8 +237,18 @@ P.S. 这里我们用到的是最大层数,原论文证明时用到的是 $L(n)
120237
121238想到快排(quick sort)与其它排序算法(如归并排序/堆排序)虽然时间复杂度是一样的,但复杂度的常数项较小;跳表的原论文也说跳表能提供一个常数项的速度提升,因此想着常数项小是不是随机算法的一个特点?这也它们大放异彩的重要因素吧。
122239
240+
241+
242+ 跳表使用空间换时间的设计思路,通过构建多级索引来提高查询的效率,实现了基于链表的“二分查找”。跳表是一种动态数据结构,支持快速地插入、删除、查找操作,时间复杂度都是 O(logn)。
243+
244+
245+
123246> 来源:https://lotabout.me/2018/skip-list/
124247
248+
249+
250+ ![ ] ( https://i02piccdn.sogoucdn.com/06eb28fd58fa8840 )
251+
125252## 参考
126253
127254- [ ftp://ftp.cs.umd.edu/pub/skipLists/skiplists.pdf] ( ftp://ftp.cs.umd.edu/pub/skipLists/skiplists.pdf ) 原论文
0 commit comments