跳表 skiplist
跳表 (Skip List) 是由 William Pugh 在 1990 年发表的文章 Skip Lists: A Probabilistic Alternative toBalanced Trees 中描述的一种查找数据结构,支持对数据的快速查找,插入和删除。
对于 AVL 树、红黑树等平衡树,在插入过程中需要做多次旋转操作,实现复杂,而跳表实现更简单、开销更低,应用广泛。
跳表是一个包含随机算法的数据结构,跳表的查找、插入、删除的==平均时间复杂度==都是 O(logn),而且可以按照范围区间查找元素,==空间复杂度==是 O(n)。
跳表的最底层通常是一个==有序链表==,上层是下层的一个==索引==,下层元素出现在上层的概率为 p (通常 p=1/2)。
跳表的创建过程
首先,对于一个有序链表,如果我们需要查找元素,则需要从头开始遍历链表,查找时间复杂度为 O(n)。如果我们每相邻两个节点增加一个指针,让指针指向下下个节点,那么上层形成了一个减缩版的链表,当我查找元素时,先在上层链表查找,当待查元素在两个节点之间时,再回到下层链表查找,这样我们就可以跳过一些节点,提高查找速度。以此类推,我们可以在第二层链表上建立第三层链表……查找一个元素时,从最高层开始,逐层向下,直到找到为止。
上述我们建立的数据结构就是一个跳表,我们可以将上层链表看作是下层链表的一个索引,每相邻两个、三个……建立一个索引,这是一种确定性策略,如果只是查找操作,这么建立跳表没有问题。但是当我们需要频繁插入和删除元素时,这种确定性策略会使维护跳表变得复杂。
为了解决上述问题,我们引入随机策略,不要求上下相邻两层链表之间的节点个数有严格的对应关系,而是为每个节点随机产生一个层数(level)。
1 | // 该 randomLevel 方法会随机生成 1~MAX_LEVEL 之间的数,且 : |
创建跳表我们首先需要一个返回层数的随机函数 randomlevel(),在有序链表的每一个元素上运行该随机函数,当返回 level=1 时,不创建索引;当返回 level=2 时,对该元素创建一级索引;当返回 level=3 时,对该元素创建一级和二级索引……
如果 SKIPLIST_P=1/2 时,一级索引有 n/2 个元素,二级索引有 n/4 个元素……空间复杂度为 O(n)。如果底层链表中的元素存储的是对象,索引只需存储对象排序的键值 key 即可。
查找元素
查找元素时,从最高层索引开始查找,逐层向下,直到找到为止。根据概率可以证明查找的平均时间复杂度为 O(logn)。
1 | V& find(const K& key) { |
插入元素
插入元素的关键是查找元素的合适插入位置,将元素插入链表中之后,运行 randomlevel 函数,确定在该元素上建立几层索引。
1 | void insert(const K &key, const V &value) { |
删除元素
删除元素的关键同样是查找操作,先找到待删除元素的位置,然后删除对应元素及其索引,删除操作调整对应指针即可。
1 | bool erase(const K &key) { |
复杂度证明
空间复杂度
对于一个节点而言,节点的层数为 的概率为 ,则该节点的期望层数为:
则跳表的期望空间为 ,且因为 为常数,所以跳表的期望空间复杂度为 O(n)。在最坏的情况下,每一层有序链表等于初始有序链表,即跳表的最差空间复杂度为 O(nlogn)。
时间复杂度
从后向前分析查找路径,这个过程可以分为从最底层爬到第 层和后续操作两个部分。在分析时,假设一个节点的具体信息在它被访问之前是未知的。
假设当前我们处于一个第 层的节点 ,我们并不知道 的最大层数和 左侧节点的最大层数,只知道 的最大层数至少为 。如果 的最大层数大于 ,那么下一步应该是向上走,这种情况的概率为 ;如果 的最大层数等于 ,那么下一步应该是向左走,这种情况概率为 。
令 为在一个无限长度的跳表中向上爬 层的期望代价,定义 ,那么有:
解得 。
由此可以得出:在长度为 的跳表中,从最底层爬到第 层的期望步数存在上界 。
现在只需要分析爬到第 层后还要再走多少步。易得,到了第 层后,向左走的步数不会超过第 层及更高层的节点数总和,而这个总和的期望为 。所以到了第 层后向左走的期望步数存在上界 。同理,到了第 层后向上走的期望步数存在上界 。
所以,跳表查询的期望查找步数为 ,又因为 ,所以跳表查询的期望时间复杂度为 O(logn)。
在最坏的情况下,每一层有序链表等于初始有序链表,查找过程相当于对最高层的有序链表进行查询,即跳表查询操作的最差时间复杂度为 O(n)。
插入操作和删除操作就是进行一遍查询的过程,途中记录需要修改的节点,最后完成修改。易得每一层至多只需要修改一个节点,又因为跳表期望层数为 ,所以插入和修改的期望时间复杂度也为 O(logn)。
skiplist 与平衡树、哈希表的比较
- skiplist 和各种平衡树(如 AVL、红黑树等)的元素是有序排列的,而哈希表不是有序的。因此,在哈希表上只能做单个 key 的查找,不适宜做范围查找。所谓范围查找,指的是查找那些大小在指定的两个值之间的所有节点。
- 在做范围查找的时候,平衡树比 skiplist 操作要复杂。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在 skiplist 上进行范围查找就非常简单,只需要在找到小值之后,对第 1 层链表进行若干步的遍历就可以实现。
- 平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而 skiplist 的插入和删除只需要修改相邻节点的指针,操作简单又快速。
- 从内存占用上来说,skiplist 比平衡树更灵活一些。一般来说,平衡树每个节点包含2个指针(分别指向左右子树),而 skiplist 每个节点包含的指针数目平均为 ,具体取决于参数 的大小。如果像 Redis 里的实现一样,取 ,那么平均每个节点包含 1.33 个指针,比平衡树更有优势。
- 查找单个 key,skiplist 和平衡树的时间复杂度都为 O(logn),大体相当;而哈希表在保持较低的哈希值冲突概率的前提下,查找时间复杂度接近 O(1),性能更高一些。所以我们平常使用的各种 Map 或 dictionary 结构,大都是基于哈希表实现的。
- 从算法实现难度上来比较,skiplist 比平衡树要简单得多。
参考文献
[1] Skip Lists: A Probabilistic Alternative toBalanced Trees
[2] Skip list - Wikipedia
[3] Redis内部数据结构详解(6)——skiplist
[4] 跳表 - OI Wiki