【数据结构】长篇详解堆,堆的向上/向下调整算法,堆排序及TopK问题

news2024/10/6 5:59:52

文章目录

  • 堆的概念
      • 性质
      • 图解
  • 向上调整算法
      • 算法分析
      • 代码整体实现
  • 向下调整算法
      • 算法分析
      • 整体代码实现
  • 堆的接口实现
      • 初始化堆
      • 销毁堆
      • 插入元素
      • 删除元素
      • 打印元素
      • 判断是否为空
      • 取首元素
      • 实现堆
  • 堆排序
      • 创建堆
      • 调整堆
      • 整合步骤
  • TopK问题

堆的概念

堆就是将一组数据所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,并满足树中每一个父亲节点都要大于其子节点称为大堆(树中每一个父亲节点都要大于其子节点称为小堆)。

性质

①对于大堆(大根堆)来说,堆的顶部也就是数组首元素一定是最大的元素
②对于小堆(小根堆)来说,堆的顶部也就是数组首元素一定是最小的元素
(这两点对于下面的堆排序来说十分重要)

此外,堆总是一棵完全二叉树,因为堆本身就是二叉树的一种顺序存储结构的实现模式
注意的是这里的堆和操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段

图解

在这里插入图片描述

在这里插入图片描述
通过图再去对比上面的概念和性质,理解会更加清晰

所谓的存储结构也就数据在内存中真实的存储情况,在一维数组中
逻辑结构就是我们想象出来的,能够帮助我们理解并且通过这个也是根据二叉树中父节点和子节点之间的下标关系来确定的

①已知父亲节点求子节点

LeftChild = Parent * 2 + 1; //左孩子的节点下标
RightChild = Parent * 2 + 2; //右孩子的节点下标

②已知子节点求父节点

Parent = (Child - 1) / 2;  //切记是减1之后再除以2

向上调整算法

向上调整算法主要在堆的插入堆排序中应用最为广泛

算法分析

对于堆的插入,就是在数组的末尾进行数字的插入,并且在插入数据之后,我们仍要保证现有的结构仍然是一个!
在这里插入图片描述
如上图,是一个小堆

然后在数组的末尾插入了一个数字,即最后一个孩子节点,但是在插入之后,我们自身的堆结构发生了变化,所以我们必须对堆的结构进行调整.

不难发现,在最后插入一个数之后,其他子树仍然保持了小堆的性质(即父节点的值小于子节点),而正在需要调整的就是该子节点的’祖宗’这条线路,如上图红色箭头一步一步指向的位置,
而利用的公式就是Parent = (Child - 1) / 2;把新插入的数和它的父节点作比较,如果这个新插入的数小于于父节点,那么就和父节点交换位置

在向上调整代码中,我们需要传入的参数是数组和插入的那个子孩子的节点的下标

void AdjustUp(HeapDataType* a, int child)  //child是下标

在实际的不断向上调整中,我们需要用循环来实现代码,并且要合理的设置循环

while (child > 0)   //不能设置为parent >= 0 
	{				//Parent = (Child - 1) / 2, 通过这个公式因为parent永远都不可能小于零
		...
	}

时间复杂度 -------O(logN)

根据最坏情况来看(比如上图),数据多少层,我们就需要调整多少次,所以次数=高度h
再根据二叉树节点数量和高度的关系可知:
在这里插入图片描述
所以可以得到关系: 次数 = h = logN
所以时间复杂度就为:O(logN)

代码整体实现

算法既可以实现小堆也可以大堆,具体看你函数内部符号的控制

整体实现如下:

typedef struct HeapNode
{
	HeapDataType* a;
	int size;
	int capacity;
}HP;
void Swap(HeapDataType* p1, HeapDataType* p2)
{
	HeapDataType tmp = *p1;
	*p1 = *p2;
	*p2 = tmp;
}
//循环写法
void AdjustUp(HeapDataType* a, int child)  //child,parent是下标
{
	int parent = (child - 1) / 2;
	while (child > 0)
	{
		//小堆:判断子节点和父亲结点的大小
		if (a[child] < a[parent])
		//大堆:if (a[child] > a[parent])
		{
			Swap(&a[child], &a[parent]);//交换孩子和父亲
			child = parent;
			parent = (child - 1) / 2;
		}
		else
		{
			break;
		}
	}
}

