关于面试:java-实现跳表skiplist及论文解读

55次阅读

共计 7851 个字符,预计需要花费 20 分钟才能阅读完成。

什么是跳跃表

跳表由 William Pugh 创造。

他在论文《Skip lists: a probabilistic alternative to balanced trees》中具体介绍了跳表的数据结构和插入删除等操作。

跳表是一种能够用来代替均衡树的数据结构,跳表应用概率均衡而不是严格执行的均衡,因而,与等效树的等效算法相比,跳表中插入和删除的算法要简略得多,并且速度要快得多。

为什么须要?

性能比拟好。

实现绝对于红黑树比较简单。

占用更少的内存。

论文解读

为了学习第一手的材料,咱们先学习一下论文,而后再联合网上的文章,实现一个 java 版本的 skip-list。

William Pugh

二叉树可用于示意抽象数据类型,例如字典和有序列表。

当元素以随机程序插入时,它们能够很好地工作。某些操作序列(例如按程序插入元素)产生了生成的数据结构,这些数据结构的性能十分差。

如果能够随机排列要插入的我的项目列表,则对于任何输出序列,树都将很好地工作。在大多数状况下,必须在线答复查问,因而随机排列输出是不切实际的。

均衡树算法会在执行操作时重新排列树,以放弃肯定的平衡条件并确保良好的性能。

skiplist 是均衡树的一种 概率代替计划

通过征询随机数生成器来均衡 skiplist。只管 skiplist 在最坏状况下的性能很差,然而没有任何输出序列会始终产生最坏状况的性能(就像枢轴元素随机抉择时的疾速排序一样)。

skiplist 数据结构不太可能会重大失衡(例如,对于超过 250 个元素的字典,搜寻所破费的工夫超过预期工夫的 3 倍的机会少于百万分之一)。相似于通过随机插入构建的搜寻树,但不须要插入即可是随机的。

概率性地均衡数据结构比显式地保持平衡容易。

ps: 大部分程序员能够手写 skip-list,然而手写一个红黑树就要简单的多。

对于许多应用程序,skiplist 比树更天然地示意,这也导致 算法更简略

skiplist 算法的简略性使其更易于实现,并且在均衡树和自调整树算法上提供了显着的恒定因子速度改良。

skiplist 也十分 节俭空间。它们能够轻松配置为每个元素均匀须要 4/3 个指针(甚至更少),并且不须要将均衡或优先级信息与每个节点一起存储。

算法核心思想

对于一个 linked list 来说,如果要查找某个元素,咱们可能须要遍历整个链表。

如果 list 是有序的,并且每两个结点都有一个指针指向它之后两位的结点(Figure 1b),那么咱们能够通过查找不超过 ⌈n/2⌉+1 个结点来实现查找。

如果每四个结点都有一个指针指向其之后四位的结点,那么只须要查看最多 ⌈n/4⌉+2 个结点(Figure 1c)。

如果所有的第 (2^i) 个结点都有一个指针指向其 2^i 之后的结点(Figure 1d),那么最大须要被查看的结点个数为 ⌈log_n2⌉,代价仅仅是将须要的指针数量加倍。

这种数据结构的查问效率很高,然而对它的插入和删除简直是不可实现的(impractical)。

接下来看下论文中的一张图:

因为这样的数据结构是基于链表的,并且额定的指针会跳过两头结点,所以作者称之为 跳表(Skip Lists)

构造

从图中能够看到,跳跃表次要由以下局部形成:

表头(head):负责保护跳跃表的节点指针。

跳跃表节点:保留着元素值,以及多个层。

层:保留着指向其余元素的指针。高层的指针越过的元素数量大于等于低层的指针,为了进步查找的效率,程序总是从高层先开始拜访,而后随着元素值范畴的放大,缓缓升高档次。

表尾:全副由 NULL 组成,示意跳跃表的开端。

skip-list 算法过程

本节提供了在字典或符号表中搜寻,插入和删除元素的算法。

搜寻操作将返回与所需的键或失败的键关联的值的内容(如果键不存在)。

插入操作将指定的键与新值相关联(如果尚未存在,则插入该键)。

Delete 操作删除指定的密钥。

易于反对其余操作,例如“查找最小键”或“查找下一个键”。每个元素由一个节点示意,插入节点时,其级别是随机抉择的,而不思考元素的数量在数据结构中。

