什么是跳跃表
跳跃表是一种有序的数据结构,它通过在每个节点上维持多个指向其它节点的指针来达到快速访问的目的。跳跃表在插入、删除和查找操作上的平均复杂度为 O(logN),最坏为 O(N),可以和红黑树相媲美,但是在实现起来,比红黑树简单很多。
redis在两个地方用到了跳跃表,一个是实现有序集合,另一个是在集群节点中用作内部数据结构。
结构
跳跃表的数据结构主要包含三个部分,数据区域,当然这里面数据区域相对比较复杂,包含一个分数值 score 和存储的值 e。
然后还包含两个后续节点,其中 next 指向同一层的后续节点,down 指向下一层级节点。
操作
初始化
:public DaoSkipList(int level)
初始化跳跃表,主要是初始化其层级
查询
:public E get(double score)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IFRQevQ4-1574910684921)(./img/2.3_2.png)]
从 top 节点开始和目标 score 进行对比,若相等,直接返回数值;否则判断本层后续节点是否存在,若不存在或者存在并大于目标 score ,则向下一层遍历,若存在并小于目标 score 则向本层后续节点遍历,直到某个节点和目标 score 相等或者所有节点遍历结束(如上图查询3的示例)。
插入值
:public void put(double score, E e)
因为跳跃表需要根据分数 score 进行排序,第一步就是找到插入的位置,找到位置后,根据抛硬币法进行判断是否添加层级,然后每一层都和链表的插入类似,当然若是目标分数为某一层级最大项,则和链表新增操作类似。
删除
:public void delete(double score)
首先找到每一层删除的位置,然后删除每一层拥有目标值的链表,每一层删除操作和链表删除类似。
重写 toString()
:public String toString()
只是为了直观展示层级的数据,这里没啥好说的
完整示例
package com.dao.datastructure.list;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
/**
* 阿导的跳跃表实现
*
* @author 阿导
* @CopyRight 万物皆导
* @Created 2019-11-27 10:25:00
*/
public class DaoSkipList<E> {
class Node<E> {
/**
* 跳跃表按照这个分数值进行从小到大排序
*/
private double score;
/**
* 存储的数据
*/
private E e;
/**
* 后续节点集合
*/
private Node<E> next, down;
public Node() {
}
public Node(double score, E e) {
this.score = score;
this.e = e;
}
}
/**
* 层级
*/
private int level = 0;
/**
* 头节点
*/
private Node<E> top;
/**
* 最大级数
*/
private static final int MAX_LEVEL = 1 << 6;
/**
* 用于产生随机数的 Random 对象
*/
private Random random = new Random();
public DaoSkipList() {
this(0);
}
/**
* 跳跃表初始化
*
* @param level
* @return
* @author 阿导
* @time 2019/11/27 :00
*/
public DaoSkipList(int level) {
// 当前层级
this.level = level;
int i = level;
// 临时节点
Node<E> temp;
// 赋值给头节点
top = null;
// 初始化层级
while (i-- != 0) {
// 声明临时节点
temp = new Node(Double.MIN_VALUE, null);
// 临时节点竖直后续节点指向 top
temp.down = top;
// 将临时节点赋值给 top
top = temp;
}
}
/**
* 根据分数来查询值
*
* @param score
* @return E
* @author 阿导
* @time 2019/11/27 :00
*/
public E get(double score) {
// 临时节点指向头节点
Node<E> temp = top;
// 开始遍历
while (temp != null) {
// 找到当前的值
if (temp.score == score) {
return temp.e;
}
// 若当前层级后续节点为空,或者当前层级后续节点分数大于当前分数,则从下一层开始继续找
if (temp.next == null || (temp.next != null && temp.next.score > score)) {
// 若下一层也为空,那应该就找不到了
if (temp.down == null) {
return null;
}
// 否则下去走一走,瞧一瞧
temp = temp.down;
} else if (temp.next != null) {
// 若小于,则接着当前层级进行查找
temp = temp.next;
}
}
// 没查到,直接返回 null
return null;
}
/**
* 插入新值
*
* @param score
* @param e
* @return void
* @author 阿导
* @time 2019/11/27 :00
*/
public void put(double score, E e) {
// 临时节点指向头节点
Node<E> temp = top;
// 这个记录当前的节点,若cur不为空,表示当前score值的节点存在
Node<E> cur = null;
// 记录每一层的前驱节点
List<Node<E>> paths = new ArrayList<>();
// 开是遍历,查找插入位置
while (temp != null) {
// 若找到节点,跳出循环
if (temp.score == score) {
cur = temp;
break;
}
// 若是本层后续节点为空或者后续节点分数大于当前分数,则到下一层去找寻
if (temp.next == null || (temp.next != null && temp.next.score > score)) {
// 记录本层前驱节点
paths.add(temp);
// 下一层不为空
if (temp.down != null) {
temp = temp.down;
} else {
break;
}
} else if (temp.next != null) {
// 否则直接向本层后续节点走
temp = temp.next;
}
}
// 这特么是找到了阿,需要替换数据
if (cur != null) {
// 这个比较简单,就是一层层将其值更改即可
while (cur != null) {
cur.e = e;
cur = cur.down;
}
// 基本上算是结束了
return;
}
// 没找到数据那就是插入或者新增操作了?
// 通过抛硬币法决定表高度
int lev = getRandomLevel();
// 这个是需要增加层级阿
if (lev > this.level) {
// 遍历初始化头部
while (lev > this.level) {
// 声明临时节点
temp = new Node(Double.MIN_VALUE, null);
// 临时节点竖直后续节点指向 top
temp.down = top;
// 将临时节点赋值给 top
top = temp;
// 加入到前驱节点集合
paths.add(0, top);
// 自加
this.level++;
}
}
// 记录层级下标
int pox = 0;
Node<E> pre;
Node<E> down = null;
// 开始遍历层级
while (pox++ < this.level) {
// 新节点
temp = new Node<>(score, e);
// 前驱节点
pre = paths.get(this.level - pox);
// 当前的节点指向前驱节点的下一个节点
temp.next = pre.next;
// 当前的下一层节点
temp.down = down;
// 他们的下一个节点便是新节点
pre.next = temp;
// 重新赋值下一层节点
down = temp;
}
}
/**
* 产生节点的高度。使用抛硬币
*
* @return int
* @author 阿导
* @time 2019/11/27 :00
*/
private int getRandomLevel() {
int lev = this.level;
if (random.nextInt() % 2 == 0) {
lev++;
}
return lev > MAX_LEVEL ? MAX_LEVEL : lev;
}
/**
* 根据分数来删除数据
*/
public void delete(double score) {
// 临时节点
Node<E> temp = top;
// 遍历取结果
while (temp != null) {
// 本层后续节点不为空,并且当前比较的分数比本层后续节点分数大,直接向本层后续节点找寻
if (temp.next != null && temp.next.score < score) {
temp = temp.next;
} else {
// 若是本层后续节点是需要删除的值,直接删除
if (temp.next != null && temp.next.score == score) {
temp.next = temp.next.next;
}
// 接着向下一层进发
temp = temp.down;
}
}
// 去掉无效的层级
while (top.next == null) {
top = top.down;
this.level--;
}
}
/**
* 重写 toString 方法
*
* @return java.lang.String
* @author 阿导
* @time 2019/11/27 :00
*/
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
Node<E> temp = top, next = null;
while (temp != null) {
next = temp;
while (next != null) {
sb.append((next.e == null ? "-INF" : next.e) + "->");
next = next.next;
}
sb.append("NULL\n");
temp = temp.down;
}
return sb.toString();
}
}
总结
跳跃表的性能可以保证在查找,删除,添加等操作的时候在对数期望时间内完成,这个性能是可以和平衡树来相比较的,而且在实现方面比平衡树要优雅,这就是跳跃表的长处。跳跃表的缺点就是需要的存储空间比较大,属于利用空间来换取时间的数据结构,下面让我们思考以下三个问题:
-
跳跃表的底层结构是什么样的,为什么可以支撑它在对数期望时间内完成基本操作(增删改查)?
-
在跳跃表中,完成一个元素的增删改查的详细过程是怎样的?
-
利用跳跃表作为底层数据结构的有序列表,在实际的业务场景中有什么运用?
前面两个问题根据以上示意,相信比较清楚,实际业务场景阿导从其它地方摘录过来了,如下:
-
在 Zset 中使用最多的场景就是涉及到排行榜类似的场景。例如实时统计一个关于分数的排行榜,这个时候可以使用 Redis 中的这个 ZSET 数据结构来维护。
-
涉及到需要按照时间的顺序来排行的业务场景,例如如果需要维护一个问题池,按照时间的先后顺序来维护,这个时候也可以使用 Zset ,把时间当做权重,把问题当做 key 值来进行存取。