//递归写法
void AdjustUp(HeapDataType* a, int child)
{
	int parent = (child - 1) / 2;
	if (child > 0)
	{
		//小堆:
		if (a[child] < a[parent])
		{
			Swap(&a[child], &a[parent]);
			child = parent;
			AdjustUp(a, child);  //递归
		}
		else
		{
			return;
		}
	}
	else
	{
		return;
	}
}

向下调整算法

向上调整算法主要在堆的数据删除堆排序中应用最为广泛

算法分析

在这里插入图片描述
对于上图根节点27来说,它的左右子树都是小堆,所以就需要将27不断向下调整,保证其整体还是一个小堆
由此可见,向下调整的前提是左右子树必须是堆
利用的公式就是
LeftChild = Parent * 2 + 1;
RightChild = Parent * 2 + 2;

在每一轮的调整中你都需要比较左右子节点的大小,比如上图就是对于27来说,15和17两个节点,15更小,所以就将15和27进行交换,然后对于19这个子树来说本身就是一个小堆,就可以不用管了,并且15本身也小于19,所以也符合小堆性质,然后继续对左边的子树进行如此的调整

在向下调整代码中,我们需要传入的参数是数组,数组大小和整棵树根节点的下标

void AdjustDown(HeapDataType* a, int size, int parent)

时间复杂度 -------O(logN)

根据最坏情况来看(比如上图),数据多少层,最坏的情况我们就需要向下调整多少次,所以次数=高度h, 再根据二叉树节点数量和高度的关系可知:
在这里插入图片描述
所以可以得到关系: 次数 = h = logN
所以时间复杂度就为:O(logN)

整体代码实现

typedef int HeapDataType;

typedef struct HeapNode
{
	HeapDataType* a;
	int size;
	int capacity;
}HP;
//转换
void Swap(HeapDataType* p1, HeapDataType* p2)
{
	HeapDataType tmp = *p1;
	*p1 = *p2;
	*p2 = tmp;
}
//循环写法
void AdjustDown(HeapDataType* a, int size, int parent)
{
	int child = parent * 2 + 1;
	while (child < size)//这里的child是左孩子下标,之所以不是child+1<size,是因为
	{			        //如果没有右孩子的话,这次循环将会终止,调整就会进行不彻底
		//小堆:
		if (child + 1 < size && a[child + 1] < a[child])
		{
			child++;
		}

		//小堆:
		if (a[child] < a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;//交换位置
		}
		else
		{
			break;
		}
	}
}
//递归写法
void AdjustDown(HeapDataType* a, int size, int parent)
{
	int child = parent * 2 + 1;
	if (child < size) 
	{
		//小堆:
		if (child + 1 < size && a[child + 1] < a[child])
		{
			child++; //如果右孩子小,那么下标就换成右孩子的下标
		}
		//小堆:
		if (a[child] < a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			AdjustDown(a, size, parent);  //递归
		}
		else
		{
			return;
		}
	}
	else
	{
		return;
	}
}

堆的接口实现

接下里,我将把堆的实现过程一步一步实现出来

初始化堆

void HeapInit(HP* hp)
{
	assert(hp);
	hp->a = NULL;
	hp->size = 0;
	hp->capacity = 0;
}

销毁堆

void HeapDestroy(HP* hp)
{
	assert(hp);

	free(hp->a);
	hp->a = NULL;
	hp->size = hp->capacity = 0;
}

插入元素

在尾部插入之后,要用AdjustUp函数向上调整形成堆

