堆的应用—堆排序和TopK问题

📣:在上篇文章中我们大致讲了堆的实现过程,本篇文章我们主要讲的是堆的最常见应用—堆排序和TopK问题。

目录

 一、堆排序

 1:手动建堆排序

 2:原数组建堆排序

二、Top-K问题 


 一、堆排序

1:手动建堆排序

假如我们随便给定一个数组:

🚀:接下来我们用小根堆来实现数组的升序。 

· 思路:

Step1:把数组中的每个元素插入到堆中

Step2:因为我们创建的是小堆,所以我们把堆顶的数据赋值到数组中,每次赋值完就删除堆顶元素,重新调整堆。重复上述操作,直到堆为空。

🍒代码如下:

//堆排序-升序
void HeapSort(int* a, int size)
{
	//创建堆结构并初始化
	HP hp;
	HeapInit(&hp);

	//将数组中的元素插入堆中
	for (int i = 0; i < size; i++)
	{
		HeapPush(&hp, a[i]);
	}

	size_t j = 0;
	//依次遍历取堆顶数据赋值给数组
	while (!HeapEmpty(&hp))
	{
		a[j] = HeapTop(&hp);
		j++;
		HeapPop(&hp);
	}

	//销毁堆
	HeapDestory(&hp);
}

int main()
{
	int a[] = { 3,2,6,4,1,9,5,8,7 };
	HeapSort(a, sizeof(a) / sizeof(int));  //堆排序
	for (int i = 0; i < sizeof(a) / sizeof(int);i++)
	{
		printf("%d ", a[i]);
	}

	printf("\n");
	return 0;
}

🍒效果如下:

 复杂度分析:

//将数组中的元素插入堆中
	for (int i = 0; i < size; i++)
	{
		HeapPush(&hp, a[i]);
	}

我们从上篇文章中了解到HeapPush的时间复杂度为O(logN),因为每循环一次就进行一次插入,所以上述代码的时间复杂度为O(N*logN).

//依次遍历取堆顶数据赋值给数组
	while (!HeapEmpty(&hp))
	{
		a[j] = HeapTop(&hp);
		j++;
		HeapPop(&hp);
	}

HeapPop和HeapPush的时间复杂度相同,所以此段代码的时间复杂度也为O(N*logN).

🤔分析:


综上,手动建堆的时间复杂度为O(N*logN)。其时间复杂度相对与我们之前学的冒泡排序O(N^2)优化了不少,但其代码量较大,并且堆的实现需要动态开辟,其空间复杂度达到了O(N)。那我们可不可以再进行下一步的优化呢?

优化:

我们知道堆的物理结构为顺序表即为数组,那我们为何不直接在数组上建堆呢?直接在数组上建堆,其空间复杂度就优化为O(1)。

 2:原数组建堆排序

既然我们要在原数组上建堆,那我们首先就需要把数组里面的元素看成堆结构。

对于直接在数组上建堆我们有两种方法:

1.向上调整建堆

2.向下调整建堆 


向上调整建堆:

· 思路:

将数组中的第一个元素看成是一个堆,然后每插入一个数字就进行一次向上调整算法,确保堆为小堆。

🍒画图演示:

🌽代码如下:

//交换数值
void Swap(int* pa, int* pb)
{
	int tmp = *pa;
	*pa = *pb;
	*pb = tmp;
}

//堆的向上调整算法
void AdjustUp(HPDataType* a, size_t child)
{
	size_t parent = (child - 1) / 2;
	while (child > 0)
	{
		if(a[child]>a[parent]) //大根堆
		if (a[child] < a[parent])   //小根堆
		{
			Swap(&a[child], &a[parent]);
			child = parent;
			parent = (child - 1) / 2;
		}
		else
		{
			break;
		}
	}
}

//向上调整建堆
void HeapSort(int* a, int n)
{
	int i = 0;
	for (i = 1; i < n; i++)  //从数组的第二个元素开始进行调整
	{
		AdjustUp(a, i);
	}
}
int main()
{
	int a[] = { 3,2,6,4,1,9,5,8,7 };
	HeapSort(a, sizeof(a) / sizeof(int));  //堆排序
	for (int i = 0; i < sizeof(a) / sizeof(int);i++)
	{
		printf("%d ", a[i]);
	}

	printf("\n");
	return 0;
}

🍊效果如下:

符合小堆的性质。

向下调整建堆:

存在问题:

对于向下建堆的前提条件是:必须确保根结点的左右子树均为小树。而刚开始数组是乱序的,所以我们无法直接在数组上进行向下建堆。

解决方法:

从倒数第一个非叶结点开始进行向下调整,从下往上调。

· 思路:

下面我们再回顾一下父亲和孩子之间的关系:

1.leftchild=parent*2+1

