数据结构堆详解:原理、实现与应用

个人专栏:《数据结构-初阶》《经典OJ题目》《C语言》

欢迎大佬交流!

一、堆的概念及结构

1、堆的基本概念

堆是一种特殊的完全二叉树,满足以下性质:

  • 每个节点的值都大于等于(或小于等于)其子节点的值。
  • 堆总是一棵完全二叉树,即除了最后一层,其他层都是满的,且最后一层的节点尽可能靠左排列。

堆分为两种类型:

  • 最大堆:每个节点的值都大于等于其子节点的值,根节点是最大值。
  • 最小堆:每个节点的值都小于等于其子节点的值,根节点是最小值。

2、堆的结构

堆通常通过数组实现,利用完全二叉树的性质进行存储:

  • 对于数组中索引为 i 的节点:
    • 父节点索引:(i - 1) / 2
    • 左子节点索引:2 * i + 1
    • 右子节点索引:2 * i + 2

因此我们采用动态数组的方式实现,同时定义出 capacity 和 size !

默认实现大根堆!

二、代码实现

0、初始化

和之前一样,我们先创建出Heap.c、Heap.h、test.c 三个文件;

同时在 Heap.h 文件中进行初始化工作

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <stdbool.h>

//动态数组实现堆
typedef int HPDataType;

typedef struct Heap
{
	HPDataType* a;
	int capacity;
	int size;
}HP;

1、初始化

分析:

将结构体中指针置为空,capacity 和 size 置为0 即可

cpp 复制代码
//初始化
void HPInit(HP* php)
{
	assert(php);
	php->a = NULL;
	php->capacity = php->size = 0;
}

2、堆的插入

a、分析:

由于是动态数组实现的,因此在查插入之前要先判断空间够不够!

如果将 x 直接插入堆,大概率会影响堆的合法性,因此当插入 x 之后,要进行调整!

该怎样调整呢?我们通过一个例子来分析(默认以大根堆为例)

把 x 插入后,显然破坏了堆的结构;

根据堆的定义,我们会先和 7 的 父节点比较,发现 7 比 5 大,因此交换位置,而此时仍然不是合法堆,我们会再次和 7 的父节点比较,7 比 6 大,继续交换,当 7 到堆顶时,调整完毕!

我们称这种调整算法为 向上调整算法 !

b、向上调整算法

根据例子中的逻辑,我们插入 x 之后,要和其父节点的比较,如果比父节点大,就和父节点进行交换!否则直接跳出循环

接着向上走,即更新孩子节点和父节点;

什么时候循环结束呢?

当孩子节点走到堆顶即 child = 0 时,此时调整完毕!

cpp 复制代码
void Swap(HPDataType* x, HPDataType* y)
{
	HPDataType tmp = *x;
	*x = *y;
	*y = tmp;
}

//向上调整算法
void AdjustUp(HPDataType* a, int child)
{
	int parent = (child - 1) / 2;
	while (child > 0)
	{
		if (a[child] > a[parent])
		{
			Swap(&a[child], &a[parent]);
			//更新节点
			child = parent;
			parent = (child - 1) / 2;
		}
		else break;
	}
}

为什么我们选择只是传入一个指针,而不是结构体呢?

因此在后面的堆排序中便能体现出这种方式的优势

c、代码

cpp 复制代码
//堆的插入
void HPPush(HP* php, HPDataType x)
{
	assert(php);
	//判断空间够不够
	if (php->capacity == php->size)
	{
		//扩容
		int newcapacity = php->capacity == 0 ? 4 : 2 * php->capacity;
		HPDataType* tmp = (HPDataType*)realloc(php->a, sizeof(HPDataType) * newcapacity);
		if (tmp == NULL)
		{
			perror("realloc failed!\n");
			return;
		}
		php->a = tmp;
		php->capacity = newcapacity;
	}

	//插入x
	php->a[php->size] = x;
	//size++
	php->size++;

	//向上调整算法
	AdjustUp(php->a, php->size - 1);
}

3、堆的删除

a、分析

删除操作是指删除堆顶元素

如果我们直接删除堆顶元素,即将数组中从下标1开始到最后的所有元素均向前挪动一位;

这样不仅会让堆原本的父子关系全部错乱,同时也会导致效率低下的问题,难以调整!

有没有更好的解决办法?

