今天把堆排序和堆相关的操作整明白了,记录一下
总地来说,一个堆排序由3步骤组成。
- 首先根据待排序数组(乱序)生成一个堆
- 将堆顶元素和堆底部元素交换,并将交换后的底部元素排除在堆外
- 重新调整堆以使其满足堆的特性
以上每一步都可以完成一个元素的排序,一直执行,直到堆空,则整个待排序数组就有序了。
以大顶堆为例,首先它是一个完全二叉树,并且所有非叶子节点的节点值都要比其左右子节点的节点值大。(相应的小顶堆则是父节点比子节点都要小)
堆的最主要操作:heapify,对一个已知的堆,当其堆顶元素发生变化时,可能不再满足堆的性质,需要进行堆整理。其操作为:在堆顶元素和其两个子节点之间选择一个最大值,与堆顶元素进行交换,并对交换后的子节点递归heapify。如堆顶和左孩子发生了交换,则对以左孩子为堆顶的堆进行递归的heapify。
heapify的代码如下:
void heapify(vector<int> &tree, int n, int i)
{
// 以线性表存储完全二叉树结构,堆的大小为n,对第i个节点进行heapify操作
// 注意,n可能和tree.size()不相同,因为在堆排序过程中,会逐渐产生已排序序列,堆的大小会逐渐缩小
// 但n最大也只能是tree的大小,而i则是堆中的一个元素,因此n和i应该满足 i < n,n <= tree.size()
if (i >= n || n > tree.size()) {
return;
}
// 从该节点和两个子节点之间选择一个最大的
int max = i;
// 数组存储的完全二叉树,其子节点计算方式为2i+1, 2i+2
int c1 = 2 * i + 1;
int c2 = 2 * i + 2;
// 注意判断子节点是否越界
if (c1 < n && tree[c1] > tree[max]) {
max = c1;
}
if (c2 < n && tree[c2] > tree[max]) {
max = c2;
}
// 如果最大值不是当前节点,则需要进行交换并且递归整理被交换的子树(子堆)
if (max != i) {
int temp = tree[i];
tree[i] = tree[max];
tree[max] = temp;
heapify(tree, n, max);
}
}
需要注意的是,heapify总是用在已经整理好的堆上,并且被改变的元素总是堆顶元素。一次堆整理所需的时间为 O ( l o g 2 n ) O(log_2n) O(log2n),其中n是堆的大小。
那么对于一个初始无序的数组来说,它是不满足堆的性质的,因此,首先需要将一个乱序的数组变成一个堆。堆的构建需要从下往上构建,也就是说,构建完子堆后,才可以构建父堆,这样,在建堆的时候就可以很好地利用heapify函数。那么第一个需要建的堆在哪里呢?简单地说,可以直接从数组的末端开始。对于一个存储完全二叉树的数组来说,末端元素都是叶子节点,叶子节点是不需要调整的(没有子节点),而第一个需要调整的节点就是最后一个元素的父节点。
假设数组长度为n,最后一个元素的节点编号为n-1,其父节点编号为(n-1-1)/2。而编号在该节点之前的节点都需要进行堆整理,即从该节点递减到0节点,逐一进行堆整理,则满足上述所说的子堆整理完后整理父堆的条件。
对一个乱序数组建立堆代码如下,用到了上面的heapify函数:
void build_heap(vector<int> tree)
{
if (tree.size() < 2) {
return;
}
// 第一个需要调整的节点,也可以直接从tree.size()-1开始调整,但最后那些叶子节点是不需要调整的,可以省下一些操作时间
int n = (tree.size() - 2) / 2;
for (int i = n; i >= 0; i--) {
// 从第一个要调整的节点开始,逐一递减。此处heapify的第二个参数n总是tree.size(),因为此时堆的大小并没有发生改变
heapify(tree, tree.size(), i);
}
}
由于进行了接近n/2次的heapify,build_heap的时间复杂度是
O
(
n
l
o
g
2
n
)
O(nlog_2n)
O(nlog2n)
有了以上的准备后,就可以开始堆排序了。
按照文章开始所说的三步走,
- 首先根据待排序数组(乱序)生成一个堆
- 将堆顶元素和堆底部元素交换,并将交换后的底部元素排除在堆外
- 重新调整堆以使其满足堆的特性
可以写出如下代码:
void heap_sort(vector<int> & nums)
{
// 此时参数名字不再用tree了,因为函数功能是将一个数组排序,而不是对一个树排序,所以改为nums逻辑比较清晰一些
// 第一步,将乱序的数组生成一个堆
build_heap(nums);
for (int i = nums.size() - 1; i >= 0; i--) {
// 第二步,将堆顶元素和最后一个元素交换,得到一个完成排序的元素
int temp = nums[0];
nums[0] = nums[i];
nums[i] = temp;
// 交换后堆顶元素发生了变化,需要对堆顶进行堆整理。
// 但需要注意的是,此时数组的最后一个元素已经排好序,被移除到堆之外了,堆的大小不再是数组的长度,而是逐渐减小,而堆的长度恰是数字i的值,因此在此时调用heapify时,第二个参数为i
heapify(nums, i, 0);
}
}
这样,一个堆排序就完成了。现在来分析一下时间复杂度。第一步建堆时间复杂度 n l o g 2 n nlog_2n nlog2n,第二步循环heapify的复杂度也是 n l o g 2 n nlog_2n nlog2n,因此,堆排序的时间复杂度为 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n),比一般的比较排序要优秀。
本文是在看了bilibili大神视频后写下的,在文末附上大神的视频链接,讲的是真滴好
bilibili-正月点灯笼-heapsort