前言:
本章我们来讲解八大排序之堆排序。2022,地球Online新赛季开始了!让我们一起努力内卷吧!
一、堆排序的概念
📚 堆排序(Heapsort):利用堆积树(堆)这种数据结构所设计的一种排序算法,它是选择排序的一种。通过堆来进行选择数据,需要注意的是 排升序要建大堆,排降序建小堆。
堆排序使用堆来选数,效率就高了很多。
时间复杂度:
空间复杂度:
稳定性:不稳定
二、堆排序的实现
我们先创建一个堆排序的函数:
void HeapSort(int arr[], int n);
假设我们要对下列数组来使用堆排序(升序):
int arr[] = {70, 56, 30, 25, 15, 10, 75};
根据我们之前学到的知识,数组是可以直接看作为完全二叉树的,所以我们可以把它化为堆。此时我们就可以 "选数" (堆排序本质上是一种选择排序)。
第一步:构建堆
第一步就是要想办法把 arr 数组构建成堆(这里我们先构建成小堆)。我们介绍两种方法,分别为向上调整算法和向下调整算法:
方法1:向上调整
通过我们之前学过的 "向上调整" ,利用插入的思路来解决。首先设计下向上调整的算法:
void Swap(HPDataType* px, HPDataType* py) {
HPDataType tmp = *px;
*px = *py;
*py = tmp;
}
/* 小堆的向上调整 */
void AdjustUp(int* arr, int child) {
assert(arr);
// 首先根据公式计算算出父亲的下标
int parent = (child - 1) / 2;
// 最坏情况:调到根,child=parent 当child为根节点时结束(根节点永远是0)
while(child > 0) {
if(arr[child] < arr[parent]) { // 如果孩子小于父亲(不符合小堆的性质)
// 交换他们的值
Swap(&arr[child],&arr[parent]); // 传地址
// 往上走
child = parent;
parent = (child - 1) / 2;
} else { // 如果孩子大于父亲(符合小堆的性质)
// 跳出循环
break;
}
}
}
① 首先,通过公式计算出父亲的下标,公式如下:
② 其次,设计循环部分,最坏情况为调到根,如果已经符合大堆的条件则终止循环。
③ 进入循环后进行判断,如果 child > parent,则交换它们的值,让父亲获得孩子的值,孩子得到父亲的值,从而让它们符合大堆的性质。
④ 交换完毕后,以便于继续判断,我们需要更新 child 和 parent 指向的元素,做到 "往上走"
此时,我们把数组里的元素依次调整即可。
💬 方法1:
/* 升序 */
void HeapSort(int arr[], int n) {
for (int i = 1; i < n; i++) {
AdjustUp(arr, i); // 传入数组 和 child的下标
}
}
我们不需要从 0 开始调,从 1 开始调。所以 i 的起始值设置为 1 。此时,小堆就构建好了。
方法2:向下调整
向下调整算法我们在堆那个章节也学过了,这里我们再来复习一下:
void SmallAjustDown(int* arr, int n, int parent) {
int child = parent * 2 + 1; // 默认为左孩子
while(child < n) { // 叶子内
// 选出左右孩子中小的那一个
if(child + 1 < n && arr[child + 1] < arr[child]) {
child = child + 1;
}
// 如果孩子小于父亲(不符合小堆的性质)
if(arr[child] < arr[parent]) {
// 交换它们的值
Swap(&arr[child], &arr[parent]);
// 往下走
parent = child;
child = parent * 2 + 1;
} else { // 如果孩子大于父亲(符合小堆的性质)
// 跳出循环
break;
}
}
}
① 因为要考虑左孩子还是右孩子,我们可以定义两个变量,分别记录左孩子和有孩子。但是我们这里可以用更好的方法,只需要定义一个孩子即可。具体实现方法如下:首先创建 child,我们先默认它是左孩子,利用传进来的 parent 根据公式计算出 child 的大小:
因为我们暂且默认为左孩子,我们随后进入循环后要进行检查,看看是左孩子大还是右孩子大,这里我们只需要根据数组的性质,将 child + 1 即可得到右孩子的下标,从而方便我们进行比较。比较完毕后将 child 重新赋值,拿个孩子小就将 child 给谁。
② 一共有两个结束条件(出口),第一个结束条件是父亲小于孩子就停止,第二个结束条件是chi调整到叶子。所以,循环体判断部分根据我们分析的结束条件,如果调整到叶子就结束,我们接受了 n,也就是数组的大小,只要 child 超过数组的大小(child > n) 就结束,这是第一个出口。
③ 进入循环后,对比左孩子和右孩子哪一个更小,child 就交付给谁。这里还要注意的是,要防止孩子不存在的情况,如果 child + 1 比 n 大,就说明孩子不存在。所以我们再写 if 判断条件的时候就要注意了,我们加一个 child + 1 < n 的条件限制一下:
if(child + 1 < n && arr[child + 1] < arr[child]) {...}
④ 确认好较小的孩子后,我们就可以开始比较孩子和父亲的大小了。如果孩子小于父亲,就不符合小堆的性质,我们就要交换它们的值。这里我们直接调用我们刚才写的 Swap 接口即可,这就是为什么在写向上调整的时候要我们单独把交换部分的代码写成函数的原因。
⑤ 交换完毕后,他们的值已经互相交换好了,这时我们要改变 parent 和 child 的指向,让它们往下走,parent = child ,child 再次根据公式算出新的 child 即可。
⑥ 设计下 if 的 else 部分,如果数组的 child 的值大于 parent 的值,说明符合小堆的性质了, break 跳出循环即可,这是第二个出口。
向下调整算法的前提:左右子树必须都是小堆
如果左子树和右子树不是小堆,怎么办?
可以用递归解决,但是我们能用循环就用循环来解决:
叶子所在的子树是不需要调的。所以,我们从倒着走的第一个非叶子结点的子树开始调。
(所以我们先调整30)
为了能够演示得更明显,我们再给数组再加点数据,假设我们需要对以下数组排序:
这里,我们找到了15。
…… 下标不断地 - - 后:
由于,从倒着走的第一个非叶子结点的子树开始调,即,最后一个节点的父亲。
我们已知最后一个节点的下标为:
那么,我们可以通过公式计算出他父亲的下标:
💬 方法2:
/* 升序 */
void HeapSort(int arr[], int n) {
for (int i = (n - 1 - 1) / 2; i >= 0; i--) {
AdjustDown(arr, n, i);
}
}
⚡ 这么写或许可以看得更明白:
/* 升序 */
void HeapSort(int arr[], int sz) {
int father = ((sz - 1) - 1) / 2; // 计算出最后一个叶子节点的父亲
while (father >= 0) {
AdjustDown(arr, sz, father);
father--;
}
}
🐞 测试堆是否创建好了:
我们这里选择使用方法2。此外,我们刚才实现的是小堆。
#include <stdio.h>
/* 交换函数 */
void Swap(int* px, int* py) {
int tmp = *px;
*px = *py;
*py = tmp;
}
/* 小堆下调 */
void AdjustDown(int arr[], int sz, int father_idx) {
int child_idx = father_idx * 2 + 1; // 计算出左孩子的值(默认认为左孩子大)
while (child_idx < sz) { // 最坏情況:调到叶子(child >= 数组范围时必然已经调到叶子)
if ((child_idx + 1 < sz) && (arr[child_idx + 1] < arr[child_idx])) { // 如果右孩子存在且右孩子比左孩子小
child_idx = child_idx + 1; // 让其代表右孩子
}
if (arr[child_idx] < arr[father_idx]) { // 如果孩子的值小于父亲的值(大符合小堆的性質)
Swap(&arr[child_idx], &arr[father_idx]); // 交换它们的值
/* 往下走 */
father_idx = child_idx; // 更新下标
child_idx = father_idx * 2 + 1; // 计算出该节点路线的新父亲
} else { // 如果孩子的值大于父亲的值(符合小堆的性质)
break; // 终止循环
}
}
}
/* 升序 */
void HeapSort(int arr[], int sz) {
/* 创建大堆,选出最大的数 O(N) */
int father = ((sz - 1) - 1) / 2; // 计算出最后一个叶子节点的父亲
while (father >= 0) {
AdjustDown(arr, sz, father);
father--;
}
}
void HeapPrint(int arr[], int sz) {
for (int i = 0; i < sz; i++) {
printf("%d ", arr[i]);
}
printf("\n");
}
int main(void)
{
int arr[] = {70, 56, 30, 25, 15, 10, 75, 33, 50, 69};
int sz = sizeof(arr) / sizeof(arr[0]);
HeapSort(arr, sz);
HeapPrint(arr, sz);
return 0;
}
🚩 运行结果:10 15 30 25 56 70 75 33 50 69
第二步:排序
刚才介绍了两种方法来构建堆,现在堆已经构建完毕了,我们可以开始设计排序部分的算法了。
❓ 如果排升序,建小堆……
① 选出最小的数,放到第一个位置,这很简单,直接取顶部就可以得到最小的数。
② 但问题来了,如何选出次小的数呢?
从 15 开始,剩下的元素看作一个堆。但是这之前建立好的堆关系全部乱了!你还得重新建堆,才能选出次小的数。建堆的时间复杂度为
,需要不断地建堆
则用小堆的时间复杂度就是
,这太离谱了!搞了一大圈结果居然是
,还不如直接遍历选出来的方便呢。
建小堆来排升序是完全可以的,但是效率太低!完全没有体现出你用堆的优势!
⚡ 解决方法:使用大堆来排升序!
💬 我们刚才已经实现好小堆了,根据上一节学到的知识,小堆要变成大堆,直接把刚才的代码的 "<" 改成 ">" 即可:
#include <stdio.h>
/* 交换函数 */
void Swap(int* px, int* py) {
int tmp = *px;
*px = *py;
*py = tmp;
}
/* 大堆下调 */
void AdjustDown(int arr[], int sz, int father_idx) {
int child_idx = father_idx * 2 + 1; // 计算出左孩子的值(默认认为左孩子大)
while (child_idx < sz) { // 最坏情況:调到叶子(child >= 数组范围时必然已经调到叶子)
if ((child_idx + 1 < sz) && (arr[child_idx + 1] > arr[child_idx])) { // 如果右孩子存在且右孩子比左孩子大
child_idx = child_idx + 1; // 让其代表右孩子
}
if (arr[child_idx] > arr[father_idx]) { // 如果孩子的值大于父亲的值(不符合大堆的性質)
Swap(&arr[child_idx], &arr[father_idx]); // 交换它们的值
/* 往下走 */
father_idx = child_idx; // 更新下标
child_idx = father_idx * 2 + 1; // 计算出该节点路线的新父亲
} else { // 如果孩子的值小于父亲的值(符合大堆的性质)
break; // 终止循环
}
}
}
/* 升序 */
void HeapSort(int arr[], int sz) {
/* 创建大堆,选出最大的数 O(N) */
int father = ((sz - 1) - 1) / 2; // 计算出最后一个叶子节点的父亲
while (father >= 0) {
AdjustDown(arr, sz, father);
father--;
}
}
void PrintArray(int arr[], int sz) {
for (int i = 0; i < sz; i++) {
printf("%d ", arr[i]);
}
printf("\n");
}
int main(void)
{
int arr[] = {70, 56, 30, 25, 15, 10, 75, 33, 50, 69};
int sz = sizeof(arr) / sizeof(arr[0]);
HeapSort(arr, sz);
PrintArray(arr, sz);
return 0;
}
🚩 运行结果:75 69 70 50 56 10 30 33 25 15
📚 现在改成了大堆,我们要排升序,我们可以让堆顶数和最后的数进行交换:
这并不会带来堆结构的破坏!我们把75不看作堆的一部分即可。再进行向下调整,就可以找到次小的数了。此时时间复杂度为
🔺 步骤总结:
① 建大堆,选出最大的数。
② 最大的数跟最后一个数交换。
③ 如何选出次大的数呢?把最后一个数不看作堆里面,进行向下调整。
💬 代码实现(用 while):
/* 堆排序 - 升序 */
void HeapSort(int arr[], int sz) {
/* 创建大堆,选出最大的数 O(N) */
int father = ((sz - 1) - 1) / 2; // 计算出最后一个叶子节点的父亲
while (father >= 0) {
AdjustDown(arr, sz, father);
father--;
}
/* 依次选数,调堆 O(N * logN) */
int end = sz - 1;
while (end > 0) {
Swap(&arr[0], &arr[end]); // 最大的数跟最后一个数交换
AdjustDown(arr, end, 0); // 调堆,选出次大的数
end--;
}
}
💬 代码实现(用 for):
void HeapSort(int arr[], int sz) {
/* 建堆 */
for (int father = (sz - 1 - 1) / 2; father >= 0; father--) {
AdjustDown(arr, sz, father);
}
/* 排序 */
for (int end = sz - 1; end > 0; end--) {
Swap(&arr[0], &arr[end]); // 最大的数跟最后一个数交换
AdjustDown(arr, end, 0); // 调堆,选出次大的数
}
}
三、完整代码
(排升序要建大堆,排降序建小堆)
💬 升序:使用大堆
#define _CRT_SECURE_NO_WARNINGS 1
#include <stdio.h>
void Swap(int* pa, int* pb) {
int tmp = *pa;
*pa = *pb;
*pb = tmp;
}
void AdjustDown(int arr[], int sz, int father) {
int child = father * 2 + 1;
while (child < sz) {
if (child + 1 < sz && arr[child + 1] > arr[child]) {
child += 1;
}
if (arr[child] > arr[father]) {
Swap(&arr[child], &arr[father]);
father = child;
child = father * 2 + 1;
}
else {
break;
}
}
}
/* 堆排序 - 升序 */
void HeapSort(int arr[], int sz) {
/* 创建大堆,选出最大的数 O(N) */
int father = ((sz - 1) - 1) / 2; // 计算出最后一个叶子节点的父亲
while (father >= 0) {
AdjustDown(arr, sz, father);
father--;
}
/* 依次选数,调堆 O(N * logN) */
int end = sz - 1;
while (end > 0) {
Swap(&arr[0], &arr[end]); // 最大的数跟最后一个数交换
AdjustDown(arr, end, 0); // 调堆,选出次大的数
end--;
}
}
void HeapPrint(int arr[], int sz) {
int i = 0;
for (i = 0; i < sz; i++) {
printf("%d ", arr[i]);
}
printf("\n");
}
int main(void)
{
int arr[] = { 70, 56, 30, 25, 15, 10, 75, 33, 50, 69 };
int sz = sizeof(arr) / sizeof(arr[0]);
printf("排序前: ");
HeapPrint(arr, sz);
HeapSort(arr, sz);
printf("排序后: ");
HeapPrint(arr, sz);
return 0;
}
🚩 运行结果:
❓ 如果要排降序呢?使用小堆即可!
💬 降序:使用小堆
#include <stdio.h>
/* 交换函数 */
void Swap(int* px, int* py) {
int tmp = *px;
*px = *py;
*py = tmp;
}
/* 小堆下调 */
void AdjustDown(int arr[], int sz, int father_idx) {
int child_idx = father_idx * 2 + 1; // 计算出左孩子的值(默认认为左孩子大)
while (child_idx < sz) { // 最坏情況:调到叶子(child >= 数组范围时必然已经调到叶子)
if ((child_idx + 1 < sz) && (arr[child_idx + 1] < arr[child_idx])) { // 如果右孩子存在且右孩子比左孩子小
child_idx = child_idx + 1; // 让其代表右孩子
}
if (arr[child_idx] < arr[father_idx]) { // 如果孩子的值小于父亲的值(不符合小堆的性質)
Swap(&arr[child_idx], &arr[father_idx]); // 交换它们的值
/* 往下走 */
father_idx = child_idx; // 更新下标
child_idx = father_idx * 2 + 1; // 计算出该节点路线的新父亲
}
else { // 如果孩子的值大于父亲的值(符合小堆的性质)
break; // 终止循环
}
}
}
/* 堆排序 - 降序 */
void HeapSort(int arr[], int sz) {
/* 创建大堆,选出最大的数 O(N) */
int father = ((sz - 1) - 1) / 2; // 计算出最后一个叶子节点的父亲
while (father >= 0) {
AdjustDown(arr, sz, father);
father--;
}
/* 依次选数,调堆 O(N * logN) */
int end = sz - 1;
while (end > 0) {
Swap(&arr[0], &arr[end]); // 最大的数跟最后一个数交换
AdjustDown(arr, end, 0); // 调堆,选出次小的数
end--;
}
}
void PrintArray(int arr[], int sz) {
for (int i = 0; i < sz; i++) {
printf("%d ", arr[i]);
}
printf("\n");
}
int main(void)
{
int arr[] = { 70, 56, 30, 25, 15, 10, 75, 33, 50, 69 };
int sz = sizeof(arr) / sizeof(arr[0]);
printf("排序前: ");
PrintArray(arr, sz);
HeapSort(arr, sz);
printf("排序后: ");
PrintArray(arr, sz);
return 0;
}
🚩 运行结果:
四、证明建堆的时间复杂度
因为堆是完全二叉树,而满二叉树也是完全二叉树。此处为了简化,将采用满二叉树来证明。(时间复杂度本来看的就是近似值,所以多几个节点不会影响最终结果):
假设树的高度为
:
第
层:
个节点,需要向下移动
层
第
层:
个节点,需要向下移动
层
第
层:
个节点,需要向下移动
层
第
层:
个节点,需要向下移动
层
……
第
层:
个节点,需要向下移动
层
则需要移动节点总的移动步数为:
①
②
② - ① 错位相减:
因此,建堆的时间复杂度为
参考资料:
Microsoft. MSDN(Microsoft Developer Network)[EB/OL]. []. .
百度百科[EB/OL]. []. https://baike.baidu.com/.
📌 笔者:王亦优
📃 更新: 2021.11.26
❌ 勘误: 无
📜 声明: 由于作者水平有限,本文有错误和不准确之处在所难免,本人也很想知道这些错误,恳望读者批评指正!
本篇完。