数据结构学习分享之堆的详解以及TopK问题

news2025/1/21 18:58:18

💓博主CSDN主页:杭电码农-NEO💓

⏩专栏分类:数据结构学习分享⏪

🚚代码仓库:NEO的学习日记🚚

🌹关注我🫵带你了解更多数据结构的知识
  🔝🔝


在这里插入图片描述


数据结构第七课

  • 1. 前言🚩
  • 2. 堆的概念以及结构🚩
  • 3. 堆的实现🚩
    • 3.1 初始化结构🏴
    • 3.2 初始化函数🏴
    • 3.3 插入函数🏴
    • 3.4 向上调整函数🏴
    • 3.5 删除函数🏴
    • 3.6 向下调整函数🏴
    • 3.7 其他函数🏴
  • 4. TopK问题🚩
    • 4.1 思路分析🏴
    • 4.2 代码实现🏴
    • 4.3 算法效率🏴
  • 5. 总结🚩


1. 前言🚩

本章就给大家带来久违的堆的知识,如果你还不知道数的相关知识,或者什么是完全二叉树,请跳转树的介绍,本章的堆结构需要树的知识做铺垫.数据结构中的堆结构本质上就是一种完全二叉树,我们上一章说完全二叉树适合用数组的结构来实现.

这一章的关键就是向下调整和向上调整!


2. 堆的概念以及结构🚩

