从零开始手搓堆:核心操作实现 + 堆排序 + TopK 算法+ 向上调整 vs 向下调整建堆的时间复杂度严密证明!

◆ 博主名称: 小此方-CSDN博客

大家好,欢迎来到晓此方的博客。

⭐️个人专栏:《C语言》_小此方的博客-CSDN博客

算法_小此方的博客-CSDN博客

⭐️踏破千山志未空,拨开云雾见晴虹。 人生何必叹萧瑟,心在凌霄第一峰。


目录

一,什么是堆

➤一句话定义

[➤ 堆的性质](#➤ 堆的性质)

[➤ 堆的存储](#➤ 堆的存储)

二,如何实现一个堆

1,创建一个堆:初始化和销毁

2,在堆中插入一个数

4,向上调整函数(shift_up)

3,在堆顶删除一个数据

4,向下调整算法

关键点解析

5,取堆顶数据以及判空方法

------------------------------------------------接下来是我想讲的重点------------------------------------------------

[三, 堆排序](#三, 堆排序)

先说结论

解释原理

1.建堆(以建小堆为例)

2,首尾交换,调整,从尾向首形成排序

四,Topk算法

1,一句话定义:

2,探索实现思路:

Top-k的经典解决办法:

一,生成大量随机数

1,原理

2,如何找到这些数据

二,实现top-k

五,数学证明向下调整建堆比向上调整建堆算法的时间复杂度更优

1,向上调整建堆的时间复杂度

向上调整建堆的时间复杂度分析(最坏情况)

[1. 堆的结构设定](#1. 堆的结构设定)

[2. 单次调整代价](#2. 单次调整代价)

[3. 总体时间复杂度(最坏情况)](#3. 总体时间复杂度(最坏情况))


一,什么是堆

➤一句话定义

堆是一种特别的完全二叉树。分为大根堆(大堆)和小根堆(小堆)。在小根堆中,所有父节点的值都小于或等于其子节点;而在大根堆中,所有父节点的值都大于或等于其子节点。

堆的性质

● 堆中某个结点的值总是不大于或不小于其父结点的值。

● 堆总是一棵完全二叉树。

堆的存储

二叉树本质上都可以通过二叉链的形式存储。但是完全二叉树和满二叉树由于其特性,可以用数组存储。

优点:

  • 数组存储能显著提高缓存命中率。
  • 利用下标之间的数学关系,可以快速定位父子节点,便于插入、删除等操作。

一个非线性的数据结构以线性方式存储,这样的存储方式让现实与想象产生隔阂,让物理与逻辑发生分歧。但是出于效率需要,这样的操作是不可避免的。

● 访问左孩子: index*2+1

● 访问右孩子:index*2+2

● 访问父亲 :index-1)/2

为什么只有一种方式?

因为整数除法中 (n-1)/2 == (n-2)/2n 是奇数时成立,因此无论从左孩子还是右孩子回溯到父节点,结果一致。所以统一采用 **(index - 1) / 2**即可。

这种线性化存储方式虽然牺牲了直观性,却极大提升了性能,是实际应用中的优选方案。

二,如何实现一个堆

我们以 C 语言为例,实现一个通用类型的堆(支持任意数据类型),并提供基本接口。

cpp 复制代码
//堆的初始化
void HeapInit(Heap* php);
// 堆的销毁
void HeapDestory(Heap* php);
// 堆的插入
void HeapPush(Heap* php, HPDataType x);
// 堆的删除
void HeapPop(Heap* php);
// 取堆顶的数据
HPDataType HeapTop(Heap* php);
// 堆的判空
int HeapEmpty(Heap* php);

1,创建一个堆:初始化和销毁

cpp 复制代码
typedef int HPDataType;
typedef struct Heap
{
	HPDataType* _a;
	int _size;
	int _capacity;
}Heap;

我们的堆是基于数组的,事实上我们也可以理解为顺序表。

● 指针_a用来指向数组的位置。

● size表示数组中已经有的数据个数。

● capacity表示数组的容量大小。

cpp 复制代码
//初始化
void HeapInit(Heap* php)
{
	assert(php);
	php->_capacity = 0;
	php->_a = NULL;
	php->_size = 0;
}
cpp 复制代码
// 堆的销毁
void HeapDestory(Heap* php)
{
	assert(php);
	free(php->_a);
	php->_a = NULL;
	php->_capacity = 0;
	php->_size = 0;
}

2,在堆中插入一个数

插入过程分为两步:

  1. 扩容检查:确保有足够的空间;
  2. 插入并调整:将新元素放入末尾,并向上调整至正确位置(维持堆的性质)。
cpp 复制代码
	assert(php);
	if (php->_size== php->_capacity)
	{
		int newcapacity = php->_capacity == 0 ? 4 : php->_capacity * 2;
		HPDataType* tmp = (HPDataType*)realloc(php->_a,newcapacity * sizeof(HPDataType));
		if (tmp == NULL)
		{
			perror("realloc::tmp");
			exit(1);
		}
		php->_capacity = newcapacity;
		php->_a = tmp;
	}

赋值,然后插入:

cpp 复制代码
	php->_a[php->_size] = x;
	php->_size++;

例如,在大根堆中,插入的新元素必须小于等于 其父节点;在小根堆中,则必须大于等于其父节点。

为此,我们需要实现一个核心辅助函数:向上调整(shift_up)

4,向上调整函数(shift_up)

cpp 复制代码
void shift_up(HPDataType*a,int child)
{
	assert(a);
	int futher = (child - 1) / 2;
	while (child > 0)
	{
		if (a[futher] > a[child])
		{
			swap(&a[futher], &a[child]);
			child = futher;
			futher = (child - 1) / 2;
		}
		else
		{
			break;
		}
	}
}

这个函数的核心逻辑如下:

输入的是新插入元素在数组中的下标,即"孩子"的位置。

通过公式

计算出其父节点的位置,然后比较父节点与子节点的值。

小根堆 为例:如果父节点的值大于子节点的值,则违反了堆的性质。此时,交换父子节点的值,并将交换后的新父节点位置作为下一个待处理的"孩子"位置,继续向上进行比较与调整。

这一过程不断重复,直到满足堆的性质(父节点 ≤ 子节点)或到达堆顶为止。

由此实现了自动向上调整(shift_up) 的机制,确保新插入的元素被正确地"上浮"到合适位置。

以下是我提供的swap()函数的一个参考:

cpp 复制代码
void swap(HPDataType* num1, HPDataType*  num2)
{
	HPDataType num3 = *num1;
	*num1 = *num2;
	*num2 = num3;
}

3,在堆顶删除一个数据

cpp 复制代码
void HeapPop(Heap* php)
{
	assert(php);
	assert(php->_size > 0);
	swap(&php->_a[0], &php->_a[php->_size - 1]);
	php->_size--;
	be_down(php->_a, 0,php->_size);
}

首先,删除操作前必须确保堆不为空,并且堆的指针有效(非空指针)。

若直接删除堆顶元素,会导致原本的第二个元素成为新的堆顶,但此时它可能违背堆的性质(例如在大根堆中比其子节点小),从而破坏整个堆的结构。这种破坏会引发连锁反应,使得所有父子和兄弟关系失序,堆不再满足定义。

为避免这一问题,我们采用一种巧妙的"间接删除法":

  • 将堆顶元素与数组末尾的元素交换;
  • 然后将最后一个元素逻辑上移除(即 size--);
  • 此时新堆顶可能不满足堆的性质,因此需要对它进行向下调整(shift_down),使其重新恢复堆的有序性。

这样既保留了堆的结构完整性,又实现了高效删除。

4,向下调整算法

cpp 复制代码
void shift_down(HPDataType* a, int futher,int n)
{
	int child = futher * 2 + 1;
	while (child < n)
	{
		if (child + 1 < n&& a[child + 1] < a[child])
		{
			child++;
		}
		if (a[child] < a[futher])
		{
			swap(&a[futher], &a[child]);
			futher=child;
			child = futher * 2 + 1;
		}
		else
		{
			break;
		}
	}
}

和向上调整算法不同。向下调整算法首先得找到父亲。

用父亲找到左孩子和右孩子,以小堆为例。判断得到左右孩子中较小的那个,比较它和父亲的大小关系,如果它比父亲小,交换,然后让孩子的位置变成新的父亲反复操作直到找不到下一个孩子 为止。

关键点解析

  1. 为何要先判断左右孩子?

    • 直接比较左右孩子的大小可能带来额外开销;
    • 使用"假设法"更高效:先假设左孩子较小,再判断是否应改为右孩子。
    • 这样减少了不必要的比较次数。
  2. 边界条件处理

    • 当 **child + 1 >= n**时,说明右孩子不存在,无需比较;
    • 因此在比较前必须加上 **child + 1 < n**的判断,防止越界。
  3. 循环终止条件

    • while (child < n) 表示当前子节点仍在有效范围内;
    • 当 **child >= n**时,说明当前节点没有子节点,无需继续调整;
    • 虽然 **father < 0**也能正常运行,但写法不够直观,建议避免。

5,取堆顶数据以及判空方法

cpp 复制代码
HPDataType HeapTop(Heap* php)
{
	assert(php);
	assert(php->_size > 0);
	return php->_a[0];
}
// 堆的判空
int HeapEmpty(Heap* php)
{
	assert(php);
	return php->_size == 0;
}

不用过多解释,最简单的部分。

------------------------------------------------接下来是我想讲的重点------------------------------------------------

三, 堆排序

先说结论

1,建堆:正向排序建大堆,逆向排序建小堆。

2,首尾互换 ,顶部元素在前n-k(k从1开始每次循环自增1)个结点内调整

3,循环以上操作。

cpp 复制代码
void HeapSort(int* a, int n)
{
	//从下往上建堆法
	for (int i = (n - 1 - 1) / 2; i>=0; i--)
	{
		be_up(a, i);
	}
	int end = n - 1;
	while (end>=0)
	{
		swap(&a[0], &a[end]);
		be_down(a, 0, end);
		end--;
	}
}

解释原理

1.建堆(以建小堆为例)

这是一个非常抽象的过程。

建堆分为从上向下调整建堆从下向上调整建堆

假如我们有这样一个数组

[10,11,7,9,12,66,13,8,16,14,15],并把它想象成树型结构:

从上向下调整建堆: 先假设A已经在堆里面了,然后从B开始到K结束,所有的结点全部做一次向上调整算法。从而达到建堆。

从下向上调整建堆 :从最后一位父亲(最后一个非叶子节点)开始(也就是E)到A的所有结点全部做一次向下调整算法**,**从而达到建堆。

2,首尾交换,调整,从尾向首形成排序

建成小根堆后,堆顶元素即为当前所有元素中的最小值。

接下来,执行以下步骤:

  1. 交换堆顶与末尾元素

    将堆顶与当前堆的最后一个元素交换位置。

  2. 缩小堆的范围

    将有效堆的边界指针 end 减 1,表示最小值已"归位",不再参与后续调整。

  3. 向下调整新堆顶

    对新的堆顶元素执行 向下调整,使其重新满足小根堆的性质。

  4. 重复上述过程

    每次都将当前最小值沉到数组末尾已排序区域,逐步得到:

    • 从后往前:从小到大的升序序列
    • 从前往后:从大到小的降序排列

四,Topk算法

1,一句话定义:

Top-K 算法是一种基于堆的数据处理方法,用于高效找出海量数据中最大(或最小)的前 K 个元素,在大规模数据场景下具有显著优势。

2,探索实现思路:

很多人最先想到的 Top-K 解法是:先对全部数据建堆,再用类似堆排序的方式依次取出前 K 个最大(或最小)的元素

但问题来了------

如果数据量是 100 万 ,还可以接受;

可如果是 10 亿条数据呢?

一个 int 占 4 字节,10 亿个整数就需要:
4 × 10⁹ 字节 ≈ 4 GB 内存

(注:1 KB = 1024 B,1 MB = 1024 KB,1 GB = 1024 MB → 10⁹ 字节 ≈ 0.93 GB,若为 40 亿字节则约为 3.73 GB)

为找区区 K 个数,而占用数 GB 内存,代价显然过高

于是你可能会想:

"那我把数据分批处理!比如分 100 次,每次从硬盘读 1000 万条,分别找出每批的前 K 个,最后从这 100×K 个候选中再选 Top-K。"

这确实缓解了内存压力,但带来了新问题:

  • 需要多次 I/O 操作,速度慢;
  • 最终仍需处理 100×K 的数据,若 K 很大,效率依然低下;
  • 时间复杂度和工程复杂度显著上升

那么,有没有一种既节省内存,又高效的方法?

答案是:有!那就是top-k经典解法------使用大小为 K 的堆。

Top-k的经典解决办法:

一,生成大量随机数
cpp 复制代码
	//创造10万个随机数据
	int n = 100000;
	FILE* fin = fopen("data.txt", "w");
	if (fin == NULL)
	{
		perror("fileopen::error");
		exit(1);
	}
	while (n)
	{
		int a = rand()%10000+n;
		fprintf(fin, "%d", a);
		fprintf(fin, "\n");
		n--;
	}
	fclose(fin);
1,原理

这个代码,通过写的方式打开data.txt文件,并利用rand函数创造大量随机数,用标准文件输入函数fprintf打印到文件中。(最后不要忘记关闭文件)。

2,如何找到这些数据

步骤如下

1,右击当前top-k文件。

2,点击打开所在文件夹。

3,找到文件data.txt,并将它拖拽到编译器中。

二,实现top-k

两大步骤:

1,读取并建一个k个数据大小的小堆

2,从剩下的n-k个元素中,每次读取一个数据,和堆顶的数据进行比较

------------大,和堆顶交换进堆并调整。

------------小,不进堆。

cpp 复制代码
	//topk最大的10个数据	
    FILE* fin2 = fopen("data.txt", "r");
	int k = 10;
	int arr[10];
	for (int i = 0; i < k; i++)
	{
		fscanf(fin2, "%d", &arr[i]);
	}
	for (int i = (k - 1 - 1) / 2; i >= 0; i--)
	{
		be_down(arr,i, k);
	}
	n = 100000;
	for (int t = 0; t < n - k; t++)
	{
		int num = 0;
		fscanf(fin2, "%d", &num);
		if (num > arr[0])
		{
			arr[0] = num;
			be_down(arr, 0, k);
		}
	}
	for (int i = 0; i < k; i++)
	{
		printf("%d", arr[i]);
		printf("\n");
	}
	fclose(fin2);
	return 0;
}

五,数学证明向下调整建堆比向上调整建堆算法的时间复杂度更优

我在上文实际上埋藏了一个伏笔------为什么堆排序的建堆操作要使用向下建堆法?

1,向上调整建堆的时间复杂度

向上调整建堆的时间复杂度分析(最坏情况)

我们考虑最坏情况:每个新插入的结点都需要从其初始位置一路向上调整至堆顶。

1. 堆的结构设定
  • 设堆共有 h 层(根结点位于第 0 层)。

  • 第 0 层有 20 个结点

    第 1 层有 21 个结点

    第 2 层有 22 个结点

    第 h−1 层有 2h−1 个结点

  • 总结点数为等比数列之和:

    N=20+21+22+⋯+2h−1=2h−1

  • 由此可得堆的高度:

    h=log2​(N+1)

2. 单次调整代价

在向上调整建堆过程中,一个位于第 i 层的结点(i≥1),最多需要向上移动 i 次才能到达根结点。

因此,单次调整的时间复杂度为 O(logN)

3. 总体时间复杂度(最坏情况)

在最坏情况下,假设每一层的所有结点都需执行最大次数的向上调整

  • 第 1 层有 21 个结点,每个最多调整 1 次 → 贡献 21⋅1
  • 第 2 层有 22 个结点,每个最多调整 2 次 → 贡献 22⋅2
  • 第 3 层有 23 个结点,每个最多调整 3 次 → 贡献 23⋅3
  • 第 h−1 层有 2h−1 个结点,每个最多调整 h−1 次 → 贡献 2h−1⋅(h−1)

因此,总操作次数为:

该式可通过错位相减法化简为:

代入 2h=N+1 和 h=log2​(N+1),得:

4. 结论

向上调整建堆在最坏情况下的时间复杂度为:O(NlogN)​

同样的计算方式:我们可以得到:向下调整算法的时间复杂度是O(N)

因此:向下调整算法更优。

希望这篇内容能帮助你真正了解堆的原理,如果觉得有收获,欢迎点赞、收藏并分享给更多人!

本期内容到此结束。

相关推荐
爱上妖精的尾巴8 分钟前
6-4 WPS JS宏 不重复随机取值应用
开发语言·前端·javascript
fie88891 小时前
NSCT(非下采样轮廓波变换)的分解和重建程序
算法
小鸡吃米…2 小时前
Python 列表
开发语言·python
kaikaile19952 小时前
基于C#实现一维码和二维码打印程序
开发语言·c#
晨晖22 小时前
单链表逆转,c语言
c语言·数据结构·算法
我不是程序猿儿2 小时前
【C#】画图控件的FormsPlot中的Refresh功能调用消耗时间不一致缘由
开发语言·c#
rit84324992 小时前
C# Socket 聊天室(含文件传输)
服务器·开发语言·c#
嘉琪0013 小时前
Vue3+JS 高级前端面试题
开发语言·前端·javascript
xunyan62343 小时前
面向对象(下)-接口的理解
java·开发语言
遥不可及~~斌3 小时前
Java 面试题集 -- 001
java·开发语言