一文讲清楚堆

目录

一、堆的概述

二、堆的常见操作

三、堆的实现(以大根堆为例)

1.堆的保存与表示

2.访问堆顶元素

3.堆顶插入元素

4.堆顶元素出堆

5.查找堆顶元素

6.判断堆是否为空

四、堆的常见操作

1. 建堆操作

方式一、 自顶向下插入法(Top-down)

2. 自底向上调整法(Bottom-up)

2. 堆排序

3. TOP-K问题


一、堆的概述

堆是一种满足特定条件的完全二叉树

分为两种:

大根堆:所有父节点都大于等于子节点

小根堆:所有父节点都小于等于子节点

大根堆的示例:

对应的数组表示:

index: 0  1  2  3  4  5  6
value: 50 30 45 20 25 40 35

在这个大根堆中:

  • 根节点50是最大的元素
  • 每个父节点的值都大于其子节点的值

小根堆的示例:

对应的数组表示:

index: 0  1  2  3  4  5  6
value: 10 15 12 25 20 18 16

在这个小根堆中:

  • 根节点10是最小的元素
  • 每个父节点的值都小于其子节点的值

完全二叉树的特性使得可以用数组来存储,对于索引i的节点:

  • 左子节点的索引为:2i + 1
  • 右子节点的索引为:2i + 2
  • 父节点的索引为:(i - 1)/ 2

定义一个堆:

//底层是一个可变数组,顺序表
typedef int HpDataType;
typedef struct Heap{
	HpDataType* arr;
	size_t size;
	size_t capacity;
}Hp;

二、堆的常见操作

方法名描述时间复杂度
HeapPush(Hp* ph, HpDataType val)堆顶插入元素O(logN)
HeapPop(Hp* ph)堆顶元素出堆O(logN)
boolEmpty(Hp* ph)判断堆是否为空O(1)
HeapSize(Hp* ph)堆里有效元素个数O(1)
HeapTop(Hp* ph)查找堆顶元素O(1)

三、堆的实现(以大根堆为例)

1.堆的保存与表示

堆是完全二叉树,利用数组存储,元素代表节点值,索引代表节点在二叉树中的位置,任意节点索引为i,可通过公式来实现寻找左右子节点和父节点

  • 左子节点的索引为:2i + 1
  • 右子节点的索引为:2i + 2
  • 父节点的索引为:(i - 1)/ 2

2.访问堆顶元素

HpDataType HeapTop(hp* ph) {
	assert(ph);

	return ph->arr[0];
}

3.堆顶插入元素

我们首先将元素插入数组末尾,即堆底,插入的元素可能会大于父节点,这时候堆的结构已经被破坏,所以我们需要修复从插入节点到根节点路径上的各个节点,从底至顶开始逐一比较修复

原始堆的示意图:

对应的数组表示:

index: 0  1  2  3  4  5  6
value: 50 30 45 20 25 40 35

以下是向大根堆中插入元素60的过程,向上调整算法示意:

  1. 首先将新元素插入到数组末尾
index: 0  1  2  3  4  5  6  7
value: 50 30 45 20 25 40 35 60

        2.将新插入的元素与其父节点比较,如果大于父节点则交换位置(向上调整)

index: 0  1  2  3  4  5  6  7
value: 50 30 45 60 25 40 35 20

         3.继续向上调整,直到满足堆的性质

index: 0  1  2  3  4  5  6  7
value: 60 30 45 50 25 40 35 20

入堆的代码实现:

void HeapPush(hp* ph, HpDataType val) {
	assert(ph);
	//空间不够需要扩容
	if (ph->size == ph->capacity) {
		//原容量为零,先给一个HpDataType的大小,若不为零,采用两倍扩容比例
		HpDataType newCapacity = ph->capacity == 0 ? sizeof(HpDataType) : ph->capacity * 2;
		//向堆申请新的空间
		HpDataType* tmp = (HpDataType*)realloc(ph->arr, sizeof(HpDataType) * newCapacity);
		if (tmp == NULL) {
			perror("realloc err!");
			return;
		}
		//更新容量和空间
		ph->capacity = newCapacity;
		ph->arr = tmp;
	}
	//向堆底插入元素,堆的元素数量加一
	ph->arr[ph->size++] = val;
	//此时堆的结构可能被破坏,向上调整算法修复堆的结构
	AdjustUp(ph->arr, ph->size - 1);
}

向上调整的代码实现:

//向上调整算法,参数数组,参数开始调整叶节点的索引
AdjustUp(HpDataType* arr, size_t child) {
	//找父节点索引
	size_t parent = (child - 1) / 2;
	//最坏的情况,叶节点调整为根节点
	while (child > 0) {
		//父节点小于等于子节点,交换两节点的值
		if (arr[parent] <= arr[child]) {
			Swap(&arr[parent], &arr[child]);
			//跟新子节点的索引,现在子节点变为新爹
			child = parent;
			//继续找爹作比较
			child = (child - 1) / 2;
		}
		else {
			break;
		}
	}
}

交换函数的代码实现:

void Swap(HpDataType* pa, HpDataType* pb) {
	int tmp = *pa;
	*pa = *pb;
	*pb = tmp;
}
  • 时间复杂度分析:O(logn)

4.堆顶元素出堆

堆顶元素是根节点,也是数组首元素,直接删除首元素,整个堆的结构被破坏,所有节点索引都会改变,我们尽可能的减少元素索引变动

我们可以采用如下步骤:

  • 交换堆顶元素与堆底元素(交换根节点与最右叶节点)(这样避免了索引变动)
  • 交换完成后,将堆底从列表中删除(注意,由于已经交换,因此实际上删除的是原来的堆顶元素)
  • 从根节点开始,使用向下调整算法完善堆的结构

以下是堆顶元素出堆的过程。首先看原始的大根堆:

index: 0  1  2  3  4  5  6
value: 50 30 45 20 25 40 35

出堆过程如下,向下调整算法:

  1. 首先将堆顶元素删除,用最后一个元素替换堆顶
index: 0  1  2  3  4  5  6
value: 35 30 45 20 25 40 [50已删除]

         2.将新的堆顶元素与其较大的子节点比较,如果小于子节点则交换位置(向下调整)

index: 0  1  2  3  4  5  6
value: 45 30 35 20 25 40 [50已删除]

         3.继续向下调整,直到满足堆的性质,此时已经满足大根堆的性质,调整完成

堆顶元素删除代码实现:

void HeapPop(hp* ph) {
	assert(ph);
	
	//将堆底元素移到堆顶,堆顶元素值被覆盖
	ph->arr[0] = ph->arr[ph->size - 1];
	//删除堆底元素
	ph->size--;
	//堆的结构被破坏,向下调整算法修复堆的结构
	AdjustDown(ph->arr, ph->size, 0);
}

向下调整算法代码实现:

//向下调整算法,参数:数组 数组元素个数 开始向下调整的父节点索引
AdjustDown(HpDataType* arr, size_t size, size_t parent) {
	//找较大的子节点,假设左子节点较大
	size_t child = 2 * parent + 1;
	//child索引不断增大,但不会超过数组元素个数
	while (child < size) {
		//假设错误,右子节点更大,更新索引
		if (child + 1 < size && arr[child] < arr[child + 1]) {
			child++;
		}
		//如果父节点小于等于子节点,交换两节点的值
		if (arr[parent] <= arr[child]) {
			Swap(&arr[parent], &arr[child]);
			//更新父节点的索引,现在变成儿子了
			parent = child;
			//继续找儿子比较
			child = 2 * parent + 1;
		}
		else {
			break;
		}
	}
}
  • 时间复杂度分析:O(logn)

5.查找堆顶元素

HpDataType HeapTop(hp* ph) {
	assert(ph);

	return ph->arr[0];
}

6.判断堆是否为空

bool HeapEmpty(hp* ph) {
	assert(ph);

	return ph->size == 0;
}

四、堆的常见操作

1. 建堆操作

方式一、 自顶向下插入法(Top-down)

步骤

  1. 初始化空堆。
  2. 逐个插入元素:每次将新元素添加到堆的末尾。
  3. 向上调整:对新插入的元素执行HeapPush 操作,使其满足堆性质。
插入顺序:4 → 10 → 3 → 5 → 1
步骤:
1. 插入4 → [4](无需调整)
2. 插入10 → [4,10] → 10与4交换 → [10,4]
3. 插入3 → [10,4,3](无需调整)
4. 插入5 → [10,4,3,5] → 5与4交换 → [10,5,3,4]
5. 插入1 → [10,5,3,4,1](无需调整)
最终堆结构:
      10
     /  \
    5    3
   / \
  4   1

代码实现:


