# [跳表(SkipList)原理篇]
# 1、什么是跳表?
维基百科:跳表是一种数据结构。它使得包含n个元素的有序序列的查找和插入操作的平均时间复杂度都是 O(logn),优于数组的 O(n)复杂度。快速的查询效果是通过维护一个多层次的链表实现的,且与前一层(下面一层)链表元素的数量相比,每一层链表中的元素的数量更少。
* 优于数组的插入操作时间复杂度
简单理解跳表是基于链表实现的有序列表,跳表通过维护一个多层级的链表实现了快速查询效果将平均时间复杂度降到了O($log^n$),这是一个典型的异空间换时间数据结构。
# 2、为什么需要跳表?
在实际开发中经常遇到需要在数据集中查找一个指定数据的场景,而常用的支持高效查找算法的实现方式有以下几种:
1. 有序数组。插入时可以先对数据排序,查询时可以采用二分查找算法降低查找操作的复杂度。缺点是插入和删除数据时,为了保持元素的有序性,需要进行大量数据的移动操作。
2. 二叉查找树。既支持高效的二分查找算法,又能快速的进行插入和删除操作的数据结构,理想的时间复杂度为 O($log^n$),但是在某些极端情况下,二叉查找树有可能变成一个线性链表,即退化成链表结构。
3. 平衡二叉树。基于二叉查找树的优点,对其缺点进行了优化改进,引入了平衡的概念。为了维持二叉树的平衡衍生出了多种平衡算法,根据平衡算法的不同具体实现有AVL树 /B树(B-Tree)/ B+树(B+Tree)/红黑树 等等。但是平衡算法的实现大多数比较复杂且较难理解。
针对大体量、海量数据集中查找指定数据有更好的解决方案,我们得评估时间、空间的成本和收益。
跳表同样支持对数据进行高效的查找,插入和删除数据操作时间复杂度能与平衡二叉树媲美,最重要的是跳表的实现比平衡二叉树简单几个级别。缺点就是“以空间换时间”方式存在一定数据冗余。
如果存储的数据是大对象,跳表冗余的只是指向数据的指针,几乎可以不计使用的内存空间。
# 3、跳表的实现原理
添加、删除操作都需要先查询出操作数据的位置,所以理解了跳表的查询原理后,剩下的只是对链表的操作。
## 3.1、查询数据
例设原始链表上的有序数据为【9,11,14,19,20,24,27】,如果我要查找的数据是20,只能从头结点沿着链表依次比较查找,如图所示:
![原始链表访问方式.png](https://imagecdn.ymm56.com/ymmfile/oa-biz/1f4ec1c0-79a8-4e4c-8881-448a5a5b2c14.png?x-oss-process=image/resize,w_1024)
链表不能像数组那样通过索引快速访问数据,只能沿着指针方向依次访问,所以不能使用二分查找算法快速进行数据查询。但是可以借鉴创建索引的这种思路,就像图书的目录一样,如果我要查看第六章的内容,直接翻到通过目录查询到的第六章对应页码处就行。
这里的目录就相当于创建的索引,该索引能够缩小我们查询数据的范围减少查询次数。在原始链表的基础上,我们增加一层索引链表,假如原始链表的每两个结点就有一个结点也在索引链表当中,如图所示:
![增加一层索引.png](https://imagecdn.ymm56.com/ymmfile/oa-biz/649b74ed-2e69-4f95-9d26-5129f73919f4.png?x-oss-process=image/resize,w_1024)
当建立了索引后检索数据的方式就发生了变化,当我们想要定位到`DataNode-20`,我们不需要在原始链表中一个一个结点访问,而是首先访问索引链表:
![检索索引节点.png](https://imagecdn.ymm56.com/ymmfile/oa-biz/40c85b44-8476-44b1-b729-557ab92fd6e4.png?x-oss-process=image/resize,w_1024)
由于索引链表的结点个数是原始链表的一半,查找结点所需的访问次数也就相应减少了一半,经过两次查询我们便找到`DataNode-20`。
正如图书的目录不止按照“章节”划分,还可以按照“第几部分”、“第几小节”进行划分,链表的索引也一样。我们可以继续为链表创建更多层索引,每层索引节点为前一层索引(对应图例的下一层)的一半,在数据量比较大时能够大大的提升我们的查询效率。
![添加第二层索引.png](https://imagecdn.ymm56.com/ymmfile/oa-biz/35911df4-1ed5-4dee-b499-3f6f90943a80.png?x-oss-process=image/resize,w_1024)
如图所示,我们基于原始链表的第1层索引,抽出了第2层更为稀疏的索引,结点数量是第1层索引的一半。这样的多层索引可以进一步提升查询效率,那么它是如何进行查询的呢?假如这次要查找`DataNode-27`,让我们来演示一下检索过程:
1. 首先,我们从最上层(第二层)的`HeadIndex-7`开始查找,`HeadIndex-7`指向`DataNode-7`比`DataNode-27`小,所以继续向右查询找到第二个索引节点`IndexNode-20`。
![图片.png](https://imagecdn.ymm56.com/ymmfile/oa-biz/1f2729e8-bc78-4160-a6e9-15dd4c77c647.png?x-oss-process=image/resize,w_1024)
2. `IndexNode-20`指向`DataNode-20`也比`DataNode-27`小,但是此时第二层已经没有后续的索引节点,所以我们需要顺着`IndexNode-20`访问下一层索引,即第一层的`IndexNode-20`。
从索引节点访问方式可知,索引节点保存着“数据节点”、“下层索引节点”的指针。
![图片.png](https://imagecdn.ymm56.com/ymmfile/oa-biz/a771b258-49e6-49a6-a273-37ecf8deb051.png?x-oss-process=image/resize,w_1024)
3. 通过第一层的`IndexNode-20`继续向右检索找到`IndexNode-27`便检索到了`DataNode-27`。
![图片.png](https://imagecdn.ymm56.com/ymmfile/oa-biz/7ff4287b-6a4e-4ba6-8e64-9a567639094d.png?x-oss-process=image/resize,w_1024)
总结:
维基百科:
跳跃列表是按层建造的。底层是一个普通的有序链表。每个更高层都充当下面列表的“快速通道”,这里在第 i 层中的元素按某个固定的概率 $p$(通常为 $\\frac 12$ 或 $\\frac 14$ 出现在第 i + 1 层中。每个元素平均出现在 ${1\\over 1-p}$ 个列表中,而最高层的元素(通常是在跳跃列表前端的一个特殊的头元素)在 $log\_{1/p}^n$个列表中出现。
在查找目标元素时,从顶层列表、头元素起步。算法沿着每层链表搜索,直至找到一个大于或等于目标的元素,或者到达当前层列表末尾。如果该元素等于目标元素,则表明该元素已被找到;如果该元素大于目标元素或已到达链表末尾,则退回到当前层的上一个元素,然后转入下一层进行搜索。每层链表中预期的查找步数最多为$\\frac 1p$,而层数为 -${log\_p^n}\\over{p}$,由于 $p$ 是常数,查找操作总体的时间复杂度为 O($log^n$)。而通过选择不同 $p$ 值,就可以在查找代价和存储代价之间获取平衡。
上面的查询例子中索引节点已经是创建好的,那么原始链表哪些数据节点需要创建索引节点、什么时候创建?这些问题的答案都要回归到往原始链表添加数据时。
## 3.2、插入数据
从上面的总结不难理解在向原始链表中插入数据时,当前插入的数据按照某个固定的概率$p$($\\frac 12$ 或 $\\frac 14$)在每层索引链表中创建索引节点。假设现在插入`DataNode-18`,我们来看看是如何插入和创建索引节点的:
### 3.2.1、找到插入数据的前置节点
首先我们按照跳表查找结点的方法,找到待插入结点的前置结点(仅小于待插入结点):
![图片.png](https://imagecdn.ymm56.com/ymmfile/oa-biz/ed68f52c-388c-4d09-adc4-2336bede4971.png?x-oss-process=image/resize,w_1024)
### 3.2.2、插入原始链表
接下来按照一般链表的插入方式,把`DataNode-18`插入到结点`DataNode-14`的后续位置:
![图片.png](https://imagecdn.ymm56.com/ymmfile/oa-biz/cdd55204-1149-45bd-9f9b-ea4c48621ba1.png?x-oss-process=image/resize,w_1024)
这样数据就插入到了原始链表中,但是我们的插入操作并没有结束。按照定义我们需要让新插入的结点随机(抛硬币的方式)“晋升”,也就是为`DataNode-18`创建索引节点,正是采取这种简单的随机方式,跳表也被称为一种随机化的数据结构。
### 3.2.3、创建索引节点
假设第一、第二次随机的结果都是晋升成功,那么我们需要为`DataNode-18`创建索引节点,插入到第一层和第二层索引的对应位置,并且向下指向原始链表的`DataNode-18`。
![图片.png](https://imagecdn.ymm56.com/ymmfile/oa-biz/677364b5-1e1c-401f-9240-4082a7e75e96.png?x-oss-process=image/resize,w_1024)
在索引链表中插入新创建的索引节点时需要注意几点:
1. 找到待插入索引节点的前置索引节点指向新索引节点,新索引节点指向前置节点之前指向的索引节点。(也就是链表的插入操作)
2. 随机的结果是“晋升成功”就可以继续向上一层创建索引,直到假设随机的结果是“晋升失败”或者“新增索引层”。
3. 每层是否创建索引节点可以一次性抛几次硬币,而不是添加一层索引后再进行投币。(这样做的目的是为了更好的用代码实现)。
新建的索引节点何如衔接到前置索引节点以及如何用代码实现,这个我们在下篇文章“SkipList 代码实现”去解析。
### 3.2.4、添加索引层
如果在第二层(目前索引最大层级)创建索引节点后,下一次随机的结果仍然是晋升成功,这时候该怎么办呢?这个时候我们就需要添加一层索引层:
![图片.png](https://imagecdn.ymm56.com/ymmfile/oa-biz/102cd11f-0d8d-4030-a407-0ae40ef83222.png?x-oss-process=image/resize,w_1024)
可以看到此时第三层只有`HeadIndex-7`和`IndexNode-18`,此时不会继续向上层创建索引,因为就算继续创建仍只有`HeadIndex-7`和`IndexNode-18`,这显得毫无意义。至此跳表的插入操作包括索引的创建过程已经解析完,跳表的删除过程正好和插入是相反的思路。
## 3.3、删除数据
### 3.3.1、查找待删除节点
假设我们要删除刚才插入的`DataNode-18`,首先我们要按照跳表查找结点的方法找到待删除的`DataNode-18`,当然如果没有找到对应的数据直接返回进行。
![图片.png](https://imagecdn.ymm56.com/ymmfile/oa-biz/b54a9427-27ef-41f2-b6f4-0ad1f681c5cf.png?x-oss-process=image/resize,w_1024)
### 3.3.2、删除原始链表节点
接下来按照链表的删除方式,把`DataNode-18`从原始链表当中删除
![图片.png](https://imagecdn.ymm56.com/ymmfile/oa-biz/ffa703ea-391b-4947-a8c6-a5194581c927.png?x-oss-process=image/resize,w_1024)
### 3.3.3、删除索引节点
同插入数据一样,删除工作并没有就此完成,我们需要将`DataNode-18`在索引层对应的`IndexNode-18`也一 一删除:
![图片.png](https://imagecdn.ymm56.com/ymmfile/oa-biz/d91ad433-1a24-447e-ba5d-13368674bca8.png?x-oss-process=image/resize,w_1024)
### 3.3.4、删除索引层
同插入索引节点一样,删除索引节点时也需要维护前置节点的指向关系。这里需要特别注意最上层索引(第三层),当删除`IndexNode-18`后该层只剩下`HeadIndex-7`,这个时候需要将该索引层也一同删除。
![图片.png](https://imagecdn.ymm56.com/ymmfile/oa-biz/254d990f-ae4e-401a-8b12-7f7d014770c2.png?x-oss-process=image/resize,w_1024)
至此整个删除操作就算完成了,此时跳表的结构就和我们之前插入之前保持一致了:
![图片.png](https://imagecdn.ymm56.com/ymmfile/oa-biz/123d7055-4a9d-4bcf-b506-97241b1bb72b.png?x-oss-process=image/resize,w_1024)
总结
1. 简单对比了跳表和其他几种高效查找算法的优缺点。
2. 跳表是基于链表实现的,是一种“以空间换时间”的“随机化”数据结构。
3. 跳表引入了索引层的概念,有了它才有了时间复杂度为O($logn$)的查询效率,从而实现了增删操作的时间复杂度也是O($logn$)。
4. 跳表拥有平衡二叉树相同的查询效率,但是跳表对于树平衡的实现是基于一种随机化的算法的,相对于AVL树/B树(B-Tree)/B+树(B+Tree)/红黑树的实现简单得多。
Java实现跳表的代码示例
```
import java.util.Random;
// 定义跳表节点类
class SkipListNode {
int value; // 节点的值
SkipListNode[] forward; // 指向下一层的指针数组
// 构造函数,初始化节点
public SkipListNode(int level, int value) {
this.value = value;
this.forward = new SkipListNode[level + 1]; // 初始化指针数组
}
}
// 定义跳表类
public class SkipList {
private static final int MAX_LEVEL = 16; // 跳表的最大层数
private final SkipListNode head; // 头节点
private int level; // 当前跳表的层数
private final Random random; // 随机数生成器
// 构造函数,初始化跳表
public SkipList() {
head = new SkipListNode(MAX_LEVEL, Integer.MIN_VALUE); // 初始化头节点
level = 0; // 初始层数为0
random = new Random(); // 初始化随机数生成器
}
// 插入操作
public void insert(int value) {
SkipListNode[] update = new SkipListNode[MAX_LEVEL + 1]; // 用于存储每层需要更新的节点
SkipListNode current = head; // 从头节点开始
// 从最高层向下查找插入位置
for (int i = level; i >= 0; i--) {
while (current.forward[i] != null && current.forward[i].value < value) {
current = current.forward[i];
}
update[i] = current; // 记录需要更新的节点
}
int newLevel = randomLevel(); // 随机生成新节点的层数
if (newLevel > level) { // 如果新节点的层数大于当前层数,更新当前层数
for (int i = level + 1; i <= newLevel; i++) {
update[i] = head;
}
level = newLevel;
}
// 创建新节点,并插入到每一层的链表中
SkipListNode newNode = new SkipListNode(newLevel, value);
for (int i = 0; i <= newLevel; i++) {
newNode.forward[i] = update[i].forward[i];
update[i].forward[i] = newNode;
}
}
// 查找操作
public boolean search(int value) {
SkipListNode current = head; // 从头节点开始
// 从最高层向下查找目标值
for (int i = level; i >= 0; i--) {
// 每层逐个节点比较
while (current.forward[i] != null && current.forward[i].value < value) {
current = current.forward[i];
}
}
current = current.forward[0]; // 到达底层链表
return current != null && current.value == value; // 判断是否找到目标值
}
// 随机生成节点的层数
private int randomLevel() {
int lvl = 0;
while (random.nextInt(2) == 1 && lvl < MAX_LEVEL) {
lvl++;
}
return lvl;
}
// 主函数,测试跳表的插入和查找操作
public static void main(String[] args) {
SkipList skipList = new SkipList();
skipList.insert(3);
skipList.insert(6);
skipList.insert(7);
skipList.insert(9);
skipList.insert(12);
skipList.insert(19);
System.out.println(skipList.search(9)); // 输出 true,表示找到目标值9
System.out.println(skipList.search(15)); // 输出 false,表示没有找到目标值15
}
}
```
- 一.JVM
- 1.1 java代码是怎么运行的
- 1.2 JVM的内存区域
- 1.3 JVM运行时内存
- 1.4 JVM内存分配策略
- 1.5 JVM类加载机制与对象的生命周期
- 1.6 常用的垃圾回收算法
- 1.7 JVM垃圾收集器
- 1.8 CMS垃圾收集器
- 1.9 G1垃圾收集器
- 2.面试相关文章
- 2.1 可能是把Java内存区域讲得最清楚的一篇文章
- 2.0 GC调优参数
- 2.1GC排查系列
- 2.2 内存泄漏和内存溢出
- 2.2.3 深入理解JVM-hotspot虚拟机对象探秘
- 1.10 并发的可达性分析相关问题
- 二.Java集合架构
- 1.ArrayList深入源码分析
- 2.Vector深入源码分析
- 3.LinkedList深入源码分析
- 4.HashMap深入源码分析
- 5.ConcurrentHashMap深入源码分析
- 6.HashSet,LinkedHashSet 和 LinkedHashMap
- 7.容器中的设计模式
- 8.集合架构之面试指南
- 9.TreeSet和TreeMap
- 三.Java基础
- 1.基础概念
- 1.1 Java程序初始化的顺序是怎么样的
- 1.2 Java和C++的区别
- 1.3 反射
- 1.4 注解
- 1.5 泛型
- 1.6 字节与字符的区别以及访问修饰符
- 1.7 深拷贝与浅拷贝
- 1.8 字符串常量池
- 2.面向对象
- 3.关键字
- 4.基本数据类型与运算
- 5.字符串与数组
- 6.异常处理
- 7.Object 通用方法
- 8.Java8
- 8.1 Java 8 Tutorial
- 8.2 Java 8 数据流(Stream)
- 8.3 Java 8 并发教程:线程和执行器
- 8.4 Java 8 并发教程:同步和锁
- 8.5 Java 8 并发教程:原子变量和 ConcurrentMap
- 8.6 Java 8 API 示例:字符串、数值、算术和文件
- 8.7 在 Java 8 中避免 Null 检查
- 8.8 使用 Intellij IDEA 解决 Java 8 的数据流问题
- 四.Java 并发编程
- 1.线程的实现/创建
- 2.线程生命周期/状态转换
- 3.线程池
- 4.线程中的协作、中断
- 5.Java锁
- 5.1 乐观锁、悲观锁和自旋锁
- 5.2 Synchronized
- 5.3 ReentrantLock
- 5.4 公平锁和非公平锁
- 5.3.1 说说ReentrantLock的实现原理,以及ReentrantLock的核心源码是如何实现的?
- 5.5 锁优化和升级
- 6.多线程的上下文切换
- 7.死锁的产生和解决
- 8.J.U.C(java.util.concurrent)
- 0.简化版(快速复习用)
- 9.锁优化
- 10.Java 内存模型(JMM)
- 11.ThreadLocal详解
- 12 CAS
- 13.AQS
- 0.ArrayBlockingQueue和LinkedBlockingQueue的实现原理
- 1.DelayQueue的实现原理
- 14.Thread.join()实现原理
- 15.PriorityQueue 的特性和原理
- 16.CyclicBarrier的实际使用场景
- 五.Java I/O NIO
- 1.I/O模型简述
- 2.Java NIO之缓冲区
- 3.JAVA NIO之文件通道
- 4.Java NIO之套接字通道
- 5.Java NIO之选择器
- 6.基于 Java NIO 实现简单的 HTTP 服务器
- 7.BIO-NIO-AIO
- 8.netty(一)
- 9.NIO面试题
- 六.Java设计模式
- 1.单例模式
- 2.策略模式
- 3.模板方法
- 4.适配器模式
- 5.简单工厂
- 6.门面模式
- 7.代理模式
- 七.数据结构和算法
- 1.什么是红黑树
- 2.二叉树
- 2.1 二叉树的前序、中序、后序遍历
- 3.排序算法汇总
- 4.java实现链表及链表的重用操作
- 4.1算法题-链表反转
- 5.图的概述
- 6.常见的几道字符串算法题
- 7.几道常见的链表算法题
- 8.leetcode常见算法题1
- 9.LRU缓存策略
- 10.二进制及位运算
- 10.1.二进制和十进制转换
- 10.2.位运算
- 11.常见链表算法题
- 12.算法好文推荐
- 13.跳表
- 八.Spring 全家桶
- 1.Spring IOC
- 2.Spring AOP
- 3.Spring 事务管理
- 4.SpringMVC 运行流程和手动实现
- 0.Spring 核心技术
- 5.spring如何解决循环依赖问题
- 6.springboot自动装配原理
- 7.Spring中的循环依赖解决机制中,为什么要三级缓存,用二级缓存不够吗
- 8.beanFactory和factoryBean有什么区别
- 九.数据库
- 1.mybatis
- 1.1 MyBatis-# 与 $ 区别以及 sql 预编译
- Mybatis系列1-Configuration
- Mybatis系列2-SQL执行过程
- Mybatis系列3-之SqlSession
- Mybatis系列4-之Executor
- Mybatis系列5-StatementHandler
- Mybatis系列6-MappedStatement
- Mybatis系列7-参数设置揭秘(ParameterHandler)
- Mybatis系列8-缓存机制
- 2.浅谈聚簇索引和非聚簇索引的区别
- 3.mysql 证明为什么用limit时,offset很大会影响性能
- 4.MySQL中的索引
- 5.数据库索引2
- 6.面试题收集
- 7.MySQL行锁、表锁、间隙锁详解
- 8.数据库MVCC详解
- 9.一条SQL查询语句是如何执行的
- 10.MySQL 的 crash-safe 原理解析
- 11.MySQL 性能优化神器 Explain 使用分析
- 12.mysql中,一条update语句执行的过程是怎么样的?期间用到了mysql的哪些log,分别有什么作用
- 十.Redis
- 0.快速复习回顾Redis
- 1.通俗易懂的Redis数据结构基础教程
- 2.分布式锁(一)
- 3.分布式锁(二)
- 4.延时队列
- 5.位图Bitmaps
- 6.Bitmaps(位图)的使用
- 7.Scan
- 8.redis缓存雪崩、缓存击穿、缓存穿透
- 9.Redis为什么是单线程、及高并发快的3大原因详解
- 10.布隆过滤器你值得拥有的开发利器
- 11.Redis哨兵、复制、集群的设计原理与区别
- 12.redis的IO多路复用
- 13.相关redis面试题
- 14.redis集群
- 十一.中间件
- 1.RabbitMQ
- 1.1 RabbitMQ实战,hello world
- 1.2 RabbitMQ 实战,工作队列
- 1.3 RabbitMQ 实战, 发布订阅
- 1.4 RabbitMQ 实战,路由
- 1.5 RabbitMQ 实战,主题
- 1.6 Spring AMQP 的 AMQP 抽象
- 1.7 Spring AMQP 实战 – 整合 RabbitMQ 发送邮件
- 1.8 RabbitMQ 的消息持久化与 Spring AMQP 的实现剖析
- 1.9 RabbitMQ必备核心知识
- 2.RocketMQ 的几个简单问题与答案
- 2.Kafka
- 2.1 kafka 基础概念和术语
- 2.2 Kafka的重平衡(Rebalance)
- 2.3.kafka日志机制
- 2.4 kafka是pull还是push的方式传递消息的?
- 2.5 Kafka的数据处理流程
- 2.6 Kafka的脑裂预防和处理机制
- 2.7 Kafka中partition副本的Leader选举机制
- 2.8 如果Leader挂了的时候,follower没来得及同步,是否会出现数据不一致
- 2.9 kafka的partition副本是否会出现脑裂情况
- 十二.Zookeeper
- 0.什么是Zookeeper(漫画)
- 1.使用docker安装Zookeeper伪集群
- 3.ZooKeeper-Plus
- 4.zk实现分布式锁
- 5.ZooKeeper之Watcher机制
- 6.Zookeeper之选举及数据一致性
- 十三.计算机网络
- 1.进制转换:二进制、八进制、十六进制、十进制之间的转换
- 2.位运算
- 3.计算机网络面试题汇总1
- 十四.Docker
- 100.面试题收集合集
- 1.美团面试常见问题总结
- 2.b站部分面试题
- 3.比心面试题
- 4.腾讯面试题
- 5.哈罗部分面试
- 6.笔记
- 十五.Storm
- 1.Storm和流处理简介
- 2.Storm 核心概念详解
- 3.Storm 单机版本环境搭建
- 4.Storm 集群环境搭建
- 5.Storm 编程模型详解
- 6.Storm 项目三种打包方式对比分析
- 7.Storm 集成 Redis 详解
- 8.Storm 集成 HDFS 和 HBase
- 9.Storm 集成 Kafka
- 十六.Elasticsearch
- 1.初识ElasticSearch
- 2.文档基本CRUD、集群健康检查
- 3.shard&replica
- 4.document核心元数据解析及ES的并发控制
- 5.document的批量操作及数据路由原理
- 6.倒排索引
- 十七.分布式相关
- 1.分布式事务解决方案一网打尽
- 2.关于xxx怎么保证高可用的问题
- 3.一致性hash原理与实现
- 4.微服务注册中心 Nacos 比 Eureka的优势
- 5.Raft 协议算法
- 6.为什么微服务架构中需要网关
- 0.CAP与BASE理论
- 十八.Dubbo
- 1.快速掌握Dubbo常规应用
- 2.Dubbo应用进阶
- 3.Dubbo调用模块详解
- 4.Dubbo调用模块源码分析
- 6.Dubbo协议模块