数据结构——堆和堆的两大应用(堆排序,TopK问题)

⭐堆的两大应用

从前一章的学习中(数据结构——树,二叉树和堆(万字详解)_VelvetShiki_Not_VS的博客),我们大致了解了具有非线性逻辑结构的树和完全二叉树的堆结构基本和各种基本函数,包含堆的插入,堆删除,向上和向下调整算法对于堆数据结构的处理方案以及时间复杂度的分析。本章中,将对堆结构的优势和应用进行详细分析和解释,以便我们能够更好理解这种结构相较于其他数据结构在查找数据,排序等方面的优越性以及实际生活中的应用问题。

本章中将着重介绍两个堆的重要用途:

🍧数据的堆排序

🍧TopK海量数据筛选


✨堆排序

堆排序是数据的一种排序方式,它的效率为O(N ^ logN),相比于时间复杂度为O(N2)的冒泡排序算法和直接插入排序算法在效率上都是跨越式的提升,虽然它是一个不稳定的排序算法,但其无需开辟额外的辅助排序空间,空间复杂度为O(1)。

对比前章我们对于堆数据排序的处理,可知如果使用数据的逐个插入并使用向上调整算法得到大堆或小堆,再以遍历输出得到升序或降序数组的方式,不仅需要提前将整个堆的数据结构都全部列出来(包括堆自身结构体或相关函数接口如堆的插入,删除或取顶函数等),还需要开辟存储堆的结构体信息空间,以及数组空间等,所消耗的资源无疑和工作量无疑对于单纯的排序需求是不友好的。

所以对于一个需要排序的数组而言,堆排序算法只需要排序数据即可,整个过程就是进行大小堆改造并调用向下调整的排序算法即可。

🌠建堆方式

使用堆结构将一个数组排序整体可以分为两大步骤:

  1. 👑将数组使用向下调整算法构建为堆
  2. 👑使用堆排序算法给堆结构的数组排序

以数组构建大堆为例,原理图如下:

在这里插入图片描述

上图为建堆的其中一种方式——向下调整算法建堆,但其实将一个数组构建为堆的方式有两种:向上调整算法或向下调整算法。

🥝向上调整建堆

从一个数组的首元素开始,向后依次遍历到末尾的同时将遍历到的每个结点作为向上调整算法的子结点参数传入,直至根结点这一路径上的所有祖先结点向上调整,大堆中遇到子结点比双亲结点值大就交换,小堆中遇到子结点值比双亲结点值小就交换。

原理图如下:

在这里插入图片描述

向上调整算法的是以将数组从头至尾遍历结束为基础,边遍历边向上至根结点调整位置的,遍历数组的时间复杂度为O(N),而单趟向上的算法时间复杂度为O(logN),所以整体采取该种算法建堆的时间复杂度为O(N*log2N)。

🎀向上调整建堆函数

void HPCreateUP(int* arr, int size, COM rule)		//传参数组,数组大小以及建堆规则
{
	assert(arr);
	if (size != 0)
	{
		int child = 0;
		while (child < size)						//从根结点开始,向后遍历至末元素向上调整
		{
			AdjustUpward(arr, child, rule);
			child++;
		}
	}
}

🎀其中,向上调整算法

void AdjustUpward(HPEtype* arr, int child, COM rule)
{
	assert(arr);
	int parent = (child - 1) / 2;
	while (child)
	{
		if (rule(arr[child], arr[parent]))
		{
			Swap(&arr[child], &arr[parent]);
			child = parent;
			parent = (child - 1) / 2;
		}
		else
		{
			break;
		}
	}
}
  1. 可以观察到,向上调整建堆函数的核心在于堆的向上调整算法。在建堆函数中,只需传入数组名首地址,数组大小和排序规则,对于大堆和小堆排序规则已在前章中有所详细阐述,可见本章开头陈列的前面章节关于堆调整算法和函数指针的排序规则链接。
  2. 将上述参数传入该函数有两个主要用途,首先是为了遍历数组,定义临时child下标,当child处于数组size范围内时循环遍历,否则函数结束,建堆完成。第二是为了将遍历到的该下标传入上调函数,并以该下标对应的子结点与祖先结点进行对比和交换,交换的规则由rule来确定。

