跳表
跳表是常见的面试题之一,因为他是redis中zset的底层数据结构
而且部分大厂可以会让手写跳表
一:什么是跳表
跳表(SkipList,全称跳跃表)是用于有序元素序列快速搜索查找的一个数据结构,跳表是一个随机化的数据结构,实质就是一种可以进行二分查找的有序链表。
跳表在原有的有序链表上面增加了多级索引,通过索引来实现快速查找。
跳表不仅能提高搜索性能,同时也可以提高插入和删除操作的性能。
它在性能上和红黑树,AVL树不相上下,但是跳表的原理非常简单,实现也比红黑树简单很多。
简而言之:跳表就能让链表拥有近乎的接近二分查找的效率的一种数据结构,其原理依然是给上面加若干层索引,优化查找速度,典型的空间换取时间的方式
通过上图你可以看到,通过这样的一个数据结构对有序链表进行查找都能近乎二分的性能。
就是在上面维护那么多层的索引,首先在最高级索引上查找最后一个小于当前查找元素的位置,然后再跳到次高级索引继续查找,直到跳到最底层为止,这时候以及十分接近要查找的元素的位置了(如果查找元素存在的话)。
由于根据索引可以一次跳过多个元素,所以跳查找的查找速度也就变快了。
二:跳表的增删改查
先定义跳表的数据结构
package com.example.testdemo.entity;
/**
* <p>
* 功能描述:跳表节点的数据结构
* </p>
*
* @author cui haida
* @date 2025/06/15/8:52
*/
public class SkipNode<T> {
int key;
private T data;
private SkipNode<T> next; // 右节点
private SkipNode<T> down; // 下节点
// 构造函数
public SkipNode(int key, T data) {
this.key = key;
this.data = data;
}
}
package com.example.testdemo.entity;
import java.util.Random;
/**
* <p>
* 功能描述:跳表的数据结构
* </p>
*
* @author cui haida
* @date 2025/06/15/8:59
*/
public class SkipList<T> {
// 头节点
private SkipNode<T> head;
// 当前跳表的层数
private int level;
// 用于投掷硬币
private Random random;
// 最大层数
private static final int MAX_LEVEL = 32;
// 无参构造 - 默认构造一个跳表
public SkipList() {
this.head = new SkipNode<>(Integer.MIN_VALUE, null);
this.level = 0;
this.random = new Random();
}
}
1:查询操作
非常的简单,思想就是二分思想
// search
public SkipNode<T> search(int key) {
// 初始化开始节点为头节点
SkipNode<T> p = head;
// 在当前的节点不是空的情况下,有三种情况:
while (p != null) {
if (p.getKey() == key) {
// 如果当前节点的key等于要查找的key,则返回当前节点
return p;
} else if (p.getNext() == null || p.getNext().getKey() > key) {
// 如果右侧没有了,或者右侧的key大于要查找的key,则继续往下找
p = p.getDown();
} else {
p = p.getNext(); // 否则向右找
}
}
return null;
}
2:删除操作
删除操作比起查询稍微复杂一些,但是比插入简单。
删除需要改变链表结构所以需要处理好节点之间的联系。对于删除操作你需要谨记以下几点:
(1)删除当前节点和这个节点的前后节点都有关系
(2)删除当前层节点之后,下一层该key的节点也要删除,一直删除到最底层
例如上图删除10节点
-
team=head
从team
出发,7 < 10
向右 -
右侧为null只能向下;
-
右侧为10在当前层删除10节点然后向下继续查找下一层10节点;
-
8 < 10
向右;第五步右侧为10删除该节点并且team向下。 -
team为null说明删除完毕退出循环。
// delete
public void delete(int key) {
SkipNode<T> term = head; // 初始化当前节点
while (term != null) {
if (term.getNext() == null || term.getNext().getKey() > key) {
// 如果当前节点的右侧没有节点,或者当前节点的右侧的key大于要删除的key
// 则继续往下找
term = term.getDown();
} else {
// 否则,当前节点的key等于要删除的key
if (term.getNext().getKey() == key) {
// 如果当前节点的key等于要删除的key,则将当前节点的next指向当前节点的next的next
term.setNext(term.getNext().getNext());
term = term.getDown();
} else {
// 否则,当前节点的key不等于要删除的key,则继续向右找
term = term.getNext();
}
}
}
}
3:插入操作
插入操作在实现起来是最麻烦的,需要的考虑的东西最多。
- 回顾查询,不需要动索引;
- 回顾删除,每层索引如果有删除就是了。
但是插入不一样了,插入需要考虑是否插入索引,插入几层等问题。
由于需要插入删除所以我们肯定无法维护一个完全理想的索引结构,因为它耗费的代价太高。
但我们使用随机化的方法去判断是否向上层插入索引。
即产生一个[0-1]的随机数如果小于0.5就向上插入索引,插入完毕后再次使用随机数判断是否向上插入索引。运气好这个值可能是多层索引,运气不好只插入最底层(这是100%插入的)。
但是索引也不能不限制高度,我们一般会设置索引最高值如果大于这个值就不往上继续添加索引了。
我们一步步剖析该怎么做,其流程为
-
首先通过上面查找的方式,找到待插入的左节点。插入的话最底层肯定是需要插入的,所以通过链表插入节点(需要考虑是否为末尾节点)
-
插入完这一层,需要考虑上一层是否插入,首先判断当前索引层级,如果大于最大值那么就停止(比如已经到最高索引层了)。否则设置一个随机数1/2的概率向上插入一层索引(因为理想状态下的就是每2个向上建一个索引节点)。
-
继续(2)的操作,直到概率退出或者索引层数大于最大索引层。
首先如何找到上层的待插入节点 ?
这个各个实现方法可能不同,如果有左、上指向的指针那么可以向左向上找到上层需要插入的节点,但是如果只有右指向和下指向的我们也可以巧妙的借助查询过程中记录下降的节点。
因为曾经下降的节点倒序就是需要插入的节点,最底层也不例外(因为没有匹配值会下降为null结束循环)。
在这里我使用栈这个数据结构进行存储,当然使用List也可以。下图就是给了一个插入示意图
如果该层是目前的最高层索引,需要继续向上建立索引应该怎么办
首先跳表最初肯定是没索引的,然后慢慢添加节点才有一层、二层索引,但是如果这个节点添加的索引突破当前最高层,该怎么办呢?
这时候需要注意了,跳表的head需要改变了,新建一个ListNode节点作为新的head,将它的down指向老head,将这个head节点加入栈中(也就是这个节点作为下次后面要插入的节点),就比如上面的9节点如果运气够好在往上建立一层节点,会是这样的
插入上层的时候注意所有节点要新建(拷贝),除了right的指向down的指向也不能忘记,down指向上一个节点可以用一个临时节点作为前驱节点。
如果层数突破当前最高层,头head节点(入口)需要改变。
/**
* 插入一个节点到跳表中
*
* @param key 要插入的键
* @param value 要插入的值
*/
public void insert(int key, T value) {
// 用于保存每一层要插入的位置的前驱节点
SkipNode<T>[] updates = new SkipNode[MAX_LEVEL];
SkipNode<T> p = head;
// 从当前最高层开始查找
for (int i = level; i >= 0; i--) {
while (p.getNext() != null && p.getNext().getKey() < key) {
p = p.getNext();
}
if (p.getNext() != null && p.getNext().getKey() == key) {
// 如果 key 已经存在,则可以选择更新值或者抛出异常
p.getNext().setData(value);
return;
}
updates[i] = p; // 记录该层插入位置的前驱节点
p = p.getDown(); // 继续下一层
}
// 生成新节点的层数
int newNodeLevel = randomLevel();
// 如果新节点层数大于当前跳表的层数,则需要更新跳表的 level
if (newNodeLevel > level) {
for (int i = level + 1; i <= newNodeLevel; i++) {
updates[i] = head; // 新增高层级的前驱设为头节点
}
level = newNodeLevel;
}
// 将新节点插入到各层级中
SkipNode<T> downNode = null;
for (int i = newNodeLevel; i >= 0; i--) {
SkipNode<T> newNodeTemp = new SkipNode<>(key, value); // 每一层都新建节点
newNodeTemp.setNext(updates[i].getNext());
updates[i].setNext(newNodeTemp);
if (downNode != null) {
newNodeTemp.setDown(downNode); // 设置下一层的指针
}
downNode = newNodeTemp;
}
}
/**
* 随机生成节点的层数
*
* @return 返回生成的层数
*/
private int randomLevel() {
int level = 0;
while (random.nextBoolean() && level < MAX_LEVEL) {
level++;
}
return level;
}
三:最后总结
对于跳表以及跳表的同类竞争产品:红黑树,为啥Redis的有序集合(zset) 使用跳表呢?因为跳表除了查找插入维护和红黑树有着差不多的效率,它是个链表,能确定范围区间,而区间问题在树上可能就没那么方便查询啦。
而JDK中跳跃表ConcurrentSkipListSet和ConcurrentSkipListMap。
有兴趣的也可以查阅一下源码。
// 一份直接可以运行的源码如下
package com.example.testdemo.entity;
import java.util.Random;
import java.util.Stack;
class AllSkipNode<T>
{
int key;
T value;
AllSkipNode right,down;//左右上下四个方向的指针
public AllSkipNode(int key, T value) {
this.key=key;
this.value=value;
}
}
public class AllSkipList <T> {
AllSkipNode headNode;//头节点,入口
int highLevel;//层数
Random random;// 用于投掷硬币
final int MAX_LEVEL = 32;//最大的层
AllSkipList(){
random=new Random();
headNode=new AllSkipNode(Integer.MIN_VALUE,null);
highLevel=0;
}
public AllSkipNode search(int key) {
AllSkipNode team=headNode;
while (team!=null) {
if(team.key==key)
{
return team;
}
else if(team.right==null)//右侧没有了,只能下降
{
team=team.down;
}
else if(team.right.key>key)//需要下降去寻找
{
team=team.down;
}
else //右侧比较小向右
{
team=team.right;
}
}
return null;
}
public void delete(int key)//删除不需要考虑层数
{
AllSkipNode team=headNode;
while (team!=null) {
if (team.right == null) {//右侧没有了,说明这一层找到,没有只能下降
team=team.down;
}
else if(team.right.key==key)//找到节点,右侧即为待删除节点
{
team.right=team.right.right;//删除右侧节点
team=team.down;//向下继续查找删除
}
else if(team.right.key>key)//右侧已经不可能了,向下
{
team=team.down;
}
else { //节点还在右侧
team=team.right;
}
}
}
public void add(AllSkipNode node)
{
int key=node.key;
AllSkipNode findNode=search(key);
if(findNode!=null)//如果存在这个key的节点
{
findNode.value=node.value;
return;
}
Stack<AllSkipNode>stack=new Stack<AllSkipNode>();//存储向下的节点,这些节点可能在右侧插入节点
AllSkipNode team=headNode;//查找待插入的节点 找到最底层的哪个节点。
while (team!=null) {//进行查找操作
if(team.right==null)//右侧没有了,只能下降
{
stack.add(team);//将曾经向下的节点记录一下
team=team.down;
}
else if(team.right.key>key)//需要下降去寻找
{
stack.add(team);//将曾经向下的节点记录一下
team=team.down;
}
else //向右
{
team=team.right;
}
}
int level=1;//当前层数,从第一层添加(第一层必须添加,先添加再判断)
AllSkipNode downNode=null;//保持前驱节点(即down的指向,初始为null)
while (!stack.isEmpty()) {
//在该层插入node
team=stack.pop();//抛出待插入的左侧节点
AllSkipNode nodeTeam=new AllSkipNode(node.key, node.value);//节点需要重新创建
nodeTeam.down=downNode;//处理竖方向
downNode=nodeTeam;//标记新的节点下次使用
if(team.right==null) {//右侧为null 说明插入在末尾
team.right=nodeTeam;
}
//水平方向处理
else {//右侧还有节点,插入在两者之间
nodeTeam.right=team.right;
team.right=nodeTeam;
}
//考虑是否需要向上
if(level>MAX_LEVEL)//已经到达最高级的节点啦
break;
double num=random.nextDouble();//[0-1]随机数
if(num>0.5)//运气不好结束
break;
level++;
if(level>highLevel)//比当前最大高度要高但是依然在允许范围内 需要改变head节点
{
highLevel=level;
//需要创建一个新的节点
AllSkipNode highHeadNode=new AllSkipNode(Integer.MIN_VALUE, null);
highHeadNode.down=headNode;
headNode=highHeadNode;//改变head
stack.add(headNode);//下次抛出head
}
}
}
public void printList() {
AllSkipNode teamNode=headNode;
int index=1;
AllSkipNode last=teamNode;
while (last.down!=null){
last=last.down;
}
while (teamNode!=null) {
AllSkipNode enumNode=teamNode.right;
AllSkipNode enumLast=last.right;
System.out.printf("%-8s","head->");
while (enumLast!=null&&enumNode!=null) {
if(enumLast.key==enumNode.key)
{
System.out.printf("%-5s",enumLast.key+"->");
enumLast=enumLast.right;
enumNode=enumNode.right;
}
else{
enumLast=enumLast.right;
System.out.printf("%-5s","");
}
}
teamNode=teamNode.down;
index++;
System.out.println();
}
}
public static void main(String[] args) {
AllSkipList<Integer>list=new AllSkipList<Integer>();
for(int i=1;i<20;i++)
{
list.add(new AllSkipNode(i,666));
}
list.printList();
list.delete(4);
list.delete(8);
list.printList();
}
}