数据结构---跳表

目录标题

  • 为什么会有跳表
  • 跳表的原理
  • 跳表的模拟实现
    • 准备工作
    • find函数
    • insert函数
    • erase函数
  • 测试
  • 效率比较

为什么会有跳表

在前面的学习过程中我们学习过链表这个容器,这个容器在头部和尾部插入数据的时间复杂度为O(1),但是该容器存在一个缺陷就是不管数据是否有序查找数据是否存在的时间复杂度都是O(N),我们只能通过暴力循环的方式查找数据是否存在,尽管数据是有序的我们也不能通过二分查找的形式去查找一个数据,那么为了解决这个问题就有人提出来了跳表这个容器。

跳表的原理

之前学习的链表结构如下:
在这里插入图片描述
那么跳表的思路就是:每相邻两个节点升高一层,增加一个指针,让指针指向下下个节点
在这里插入图片描述
这样所有新增加的指针连成了一个新的链表,但它包含的节点个数只有原来的一半。由于新增加的指针,我们不再需要与链表中每个节点逐个进行比较了,需要比较的节点数大概只有原来的一半。比如说要查找元素19,首先就比较头结点最上面指针指向的节点(节点中的元素为6)是否大于19,如果大于就跳转到该节点,那么很明显这里是大于的所以跳转到节点6上,然后接着比较元素6最上面的指针指向的节点(节点9)是否大于19,很明显是晓得所以跳转到元素9上,然后接着就来到节点17,因为节点17最上面的节点指向的元素是21比19大,那么这时就不能往该指针指向的节点进行跳转,我们得比较该指针下面的指针(17号节点中下方指针)指向的节点,该节点指向的值刚好为19所以就找到了指定元素,那么这就是跳表查找元素的原理
在这里插入图片描述

可是上面的查找过程并没有提高很大的效率最好的情况也是O(N/2),所以为了进一步的提高效率我们在第二层新产生的链表上,继续为每相邻的两个节点升高一层增加一个指针,从而产生第三层链表:
在这里插入图片描述
那么这时查找元素19就会变的更快,经过一次比较就来到了节点9,然后就跳转到节点17,最后就来到了节点19:
在这里插入图片描述
可以看到再添加一层节点就会使得节点查找的效率变的更高,那么同样的道理只要链表的数据个数够长我们还可以添加多层这样以此类推直到无法添加节点为止,那么这个时候进行第一次比较就可以过滤掉一半的元素,再经历一次比较又可以过滤掉1/4的元素,再经历一次比较就可以过滤掉1/8的元素等等,这样一直循环下去我们就可以发现此时查找元素的时间复杂度为O(logN),但是这里存在一个问题虽然这样的结构能够带来logN的效率,但是该效率是用整齐的结构换过来的,如果在使用的过程中对元素进行插入和删除这种结构就会被破坏,比如说最高的层数为10,如果我要往中间的节点插入一个节点的话这个节点的层数是多少呢?1到10都可以对吧,但是插入之后链表就没办法保持之前的规律(每相邻的节点抬高一层),如果要维持这种对应关系,就必须把新插入的节点后面的所有节点(也包括新插入的节点)重新进行调整,这会让时间复杂度重新蜕化成O(n)。skiplist的设计为了避免这种问题,做了一个大胆的处理,不再严格要求对应比例关系,而是插入一个节点的时候随机出一个层数。这样每次插入和删除都不需要考虑其他节点的层数,这样就好处理多了。
在这里插入图片描述
那这里就存在一个问题如果时随机分配层数的话那如何来保证效率呢?随机数有很多很多:1是随机数1w也是随机数,并且还可能出现多个很大的随机数,比如说当前有100个节点但是有99个节点的高度为100,但是我们需要很多高层的节点吗?好像不需要对吧,理论上来说层数越高的节点出现的概率应该越小,所以为了提高跳表的空间效率和时间效率我们给跳表设计一个最大层数maxLevel的限制,其次再设置一个多增加一层的概率P,也就是说每个节点的高度至少为1每升高一层的概率p,那么一个节点的高度为1的概率是:1-P(本来就有一个层高度不增加的概率为1-p)所以高度为1的概率是1-P,同样的道理高度为1增高一层的概率为p不增加的概率为1-p所以层数为二的概率就是p*(1-p),节点层数为3的概率就是p*p*(1-p),这样以此类推就不难发现节点层数越高出现的概率越小:
在这里插入图片描述
那么这就是跳表的原理接下来我们来看看如何实现跳表。

