跳表(skipList)

1、为什么有skipList这种数据结构的出现

咱们知道二分查找算法之因此能达到 O(logn) 这样高效的一个重要缘由在于它所依赖的数据结构是数组,数组支持随机访问一个元素,经过下标很容易定位到中间元素。而链表是不支持随机访问的,只能从头至尾依次访问。可是数组有数组的局限性,好比须要连续的内存空间,插入删除操做会引发数组的扩容和元素移动,链表有链表的优点,链表不须要先申请连续的空间,插入删除操做的效率很是高。在不少状况下,数据是经过链表这种数据结构存储的,若是是有序链表,真的就没有办法使用二分查找算法了吗?实际上对有序链表稍加改造,咱们就能够对链表进行二分查找。这就是咱们要说的跳表。说到底,skipList的出现就是为了实现有序链表的二分查找,使有序链表的查找速度可以媲美它的效率和红黑树以及 AVL 树,而删除和插入的速度又能比他们速度更快。下面咱们来看一下,跳表是怎么跳的。这里记住一句话:跳表的查找、插入、删除的时间复杂度都是 O(logn)java

2、跳表的特色及结构

总结就是一句话:给有序的链表再加索引,在n级索引的基础上,还能够再加n+1级索引,直至该级索引的索引点只有2个时,就不必再加索引web

先看一下跳表的特色算法

  1. 由不少层组成
  2. 每一层都是一个有序链表
  3. 最底层的链表包含全部元素
  4. 每一个元素插入时随机生成它的leve
  5. 若是一个元素出如今第i层的链表中,则它在i-1层中也会出现
  6. 上层节点能够跳转到下层
  7. 跳表查询、插入、删除的时间复杂度为O(log n),与平衡二叉树接近

跳表的最终结构图:
在这里插入图片描述数组

3、跳表相关操做

  1. 查找操做,查找的时间复杂度
    在这里插入图片描述
    查找元素的过程是从最高级索引开始,一层一层遍历最后下沉到原始链表。因此,时间复杂度 = 索引的高度 * 每层索引遍历元素的个数。

图中所示,如今到达第 k 级索引,咱们发现要查找的元素 x 比 y 大比 z 小,因此,咱们须要从 y 处降低到 k-1 级索引继续查找,k-1级索引中比 y 大比 z 小的只有一个 w,因此在 k-1 级索引中,咱们遍历的元素最多就是 y、w、z,发现 x 比w大比 z 小以后,再降低到 k-2 级索引。因此,k-2 级索引最多遍历的元素为
w、u、z。其实每级索引都是相似的道理,每级索引中都是两个结点抽出一个结点做为上一级索引的结点。
如今咱们得出结论:当每级索引都是两个结点抽出一个结点做为上一级索引的结点时,每一层最多遍历3个结点。
跳表的索引高度 h = log2n,且每层索引最多遍历 3 个元素。因此跳表中查找一个元素的时间复杂度为 O(3*logn),省略常数即:O(logn)。数据结构

  1. 空间复杂度

跳表经过创建索引,来提升查找元素的效率,就是典型的“空间换时间”的思想,因此在空间上作了一些牺牲,那空间复杂度究竟是多少呢?dom

假如原始链表包含 n 个元素,则一级索引元素个数为 n/二、二级索引元素个数为 n/四、三级索引元素个数为 n/8 以此类推。因此,索引节点的总和是:n/2 + n/4 + n/8 + … + 8 + 4 + 2 = n-2,空间复杂度是 O(n)。
以下图所示:若是每三个结点抽一个结点作为索引,索引总和数就是 n/3 + n/9 + n/27 + … + 9 + 3 + 1= n/2,减小了一半。因此咱们能够经过较少索引数来减小空间复杂度,可是相应的确定会形成查找效率有必定降低,咱们能够根据咱们的应用场景来控制这个阈值,看咱们更注重时间仍是空间。
在这里插入图片描述svg

  1. 插入数据

