目录
3.交换后的堆顶元素采用向下调整算法,将合适的元素沉到堆底部
2.让堆顶的元素与堆尾的元素进行交换,交换后除过堆的最后一个元素之外,堆顶元素继续采用向下调整算法,最终排序完成
上期内容,我们主要学习了堆的基本知识点,掌握了堆的相关操作,紧接上期,本期的内容主要就是关于堆的应用,像什么经典的TOP-K问题,堆排序,这些问题都涉及到了堆的应用,我们直接进入正题。
堆的应用
TOP-K问题
既然我们要学习TOP-K问题,就得知道什么是TOP-K问题。
TOP-K问题:即求数据结合中前K个最大的元素或者最小的元素。需要注意的是,这里的K值往往是比较大的值。就比如说,我们要找出一个班中成绩排名前10的学生,这就是一个简化的TOP-K问题。
既然我们提出了TOP-K问题,那么我么就得解决这个问题,那么解决这个问题的方法是什么呢?我们先给出解决步骤,在每个步骤下面我会一一解释:
如果对于较小的数据集合,我们可以通过排序算法来解决,但是,当数据量很大时,我们知道排序中时间复杂度最优都是N*logN(排序相关的知识我们后期会讲解,大家先作为了解),所以此时排序算法就不再适合,我们主要通过堆这个数据结构来解决这个问题:
1.先建一个K个元素的堆
如果要找出最大的前K个数,就用数组的前K个数建小堆;
如果要找出最小的前K个数,就用数组的前K个数建大堆;
本次我们默认是求N个数最大的前K个数。
//创建一个K个数的小堆
HP hp;
HeapInit(&hp);
for (int i = 0; i < k; i++)
{
HeapPush(&hp, a[i]);
}
2.剩下的N-K个元素依次和堆顶元素来比较交换
for (int j = k; j < n; j++)
{
if (a[j] > HeapTop(&hp))
{
hp.a[0] = a[j];
}
}
3.交换后的堆顶元素采用向下调整算法,将合适的元素沉到堆底部
for (int j = k; j < n; j++)
{
if (a[j] > HeapTop(&hp))
{
hp.a[0] = a[j];
AdjustDown(hp.a,k,0);
}
}
4.整体代码
TOP-K问题算法实现代码
void PrintTopK(int* a, int n, int k)
{
//创建一个K个数的小堆
HP hp;
HeapInit(&hp);
for (int i = 0; i < k; i++)
{
HeapPush(&hp, a[i]);
}
//剩下的N-K个元素与堆顶的元素比较,如果比堆顶的元素大,就替换
for (int j = k; j < n; j++)
{
if (a[j] > HeapTop(&hp))
{
hp.a[0] = a[j];
AdjustDown(hp.a,k,0);
}
}
HeapPrint(&hp);
HeapDestroy(&hp);
}
TOP-K问题测试代码
int main()
{
int n = 10000;
int* a = (int*)malloc(sizeof(int) * n);
srand(time(0));
for (int i = 0; i < n; ++i)
{
a[i] = rand() % 10000;
}
// 再去设置10个比10000大的数
a[990] = 10000 + 1;
a[991] = 10000 + 2;
a[992] = 10000 + 3;
a[993] = 10000 + 4;
a[994] = 10000 + 5;
a[995] = 10000 + 6;
a[996] = 10000 + 7;
a[997] = 10000 + 8;
a[998] = 10000 + 9;
a[999] = 10000 + 10;
PrintTopK(a, n, 10);
return 0;
}
结果展示
5.算法整体分析
Q:求N个数中最大的前K个数,为什么建小堆呢?
A:我们知道,小堆中堆顶的元素是最小的,从堆顶自上至下元素依次是增大的,所以我们可以称小堆中比较大的元素是沉在堆底部的,我们建了一个小堆之后,就可以让剩余的N-K个元素依次和堆顶的元素进行比较替换,然后用向下调整算法,最终大的数全部沉到堆底,最终小堆中的数就是N个数中最大的前K个数。
Q:大堆为什么不行呢?
A:大家可以想一想一种极端的情况,如果我们所建立的大堆的堆顶的元素就是N个数中最大的数,那么就会导致剩余的N-K个数都比堆顶的数小,因为TOP-K算法思想就是比堆顶的元素大才替换,但是在这种情况下,N-K中的所有数都比堆顶的元素小,所以没有机会去替换堆顶的元素,这就是导致了我们最终只找出了N个数中最大的一个数,其它K-1个较大的数根本就没有机会进堆,这就是我们不建大堆的原因。
上述整过程都是求最大的前K个数,求最小的前K个数也是同理的,大家可以自行分析。
堆排序
之前我们接触过了冒泡排序,今天我们要学习一种新的排序,堆排序。
我们依然给出堆排序的整个算法流程,最后我会一一向大家解释。
1.建堆
如果我们要排升序,建立大堆;
如果我们要排降序,建立小堆;
为什么排升序要建大堆,排降序要建立小堆呢?
如果我们排升序建立了小堆,那么我们就可以找到最小的元素,但是我们将最小的元素取出之后,剩余的元素就不再是小堆了,堆的结构会发生改变,我们只好再去重新建立堆,这是非常复杂的,但是如果我们建立了大堆之后,整个排序会非常的简便,只需要合理利用向上调整算法或者向下调整算法即可。具体的实现我们可以接着往下看:
这里建堆,我们要用到两个算法,向上调整算法和向下调整算法,我们在上期讲过这两个算法,详情请点击这里。
这里给出了两个建堆的方法,分别使用向下调整算法和向下调整算法建立堆。
方法一:使用向上调整算法建堆
之前我们在进行堆的相关操作时,我们在插入堆元素的同时进行了向上调整算法,堆排序中我们可以认为数组的元素已经全部插入了堆中,但是在插入时我们没有进行向上调整算法,而是在将要排序的数组的元素全部插入堆中之后再次采用向上调整算法调整堆,采用向上调整算法的前提是,在未插入元素之前,原有的堆必须是大堆或者小堆。
在排序算法中的堆中的元素单单只是插入了堆,并没有进行向上调整算法,所以此时的堆既不是大堆也不是小堆, 我们要进行向上调整算法只能从第二个元素开始进行向上调整,因为堆中只有一个元素时,我们可以认为这个堆既是大堆也是小堆。就这样依次往后进行调整,最终整个堆便成了大堆。
for (int i = 1; i < n; i++)
{
AdjustUp(a, i);
}
方法二: 使用向下调整算法建堆
在堆的操作那期中,我们运用向下调整算法的场景就是,在删除某个元素时,我们将堆顶的元素与堆底的元素进行了交换,然后再让堆顶的元素采用了向下调整算法,最终保留了堆的特性,删之前堆是大堆(小堆),删除之后,运用向下调整算法最终保证删除堆顶元素之后的堆仍然是大堆(小堆)。
但是使用向下调整算法的前提是向下调整的元素的左右子树都必须是大堆(小堆),与向上算法同理,只有当堆中只有一个元素时,这个堆可以说它既是大堆也是小堆。所以对于排序中的对采用向下调整算法时,必须从堆的最后一节点的父亲节点开始进行向下调整算法,这样依次进行调整,最终整个堆就变成了大堆。
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(a, n, i);
}
建堆的时间复杂度
建堆的时间复杂度的分析要用到高中错位相减的相关知识, 从倒数第二层开始进行向下调整,倒数第二层的元素向下调整一次,倒数第三层的元素最多向下调整两次,依次往上,然后再根据每层的元素的个数,以及每层调整的次数,最终算出建堆的总共的次数就是建堆的复杂度,这里就不为大家证明,直接给出结论:
向上调整算法建堆的时间复杂度为O(N*logN),N为堆中元素的个数。
向下调整算法建堆的时间复杂度为O(N)。
2.让堆顶的元素与堆尾的元素进行交换,交换后除过堆的最后一个元素之外,堆顶元素继续采用向下调整算法,最终排序完成
for (int end = n - 1; end >= 0; end--)
{
HPDataType tmp = a[0];
a[0] = a[end ];
a[end] = tmp;
AdjustDown(a, end, 0);
}
3.堆排序整体代码
void HeapSort(int* a,int n)
{
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(a, n, i);
}
for (int end = n - 1; end >= 0; end--)
{
HPDataType tmp = a[0];
a[0] = a[end ];
a[end] = tmp;
AdjustDown(a, end, 0);
}
}
堆排序的时间复杂度:
对于完全二叉树,当完全二叉树的元素很多时,堆底部几层的元素几乎就占了很大一部分,我们知道堆高度h=log2(N+1),采用向下调整算法,每次要将堆顶的元素进行向下调整,要调整logN-1次(也有logN-2,logN-3,我们近似的理解为交换了logN次),因为堆底部几层的元素占了很大一部分,所以可以近似的理解为,大部分的元素都交换了logN次,所以就可以说堆排序总共交换了N*logN次,所以堆排序的时间复杂度为O(N*logN)
4.堆排序测试代码
int main()
{
int arr[] = { 10,30,26,47,90,198,27,34,45,24,46,29 };
HeapSort(arr,sizeof(arr)/sizeof(int));
for (int i = 0; i < sizeof(arr) / sizeof(int); i++)
{
printf("%d\n", arr[i]);
printf("\n");
}
}
5.结果展示
6.算法整体分析
Q:为什么排升序要建立大堆呢?小堆为什么不行呢?
A:如果我们建小堆会发生什么呢,如果我们建立个小堆,回到堆排序的第二步,此时交换,并且进行向下调整之后,最终我们最终会得到一个降序的序列,所以我们是不去建立小堆的。
总结
到了这里,数据结构中堆的相关知识点就这些了,我们来一起回顾一下。
1.堆是一种特别的完全二叉树,但是完全二叉树不一定是堆。
2.如果我们要找N个数中最大的前K个数,就要建小堆,反之亦然。
3.如果我们要排升序就要建大堆,反之亦然。