级别 i 节点具备 i 个前向指针,索引从 1 到 i。

咱们不须要在节点中存储节点的级别。级别以某个适当的常量 MaxLevel 进行下限。

list 的级别是列表中以后的最大级别(如果列表为空,则为 1)。

列表的题目具备从一级到 MaxLevel 的前向指针。

标头的前向指针在高于列表的以后最大级别的级别上指向 NIL

初始化

调配元素 NIL 并为其提供比任何非法密钥更大的密钥。

所有 skiplist 的所有级别均以 NIL 终止。

初始化一个新列表,以使列表的级别等于 1,并且列表题目的所有前向指针都指向 NIL

搜索算法

咱们通过遍历不超过蕴含要搜寻元素的节点的前向指针来搜寻元素(图 2)。

如果在以后的前向指针级别上无奈再获得任何停顿,则搜寻将向下挪动到下一个级别。

当咱们无奈在 1 级进行更多解决时,咱们必须紧靠在蕴含所需元素的节点之前(如果它在列表中)

插入和删除算法

要插入或删除节点,咱们只需进行搜寻和拼接,如图 3 所示。

图 4 给出了插入和删除的算法。

放弃向量更新,以便在搜寻实现时(并且咱们筹备执行拼接),update [i]蕴含一个指向级别 i 或更高级别的最左边节点的指针,该指针位于插图地位的左侧 / 删除。

如果插入生成的节点的级别大于列表的先前最大级别,则咱们将更新列表的最大级别,并初始化更新向量的适当局部。

每次删除后,咱们查看是否删除了列表的最大元素,如果删除了,请减小列表的最大级别。

抉择一个随机级别

最后,咱们探讨了概率分布,其中一半的具备 i 指针的节点也具备 i + 1 指针。

为了解脱魔术常数,咱们说具备 i 指针的节点的一小部分也具备 i + 1 指针。(对于咱们最后的探讨,p = 1/2)。

通过与图 5 中等效的算法随机生成级别。

生成级别时不参考列表中元素的数量。

咱们从什么级别开始搜寻?定义 L(n)

在用 p = 1/ 2 生成的 16 个元素的 skiplist 中,咱们可能会遇到 9 个 1 级元素,3 个 2 级元素,3 个 3 级元素和 1 个 14 级元素(这是不太可能的,然而能够产生)。

咱们应该如何解决呢?

如果咱们应用规范算法并从 14 级开始搜寻,咱们将做很多无用的工作。

咱们应该从哪里开始搜寻?

咱们的分析表明,现实状况下,咱们将在冀望 1/p 个节点的级别 L 处开始搜寻。

当 L = log_(1/p)n 时,会产生这种状况。

因为咱们将常常援用此公式,因而咱们将应用 L(n) 示意 log_(1/p)n。

对于决定如何解决列表中异样大的元素的状况,有许多解决方案。

别放心,要乐观些。

只需从列表中存在的最高级别开始搜寻即可。

正如咱们将在剖析中看到的那样,n 个元素列表中的最大级别显著大于 L(n) 的概率十分小。

从列表中的最高级别开始搜寻不会给预期搜寻工夫减少一个很小的常数。

这是本文形容的算法中应用的办法

应用少于给定的数量。

只管一个元素可能蕴含 14 个指针的空间,但咱们不须要全副应用 14 个指针。

咱们能够抉择仅应用 L(n) 级。

有很多办法能够实现此目标,然而它们都使算法复杂化并且不能显着进步性能,因而不倡议应用此办法。

修复随机性(dice)

如果咱们生成的随机级别比列表中的以后最大级别大一倍以上,则只需将列表中以后的最大级别再加上一个作为新节点的级别即可。

在实践中,从直观上看,此更改仿佛成果很好。

然而,因为节点的级别不再是齐全随机的,这齐全毁坏了咱们剖析后果算法的能力。

程序员可能应该随便实现它,而纯正主义者则应防止这样做。

确定 MaxLevel

因为咱们能够平安地将级别限度为 L(n),因而咱们应该抉择 MaxLevel = L(n)(其中 N 是 skiplist 中元素数量的下限)。

如果 p = 1/2,则应用 MaxLevel = 16 实用于最多蕴含 216 个元素的数据结构。

ps: maxLevel 能够通过元素个数 + P 的值推导进去。

针对 P,作者的倡议应用 p = 1/4。前面的算法剖析局部有具体介绍,篇幅较长,感兴趣的同学能够在 java 实现之后浏览到。

