在软件开发领域,无论您使用何种编程语言,对各种数据结构的熟悉是必不可少的。常见的数据结构包括数组、链表、栈、堆、树和图等。然而,除了这些常见类型之外,近年来在技术面试中频繁出现一种较少被提及但非常重要的数据结构——跳表。
跳表是由美国计算机科学家 William Pugh 所发明,最初在 1990 年的论文《Skip Lists: A Probabilistic Alternative to Balanced Trees》中被介绍。尽管您可能是首次听说这一概念,跳表却是一种基础而并非新近出现的数据结构。作为一种查找效率较高的数据结构,它在复杂性上介于数组和平衡树之间。
跳表的存在类似于《西游记》中所描述的六耳猕猴,它不属于常规数据结构类别,甚至在许多经典算法书籍中也难觅其踪影。但跳表在实际应用中扮演着关键角色,例如在 Redis 和 HBase 等知名系统中都有其应用。
跳表的基本结构
跳表在很多地方借鉴了链表,我们从普通的单向有序链表开始讲起:
在有序链表中,寻找特定数据通常需要从左至右逐个检查节点,直到找到目标或遇到一个比目标大的节点,这时就需要停止搜索。这种方法在数据量较大时效率较低,时间复杂度为 O(n),性能比较低下。那么有没有什么办法呢?
别急,如下图所示,选择几个节点,如 3、11、22、37,并将它们连接成一个新的链表,这个新链表的节点数量远少于原始链表。当我们想查数据的时候,先用这个新链表查。当碰到比目标大的节点时,再跳到原始链表中查找。
比如,我们想查找 26,查找的过程是沿着绿色的路径进行的:
首先,26 首先和 3、11、22 比较,26 比它们都大,直到发现它比 37 要小,因此,我们返回到 22 的位置并跳到原始链表,接着从原始链表中的 22 向后查找,找到了 26。
由于我们新增了链表 3-11-22-37,通过上面绿色箭头的流程,这个新链表将辅助我们快速找到 26,我们不用再傻傻地遍历原始链表,效率提升了很多。这是一个典型的空间换时间场景。
同样的思路,我们可以在现有基础上进一步构建更高层次的链表,以进一步提升性能。例如,我们可以将 22 单独提取出来,形成一个只包含一个节点的新链表,如下图:
假设我们还是查找 26,那么和刚才的过程差不多,先和 22 比较,发现大于 22,但是 22 没有后继节点,于是跳到下一层链表 22 所在的位置,和 37 比较发现不行,继续跳到下一层链表 22 所在的位置,然后向右找到了 26。过程和之前差不多但是路径更短了。当数据量足够大的时候,这种多层链表能让我们跳过很多中间节点,极大地加快查找速度。上面的查找过程类似于二分查找,使得时间复杂度可以降低到 O(log n)。
跳表的构造过程
在上面,我们从单向有序链表讲起,通过一个具体的示例,看到了跳表的基本数据结构,那么一个跳表应该分多少层是怎么决定的呢?答案是随机,以上面的 3 层跳表为例,我们来看看跳表的诞生过程:
首先,我们创建链表的第一个元素 3,它的随机层数为 2:
接着插入 7 随机层数为 1:
接着插入 11 随机层数为 2:
接着插入 19 随机层数为 1:
接着插入 22 随机层数为 3:
接着插入 26 随机层数为 1:
最后插入 37 随机层数为 2:
如此一来,我们就构建出了上面的跳表。通过这种随机层数的方式,插入操作只需要修改插入节点前后的指针,不需要对其他节点的层数进行调整。这是跳表的一个很核心的一个原理,这个原理使得它在插入性能上大大优于传统的平衡树。
通过上面的过程,我们很容易理解“跳表”名字的由来。跳表,英文是 SkipList,指的就是除了最下面原始链表之外,它会产生多层冗余、稀疏的链表。我们在查找数据时先在上层的链表中找,然后逐层向下,最终到原始链表来查找目标位置。在这个查找过程中,我们跳过了很多节点,从而大大加快了查找速度。
到此为止,关于跳表的查找和插入操作,我们就讲完了。而删除与插入差不多,我们也很容易能推测出来。无论用什么语言实现都比较简单,至少比传统的平衡树简单多了。
我们上面的例子是假设一个节点为一个数字对象,而实际上跳表的节点往往是一个 keyValue 结构。当然,跳表的查找过程、排序过程都只关注 key,并不关心 Value。
最后很多人从严谨的角度会问一个问题,跳表使用简单的随机数操作而构建出来的多层链表结构,能保证它的查找性能吗?关于这个问题在 William Pugh 前面的论文中已经被数学证明过了,结论就是平均时间复杂度为 O(log n)。所以,放心用吧!
总结
今天我们聊了跳表,跳表是一种由多个层次的链表组成的数据结构,其中高层次的链表更为稀疏。在查找数据时,我们首先从最高层次的链表开始,然后逐层向下查找,直至找到目标。跳表的构造过程简单,特别是与传统平衡树相比,其优势非常明显。平衡树的插入和删除操作可能涉及复杂的子树变化,而跳表的操作只需修改相邻节点的指针,简单而高效。