DS高阶:跳表

一、skiplist

1.1 skiplist的概念

skiplist本质上也是一种查找结构,用于解决算法中的查找问题,跟平衡搜索树和哈希表的价值是一样的,可以作为key或者key/value的查找模型。skiplist是由William Pugh发明的,最早出现于他在1990年发表的论文《Skip Lists: A Probabilistic Alternative to Balanced Trees》

skiplist,顾名思义,首先它是一个list。实际上,它是在有序链表的基础上发展起来的。如果是一个有序的链表,查找数据的时间复杂度是O(N)。

1.2 skiplist的优化思路分析

假如我们每相邻两个节点升高一层,增加一个指针,让指针指向下下个节点,如下图b所

示。这样所有新增加的指针连成了一个新的链表,但它包含的节点个数只有原来的一半。**由于新增加的指针,我们不再需要与链表中每个节点逐个进行比较了,需要比较的节点数大概只有原来的一半。(多层链表的启发思路)**以此类推,我们可以在第二层新产生的链表上,继续为每相邻的两个节点升高一层,增加一个指针,从而产生第三层链表。如下图c,这样搜索效率就进一步提高了。

skiplist正是受这种多层链表的想法的启发而设计出来的 。实际上,按照上面生成链表的方式,上面每一层链表的节点个数,是下面一层的节点个数的一半,这样查找过程就非常类似二分查找,使得查找的时间复杂度可以降低到O(log n)。但是这个结构在插入删除数据的时候有很大的问题,插入或者删除一个节点之后,就会打乱上下相邻两层链表上节点个数严格的2:1的对应关系。如果要维持这种对应关系,就必须把新插入的节点后面的所有节点(也包括新插入的节点)重新进行调整,这会让时间复杂度重新蜕化成O(n)。

skiplist的设计为了避免这种问题,做了一个大胆的处理,不再严格要求对应比例关系,而是
插入一个节点的时候随机出一个层数。这样每次插入和删除都不需要考虑其他节点的层数
,这样就好处理多了。

1.3 随机出层数的含义

插入节点时随机出一个层数究竟是什么意思呢???难道直接random任意数就可以了吗??

答:虽然是随机,但是也有规则的限制。这里首先要细节分析的是这个随机层数是怎么来的。一般跳表会设计一个最大层数maxLevel的限制,其次会设置一个多增加一层的概率p。那么计算这个随机层数的伪代码如下图:

一个节点的平均层数(也即包含的平均指针数目),计算如下:

现在很容易计算出:

当p=1/2时,每个节点所包含的平均指针数目为2;

当p=1/4时,每个节点所包含的平均指针数目为1.33。

时间复杂度:logN

具体的分析可以看下面的文章:Redis内部数据结构详解(6)------skiplist

2、skiplist的模拟实现

力扣有一道设计跳表的题. - 力扣(LeetCode)设计跳表

基本的调表需要实现4个函数:构造函数、搜索、插入、删除。下面我们来一个个分析。

2.1 skiplist的基本结构

cpp 复制代码
struct SkiplistNode
{
	int _val;//存储对应的值
	vector<SkiplistNode*> _nextV;//存放对应的next指针集合
	SkiplistNode(const int&val, size_t level = 1) //level表示需要开辟的层数 不传就是默认开满
		:_val(val)
	{
		_nextV.resize(level, nullptr);
	}
};




class Skiplist
{
	typedef  SkiplistNode Node;
public:


private:
	Node* _head;//虚拟头节点
    const size_t _maxLevel = 32; //用缺省参数去初始化
    const double _p = 0.25;//用缺省参数去初始化
};

2.2 skiplist的默认构造

cpp 复制代码
Skiplist()
{
	srand((unsigned int)time(nullptr));//为了方便后面的随机取层数,先弄一个随机种子
	_head = new Node(-1);//默认开一层,用默认构造初始化
}

给虚拟头节点申请一块空间,一开始默认就开一层。为了能够方面后面利用rand函数随机取层数,所以在这个地方先用了一个时间种子

我们默认开的是一层,因为在数据量小的时候其实我们可以根据插入的情况去调整_head的层数,如果是数据量特别大的话,也可以一次性就把他开到满