Java 实现版本

加深印象

咱们无论看实践感觉本人会了,然而经常是眼高手低。

最好的形式就是本人写一遍,这样印象能力粗浅。

节点定义

咱们能够认为跳表就是一个增强版本的链表。

所有的链表都须要一个节点 Node,咱们来定义一下:

/**
* 元素节点
* @param <E> 元素泛型
* @author 老马啸东风
*/
private static class SkipListNode<E> {
/**
* key 信息
* <p>
* 这个是什么?index 吗?*
* @since 0.0.4
*/
int key;
/**
* 寄存的元素
*/
E value;
/**
* 向前的指针
* <p>
* 跳表是多层的,这个向前的指针,最多和层数一样。*
* @since 0.0.4
*/
SkipListNode<E>[] forwards;
@SuppressWarnings("all")
public SkipListNode(int key, E value, int maxLevel) {
this.key = key;
this.value = value;
this.forwards = new SkipListNode[maxLevel];
}
@Override
public String toString() {
return "SkipListNode{" +
"key=" + key +
", value=" + value +
", forwards=" + Arrays.toString(forwards) +
'}';
}
}

事实证明,链表中应用 array 比应用 List 能够让代码变得简洁一些。

至多在浏览起来更加直,第一遍就是用 list 实现的,起初不全副重写了。

比照如下:

newNode.forwards[i] = updates[i].forwards[i]; // 数组
newNode.getForwards().get(i).set(i, updates.get(i).getForwards(i)); // 列表

查问实现

查问的思维很简略:咱们从最高层开始从左向右找(最下面一层能够最快定位到咱们想找的元素大略地位),如果 next 元素大于指定的元素,就往下一层开始找。

任何一层,找到就间接返回对应的值。

找到最上面一层,还没有值,则阐明元素不存在。

/**
* 执行查问
* @param searchKey 查找的 key
* @return 后果
* @since 0.0.4
* @author 老马啸东风
*/
public E search(final int searchKey) {
// 从右边最上层开始向右
SkipListNode<E> c = this.head;
// 从已有的最上层开始遍历
for(int i = currentLevel-1; i >= 0; i--) {while (c.forwards[i].key < searchKey) {
// 以后节点在这一层间接向前
c = c.forwards[i];
}
// 判断下一个元素是否满足条件
if(c.forwards[i].key == searchKey) {return c.forwards[i].value;
}
}
// 查问失败,元素不存在。return null;
}

ps: 网上的很多实现都是谬误的。大部分都没有了解到 skiplist 查问的精华。

插入

若 key 不存在,则插入该 key 与对应的 value;若 key 存在,则更新 value。

如果待插入的结点的层数高于跳表的以后层数 currentLevel,则更新 currentLevel。

抉择待插入结点的层数 randomLevel:

randomLevel 只依赖于跳表的最高层数和概率值 p。

算法在前面的代码中。

另一种实现办法为,如果生成的 randomLevel 大于以后跳表的层数 currentLevel,那么将 randomLevel 设置为 currentLevel+1,这样不便当前的查找,在工程上是能够承受的,但同时也毁坏了算法的随机性。

/**
* 插入元素
*
*
* @param searchKey 查问的 key
* @param newValue 元素
* @since 0.0.4
* @author 老马啸东风
*/
@SuppressWarnings("all")
public void insert(int searchKey, E newValue) {SkipListNode<E>[] updates = new SkipListNode[maxLevel];
SkipListNode<E> curNode = this.head;
for (int i = currentLevel - 1; i >= 0; i--) {while (curNode.forwards[i].key < searchKey) {curNode = curNode.forwards[i];
}
// curNode.key < searchKey <= curNode.forward[i].key
updates[i] = curNode;
}
// 获取第一个元素
curNode = curNode.forwards[0];
if (curNode.key == searchKey) {
// 更新对应的值
curNode.value = newValue;
} else {
// 插入新元素
int randomLevel = getRandomLevel();
// 如果层级高于以后层级,则更新 currentLevel
if (this.currentLevel < randomLevel) {for (int i = currentLevel; i < randomLevel; i++) {updates[i] = this.head;
}
currentLevel = randomLevel;
}
// 构建新增的元素节点
//head==>new L-1
//head==>pre==>new L-0
SkipListNode<E> newNode = new SkipListNode<>(searchKey, newValue, randomLevel);
for (int i = 0; i < randomLevel; i++) {newNode.forwards[i] = updates[i].forwards[i];
updates[i].forwards[i] = newNode;
}
}
}

