Jiang's blog

数据结构学习笔记---跳表

Word count: 2.4kReading time: 9 min
2020/03/31 Share

学习自极客时间的《数据结构与算法之美》 作者:王争

跳表

跳表是一种动态数据结构,支持快速的插入、删除、查找操作,时间复杂度都是 O(logn)。目前开源软件 Redis 和 LevelDB 都有用到它,它的效率和红黑树以及 AVL 树不相上下,但跳表的原理相当简单,可以支持快速的插入、删除、查找操作。Redis 中的有序集合(Sorted Set)就是用跳表来实现的。

跳表的实现

1. 单链表

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

GQgkef.png

2. 建立一级索引

如果是说链表是排序的,并且节点中还存储了指向前面第二个节点的指针的话,那么在查找一个节点时,仅仅需要遍历N/2个节点即可。

GQ2Ch4.png

3. 多层索引

在第一级索引的基础之上,每两个结点就抽出一个结点到第二级索引。现在我们再来查找莫个数,只需要遍历需要遍历的结点数量又减少了。

GQRAPg.png

通过建立多级索引,从而使得链表能够实现二分查找。

GQRGRJ.png

跳表的时间复杂度

在一个单链表中查询某个数据的时间复杂度是 O(n)。那在一个具有多级索引的跳表中,查询某个数据的时间复杂度是多少呢?

  • 每两个结点会抽出一个结点作为上一级索引的结点,那第一级索引的结点个数大约就是 n/2,第二级索引的结点个数大约就是 n/4,第三级索引的结点个数大约就是 n/8,依次类推,也就是说,第 k 级索引的结点个数是第 k-1 级索引的结点个数的 1/2,那第 k级索引结点的个数就是 n/(2k)。
  • 假设索引有 h 级,最高级的索引有 2 个结点。通过上面的公式,我们可以得到 n/(2h)=2,从而求得 h=log2n-1。如果包含原始链表这一层,整个跳表的高度就是 log2n。我们在跳表中查询某个数据的时候,如果每一层都要遍历 m 个结点,那在跳表中查询一个数据的时间复杂度就是 O(m*logn)。
  • 假设我们要查找的数据是 x,在第 k 级索引中,我们遍历到 y 结点之后,发现 x 大于 y,小于后面的结点 z,所以我们通过 y 的 down 指针,从第 k 级索引下降到第 k-1 级索引。在第 k-1 级索引中,y 和 z 之间只有 3 个结点(包含 y 和 z),所以,我们在 K-1 级索引中最多只需要遍历 3 个结点,依次类推,每一级索引都最多只需要遍历 3 个结点,也就是说** m=3。**

GQWQOI.png

通过上面的分析,我们得到 m=3,所以在跳表中查询任意数据的时间复杂度就是 O(logn)。这个查找的时间复杂度跟二分查找是一样的。

3. 跳表的空间复杂度

假如原始链表包含 n 个元素,则一级索引元素个数为 n/2、二级索引元素个数为 n/4、三级索引元素个数为 n/8 以此类推。所以,索引节点的总和是:n/2 + n/4 + n/8 + … + 8 + 4 + 2 = n-2,空间复杂度是 O(n)。

如下图所示:如果每三个结点抽一个结点做为索引,索引总和数就是 n/3 + n/9 + n/27 + … + 9 + 3 + 1= n/2,减少了一半。

GQf1gJ.png

在实际的软件开发中,原始链表中存储的有可能是很大的对象,而索引结点只需要存储关键值和几个指针,并不需要存储对象,所以当对象比索引结点大很多时,那索引占用的额外空间就可以忽略了。

举个例子:我们现在需要用跳表来给所有学生建索引,学生有很多属性:学号、姓名、性别、身份证号、年龄、家庭住址、身高、体重等。学生的各种属性只需要在原始链表中存储一份即可,我们只需要用学生的学号(int 类型的数据)建立索引,所以索引相对原始数据而言,占用的空间可以忽略。

4. 插入数据

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

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

对于跳表来说,查找某个结点的的时间复杂度是 O(logn),所以这里查找某个数据应该插入的位置,方法也是类似的,时间复杂度也是 O(logn)。

GQ4ch8.png

5. 删除数据

跳表中,每一层索引其实都是一个有序的单链表,单链表删除元素的时间复杂度为 O(1),索引层数为 logn 表示最多需要删除 logn 个元素,所以删除元素的总时间包含 查找元素的时间 加 删除 logn个元素的时间 为 O(logn) + O(logn) = 2 O(logn),忽略常数部分,删除元素的时间复杂度为 O(logn)。