如果有一个关键码的集合K = { , , ,…, },把它的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,并满足: <= 且 <= ( >= 且 >= ) i = 0,1,2…,则称为小堆(或大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。

堆的性质:

  • 堆中某个节点的值总是不大于或不小于其父节点的值;
  • 堆总是一棵完全二叉树。

画图理解,一个堆要么是大堆要么是小堆
在这里插入图片描述


3. 堆的实现🚩

这里我给大家带来的是小堆的实现,只要实现了小堆,小堆转换成大堆是很容易的,我们直接开始!

3.1 初始化结构🏴

我们前面说,堆这种结构用数组的形式来存储是比较好的,所以这里和顺序表有一些相似,也是动态的数组:

#include<stdio.h>
#include<stdlib.h>
#include<assert.h>//实现小堆

typedef int HPDataType;

typedef struct HeapNode
{
	HPDataType* a;//定义一个HPDataType类型的数组
	int size;//存储元素个数
	int capacity;//数组容量
}HN;

3.2 初始化函数🏴

这里和顺序表一样,这里不做过多解释,不太理解的朋友可以跳转顺序表讲解

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

3.3 插入函数🏴

有意思的地方来了,我们说堆实际上是一个完全二叉树,所以我们插入数据以后,它也要是一个完全二叉树,所以这里在逻辑结构上,插入的元素应该插入的位置是在:

在这里插入图片描述


当我们知道了要插入数据实际上就是物理存储的数组中的最后一个位置的下一个位置,我们就可以来实现这段代码了:

void HeapPush(HN* hp, HPDataType x)
{
	assert(hp);
	if (hp->size == hp->capacity)//判断是否需要扩容,和顺序表一样
	{
		int newcapacity = hp->capacity == 0 ? 4 : hp->capacity * 2;
		HPDataType* newnode = (HPDataType*)realloc(hp->a,sizeof(HPDataType) * newcapacity);
		if (newnode == NULL)
		{
			printf("realloc fail\n");
			exit(-1);
		}

		hp->a = newnode;
		hp->capacity = newcapacity;
	}
	hp->a[hp->size] = x;
	hp->size++;
}

这段代码和顺序表的尾插没有什么区别,所以我就不再解释这段代码.

但是这段代码并不正确,因为它插入到末尾后有可能不符合我们小堆的概念,所以还需要下一步:向上调整!


3.4 向上调整函数🏴

我们刚刚写好的尾插函数只是将数据插入进去了,但是要满足我们的小堆结构的话,需要父亲比两个儿子都小,但是我们刚刚插入进入的数据三比5还要小,甚至比4还要小,所以这个地方就需要向上调整我们的数据,这个地方向上调整的路径是这样的:

在这里插入图片描述

即儿子和父亲比较,如果儿子比父亲小,那么将父亲和儿子的值交换,依次往上走直到父亲比儿子大或者走到根节点的时候停止

并且我们在了解了二叉树的结构时学习到了一个公式,那就是:

  • 父亲下标×2+1=左孩子的下标,父亲小标×2+2=右孩子下标
  • 不管是左孩子还是右孩子,孩子下标÷2=父亲下标

有了基本思路我们直接上手:

void AdjustUp(int* a, int child)//小堆向上调整判断为a[child]>a[parent]
{
	int parent = (child - 1) / 2;
	while (child > 0)
	{
		if (a[child] < a[parent])
		{
			swap(&a[child], &a[parent]);//交换它们两个的值
			child = parent;//再将parent的下标给child,依次往后走!
			parent = (child - 1) / 2;
		}
		else//不满足情况就跳出循环
		{
			break;
		}
	}
}

所以我们上面的尾插代码插入末尾数据后其实还远远不够,还需要向上调整!,这里我们修改尾插函数为:

void HeapPush(HN* hp, HPDataType x)
{
	assert(hp);
	if (hp->size == hp->capacity)
	{
		int newcapacity = hp->capacity == 0 ? 4 : hp->capacity * 2;
		HPDataType* newnode = (HPDataType*)realloc(hp->a,sizeof(HPDataType) * newcapacity);
		if (newnode == NULL)
		{
			printf("realloc fail\n");
			exit(-1);
		}

		hp->a = newnode;
		hp->capacity = newcapacity;
	}
	hp->a[hp->size] = x;
	hp->size++;
	AdjustUp(hp->a, hp->size - 1);//把向上调整加上就完美了
}

调整过程为:

在这里插入图片描述这里我们的插入就完美结束了!


3.5 删除函数🏴

删除堆是删除堆顶的数据,这里使用的方法是将堆顶的数据根最后一个数据一换,然后删除数组最后一个数据,再进行向下调整算法,画图理解:

在这里插入图片描述


有了这种思路,完美现在来实现代码:

void HeapPop(HN* hp)
{
	assert(hp);
	assert(hp->a);
	swap(&hp->a[hp->size - 1], &hp->a[0]);
	hp->size--;
	AdjustDown(hp->a, hp->size, 0);//向下调整函数
}

3.6 向下调整函数🏴

我们根据上面的思路来边写代码边解释:

void AdjustDown(int* a, int n, int parent)//小堆需要找到两个孩子中较小的内个,并且child小于parent就交换.
{
	int child = parent * 2 + 1;//先默认为左孩子
	while (child < n)
	{
		if (a[child+1] < a[child] && child + 1 < n)//找到两个孩子中最小的内个
		{
			child++;//这里我们将child默认为左孩子,但是如果右孩子小于左孩子,我们就将child+1指向右孩子
		}
		if (a[child] < a[parent])
		{
			swap(&a[child], &a[parent]);//交换两个值
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			break;
		}

	}
}

这里设计比较巧妙的点是,我们先默认左孩子是较小的孩子节点,然后再去判断左右孩子哪个小,如果右孩子比左孩子小就将child+1指向右孩子,反之我们就不进入 i f 语句,child就是我们默认的左孩子


3.7 其他函数🏴

将我们最难理解并且也是最重要的向上调整和向下调整函数讲解了过后,剩下的就是一些歪瓜裂枣了,非常容易实现:

  • 取堆顶元素
HPDataType HeapTop(HN* hp)
{
	assert(hp);
	assert(hp->a);
	return hp->a[0];
}
  • 堆的销毁
void HeapDestroy(HN* hp)
{
	assert(hp);
	free(hp->a);
	hp->size = hp->capacity = 0;
}
  • 判断堆是否为空
bool HeapEmpty(HN* hp)
{
	assert(hp);
	return hp->size == 0;//为空就返回true
}

4. TopK问题🚩

TOP-K问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。
比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。
对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能数据都不能一下子全部加载到内存中)。


4.1 思路分析🏴

最佳的方式就是用堆来解决,基本思路如下:

1. 用数据集合中前K个元素来建立堆:

  • 前k个最大的元素,则建小堆
  • 前k个最小的元素,则建大堆

2.用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素

3. 将剩余N-K个元素依次与堆顶元素比完之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素。