2.rightchild=parent*2+2

3.parent=(child-1)/2

从父结点和字结点之间的关系我们能够得到结论:倒数第一个非叶结点就是最后一个结点的父亲结点。当我们找到这个结点,把它和它的孩子看成一个整体,进行向下调整。然后刷新父亲结点依次循环下去。

🍒画图演示:

 🌽代码如下:

//交换数值
void Swap(int* pa, int* pb)
{
	int tmp = *pa;
	*pa = *pb;
	*pb = tmp;
}

//堆的向下调整算法
void AdjustDown(HPDataType* a, size_t size, size_t root)
{
	int parent = root;
	int child = 2 * parent + 1;   //默认较小孩子结点为左孩子结点
	while (child < size)
	{
		//确保child对应的值为较小孩子结点的值
		if (child + 1 < size && a[child + 1] < a[child])  //确保右孩子结点在堆得的范围内
		{
			child++; //此时child中存的值为右孩子结点中的值
		}
		//如果孩子结点小于父亲结点则交换,并继续向下调整
		if (a[child] < a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = 2 * parent + 1;
		}
		//如果孩子结点大于父亲结点,则结束调整
		else
		{
			break;
		}
	}

}

//向下调整建堆
void HeapSort(int* a, int n)
{
	int i = 0;
	for (i = (n-1-1)/2; i>=0; i--)  //从数组的倒数第一个非叶结点开始进行调整
	{
		AdjustDown(a, n,i);
	}
}
int main()
{
	int a[] = { 3,2,6,4,1,9,5,8,7 };
	HeapSort(a, sizeof(a) / sizeof(int));  //堆排序
	for (int i = 0; i < sizeof(a) / sizeof(int);i++)
	{
		printf("%d ", a[i]);
	}

	printf("\n");
	return 0;
}

🍊效果如下:

 符合小堆的性质。


向上调整建堆和向下调整建堆的性能分析:

下面我们以一个满二叉树为例,分析向上建堆和向下建堆的时间复杂度

 向上建堆:

计算向上建堆的时间复杂度计算的就是堆的最坏情况下的调整次数。通过上文我们知道是从数组的第二个元素开始进行调整的,也就是从树的第二层开始,最坏情况下第二层每个结点的调整次数最多一次,一个有两个结点。接下来第三层每个结点的调整次数最多二次,一共有四个结点。我们可以发现规律:每层的调整次数=2^(k-1)*(k-1) (k为层)。各层累计相加最后得到结果。

 

通过计算我们得到向上建堆的时间复杂度为:O(N*logN)。 

 向下建堆:

从上文中我们知道向上建堆是从倒数第一个非叶结点开始调整的,且:每层的调整次数=2^(k-1)*(k-1) (k为层)。所以我们只需要将第一层到倒数第二层的总次数相加,计算过程和向上调整一个思路。

 

 通过计算我们得到向上建堆的时间复杂度为:O(N)。 

 时间复杂度对比:

向上建堆:O(N*logN)

向下建堆:O(N)

通过时间复杂度的对比我们可以发现和向上建堆相比,向下建堆的时间复杂度更优。


排序建大小堆分析:

解析:从上文中我们得到结论向下建堆更优,但升序能够用建小堆来解决吗?我们知道小堆的第一个元素是最小的,如果我们用小堆进行升序,那我们就需要从第二个元素开始继续看成是一个堆,此时已不符合小堆性质。如果我们用小堆升序,也就意味着我们要建N(N为数组元素个数)次堆,这样做根本没意义,还不如直接遍历数组。

结论:

升序建大堆

降序建小堆

🚀下面我们以升序为例,降序同理。

首先我们来看一下建好大堆的样子:

 ·思路:

当我们建完堆后,此时堆顶就是最大的数据,现在我们将堆顶的数据和最后一个数据进行交换交换完后数组中数据个数size--,把最后一个数字不看做堆里的。此时左子树和右子树仍然是大堆,继续进行向下调整。

🍒画图演示:

  🌽代码如下:

//数据交换
void Swap(HPDataType* pa, HPDataType* pb)
{
	HPDataType tmp = *pa;
	*pa = *pb;
	*pb = tmp;
}

//堆的向下调整算法
void AdjustDown(HPDataType* a, size_t size, size_t root)
{
	int parent = root;
	int child = 2 * parent + 1;   //默认较小孩子结点为左孩子结点
	while (child < size)
	{
		//确保child对应的值为较小孩子结点的值
		if (child + 1 < size && a[child + 1] > a[child])  //确保右孩子结点在堆得的范围内
		{
			child++; //此时child中存的值为右孩子结点中的值
		}
		//如果孩子结点小于父亲结点则交换,并继续向下调整
		if (a[child] > a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = 2 * parent + 1;
		}
		//如果孩子结点大于父亲结点,则结束调整
		else
		{
			break;
		}
	}

}

