通过前面的总结,我们知道红黑树是一种存在于内存中,可以保证在最坏的情况下,对红黑树进行例如search,insert,以及delete等基本的动态集合操作的时间复杂度为O(lg n)。但是显而易见,红黑树实现起来比较复杂,尤其是对红黑树进行insert和delete操作。并且在红黑树中进行范围查询时需要对红黑树进行中序遍历,这也是比较复杂的操作。那有没有一种能确保对动态集合search,insert以及delete等操作的时间复杂度在O(lg n)的前提下,实现比较简单,还能比较方便的进行范围查询的数据结构呢?答案是肯定的,就是我们今天要总结的数据结构——跳跃表(skip list)。
假设我们在内存中有一个长度达到10万以上的一个已经排好序的链表结构。我们要往这个链表结构中插入一个元素。我们是怎么进行插入的呢?
来看下图所示的这个列表(为了使链表结构简单,图中只画出了四个元素:1,2,5,10):
上图所示链表中,各元素按照升序排列,现在要在该链表中插入元素3,首先要确定元素3应该插入的位置,由于是链表结构因此无法使用二分查找算法,只能和原链表中的结点逐一比较大小来确定位置。这一步的时间复杂度是O(N)。插入的过程到时很容易,直接改变结点指针的目标,时间复杂度是O(1)。因此总体的时间复杂度是O(N)。这对于拥有上十万的集合来说,这种办法显然太慢了。那有什么办法可以让search,insert以及delete操作性能更高一点呢?search,insert以及delete操作其实归根结底就是search太慢的问题。所以只要search操作变快insert和delete操作也会变快。
让我们来回想一下之前总结的MySQL索引。所谓的索引就是把数据库表中的一些特定信息提取出来,缩小查询操作时的搜索范围,来提升查询性能。那我们是不是可以借鉴数据库索引的思想,提取出链表中的部分关键结点。比如给定一个长度是7的有序链表,结点值一次是1,2,3,5,6,7,8。那么我们可以取出所有值为奇数的结点作为关键结点。
此时如果要插入一个值为4的新节点,不再需要和原结点8,7,6,5,3逐一进行比较,只需要比较关键结点7,5,3即可。
确定了新结点在关键结点中的位置(3和5之间),就可以回到原链表,迅速定位到对应的位置插入(同样是3和5之间)。
节点数目少,优化效果不是很明显,如果是十万个结点,比较次数就整整减少了一半!也就是说虽然增加了50%的额外的空间,但是性能提高了一倍。不过我们可以进一步思考。既然已经提取出了一层关键结点作为索引,那我们为何不能从索引中进一步提取,提出一层索引的索引?
有了2级索引之后,新的结点可以先和2级索引比较,确定大体范围之后在和1级索引进行比较,最后再回到原链表,找到并插入对应位置。当结点很多的时候,比较次数就会减少到原来的四分之一!当节点足够多的时候,我们可以不止提出两层索引,还可以向更高层次提取,保证每一层是上一层结点数的一半。提取的极限就是同一层只有两个结点的时候,因为一个结点没有比较的意义。这样的多层链表结构就是所谓的跳跃表。
有一个问题需要注意:当大量的新节点通过逐层比较,最终插入到原链表之后,上层的索引结点会渐渐变得不够用。这时候需要从新结点中选取一部分提到上一层。可是究竟应该提拔谁呢?这可能是随机选取(也叫抛硬币,50%的可能性会被提拔,50%的可能性不会被提拔)的,也可能是根据某些规则确定性的选取,其中随机选取更加常见(因为跳跃表的元素删除和添加是不可预测的,很难用一种有效的算法来保证跳跃表的索引分布始终均匀,随机选取虽然不能保证索引绝对均匀分布,却可以大体上趋于均匀)。下面以随机选取为例进行说明,我们仍然借用刚才的例子,假如值为9的新节点插入原链表:
总结一下,跳跃表插入节点的流程有以下几步:
- 新结点和各层索引结点逐一比较,确定原链表的插入位置,时间复杂度是O(logN)。
- 把索引插入到原链表,时间复杂度是O(1)。
- 利用抛硬币的随机方式,决定新结点是否提升到上一级索引。结果为正则提升,并且继续抛硬币,结果为负则停止,时间复杂度是O(logN)。
总体上,跳跃表插入操作的时间复杂度是O(logN),而这种数据结构所占空间是2N。
下面来讨论一下跳跃表的删除操作。跳跃表的删除操作比较简单,只要在索引层找到要删除的结点,然后顺藤摸瓜,删除每一层的相同结点即可。
如果某一层索引在删除后只剩下一个结点,那么整个一层就可以干掉了。例如要删除结点的值是5:
我们来总结一下跳跃表删除结点的操作步骤:
- 自上而下,查找第一次出现结点的索引,并逐层找到每一层对应的结点(因为每层索引都是由上层索引晋升的),时间复杂度是O(logN)。
- 删除每一层查找到的结点,如果该层只剩下一个结点,删除整个一层,时间复杂度是O(logN)。
总体上,跳跃表删除操作的时间复杂度是O(logN)。
从上面的总结可以看出,相对于红黑树来说,由于跳跃表维持结构平衡的成本比较低,完全依靠随机。而红黑树在多次插入和删除后,需要rebalance来重新调整结构平衡。
跳跃表在lucene以及redis的sorted-set有序集合中都得到了应用。
这是redis的作者选用了跳跃表来实现sorted-set的说明
There are a few reasons:
- They are not very memory intensive. It’s up to you basically. Changing parameters about the probability of a node to have a given number of levels will make then less memory intensive than btrees.
- A sorted set is often target of many ZRANGE or ZREVRANGE operations, that is, traversing the skip list as a linked list. With this operation the cache locality of skip lists is at least as good as with other kind of balanced trees.
- They are simpler to implement, debug, and so forth. For instance thanks to the skip list simplicity I received a patch (already in Redis master) with augmented skip lists implementing ZRANK in O(log(N)). It required little changes to the code.
About the Append Only durability & speed, I don’t think it is a good idea to optimize Redis at cost of more code and more complexity for a use case that IMHO should be rare for the Redis target (fsync() at every command). Almost no one is using this feature even with ACID SQL databases, as the performance hint is big anyway.
About threads: our experience shows that Redis is mostly I/O bound. I’m using threads to serve things from Virtual Memory. The long term solution to exploit all the cores, assuming your link is so fast that you can saturate a single core, is running multiple instances of Redis (no locks, almost fully scalable linearly with number of cores), and using the “Redis Cluster” solution that I plan to develop in the future.