跳表的模拟实现

准备工作

首先每个节点的中存在一个变量用来存储数据,然后还需要一堆指针来记录下一个节点的位置,指针的数量不清楚而且又不止一个所以我们可以创建一个vector对象来存储指针,那么这里我们就可以创建一个类来描述指针,该类的构造函数就需要两个参数一个参数表示节点的层数另外一个参数表示节点存储的数据,然后在构造函数里面就对数组进行初始化将每个指针都初始化为空,那么这里的代码如下:

template<class T>
struct ListNode
{
	typedef ListNode<T> Node;
	ListNode(T val,int size)
		:_nextV(size,nullptr)
		,_val(val)
	{}
	T _val;
	vector<Node*> _nextV;
};

然后在跳表类里面就存储一个指针变量用来记录头结点然后创建另个变量用来记录当前节点的最大层数和增加层数的概率,在构造函数里面将这个指针指向一个新创建的节点,节点的高度为1存储的值为元素的默认构造,并且后面的代码我们要用到随机数这个东西,所以在构造函数里面还得添加一个时间戳,那么这里的代码如下:

template<class T>
class Skiplist
{
public:
	typedef ListNode<T> Node;
	Skiplist()
	{
		srand(time(0));
		_head = new Node(T(), 1);
	}

private:
	Node* _head;
	int _maxLevel = 32;
	double _p = 0.5;
};

find函数

find函数是这个类的关键因为这里我们不仅要找到这个元素还得找到直线这个元素的其他节点,比如我们要查找元素21:
在这里插入图片描述
在节点21前面存在很多节点指向21,比如说9号节点17号节点19号节点这三个节点都指向节点21,那么find函数不仅要判断21是否存在还得返回指向21号节点的节点,之所以这么做是为了方便后面的insert函数和erase函数,但是这里存在一个问题我们知道了哪个节点指向21,但是不知道是这些节点中的哪个指针指向21?所以这个时候就得将数组中的下表结合起来,因为一个高度的指针只有一个指针指向某个节点,所以我们就可以用数组的下表来进行辅助判断,如果数组中下表为1的元素中记录的节点是9号节点的话就表明9号节点的中下表为1的指针指向了该节点,那么这样find函数不仅可以查询节点是否存在还可以找到哪些位置指向该节点,这样可以方便后面的插入函数和删除函数,那么首先这个函数需要一个T类型的参数,然后返回值是vector类型并且vector中的元素类型是Node*

vector<Node*> find(const T& target)
{}

然后在函数里面就可以创建一个vector,将其大小初始化为头结点的长度,然后创建一个变量level用来存储头结点中数组的个数,因为我们要不停比较节点中的值,所以还得创建一个Node类型的指针用来指向当前寻找的节点,然后就可以创建一个while循环,因为循环的目的是将每一个指向目标节点的节点都记录下来,所以循环结束的条件就是level大于等于0,那么这里代码如下:

vector<Node*> find(const T& target)
{
	int level = _head->_nextV.size()-1;//这里是下表所以要减一
	vector<Node*> tmp(level+1,_head);//这里要加一,并且每个元素都指向头结点
	Node* cur = _head;
	while (level >= 0)
	{
	}
}

循环里面我们就要做出判断:当前cur指向的节点的level层指针指向的元素是否小于target,如果小于就将cur指向level层指针指向的元素,如果大于target或者当前的指针指向的元素为空就说明找到了指向插入位置或者要删除元素的前一个节点,那么这个时候就将该节点的地址填入数组中的level下表然后将level的值减减即可,那么这里额度代码如下:

