前言
前面的文章我们学习了性能高效的基于二叉搜索树的动态数据结构红黑树,其平均时间复杂度为 ,今天我们再来学习另外一种优秀的数据结构跳跃表(Skip List),其综合性能与红黑树一样,而且功能更强大,从某种意义上来说是可以替代红黑树的。但是由于红黑树的代码实现需要严密的逻辑判断,因此较为复杂,而跳跃表就相对来讲实现更容易一些。
跳跃表的基本概念与性质
跳跃表(Skip List)是1987年才诞生的一种崭新的数据结构,它在进行查找、插入、删除等操作时的期望时间复杂度均为O(logn),接近线性时间,有着近乎替代平衡树的本领。而且最重要的一点,就是它的编程复杂度较同类的AVL树,红黑树等要低得多,这使得其无论是在理解还是在推广性上,都有着十分明显的优势。
在介绍跳跃表之前,我们先来思考一个问题,如果现在我们要维护一组有序的整数序列,在支持高效的插入,删除和搜索的同时并能维护序列的有序性,那么应该采用什么什么数据结构?
首先哈希表应该被排除掉,虽然支持O(1)的增,删,查,但是HashMap不能维护有序性。接着我们思考下使用数组怎么样,使用数组查询很快,但删除和新增比较慢,最坏情况下是O(N)的复杂度,所以也被排除掉。
然后我们能想到的就是二叉查找树,但二叉查找树没有维持平衡性,最坏情况下依然是O(N),所以也被排除掉,最后我们想到了二叉树里面的Boss,没错,它就是红黑树,它可以维持有序性,并且增,删,查都有不错的性能,目前看满足了所有的需求。
但是,红黑树有一个缺点,不支持范围搜索,或者做不到高效的范围搜索,什么是范围搜索?简单点说就是 sql 里面 where 条件里面 between 3 and 100,查询一个区间的数据,数据库里面采用是B+树索引,所以可以支持,但这不是今天讨论的重点,除了B+树索引外,那么还有哪种数据结构可以实现有序,支持高效的增,删,查,并支持区间搜索呢?
答案是肯定的,它就是我们要介绍的跳跃表,跳跃表是一种数据结构,它允许快速查询一个有序连续元素的数据链表。跳跃表这种结构在Lucene,Redis,Leveldb等框架中都有应用,比如redis里面的zset结构,底层采用的就是跳跃表。
跳跃表的重要性质:
- 一个跳表应该由很多层结构组成
- 每一层都是一个有序的链表
- 最底层(Level 1)的链表包含所有元素
- 如果一个元素出现在 Level i 的链表中,则它在 Level i 之下的链表也都会出现。
- 每个节点包含两个指针,一个指向同一链表中的下一个元素,一个指向下面一层的元素。(在实现中通常我们为了一些操作的方便,给每个结点都提供了上下左右四个指针域)
- Head指针指向最高层的第一个元素。
跳跃表结构分析
跳跃表的结构是多层的,通过从最高维度的表进行检索再逐渐降低维度从而达到对任何元素的检索接近线性时间的目的
。
如上图:对节点8的检索走红色标记的路线,需要4步。对节点5的检索走蓝色路线,需要4步。由此可见,跳跃表本质上是一种网络布局结构,通过增加检索的维度(层数)来减少链表检索中需要经过的节点数。
理想跳跃表应该具备如下特点:
- 包含有N个元素节点的跳跃表拥有 层
- 上层链表包含的节点数恰好等于下层链表节点数的1/2
但如此严苛的要求在算法上过于复杂。因此通常的做法是:每次向跳跃表中增加一个节点就有50%的随机概率向上层链表增加一个跳跃节点,并以此类推。
接下来,我们做如下规范说明:
- 跳跃表的层数,我们称其维度。自顶向下,我们称为降维,反之亦然。
- 表中,处于不同链表层的相同元素。我们称为“同位素”。
- 最底层的链表,即包含了所有元素节点的链表是L1层,或称基础层。除此以外的所有链表层都称为跳跃层。
跳跃表的结构定义
/**
* SkipList的性质
* (1) 由很多层结构组成,level是通过一定的概率随机产生的,基本是50%的产生几率。
* (2) 每一层都是一个有序的链表,默认是升序。
* (3) 最底层(Level 1)的链表包含所有元素。
* (4) 如果一个元素出现在Level i 的链表中,则它在Level i 之下的链表也都会出现。
* (5) 每个节点包含四个指针,但有可能为nullptr。
* (6) 每一层链表横向为单向连接,纵向为双向连接。
*/
template<typename T>
class SkipList
{
public:
SkipList() : level(1)
{
Listhead = new Node();
nodeSum = 1;
}
/* 跳跃表的表销毁 */
~SkipList();
/* 插入元素 */
void insert(const T& val)
/* 删除元素 */
void remove(const T& val)
/* 查询元素 */
bool search(const T& val)
/* 产生随机值 */
bool randomVal();
/* 层序遍历跳跃表 */
void print();
private:
struct Node
{
Node(T data = T())
:_data(data)
, _up(nullptr)
,_down(nullptr)
, _left(nullptr)
, _right(nullptr)
{}
T _data;
Node* _up;
Node* _down;
Node* _left;
Node* _right;
};
Node* Listhead; // 跳跃表头节点
int level; // 跳跃表的层数
int nodeSum; // 跳跃表的结点个数
};
抛硬币产生随机值
插入元素的时候,元素所占有的层数完全是随机的,即该元素是否要从基础层添加到跳跃层,我们通过随机算法产生:
static unsigned int seed = NULL; // 随机种子
template<typename T>
/* 丢硬币产生随机值 */
bool SkipList<T>::randomVal()
{
if (seed == NULL)
seed = (unsigned)time(NULL);
srand(seed);
int K= rand() % 2; // 两种情况,正反两面
seed = rand();
if (K == 0)
return true;
else
return false;
}
相当与做一次丢硬币的实验,如果遇到正面,继续丢,遇到反面,则停止。
用实验中丢硬币的次数 K 作为元素占有的层数。显然随机变量 K 满足参数为 p = 1/2 的几何分布,
K 的期望值 ,就是说,各个元素的层数,期望值是 2 层。
跳跃表的元素添加操作
向跳跃表中添加元素是跳跃表最为复杂的操作,简单分析如下:
- 首先进入最底层Level 1,然后寻找合适位置插入(大于其值之前的位置)
- 注意我们为了在删除元素时的操作方便,还需要记录up指针和left指针
- 搜索过程中需要判断该元素是否已经存在,若存在则不进行插入
- 根据抛硬币产生的随机值决定是否将该元素添加至跳跃层,为了防止不断进行升维,我们在这里限制插入一个元素最多升维一次。
- 升维后进行上下相同元素和左右元素的串联
- 另外我们还需要保证在在最高层有一个元素的情况下,不会再向上扩层升维
/* 插入元素 */
void insert(const T& val)
{
Node* curNode = Listhead;
/* 向下走到最底层Level 1 */
while (curNode->_down != nullptr)
{
curNode = curNode->_down;
}
Node* curHead = curNode;
Node* newNode = nullptr;
/* 寻找合适位置进行插入 */
while (curNode->_right != nullptr)
{
/* 待插入元素小于表中节点值,找到了合适位置 */
if (val < curNode->_right->_data)
{
newNode = new Node(val);
newNode->_right = curNode->_right;
curNode->_right->_left = newNode;
curNode->_right = newNode;
newNode->_left = curNode; // 更新新节点的left指针
break;
}
/* 待插入元素等于表中节点值,不进行插入 */
else if (val == curNode->_right->_data)
{
return;
}
curNode = curNode->_right;
}
/* 遍历到末节点也没有合适位置,直接插入到链表末尾即可 */
if (newNode == nullptr)
{
newNode = new Node(val);
curNode->_right = newNode;
newNode->_left = curNode;
}
/* 限制升维最高升一层,防止不断升层,浪费空间 */
int maxLevel = level + 1;
int curLevel = 1;
/* 根据抛硬币产生的随机值决定是否将该元素添加至跳跃层 */
while (randomVal())
{
/* 跳跃表层数不能超过最大限制,防止不断升层 */
if (level > maxLevel)
{
return;
}
curLevel++;
/* 是否要进行升维,此操作每添加一个元素最多执行一次 */
if (level < curLevel)
{
level++; // 跳跃表的层数增加
Node* newHead = new Node();
newHead->_down = Listhead;
Listhead->_up = newHead; // 更新up结点
Listhead = newHead; // 使Listhead指向当前新结点
}
// curHead升一层(要么进行了升维,要么本来层数就大于1)
curHead = curHead->_up;
curNode = curHead;
/* 将新插入的结点继续添加到跳跃层中,搜寻算法与Level 1层相同 */
Node* skipNode = nullptr;
while (curNode->_right != nullptr)
{
if (val < curNode->_right->_data &&
skipNode == nullptr)
{
skipNode = new Node(val);
skipNode->_right = curNode->_right;
curNode->_right->_left = skipNode;
curNode->_right = skipNode;
skipNode->_left = curNode; // 更新left指针域
}
curNode = curNode->_right;
}
/* 该跳跃层没有结点,即上述升维后的结果。将新结点添加到新产生的跳跃层 */
if (skipNode == nullptr)
{
skipNode = new Node(val);
curNode->_right = skipNode;
skipNode->_left = curNode; // 更新left指针域
}
/**
* 将基础层和跳跃层 或 跳跃层与跳跃层 中新插入的结点串联起来
* newNode初始是Level 1层中新插入的结点
* 我们需要使newNode走向其值存在的最高层(垂直关系)
*/
while (newNode->_up != nullptr)
{
newNode = newNode->_up;
}
skipNode->_down = newNode; // 更新跳跃结点的down指针域
newNode->_up = skipNode; // 更新newNode的up指针域
/* 保证在一层只有一个元素的情况下,不会再向上扩层 */
if (curHead->_right == skipNode)
{
return;
}
}
}
跳跃表的查询操作
由于跳跃表的删除操作需要使用到查询操作的思想,跳跃表的优点就是查询效率高,是线性时间的复杂度,我们实现查询操作要充分考虑跳跃表的性质,即每层是有序的递增的。接下来看一下查询操作的实现:
- 首先我们从最高层开始向右遍历,若查询元素小于遍历到的结点值,继续向右遍历,直到找到该元素或结点值大于查询元素值
- 若查询元素大于本层遍历到的结点值,那么我们需要进行降维,即向下走一层,因为本层不可能出现该元素
- 当把最后一层都遍历完毕也没有找到该元素时,在继续降维时发现已到最底层,因此查询失败
/* 查询元素 */
bool search(const T& val)
{
/* 从首层结点开始 */
Node* preNode = Listhead;
Node* curNode = preNode->_right;
while (true)
{
if (curNode != nullptr)
{
/* 要查询的val大,在本层向右遍历 */
if (curNode->_data < val)
{
preNode = curNode;
curNode = curNode->_right;
continue;
}
/* 找到要查询的元素 */
else if (val == curNode->_data)
{
return true;
}
}
/* 判断是否将所有层都遍历完 */
if (preNode->_down == nullptr)
{
return false;
}
/* 继续向下层遍历 */
preNode = preNode->_down;
curNode = preNode->_right;
}
}
跳跃表的元素删除操作
跳跃表的删除操作较增加操作简单一些,基本流程如下:
- 首先从最高层开始进行元素查找,查找的思路与查询操作完全相同
- 若没有查询到待删除元素值,直接返回即可
- 若查询到了待删除元素,我们从该元素所在的最高层开始进行元素删除,每层的元素删除和单链表的元素删除一样,因为我们为每个结点都保存了left指针,因此删除非常方便,将待删除结点的左右结点串联起来即可
- 继续降维向下层遍历,这是一种自上而下的垂直删除操作的过程,直到将基础层(最底层)的该元素删除
- 还需要注意的是,当我们在本层删除了该元素后,还需要判断本层是否还有其他元素,若没有其他元素了,那么我们需要将该层销毁(释放Head节点内存),并将跳跃表层数level减一。
/* 删除元素 */
void remove(const T& val)
{
/* 首先查询待删除元素是否在跳跃表中,查询方式和上述查询元素方法相同 */
Node* preNode = Listhead;
Node* curNode = preNode->_right;
while (true)
{
if (curNode != nullptr)
{
/* 待删除的val值大,在本层向右遍历 */
if (curNode->_data < val)
{
preNode = curNode;
curNode = curNode->_right;
continue;
}
/* 找到要删除的元素位置,跳出循环进行后续操作 */
else if (curNode->_data == val)
{
break;
}
}
/* 判断是否将所有层都遍历完 */
if (preNode->_down == nullptr)
{
break;
}
/* 继续向下层遍历 */
preNode = preNode->_down;
curNode = preNode->_right;
}
/* 判断是否查询到了待删除的元素 */
if (curNode == nullptr)
{
return;
}
/* 从找到的位置开始垂直删除元素 */
while (curNode != nullptr)
{
Node* delNode = curNode;
/* 找到待删除元素的前驱结点 */
preNode = curNode->_left;
/* 将待删除元素的左右结点串联起来 */
preNode->_right = curNode->_right;
if (curNode->_right != nullptr)
{
/* 更新左指针域 */
curNode->_right->_left = preNode;
}
/* 垂直向下层进行删除 */
curNode = curNode->_down;
delete delNode;
delNode = nullptr;
}
/**
* 若将该结点删除后,本层已经没有元素,那么需要更新层数,并将该层释放
* 我们采取自上而下的方式进行逐层判断
*/
Node* head = Listhead;
while (head->_right == nullptr && head->_down != nullptr)
{
level--; /* 更新跳跃表的层数 */
Node* delNode = head;
head = head->_down;
delete delNode;
delNode = nullptr;
}
Listhead = head; /* 更新跳跃表头节点ListHead */
}
跳跃表的表释放
对于跳跃表的释放我们在析构函数中完成,具体的释放操作与单链表的释放是类似的,我们只需要自顶而下的对每层的链表进行释放即可完成整个跳跃表的释放。
~SkipList()
{
Node* head = Listhead;
Node* delNode = nullptr;
while (head != nullptr)
{
/* 释放本层元素 */
Node* curNode = head->_right;
while (curNode != nullptr)
{
delNode = curNode;
curNode = delNode->_right;
delete delNode;
delNode = nullptr;
}
/* 释放头节点,并使head指针指向下一层,准备释放下一层的元素 */
delNode = head;
head = head->_down;
delete delNode;
}
}
跳跃表的层序遍历
按层打印跳跃表我们可以将问题分解,每层按照遍历单链表的方式进行打印,然后跳到下一层进行打印,直到打印完所有的层即可:
/* 按层打印跳跃表 */
void print()
{
cout << "level = " << level << endl;
Node* head = Listhead;
while (head != nullptr)
{
Node* curNode = head->_right;
while (curNode != nullptr)
{
cout << curNode->_data << " ";
curNode = curNode->_right;
}
cout << endl;
/* 跳到下一层 */
head = head->_down;
}
}
上述对于跳跃表的操作讲解是我的一点理解和提炼,因此讲解的较为简单,大家也可以参考这篇博文《跳跃表原理》,有具体的图示,便于理解。