这里可能有人有疑问,为什么排前k个最大的数不建立大堆?要建立小堆.这是因为我们希望我们建立的K个元素的堆中就直接存放前K个最大的元素.建立的小堆的堆顶元素是这K个数中最小的元素,当N-K中其他元素大于堆顶元素时,就可以插入到堆中,然而如果我们建立大堆的话,假如100和90和80都是前K个大的数,当90在堆中时,80就不能入堆了,因为90大于80,堆顶元素肯定大于或者等于90.然而当100在堆中时,90肯定也不能入堆了 所以建立大堆是行不通的


画图理解:

在这里插入图片描述事实上这里的77 19 20就是这十个数中前三大的!


4.2 代码实现🏴

这里我们有了思路后可以实现一下代码:

void TestTopk()//topk问题,找出n个数中的前k个最大数
{
	int n = 10000;
	int* a = (int*)malloc(sizeof(int) * n);
	srand(time(0));
	for (size_t i = 0; i < n; ++i)
	{
		a[i] = rand() % 1000000;//生成一百万以内的随机数
	}
	a[5] = 1000000 + 1;
	a[1231] = 1000000 + 2;
	a[531] = 1000000 + 3;
	a[5121] = 1000000 + 4;
	a[115] = 1000000 + 5;
	a[2335] = 1000000 + 6;
	a[9999] = 1000000 + 7;
	a[76] = 1000000 + 8;
	a[423] = 1000000 + 9;
	a[3144] = 1000000 + 10;
	PrintTopK( a, n, 10);
}

这里我们先随机生成一万个一百万以内的数字,然后再在10个随机位置下标给上大于一百万的数.这样我们自己设计的十个数肯定就是这一万个数中的前10个最大的数 然后我们再调用TopK函数来实现它:

void PrintTopK(int* a, int n, int k)
{
	HN hp;//创建并初始化堆
	HeapInit(&hp);
	//第一步:创建一个k个数的小堆
	for (int i = 0; i < k; i++)//将数组前10个数建立为小堆
	{
		HeapPush(&hp, a[i]);
	}
	//第二步:剩下的n-k个数和堆顶的数据比较,比堆顶大就替换它
	for (int i = k; i < n; i++)
	{
		if (a[i] > HeapTop(&hp))
		{
			HeapPop(&hp);
			HeapPush(&hp, a[i]);//这里直接调用Push函数就不要再写向下调整了,因为此函数中以及包含了向下调整
			//hp.a[0] = a[i];//如果是这种写法就要加上向下调整
			//AdjustDown(hp.a, hp.size, 0);
		}
	}
	HeapPrint(&hp);
	HeapDestroy(&hp);
}

大家可以自己去写一下,最后会发现,打印的数据就是我们设计好的数据!

这里我们就把大名鼎鼎的TopK问题给解决了!


4.3 算法效率🏴

先给结论:TopK问题用堆结构解决的时间复杂度为O(K+(N-K)log2k)

首先,建立小堆的时间复杂度为O(K),再将剩余元素与堆顶元素进行比较,一共要比较(N-K)次,而每一次比较替换堆顶元素后,需要向下调整,最坏的情况是调整到最下面的叶子节点,所以最坏调整数的高度次,然而数的高度等于log2K,这里的K代表堆中元素个数,所以综上所述:TopK问题的解决使用到的时间复杂度为O(K+(N-K)log2k)

并且在现实运用中,N总是远远大于K的,所以这个地方的时间复杂度在一些情况下可以接近O(N)!这个效率可想而知是很优的!


5. 总结🚩

堆这个地方的知识还远远没有结束,这只是冰山一角,还有非常经典的堆排序不放在这里讲解,我后面会专门出一个专栏来讲解插入排序,希尔排序,堆排序等等排序方式.

其实堆主要就围绕向上调整和向下调整这两个步骤讲解,其他的思路和顺序表大相径庭.我们这一章实现的是小堆,如果你想要实现大堆也很简单,只需要在向上调整中,和向下调整中修改几个大小于符号就可以实现大堆.比如在向上调整中,大堆应该是儿子大于父亲才交换,在向下调整中,要选取两个孩子中较大的孩子…这个就留给那么自己实现了


💕 我的码云:gitee-杭电码农-NEO💕

🔎 下期预告:链式二叉树详解 🔍

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

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