🌈测试用例

//创建数组并建小堆
int arr[] = { 5,6,3,8,4,9};
int size = sizeof(arr) / sizeof(arr[0]);
HPCreateUP(arr, size, Smaller);
//打印已成为小堆结构的数组
int i = 0;
for (i = 0; i < size; i++)
{
    printf("%d ", arr[i]);
}

🌈观察结果

3 4 5 8 6 9

数组构建成堆的完全二叉逻辑结构:

在这里插入图片描述

可以看出,通过向上调整算法对一个整型数组构建出的小堆,符合小堆的性质。

🥝向下调整建堆

与向上调整建堆从数组首地址结点向后遍历并调用向上调整算法不同,向下调整建堆以一个数组的逻辑结构中最后一个非叶子结点开始向前遍历,并且每遍历一个结点,就以该结点为双亲结点,对其左子树和右子树进行向下调整,大堆找子大,小堆找子小。

原理图如下
在这里插入图片描述

向下调整建堆整个过程是以双亲结点parent从末非叶子结点遍历至根结点(下标为0)之后才建堆完成的,其中的每趟都需要调用向下调整算法,向下调整每趟的父子对比交换的循环结束条件为子结点下标child超出size范围,即单趟循环结束。

🎀向下调整建堆函数

void HPCreateDown(int* arr, int size, COM rule)		//传参数组,数组大小,建堆规则
{
	assert(arr);
	if (size != 0)
	{
		int root = (size - 1 - 1) / 2;	//第一次-1为堆尾顺序表结点下标,第二次-1再除以2是公式,目的是找堆中的最后一个非叶子结点,并从该结点向前遍历至根结点
		while (root >= 0)		//遍历到堆顶仍需要向下调整,当root将整个堆遍历完全时才结束调整
		{
			AdjustDownward(arr, size, root, rule);
			root--;
		}
	}
}

🎀其中,调用到的向下调整算法

void AdjustDownward(HPEtype* arr, int size, int parent, COM rule)
{
	assert(arr);
	int child = parent * 2 + 1;
	while (child < size)		//向下调整的循环结束条件为当需要交换的子结点下标超出数组size范围时
	{
		if (child + 1 < size && rule(arr[child + 1], arr[child]))		//左右子结点值比较
		{
			child = child + 1;
		}
		if (rule(arr[child], arr[parent]))		//父子间结点值比较
		{
			Swap(&arr[parent], &arr[child]);
			parent = child;
			child = child * 2 + 1;
		}
		else
		{
			break;
		}
	}
}
  1. 与向上调整建堆一样,将所需建堆的数组首地址,数组大小和建堆规则传入函数中,定义一个数组的逻辑结构中临时标识最后一个非叶子结点的下标整型root,并将该下标与其他参数一起传入向下调整算法中,使以该下标为起始的双亲结点值和其对应子孙结点值比较并交换,小堆找子小,大堆找子大,循环对比并完成该双亲结点向下的堆子树结点位置调整。

  2. 向下建堆函数的整体时间复杂度仅为O(N),在效率上略优于向上建堆的方法,所以后续在建堆的基础上对数组本身进行堆排序时就只采用向下建堆的方式。

🌈测试用例

//创建数组并建大堆
int arr[] = { 43,48,69,-2,1002,96,2,4,1,3,5,-6 };
int size = sizeof(arr) / sizeof(arr[0]);
HPCreateDown(arr, size, Bigger);
//打印已成为大堆结构的数组
int i = 0;
for (i = 0; i < size; i++)
{
    printf("%d ", arr[i]);
}

🌈结果观察

1002 48 96 4 43 69 2 -2 1 3 5 -6

数组构建成堆的完全二叉逻辑结构:
在这里插入图片描述

可以看出,通过向下调整算法对一个整型数组构建出的小大,符合大堆的性质。


🌠堆排序