vector<Node*> find(const T& target)
{
	int level = _head->_nextV.size()-1;//这里是下表所以要减一
	vector<Node*> tmp(level+1,_head);//这里要加一,并且每个元素都指向头结点
	Node* cur = _head;
	while (level >= 0)
	{
		// 目标值比下一个节点值要大,向右走
		// 下一个节点是空(尾),目标值比下一个节点值要小,向下走
		if (cur->_nextV[level] != nullptr && cur->_nextV[level]->_val < target)
		{
			// 向右走
			cur = cur->_nextV[level];
		}
		else if (cur->_nextV[level] == nullptr || cur->_nextV[level]->_val >= target)
		{
			// 更新level层前一个
			tmp[level] = cur;
			// 向下走
			level--;
		}
	}
	return tmp;
}

当然这样的find函数还是太难用了,所以我们还是添加一个简化版的find函数,那么这里的思路是一样的我们就直接看代码:

bool search(T target) 
{
	Node* cur = _head;
	int level = _head->_nextV.size() - 1;
	while (level >= 0)
	{
		// 目标值比下一个节点值要大,向右走
		// 下一个节点是空(尾),目标值比下一个节点值要小,向下走
		if (cur->_nextV[level] && cur->_nextV[level]->_val < target)
		{
			// 向右走
			cur = cur->_nextV[level];
		}
		else if (cur->_nextV[level] == nullptr || cur->_nextV[level]->_val > target)
		{
			// 向下走
			--level;
		}
		else
		{
			return true;
		}
	}
	return false;
}

insert函数

当出现重复值时插入可能失败所以insert函数的返回值为bool,函数的参数为T类型

bool insert(T num)
{
}

那么在函数的开始就得创建一个数组用来接收find函数的返回值,然后我们得生成一个随机数用来记录数的高度,那么这里我们还得写一个函数用来随机创建树的高度,那么这里的代码如下:

int RandomLevel()
{
	size_t level = 1;
	// rand() ->[0, RAND_MAX]之间
	while (rand() <= RAND_MAX*_p && level < _maxLevel)
	{
		++level;
	}
	return level;
}

我们知道rand函数生成的数据大小是有范围的,那么我们就可以利用这个范围来生成随机高度,有了这个函数之后我们就可以得到高度,然后new出来节点并将节点的高度设为函数的返回值,那么这里的代码如下:

bool insert(T num)
{
	vector<Node*> prevV = FindPrevNode(num);
	int height = RandomLevel();
	Node* newnode = new Node(num, height);
}

因为插入一个节点之后,可能该节点的高度会刷新整个链表的高度,所以我们得进行判断如果新创建出来的节点高度大于头结点的高度就得对头节点和上面的prevV进行扩长,那么这里就得添加一个if语句:

bool insert(T num)
{
	vector<Node*> prevV = FindPrevNode(num);
	int height = RandomLevel();
	Node* newnode = new Node(num, height);
	if (height > _head->_nextV.size())
	{
		_head->_nextV.resize(height, nullptr);
		prevV.resize(height, _head);
	}
}

然后就要将prevV数组中的节点高度个元素的指针指向进行改变,让新插入的节点指向他们之前指向的节点,然后让这些节点指向新插入的节点,比如说下面的图片要插入元素15在这里插入图片描述
插入之后就变成这样:
在这里插入图片描述
而数组prevV中刚好记录所有指向该位置的节点,那么这里就可以创建一个while循环进行更改,那么完整的代码如下:

bool insert(T num)
{
	vector<Node*> prevV = find(num);
	int height = RandomLevel();
	Node* newnode = new Node(num, height);
	if (height > _head->_nextV.size())
	{
		_head->_nextV.resize(height, nullptr);
		prevV.resize(height, _head);
	}
	for (int i = 0; i < height; i++)
	{
		newnode->_nextV[i] = prevV[i]->_nextV[i];
		prevV[i]->_nextV[i] = newnode;
	}
}

