跳表是一种各方面性能都比较优秀的动态数据结构,可以支持快速地插入、删除、查找操作,它甚至可以替代红黑树。比如 Redis 中的有序集合(Sorted Set)的实现就利用了跳表。

跳表使用空间换时间的设计思路,通过构建多级索引来提高查询的效率,实现了基于链表的二分查找。

跳表是什么?

对一个单链表来讲,即便链表中存储的数据都是有序的,如果我们要想在其中查找某个数据,也只能从头到尾遍历链表。这样查找效率就会很低,时间复杂度是 O(n)。
image.png
为了提高查找效率,我们对链表建立一级索引,通过对每两个结点提取一个结点到上一级的方式,我们把抽出来的那一级叫索引层。如下图所示。图中的 down 表示指向下一级结点的指针。
image.png
如果我们现在要查找某个结点,比如 16。我们可以先在索引层遍历,当遍历到 13 时,我们发现下一个结点是 17,那要查找的结点 16 肯定就在这两个结点之间。然后我们通过索引层结点的 down 指针,下降到原始链表这一层,继续遍历。这个时候,我们只需要再遍历 2 个结点,就可以找到值等于 16 的这个结点了。这样,原来如果要查找 16,需要遍历 10 个结点,现在只需要遍历 7 个结点。

从这个例子里,我们看出,加了一层索引之后,查找一个结点需要遍历的结点个数减少了,也就是说查找效率提高了。那如果我们再加一级索引呢?效率会不会提升更多呢?我们在第一级索引的基础之上,每两个结点就抽出一个结点到第二级索引。现在我们再来查找 16,只需要遍历 6 个结点了,需要遍历的结点数量又减少了。
image.png
当链表的长度 n 比较大时,比如 1000、10000 的时候,在构建索引之后,查找效率的提升就会非常明显。这种链表加多级索引的结构,就是跳表

复杂度分析

1. 跳表的时间复杂度

我们知道,在一个单链表中查询某个数据的时间复杂度是 O(n)。那在一个具有多级索引的跳表中,查询某个数据的时间复杂度是多少呢?先把问题分解一下,先来想下,如果链表里有 n 个结点,会有多少级索引呢?

假设每两个结点会抽出一个结点作为上一级索引的结点,那第一级索引的结点个数就是 n/2,第二级索引的结点个数就是 n/4,第三级索引的结点个数大约就是 n/8,依次类推,第 k级索引结点的个数就是 n/(2k)。假设索引有 h 级,最高级的索引有 2 个结点。通过上面的公式,我们可以得到 n/(2h)=2,从而求得 h=log2n-1。如果包含原始链表这一层,那整个跳表的高度就是 log2n。我们在跳表中查询某个数据时,如果每一层都要遍历 m 个结点,那在跳表中查询一个数据的时间复杂度就是 O(m*logn)

那这个 m 的值是多少呢?按照前面这种索引结构,我们每一级索引都最多只需遍历 3 个结点,即 m=3。因为假设我们要查找的 x 在第 k 级索引中,我们遍历到 y 结点后发现 x 在 y 和 z 之间,所以通过 y 的 down 指针下降到第 k-1 级索引,y 和 z 之间只有 3 个结点(因为我们每隔两个节点就建立一个索引),所以我们在 K-1 级索引中最多只需要遍历 3 个结点,依次类推,每一级索引都最多只需要遍历 3 个结点。
image.png
通过上面的分析,我们得到 m=3,所以在跳表中查询任意数据的时间复杂度就是 O(logn)。这个查找的时间复杂度跟二分查找是一样的。换句话说,我们其实是基于单链表实现了二分查找,不过这种查询效率的提升,前提是建立了很多级索引,也就是空间换时间的设计思路。

2. 跳表的空间复杂度

比起单纯的单链表,跳表需要存储多级索引,所以肯定要消耗更多空间。下面,我们就来分析一下跳表的空间复杂度。假设原始链表大小为 n,那第一级索引大约有 n/2 个结点,第二级索引大约有 n/4 个结点,以此类推,每上升一级就减少一半,直到剩下 2 个结点。我们把每层索引的结点数写出来,就是一个等比数列。
image.png
这几级索引的结点总和就是 n/2+n/4+n/8…+8+4+2=n-2。所以跳表的空间复杂度是 O(n)。也就是说,如果将包含 n 个结点的单链表构造成跳表,我们需要额外再用接近 n 个结点的存储空间。

那有没有什么办法降低索引占用的内存空间呢?我们前面都是每两个结点抽一个结点到上级索引,如果我们每三个结点或五个结点抽一个结点到上级索引,是不是就不用那么多索引结点了呢?
image.png
从图中看到,第一级索引需要约 n/3 个结点,第二级索引需要约 n/9 个结点。假设最高一级的索引结点个数是 1。我们把每级索引的结点个数都写下来,也是一个等比数列。通过等比数列求和公式,总的索引结点大约就是 n/2。尽管空间复杂度还是 O(n),但比上面的每两个结点抽一个结点的索引构建方法,要减少了一半的索引结点存储空间。

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

高效的动态插入和删除

实际上,跳表这个动态数据结构,不仅支持查找操作,还支持动态的插入、删除操作,而且插入、删除操作的时间复杂度也是 O(logn)。那如何在跳表中插入一个数据,以及它是如何做到 O(logn) 时间复杂度的?

1. 动态插入

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