2.3 skiplist的搜索

cpp 复制代码
bool search(int target) 
{
	//要不断往下走
	Node* cur = _head;
	int level = _head->_nextV.size() - 1;//从后往前去找
	while (level >= 0)
	{
	   //如果我比你大 我就跳过去->更新cur   
	   //如果我比你小或者你为空 我就往下走 --level
		if (cur->_nextV[level] == nullptr || target < cur->_nextV[level]->_val) --level;
		else if (target > cur->_nextV[level]->_val)  cur = cur->_nextV[level];
		else return true;
	}
	return false; //循环结束都没有找到,说明找不到。
}

我们要从高层一直找到底层,所以要从_nextV的后面开始找。

1、如果你为空,或者我比你小,那就得往下走 ->--level

2、如果我比你大,就可以直接跳到你的位置->更新cur=cur->_nextV[level]

3、如果找到了就返回true,如果循环结束了都找不到,那就返回false

2.4 找到prevV指针数组

为什么要单独去封装这个函数呢?

因为不管是插入,还是删除,我们都需要去找前驱节点的集合,这样才能去改变连接关系,所以为了提高代码的复用性,封装这样的一个函数,去找到待插入位置或者是待删除位置的前驱节点集合。

cpp 复制代码
vector<Node*> FindPrevNode(int num) //帮助我们找到前驱指针集合
{
  //最终我们要返回待插入位置或者是待删除位置的前驱指针集合  一开始的时候默认是head、
	Node* cur = _head;
	int level = _head->_nextV.size() - 1;
	vector<Node*> prevV(level+1, _head);
	while (level >= 0)
	{
		if (cur->_nextV[level] == nullptr || num < cur->_nextV[level]->_val)
		{
			//更新level的层的前一个节点 往下跳之前保存前驱节点
			prevV[level] = cur;
			--level;
		}
		else//(num >= cur->_nextV[level]->_val)  
			cur = cur->_nextV[level];
	}
	return prevV;
}

当我们需要往后面跳之前,保存当前的cur进去prevV数组中,这样我们返回的数组就是待插入节点对应的前驱节点集合了!

2.5 随机层数的生成函数

我们在插入节点之前,要随机生成一个层数,所以要先实现一个生成层数的函数

2.5.1 C语言rand( )版本

cpp 复制代码
size_t RandomLevel() //C语言版本
{
	size_t level = 1;//初始的层数
	while (rand() <= RAND_MAX * _p && level < _maxLevel)  ++level; //RAND_MAX是随机数的最大值
	return level;
}

2.5.2 C++11随机数库

cpp 复制代码
	size_t RandomLevel() //需要的时候去搜 C++11的随机数库即可  头文件chrono和random
	{
		//类似随机数种子,但是只用一次是最好的 所以设置成staic 这样就只会调用一次了
		static std::default_random_engine generator(std::chrono::system_clock::now().time_since_epoch().count());//now.time_since_epoch().count()是一个时间戳 类似随机数种子
		static std::uniform_real_distribution<double> distribution(0.0, 1.0);
		size_t level = 1;
		while (distribution(generator) <= _p && level < _maxLevel)  ++level;
		return level;
	}

std::chrono::system_clock::now().time_since_epoch().count() 类似一个时间戳,相当于是随机种子,但是由于只需要初始化一次,所以我们将他变成static变量,这样就只要初始化一次即可!

关于C++11的random库用法,还是比较复杂的,大家可以参考一些相关的文章。

2.6 skiplist的增加

cpp 复制代码
void add(int num)  //插入节点
{
	vector<Node*> prevV = FindPrevNode(num); //右值引用
	size_t n = RandomLevel(); //表示需要开多少层
	//如果n超过了_head的最大层数,那么就要调整一下
	if (n > _head->_nextV.size())
	{
		_head->_nextV.resize(n, nullptr); 
		prevV.resize(n, _head);//不够的地方也要更新过去
	}
	Node* newnode = new Node(num, n);//申请对应的新节点  然后根据prevV数组去建立连接
	for (size_t i = 0; i < n; ++i) //连接前后节点,首先要先连后面的 再连前面的
	{
		newnode->_nextV[i] = prevV[i]->_nextV[i];
		prevV[i]->_nextV[i] = newnode;
	}
}