GQ5l8S.png

6. 跳表索引动态更新

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

GQI3i6.png

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

我们通过一个随机函数,来决定将这个结点插入到哪几级索引中,比如随机函数生成了值 K,那我们就将这个结点添加到第一级到第 K 级这 K 级索引中。

GQIrJf.png

过程如下:
GQ41mR.png

GQ4QX9.png

GQ4Kl4.png

GQ4M6J.png

跳表的实现代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
import random


class SkipListNode(object):
def __init__(self, val, high=1):
# 节点存储的值
self.data = val
# 节点对应索引层的深度
self.deeps = [None] * high


class SkipList(object):
"""
An implementation of skip list.
The list stores positive integers without duplicates.
跳表的一种实现方法。
跳表中储存的是正整数,并且储存的是不重复的。
Author: Ben
"""

def __init__(self):
# 索引层的最大深度
self.__MAX_LEVEL = 16
# 跳表的高度
self._high = 1
# 每一索引层的首节点, 默认值为None
self._head = SkipListNode(None, self.__MAX_LEVEL)

def find(self, val):
cur = self._head
# 从索引的顶层, 逐层定位要查找的值
# 索引层上下是对应的, 下层的起点是上一个索引层中小于插入值的最大值对应的节点
for i in range(self._high - 1, -1, -1):
# 同一索引层内, 查找小于插入值的最大值对应的节点
while cur.deeps[i] and cur.deeps[i].data < val:
cur = cur.deeps[i]

if cur.deeps[0] and cur.deeps[0].data == val:
return cur.deeps[0]
return None

def insert(self, val):
'''
新增时, 通过随机函数获取要更新的索引层数,
要对低于给定高度的索引层添加新结点的指针
'''
high = self.randomLevel()
if self._high < high:
self._high = high
# 申请新结点
newNode = SkipListNode(val, high)
# cache用来缓存对应索引层中小于插入值的最大节点
cache = [self._head] * high
cur = self._head

# 在低于随机高度的每一个索引层寻找小于插入值的节点
for i in range(high - 1, -1, -1):
# 每个索引层内寻找小于带插入值的节点
# ! 索引层上下是对应的, 下层的起点是上一个索引层中小于插入值的最大值对应的节点
while cur.deeps[i] and cur.deeps[i].data < val:
cur = cur.deeps[i]
cache[i] = cur

# 在小于高度的每个索引层中插入新结点
for i in range(high):
# new.next = prev.next \ prev.next = new.next
newNode.deeps[i] = cache[i].deeps[i]
cache[i].deeps[i] = newNode

def delete(self, val):
'''
删除时, 要将每个索引层中对应的节点都删掉
'''
# cache用来缓存对应索引层中小于插入值的最大节点
cache = [None] * self._high
cur = self._head
# 缓存每一个索引层定位小于插入值的节点
for i in range(self._high - 1, -1, -1):
while cur.deeps[i] and cur.deeps[i].data < val:
cur = cur.deeps[i]
cache[i] = cur
# 如果给定的值存在, 更新索引层中对应的节点
if cur.deeps[0] and cur.deeps[0].data == val:
for i in range(self._high):
if cache[i].deeps[i] and cache[i].deeps[i].data == val:
cache[i].deeps[i] = cache[i].deeps[i].deeps[i]

def randomLevel(self, p=0.25):
'''
#define ZSKIPLIST_P 0.25 /* Skiplist P = 1/4 */
https://github.com/antirez/redis/blob/unstable/src/t_zset.c
'''
high = 1
for _ in range(self.__MAX_LEVEL - 1):
if random.random() < p:
high += 1
return high

def __repr__(self):
vals = []
p = self._head
while p.deeps[0]:
vals.append(str(p.deeps[0].data))
p = p.deeps[0]
return '->'.join(vals)


if __name__ == '__main__':
sl = SkipList()
for i in range(100):
sl.insert(i)
print(sl)
p = sl.find(7)
print(p.data)
sl.delete(37)
print(sl)
sl.delete(37.5)
print(sl)
CATALOG
  1. 1. 跳表
    1. 1.1. 跳表的实现
      1. 1.1.1. 1. 单链表
      2. 1.1.2. 2. 建立一级索引
      3. 1.1.3. 3. 多层索引
    2. 1.2. 跳表的时间复杂度
    3. 1.3. 3. 跳表的空间复杂度
    4. 1.4. 4. 插入数据
    5. 1.5. 5. 删除数据
    6. 1.6. 6. 跳表索引动态更新
    7. 1.7. 跳表的实现代码