堆排序
预备知识
满二叉树: 除最后一层结点均无任何子节点外,每一层的所有结点都有两个子结点的树。也就是每一层节点个数都是最大值的二叉树(每层
k
k
k的节点个数=
2
k
−
1
2^{k-1}
2k−1),也可以说除了叶子结点之外的每一个结点都有两个孩子,每一层(当然包含最后一层)都被完全填充。
完全二叉树: 如果二叉树中除去最后一层节点为满二叉树,且最后一层的结点依次从左到右分布,则此二叉树被称为完全二叉树。
所以一个满二叉树一定是完全二叉树,完全二叉树不一定是满二叉树。
堆: 堆是一个完全二叉树,可以分为大根堆(最大堆)和小根堆(最小堆)
- 大根堆:对于任意一个子树,父节点的值大于等于其左右孩子的值
- 小根堆:对于任意一个子树,父节点的值小于等于其左右孩子的值
一些性质: 假如用一个数组存储一个堆,那么节点 i i i 的左右孩子的下标分别为 2 i + 1 2i+1 2i+1 和 2 i + 2 2i+2 2i+2,其父节点的下标为 i − 1 2 \frac{i-1}{2} 2i−1。如果设叶子节点的高度为1,那么一个长度为 n n n 的数字可以构建的完全二叉树的高度为 h = l o g n h = logn h=logn。
数组构建堆
根据上面的预备知识,一个堆可以分为大根堆和小根堆,如何使用一个数组构建一个堆呢(大根堆为例)。如给定一个数组 arr = [2,1,3,6,0,4],构建的目标大根堆为 arr = [6,3,4,1,0,2]。下面描述一下构建步骤:
- 首先把下标为2作为二叉树的根节点 ,heap = [2]
- 添加1作为2的左子树,此时符合大根堆的要求,heap = [2,1]
- 添加3作为2的右子树,heap = [2,1,3],此时不符合,因为根节点2小于右孩子3,那么交换根节点和右孩子的数值,变为 heap = [3,1,2]
- 添加6作为1的左孩子,heap = [ 3,1,2,6],1的左孩子6比较大,所以交换1和6的位置,heap = [3,6,2,1],此时2的左孩子6比较大,交换二者的位置,heap = [6,3,2,1]
- 添加0作为数字3的右孩子,heap = [6,3,2,1,0],符合要求
- 添加4作为数字2的左孩子,heap = [6,3,2,1,0,4],2的左孩子4比较大,交换位置,heap = [6,3,4,1,0,2]
- 构建完成
下图就是上面的构建过程
代码实现
#include <iostream>
#include <vector>
using namespace std;
/*
* 交换两个数字
*/
void mySwap(vector<int>& vec, int index1, int index2) {
int temp = vec[index1];
vec[index1] = vec[index2];
vec[index2] = temp;
}
/*
* 在已有的大根堆中插入一个数字
*/
void heapInsert(vector<int>& vec, int index) {
// 如果孩子节点大于父节点,那么就交换二者的位置
while (vec[index] > vec[(index - 1) / 2]) {
mySwap(vec, index, (index - 1) / 2);
index = (index - 1) / 2; // 更新孩子节点的位置
}
}
/*
* 构建大根堆
*/
void maxHeap(vector<int>& vec) {
// 遍历每一个元素,插入到已有的大根堆中
for (int i = 0; i < vec.size(); i++) {
heapInsert(vec, i);
}
}
int main() {
vector<int> arr = {2,1,3,6,0,4};
maxHeap(arr);
for (int i = 0; i < arr.size(); i++) {
cout << arr[i] << " ";
}
cout << endl;
return 0;
}
构建大根堆的时间复杂度
假设有
n
n
n 个节点,设叶节点的高度为1,那么构建的完全二叉树的高度
h
=
l
o
g
n
+
1
h=logn+1
h=logn+1,那么叶节点的父节点最多需要调整
1
1
1 次(倒数第一层的父节点),最后一层总共包括
2
h
−
1
2^{h-1}
2h−1 个节点,在最坏的情况下,每个节点都需要调整,需要调整的次数就为
1
×
2
h
−
1
1 \times 2^{h-1}
1×2h−1;倒数第二层的父节点最多需要调整
2
2
2 次,包含的节点个数为
2
h
−
2
2^{h-2}
2h−2,总共需要调整
2
×
2
h
−
2
2\times2^{h-2}
2×2h−2次,以此类推,根节点需要调整
h
−
1
h-1
h−1 次,总共需要调整
(
h
−
1
)
×
2
1
(h-1) \times 2^{1}
(h−1)×21 次。那么整个的时间复杂度为
s
=
1
×
2
h
−
1
+
2
×
2
h
−
2
+
⋯
+
(
h
−
1
)
×
2
1
s = 1 \times 2^{h-1} + 2\times2^{h-2} + \cdots + (h-1)\times2^1
s=1×2h−1+2×2h−2+⋯+(h−1)×21
两侧乘以2,并相减
s
=
1
×
2
h
−
1
+
2
×
2
h
−
2
+
⋯
+
(
h
−
1
)
×
2
0
2
s
=
1
×
2
h
+
2
×
2
h
−
1
+
⋯
+
(
h
−
1
)
×
2
1
2
s
−
s
=
2
h
+
2
h
−
1
+
2
h
−
2
+
⋯
+
2
1
−
(
h
−
1
)
s
=
2
h
+
2
h
−
1
+
2
h
−
2
+
⋯
+
2
1
+
1
−
h
s
=
2
×
2
h
−
2
+
1
−
h
s
=
4
×
2
h
−
1
−
h
−
1
\begin{array}{l} s = 1 \times {2^{h - 1}} + 2 \times {2^{h - 2}} + \cdots + \left( {h - 1} \right) \times {2^0}\\ 2s = 1 \times {2^h} + 2 \times {2^{h - 1}} + \cdots + \left( {h - 1} \right) \times {2^1}\\ 2s - s = {2^h} + {2^{h - 1}} + {2^{h - 2}} + \cdots + {2^1} - (h - 1)\\ s = {2^h} + {2^{h - 1}} + {2^{h - 2}} + \cdots + {2^1} + 1 - h\\ s = 2 \times {2^h} - 2 + 1 - h\\ s = 4 \times {2^{h - 1}} - h - 1 \end{array}
s=1×2h−1+2×2h−2+⋯+(h−1)×202s=1×2h+2×2h−1+⋯+(h−1)×212s−s=2h+2h−1+2h−2+⋯+21−(h−1)s=2h+2h−1+2h−2+⋯+21+1−hs=2×2h−2+1−hs=4×2h−1−h−1
h
=
l
o
g
n
+
1
h=logn+1
h=logn+1 带入到
s
s
s 中的
s
=
4
n
−
l
o
g
n
s=4n-logn
s=4n−logn ,所以构建大根度需要的时间复杂度为
O
(
n
)
O(n)
O(n)。
调整大根堆
假设给定的一个数组已经是大根堆,arr = [6, 3, 4, 1, 0, 2],如果某个下标的数字**向下变小**,那么原来的大根堆就有可能被破坏,如下标为0的数字变为0,arr = [0, 3, 4, 1, 0, 2],那么原来的大根堆就被破坏了。那么如何再把它调整为大根堆,如果需要调整的数的下标index=0,堆的长度heapSize=6,采用如下步骤:
- 首先计算出来下标0的左孩子和右孩子的下标,并判断他们是否越界,如果都不存在,表示是叶子节点,那么直接结束整个流程;如果任意一个存在,那么就进行下一步的操作,此例中,index=0的left=1,right=2
- 选择arr[index]、arr[left]、arr[right]三个数中最大的数,如果vec[index]是最大的那么直接结束整个流程,此例中,vec[right]比较大,所以交换过后 arr=[4, 3, 0, 1, 0, 2],把right的值更新给index
- 此时index=2,那么left=5,right=6,right越界,舍弃right;在arr[index],arr[left]两个数中选择最大的数,此例中,arr[left]比较大,所以交换index中位置 arr = [4, 3, 2, 1, 0, 0],把left的值更新给index
- 此时index=5,其为叶子节点,arr = [4, 3, 2, 1, 0, 0]所以结束流程
以上就是调整的过程,其调整的核心思想就是,判断当前的节点与其左右孩子比较,选择较大的,然后下沉,直到比较到叶子节点,如果中间的任何一个过程,index的值最大,也是结束该过程。通过上述的描述,如果有 n n n 个节点,那么可以构建的完全二叉树高度为 l o g n + 1 logn+1 logn+1,那么如果调整的是根节点,最坏的情况需要调整 l o g n logn logn 次,所以这个过程的时间复杂度为 O ( l o g n ) O(logn) O(logn)。
代码实现
#include <iostream>
#include <vector>
using namespace std;
/*
* 交换两个数
*/
void mySwap(vector<int>& vec, int index1, int index2) {
int temp = vec[index1];
vec[index1] = vec[index2];
vec[index2] = temp;
}
/*
* 根据给定的index和heapSize调整,使其成为大根堆
*/
void heapify(vector<int>& vec, int index, int heapSize) {
int left = index * 2 + 1; // 计算左孩子的下标
while (left < heapSize) { // 如果左孩子下标超过heapSize,那么就直接结束
int largest = left; // 最大数的下标初始化为左孩子的下标
// 当右孩子没有越界,切右孩子大于左孩子的时候,更新largest
if ((left + 1) < heapSize && vec[left + 1] > vec[left]) {
largest = left + 1;
}
// 把largest和index对应的数字比较,即比较三者的值
largest = (vec[largest] > vec[index] ? largest : index);
// 如果最大数的下标还是index,那么表示现在的堆已经是最大堆,直接结束
if (largest == index) {
break;
}
// 交换两个数
mySwap(vec, largest, index);
index = largest; // index 下沉,即更新index的值
left = index * 2 + 1;
}
}
int main() {
vector<int> arr = {0,3,4,1,0,2};
heapify(arr, 0, arr.size());
for (int i = 0; i < arr.size(); i++) {
cout << arr[i] << " ";
}
cout << endl;
return 0;
}
堆排序
有了上面的知识以后,怎么实现堆排序的呢?
算法思想如下:
- 把给定的序列调整为大根堆,定义heapSize的长度(整个数组的长度)
- 把堆heapSize-1对于元素与根节点位置的元素交换,此时heapSize-1对于的是最大的数
- heapSize的长度减小1,调整现有长度的堆为最大堆
- 重复1~3过程,直到heapSize的长度为0
通过下图的动画了解一下堆排序的整体过程:
只要熟练了上文的内容,下面堆排序的代码就很好写了
代码实现
#include <iostream>
#include <vector>
using namespace std;
void mySwap(vector<int>& vec, int index1, int index2) {
int temp = vec[index1];
vec[index1] = vec[index2];
vec[index2] = temp;
}
/*
* 在已有的大根堆中插入一个数字
*/
void heapInsert(vector<int>& vec, int index) {
// 如果孩子节点大于父节点,那么就交换二者的位置
while (vec[index] > vec[(index - 1) / 2]) {
mySwap(vec, index, (index - 1) / 2);
index = (index - 1) / 2; // 更新孩子节点的位置
}
}
/*
* 根据给定的index和heapSize调整,使其成为大根堆
*/
void heapify(vector<int>& vec, int index, int heapSize) {
int left = index * 2 + 1; // 计算左孩子的下标
while (left < heapSize) { // 如果左孩子下标超过heapSize,那么就直接结束
int largest = left; // 最大数的下标初始化为左孩子的下标
// 当右孩子没有越界,切右孩子大于左孩子的时候,更新largest
if ((left + 1) < heapSize && vec[left + 1] > vec[left]) {
largest = left + 1;
}
// 把largest和index对应的数字比较,即比较三者的值
largest = (vec[largest] > vec[index] ? largest : index);
// 如果最大数的下标还是index,那么表示现在的堆已经是最大堆,直接结束
if (largest == index) {
break;
}
// 交换两个数
mySwap(vec, largest, index);
index = largest; // index 下沉,即更新index的值
left = index * 2 + 1;
}
}
/*
* 堆排序函数
*/
void heapSort(vector<int>& vec) {
// 给定的序列长度少于2个直接返回
if (vec.size() < 2) {
return;
}
// 把数组调整为大根堆
for (int i = 0; i < vec.size(); i++){
heapInsert(vec, i);
}
int heapSize = vec.size(); // 定义堆的长度
// 堆的长度为0时,排序完毕
while (heapSize > 0) {
mySwap(vec, 0, heapSize - 1); // 交换根节点和堆尾
heapSize--; // 堆的长度减少一
heapify(vec, 0, heapSize); // 调整现有的数组为大根堆
}
}
int main() {
vector<int> arr = { 6, 4, 8, 9, 2, 3, 1};
heapSort(arr);
for (int i = 0; i < arr.size(); i++) {
cout << arr[i] << " ";
}
cout << endl;
return 0;
}
根据上述的代码,产生大根堆的时间复杂度为 O ( n ) O(n) O(n) ,在堆排序的过程中,需要对数组遍历 n n n 次,每次都需要调整,最坏的情况需要调整 l o g n logn logn 次,所以整个的时间复杂度为 O ( n × l o g n ) + O ( n ) O(n\times logn)+O(n) O(n×logn)+O(n),取最高大项,所以整体堆排序的时间复杂度为 O ( n × l o g n ) O(n \times logn) O(n×logn)。在堆排序的过程中,只用了常数个的变量,所以空间复杂度为 O ( 1 ) O(1) O(1)。堆排序是一种不稳定的排序算法,如 arr=[8,6,6],是一个大根堆,根节点index=0需要和right=2交换,即arr=[6,6,8],两个6的下标顺序发生了改变,所以是不稳定的排序算法。
总结
- 稳定性:不稳定
- 时间复杂度: O ( n × l o g n ) O(n\times logn) O(n×logn)
- 空间复杂度: O ( 1 ) O(1) O(1)
欢迎大家关注我的个人公众号,同样的也是和该博客账号一样,专注分享技术问题,我们一起学习进步