【数据结构与算法】跳表实现详解
文章目录
- Ⅰ. 前言
- Ⅱ. 跳表(skiplist)
- 一、什么是跳表
- 二、跳表的发明历程
- 三、跳表的搜索方式
- Ⅲ. skiplist的算法性能分析
- 一、理论准备
- 二、性能分析(了解即可,主要记结论)
- Ⅳ. skiplist与平衡树、哈希表的比较
- Ⅴ. skiplist的实现
- [ 设计跳表](https://leetcode.cn/problems/design-skiplist/)
- 一、节点的搭建
- 二、生成概率高度函数RandomLevel()
- 三、查找函数search()
- 四、插入函数add()
- 五、删除函数erase()
- Ⅵ. 关于Redis中的一些拓展知识
- :sa: Redis为什么用skiplist而不用平衡树?
- 附加:整体代码

Ⅰ. 前言
skiplist
是一种随机化的数据结构,基于并联的链表,实现简单,插入、删除、查找的复杂度均为 O(logN)(大多数情况下,因为是实现上是概率问题),因为其性能匹敌红黑树且实现较为简单,因此在很多著名项目都用 skiplist
来代替红黑树,例如 LevelDB
、RocksDB
、Redis
中的有序集合 zset
的底层存储结构就是用的 skiplist
。
目前常用的 key-value
数据结构有三种:哈希表、红黑树、skiplist
,它们各自有着不同的优缺点:
-
哈希表:插入、查找最快,为
O(1)
;如使用链表实现则可实现无锁;数据有序化需要显式的排序操作,即哈希表是无序的。 -
红黑树:插入、查找为
O(logn)
,但常数项较小;无锁实现的复杂性很高,一般需要加锁;数据天然有序。 -
skiplist:插入、查找为
O(logn)
,但常数项比红黑树要大;底层结构为链表,可无锁实现;数据天然有序。
下面我们就着重来讲解一下 skiplist
,以下是参考文案 (为了防止链接失效,大部分内容已经cv在文中,仅供参考!):
Redis内部数据结构详解(6)——skiplist
浅析skiplist(跳表)
skiplist跳表–一种高性能数据结构
Ⅱ. 跳表(skiplist)
一、什么是跳表
skiplist
本质上也是一种查找结构,是一个“概率型”的数据结构,用于解决算法中的查找问题,即根据给定的 key
,快速查到它所在的位置(或者对应的 value
)。
跳表是在 1989
年由 William Pugh
发明的,作者时年 29
岁。skiplist
发明的 初衷是为了克服平衡树的一些缺点,比如平衡树在节点插入时,需要额外的进行的树的转枝,剪枝操作,才能够达到树的平衡。而 skiplist
借助于其独特的设计,在数据插入过程中,不需要额外的数据排序过程,只需要找到其需要插入位置的前置节点,即可插入,插入之后,依然保持数据结构的数据平衡。
他在论文《Skip lists: a probabilistic alternative to balanced trees》中详细介绍了跳表的数据结构和插入删除等操作。论文是这么介绍跳表的:
Skip lists are a data structure that can be used in place of balanced trees. Skip lists use probabilistic balancing rather than strictly enforced balancing and as a result the algorithms for insertion and deletion in skip lists are much simpler and significantly faster than equivalent algorithms for balanced trees.
翻译:跳表是一种可以用来代替平衡树的数据结构。跳表使用概率平衡而不是严格强制的平衡,因此,跳表中的插入和删除算法比平衡树的等效算法简单得多,速度也快得多。
二、跳表的发明历程
skiplist
,顾名思义,首先它是一个 list
。实际上,它是在有序链表的基础上发展起来的。如果是一个有序的链表,查找数据的时间复杂度是 O(N)
。所以 William Pugh
开始想出了下面的优化思路:
- 假如我们每相邻两个节点升高一层,增加一个指针,让指针指向下下个节点,如下图所示中的 b。这样所有新增加的指针连成了一个新的链表,但它包含的节点个数只有原来的一半。由于新增加的指针,我们不再需要与链表中每个节点逐个进行比较了,需要比较的节点数大概只有原来的一半。
- 以此类推,我们可以在第二层新产生的链表上,继续为每相邻的两个节点升高一层,增加一个指针,从而产生第三层链表。如下图中的c,这样搜索效率就进一步提高了。
skiplist
正是受这种多层链表的想法的启发而设计出来的。实际上,按照上面生成链表的方式,上面每一层链表的节点个数,是下面一层的节点个数的一半,这样查找过程就非常类似二分查找,使得查找的时间复杂度可以降低到O(log n)
。但是这个结构在插入删除数据的时候有很大的问题,插入或者删除一个节点之后,就会打乱上下相邻两层链表上节点个数严格的2:1
的对应关系。如果要维持这种对应关系,就必须把新插入的节点后面的所有节点(也包括新插入的节点)重新进行调整,这会让时间复杂度重新蜕化成O(n)
。
skiplist
的设计为了避免这种问题,做了一个大胆的处理,不再严格要求对应比例关系,而是插入一个节点的时候随机出一个层数。这样每次插入和删除都不需要考虑其他节点的层数,这样就好处理多了。细节过程入下图:
上图中插入 17
这个元素,其实就相当于是放了一堵墙,将 9
和 12
两个元素的指针挡住了,接到了 17
上面,这其实是不影响这些节点的,所以插入过程对其他节点是很稳定的!
♻️ 其中最底层的链表,即包含了所有元素节点的链表是 L1
层,或称基础层,基础层包括所有的元素。除此以外的所有链表层都称为跳跃层。
三、跳表的搜索方式
我们先来看一个有序链表,如下图(最左侧的灰色节点表示一个空的头结点):
在这样一个链表中,如果我们要查找某个数据,那么需要从头开始逐个进行比较,直到找到包含数据的那个节点,或者找到第一个比给定数据大的节点为止(没找到)。也就是说,时间复杂度为 O(n)
。同样,当我们要插入新数据的时候,也要经历同样的查找过程,从而确定插入位置。
假如我们每相邻两个节点增加一个指针,让指针指向下下个节点,如下图:
这样所有新增加的指针连成了一个新的链表,但它包含的节点个数只有原来的一半(上图中是7, 19, 26)。现在当我们想查找数据的时候,可以 先沿着这个新链表进行查找,当碰到比待查数据大的节点时,再回到原来的链表中进行查找(也就是向下走)。比如,我们想查找 23
,查找的路径是沿着下图中标红的指针所指向的方向进行的:
23
首先和7
比较,再和19
比较,比它们都大,继续向右比较。- 但
23
和26
比较的时候,比26
要小,因此要向下走,与22
比较。 23
比22
要大,继续向右和26
比较。23
比26
小,说明待查数据23
在原链表中不存在,而且它的插入位置应该在22
和26
之间。
后面若增加了高度,也是一样的遍历方法!
Ⅲ. skiplist的算法性能分析
一、理论准备
如果是第一次接触 skiplist
,那么一定会产生一个疑问:节点插入时随机出一个层数,仅仅依靠这样一个简单的随机数操作而构建出来的多层链表结构,能保证它有一个良好的查找性能吗?为了回答这个疑问,我们需要分析 skiplist
的统计性能!
在分析之前,我们还需要着重指出的是,执行插入操作时计算随机数的过程,是一个很关键的过程,它对 skiplist
的统计特性有着很重要的影响。这并不是一个普通的服从均匀分布的随机数,它的计算过程如下:
- 首先,每个节点肯定都有第一层指针。
- 如果一个节点有第
i
层 (i>=1
) 指针(即节点已经在第1
层到第i
层链表中),那么它有第i + 1
层指针的概率为p
。 - 节点最大的层数不允许超过一个最大值,记为
MaxLevel
。
这个计算随机层数的伪代码如下所示:
randomLevel()
的伪代码中包含两个参数,一个是 p
,一个是 MaxLevel
。在 Redis
的 skiplist
实现中,这两个参数的取值为:
p = 1/4
MaxLevel = 32
二、性能分析(了解即可,主要记结论)
根据前面 randomLevel()
的伪码,我们很容易看出,产生越高的节点层数,概率是越低的。定量的分析如下:
因此,一个节点的平均层数(也即包含的平均指针数目),计算如下:
现在很容易计算出:
- 当
p=1/2
时,每个节点所包含的平均指针数目为2
; - 当
p=1/4
时,每个节点所包含的平均指针数目为1.33
。这也是Redis
里的skiplist
实现在空间上的开销。
也可以看出了,我们 取的概率越小,那么产生越高节点的层数的几率就越小!可能有点抽象,画个图帮助理解一下:
接下来,为了分析时间复杂度,我们计算一下 skiplist
的平均查找长度。查找长度指的是查找路径上跨越的跳数,而查找过程中的比较次数就等于查找长度加 1
。以前面图中标出的查找 23
的查找路径为例,从左上角的头结点开始,一直到结点 22
,查找长度为 6
。
我们注意到,每个节点插入的时候,它的层数是由随机函数 randomLevel()
计算出来的,而且随机的计算不依赖于其它节点,每次插入过程都是完全独立的。所以,从统计上来说,一个 skiplist
结构的形成与节点的插入顺序无关。
这样的话,为了计算查找长度,我们可以将查找过程倒过来看,从右下方第 1
层上最后到达的那个节点开始,沿着查找路径向左向上回溯,类似于爬楼梯的过程。我们假设当回溯到某个节点的时候,它才被插入,这虽然相当于改变了节点的插入顺序,但从统计上不影响整个 skiplist
的形成结构。
现在假设我们从一个 层数i
的 节点x
出发,需要向左向上攀爬 k
层。这时我们有两种可能:
- 如果节点
x
有第i+1
层指针,那么我们需要向上走。这种情况概率为p
。 - 如果节点
x
没有第i+1
层指针,那么我们需要向左走。这种情况概率为1-p
。
这两种情形如下图所示:
用 C(k)
表示向上攀爬 k
个层级所需要走过的平均查找路径长度(概率期望),那么:
代入,得到一个差分方程并化简:
这个结果的意思是,我们每爬升 1
个层级,需要在查找路径上走 1/p
步。而我们总共需要攀爬的层级数等于整个 skiplist的总层数 - 1
。
那么接下来我们需要分析一下当 skiplist
中有 n
个节点的时候,它的总层数的概率均值是多少。这个问题直观上比较好理解。根据节点的层数随机算法,容易得出:
所以,从第 1
层到最高层,各层链表的平均节点数是一个指数递减的等比数列。容易推算出,总层数的均值为 l o g 1 / p n log_{1/p}n log1/pn,而最高层的平均节点数为 1/p
。
综上,粗略来计算的话,平均查找长度约等于:
- C( l o g 1 / p n − 1 log_{1/p}n-1 log1/p