[230523] 堆排序(Heap Sort)
1. 二叉堆的性质
- 大顶堆:每个节点的值都大于或等于其左右孩子节点的值(不保证大顶堆对应的 array 为降序序列!!!不保证大顶堆对应的 array 为降序序列!!!不保证大顶堆对应的 array 为降序序列!!!我们只是利用大顶堆来构造出升序序列。可以保证的唯一性质是:大顶堆的堆顶元素为大顶堆元素中的最大值。)
- 小顶堆:每个节点的值都小于或等于其左右孩子节点的值(同理)
2. Heapify 过程
Heapify:把一个给定的 array 转换成大顶堆/小顶堆的 array 序列(递归过程)
下例把一个给定的 array 逐步 heapify 为一个大顶堆:
start:array = {1, 3, 5, 4, 6, 13, 10, 9, 8, 15, 17}
对应二叉堆:
1
/ \
3 5
/ \ / \
4 6 13 10
/ \ / \
9 8 15 17
heapify(递归过程)的目的是使原 array 中每一个非叶子节点都下沉至满足大顶堆/小顶堆限制条件的最底层(说大白话就是,如果在一次 heapify 中破坏了大顶堆/小顶堆的性质,则要继续调用 heapify 维护大顶堆/小顶堆的性质)
2.1 第 1 轮 heapify(初始化大顶堆/小顶堆)
第一轮 heapify(即大多数博文中所说的初始化堆):
从最后一个非叶子节点(6)开始 heapify:
- 如何找到最后一个非叶子节点的下标?
- 节点总数为 n → 非叶子节点总数为 n / 2 - 1 → 最后一个非叶子节点下标为 n / 2 - 1
- 如何 heapify?
- 目标为大顶堆:比较当前节点与两个孩子的值,选择最大值放在当前节点的位置上(swap操作)
- 目标为小顶堆:比较当前节点与两个孩子的值,选择最小值放在当前节点的位置上(swap操作)
6 heapified:array = {1, 3, 5, 4, 17, 13, 10, 9, 8, 15, 6}
二叉堆:
1
/ \
3 5
/ \ / \
4 17 13 10
/ \ / \
9 8 15 6
heapify 4:
4 heapified:array = {1, 3, 5, 9, 17, 13, 10, 4, 8, 15, 6}
二叉堆:
1
/ \
3 5
/ \ / \
9 17 13 10
/ \ / \
4 8 15 6
heapify 5:
5 heapified:array = {1, 3, 13, 9, 17, 5, 10, 4, 8, 15, 6}
二叉堆:
1
/ \
3 13
/ \ / \
9 17 5 10
/ \ / \
4 8 15 6
heapify 3:
- 首先 swap 3 和 17
- 然后 swap 3 和 15
3 heapified:array = {1, 17, 13, 9, 15, 5, 10, 4, 8, 3, 6}
二叉堆:
1
/ \
17 13
/ \ / \
9 15 5 10
/ \ / \
4 8 3 6
heapify 1:
- 首先 swap 1 和 17
- 然后 swap 1 和 15
- 最后 swap 1 和 6
1 heapified:array = {17, 15, 13, 9, 6, 5, 10, 4, 8, 3, 1}
二叉堆:
17
/ \
15 13
/ \ / \
9 6 5 10
/ \ / \
4 8 3 1
一轮 heapify 结束后,可以让堆顶元素 17 就位(swap(堆顶元素 array[0], array[n - 1]) ),然后 array 规模递减(n–),开始下一轮的 heapify(注意:由于在第一轮 heapify 中我们已经构造好了一个大顶堆,所以在 swap 以后只有堆顶元素是非法的,所以在 heapSort 中只需要对堆顶元素进行 heapify)
所以,可以在此处领会到:为什么是使用大顶堆找到升序序列,使用小顶堆找到降序序列。
2.2 第 2、3、…、n-1 轮 heapify
swap(array[0], array[n - 1]):array = {1, 15, 13, 9, 6, 5, 10, 4, 8, 3, 17}
用如下二叉堆开始下一轮heapify:
1
/ \
15 13
/ \ / \
9 6 5 10
/ \ /
4 8 3
第二轮 heapify 结束,15 就位:
array = {3, 9, 13, 8, 6, 5, 10, 4, 1, 15, 17}
3
/ \
9 13
/ \ / \
8 6 5 10
/ \
4 1
第三轮 heapify 结束,13 就位:
array = {1, 9, 10, 8, 6, 5, 3, 4, 13, 15, 17}
1
/ \
9 10
/ \ / \
8 6 5 3
/
4
第四轮 heapify 结束,10就位:
array = {4, 9, 5, 8, 6, 1, 3, 10, 13, 15, 17}
4
/ \
9 5
/ \ / \
8 6 1 3
第五轮 heapify 结束,9 就位:
array = {3, 8, 5, 4, 6, 1, 9, 10, 13, 15, 17}
3
/ \
8 5
/ \ /
4 6 1
第六轮 heapify 结束,8 就位:
array = {1, 6, 5, 4, 3, 8, 9, 10, 13, 15, 17}
1
/ \
6 5
/ \
4 3
第七轮 heapify 结束,6 就位:
array = {3, 4, 5, 1, 6, 8, 9, 10, 13, 15, 17}
3
/ \
4 5
/
1
第八轮 heapify 结束,5 就位:
array = {1, 4, 3, 5, 6, 8, 9, 10, 13, 15, 17}
1
/ \
4 3
第九轮 heapify 结束,4 就位:
array = {3, 1, 4, 5, 6, 8, 9, 10, 13, 15, 17}
3
/
1
第十轮 heapify 结束,3 就位:
array = {1, 3, 4, 5, 6, 8, 9, 10, 13, 15, 17}
1
第十轮 heapify 结束后,二叉堆规模为 1,原 array 元素全部就序。
3. 代码实现
#include <iostream>
using namespace std;
// 函数作用:对节点i进行heapify
// N为heap的规模
void heapify(int arr[], int N, int i)
{
int largest = i; //记录非叶子节点及其左右孩子最大值的下标,初始化为非叶子节点的下标
int l = 2 * i + 1; //左孩子下标
int r = 2 * i + 2; //右孩子下标
//确定largest,要先检查左右孩子的下标是否越界
if (l < N && arr[l] > arr[largest])
largest = l;
if (r < N && arr[r] > arr[largest])
largest = r;
//如果非叶子节点值不是最大值,则要进行swap
//并且swap后会使二叉堆中下标为largest的元素非法,所以要继续进行heapify
if (largest != i) {
swap(arr[i], arr[largest]);
//对arr[largest]进行heapify
heapify(arr, N, largest);
}
}
//堆排序主算法
void heapSort(int arr[], int N)
{
// 进行第一轮heapify(对所有非叶子节点进行heapify),此时构造好了一个大顶堆
for (int i = N / 2 - 1; i >= 0; i--)
heapify(arr, N, i);
//swap逐步构建升序序列,然后减小array规模,此时只需要对array[0]进行heapify
//i既指示就序位置也指示下一轮heapify的规模
for (int i = N - 1; i > 0; i--) {
//swap以构造升序序列,同时减小array规模
swap(arr[0], arr[i]);
//对array[0]进行heapify
heapify(arr, i, 0);
}
}
//工具函数:打印数组
void printArray(int arr[], int N)
{
for (int i = 0; i < N; ++i)
cout << arr[i] << " ";
cout << "\n";
}
//驱动代码
int main()
{
int arr[] = { 12, 11, 13, 5, 6, 7 };
int N = sizeof(arr) / sizeof(arr[0]);
heapSort(arr, N); //堆排序
cout << "Sorted array is: ";
printArray(arr, N);
}
4. 性能分析
- 时间复杂度:O(N log N)
- 空间复杂度:O(1)
- 稳定性:不稳定
- 就地算法:是就地算法
5. 适用场景
- 混合排序算法,如:IntroSort.
- Sort a nearly sorted (or K sorted) array
- k largest(or smallest) elements in an array
6. 堆排序与归并排序、选择排序
- 哪种排序算法更好?堆排序还是合并排序?
- 答案在于它们的时间复杂性和空间需求的比较。Merge排序比Heap排序稍快。但另一方面,合并排序需要额外的内存。根据需要,应该选择使用哪一个。
- 为什么堆排序比选择排序好?
- 堆排序类似于选择排序,但有更好的方法来获得最大元素。它利用堆数据结构在恒定时间内获得最大元素。
7. 小顶堆代码
补充:利用小顶堆构造降序序列
#include <iostream>
using namespace std;
void swap(int& a, int& b) {
int tmp = a;
a = b;
b = tmp;
}
void heapify(int arr[], int N, int i) {
int minIdx = i;
int left = 2 * i + 1;
int right = 2 * i + 2;
if(left < N && arr[minIdx] > arr[left]) {
minIdx = left;
}
if(right < N && arr[minIdx] > arr[right]) {
minIdx = right;
}
if(minIdx != i) {
swap(arr[minIdx], arr[i]);
heapify(arr, N, minIdx);
}
}
void heapSort(int arr[], int N) {
//堆的初始化
for(int i = N / 2 - 1; i >= 0; --i) {
heapify(arr, N, i);
}
//利用小顶堆构造降序序列
for(int i = N - 1; i > 0; --i) {
swap(arr[0], arr[i]);
heapify(arr, i, 0);
}
}
int main() {
int arr[10] = {4, 2, 3, 5, 6, 9, 7, 6, 8, 0};
heapSort(arr, 10);
for(int i = 0; i < 10; ++i) {
cout << arr[i] << " ";
}
return 0;
}