插入数据看起来也很简单,跳表的原始链表须要保持有序,因此咱们会向查找元素同样,找到元素应该插入的位置。以下图所示,要插入数据6,整个过程相似于查找6,整个的查找路径为 一、一、一、四、四、5。查找到第底层原始链表的元素 5 时,发现 5 小于 6 可是后继节点 7 大于 6,因此应该把 6 插入到 5 以后 7 以前。整个时间复杂度为查找元素的时间复杂度 O(logn)。
在这里插入图片描述
这里有一个须要格外注意的地方:若是只是单纯插入数据而不更新索引,跳表最后也会退化成链表,以下图:
在这里插入图片描述
4. randomLevel() 方法大数据

对于这个问题 ,可能你们广泛想到的方法就是,插入数据后删除原来的索引,再从新创建索引。那么重建索引的重建索引的时间复杂度是多少呢?由于索引的空间复杂度是 O(n),即:索引节点的个数是 O(n) 级别,每次彻底从新建一个 O(n) 级别的索引,时间复杂度也是 O(n) 。形成的后果是:为了维护索引,致使每次插入数据的时间复杂度变成了 O(n)。spa

那有没有其余效率比较高的方式来维护索引呢?假如跳表每一层的晋升几率是 1/2,最理想的索引就是在原始链表中每隔一个元素抽取一个元素作为一级索引。换种说法,咱们在原始链表中随机的选 n/2 个元素作为一级索引是否是也能经过索引提升查找的效率呢? 固然能够了,由于通常随机选的元素相对来讲都是比较均匀的。以下图所示,随机选择了n/2 个元素作为一级索引,虽然不是每隔一个元素抽取一个,可是对于查找效率来说,影响不大,好比咱们想找元素 16,仍然能够经过一级索引,使得遍历路径较少了将近一半。若是抽取的一级索引的元素刚好是前一半的元素 一、三、四、五、七、8,那么查找效率确实没有提高,可是这样的几率过小了。咱们能够认为:当原始链表中元素数量足够大,且抽取足够随机的话,咱们获得的索引是均匀的。咱们要清楚设计良好的数据结构都是为了应对大数据量的场景,若是原始链表只有 5 个元素,那么依次遍历 5 个元素也没有关系,由于数据量太少了。因此,咱们能够维护一个这样的索引:随机选 n/2 个元素作为一级索引、随机选 n/4 个元素作为二级索引、随机选 n/8 个元素作为三级索引,依次类推,一直到最顶层索引。这里每层索引的元素个数已经肯定,且每层索引元素选取的足够随机,因此能够经过索引来提高跳表的查找效率。
在这里插入图片描述
那代码该如何实现,才能使跳表知足上述这个样子呢?能够在每次新插入元素的时候,尽可能让该元素有 1/2 的概率创建一级索引、1/4 的概率创建二级索引、1/8 的概率创建三级索引,以此类推,就能知足咱们上面的条件。如今咱们就须要一个几率算法帮咱们把控这个 1/二、1/四、1/8 ... ,当每次有数据要插入时,先经过几率算法告诉咱们这个元素须要插入到几级索引中,而后开始维护索引并把数据插入到原始链表中。下面开始讲解这个几率算法代码如何实现。
咱们能够实现一个 randomLevel() 方法,该方法会随机生成 1~MAX_LEVEL 之间的数(MAX_LEVEL表示索引的最高层数),且该方法有 1/2 的几率返回 一、1/4 的几率返回 二、1/8的几率返回 3,以此类推。设计

  1. randomLevel() 方法返回 1 表示当前插入的该元素不须要建索引,只须要存储数据到原始链表便可(几率 1/2)
  2. randomLevel() 方法返回 2 表示当前插入的该元素须要建一级索引(几率 1/4)
  3. randomLevel() 方法返回 3表示当前插入的该元素须要建二级索引(几率 1/8) randomLevel() 方法返回 4 表示当前插入的该元素须要建三级索引(几率1/16)
  4. 。。。以此类推

