上文我们介绍并进行了 堆的模拟实现,可是堆有什么用呢?对数据管理的效率如何?因此这篇博客进行堆的应用—— 堆排序及TopK问题的介绍。
堆排序是堆的应用之一。说到排序,冒泡排序,选择排序肯定是C语言初学者第一个想到的,那需要这么多排序算法吗?它们相比谁更优呢?因此,我们引入时间复杂度和空间复杂度对此进行对比
复杂度介绍
算法在编写成可执行程序后,运行时需要耗费时间资源和空间(内存)资源 。因此衡量一个算法的好坏,一般是从时间和空间两个维度来衡量的,即时间复杂度和空间复杂度。
时间复杂度主要衡量一个算法的运行快慢,而空间复杂度主要衡量一个算法运行所需要的额外空间。在计算机发展的早期,计算机的存储容量很小。所以对空间复杂度很是在乎。但是经过计算机行业的迅速发展,计算机的存储容量已经达到了很高的程度。所以我们如今已经不需要再特别关注一个算法的空间复杂度。
所以本文主要对时间复杂度进行讲解
时间复杂度的概念
时间复杂度的定义:在计算机科学中,算法的时间复杂度是一个函数,它定量描述了该算法的运行时间。一个算法执行所耗费的时间,从理论上说,是不能算出来的,只有你把你的程序放在机器上跑起来,才能知道。但是我们需要每个算法都上机测试吗?是可以都上机测试,但是这很麻烦。所以才有了时间复杂度这个分析方式。一个算法所花费的时间与其中语句的执行次数成正比例,算法中的基本操作的执行次数,为算法的时间复杂度。
即:找到某条基本语句与问题规模N之间的数学表达式,就是算出了该算法的时间复杂度。
- 也就是说,我们是通过程序执行次数来得出的时间复杂度。
- 实际中我们计算时间复杂度时,我们其实并不一定要计算精确的执行次数,因为现在CPU的算力太强了,一般规的数据的计算量根本不在话下,所以只需要大概执行次数,那么这里我们使用大O的渐进表示法。
大O的渐进表示法
大O符号(Big O notation):是用于描述函数渐进行为的数学符号。
推导大O阶方法:
1、用常数1取代运行时间中的所有加法常数。
2、在修改后的运行次数函数中,只保留最高阶项。
3、如果最高阶项存在且不是1,则去除与这个项目相乘的常数。得到的结果就是大O阶。
使用大O渐进表示法可以简洁高效的表达出时间复杂度,简洁的表示也是由于CPU的算力太强。
还需要注意的是有些算法的时间复杂度存在最好、平均和最坏情况:如十个数里面找出一个目标数,最好情况为第一次就找到了,最坏为最后一次才找到,或者找不到,所以在实际中一般情况关注的是算法的最坏运行情况
具体次数 | 大O表示法 | 阶 |
---|---|---|
5201314 | O(1) | 常数阶 |
3n+4 | O(n) | 线性阶 |
3n^2+4n+5 | O(n^2) | 平方阶 |
3log(2)n+4 | O(logn) | 对数阶 |
2n+3nlog(2)n+14 | O(nlogn) | nlog阶 |
n^3 +2n^2+4n+6 | O(n^3) | 立方阶 |
2^n | o(2^N) | 指数阶 |
- log一般情况下默认底数为2
空间复杂度概念
空间复杂度也是一个数学表达式,是对一个算法在运行过程中临时占用存储空间大小的量度 。
空间复杂度不是程序占用了多少bytes的空间,因为这个也没太大意义,所以空间复杂度算的是变量的个数。
空间复杂度计算规则基本跟实践复杂度类似,也使用大O渐进表示法。
注意:函数运行时所需要的栈空间(存储参数、局部变量、一些寄存器信息等)在编译期间已经确定好了,因此空间复杂度主要通过函数在运行时候显式申请的额外空间来确定。
好了,接下来我们围绕时间复杂度进行相关证明
建堆时间复杂度证明
回顾堆的定义:堆总是一颗完全二叉树,而满二叉树又是一颗特殊的完全二叉树。
完全二叉树的定义:
完全二叉树:完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。
对于深度为K的,有n个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从1至n的结点一一对应时称之为完全二叉树。 要注意的是满二叉树是一种特殊的完全二叉树
推导证明
- 对于高度为h的完全二叉树,其节点个数N范围为:[2^(h-1) ~ 2^h-1],完全二叉树节点最大时即为满二叉树,所以满二叉树是一颗特殊的完全二叉树
- 完全二叉树的高度为:log(2)(N+1)。
- 证明如下
掌握以上推导后,建堆的时间复杂度就很好理解了
建堆思路
在实现堆一文中,我们可知建堆是借助AdjustUp来实现的,每插入一个数据就要与堆中数据进行对比,从而建成大堆或小堆。从动画可以看出,以满二叉树为例,一次调整最差的情况就是从当前位置调整到根位置也就是高度次——log(2)(N+1),一共有2^(h-1) 个节点,这其实也是一个等比数列的前N项和,具体证明如下,最终,基于AdjustUp的向上调整建堆的时间复杂度为O(N*logN)
实现堆排序
堆排序即利用堆的思想来进行排序,总共分为两个步骤:
1.建堆
- 升序建大堆
- 降序建小堆
2.利用堆删除思想来进行排序
堆删除思想
在删除堆顶元素时,为了提高效率,我们将首尾元素交换,以最低代价调整堆,这样只需调整高度次log(2)(N+1),即可完成堆的删除操作,时间复杂度仅为O(logN)
堆排序思想
在堆排时,我们也利用这样的思想,因为堆顶元素的大小是可以知道的(大堆则堆顶位置的为堆中最大值,小堆反之),也正是这一特性,我们排升序就得建大堆,排降序就得建小堆(每一轮选出堆中的最值,将其放到当前数组的最后面,直到数组元素有序,这便是堆排序)
但也有不想按照上述思路实现堆排序的,如思路一(该思路不遵从升序建大堆的特性,而是与之相反)
思路一:放入堆中排序
//消耗过大,拷贝复制消耗太大
void HeapSort1(int* a, int n)//此时sizeof(a)为4,是指针的大小,不是数组
{
//得先有一个堆
HP hp;
HPInit(&hp);
//建堆
for (int i = 0; i < n; i++)
{
HPPush(&hp, a[i]);//将数组元素放入堆中
}
//建好堆后将堆顶元素拷回原数组,再pop掉,重新选出最值
int i = 0;
while (!HPEmpty(&hp))
{
int top = HPTop(&hp);//获取堆顶元素
HPPop(&hp);//pop掉
a[i++] = top;//拷贝回原数组
}
PrintArray(a, n);
}
int main()
{
//HPTest();
int a[] = { 0,5,7,9,4,3,1,6,8,2 };
PrintArray(a, sizeof(a) / sizeof(int));
HeapSort1(a, sizeof(a) / sizeof(int));
return 0;
}
以上方法的具体过程写在注释里了,可以按着步骤来看。但这样排好吗?有没有什么不足呢?还有没有优化的地方呢?
- 这种写法看似好理解,但与上述堆排的步骤好像有点不一样
- 仔细看就会发现,我们必须要有一个堆才能进行排序。而且要把数组元素先放进堆中,再将其拷贝回原数组,这样看是不是有点多次一举啊,能不能直接在原数组进行排序呢?
我们回到上文建堆和删除堆处可以发现,所谓建堆和删除堆都是基于向上调整和向下调整实现的,这两大功能是堆排序的核心(其实只用向下调整就可以完成这一任务,效率也更高),所以基于这一思路,也就是上述堆排的思路再次实现堆排序
思路二:原地建堆
图做的有点糙,各位将就看吧
//直接排序
void HeapSort2(int* a, int n)
{
//原地建堆
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{//最开始的调整位置为最后一个父亲节点
AdjustDown(a, n, i);//参数为:数组,元素个数,父亲位下标
}
//升序建大堆
int end = n - 1;//用来控制未进行堆排数组的下标
while (end > 0)
{ //上面已经建好堆,此时堆顶为最值
Swap(&a[0], &a[end]);//交换首尾元素,将最值放到数组后面
AdjustDown(a, end, 0);//选出当前数组的最值
end--;//调整数组中未堆排元素下标
}
PrintArray(a, n);
}
int main()
{
//HPTest();
int a[] = { 0,5,7,9,4,3,1,6,8,2 };
PrintArray(a, sizeof(a) / sizeof(int));
HeapSort2(a,sizeof(a)/sizeof(int));
return 0;
}
具体实现步骤也在注释中
AdjustDown和AdjustUp代码
void Swap(int* p1, int* p2)
{
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
void AdjustUp(int* a, int child)
{
int parent = (child - 1) / 2;
while (child > 0)
{
if (a[child] > a[parent])
{
Swap(&a[child], &a[parent]);
child = parent;
parent= (child - 1) / 2;
}
else
{
break;
}
}
}
void AdjustDown(int* a, int n, int parent)//n为个数。parent为下标
{
int child = parent * 2 + 1;
while (child < n)
{
if (child + 1 < n && a[child + 1] > a[child])
{
child++;
}
if (a[child] > a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
实现堆排序之后,接下来就是性能分析
堆排序时间复杂度证明
通过时间复杂度来进行性能的分析,堆排序的实现包括建堆和排序两部分。
首先对思路一的排序进行分析:
思路一是将数据放入堆中再进行进行排序,所以其建堆时依赖的也是向上调整来建堆,也就是我们上面已经证明过的建堆时间复杂度
- 所以思路一的时间复杂度为O(N*logN)
思路二:堆排序——原地建堆时间复杂度
思路二是利用了AdjustDown来原地建堆进行排序的,所以AdjustDown的建堆时间时间复杂度证明如下:
- 所以思路二的时间复杂度为O(N*logN)
- 通过证明,我们发现思路一和思路二的时间复杂度都是一样的,但是思路一回有大量的数据拷贝,这里会有空间复杂度的消耗,而且不需要有堆,更加简便,所以思路二依旧是更优项。
- 对于AdjustDown和AdjustUp,两者都可以建堆,而且AdjustDown还可以调整堆,因此更胜一筹,所以我们使用AdjustDown来实现堆排就好了(其原因为开始调整的位置不同,导致要调节的节点数量不同)
TopK问题
TOP-K问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。
比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。
对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能数据都不能一下子全部加载到内存中)。最佳的方式就是用堆来解决,基本思路如下:
- 用数据集合中前K个元素来建堆
前k个最大的元素,则建小堆
前k个最小的元素,则建大堆 - 用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素将剩余N-K个元素依次与堆顶元素比完之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素。
总的来说,如果数据量太大,不能同时加载到内存里,这时候就可以将数据放入文件里,再建一个堆,与堆顶元素比较,直到比对完成,此时堆中的数据就是文件数据中的最值。
创造数据
void Createdata()
{
srand(time(0));
int N = 1000000;
FILE* pf = fopen("data.txt", "w");
if (pf == NULL)
{
perror("fopen failed");
return;
}
for (int i = 0; i < N; i++)
{
int x = rand() % 100000;
fprintf(pf, "%d\n", x);
}
fclose(pf);
}
生成随机数,将1000000个数据写入文件中,此时rand模上的是100000,所以文件中是不会有大于等于100000的数据的
建堆找最值
void TopK(int k)
{
int* a = (int*)malloc(sizeof(int) * k);//开辟k个空间做堆
if (a == NULL)
{
perror("malloc failed");
return;
}
FILE* pf = fopen("data.txt", "r");
if (pf == NULL)
{
perror("fopen failed");
return;
}
//找最大,建小堆
for (int i = (k-1-1)/2; i > 0; i--)
{
fscanf(pf, "%d",&a[i]);
AdjustDown(a, k,i);
}
//数据比对,将目标值入堆
while (!feof(pf))
{
int x;
fscanf(pf, "%d", &x);
if (x > a[0])//与堆顶数据相比
{
a[0] = x;
AdjustDown(a, k, 0);//重新调整堆,将最小值放在堆顶
}
}
fclose(pf);
PrintArray(a, k);
free(a);
}
int main()
{
//Createdata();
TopK(5);
return 0;
}
这里找文件数据中的最大值,所以建小堆。
验证
这里手动去文件中修改几个值,让其大于100000,如果等会找到的是这几个值则说明验证成功
- 需要注意数据造一次就好,每次造数据都会将原有数据清空,就又需要去文件中修改值
- 修改后记得保存数据
- 文件打开方法如下
至于堆排序的性能如何,我们在下一篇介绍八大排序时再进行展示。