其中 getRandomLevel 是一个随机生成 level 的办法。

/**
* 获取随机的级别
* @return 级别
* @since 0.0.4
*/
private int getRandomLevel() {
int lvl = 1;
//Math.random() 返回一个介于 [0,1) 之间的数字
while (lvl < this.maxLevel && Math.random() < this.p) {lvl++;}
return lvl;
}

个人感觉 skiplist 十分奇妙的一点就是利用随机达到了和均衡树相似的均衡成果。

不过也正因为随机,每次的链表生成的都不同。

删除

删除特定的 key 与对应的 value。

如果待删除的结点为跳表中层数最高的结点,那么删除之后,要更新 currentLevel。

/**
* 删除一个元素
* @param searchKey 查问的 key
* @since 0.0.4
* @author 老马啸东风
*/
@SuppressWarnings("all")
public void delete(int searchKey) {SkipListNode<E>[] updates = new SkipListNode[maxLevel];
SkipListNode<E> curNode = this.head;
for (int i = currentLevel - 1; i >= 0; i--) {while (curNode.forwards[i].key < searchKey) {curNode = curNode.forwards[i];
}
// curNode.key < searchKey <= curNode.forward[i].key
// 设置每一层对应的元素信息
updates[i] = curNode;
}
// 最上面一层的第一个指向的元素
curNode = curNode.forwards[0];
if (curNode.key == searchKey) {for (int i = 0; i < currentLevel; i++) {if (updates[i].forwards[i] != curNode) {break;}
updates[i].forwards[i] = curNode.forwards[i];
}
// 移除无用的层级
while (currentLevel > 0 && this.head.forwards[currentLevel-1] == this.NIL) {currentLevel--;}
}
}

输入跳表

为了便于测试,咱们实现一个输入跳表的办法。

/**
* 打印 list
* @since 0.0.4
*/
public void printList() {for (int i = currentLevel - 1; i >= 0; i--) {SkipListNode<E> curNode = this.head.forwards[i];
System.out.print("HEAD->");
while (curNode != NIL) {String line = String.format("(%s,%s)->", curNode.key, curNode.value);
System.out.print(line);
curNode = curNode.forwards[i];
}
System.out.println("NIL");
}
}

测试

public static void main(String[] args) {SkipList<String> list = new SkipList<>();
list.insert(3, "耳朵听声音");
list.insert(7, "镰刀来割草");
list.insert(6, "口哨嘟嘟响");
list.insert(4, "红旗顶风飘");
list.insert(2, "小鸭水上漂");
list.insert(9, "勺子能吃饭");
list.insert(1, "铅笔细又长");
list.insert(5, "秤钩来买菜");
list.insert(8, "麻花扭一扭");
list.printList();
System.out.println("---------------");
list.delete(3);
list.delete(4);
list.printList();
System.out.println(list.search(8));
}

日志如下:

HEAD->(5, 秤钩来买菜)->(6, 口哨嘟嘟响)->NIL
HEAD->(1, 铅笔细又长)->(2, 小鸭水上漂)->(3, 耳朵听声音)->(4, 红旗顶风飘)->(5, 秤钩来买菜)->(6, 口哨嘟嘟响)->(7, 镰刀来割草)->(8, 麻花扭一扭)->(9, 勺子能吃饭)->NIL
---------------
HEAD->(5, 秤钩来买菜)->(6, 口哨嘟嘟响)->NIL
HEAD->(1, 铅笔细又长)->(2, 小鸭水上漂)->(5, 秤钩来买菜)->(6, 口哨嘟嘟响)->(7, 镰刀来割草)->(8, 麻花扭一扭)->(9, 勺子能吃饭)->NIL
麻花扭一扭

小结

SkipList 是十分奇妙的一个数据结构,到目前为止,我还是不能手写红黑树,不过写跳表绝对会轻松很多。给论文作者点赞!

下一节让咱们一起 jdk 中的 ConcurrentSkipListSet 数据结构,感触下 java 官网实现的魅力。

心愿本文对你有帮忙,如果有其余想法的话,也能够评论区和大家分享哦。

各位 极客 的点赞珍藏转发,是老马继续写作的最大能源!

正文完
 0