前面已经提到,对一个数组使用堆排序的前提是该数组必须满足堆的结构和性质,如果按照以上两种方法的其中一种将数组构建好了堆,就可以使用堆的排序算法将一个已经构成堆结构的数组本身按需进行升降序的排列了

🥝首尾交换向下调整法

预先将一个数组按照向上或向下建堆的方式构建出以堆为数组逻辑结构的数组后,此时按照前章提到过的堆删除思路,因为堆结构的特殊性,交换前的堆顶是整个数组中最大或最小的数据,将堆顶和堆尾的数据交换,此时该最值被交换到堆尾,而堆顶的数据作为整个完全二叉树的根结点数据,其值可能不满足左右子树的父子间大小关系,所以从根结点开始进行向下子孙结点间的位置和数值调整,调整原理已在上述详细阐述。

值得注意的是,数据的升降排序需要建的大小堆不相同。如果一个数组需要排升序,需要建大堆;如果排降序,需要建小堆。这是因为采用的调整方法为堆首尾数据交换,即采取的排序策略为堆删除的思路,所以每次首尾交换后,堆尾得到的都是堆顶原最大或最小的数据,每次交换结束后,堆尾数据变为最值,并让控制堆尾的下标end–,使堆尾倒移动到前面的结点上,等待接收次最值数据;而堆顶的原堆尾数据需要进行向下调整,使整个结构仍然为堆。

以将数组建成大堆并排升序为例,原理图如下:
在这里插入图片描述

🎀堆排序算法

void HPSort(int* arr, int size, COM rule)
{
	int end = size - 1;							//定义待交换堆尾下标标识
	while (end > 0)								//当堆尾下标end由整个堆尾遍历到堆顶时,结束循环
	{
		Swap(&arr[0], &arr[end]);				//交换堆顶堆尾数据
		AdjustDownward(arr, end, 0, rule);		//堆顶数据向下调整
		end--;
	}
}
  1. 堆排序算法相比于堆的遍历打印出伪升降序数组数据而言,其最大优势是不需要额外定义堆的相关结构和函数接口,只需要传入数组名,数组大小及建堆规则即可按需将一个数组排升序或降序:如果排升序,则建大堆,如果排降序,则建小堆。
  2. 因为采用的是首尾交换的方法将数据排序好,每次将堆顶数据交换至堆尾,所以整个堆的数据排序是从后向前建立起来的,又因为是对以堆为数据结构的数组本身进行数值间交换和调整的,所以堆排序实质上是对数组本身进行排序,而不是像堆遍历那般取顶再删顶得出的升降序数组的伪排序。
  3. 采用向下调整建堆并调用堆排序算法的整体时间复杂度为O(N) + O(N * logN) = O(N * logN)。

🌈测试用例1

//构建数组和计算大小以便传参
int arr[] = { 43,48,69,-2,1002,96,2,4,1,3,5,-6 };
int size = sizeof(arr) / sizeof(arr[0]);
//以大堆规则传入建大堆
HPCreateDown(arr, size, Bigger);
//大堆以排出升序数组
HPSort(arr, size, Bigger);
//打印升序数组
int i = 0;
for (i = 0; i < size; i++)
{
    printf("%d ", arr[i]);
}

🌈观察结果

-6 -2 1 2 3 4 5 43 48 69 96 1002

🌈测试用例2

//构建数组和计算大小以便传参
int arr[] = { 0, 5,6,3,8,4,9, 15, 20};
int size = sizeof(arr) / sizeof(arr[0]);
//以小堆规则传入建小堆
HPCreateUP(arr, size, Smaller);
//小堆以排出降序数组
HPSort(arr, size, Smaller);
//打印降序数组
int i = 0;
for (i = 0; i < size; i++)
{
    printf("%d ", arr[i]);
}

🌈观察结果

20 15 9 8 6 5 4 3 0

🎃因为在测试用例中需要排序的数据量较小,所以看似排序的时间效率与冒泡排序并没有多大区别,但如果将数据量增大到几个数量级以上,观察程序的运行时间占用就可以明显观察到排序算法的优劣。