因此,经过 randomLevel() 方法,咱们能够控制整个跳表各级索引中元素的个数。重点来了:randomLevel() 方法返回 2 的时候会创建一级索引,咱们想要一级索引中元素个数占原始数据的 1/2,可是 randomLevel() 方法返回 2 的几率为 1/4,那是否是有矛盾呢?明明说好的 1/2,结果一级索引元素个数怎么变成了原始链表的 1/4?咱们先看下图,应该就明白了。
在这里插入图片描述
假设咱们在插入元素 6 的时候,randomLevel() 方法返回 1,则咱们不会为 6 创建索引。插入 7 的时候,randomLevel() 方法返回3 ,因此咱们须要为元素 7 创建二级索引。这里咱们发现了一个特色:当创建二级索引的时候,同时也会创建一级索引;当创建三级索引时,同时也会创建一级、二级索引。因此,一级索引中元素的个数等于 [ 原始链表元素个数 ] * [ randomLevel() 方法返回值 > 1 的几率 ]。由于 randomLevel() 方法返回值 > 1就会建索引,凡是建索引,不管几级索引必然有一级索引,因此一级索引中元素个数占原始数据个数的比率为 randomLevel() 方法返回值 > 1 的几率。那 randomLevel() 方法返回值 > 1 的几率是多少呢?由于 randomLevel() 方法随机生成 1~MAX_LEVEL 的数字,且 randomLevel() 方法返回值 1 的几率为 1/2,则 randomLevel() 方法返回值 > 1 的几率为 1 - 1/2 = 1/2。即经过上述流程实现了一级索引中元素个数占原始数据个数的 1/2。

同理,当 randomLevel() 方法返回值 > 2 时,会创建二级或二级以上索引,都会在二级索引中增长元素,所以二级索引中元素个数占原始数据的比率为 randomLevel() 方法返回值 > 2 的几率。 randomLevel() 方法返回值 > 2 的几率为 1 减去 randomLevel() = 1 或 =2 的几率,即 1 - 1/2 - 1/4 = 1/4。OK,达到了咱们设计的目标:二级索引中元素个数占原始数据的 1/4。

以此类推,能够得出,遵照如下两个条件:

  1. randomLevel() 方法,随机生成 1~MAX_LEVEL 之间的数(MAX_LEVEL表示索引的最高层数),且有 1/2的几率返回一、1/4的几率返回 二、1/8的几率返回 3 …
  2. randomLevel() 方法返回 1 不建索引、返回2建一级索引、返回 3建二级索引、返回 4 建三级索引 …

就能够知足咱们想要的结果,即:一级索引中元素个数应该占原始数据的 1/2,二级索引中元素个数占原始数据的 1/4,三级索引中元素个数占原始数据的 1/8 ,依次类推,一直到最顶层索引。

randomLevel() 方法代码:

// 理论来说,一级索引中元素个数应该占原始数据的 50%,二级索引中元素个数占 25%,三级索引12.5% ,一直到最顶层。
    // 由于这里每一层的晋升几率是 50%。对于每个新插入的节点,都须要调用 randomLevel 生成一个合理的层数。
    // 该 randomLevel 方法会随机生成 1~MAX_LEVEL 之间的数,且 :
    // 50%的几率返回 1
    // 25%的几率返回 2
    // 12.5%的几率返回 3 ...
    private int randomLevel() {
        int level = 1;
        while (Math.random() < SKIPLIST_P && level < MAX_LEVEL) {
            level += 1;
        }
        return level;
    }

完整的插入流程:
在这里插入图片描述
整个插入过程的路径与查找元素路径相似, 每层索引中插入元素的时间复杂度 O(1),因此整个插入的时间复杂度是 O(logn)。

  1. 删除数据
    在这里插入图片描述
    删除元素的时间复杂度:
    删除元素的过程跟查找元素的过程相似,只不过在查找的路径上若是发现了要删除的元素 x,则执行删除操做。跳表中,每一层索引其实都是一个有序的单链表,单链表删除元素的时间复杂度为 O(1),索引层数为 logn 表示最多须要删除 logn 个元素,因此删除元素的总时间包含 查找元素的时间 加 删除 logn个元素的时间 为 O(logn) + O(logn) = 2 O(logn),忽略常数部分,删除元素的时间复杂度为 O(logn)。

本文转自