Skip to content

Commit 0366fa8

Browse files
author
jiahaixin
committed
skiplist
1 parent a090097 commit 0366fa8

File tree

15 files changed

+204
-23
lines changed

15 files changed

+204
-23
lines changed

docs/.DS_Store

2 KB
Binary file not shown.

docs/_images/.DS_Store

2 KB
Binary file not shown.
0 Bytes
Binary file not shown.
1.08 MB
Loading
1.82 MB
Loading
3.39 MB
Loading

docs/data-management/.DS_Store

0 Bytes
Binary file not shown.

docs/data-management/Redis/Redis-Datatype.md

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,23 @@ Redis 的列表结构常用来做异步队列使用。将需要延后处理的
181181

182182
#### [列表的实现](http://redisbook.com/preview/adlist/implementation.html)
183183

184+
列表这种数据类型支持存储一组数据。这种数据类型对应两种实现方法,一种是压缩列表(ziplist),另一种是双向循环链表。
185+
186+
当列表中存储的数据量比较小的时候,列表就可以采用压缩列表的方式实现。具体需要同时满足下面两个条件:
187+
188+
- 列表中保存的单个数据(有可能是字符串类型的)小于 64 字节;
189+
- 列表中数据个数少于 512 个。
190+
191+
>听到“压缩”两个字,直观的反应就是节省内存。之所以说这种存储结构节省内存,是相较于数组的存储思路而言的。我们知道,数组要求每个元素的大小相同,如果我们要存储不同长度的字符串,那我们就需要用最大长度的字符串大小作为元素的大小(假设是 20 个字节)。那当我们存储小于 20 个字节长度的字符串的时候,便会浪费部分存储空间。听起来有点儿拗口,我画个图解释一下。
192+
>
193+
>![img](https://static001.geekbang.org/resource/image/2e/69/2e2f2e5a2fe25d26dc2fc04cfe88f869.jpg)
194+
>
195+
>压缩列表这种存储结构,一方面比较节省内存,另一方面可以支持不同类型数据的存储。而且,因为数据存储在一片连续的内存空间,通过键来获取值为列表类型的数据,读取的效率也非常高。
196+
197+
当列表中存储的数据量比较大的时候,也就是不能同时满足刚刚讲的两个条件的时候,列表就要通过双向循环链表来实现了。
198+
199+
Redis 的这种双向链表的实现方式,非常值得借鉴。它额外定义一个 list 结构体,来组织链表的首、尾指针,还有长度等信息。这样,在使用的时候就会非常方便。
200+
184201
我们可以从 [源码](https://github.com/redis/redis/blob/unstable/src/adlist.h "redis源码")`adlist.h/listNode` 来看到对其的定义:
185202

186203
```c
@@ -207,13 +224,21 @@ typedef struct list {
207224
} list;
208225
```
209226

210-
对于 Javaer 我认为知道底层是个**双端链表**就够了,如果再深入一点,你会发现 Redis 底层存储的还不是一个简单的 `linkedlist`,而是称之为快速链表 `quicklist` 的一个结构。
211-
212227

213228

214229
### 3、Hash(字典)
215230

216-
Redis hash 是一个键值对集合。KV 模式不变,但 V 是一个键值对。
231+
Redis hash 是一个键值对集合。KV 模式不变,但 V 又是一个键值对。
232+
233+
字典类型也有两种实现方式。一种是我们刚刚讲到的压缩列表,另一种是散列表。
234+
235+
同样,只有当存储的数据量比较小的情况下,Redis 才使用压缩列表来实现字典类型。具体需要满足两个条件:字典中保存的键和值的大小都要小于 64 字节;字典中键值对的个数要小于 512 个。
236+
237+
当不能同时满足上面两个条件的时候,Redis 就使用散列表来实现字典类型。Redis 使用MurmurHash2这种运行速度快、随机性好的哈希算法作为哈希函数。对于哈希冲突问题,Redis 使用链表法来解决。除此之外,Redis 还支持散列表的动态扩容、缩容。当数据动态增加之后,散列表的装载因子会不停地变大。为了避免散列表性能的下降,当装载因子大于 1 的时候,Redis 会触发扩容,将散列表扩大为原来大小的 2 倍左右(具体值需要计算才能得到,如果感兴趣,你可以去[阅读源码](https://github.com/redis/redis/blob/unstable/src/dict.c))。
238+
239+
扩容缩容要做大量的数据搬移和哈希值的重新计算,所以比较耗时。针对这个问题,Redis 使用我们在散列表(中)讲的渐进式扩容缩容策略,将数据的搬移分批进行,避免了大量数据一次性搬移导致的服务停顿。
240+
241+
217242

218243
Redis 的字典相当于 Java 语言里面的 HashMap,它是无序字典, 内部实现结构上同 Java 的 HashMap 也是一致的,同样的**数组 + 链表**二维结构。第一维 hash 的数组位置碰撞时,就会将碰撞的元素使用链表串接起来。
219244

@@ -263,6 +288,8 @@ typedef struct dict {
263288

264289
### 4、Set(集合)
265290

291+
集合这种数据类型用来存储一组不重复的数据。这种数据类型也有两种实现方法,一种是基于有序数组,另一种是基于散列表。当要存储的数据,同时满足下面这样两个条件的时候,Redis 就采用有序数组,来实现集合这种数据类型。存储的数据都是整数;存储的数据元素个数不超过 512 个。当不能同时满足这两个条件的时候,Redis 就使用散列表来存储集合中的数据。
292+
266293
Redis 的 Set 是 String 类型的无序集合。它是通过 HashTable 实现的, 相当于 Java 语言里面的 HashSet,它内部的键值对是无序的唯一的。它的内部实现相当于一个特殊的字典,字典中所有的 value 都是一个值`NULL`
267294

268295
当集合中最后一个元素移除之后,数据结构自动删除,内存被回收。
@@ -279,6 +306,17 @@ Redis 正是通过分数来为集合中的成员进行从小到大的排序。zs
279306

280307
zset 中最后一个 value 被移除后,数据结构自动删除,内存被回收。
281308

309+
实际上,跟 Redis 的其他数据类型一样,有序集合也并不仅仅只有跳表这一种实现方式。当数据量比较小的时候,Redis 会用压缩列表来实现有序集合。具体点说就是,使用压缩列表来实现有序集合的前提,有这样两个:
310+
311+
- 所有数据的大小都要小于 64 字节;
312+
- 元素个数要小于 128 个。
313+
314+
315+
316+
为什么 Redis 要用跳表来实现有序集合,而不是红黑树?
317+
318+
Redis 中的有序集合是通过跳表来实现的,严格点讲,其实还用到了散列表。不过散列表我们后面才会讲到,所以我们现在暂且忽略这部分。如果你去查看 Redis 的开发手册,就会发现,Redis 中的有序集合支持的核心操作主要有下面这几个:插入一个数据;删除一个数据;查找一个数据;按照区间查找数据(比如查找值在[100, 356]之间的数据);迭代输出有序序列。其中,插入、删除、查找以及迭代输出有序序列这几个操作,红黑树也可以完成,时间复杂度跟跳表是一样的。但是,按照区间来查找数据这个操作,红黑树的效率没有跳表高。对于按照区间查找数据这个操作,跳表可以做到 O(logn) 的时间复杂度定位区间的起点,然后在原始链表中顺序往后遍历就可以了。这样做非常高效。当然,Redis 之所以用跳表来实现有序集合,还有其他原因,比如,跳表更容易代码实现。虽然跳表的实现也不简单,但比起红黑树来说还是好懂、好写多了,而简单就意味着可读性好,不容易出错。还有,跳表更加灵活,它可以通过改变索引构建策略,有效平衡执行效率和内存消耗。不过,跳表也不能完全替代红黑树。因为红黑树比跳表的出现要早一些,很多编程语言中的 Map 类型都是通过红黑树来实现的。我们做业务开发的时候,直接拿来用就可以了,不用费劲自己去实现一个红黑树,但是跳表并没有一个现成的实现,所以在开发中,如果你想使用跳表,必须要自己实现。
319+
282320

283321

284322
## 二、其他数据类型

docs/data-structure-algorithms/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,4 +112,8 @@
112112
- 正确性:算法的正确性是指算法至少应该具有输入、输出和加工处理无歧义、能正确反映问题的需求、能得到问题的正确答案
113113
- 可读性:算法设计的另一目的是为了便于阅读、理解和交流
114114
- 健壮性:当输入数据不合法时,算法也能做出相关处理,而不是产生异常或错误结果
115-
- 时间效率高和存储量低
115+
- 时间效率高和存储量低
116+
117+
118+
119+
![](https://static001.geekbang.org/resource/image/91/a7/913e0ababe43a2d57267df5c5f0832a7.jpg)

docs/data-structure-algorithms/Skip-List.md

Lines changed: 137 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,63 @@
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

112151
P.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

116233
1. 各种搜索结构提高效率的方式都是通过空间换时间得到的。
@@ -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

Comments
 (0)