👑堆排序总结:

  1. 如果要对一个无序的数组建堆并排序,首先要建堆——如果采用前章不断pop来取顶再存入另一个顺序表或建好堆后通过遍历的方式得到排好序的数组,没有效率且没意义(时间复杂度为O(N2)),因为没体现出堆的优势。因为如果用上述方式建堆,可能会造成数据栈的溢出,这样的方式很不好,且空间复杂度为O(N),排序前先要写一个堆结构及各种功能函数接口,再逐个取顶Top并pop放到数组中。

  2. 建堆的方式有两种,一种是从堆顶至堆尾遍历结点数据的同时向上调整建堆(类似于堆数据Push方法),时间复杂度为O(N * log2N);另一种先寻找数组逻辑结构的末非叶结点,并以该节点为起始点end,依次对每棵子树向下调整建堆(向下调整的前提是左右子树必须是堆),时间复杂度为O(N)(可通过等差乘等比数列的错位相减法证明)。

  3. 采用向下调整建堆并调用堆排序算法的整体时间复杂度为O(N) + O(N * logN) = O(N * logN),排序的空间复杂度为O(1),因为没有借助到辅助空间来协助排序;该排序的稳定性较差,因为会对相同数据的前后相对顺序发生改变,因为调用的向下调整算法发生频繁的首尾交换以及父子结点间数值的交换,所以相同的数值顺序可能会颠倒。

  4. 升序建大堆,降序建小堆,不能颠倒使用的原因是,如果升序建小堆通过取顶元素来插入新数组得到升序数组,取顶一次后余下的数据不再是堆,又要通过建堆而重复向上或向下调整步骤,时间复杂度太大为O(N2),降序建大堆同理,效率太差且没有体现堆结构的优势。

  5. 堆顶最值数与最后一个节点的数交换(按照堆删除的思路),此时左右子树都满足堆结构,再进行向下调整,选出次大数,再将堆顶数据进行向下调整,保持堆结构的同时可以选出次最值数作为堆顶元素,从而让之后首尾交换排序和向下调整循环起来。


✨TopK问题

对堆的构建和堆排序有了整体的了解后,就可以从实际应用的角度出发,对海量的数据进行个性化的处理了。TopK问题描述的是从N个极大的数据中选出K个最值数,如从成千上万的数据中排序筛选出最大或最小的10个数。TopK问题在现实生活中有很广的应用,并且选数与排序的组合能在各种应用场景中让人们在生活中享受到各种遍历,如在手机电商或各式各样的商业平台中挑选出符合用户意愿的口碑最好的一系列商家,或销量最高,综合评价最好的产品等。

解决TopK问题可以由前章与本章学习的堆结构出发,以堆的选数优势和排序效率来对大量数据进行挑选,进而找出最大或最小的一系列数据,有三种方案可以解决:

  1. 🥝堆排序——将所有的N个数进行堆排序,升序的前K个数为最小,降序的前K个数为最大。使用数组向下调整法建堆和堆排序的整体时间复杂度为O(N * logN),其中建堆为O(N),堆排序为O(N * logN)。若数据量过大,排序的方法不可取,因为内存可能会存不下那么多数据,其空间复杂度为O(N)。

  2. 🥝对极大的N个数构建堆,取顶后再Pop堆顶K次,即可以选出用户所需的前最大或最小的K个数。其中向下调整建堆为O(N),建好后取顶Pop时间复杂度为O(K * logN),所以整体时间复杂度为O(N + K * logN)。该方法同样需要对极大的N个数建堆,但仅需要堆K个数进行取顶和删除选次最值,相比第一种方法效率有提升,但问题同样存在于内存空间上,也可能会造成内存溢出的隐患。

  3. 🥝只建立需要选出的K个数的堆,选小数建大堆,选大数建小堆。先用N个数中的前K个数建堆,后再用N - K个数与堆顶数据比较,如果在选更小数的K个数的大堆中,遍历到数据比堆顶值更小的,就进入堆中,将更小的数沉淀到堆的子树结点中,当N - K个数遍历完全后,K个数的大堆中存储的就是最小的K个数,且因为具有堆结构,所以可以直接调用排序函数将该K个最小数排序输出。选大数的建K个数的小堆同理。

    原理图如下
    在这里插入图片描述

