「这是我参与2022首次更文挑战的第12天,活动详情查看:2022首次更文挑战」
前言
笔者除了大学时期选修过《算法设计与分析》和《数据结构》还是浑浑噩噩度过的(当时觉得和编程没多大关系),其他时间对算法接触也比较少,但是随着开发时间变长对一些底层代码/处理机制有所接触越发觉得算法的重要性,所以决定开始系统的学习(主要是刷力扣上的题目)和整理,也希望还没开始学习的人尽早开始。
系列文章收录《算法》专栏中。
问题描述
不使用任何库函数,设计一个跳表。
跳表是在 O(log(n)) 时间内完成增加、删除、搜索操作的数据结构。跳表相比于树堆与红黑树,其功能与性能相当,并且跳表的代码长度相较下更短,其设计思想与链表相似。
例如,一个跳表包含 [30, 40, 50, 60, 70, 90],然后增加 80、45 到跳表中,以下图的方式操作:
Artyom Kalinin [CC BY-SA 3.0], via Wikimedia Commons
跳表中有很多层,每一层是一个短的链表。在第一层的作用下,增加、删除和搜索操作的时间复杂度不超过 O(n)。跳表的每一个操作的平均时间复杂度是 O(log(n)),空间复杂度是 O(n)。
在本题中,你的设计应该要包含这些函数:
- bool search(int target) : 返回target是否存在于跳表中。
- void add(int num): 插入一个元素到跳表。
- bool erase(int num): 在跳表中删除一个值,如果 num 不存在,直接返回false. 如果存在多个 num ,删除其中任意一个即可。
注意,跳表中可能存在多个相同的值,你的代码需要处理这种情况。
样例:
Skiplist skiplist = new Skiplist();
skiplist.add(1);
skiplist.add(2);
skiplist.add(3);
skiplist.search(0); // 返回 false
skiplist.add(4);
skiplist.search(1); // 返回 true
skiplist.erase(0); // 返回 false,0 不在跳表中
skiplist.erase(1); // 返回 true
skiplist.search(1); // 返回 false,1 已被擦除
约束条件:
0 <= num, target <= 20000- 最多调用
50000次search,add, 以及erase操作。
确定学习目标
对《实现跳表(skiplist)算法》的算法过程进行剖析。
剖析
首先我们还是先对跳表(skiplist)的定义和设计目的了解下。
跳表(skiplist)简介
跳表(SkipList,全称跳跃表)是用于有序元素序列快速搜索查找的一个数据结构,跳表是一个随机化的数据结构,实质上是一种可以进行二分查找的有序链表。跳表在原有的有序链表上增加了多级索引,通过索引来实现快速查找。跳表不仅能提高搜索性能,同时也可以提高插入和删除操作的性能。它在性能上和红黑树、AVL树不相上下,但是跳表的原理非常简单,实现也比红黑树简单很多。
结合 “问题描述”章节 的动图我们可以有个大概感知:跳表最底层包含所有关键字、所有索引节点值都是关键字和我们了解的Mysql B+树索引类似,除最底层上面层都是索引用于查询缩小范围,定义中也说的“跳表”本质上是利用二分查找+链表,所以主要的设计思想我们可以得出:
- “跳表”是空间换时间
- 链表虽然增删操作比较方便,但是随机增删和访问其实效率不高只能遍历时间复杂度为O(n),其实要解决的就是随机查找,链表利用二分思想的缩小范围解决随机查找,集合链表本身的增删操作简单也实现了“不仅能提高搜索性能,同时也可以提高插入和删除操作的性能”。
对跳表有所了解了,我们继续分析下实现它要解决啥问题。
实现分析
我们先看跳表的结构,再看查找,最后看增删(因为增删的过程中有用到查找)。
跳表的结构
我们可以根据上文对跳表的定义和问题描述中的图,得知跳表有一个head节点表示跳表的头,next是一个节点数组表示下个节点集,0下标表示最高层,size-1表示最底层,(0,size-1)区间就是索引部分。
我们需要控制层数的最大值,为什么?索引层过多存放的节点就多占用内存就多,所以必须要有一个上限。那上线我们定为32,为什么?可以想下,32层减去最底层1为31层,按照理想的二分法我们能对多少数据进行二分?2^31几个亿大部分场景足够了。
所以跳表的类结构如下:
class Node {
private Integer value;
private Node[] next;
public Node(Integer value, int size) {
this.value = value;
this.next = new Node[size];
}
@Override
public String toString() {
return String.valueOf(value);
}
}
/**
* 最大层数
*/
private static final Integer DEFAULT_MAX_LEVEL = 32;
/**
* 头节点
*/
private Node head = new Node(null, DEFAULT_MAX_LEVEL);
/**
* 当前有效层数,从1开始
*/
private Integer currentLevel = 1;
查找
比如我们找到10
- 从最上层索引开始,我们取到一个7,7比10小,我们需要继续往右。
- 往右是null,我们需要往7的下层开始,继续往右。
- 往右是37大于10,我们还要继续往7的下层开始,继续往右。
- 往右是19大于10,我们还要继续往7的下层开始,继续往右。
- 往右是11大于10,可是已经是最后一层了,查找终止返回false。
比如我们要找11和上面找10的步骤一样,最终找到11,返回true。其他的步骤类似,我们就不一一赘述,下面我们总结下算法的流程:
-
从上层开始找,寻找小于目标的最大值节点(假设target为11,比如索引层为8,9,12,15,null,我们只要定位到9就行了)
-
如果第1步没有直接返回,我们就在第一步中找到的小于目标的最大值节点往下找,重复步骤1,直到遍历到最底层节点。
public boolean search(int target) {
Node searcheNode = head;
for (int i = currentLevel - 1; i >= 0; i--) {
searcheNode = findClosest(searcheNode, i, target);
if (searcheNode.next[i] != null && target == searcheNode.next[i].value) {
return true;
}
}
return false;
}
/**
* 通用的方法-找出target最近节点,这也是为啥不直接找出相等节点原因,因为当删除查询的时候如果是相等的话就改变不了前一个节点所在层的next
* 虽然可以定制不同的查询,但是使用通用方法只维护一种查询,维护成本低
*
* @param node 当前查询节点
* @param levelIndex 当前遍历层数index
* @param target 查询值
* @return
*/
private Node findClosest(Node node, int levelIndex, int target) {
while (node.next[levelIndex] != null && target > node.next[levelIndex].value) {
node = node.next[levelIndex];
}
return node;
}
增加
增加的大致流程如上图,可以看出也是找到最接近的节点(查找流程查看上面的 “查找”章节 )往它后面添加节点,另外核心要解决的是层数的选择和控制,如果按照理想的二分实现比较复杂并且复杂度也可能会上去,所以我们这里使用随机层。
/**
* 随机一个层数,需要控制小于等于上限并且不等大于目前层数+n
*/
private int randomLevel() {
int level = 1;
while (Math.random() < DEFAULT_P_FACTOR && level < DEFAULT_MAX_LEVEL && level < (currentLevel + 3)) {
++level;
}
return level;
}
/**
* 添加
*
* @param num
*/
public void add(int num) {
int level = randomLevel();
Node newNode = new Node(num, level);
Node searcheNode = head;
//只用一次遍历
int biggerLevel = level > currentLevel ? level : currentLevel;
for (int i = biggerLevel - 1; i >= 0; i--) {
//当level index大于currentLevel-1时
if (i > (currentLevel - 1)) {
head.next[i] = newNode;
} else {//当level index小于等于currentLevel-1时
searcheNode = findClosest(searcheNode, i, num);
if (i < level) {
if (searcheNode.next[i] == null) {
searcheNode.next[i] = newNode;
} else {
Node temp = searcheNode.next[i];
searcheNode.next[i] = newNode;
newNode.next[i] = temp;
}
}
}
}
//大于当前就替换
if (level > currentLevel) {
currentLevel = level;
}
}
删除
了解了查找和增加,删除比较简单,下面贴出完整的代码。
代码
package com.study.algorithm.skiplist;
/**
* 跳表的实现-结构、增删查
*/
public class SkipList {
class Node {
private Integer value;
private Node[] next;
public Node(Integer value, int size) {
this.value = value;
this.next = new Node[size];
}
@Override
public String toString() {
return String.valueOf(value);
}
}
/**
* 最大层数
*/
private static final Integer DEFAULT_MAX_LEVEL = 32;
/**
* 头节点
*/
private Node head = new Node(null, DEFAULT_MAX_LEVEL);
/**
* 当前有效层数,从1开始
*/
private Integer currentLevel = 1;
/**
* 随机层数概率,第1层以上(不包括第一层)的继续叠加的概率,层数不超过maxLevel,层数的起始号为1
*/
private static final Double DEFAULT_P_FACTOR = 0.5;
public boolean search(int target) {
Node searcheNode = head;
for (int i = currentLevel - 1; i >= 0; i--) {
searcheNode = findClosest(searcheNode, i, target);
if (searcheNode.next[i] != null && target == searcheNode.next[i].value) {
return true;
}
}
return false;
}
/**
* 通用的方法-找出target最近节点,这也是为啥不直接找出相等节点原因,因为当删除查询的时候如果是相等的话就改变不了前一个节点所在层的next
* 虽然可以定制不同的查询,但是使用通用方法只维护一种查询,维护成本低
*
* @param node 当前查询节点
* @param levelIndex 当前遍历层数index
* @param target 查询值
* @return
*/
private Node findClosest(Node node, int levelIndex, int target) {
while (node.next[levelIndex] != null && target > node.next[levelIndex].value) {
node = node.next[levelIndex];
}
return node;
}
/**
* 添加
*
* @param num
*/
public void add(int num) {
int level = randomLevel();
Node newNode = new Node(num, level);
Node searcheNode = head;
//只用一次遍历
int biggerLevel = level > currentLevel ? level : currentLevel;
for (int i = biggerLevel - 1; i >= 0; i--) {
//当level index大于currentLevel-1时
if (i > (currentLevel - 1)) {
head.next[i] = newNode;
} else {//当level index小于等于currentLevel-1时
searcheNode = findClosest(searcheNode, i, num);
if (i < level) {
if (searcheNode.next[i] == null) {
searcheNode.next[i] = newNode;
} else {
Node temp = searcheNode.next[i];
searcheNode.next[i] = newNode;
newNode.next[i] = temp;
}
}
}
}
//大于当前就替换
if (level > currentLevel) {
currentLevel = level;
}
}
/**
* 删除:找到并改变next就行和链表删除类似
*
* @param num
* @return
*/
public boolean erase(int num) {
boolean flag = false;
Node searcheNode = head;
for (int i = currentLevel - 1; i >= 0; i--) {
searcheNode = findClosest(searcheNode, i, num);
if (searcheNode.next[i] != null && num == searcheNode.next[i].value) {
flag = true;
searcheNode.next[i] = searcheNode.next[i].next[i];
continue;
}
}
return flag;
}
/**
* 随机一个层数,需要控制小于等于上限并且不等大于目前层数+n
*/
private int randomLevel() {
int level = 1;
while (Math.random() < DEFAULT_P_FACTOR && level < DEFAULT_MAX_LEVEL && level < (currentLevel + 3)) {
++level;
}
return level;
}
public static void main(String[] args) {
Skipl skipl = new Skipl();
skipl.add(1);
skipl.add(2);
skipl.add(3);
skipl.search(0); // 返回 false
skipl.add(4);
skipl.search(1); // 返回 true
skipl.erase(0); // 返回 false,0 不在跳表中
skipl.erase(1); // 返回 true
skipl.search(1); // 返回 false,1 已被擦除
}
}