一个很关键的地方就是,我们随机生成了一个层数后,有可能我们的_head的层数都没这个多,所以我们必须利用resize去初始化一下,否则会出现越界访问。

中间插入的逻辑就类似链表的指定位置插入,先让自己的后继指向前驱的后继,然后再让前驱指向自己,必须按照这个顺序,否则会丢失节点

2.7 skiplist的删除

cpp 复制代码
bool erase(int num) 
{
	//首先 有可能没有这个数 所以要看看是不是真的没有
	vector<Node*> prevV = FindPrevNode(num);
	if (prevV[0]->_nextV[0] == nullptr || prevV[0]->_nextV[0]->_val != num)  return false;
	//有的话,就要去删除然后重新连接
	Node* del = prevV[0]->_nextV[0];//我们需要删除的节点,但是在删除前要调整一下连接的关系
	for (size_t i = 0; i < del->_nextV.size(); ++i)  prevV[i]->_nextV[i] = del->_nextV[i];
	delete del;
	
	// 如果删除最高层节点,把头节点的层数也降一下
	int i = _head->_nextV.size() - 1;
	while (i >= 0)
	{
		if (_head->_nextV[i] == nullptr)  --i;
		else  break;
	}
	_head->_nextV.resize(i + 1);
	return true;
}

有可能我们找不到这个数,这个时候就没什么可以删的了。

在删除这个节点之前,我们要先记录这个节点,然后去改变被删除节点的连接关系,类似链表的指定位置删除。

如果我们删除的恰好是最高层的节点,这个时候可以整体对头结点的层数降个高度,这样就提高了查找效率。

三、skiplist跟平衡搜索树和哈希表的对比

  1. skiplist相比平衡搜索树(AVL树和红黑树)对比,都可以做到遍历数据有序,时间复杂度也差不多。但是skiplist在平衡树面前优势明显。

skiplist的优势是:

a、**skiplist实现简单,容易控制。**平衡树增删查改遍历都更复杂。

b、**skiplist的额外空间消耗更低。**平衡树节点存储每个值有三叉链,平衡因子/颜色等消耗skiplist中p=1/2时,每个节点所包含的平均指针数目为2;skiplist中p=1/4时,每个节点所包含的平均指针数目为1.33;

  1. skiplist相比哈希表而言,就没有那么大的优势了:

哈希表的优势如下:

a、哈希表平均时间复杂度是O(1),比skiplist快。

b、哈希表空间消耗略多一点。

skiplist优势如下:

a、遍历数据有序

b、skiplist空间消耗略小一点,哈希表存在链接指针和表空间消耗。

c、哈希表扩容有性能损耗。

d、哈希表再极端场景下哈希冲突高,效率下降厉害,需要红黑树补足接力。

相关推荐
1 9 J3 分钟前
Java 上机实践4(类与对象)
java·开发语言·算法
passer__jw7672 小时前
【LeetCode】【算法】3. 无重复字符的最长子串
算法·leetcode
passer__jw7672 小时前
【LeetCode】【算法】21. 合并两个有序链表
算法·leetcode·链表
sweetheart7-72 小时前
LeetCode22. 括号生成(2024冬季每日一题 2)
算法·深度优先·力扣·dfs·左右括号匹配
李元豪3 小时前
【智鹿空间】c++实现了一个简单的链表数据结构 MyList,其中包含基本的 Get 和 Modify 操作,
数据结构·c++·链表
2401_858286113 小时前
L7.【LeetCode笔记】相交链表
笔记·leetcode·链表
我不是星海3 小时前
1.集合体系补充(1)
java·数据结构
UestcXiye4 小时前
《TCP/IP网络编程》学习笔记 | Chapter 9:套接字的多种可选项
c++·计算机网络·ip·tcp
一丝晨光4 小时前
编译器、IDE对C/C++新标准的支持
c语言·开发语言·c++·ide·msvc·visual studio·gcc
景鹤4 小时前
【算法】递归+回溯+剪枝:78.子集
算法·机器学习·剪枝