对于纯粹的单链表,需要遍历每个结点,来找到插入的位置。但是,对于跳表来说,因为查找某个结点的时间复杂度是 O(logn),所以这里查找某个数据应该插入的位置,时间复杂度也是 O(logn)
image.png

2. 动态删除

如果这个结点在索引中也有出现,我们除了要删除原始链表中的结点,还要删除索引中的结点。因为单链表中的删除操作需要拿到要删除结点的前驱结点,然后通过指针操作完成删除。所以在查找要删除的结点的时候,一定要获取前驱结点。如果我们用的是双向链表,就不需要考虑这个问题了。

3. 跳表索引动态更新

当我们不停地往跳表中插入数据时,如果我们不更新索引,就有可能出现某两个索引结点之间数据会非常多的情况。极端情况下,跳表还会退化成单链表。
image.png
作为一种动态数据结构,我们需要某种手段来维护索引与原始链表大小之间的平衡,也就是说,如果链表中结点多了,索引结点就相应地增加一些,避免复杂度退化,以及查找、插入、删除操作性能下降。如果你了解红黑树、AVL 树这样平衡二叉树,你就知道它们是通过左右旋的方式保持左右子树的大小平衡,而跳表是通过随机函数来维护这个“平衡性”的。

当我们往跳表中插入数据的时候,我们可以选择同时将这个数据插入到部分索引层中,那如何选择加入到哪些索引层中呢?我们通过一个随机函数,来决定将这个结点插入到哪几级索引中,比如随机函数生成了值 K,那我们就将这个结点添加到第一级到第 K 级这 K 级索引中。
image.png
这里,随机函数的选择很有讲究,从概率上来讲,要能够保证跳表的索引大小和数据大小平衡性,不至于性能过度退化。

代码实现:

  1. public class SkipList {
  2. /** 最大层数 */
  3. private static final int MAX_LEVEL = 16;
  4. /** 当前层数 */
  5. private static int levelCount = 1;
  6. /** 头节点 */
  7. private static final Node head = new Node();
  8. /**
  9. * 查找
  10. */
  11. public static Node find(int value) {
  12. Node p = head;
  13. // 从最顶层开始,遍历每层查找
  14. for (int i = levelCount - 1; i >= 0; --i) {
  15. while (p.forwards[i] != null && p.forwards[i].data < value) {
  16. p = p.forwards[i];
  17. }
  18. }
  19. if (p.forwards[0] != null && p.forwards[0].data == value) {
  20. return p.forwards[0];
  21. } else {
  22. return null;
  23. }
  24. }
  25. /**
  26. * 插入
  27. */
  28. public static void insert(int value) {
  29. // 随机生成层数
  30. int level = randomLevel();
  31. Node newNode = new Node();
  32. newNode.data = value;
  33. newNode.maxLevel = level;
  34. // update数组用来保存新节点的所有层数上,每个索引节点的前一个节点的信息
  35. Node[] update = new Node[level];
  36. for (int i = 0; i < level; ++i) {
  37. // 先循环将新节点的每层索引节点指向头节点
  38. update[i] = head;
  39. }
  40. Node p = head;
  41. // 从level层开始,从头节点开始遍历向后查找,索引节点要插入的位置
  42. for (int i = level - 1; i >= 0; --i) {
  43. while (p.forwards[i] != null && p.forwards[i].data < value) {
  44. p = p.forwards[i];
  45. }
  46. update[i] = p;
  47. }
  48. // 修改指针,插入索引层节点
  49. for (int i = 0; i < level; ++i) {
  50. newNode.forwards[i] = update[i].forwards[i];
  51. update[i].forwards[i] = newNode;
  52. }
  53. // 更新当前层高
  54. if (levelCount < level) levelCount = level;
  55. }
  56. /**
  57. * 删除
  58. */
  59. public static void delete(int value) {
  60. Node[] update = new Node[levelCount];
  61. Node p = head;
  62. // 从最高层开始,遍历每层查找要删除的元素
  63. for (int i = levelCount - 1; i >= 0; --i) {
  64. while (p.forwards[i] != null && p.forwards[i].data < value) {
  65. p = p.forwards[i];
  66. }
  67. // 这里即使不是给定元素也会放到update数组中
  68. update[i] = p;
  69. }
  70. // 遍历update数组进行删除,update数组的每个元素不一定是给定元素,因此删除前需要进行判断
  71. if (p.forwards[0] != null && p.forwards[0].data == value) {
  72. for (int i = levelCount - 1; i >= 0; --i) {
  73. if (update[i].forwards[i] != null && update[i].forwards[i].data == value) {
  74. update[i].forwards[i] = update[i].forwards[i].forwards[i];
  75. }
  76. }
  77. }
  78. while (levelCount > 1 && head.forwards[levelCount] == null){
  79. levelCount--;
  80. }
  81. }
  82. /**
  83. * 该 randomLevel 方法会随机生成 1~MAX_LEVEL 之间的数,且 :
  84. * 50%的概率返回 1
  85. * 25%的概率返回 2
  86. * 12.5%的概率返回 3 ...
  87. */
  88. private static int randomLevel() {
  89. int level = 1;
  90. while (Math.random() < 0.5f && level < MAX_LEVEL) {
  91. level += 1;
  92. }
  93. return level;
  94. }
  95. public static class Node {
  96. private int data = -1;
  97. // forwards数组用于存储该节点所有层的下一个节点的信息
  98. private final Node[] forwards = new Node[MAX_LEVEL];
  99. private int maxLevel = 0;
  100. }
  101. }