我们不妨先将堆顶元素和末尾元素进行交换,接着删除末尾元素,此时再进行调整;

这种方法不仅最小程度破坏原有堆的结构,同时不需要大量数据的挪动,提高了效率

那么在这种情况下,我们该怎样调整呢,依旧通过一个例子来说明

接着 5 到底 是和 3 交换,还是和 6 交换呢?

由于是大根堆,肯定要和最大的孩子交换,即和 6 交换

此时已经符合大根堆的特点,无需再调整;

我们称这种调整算法为向下调整算法

b、向下调整算法

根据例子中的逻辑,首先和最后一个节点交换位置,接着删除最后一个节点后开始对堆顶元素进行调整;

堆顶元素即为父节点,首先判断孩子找到最大的那个孩子节点,接着判断 孩子节点 是否 大于 父节点,如果是就交换,否则就直接跳出循环;

接着交换之后要向下走,即更新父节点和孩子节点

循环结束呢?

当 child 走到数组中最后一个位置时,调整完毕!

cpp 复制代码
//向下调整算法
void AdjustDown(HPDataType* a, int n, int parent)
{
	int child = parent * 2 + 1;
	while (child < n)
	{
		//找最大孩子
		if (child + 1 < n && a[child] < a[child + 1]) child++;
		if (a[child] > a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else break;
	}
}

c、代码

cpp 复制代码
//堆的删除
void HPPop(HP* php)
{
	assert(php);
	assert(php->size > 0);
	//1.交换
	Swap(&php->a[0], &php->a[php->size - 1]);
	//2.删除最后一个元素
	php->size--;
	//3.向下调整
	AdjustDown(php->a, php->size, 0);
}

4、获取堆顶元素

分析:

先断言,保证堆中有元素!

接着返回堆顶元素即可

cpp 复制代码
//获取堆顶元素
HPDataType HPTop(HP* php)
{
	assert(php);
	assert(php->size > 0);

	return php->a[0];
}

5、获取堆的元素个数

分析:

先断言,接着返回 size 即可

cpp 复制代码
//获取堆的元素个数
int HPSize(HP* php)
{
	assert(php);
	return php->size;
}

6、判断堆是否为空

分析:

先断言,接着返回 size == 0 的结果

cpp 复制代码
//判断堆是否为空
bool HPEmpty(HP* php)
{
	assert(php);

	return php->size == 0;
}

7、销毁堆

分析:

先断言,接着释放动态数组的空间,并将 size 和 capacity 置为0;

cpp 复制代码
//销毁堆
void HPDestroy(HP* php)
{
	assert(php);
	free(php->a);
	php->a = NULL;
	php->capacity = php->size = 0;
}

三、测试代码

cpp 复制代码
#include "Heap.h"

int main()
{
	HP hp;

	HPInit(&hp);

	HPPush(&hp, 1);
	HPPush(&hp, 2);
	HPPush(&hp, 3);
	HPPush(&hp, 4);
	HPPush(&hp, 5);
	HPPush(&hp, 6);
	HPPush(&hp, 7);

	printf("堆的元素个数:%d\n", HPSize(&hp));

	while (!HPEmpty(&hp))
	{
		printf("%d ", HPTop(&hp));
		HPPop(&hp);
	}

	HPDestroy(&hp);

	return 0;
}

四、堆排序

1、堆排序的定义

堆排序是一种基于二叉堆数据结构的比较排序算法;

它将待排序的序列构建成一个堆,通过反复调整堆结构实现排序;

堆排序分为两个阶段:建堆排序

2、建堆

建堆(Heap Construction)是指将一个无序的数组或列表通过特定操作调整为满足堆性质的数据结构的过程;

堆是一种特殊的完全二叉树,分为大根堆和小根堆两种类型。

那到底该怎样建堆呢?

我们通过模拟一个例子来判断

这种情况下,我们该怎样建堆呢?

思路一:从下标为1开始进行执行向上调整算法

给出两种图示,供小伙伴们理解

图一:

图二:

思路二:从倒数第一个非叶节点开始执行向下调整算法

同样采用图示法理解

从倒数第一个非叶节点开始调整

理解之后我们会发现两者建堆的效率有所不同,下面来分析时间复杂度

由于堆是一个完全二叉树,满二叉树是特殊的完全二叉树;

因此分析时间复杂度最坏的情况就是满二叉树

我们以满二叉树为例进行分析

a、思路一时间复杂度

b、思路二时间复杂度

总结:

我们发现思路二建堆的效率更优,因此我们采用向下调整的建堆方式!

cpp 复制代码
void HeapSort(int* a, int n)
{
	//建堆
	//思路一
	//for (int i = 1; i < n; i++)
	//{
	//	AdjustUp(a, i);
	//}
	//思路二
	for (int i = (n - 1 - 1) / 2; i >= 0; i--)
	{
		AdjustDown(a, n, i);
	}

}

3、排序

那么升序和降序分别建哪种堆呢?

先给出结论:升序建大堆,降序建小堆!

分析:

如果升序建成小堆,建好堆后的堆顶元素是最小的,接下来该怎么找倒数第二小呢?

能直接以下标为1的元素为堆顶元素吗?

这样势必会破坏堆中原有关系,想要采取这种方法,只能再次建堆,代价太大!

因此,升序我们建大堆,降序建小堆

排序逻辑:

我们借助堆删除的思想,由于我们想要的是升序的结果,建的是大根堆;

因此,当建好大根堆之后,先把堆顶元素与末尾元素进行交换,这样就得到了最大的元素;

同时控制边界条件,接着执行一次向下调整算法,这样就将第二大的元素放到了堆顶,重复上述操作即可;

cpp 复制代码
void HeapSort(int* a, int n)
{
	//建堆
	//思路一
	//for (int i = 1; i < n; i++)
	//{
	//	AdjustUp(a, i);
	//}
	//思路二
	for (int i = (n - 1 - 1) / 2; i >= 0; i--)
	{
		AdjustDown(a, n, i);
	}

	//排序
	int end = n - 1;
	while (end > 0)
	{
		Swap(&a[0], &a[end]);
		AdjustDown(a, end, 0);
		end--;
	}
}

4、堆排的应用--TopK

定义:Topk 问题指从一组数据中找出前 k 个最大或最小的元素。

假设我们要求出世界20强公司

分析:

最简单的方法就是直接对所有公司进行排序,取前500个元素即可

这显然不太可能,数据量太大了,如果使用排序会极其浪费空间!

假如我们建堆呢?

如果建大根堆,仍然是将所有元素全部插入到堆中;

建堆完成后,取出堆顶元素即最大的元素之后,怎样找第二大元素?

显然这时原有顺序均已被破坏,只能再次建堆,代价太大!

如果建小根堆呢?

假设我们利用前 20 个数建一个大小为 20 的小根堆;

如果比堆顶数据大,就替代堆顶进堆(覆盖之后进行向下调整)

这样最终第20大的元素就会在堆顶,前20大的元素均会出现在堆中

总结一下:

当求前 k 大的数据时,建小根堆即可!

求前 k 小的数据时,建大根堆即可!

如有不足之处欢迎指出!

相关推荐
Zephyr_01 小时前
c++数据结构
数据结构·c++
故事和你911 小时前
蓝桥杯-2026年C++B组省赛
开发语言·数据结构·c++·算法·蓝桥杯·动态规划·图论
星恒随风1 小时前
C语言算法复杂度详解:时间复杂度与空间复杂度一篇讲透
c语言·算法
傻瓜搬砖人1 小时前
c语言绿皮书第三版第十一章习题
c语言·开发语言·算法·谭浩强·绿皮书第三版
如君愿1 小时前
考研复习 Day 33 | 习题--计算机网络 第六章(应用层 上)、数据结构 查找算法(上)
数据结构·计算机网络·考研·课后习题
计算机安禾1 小时前
【c++面向对象编程】第3篇:类与对象(二):构造函数与析构函数
开发语言·c++·算法
小年糕是糕手1 小时前
【C++】vector 不踩坑指南:用法、底层实现与迭代器失效解析
c++·算法
SilentSamsara2 小时前
生成器完全指南:`yield` 与惰性求值的工程价值
linux·开发语言·python·算法·机器学习·青少年编程
玛卡巴卡ldf2 小时前
【LeetCode 手撕算法】(二分查找)搜索插入位置、搜索二维矩阵、查找数组相同的所有位置、搜索旋转排序数组、旋转升序数组的最小值
数据结构·算法·leetcode