void HeapPush(HP* hp, HPDataType x)
{
	assert(hp);

	// 扩容
	if (hp->size == hp->capacity)
	{
		int new = hp->capacity == 0 ? 4 : hp->capacity * 2;
		HPDataType*tmp=(HPDataType*)realloc(hp->a, sizeof(HPDataType) * new);
		if (tmp == NULL)
		{
			perror("realloc fail");
			exit(-1);
		}

		hp->a = tmp;
		hp->capacity = new;
	}

	hp->a[hp->size] = x;
	hp->size++;

	AdjustUp(hp->a, hp->size - 1);//插入之后向上调整堆
}

删除元素

一般指删除首元素,至于为什么HeapPop是删除首元素
根本就是因为要弹出尾元素很简单,直接size–不就完了

void HeapPop(HP* php)
{
	assert(php);
	assert(php->size > 0);

	Swap(&php->a[0], &php->a[php->size - 1]);//首元素换到尾部来,然后再size--
	--php->size;

	AdjustDown(php->a, php->size, 0);//再用AdjustDown函数再来调整堆
}

打印元素

void HeapPrint(HP* php)
{
	assert(php);

	for (size_t i = 0; i < php->size; i++)
	{
		printf("%d ", php->a[i]);
	}
	printf("\n");
}

判断是否为空


bool HeapEmpty(HP* php)
{
	assert(php);
	return php->size == 0;
}

取首元素

HPDataType HeapTop(HP* php)
{
	assert(php);
	assert(php->size > 0);
	return php->a[0];
}

实现堆

int main()
{
	HP hp;
	HeapInit(&hp);
	int a[] = { 20, 11, 28, 31, 111, 52, 34, 16, 7, 9 };
	for (int i = 0; i < sizeof(a) / sizeof(int); i++)
	{
		HeapPush(&hp, a[i]);//插入
	}
	HeapPop(&hp);//把堆的首元素7删除删除
	HeapPrint(&hp);
	printf("堆顶元素:%d\n", HeapTop(&hp));
	HeapDestroy(&hp);
	return 0;
}

运行出来的结果:
在这里插入图片描述

堆排序

首先堆排有几个关键的步骤
①创建堆 ②调整堆

创建堆

创建堆的方式有两种①向上调整建堆 ②向下调整建堆

①首先我们来看第一种:向上调整建堆

这种方式的原理就是看作最开始堆中只有一个元素,从第一个元素开始就已经在向上调整,然后逐渐像堆中加入元素,随着一个一个元素的加入,也就形成了堆
图解如下:
在这里插入图片描述
而代码就是通过AdjustUp函数和一个for循环就可以完成上面步骤

//以前n个数建小堆
for (int i = 0; i < n; ++i)
{
	AdjustUp(a, i);  //a为数组的指针
}

时间复杂度: O(n*logn)

分析:首先我们上面详细分析了AdjustUp()的时间复杂度为O(logn),然后循环了n此每次建堆,所以两者相乘,时间复杂度也就是 n*logn


②我们来看第一种:向下调整建堆 -----堆排序中最主要用到的方法

这种建堆的关键就是从倒数第一个非叶子节点开始调(也就是树中最后一个父节点),然后逐渐+1,就可以调整从最后一个父节点开始的每一棵树.
不难发现这样也符合向下调整的前提,即左右子树都是堆
那么我们如何找到最后一个节点的父亲?
就需要用到公式:Parent = (Child - 1) / 2;
图解如下
在这里插入图片描述

而代码就是通过AdjustDown函数和一个for循环就可以完成上面步骤

for (int i = ((n-1)-1)/2; i >= 0; --i) 
//(n-1)是拿到树最后一个节点,然后再根据公式Parent = (Child - 1) / 2;
{
	AdjustDown(a, size, i);
}

时间复杂度:O(n)
根据下面的思路
在这里插入图片描述
在这里插入图片描述

因此建堆的时间复杂度为O(n)

总结😗:其实两种方式建堆之所以时间复杂度有差距,就是因为向下调整建堆可以看作忽略了最后一排的节点,直接从倒数第二排节点开始调整的,而在一棵满二叉树中最后一排的节点其实就占据了整棵树的二分之一,所以相当于向下调整比向上调整少经历了很多的节点
所以实际堆排序中我们更多的使用的是向下调整建堆,因此时间复杂度为O(n)

