一、跳表是怎么诞生的?
我们都知道链表是一种很基础的,也很常用的动态数据结构,它因为不需要一块连续的内存空间和高效的插入删除效率而被广泛使用。但是,有利就有弊。链表要想随机访问第k个数据,就没有数据那么高效了。也因此在链表上实现类似数组的二分搜索(依赖对第k个元素的随机访问)就不可能了,但是通过给有序链表加索引对其数据结构进行优化,也可以在优化后的链表上实现与二分搜索类似的搜索,而通过给有序链表添加索引诞生的数据结构就是跳表(跳跃链表)。
二、如何理解跳表
对于单链表来说,即便链表是有序的,搜索一个元素也需要从头到尾遍历链表,这样查找的效率就会很低,时间复杂度是O(n)。
如何提高效率呢?如图所示,对链表建立一级索引,两个节点提取一个节点道索引层,这样在查找某个节点的时候,就可以利用索引遍历更少的节点,来确定解空间,而解空间是常数级的,在计算时间复杂度时可以忽略不计,这样查询的效率就提高为了原来的大约两倍。其实这就是一种典型的空间换时间的算法优化思想。
根据提取节点建立索引,空间换时间的思路,其实我们为了进一步提高效率还可以在一级索引的基础上,继续提取节点, 直到最后一级索引只有一个节点为止,以每两个节点提取一个节点到上一层节点为例子,可以认为每次进入下一级索引搜索之前,都可以根据上一级的索引将搜索的数据量缩小为原来的一半,那么搜索的次数就可以估算为log2n,搜索的时间复杂度就是O(logn)。
三、跳表的实现
Design a Skiplist without using any built-in libraries.
A skiplist is a data structure that takes O(log(n)) time to add, erase and search. Comparing with treap and red-black tree which has the same function and performance, the code length of Skiplist can be comparatively short and the idea behind Skiplists is just simple linked lists.
For example, we have a Skiplist containing [30,40,50,60,70,90] and we want to add 80 and 45 into it. The Skiplist works this way:
Artyom Kalinin [CC BY-SA 3.0], via Wikimedia Commons
You can see there are many layers in the Skiplist. Each layer is a sorted linked list. With the help of the top layers, add, erase and search can be faster than O(n). It can be proven that the average time complexity for each operation is O(log(n)) and space complexity is O(n).
See more about Skiplist: en.wikipedia.org/wiki/Skip_l…
Implement the Skiplist class:
Skiplist()Initializes the object of the skiplist.bool search(int target)Returnstrueif the integertargetexists in the Skiplist orfalseotherwise.void add(int num)Inserts the valuenuminto the SkipList.bool erase(int num)Removes the valuenumfrom the Skiplist and returnstrue. Ifnumdoes not exist in the Skiplist, do nothing and returnfalse. If there exist multiplenumvalues, removing any one of them is fine.
Note that duplicates may exist in the Skiplist, your code needs to handle this situation.
大致的了解了之后跳表后,接下来就来完成跳表的实现,我这里用leetcode的一道题来进行讲解,也方便大家看后可以自己去coding一下,实践出真知,一个数据结构只有自己实现过一遍,印象才深刻,也才知道实现的过程中有哪些地方需要注意。
- 跳表节点的定义 首先我们来看看跳表节点的定义,由图可知节点除了需要key和value外呢,还需要一个指向下一层索引的节点的指针,以及一个指向同级索引的下一个节点的指针,所以定义如下:
class Node {
int val;
Node right, down;
public Node(int val) {
this.val=val;
}
}
跳表的结构和初始化也很重要,其主要参数和初始化方法为:
public class SkipList {
static final int MAX_LEVEL = 16;
// level从1开始
int level;
Node head;
Random random;
public Skiplist() {
head = new Node(Integer.MIN_VALUE);
level = 1;
random = new Random();
}
}
- 跳表的查找 跳表的查询是从最顶层的索引以此向下查找的,也就是从head节点进行查找,如果找到了直接返回查找结果,没返回的话,就要明确接下来是要向右搜索还是向下搜索(因为只有这两个方向可以走)。
仔细思考一下,不难得出结论: 在当前节点不是带搜索节点的时候,
- 如果当前节点的right节点的值大于target值,那么结果可能出现在下一层[cur, cur.right)区间中,所以节点要向下移动。
- 否则节点向右移动,因为结果可能出现在[cur.right, end]区间中。这里有一个特殊case需要判断一下,就是当right节点为null的时候,结果集可能出现在下面的任何一层索引中,所以直接向下移动查找。
public boolean search(int target) {
Node start = head;
while (start!=null) {
if (target == start.val) {
return true;
}else if (start.right == null) {
start = start.down;
}else if (start.right.val > target) {
start = start.down;
}else{
start = start.right;
}
}
return false;
}
- 跳表的删除
跳表在删除的时候,我们是自顶向下一层一层的删除,具体的删除方法就是让待删除节点的上一个节点指向待删除节点的下一个节点,直接把它给跳过去,所以在代码实现的时候我们找到的都是每一层待删除节点的上一个节点,这一点要注意。删除后,需要从head节点重新统计高度,并更新。
public boolean erase(int num) {
Node start = head;
boolean result = false;
while (start != null) {
// 如果right空了,那么就只能直接往下了
if (start.right == null) {
start = start.down;
}else if (start.right.val == num) {
start.right = start.right.right;
// 删除后进入下一层继续搜索
start = start.down;
result = true;
}else if (start.right.val > num) {
start = start.down;
}else{
start = start.right;
}
}
while (head.down!=null && head.down.right == null) {
head = head.down;
level--;
}
return result;
}
- 跳表的插入 如图所示,跳表的插入是首先插入在最底层,然后再向上一层一层的维护索引层,至于要不要向上提取节点添加索引层,是靠抛硬币来决定的,也就是说有一定的概率出发节点提取。根据这种情况分析,在底层插入节点后,如果需要提取节点是需要回溯下降路径经过的节点的。根据分许可知,回溯是逆着回的,也就是说记录路径经过节点的时候先入的后出,那么这个节点的记录就非常适合用栈去保存了。第二个需要注意的点就是当当前的层是最顶层,并继续向上提取节点的时候,要新new一个head头节点,并把新head的down指向老head。
public void add(int num) {
if (search(num)) {
return;
}
Stack<Node> stack = new Stack<Node>();
Node cur = head;
// 自定向下寻找num节点,用stack记录查找路径
while (cur!=null) {
if(cur.right == null) {
stack.add(cur);
cur=cur.down;
}else if (cur.right.val > num) {
stack.add(cur);
cur=cur.down;
}else {
cur = cur.right;
}
}
int curLevel=1; //当前层数,从第一层添加(第一层必须添加,先添加再判断)
Node downNode = null;
while (!stack.isEmpty()) {
Node pre = stack.pop();
Node node = new Node(num);
node.down = downNode;
node.right = pre.right;
pre.right = node;
double rand = random.nextDouble(); //[0-1]随机数
if (rand > 0.5) {
break;
}
level++;
downNode = node;
if (level > MAX_LEVEL) {
break;
}
if (stack.isEmpty()) {
Node head = new Node(Integer.MIN_VALUE);
head.down=pre;
stack.add(head);
}
}
}
- 整体代码
class Skiplist {
static final int MAX_LEVEL = 16;
// level从1开始
int level;
Node head;
Random random;
class Node {
int val;
Node right, down;
public Node(int val) {
this.val=val;
}
}
public Skiplist() {
head = new Node(Integer.MIN_VALUE);
level = 1;
random = new Random();
}
public boolean search(int target) {
Node start = head;
while (start!=null) {
if (target == start.val) {
return true;
}else if (start.right == null) {
start = start.down;
}else if (start.right.val > target) {
start = start.down;
}else{
start = start.right;
}
}
return false;
}
public void add(int num) {
if (search(num)) {
return;
}
Stack<Node> stack = new Stack<Node>();
Node cur = head;
// 自定向下寻找num节点,用stack记录查找路径
while (cur!=null) {
if(cur.right == null) {
stack.add(cur);
cur=cur.down;
}else if (cur.right.val > num) {
stack.add(cur);
cur=cur.down;
}else {
cur = cur.right;
}
}
int curLevel=1; //当前层数,从第一层添加(第一层必须添加,先添加再判断)
Node downNode = null;
while (!stack.isEmpty()) {
Node pre = stack.pop();
Node node = new Node(num);
node.down = downNode;
node.right = pre.right;
pre.right = node;
double rand = random.nextDouble(); //[0-1]随机数
if (rand > 0.5) {
break;
}
level++;
downNode = node;
if (level > MAX_LEVEL) {
break;
}
if (stack.isEmpty()) {
Node head = new Node(Integer.MIN_VALUE);
head.down=pre;
stack.add(head);
}
}
}
public boolean erase(int num) {
Node start = head;
boolean result = false;
while (start != null) {
// 如果right空了,那么就只能直接往下了
if (start.right == null) {
start = start.down;
}else if (start.right.val == num) {
start.right = start.right.right;
// 删除后进入下一层继续搜索
start = start.down;
result = true;
}else if (start.right.val > num) {
start = start.down;
}else{
start = start.right;
}
}
while (head.down!=null && head.down.right == null) {
head = head.down;
level--;
}
return result;
}
}
五、为什么Mysql不使用跳表来维护索引?Redis使用跳表?
MySQL 的数据是持久化的,意味着数据(索引+记录)是保存到磁盘上的,因为这样即使设备断电了,数据也不会丢失。由于数据库的索引是保存到磁盘上的,因此当我们通过索引查找某行数据的时候,就需要先从磁盘读取索引到内存,再通过索引从磁盘中找到某行数据,然后读入到内存,也就是说查询过程中会发生多次磁盘 I/O,而磁盘 I/O 次数越多,所消耗的时间也就越大。因此Mysql的索引需要利用尽可能少的磁盘IO次数来完成数据从磁盘到内存的读取。B+树的每个数据页可以存放M个节点,在磁盘上可以为其预分配连续空间,整个结构更加的矮宽,每次磁盘IO读取一个数据页可以读取M个节点。跳表的话,节点之间在磁盘上无法保证连续,可能一次IO只能读取一个节点。
而redis的数据一般来说并不需要持久化,并不需要考虑磁盘IO次数的问题,而跳表的实现比B+树简单很多,更好维护故采用跳表比较适合。 总结: b+树主要是用在外部存储上,为了减少磁盘IO次数。 跳表比较适合内存存储。
六、参考文献
极客时间 <<数据结构与算法之美>>
小林coding <<为什么 MySQL 采用 B+ 树作为索引>>