前言
上篇文章我们讲解了如何用c语言实现堆,本篇我们将对堆这个结构的应用进行挖掘,重点讲解堆排序
一、什么是堆排序,为什么用它?
堆排序,顾名思义,就是利用堆这一数据结构进行排序的一种方法
先前我们提到过,堆这种结构分为大堆小堆,他们本身就是有一定顺序的,那么我们利用堆这种结构进行排序必然会事半功倍,效率提升很多
注:log2N代表log以2为底的N,为方便表示,我们后续用logN代替表示
举个例子,我们现在比较常见的排序方式是冒泡排序、选择排序、快排等等,它们的时间复杂度多为O(N²)或者O(N * log2N),并且在目前快速排序这种排序效率比较高的排序方式中,它的最好情况下的时间复杂度为O(N * logN),最坏情况会达到O(N²),而堆排序这种方法,它的时间复杂度永远都是O(logN),非常稳定效率高,并且占用空间也少,因此它十分适用于大规模的数据排序
看下方图表,我们打个比方,
我们用冒泡排序与堆排序进行比较,冒泡排序的时间复杂度为O(N²),堆排序的时间复杂度为O(N * logN),那么当我们要测量1k个数据时,冒泡排序大概要排序100w次,而堆排序则是1w次;当测量100w个数据时,冒泡排序要进行1万亿次,而堆排序只需要2000w次,可见当后面数据规模越大的时候,两者差距越发明显,这就体现出了堆排序的优点
二、堆排序的实现
下面我们讲解一下如果用代码去实现堆排序
1.建大堆还是建小堆?
我们知道,堆有两种结构,一种是大堆,一种是小堆,那么我们如何根据排序要求进行建堆呢?
举个例子,如果我们要求对数据进行升序排序,那么我们是建大堆还是小堆呢?
可能有些老铁会想,大堆嘛,越来越小,小堆,越来越大,那肯定是小堆更契合升序的要求,于是就选择了小堆
但这种想法是不对的,当我们以小堆进行建堆来进行升序排序时,我们会发现,我们会发现堆顶的是最小的数据,根据它来比较是没有意义的,而且我们无法确定兄弟结点之间的大小,也无法确定堂兄弟结点的父结点与自身之间的大小,这就不能当作topk问题去解决,也不能拿出最后一层的几个结点,因为你无法保证最后一层的某个结点的父结点是不是会大于最后一层的某个结点,这些都是有可能的,因此建小堆会很麻烦,准确度也不高,因此我们要采取建大堆的方式来进行排序
当我们建大堆进行排序时,我们可以发现,最大的数据是在堆顶的,那么我们上节课我们提到的删除功能在这里有了联系,我们提到过,堆可以用来进行排序,插入是无法进行堆排序的,那么删除就派上了用场
当我们删除堆的一个数据时,删的是堆顶数据,而我们的最大数据就在堆顶,删除后最大数据与堆尾的数据进行交换,然后堆尾数据到了堆顶,我们进行向下调整来维持大堆的基本结构,调整完之后,我们堆顶的数据便是所有数据中第二大的数据(第一大的数据在堆尾),此时再进行删除,将堆顶数据与堆尾数据进行交换(此时的堆尾已经更新成最后一层倒数第二个数据了,并非是交换过去的最大的数据),再进行向下调整,后面按照这种方式依次进行,依次删除
当全部交换完之后,我们会发现原本的大堆已经变成了一个小堆,是一个依次递减完全有序的小堆,从数组方面看,就是一个升序数组,所有大的数据都排到了堆尾部分,此时我们就获得了一个升序数组,排序就完成了
如图所示部分步骤,后面一直重复即可(删除不是真的删除,只是堆尾的end一直减1,但是数据仍然存在于数组中,我们依然可以访问)
2、怎么实现?
代码实现:
有的老铁疑问,这个堆排序建堆是怎么实现的,用堆排序的时候总不能现场搓一个堆出来
为此,我们可以用向上调整和向下调整进行建堆(两者任选其一即可),我们图中选择的是向下调整建堆,因为这种方式更加便捷,我们先提供一下向上建堆该修改
for(int i = 1;i < n;i++)
{
AdjustUp(a,i);
}
将模拟建堆部分修改如上即可
3、怎么模拟建堆?采用哪种方式?为什么?
至于为什么要用向下调整建堆,这种方式为什么更加便捷,我们现在可以来推导一下
我们可以根据图片分析,当向下调整时,每层结点越多的时候,每个结点要调整的次数越少,当结点最多的时候(最后一层不计),仅需要调整1层也就是2 ^ (h - 1) * 1;而当向上调整时,假设从最后一层的最后一个开始,它一共要向上调整h - 1次,而最后一层结点是最多的,那么就是当结点最多的时候,需要调整的次数也最多,也就是 2 ^ (h - 1) *(h - 1)因此我们可以粗略地估计出向下调整建堆的效率是高于向上调整建堆的
根据代码和图片来看,当我们选择向下调整建堆时,可以计算出建堆的总次数为S(每层结点个数和每层结点要向下调整的次数的乘积和),之后我们可以运用错位相减法进行计算
2S = 2 ^ 1 * (h - 1) + 2 ^ 2 * (h - 2) + 2 ^ 3 * (h - 3) + … + 2 ^ (h - 1) * 1
那么可以求得:
S = 2 ^ (h - 1) + 2 ^ (h - 2) + … + 2 ^ 3 + 2 ^ 2 + 2 ^ 1 + 2 ^ 0 - h = 2^h - 1 - h
而我们之前得到,N = 2 ^ h - 1,(N为结点总数),那么h = log(N + 1)
那么:S = N - log(N + 1),可近似认为S = N - logN,最后近似为S = N,
得到总次数为N,时间复杂度即为O(N)
即:向下调整模拟建堆的时间复杂度为O(N)
而向上建堆的话,则是S = 2 ^ (h - 1) * (h - 1) + 2 ^ (h - 2) * (h - 2) + 2 ^ (h - 3) * (h - 3) + … + 2 ^ 2 * 2 + 2 ^ 1 * 1;
依旧通过错位相减法,可以计算出S = -2 ^ 1 - 2 ^ 2 - 2 ^ 3 - … - 2 ^ (h - 2) - 2 ^ (h - 1) + 2 ^ h * (h - 1),补一个2 ^ 0 ,再减掉一个,我们可以计算出S = 2 ^ h * h - 2 ^ h - 2 ^ h + 2
又因为N = 2 ^ h - 1,(N为结点总数),那么N - 1 = 2 ^ h - 2
所以;S = N * logN - 2N = N(logN - 2) = N * logN
也就是说,向上调整模拟建堆的时间复杂度为N * logN,比之向下调整模拟建堆的效率要低,因此我们多选用向下调整模拟建堆
不过这两种方法都可以,因为我们堆排序的时间复杂度为O(N * logN),因此整个程序的时间复杂度也是O(N * logN),所以不必太纠结
总结
以上便是本篇的全部内容,后面我们会讲解topk问题,该问题类似于堆排序并借助其进行查找,但实现方式又不完全相同,敬请期待!