erase函数

erase函数的实现思路也是一样的首先得判断一下当前要删除的元素是否存在如果不存在的话就直接返回false,如果存在的话先创建一个数组和find函数一起记录指向该节点的节点,然后跟insert函数一样修改节点中指针的指向,将指向该节点的指针指向该节点的下一个,那么这里的代码如下:

bool erase(T num)
{
	vector<Node*> prevV = FindPrevNode(num);
	// 第一层下一个不是val,val不在表中
	if (prevV[0]->_nextV[0] == nullptr || prevV[0]->_nextV[0]->_val != num)
	{
		return false;
	}
	else
	{
		Node* del = prevV[0]->_nextV[0];
		// del节点每一层的前后指针链接起来
		for (size_t i = 0; i < del->_nextV.size(); i++)
		{
			prevV[i]->_nextV[i] = del->_nextV[i];
		}
		delete del;
	}
}

但是这里存在一个问题,如果将最高节点删除了那头节点是不是也得进行变化呢?所以这里还得查看一下头结点中不为空指针的数量,然后将其长度进行缩小,那么完整的代码如下:

bool erase(T num)
{
	vector<Node*> prevV = FindPrevNode(num);
	// 第一层下一个不是val,val不在表中
	if (prevV[0]->_nextV[0] == nullptr || prevV[0]->_nextV[0]->_val != num)
	{
		return false;
	}
	else
	{
		Node* del = prevV[0]->_nextV[0];
		// del节点每一层的前后指针链接起来
		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;
}

测试

为了方便测试我们可以添加一个打印函数:

void Print()
{
	Node* cur = _head;
	while (cur)
	{
		printf("%2d\n", cur->_val);
		// 打印每个每个cur节点
		for (auto e : cur->_nextV)
		{
			printf("%2s", "↓");
		}
		printf("\n");
		cur = cur->_nextV[0];
	}
}

然后用下面的代码来进行测试:

int main()
{
	Skiplist<int> tmp;
	tmp.insert(11);
	tmp.insert(5);
	tmp.insert(6);
	tmp.insert(12);
	tmp.insert(2);
	tmp.insert(3);
	tmp.insert(7);
	tmp.insert(17);
	tmp.insert(19);
	cout << tmp.search(11) << endl;
	cout << tmp.search(20) << endl;
	tmp.erase(11);
	cout << tmp.search(11) << endl;
	cout << endl;
	tmp.Print();
	return 0;
}

代码的运行结果如下:
在这里插入图片描述
可以看到代码的运行结果符合我们的预期。

效率比较

  1. skiplist相比平衡搜索树(AVL树和红黑树)对比,都可以做到遍历数据有序,时间复杂度也差不多。skiplist的优势是:a、skiplist实现简单,容易控制。平衡树增删查改遍历都更复杂。b、skiplist的额外空间消耗更低。平衡树节点存储每个值有三叉链,平衡因子/颜色等消耗。skiplist中p=1/2时,每个节点所包含的平均指针数目为2;skiplist中p=1/4时,每个节点所包含的平均指针数目为1.33;
  2. skiplist相比哈希表而言,就没有那么大的优势了。相比而言a、哈希表平均时间复杂度是O(1),比skiplist快。b、哈希表空间消耗略多一点。skiplist优势如下:a、遍历数据有序b、skiplist空间消耗略小一点,哈希表存在链接指针和表空间消耗。c、哈希表扩容有性能损耗。d、哈希表再极端场景下哈希冲突高,效率下降厉害,需要红黑树补足接力。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/71680.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

opencv 基础50-图像轮廓学习03-Hu矩函数介绍及示例-cv2.HuMoments()

什么是Hu 矩&#xff1f; Hu 矩&#xff08;Hu Moments&#xff09;是由计算机视觉领域的科学家Ming-Kuei Hu于1962年提出的一种图像特征描述方法。这些矩是用于描述图像形状和几何特征的不变特征&#xff0c;具有平移、旋转和尺度不变性&#xff0c;适用于图像识别、匹配和形状…

如何在 .NET Core WebApi 中处理 MultipartFormDataContent 中的文件

问题描述# 上图示例展示了用户通过 IOS 客户端发送请求时&#xff0c;对应后端接口接收到的 Request 内容。从请求内容的整体结果&#xff0c;我们可以看出这是一个 multipart/form-data 的数据格式&#xff0c;由于这种数据是由多个 multipart section 组成&#xff0c;所以我…

uniapp input输入框placeholder文本右对齐

input输入框placeholder文本右对齐 给input标签加上placeholder-class&#xff0c;这个是给placeholder设置样式&#xff0c;右对齐这就是text-align:right;字体颜色之类依次编辑即可。

8.13树的总结(有新知识再更新)

二叉树题目几个重点&#xff1a; 1. 理解递归&#xff0c;优先掌握递归实现 递归三部曲&#xff1a;1.确定递归函数的参数和返回类型&#xff1b;2.确定终止条件 &#xff1b;3.确定递归逻辑 因为递归一层一层对我来说有点绕&#xff0c;主要感悟就是只针对某一个节点思考&…

arcgis更改图层字段名脚本

话不多说&#xff0c;上脚本源码&#xff0c;复制黏贴即可 #-*- coding:utf-8 -*- __author__ lumen import arcpy #输入图层 InputFeature arcpy.GetParameterAsText(0) #原始字段 oldField arcpy.GetParameterAsText(1) # 获取原始字段类型 oldFieldType desc arcpy.…

elasticsearch 基础

ES 搜索技术历史 今天看的是《Elasticsearch实战与原理解析》 第一章 搜索技术发展史 1、搜索技术发展史 宏观而言&#xff0c;搜索引擎的发展经历了五个尖端和两大分类。五个阶段分别是ftp文件检索阶段、分类目录阶段、文本相关性检索阶段、网页链接分析阶段和用户意图识别…

汽车上的电源模式详解

① 一般根据钥匙孔开关的位置来确定整车用电类别&#xff0c;汽车上电源可以分为常电&#xff0c;IG电&#xff0c;ACC电 1&#xff09;常电。常电表示蓄电池和发电机输出直接供电&#xff0c;即使点火开关在OFF档时&#xff0c;也有电量供应。一般来讲模块的记忆电源及需要在车…

Maven工程的安装配置及搭建(集成eclipse完成案例,保姆级教学)

目录 一.下载及安装及环境配置 1.下载及安装 2.环境变量的配置 3.检测是否安装成功 4.配置Maven 1.更换本地仓库 2. 配置镜像 二.集成eclipse完成案例 1.eclipse前期配置Maven 2.创建Maven工程 一.下载及安装及环境配置 1.下载及安装 下载地址&#xff1a;Maven – Down…

【算法|数组】手撕经典二分法

算法|数组——二分查找 文章目录 算法|数组——二分查找引言二分查找左闭右闭写法左闭右开写法 总结 引言 首先学习这个算法之前需要了解数组知识&#xff1a;数组。 大概介绍以下&#xff1a; 数组是存储在连续内存空间上的相同类型数据的集合。数组下标都是从0开始。数组在…

网络编程(JavaEE初阶系列10)

目录 前言&#xff1a; 1.网络编程的基础 1.1为什么需要网络编程 1.2什么是网络编程 1.3网络编程中的基本概念 1.3.1发送端和接收端 1.3.2请求和响应 1.3.3客户端和服务端 2.Socket套接字 2.1概念 2.2分类 3.UDP数据报套接字编程 3.1DataGramSocket API 3.2Datagr…

VR全景乡村旅游浇灭乡愁,近距离体验自然之美

说起乡愁&#xff0c;可能每位漂泊的游子都有所感受&#xff0c;在外漂泊数十载&#xff0c;每到佳节倍思亲&#xff0c;家乡的一草一木都浮现在脑海中&#xff0c;满载着儿时的回忆。为了留住那抹儿时回忆&#xff0c;VR全景助力数字化乡村建设。 乡村振兴是国家的重大战略&am…

内网横向移动—ARP攻击图片捕捉数据劫持DNS劫持

内网横向移动—ARP攻击&图片捕捉&数据劫持&DNS劫持 1. ARP1.1. APR介绍1.1.1. ARP工作原理1.1.2. APR欺骗工作原理 1.2. 环境准备1.3. 适用场景 2. ARP断网攻击演示2.1. 使用kali进行演示2.1.1. nmap判断存活2.1.2. 安装工具2.1.3. 攻击Windows 10虚拟机2.1.3.1. 查…

Ubuntu常用压缩指令总结

一、tar tar是Linux系统中最常用的压缩工具之一&#xff0c;它的一个优点是它可以保留文件的权限和所有权信息。tar可以创建.tar文件&#xff08;通常称为"tarball"&#xff09;&#xff0c;或者与gzip或bzip2等工具结合使用来创建.tar.gz或.tar.bz2文件。gzip工具的…

CSS:盒子模型 与 多种横向布局方法

目录 盒子模型块级盒子内联级盒子内联块级盒子弹性盒子display 改变模型区域划分text 内容区padding 填充区border 边框区margin 外边距直接设置盒子大小 布局横向布局方法一 float 浮起来方法二 内联块级元素实现方法三 弹性盒子模型 盒子模型 块级盒子 独占一行&#xff0c…

轻松转换TS视频为MP4,实现优质视频剪辑体验

如果你是一个视频剪辑爱好者&#xff0c;你一定会遇到各种视频格式之间的转换问题&#xff0c;特别是将TS视频转换为MP4格式。别担心&#xff0c;我们的视频剪辑软件将为你提供最简单、高效的解决方案&#xff01; 首先第一步&#xff0c;我们要进入媒体梦工厂主页面&#xff…

如何使用webpack打包一个库library,使用webpack打包sdk.

如何使用webpack打包一个库library 如果你需要自己封装一些包给别人使用,那么可以参考以下方法 初始化库 mkdir library cd library npm init -y经过以上步骤后会生成一个library文件夹&#xff0c;里面包含一个package.json文件。然后简单修改为如下所示&#xff1a; {&qu…

idea中提示Unsupported characters for the charset ‘ISO-8859-1‘

application.properties中文注释拉黄线 &#xff0c;提示Unsupported characters for the charset ISO-8859-1 解决办法&#xff1a; 注意&#xff1a; 改完之后之前输入的中文就变成“ &#xff1f;&#xff1f;&#xff1f;”了&#xff0c;建议备份一下 1、打开setti…

Unity C# 之 Http 获取网页的 html 数据,并去掉 html 格式等相关信息

Unity C# 之 Http 获取网页的 html 数据&#xff0c;并去掉 html 格式等相关信息 目录 Unity C# 之 Http 获取网页的 html 数据&#xff0c;并去掉 html 格式等相关信息 一、简单介绍 二、实现原理 三、注意事项 四、效果预览 五、关键代码 一、简单介绍 Unity中的一些知…

解决GitHub的速度很慢的几种方式

1. GitHub 镜像访问 这里提供两个最常用的镜像地址&#xff1a; https://hub.njuu.cf/search https://www.gitclone.com/gogs/search/clonesearch 也就是说上面的镜像就是一个克隆版的 GitHub&#xff0c;你可以访问上面的镜像网站&#xff0c;网站的内容跟 GitHub 是完整同步…

【变形金刚03】使用 Pytorch 开始构建transformer

一、说明 在本教程中&#xff0c;我们将使用 PyTorch 从头开始构建一个基本的转换器模型。Vaswani等人在论文“注意力是你所需要的一切”中引入的Transformer模型是一种深度学习架构&#xff0c;专为序列到序列任务而设计&#xff0c;例如机器翻译和文本摘要。它基于自我注意机…