int main() {

	hp Heap;
	HeapInit(&Heap);

	int arr[] = { 4,10,3,5,1 };
	for (int i = 0; i < sizeof(arr)/sizeof(int); i++)
	{
		HeapPush(&Heap, arr[i]);
	}
	HeapDestory(&Heap);
	return 0;
}

时间复杂度分析

  • 每次插入需 O(logn) 时间(n 为当前堆大小)
  • 遍历数组需O(n)
  • 总时间 = O(nlogn)

2. 自底向上调整法(Bottom-up)

步骤

  1. 直接填充数组:将所有元素按原始顺序放入数组。
  2. 从最后一个非叶子节点开始(叶子节点就是天然的一层堆,不需要调整):索引(size - 1 - 1) / 2
  3. 向前遍历:对每个节点执行 AdjustDown 操作。
初始数组:[3,5,2,10,4]
索引映射:
      3(0)
     /   \
    5(1) 2(2)
   / \
10(3)4(4)

操作步骤:
1. 从索引1(元素5)开始调整 → 无需交换
2. 处理索引0(元素3):
   - 比较子节点5和2 → 与5交换
   - 交换后结构:
      5(0)
     /   \
    3(1) 2(2)
   / \
10(3)4(4)
   - 继续检查交换后的索引1(元素3) → 与10交换
最终堆结构:
      10
     /   \
    5     2
   / \
  3   4

代码实现:

void HeapCreate(hp* ph, HpDataType* arr, size_t size) {
	assert(ph);
	//给数组开辟空间
	ph->arr = (HpDataType*)malloc(sizeof(HpDataType) * size);
	if (ph->arr == NULL) {
		perror("malloc err!");
		return;
	}
	//拷贝传过来的数组
	memcpy(ph->arr, arr, sizeof(HpDataType) * size);
	ph->size = ph->capacity = size;

	//调整堆的过程,从最后一个非叶子节点开始
	for (int i = (size - 1 - 1) / 2; i >= 0; i--) {
		AdjustDown(ph->arr, size, i);
	}

}

复杂度分析:

2. 堆排序

流程:

1)建堆 实现升序建大根堆 实现降序建小根堆

核心原理:

堆排序通过反复提取堆顶元素(极值)实现排序,堆的类型决定了提取元素的顺序:

  • 大顶堆:堆顶始终为当前最大值
  • 小顶堆:堆顶始终为当前最小值

2) 排序 利用堆删除思想排序

注:此图引自hello‑algo.com 

代码实现:

void HeapSort(HpDataType* arr, int size) {
	// 建堆 升序建大根堆 降序建小根堆
	for (int i = (size - 2) / 2; i >= 0; i--) {
		AdjustDown(arr, size, i);
	}

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

3. TOP-K问题

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

  1. 用数据集合中前K个元素来建堆 前k个最大的元素,则建小堆(大的来了就替换堆顶最小值,重新修复堆结构,到最后就剩k个需要的数据) 前k个最小的元素,则建大堆(小的来了就替换堆顶最大值,重新修复堆结构,到最后就剩k个需要的数据)
  2. 用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素
  3. 将剩余N-K个元素依次与堆顶元素比完之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素

代码实现:

void HeapSort(HpDataType* arr, int size) {
	// 建堆 升序建大根堆 降序建小根堆
	for (int i = (size - 2) / 2; i >= 0; i--) {
		AdjustDown(arr, size, i);
	}

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

void DataCreate()
{
	// 造数据
	int n = 99;
	srand(time(0));
	FILE* fp = fopen("D:\\CODE\\DataStructure\\DataStructureClone\\Heap\\data.x", "w");
	if (fp == NULL)
	{
		perror("fopen error");
		return;
	}

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

	fclose(fp);
}

void HeapTopK() {
	//输入指令
	printf("请输入k:");
	int k = 0;
	scanf_s("%d", &k);

	//创建随机数据
	DataCreate();

	//读取文件中k个数据
	FILE* fout = fopen("D:\\CODE\\DataStructure\\DataStructureClone\\Heap\\data.x", "r");
	if (fout == NULL)
	{
		perror("fopen error");
		return;
	}

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

	for (int i = 0; i < k; i++)
	{
		fscanf_s(fout, "%d", &minheap[i]);
	}
	//创建小堆
	for (int i = (k - 1 - 1) / 2; i >= 0; i--)
	{
		AdjustDown(minheap, k, i);
	}

	int x = 0;
	while (fscanf_s(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]);
	}

	fclose(fout);
}

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Vect.

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值