跳表可以说是我最喜欢的数据结构了,一方面因为其优越的性能,无论是插入、删除操作还是数据查找,他的时间复杂度都是O(logn),要知道最优秀的二分查找算法的时间复杂度也是O(logn),另一方面跳表数据结构实现相对于红黑树来说足够的简单,同时他还支持O(logn)时间复杂度的范围查询,而红黑树的范围查询则没有这么高的效率。
跳表因为其优秀的综合性能,在以速度著称的Redis中的SortedSet有序集合也是采用了跳表这种数据结构来实现。
如果你还不了解跳表,那么让我们一起来学习这种优秀的数据结构吧!
跳表数据结构描述
现在给你一批无序的int类型数据,例如1,3,98,13,6,9,27,45,尝试设计一种高性能的数据结构存储这批数据,使其有序同时能够支持以下操作:
- 新增一条数据;
- 删除指定的一条数据;
- 给定一个数值,查询其是否存在;
- 能够支持范围查询,例如查询数值在[10,30]之间的数据;
首先我们可以考虑用数组或者链表来存储上述数据:
针对有序的数组和链表,我们一起来看看数组和链表两种存储方式能否满足上述操作需求:
- 新增一条数据:数组采用二分查找算法找到第一个大于该数据的下标,该下标存储新增数据,后面的数据整体向后移动一位;链表则需遍历整个链表找到第一个大于该数据的节点,但是后面的链表无需做移动操作;
- 删除指定的一条数据:数组采用二分查找找到该数据的下标,后面的数据整体向前移动一位;链表需要找到当前节点,并把前一个节点的next指向后一个节点;
- 给定一个数值,查询其是否存在:数组采用二分查找可以高效地找到;链表则需要遍历整个链表;
- 支持范围查询:数组采用二分查找高效地找到起始数据的下标;链表则需要遍历整个链表;
综上所述,可以很明显地看出来数组的特性就是查找快速,但是插入删除因为需要移动数组,所以性能相对较差,链表则是插入、删除快,但是前提是能找到那个要插入、删除的节点,而查询则需要遍历整个链表。
那么问题来了,能不能结合数组的快速查找能力以及链表O(1)的插入、删除能力来设计一种数据结构呢?
这就是今天要说的跳表:一个有序的链表,不仅能满足数据的快速插入、删除能力,同时还能利用二分查找的思想来支持指定数据的查找,甚至是范围数据的查找。
看图说话,如果我们从原始链表查询数据123的话,我们需要从头节点开始遍历10个节点,才能找到123这个节点,但是如果通过多级索引来查找的话,只需要遍历6个节点即可找到,看起来是不是没有快多少?这是因为数据量不大,效率提升不明显,如果数据量大,效率的提升就会很明显了。
现在插入一个数据38,看看跳表里是如何操作的:
文章开始部分的动画也演示了一个数据的新插入流程,链表的插入其实真正耗费的性能在于插入位置的查找上面,而查找正是利用了多级索引的查找来快速定位到要插入的节点位置,删除同理。
插入数据的时候会存在一个问题,就是当我们不停地向跳表中新增数据的时候,如果不及时更新索引的话,那么就会出现2个索引节点之间数据非常多的情况:
因此需要我们在插入每一个数据的时候,同时需要更新跳表的索引,来保证跳表中原始链表和索引之间的平衡性,而这个平衡性的保证在跳表中是依赖于一个索引层级随机函数的实现:
/**
* 索引层级随机函数
*/
private int randLevel() {
int level = 1;
while (Math.random() < 0.5f && level < MAX_LEVEL) {
level ++;
}
return level;
}
上面只是一种参考实现,如果你感兴趣可以看看JDK中ConcurrentSkipListMap源码中doPut方法关于节点层级的获取方式:
private V doPut(K key, V value, boolean onlyIfAbsent) {
...忽略其他代码...
int rnd = ThreadLocalRandom.nextSecondarySeed();
if ((rnd & 0x80000001) == 0) { // test highest and lowest bits
int level = 1, max;
while (((rnd >>>= 1) & 1) != 0)
++level;
...忽略其他代码...
}
或者你也可以参考Redis中关于SortedSet有序集合的实现方式:
// 最大层数32层
#define ZSKIPLIST_MAXLEVEL 32 /* Should be enough for 2^64 elements */
// 随机数值0.25
#define ZSKIPLIST_P 0.25 /* Skiplist P = 1/4 */
/* Returns a random level for the new skiplist node we are going to create.
* The return value of this function is between 1 and ZSKIPLIST_MAXLEVEL
* (both inclusive), with a powerlaw-alike distribution where higher
* levels are less likely to be returned. */
int zslRandomLevel(void) {
// 初始化层为1
int level = 1;
while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
level += 1;
return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}
跳表代码实现
如果你已经理解了跳表的设计思路,那么你就可以尝试去刷一下LeetCode上的1206题.设计跳表,用自己熟悉的语言去实现一遍。下面给出我自己实现的代码:
/**
* Your Skiplist object will be instantiated and called as such:
* Skiplist obj = new Skiplist();
* boolean param_1 = obj.search(target);
* obj.add(num);
* boolean param_3 = obj.erase(num);
*/
class Skiplist {
//最大层数
private int MAX_LEVEL = 16;
//当前跳表最大层级
private int listLevel = 1;
//初始化定义一个头节点
private ListNode head = null;
public Skiplist() {
//定义一个空节点
head = new ListNode();
}
/**
* 查找数据
* @param target 查找的数据
*/
public boolean search(int target) {
//逐层往下找,prev节点最后就是第1层的小于target的前置节点,看他下一个节点的值是否等于target
ListNode prev = head;
for (int i = listLevel - 1; i >= 0; i--) {
while (prev.forwards[i] != null && prev.forwards[i].val < target) {
prev = prev.forwards[i];
}
}
return prev.forwards[0] != null && prev.forwards[0].val >= 0 && prev.forwards[0].val == target;
}
/**
* 添加数据
* @param num 插入的数据
*/
public void add(int num) {
int level = randLevel();
ListNode node = new ListNode();
node.val = num;
node.level = level;
//每一层初始化默认为head节点
ListNode[] preNodes = new ListNode[level];
for (int i = 0; i < level; i++) {
preNodes[i] = head;
}
//找前置节点,从头开始找
ListNode prev = head;
//从上往下找,下一层从上一层的prev开始找
for (int i = level - 1; i >= 0; i--) {
while (prev.forwards[i] != null && prev.forwards[i].val < num) {
prev = prev.forwards[i];
}
//这样每一层的前置节点就找到了
preNodes[i] = prev;
}
for (int i = level - 1; i >= 0; i--) {
node.forwards[i] = preNodes[i].forwards[i];
preNodes[i].forwards[i] = node;
}
if (level > listLevel) {
listLevel = level;
}
}
/**
* 删除数据,删除数据需要先找到每一层的前置节点,找到后每一层都要删掉,然后要降级
* @param num 插入的数据
*/
public boolean erase(int num) {
ListNode[] preNodes = new ListNode[listLevel];
//找前置节点,从头开始找
ListNode prev = head;
for (int i = listLevel - 1; i >= 0; i--) {
while (prev.forwards[i] != null && prev.forwards[i].val < num) {
prev = prev.forwards[i];
}
preNodes[i] = prev;
}
boolean isErase = false;
//每一层找前置节点的下一个节点的值是否等于target,如果相等,则删掉
for (int i = listLevel - 1; i >= 0; i--) {
if (preNodes[i].forwards[i] != null && preNodes[i].forwards[i].val == num) {
preNodes[i].forwards[i] = preNodes[i].forwards[i].forwards[i];
isErase = true;
}
}
//更新索引层数,每一层都去查找一下。如果head.forwards[i] == null,则该层消失
while (listLevel > 1 && head.forwards[listLevel - 1] == null) {
listLevel --;
}
return isErase;
}
/**
* 索引层级随机函数
*/
private int randLevel() {
int level = 1;
while (Math.random() < 0.5f && level < MAX_LEVEL) {
level ++;
}
return level;
}
public class ListNode {
private int val = -1;
private int level;
//普通的链表这个地方就是一个next节点,跳表记录的是每一层的node
private ListNode[] forwards = new ListNode[MAX_LEVEL];
}
}
最后
现在相信你已经了解了跳表这种数据结构的由来,一句话来定义跳表就是:一种链表+多级索引的数据结构。
跳表在JDK中也有实现,就是java.util.concurrent包下的ConcurrentSkipListMap以及基于ConcurrentSkipListMap实现的ConcurrentSkipListSet,类似于HashMap和HashSet的关系,ConcurrentSkipListMap涉及到并发编程、SkipList、SortedMap等知识点。
基于对跳表的学习,加上代码实现一遍,下篇文章我们一起来学习JDK中ConcurrentSkipListMap的源码。