跳表(SkipList,全称跳跃表)是用于有序元素序列快速搜索查找的一个数据结构,跳表是一个随机化的数据结构,实质上就是一种可以进行二分查找的有序链表。
跳表在原有的有序链表上增加了多级索引,通过索引来实现快速查找。跳表不仅能提高搜索性能,同时也可以提高插入和删除操作的性能。它在性能上和红黑树、AVL 树不相上下,但是跳表的原理非常简单,实现也比红黑树简单很多。
回顾链表,链表的痛点就是查询很慢很慢!每次查询都是 O(n) 复杂度的操作,下图是一个带头结点的链表(头结点相当于一个固定的入口,不存储有意义的值),每次查找都需要一个个枚举,相当得慢。
我们能不能稍微优化一下呢?答案是可以的,我们知道很多算法和数据结构是以空间换时间,我们在上面加一层索引,让部分节点在上层能够直接被定位到,这样链表的查询时间就近乎减少一半。
这样在查询某个节点时,首先会从上一层快速定位节点所在的一个范围,然后向下查找。在表结构设计上会增加一个向下的索引(指针)用来查找底层节点,平均查找速度为 O(n/2)。但是当节点数量很大的时候,它依旧很慢很慢。
我们都知道二分查找是每次都能近乎折半的去压缩查找范围,要是有序链表也能这么跳起来那就太完美了。
没错,跳表就能让链表拥有近乎接近二分查找效率的一种数据结构,其原理依然是给上面加若干层索引,优化查找速度。
如上图所示,通过这样的一个数据结构,对有序链表进行查找都能达到近乎二分的性能。在上面维护那么多层的索引,首先在最高级索引上查找最后一个小于当前查找元素的位置,然后再跳到次高级索引继续查找,直到跳到最底层为止,这时候已经十分接近要查找的元素的位置了(如果查找元素存在的话)。由于根据索引可以一次跳过多个元素,所以查找的速度也就变快了。
对于理想的跳表,每向上一层,索引节点数量都是下一层的 1/2。但是这样完美的结构真的存在吗?
大概率是不存在的,因为作为一个链表,少不了增删改查的一些操作。而删除和插入可能会改变整个结构,所以这是理想的结构。至于在插入的时候是否要添加上层索引,是个概率问题(1/2 的概率)。
跳表的增删改查
查询操作
查询流程很简单,设置一个临时节点 team=head。当 team 不为 null 时,其流程大致如下。
- 从 team 节点出发,如果当前节点的 key 与查询的 key 相等,那么直接返回当前节点(如果是修改操作,那么一直向下进行修改值即可)
- 如果 key 不相等,且右侧为 null,那么证明只能向下(结果可能出现在右下方),此时team=team.down
- 如果 key 不相等,且右侧不为 null,且右侧节点 key 小于待查询的 key。那么说明同级还可向右,此时 team=team.right
- 如果 key 不相等,且右侧不为 null,且右侧节点 key 大于待查询的 key 。那么说明如果有结果,则就在这个索引和下个索引之间,此时 team=team.down
- 最终将按照这个步骤返回正确的节点或者 null(说明没查到)
- 首先从 head 出发,发现右侧不为空,且 7 < 12,向右
- 右侧为 null,向下
- 节点 7 的右侧不为空,且 10 < 12,继续向右
- 节点 10 右侧为 null,向下
- 右侧为节点 12,小于等于,向右
- 发现节点值相等,返回节点,结束
删除操作
删除需要改变链表结构,所以需要处理好节点之间的联系,对于删除操作需要谨记以下几点。
- 删除当前节点,和这个节点的前后节点都有关系
- 删除当前层节点之后,下一层该 key 的节点也要删除,一直删除到最底层为止
如果找到了当前节点,怎么查找它的前一个节点呢?总不能再遍历一遍吧。
使用四个方向的指针(上下左右)用来找到左侧节点是可以的,但是这里可以特殊处理一下 ,不直接判断和操作节点,而是先找到待删除节点的左侧节点,通过这个节点即可完成删除,然后这个节点直接向下去找下一层待删除的左侧节点。
设置一个临时节点 team=head,当 team 不为 null 时,具体循环流程如下。
- 如果 team 右侧为 null,那么 team=team.down(之所以敢直接这么判断,是因为左侧有头结点,所以不用担心特殊情况)
- 如果 team 右侧不为 null,并且右侧的 key 等于待删除的 key,那么先删除节点,再 team 向下,team=team.down,为了删除下层节点
- 如果 team 右侧不为 null,并且右侧 key 小于待删除的 key,那么 team 向右,team=team.right
- 如果 team 右侧不为 null,并且右侧 key 大于待删除的 key,那么 team 向下,team=team.down,在下层继续查找删除节点
- 首先 team=head,从 team 出发,7 < 10,向右(team=team.right 后面省略)
- 右侧为 null,只能向下
- 右侧为 10,在当前层删除 10 节点,然后向下继续查找下一层 10 节点
- 8 < 10,向右
- 右侧为 10,删除该节点,并且 team 向下
- team 为 null,说明删除完毕,退出循环
插入操作
插入操作实现起来是最麻烦的,需要的考虑的东西最多。回顾查询,不需要动索引,回顾删除,每层索引如果有,删除就是了。但是插入不一样,插入需要考虑是否插入索引,插入几层等问题。
由于需要插入删除所以我们肯定无法维护一个完全理想的索引结构,因为它耗费的代价太高。但我们使用随机化的方法去判断是否向上层插入索引。即产生一个[0-1]的随机数,如果小于 0.5 就向上插入索引,插入完毕后再使用随机数来判断是否再向上插入索引。
如果运气好,这个值可能是多层索引,但如果运气不好,则只插入最底层(因为这是 100% 要插入的)。
但是索引也不能不限制高度,我们一般会设置索引最高值,如果大于这个值,就不继续往上添加索引了。
- 首先通过上面查找的方式,找到待插入的左节点。最底层肯定是需要插入的,通过链表插入节点(需要考虑是否是末尾节点)
- 插入完这一层,需要考虑上一层是否需要插入,首先判断当前索引层级,如果大于最大值,那么就停止(比如已经到达最高索引层了)。否则就设置一个随机数,以 1/2 的概率向上插入一层索引(因为理想状态下就是需要每 2 个向上建一个索引节点)
- 重复第二步的操作,直到概率退出或者索引层数大于最大索引层
在具体向上插入的时候,实际上还有非常重要的细节需要考虑。
首先是如何找到上层的待插入节点?
各个实现方法可能不同,如果有左、上指向的指针,那么可以向左向上找到上层需要插入的节点,但是如果只有右指向和下指向的指针,我们也可以巧妙地借助查询过程中记录的下降节点。因为曾经下降的节点倒序就是需要插入的节点,最底层也不例外(因为没有匹配值,会下降为 null,结束循环),可以使用栈或 List 数据结构进行存储。
其次是如果该层是目前的最高层索引,如何继续向上建立索引?
首先跳表最初肯定是没索引的,然后才慢慢添加节点,才有一层、二层索引,但是如果这个节点添加的索引突破了当前的最高层,该怎么办呢?
这时候需要注意,跳表的 head 需要改变了,新建一个 ListNode 节点作为新的 head,将它的 down 指向旧 head,并将这个 head 节点加入栈中(即这个节点作为下次后面要插入的节点)。例如上面的 9节点如果运气够好,会再往上建立一层节点,如下图所示。
简单总结,插入上层的时候,注意所有节点要新建(拷贝),除了 right 的指向,down 的指向也不能忘记。如果层数突破当前最高层,头 head 节点(入口)也需要改变。
'use strict';
// const _ = require('lodash');
class SkipNode {
constructor(key, value, right = null, down = null) {
this.key = key;
this.down = down;
this.right = right;
this.value = value;
}
}
class SkipList {
constructor() {
this.highLevel = 0;
this.maxLevel = 32;
this.headNode = new SkipNode(-1, null);
}
// 搜索节点
search(key) {
let tempNode = this.headNode;
// 当节点不为null时, 遍历搜索
while (tempNode !== null) {
// 找到节点
if (key === tempNode.key) return tempNode;
// 节点右侧为null或key值比搜索的大, 则往下一层搜索
if (tempNode.right === null || tempNode.right.key > key) {
tempNode = tempNode.down;
} else {
// 节点右侧的key比搜索的小, 说明节点可能还在右侧
tempNode = tempNode.right;
}
}
return null;
}
// 删除节点
delete(key) {
let tempNode = this.headNode;
// 当节点不为null时, 遍历搜索
while (tempNode !== null) {
// 节点右侧为null或节点右侧key值比搜索的大, 则往下一层搜索
if (tempNode.right === null || tempNode.right.key > key) {
tempNode = tempNode.down;
} else if (key === tempNode.right.key) {
// 找到节点, 删除并继续往下一层搜索
tempNode.right = tempNode.right.right;
tempNode = tempNode.down;
} else {
// 节点右侧key值比搜索的小, 说明节点可能还在右侧
tempNode = tempNode.right;
}
}
}
// 添加节点
add(node) {
const key = node.key;
const findNode = this.search(key);
// 找到节点, 修改value值
if (findNode !== null) {
findNode.value = node.value;
return;
}
// 数组记录从上往下搜索节点的过程, 其右侧可能需要添加
const stack = [];
let tempNode = this.headNode;
while (tempNode !== null) {
// 节点右侧为null或key值比搜索的大, 记录并往下一层搜索
if (tempNode.right === null || tempNode.right.key > key) {
stack.push(tempNode);
tempNode = tempNode.down;
} else {
// 节点右侧key值小于等于搜索的, 说明还可继续往右搜索
tempNode = tempNode.right;
}
}
// 设置初始层数, 其值不可大于阈值
let level = 1;
// 设置初始前驱节点
let downNode = null;
// 从下往上倒推
while (stack.length) {
tempNode = stack.pop();
// 添加的新节点
const newNode = new SkipNode(node.key, node.value);
newNode.down = downNode;
downNode = newNode;
// 节点右侧为null, 说明添加位置在末尾
if (tempNode.right === null) {
tempNode.right = newNode;
} else {
// 节点右侧key值不为null, 说明添加位置在两个节点中间
newNode.right = tempNode.right
tempNode.right = newNode;
}
// 层级超过阈值, 退出
if (level > this.maxLevel) break;
// 概率性退出
// if (_.random(0, 1) < 1) break;
if (Math.random() > 0.5) break;
level++;
// 比当前最大高度要高但依然在允许范围内
if (level > this.highLevel) {
this.highLevel = level;
// 尝试往上一层添加
const newHeadNode = new SkipNode(-1, null);
newHeadNode.down = this.headNode;
this.headNode = newHeadNode;
stack.push(this.headNode);
}
}
}
// 打印节点
printList() {
let tempNode = this.headNode;
let lastNode = tempNode;
// 找到最底层的节点
while (lastNode.down !== null) {
lastNode = lastNode.down;
}
while (tempNode !== null) {
// 右侧节点
let lastRightNode = lastNode.right;
let tempRightNode = tempNode.right;
let line = 'head->';
// 右侧节点都有值时
while (lastRightNode !== null && tempRightNode !== null) {
// 右侧节点key值相同时, 记录key值
if (lastRightNode.key === tempRightNode.key) {
line = `${line}${lastRightNode.key}->`;
lastRightNode = lastRightNode.right;
tempRightNode = tempRightNode.right;
} else {
// 右侧节点key值不相同时, 最底层节点往右侧搜索
lastRightNode = lastRightNode.right;
line = `${line} `;
}
}
// 继续往下一层搜索, 并打印该层节点
tempNode = tempNode.down;
console.log(line);
}
}
}
function test() {
const list = new SkipList();
for (let i = 0; i < 20; i++) {
list.add(new SkipNode(i, 123));
}
list.printList();
list.delete(13);
list.printList();
}
test();