//升序
void HeapSort(int* a, int n)
{
	int i = 0;
	for (i = (n-1-1)/2; i>=0; i--)  //从数组的倒数第一个非叶结点开始进行调整
	{
		AdjustDown(a, n,i);
	}

	//大堆升序
	size_t end = n - 1;
	while (end > 0)
	{
		Swap(&a[0], &a[end]);
		AdjustDown(a, end, 0);
		end--;
	}
}
int main()
{
	int a[] = { 3,2,6,4,1,9,5,8,7 };
	HeapSort(a, sizeof(a) / sizeof(int));  //堆排序
	for (int i = 0; i < sizeof(a) / sizeof(int);i++)
	{
		printf("%d ", a[i]);
	}

	printf("\n");
	return 0;
}

🍊效果如下:

二、Top-K问题 

Top-K问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大

比如:成绩前十名、世界富豪排行榜、企业世界五百强等等。

对于解决Top-K问题,我们能想到很多种方法,例如:

1.排序—时间复杂度:O(N*logN)。空间复杂度O(1)。可进一步优化。

2.建立N个数的大堆,Pop k次,就可以得到最大的前K个—时间复杂度:O(N+logN*K)。空间复杂度:O(1)

存在问题:

当N远大于K,上述方法就会存在问题,例如当我们从100亿个数中找出最大的前十个,上述方法就会导致内存不足,现在让我们分析一下100亿个数需要多少空间。

1G=1024MB

1024MB=1024*1024KB

1024*1024KB=1024*1024*1024Byte≈10亿字节

一个整数占据内存空间4个字节,100亿个整数就占据400亿个字节,相当于40G内存空间

电脑中的运行内存一般为16G,顶配电脑最多也不过32G,对于40G个数据根本放不下,而这也说明了100亿个整数是放在磁盘中。

解决方案:

用前K个数建立一个K个数的小堆,然后依次遍历剩下的N-k个数字,如果遍历到的数字比堆顶的数据大,就替换进堆,最后堆里面的K个数就是最大的K个。

复杂度:

时间复杂度:O(K+logK*(N-k))

空间复杂度:O(K)

🚀:接下来我们将从1w个数中找出最大的前10个数

//堆的向下调整算法
void AdjustDown(HPDataType* a, size_t size, size_t root)
{
	int parent = root;
	int child = 2 * parent + 1;   //默认较小孩子结点为左孩子结点
	while (child < size)
	{
		//确保child对应的值为较小孩子结点的值
		if (child + 1 < size && a[child + 1] < a[child])  //确保右孩子结点在堆得的范围内
		{
			child++; //此时child中存的值为右孩子结点中的值
		}
		//如果孩子结点小于父亲结点则交换,并继续向下调整
		if (a[child] < a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = 2 * parent + 1;
		}
		//如果孩子结点大于父亲结点,则结束调整
		else
		{
			break;
		}
	}

}

void PrintTopK(int* a, int n, int k)
{
	//用数组中前K个元素建堆
	int* KminHeap = (int*)malloc(sizeof(int) * k);
	assert(KminHeap);
	for (int i = 0; i < k; i++)
	{
		KminHeap[i] = a[i];
	}
	//建小堆
	for (int j = (k - 1 - 1) / 2; j >= 0; j--)
	{
		AdjustDown(a, k, j);
	}
	//遍历剩下的N-k个数据,如果遍历到的数字比堆顶的数据大,就替换进堆
	for (int i = k; i < n; i++)
	{
		if (a[i] > KminHeap[0])
		{
			KminHeap[0] = a[i];
			AdjustDown(KminHeap, k, 0);
		}
	}
	for (int j = 0; j < k; j++)
	{
		printf("%d ", KminHeap[j]);
	}
	printf("\n");
	free(KminHeap);
}

void TestTopk()
{
	int n = 10000;
	int* a = (int*)malloc(sizeof(int) * n);
	srand(time(0));
	for (size_t i = 0; i < n; i++)
	{
		a[i] = rand() % 10000; //产生的随机值都小于1w
	}
	//1w个数据中随机赋值10个数值超过1w的数
	a[5] = 10000 + 1;
	a[234] = 10000 + 2;
	a[567] = 10000 + 3;
	a[1123] = 10000 + 4;
	a[3214] = 10000 + 5;
	a[3567] = 10000 + 6;
	a[4332] = 10000 + 7;
	a[6653] = 10000 + 8;
	a[5563] = 10000 + 9;
	a[7864] = 10000 + 10;
	PrintTopK(a, n, 10);
}
int main()
{
	TestTopk();
	return 0;
}

🍊效果如下:

  • 6
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值