🎀TopK选数算法

HPEtype* TopK(HPEtype* arr, int k, int size, COM rule)	//将待选数组arr及容量size,需要选的K个数,和选数规则传入
{
	assert(arr);
	HPEtype* TopHeap = (HPEtype*)malloc(k * sizeof(HPEtype));	//开辟K个数的顺序表作为堆使用
	assert(TopHeap);
	int i = 0;
	for (i = 0; i < k; i++)								//将总选数数组中的前K个数赋值进堆中
	{
		TopHeap[i] = arr[i];
	}
	for (i = (k - 2) / 2; i >= 0; i--)					//将K个数的堆向下调整建堆
	{
		AdjustDownward(TopHeap, k, i, rule);
	}
	for (i = k; i < size; i++)							//遍历N-K个数,与K个数的堆顶比较,选大数比大,选小数比小
	{
		if (rule(TopHeap[0], arr[i]))
		{
			TopHeap[0] = arr[i];						//满足要求则替换堆顶,进堆下调,整体保持堆的结构不变
			AdjustDownward(TopHeap, k, 0, rule);
		}
	}
	return TopHeap;										//将选好K个数的堆数组返回给实参
}
  1. 该算法大致分为三个步骤,首先将需要选数的N个数的前K个数存入待建堆的数组中,选大数建小堆,选小数建大堆。这里的建堆与选数相反的原因是,如果选大数建大堆,则大数将会排列在堆顶的根结点中,其他比堆顶小的数将会无法进堆,所以最后选下来仅有堆顶的最大数有意义,而次大的K个数将会完全无法进入该堆中;小堆同理,如果选小数建小堆,则最小数可能会卡在堆顶,其他K个次小数无法进堆,造成仅能选出1个最小数的堆结构,而无法选出最小的K个数。

  2. 该算法的时间复杂度为O(K + (N-K)*logK)),O(K)为建堆的时间,O(N - K)为遍历所需时间,O(logK)为进堆的每个数据向下调整的时间(最坏情况需要调整到堆尾结点)。

  3. 最值的K个数选出以后保持着大堆或小堆的结构,当要以升降序排序输出打印时需注意:

    ☣️如果选的是最小的K个数,因为建的是大堆,所以可以默认以大堆结构规则进行升序排序而不需要重新建堆,直接输出升序数组即可。而如果想以降序数组输出,则因为选出的数组结构为大堆,需要重新堆大堆数据建小堆后再以小堆规则排降序,才可输出降序数组数据。切不可以大堆数组结构调用堆排序以降序输出,否则数据将排序错误。选大数的小堆排升降序输出同理。

🌈测试用例——10000个数中选最大的10个数

//创建10000个数的待选数数组
int size = 10000;
int* arr = (int*)malloc(size * sizeof(int));
assert(arr);
//以随机数给这10000个数赋值
srand(time(0));
for (int i = 0; i < size; i++)
{
    arr[i] = rand() % 1000000;
}
//自定义10个最大的数,方便筛选观察
arr[1] = 1000020;
arr[5] = 1234567;
arr[200] = 89433210;
arr[501] = 1030026;
arr[2000] = 1000027;
arr[5001] = 1003021;
arr[2003] = 1502419;
arr[80] = 1004026;
arr[1998] = 1020305;
arr[8] = 2000000;
//因为要选10个最大数,所以K为10,将数组,容量和K及建小堆规则传入TopK函数中
int k = 10;
int* Toparr = TopK(arr, k, size, Smaller);
//将选好最大10个数的数组打印输出
PrintTopK(Toparr, k);

🌈观察结果

1000020 1003021 1000027 1020305 1030026 1234567 1004026 89433210 2000000 1502419

