什么是跳跃表
跳表由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)。
如果所有的第(2i)个结点都有一个指针指向其2i之后的结点(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 官方实现的魅力。
希望本文对你有帮助,如果有其他想法的话,也可以评论区和大家分享哦。
各位极客的点赞收藏转发,是老马持续写作的最大动力!