相关文章

未来已来,时代颠覆者ChatGPT你真的了解吗?

文章目录 什么是ChatGPTchatgpt与自然语言处理从gpt1.0到chatgpt&#xff0c;经历了什么chatgpt是一个语言模型chatgpt是如何处理文字输入的写在最后 什么是ChatGPT ChatGPT是美国OpenAI研发的聊天机器人程序&#xff0c;2022年11月30日发布。ChatGPT是人工智能技术驱动的自然语…

网络基础知识(4)——建立与关闭连接

建立 TCP 连接&#xff1a;三次握手 前面我们提到过&#xff0c;TCP 协议是一个面向连接的协议&#xff0c;双方在进行网络通信之间&#xff0c;都必须先在双方之间建立一条连接&#xff0c;俗称“握手”&#xff0c;可能在学习网络编程之前&#xff0c;大家或多或少都听过“…

SpringSecurity安全权限框架及其原理

1. 基础使用 首先创建最基本的SpringBoot项目&#xff0c;默认都会。主要是引入依赖和创建Controller进行测试。 <?xml version"1.0" encoding"UTF-8"?> <project xmlns"http://maven.apache.org/POM/4.0.0"xmlns:xsi"http://w…

十五天MySQL学习计划(运维篇-完结)读写分离-第十五天

十五天MySQL学习计划&#xff08;运维篇-完结&#xff09;读写分离-第十五天 读写分离 1.读写分离 ​ 读写分离&#xff0c;简单的说是把对数据库的读和写操作分开&#xff0c;以对应不同的数据库服务器。主服务器提供写操作&#xff0c;从数据库提供读操作&#xff0c;这样…

linux0.12-8-10-sys.c

[369页] 1、 这个文件需要配合其他文件一起看才能明白函数的作用&#xff1b; 2、 进程ID和进程组ID是描述进程之间的关系&#xff0c;与用户ID和组ID等其他无关系&#xff1b; 3、 用户ID和组ID和文件&#xff08;程序&#xff09;属性有关&#xff0c;当进程执行&#xff08;…

首发!车联网前装搭载率破70%,本土供应商抢下半壁江山

对于汽车智能化来说&#xff0c;网联化是相辅相成的角色&#xff0c;从传统3G、4G到5G的进化&#xff0c;提升座舱信息娱乐的体验&#xff1b;到C-V2X落地为辅助驾驶及自动驾驶提供冗余感知。 同时&#xff0c;车联网的普及&#xff0c;也进一步驱动互联网生态内容、服务以及类…

【AUTOSAR】【以太网】SoAd

目录 一、概述 二、限制与约束 三、依赖模块 5.1 TCPIP模块 5.2 通用上层 四、功能描述 4.1 套接字连接 4.2 PDU传输 4.3 PDU Header option 4.4 PDU 接收 4.5 最佳匹配算法 4.6 消息接受策略 4.7 TP PDU取消 4.8 路由组 4.9 PDU fan-out 五、API接口 5.1 API…

SpringBoot实现登录拦截的实现

对于管理系统或其他需要用户登录的系统&#xff0c;登录验证都是必不可少的环节&#xff0c;在SpringBoot开发的项目中&#xff0c;通过实现拦截器来实现用户登录拦截并验证。 1、SpringBoot实现登录拦截的原理 SpringBoot通过实现HandlerInterceptor接口实现拦截器&#xff…

如何完成GNSS接收器的定时校准

背景 GNSS以其提供亚米级精度定位的能力而闻名。然而鲜为人知的是&#xff0c;GNSS还提供了一种非常便捷的方法&#xff0c;可以通过GNSS接收器获得纳秒&#xff08;甚至亚纳秒&#xff09;的定时精度。事实上&#xff0c;除了三个空间维度之外&#xff0c;GNSS还使用户能够计…

iPhone照片导入电脑的图文教程,批量上传的3个方法!

案例&#xff1a;苹果手机照片怎么批量上传到电脑&#xff1f; 【友友们&#xff0c;手机照片太多&#xff0c;占用了我很多内存。想要把照片上传批量上传到电脑上进行保存&#xff0c;该怎么做&#xff1f;】 随着iPhone的普及和摄影功能的提升&#xff0c;越来越多的用户希望…