可以看到,最大的数据恰好符合我们定义的10个最大数,即可证明建K个数的小堆选数成功,因为之前的随机数赋值均与1000000相模,所以生成的随机数不可能超过1000000,而自定义的数据均超过了1000000,观察调试可知:
在这里插入图片描述

而经过筛选后K个数的小堆中:
在这里插入图片描述

其逻辑结构:
在这里插入图片描述

因为选的是大数,建小堆后恰好以小堆结构从TopK函数中选数出来并赋值给实参数组。该函数仅将最大的K个数选了出来,如果想以升降序的顺序排序,就需要调用前面堆排序函数,但在调用前需要注意选好数的数组是什么类型的堆结构:

🌈将上例选好最大10个数的小堆结构数组以降序排序并打印

//选出最大K个数
int* Toparr = TopK(arr, k, size, Smaller);
//最大数以降序排序
HPSort(Toparr, k, Smaller);
//打印
PrintTopK(Toparr, k);

🌈观察结果

89433210 2000000 1502419 1234567 1030026 1020305 1004026 1003021 1000027 1000020

可以看出,堆的排序规则需要与选数规则对应才能排序出有效数值,而如果以选大数的小堆规则传入堆排序并要求其以大堆规则排升序,则会造成数据混乱。如果想将选好数的K个数组排升序,必须先将小堆数组重新建堆建为大堆,再以大堆排序规则传入堆排序函数中,最终可以将选好的K个数以升序打印输出:

//选大数,建小堆
int* Toparr = TopK(arr, k, size, Smaller);
//将小堆数据重新建大堆
HPCreateDown(Toparr, k, Bigger);
//将大堆规则传入堆排序,以升序排序
HPSort(Toparr, k, Bigger);
PrintTopK(Toparr, k);

🌈观察结果

1000020 1000027 1003021 1004026 1020305 1030026 1234567 1502419 2000000 89433210

🌈测试用例2——1000个数中选出最小的5个数

//开辟1000个数的数组并随机赋值
int size = 1000;
int* arr = (int*)malloc(size * sizeof(int));
assert(arr);
srand(time(0));
for (int i = 0; i < size; i++)
{
    arr[i] = rand() % 1000;
}
//自定义便于观察的最小5个数
arr[1] = -100;
arr[501] = -26;
arr[500] = -302;
arr[200] = -150;
arr[8] = -20;
int k = 5;
//选最小数,建大堆
int* Toparr = TopK(arr, k, size, Bigger);
//并以大堆规则传入堆排序,以升序排序打印
HPSort(Toparr, k, Bigger);
PrintTopK(Toparr, k);
//若想排降序,将数组中数据改建小堆,并以小堆结构排降序,打印输出
HPCreateDown(Toparr, k, Smaller);
HPSort(Toparr, k, Smaller);
PrintTopK(Toparr, k);

🌈观察结果

//最小的5个数升序打印
-302 -150 -100 -26 -20
//降序打印
-20 -26 -100 -150 -302

🎃TopK问题总结:

  1. TopK中找最大的前K个数建小堆可以总结为,如果建大堆,其堆顶为最大的数据,该数据和任何其他N-K个数比较都大,导致其他数据都进不去该K个数的堆,以至于最终遍历完全后该堆仍然只有堆顶最大的数是有意义的,所以其他数并不是最大的前K个。如果建立K个数的小堆存储规则,如果N-K个数与堆顶比较,有比它大的就替换堆顶进入小堆内并向下调整,将较大数据沉淀到小堆中的下层,全部对比完后,在该K个数的小堆中留下的就是所有数据中最大的前K个了。

  2. 选第三种方法作为TopK的解决最大优势为,空间复杂度仅需O(K),因为只需要建立K个数的小堆即可,而不用前两种方法需要建立出具有的极大N个数的堆空间。


⭐后话

  1. 博客项目代码开源,获取地址请点击本链接:堆应用:排序和TopK
  2. 若阅读中存在疑问或不同看法欢迎在博客下方或码云中留下评论。
  3. 欢迎访问我的Gitee码云,如果对您有所帮助还可以一键三连,获取更多学习资料请关注我,您的支持是我分享的动力~。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值