还有一点需要注意的是:如果你想要升序,即从小打大,需要建大堆.
建了大堆之后,再交换首元素(最大的)和末尾元素,然后把最大的元素不算入堆中的元素,
再进行向下调整
如果你建小堆,当你拿到首元素(最小的元素之后),需要将数组依次前移然后重新建堆,每次都前移然后每次都建堆,时间复杂度直接拉满!!!
同理 如果你想要降序,即从大打小,需要建小堆.


调整堆

在堆建好之后,就可以开始调整堆了,比如你是升序,即从小打大,需要建大堆.
建了大堆之后,循环N次 ,进行N次调整堆操作,每一次调整 堆得到的最大值,将此值和数组的最后一个元素进行交换,交换减小数组的长度(最后被减小的那几个值不参与堆的调整),直到最后一个元素,就完成了堆的排序.

如下图,降序—小堆, 展示了其中一个调整过程

在这里插入图片描述

整合步骤

综合建堆和调整,完整的堆排序代码就出来了

void HeapSort(int* a, int n)
{
	// 建堆 (大堆)or  (小堆)
	for (int i = 1; i < n; i++)
	{
		AdjustUp(a, i);
	}
int end = n - 1;
while (end > 0)
{
	Swap(&a[0], &a[end]);  //交换
	AdjustDown(a, end, 0); //向下调整
	--end;   //换下来的最后一个数不计入堆中
}

升序建大堆,降序建小堆很重要!

TopK问题

最后我们再来解决一个堆在实际应用中很重要的Topk问题

通常这是在数据很大的情况下才会使用到的,如世界前500强,全省高考前十等等…
因为如果数据很大,你不可能在内存中创建一个这么大的数组来装下这么多数据,所以就要用topk问题的思路
举个简单的例子:
比如你有1000个数据,你要找前100个大的数据,那么你先随便拿100个数据(无论其大小多少)建小堆,然后另外900个数据依次与堆顶的最小数据进行比较,比它大就替换,然后再调整堆,这样1000个数据都参与了对比,对比了900次,900个最小的被拿走,剩下的100个一定是最大的,再进行堆排序

接下来用文件传输数据的形式进行举例

void CreateNDate()
{
	// 造数据
	int n = 10000000;
	srand(time(0));
	const char* file = "data.txt";
	FILE* fin = fopen(file, "w");
	if (fin == NULL)
	{
		perror("fopen error");
		return;
	}

	for (int i = 0; i < n; ++i)
	{
		int x = (rand() + i) % 10000000;
		fprintf(fin, "%d\n", x);
	}

	fclose(fin);
}

void TestTopK(const char* filename, int k)
{
	// 1. 建堆--用a中前k个元素建堆
	FILE* fout = fopen(filename, "r");
	if (fout == NULL)
	{
		perror("fopen fail");
		return;
	}

	int* minheap = (int*)malloc(sizeof(int) * k);
	if (minheap == NULL)
	{
		perror("malloc fail");
		return;
	}

	for (int i = 0; i < k; i++)
	{
		fscanf(fout, "%d", &minheap[i]);
	}

	// 前k个数建小堆
	for (int i = (k-2)/2; i >=0 ; --i)
	{
		AdjustDown(minheap, k, i);
	}


	// 2. 将剩余n-k个元素依次与堆顶元素交换,不满则则替换
	int x = 0;
	while (fscanf(fout, "%d", &x) != EOF)
	{
		if (x > minheap[0])
		{
			// 替换你进堆
			minheap[0] = x;
			AdjustDown(minheap, k, 0);
		}
	}


	for (int i = 0; i < k; i++)
	{
		printf("%d ", minheap[i]);
	}
	printf("\n");

	fclose(fout);
}


int main()
{
	CreateNDate();
	TestTopK("data.txt", 5);
	return 0;
}

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

四川玖璨电子商务有限公司:新媒体视频运营

新媒体视频运营是随着互联网技术的发展而兴起的一种新型媒体运营方式。它通过制作、发布和推广优质视频内容&#xff0c;以吸引受众眼球&#xff0c;提升品牌知名度和影响力。四川玖璨电商小编在此次新媒体视频运营实验中&#xff0c;我们对新媒体视频运营的关键要素进行了探索…

大数据精准营销适合什么行业,面临哪些问题?

大数据营销适合的行业还是非常多的&#xff0c;毕竟现在大部分的企业都是通过网络方式来获得客户的&#xff0c;只要同领域有网站&#xff0c;而且他们存在竞价或者是优化&#xff0c;或者是使用了相关的软件&#xff0c;这种情况下都是完全可以运用的。比较常见的行业有金融行…

高端知识竞赛中选手台桌设计方案和配套软件

高端知识竞赛选手台桌用于知识竞赛现场选手台&#xff0c;集成了知识竞赛软硬件设备&#xff0c;包括台前计分一体机或LED屏、抢答按钮等&#xff0c;和天纵竞赛软件配合使用&#xff0c;实现高端竞赛的计分需要。 知识竞赛选手台桌前计分屏可以显示丰富的内容&#xff0c;包括…

(JavaEE)多线程带来的的风险-线程安全 (第一部)

前言&#xff1a;线程安全是整个多线程中&#xff0c;最为复杂的部分&#xff0c;也是最重要的部分。 目录 什么是线程安全问题&#xff1f; 线程不安全的原因 ⁜⁜总结 &#xff1a;线程安全问题的原因 ⁜⁜ 解决方法1 ——加锁 synchronized &#xff08;监视器锁monitor…

客户画像的作用是什么?

1、深度挖掘 客户画像包括客户的基本信息和个人行为数据&#xff0c;这些都可以作为企业成员和客户沟通的基础&#xff0c;且在此基础上可以更好地共情并深度挖掘客户真正想要什么。 2、分层精细运营 根据客户画像能充分了解客户的需求&#xff0c;再结合产品属性和企业文化…

【memmove函数的功能介绍及模拟实现】

memmove函数的功能介绍及模拟实现✍️ 1.memmove函数的功能介绍&#x1f9d0; 1.1函数结构&#x1f575;️ void * memmove ( void * destination, const void * source, size_t num );1.2 功能介绍&#x1f92f; 根据cplusplus网站上对memmove函数的介绍&#xff1a; 它的…

全栈测试平台RunnerGo你了解嘛

在当今这个数字化时代&#xff0c;应用程序的性能至关重要。一款可靠的性能测试工具&#xff0c;能够为企业带来无数的好处。最近&#xff0c;一款名为RunnerGo的开源性能测试工具备受瞩目。本文将详细介绍RunnerGo的特点、优势以及如何解决性能测试中的痛点。 RunnerGo产品介绍…

02-Redis持久化

上一篇&#xff1a;01-Redis核心数据结构与高性能原理 1.RDB快照&#xff08;snapshot&#xff09; 在默认情况下&#xff0c; Redis 将内存数据库快照保存在名字为 dump.rdb 的二进制文件中。 你可以对 Redis 进行设置&#xff0c; 让它在“ N 秒内数据集至少有 M 个改动”…

MemJam: A false Dependency attack against constant-time crypto implementations【存储缓存】

作者&#xff1a;A. Moghimi, J. Wichelmann, T. Eisenbarth, and B. Sunar. 发布&#xff1a;International Journal of Parallel Programming 时间&#xff1a;Aug 2019. 笔记&#xff1a; 缓存定时攻击 1、攻击原理 共享缓存存在定时侧信道的风险&#xff08;例如在处理…

C++信息学奥赛1168:大整数加法

#include <iostream> #include <string> #include <cstring> using namespace std;char ArrString[205], BrrString[205];// 自定义的取最大值函数 int max(int a, int b){if(a > b){return a;}else{return b;} }int main(){int len 0;cin >> ArrS…

C++11 新特性 ⑤ | 仿函数与 lambda 表达式

目录 1、引言 2、仿函数 3、lambda表达式 3.1、lambda表达式的一般形式 3.2、返回类型说明 3.3、捕获列表的规则 3.4、可以捕获哪些变量 3.5、lambda表达式给编程带来的便利 VC常用功能开发汇总&#xff08;专栏文章列表&#xff0c;欢迎订阅&#xff0c;持续更新...&a…

开源大模型ChatGLM2-6B 2. 跟着LangChain参考文档搭建LLM+知识库问答系统

0. 环境 租用了1台GPU服务器&#xff0c;系统 ubuntu20&#xff0c;Tesla V100-16GB &#xff08;GPU服务器已经关机结束租赁了&#xff09; SSH地址&#xff1a;* 端口&#xff1a;17520 SSH账户&#xff1a;root 密码&#xff1a;Jaere7pa 内网&#xff1a; 3389 &#xf…

【Linux】自制shell

本期我们利用之前学过的知识&#xff0c;写一个shell命令行程序 目录 一、初始代码 二、使用户输入的ls指令带有颜色分类 三、解决cd指令后用户所在路径不变化问题 3.1 chdir函数 四、关于环境变量的问题 一、初始代码 #include<stdio.h> #include<unistd.h…

第三节:在WORD为应用主窗口下关闭EXCEL的操作(2)

【分享成果&#xff0c;随喜正能量】凡事好坏&#xff0c;多半自作自受&#xff0c;既不是神为我们安排&#xff0c;也不是天意偏私袒护。业力之前&#xff0c;机会均等&#xff0c;毫无特殊例外&#xff1b;好坏与否&#xff0c;端看自己是否能应机把握&#xff0c;随缘得度。…

Pyspark案例综合(数据计算)

数据计算 map方法 map算子 map算子&#xff08;成员方法&#xff09;接受一个处理函数&#xff0c;可用lambda快速编写&#xff0c;对RDD内的元素一一处理&#xff0c;返回RDD对象 链式调用 对于返回值是新的RDD的算子&#xff0c;可以通过链式调用的方式多次调用算子 &q…

Windows Server操作系统概述

文章目录 一、计算机系统的层次结构二、五大基本功能1. 处理器管理2. 储蓄器管理3. 文件管理4. 设备管理5. 作业管理 三、应用场景四、发展历程1. Unix概述相关版本应用场景 2. Linux概述相关版本应用场景 3. windows概述普通版本服务器版本 首先可以看下思维导图&#xff0c;以…

Win10 NVIDIA Incompatible

Win10 NVIDIA 不兼容 https://www.nvidia.cn/Download/index.aspx?langcn https://www.nvidia.com/Download/Find.aspx?langen-us win10 version 1803

Python安装与环境变量配置傻瓜式教程(2023年9月)

给我家憨憨写的python教程 ——雁丘 Python的环境变量可以在安装包勾选自动配置&#xff0c;故相比Java相比简单不少 Python安装与环境变量配置傻瓜式教程&#xff08;2023年9月&#xff09; 一 Python的下载二 Python的安装三 手动配置环境变量四 检验环境变量 一 Python的下…

强化历程7-排序算法(2023.9.12)

此笔记学习图片来自于如下网址 1https://www.west999.com/info/html/chengxusheji/Javajishu/20190217/4612849.html 文章目录 强化历程7-排序算法1 冒泡排序(交换排序)2 选择排序3 直接插入排序4 希尔排序5 归并排序6 快速排序7 堆排序8 计数排序 强化历程7-排序算法 1 冒泡排…

《向量数据库指南》——向量数据库内核面临的技术挑战及应对措施

最近一年&#xff0c;以 ChatGPT、LLaMA 为代表的大语言模型的兴起&#xff0c;将向量数据库的发展推向了新的高度。 向量数据库是一种在机器学习和人工智能领域日益流行的新型数据库&#xff0c;它能够帮助支持基于神经网络而不是关键字的新型搜索引擎。向量数据库不同于传统的…