Rave Reports v2022 for Delphi 7-11

Rave Reports v2022 for Delphi 7-11 Rave Reports是来自Nevrona的一组公司&#xff0c;从Delphi和CBuilder中的数据库中报告进度。您可以使用专用工具轻松设计自己的报告。如果您需要将报告更改为您的用户&#xff0c;例如要发布的订单报告&#xff0c;此工具也提供了此功能&a…

Java | 一分钟掌握定时任务 | 3 - 单机定时之Timer

作者&#xff1a;Mars酱 声明&#xff1a;本文章由Mars酱原创&#xff0c;部分内容来源于网络&#xff0c;如有疑问请联系本人。 转载&#xff1a;欢迎转载&#xff0c;转载前先请联系我&#xff01; 介绍 这个是个JDK远古时代的api了&#xff0c;据考证&#xff0c;可以追溯到…

CSS小技巧之圆形虚线边框

虚线相信大家日常都用的比较多&#xff0c;常见的用法就是使用 border-style 控制不同的样式&#xff0c;比如设置如下边框代码&#xff1a; border-style: dotted dashed solid double;这将设置顶部的边框样式为点状&#xff0c;右边的边框样式为虚线&#xff0c;底部的边框样…

视频采集到录制 - 采集到显示碰到一些难点

项目中用到相机后端处理&#xff0c;走了一些弯路&#xff0c;也遇到不少问题&#xff08;解决了不少问题&#xff09;&#xff0c;特意写下本文记录下当时点点滴滴。 讲一下背景&#xff0c;公司自研相机&#xff0c;用于一些高端场合&#xff0c;因此对后端处理也非常讲究 …

网络基本知识分享

目录 1.IP地址 2.端口号 3.协议 4.协议分层 5.Tcp/Ip五层网络模型 5.1 应用层 5.2 传输层 5.3 网络层 5.4 数据链路层 5.5 物理层 6.封装和分用 6.1 封装 6.1.1 应用层拿到数据 6.1.2 向下传递给传输层 6.1.3 继续向下传递给网络层 6.1.4 继续向下传递给数据链…

【自制视频课程】C++OpnecV基础35讲——第一章 前言

为什么要学习OpenCV&#xff1f; 首先&#xff0c;opencv是一个广泛使用的计算机视觉库&#xff0c;它提供了丰富的图像处理和计算机视觉算法&#xff0c;可以帮助我们快速地开发出高质量的图像处理应用程序&#xff1b; 其次&#xff0c;opencv是一个开源库&#xff0c;可以免…

Spark大数据处理讲课笔记4.3 Spark SQL数据源 - Parquet文件

文章目录 零、本讲学习目标一、Parquet概述二、读取和写入Parquet的方法&#xff08;一&#xff09;利用parquet()方法读取parquet文件1、读取parquet文件2、显示数据帧内容 &#xff08;二&#xff09;利用parquet()方法写入parquet文件1、写入parquet文件2、查看生成的parque…

零入门kubernetes网络实战-32->基于路由技术+brigde+veth pair形成的跨主机通信方案

《零入门kubernetes网络实战》视频专栏地址 https://www.ixigua.com/7193641905282875942 本篇文章视频地址(稍后上传) 本文主要使用的技术是 路由技术Linux虚拟网桥虚拟网络设备veth pair来实现跨主机通信 该方案是flannel网络方案中的host-gw网络模型的基础。 1、总结 本…

化制为智,驭数前行 | 如何把握油气装备领域智能制造的未来?

01「智」赋未来&#xff0c;油燃而升 2015年&#xff0c;我国提出了“中国制造2025”规划&#xff0c;把智能制造作为两化深度融合的主攻方向&#xff0c;智能制造产业链所蕴藏的巨大投资机会将逐渐被市场挖掘。作为国家战略的基础&#xff0c;油气工程装备&#xff0c;特别是…

C++ 基础STL-list容器

STL-list 容器&#xff0c;又称双向链表容器&#xff0c;即该容器的底层是以双向链表的形式实现的。这意味着&#xff0c;list 容器中的元素可以分散存储在内存空间里&#xff0c;而不是必须存储在一整块连续的内存空间中。 链表的优点&#xff1a;可以对任意位置进行快速插入和…