目录
一、堆的性质
堆是一种特殊的树。
只要满足以下两点,它就是一个堆:
- 堆是一个完全二叉树;
- 堆中每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值。
第一点,堆必须是一个完全二叉树。完全二叉树要求,除了最后一层,其他层的节点个数都是满的,最后一层的节点都靠左排列,自然堆也具有完全二叉树的所有性质。
第二点,堆中的每个节点的值必须大于等于(或者小于等于)其子树中每个节点的值。实际上,我们还可以换一种说法,堆中每个节点的值都大于等于(或者小于等于)其左右子节点的值。这两种表述是等价的。
对于每个节点的值都大于等于子树中每个节点值的堆,我们叫做“大顶堆”。对于每个节点的值都小于等于子树中每个节点值的堆,我们叫做“小顶堆”。
以下讲解都用大根堆为例。
二、堆的实现
1. 存储方式
因为堆是完全二叉树,而用数组来存储完全二叉树是非常节省存储空间的,所以堆也选择数组存储。
class dui{
public:
int* a;
int n;
dui(int x=100){
n=0;
a=new int[x];
}
...
};
这里我们定义一个堆的类,n代表我们堆中的元素个数,所以构造函数默认n是0,另外我们定义一个数组a,构造函数中可以自定义堆的大小,默认是100;当然数组也可以直接用vector。
用数组的1号下标存储根节点。那么对于一般的节点 i,其左孩子为 i*2,右孩子为 i*2+1,其父亲节点则为 i/2。(当然所有派生出来的节点x都要先判断 0<x<=n,表示存在)
2. 向堆中插入元素
为了保证其完全二叉树的性质,所以插入元素的位置,一定是n的下一个位置;
为了保证其堆的性质,插入一个元素后我们需要进行调整,这个过程称之为堆化。
例如我们在如下图所示堆中插入下标为14的节点22。
因为22大于其父亲节点9,这不满足大根堆的性质,所以我们要执行交换,以此类推,直至不在发生交换。如下图:
像这种顺着节点所在的路径,依次向上对比,然后交换的操作,我们称之为自下而上的堆化。因为插入节点是在最底层,所以插入操作适合自下而上的堆化方法。
void dui::push(int x){
n++;
a[n]=x;
int i=n; //从n开始
while(i/2>0 && a[i]>a[i/2]){ //其父亲存在,并且需要交换
swap(a[i],a[i/2]);
i=i/2;
}
}
3. 删除堆顶元素
为了满足完全二叉树的性质,我们的删除步骤是用最后一个节点覆盖堆顶,然后再从堆顶去维护堆的性质。
例如下图我们删除节点12。我们先用12替换(覆盖)节点33,然后再从上往下依次比较,交换,这种方式称之为自上而下的堆化。因为我们是先从根开始调整的,所以删除操作适合自上而下的堆化方法。
这里还要注意,自上而下的堆化过程比自下而上的方法要复杂点儿,因为左右孩子是需要去选择的,而父亲节点只有一个。
void dui::pop(){
a[1]=a[n];
n--;
int i=1; //从堆顶开始调整
int pos=i; //默认是i
while (true){
if (i*2<=n && a[i]<a[i*2]) pos=i*2; //左孩子存在,并且比父亲大
if (i*2+1<=n && a[pos]<a[i*2+1]) pos=i*2+1; //右孩子存在,并且比左孩子(也包括父亲)大
if (pos==i) break; //不发生调整 退出
swap(a[i],a[pos]); //交换
i=pos; //继续调整
}
}
代码中,我们先用pos代表要交换的位置,默认是i,然后判断左孩子是否存在且需要交换,再去判断右孩子,这样才能从左右孩子中选择出较大的并且满足交换条件的节点。
4. 其他操作
因为C++的STL中的优先队列priority_queue就是用堆实现的,所以我们这里自定义的堆这个类也想实现像优先队列的这种封装。
int size(){return n;} //堆的大小
int top(){return a[1];} //返回堆顶
bool empty(){ return (n==0)?1:0;} //判断堆是否为空
这些操作都很简单,不再赘述。
5. 完整代码
#include<iostream>
using namespace std;
class dui{
public:
int* a;
int n;
dui(int x=100){
n=0;
a=new int[x];
}
int size(){return n;} //堆的大小
int top(){return a[1];} //返回堆顶
bool empty(){ return (n==0)?1:0;} //判断堆是否为空
void push(int x); //插入元素至堆
void pop(); //删除堆顶元素
};
void dui::push(int x){
n++;
a[n]=x;
int i=n;
while(i/2>0 && a[i]>a[i/2]){ //大根堆
swap(a[i],a[i/2]);
i=i/2;
}
}
void dui::pop(){
a[1]=a[n];
n--;
int i=1; //从堆顶开始调整
int pos=i; //默认是i
while (true){
if (i*2<=n && a[i]<a[i*2]) pos=i*2; //左孩子存在,并且比父亲大
if (i*2+1<=n && a[pos]<a[i*2+1]) pos=i*2+1; //右孩子存在,并且比左孩子(也包括父亲)大
if (pos==i) break; //不发生调整 退出
swap(a[i],a[pos]); //交换
i=pos; //继续调整
}
}
int main(){
int p[]={0,1,4,3,2,5,8,7,6};
dui q;
for (int i=1; i<=8; i++){
q.push(p[i]);
}
for (int i=1; i<=8; i++) cout<<q.a[i]<<" "; cout<<endl;
cout<<q.size()<<endl;
while (!q.empty()){
cout<<q.top()<<" ";
q.pop();
}
}
三、堆排序
学会了第二部分堆的实现,那么再实现堆排序就很简单。但是真正的堆排序中还有些细节要注意。(还是以大根堆为例)
1. 原地建堆
我们直接用待排序的数组建堆,而不用再去开辟新空间。
建堆的方法我们采用自上而下的方法。
因为叶子节点是没有孩子的,所以可以跳过,直接从非叶子节点(n/2)开始。
对于非叶子节点,我们要先调整编号大的节点,这样才能保证每个节点调整自己前,其所有子节点都是满足堆的性质的,否则调整的时候无法确定到底是选择左孩子还是右孩子。
因为在后续的排序过程中还会用到堆化操作,所以单独写成一个过程,其含义是:在大小为n的堆a中,从i号节点自上而下进行调整。
void heapful(int a[],int n,int i){//自上而下的堆化
int pos=i; //默认是i
while (true){
if (i*2<=n && a[i]<a[i*2]) pos=i*2; //左孩子存在,并且比父亲大
if (i*2+1<=n && a[pos]<a[i*2+1]) pos=i*2+1; //右孩子存在,并且比左孩子(也包括父亲)大
if (pos==i) break; //不发生调整 退出
swap(a[i],a[pos]); //交换
i=pos; //继续调整
}
}
void sort(int a[],int n){
//原地建堆 自上而下堆化 从非叶子节点开始
for (int i=n/2; i>=1; i--){
heapful(a,n,i);
}
...
}
2. 排序
建好堆后,堆顶也就是a[1],即是堆的最大值,我们把它跟最后一个元素交换,那最大元素就放到了下标为 n 的位置。
这个过程类似于“删除堆顶元素”的操作,删除之后我们堆化剩余的n-1个元素(最后一个元素已经是有序的,不再考虑),然后可以得到剩下的 n−1 个元素重构的堆。
再交换,堆化,直至堆中只剩一个元素,排序结束。
过程如下图:
代码: 需要注意的是,堆的大小是一直在减小的,heapful的堆大小参数是i-1。
//排序过程:交换 维护
for (int i=n; i>1; i--){
swap(a[1],a[i]);
heapful(a,i-1,1); //这里要尤为注意:堆的元素个数是i-1
}
3. 完整代码
#include<iostream>
using namespace std;
void heapful(int a[],int n,int i){//自上而下的堆化
int pos=i; //默认是i
while (true){
if (i*2<=n && a[i]<a[i*2]) pos=i*2; //左孩子存在,并且比父亲大
if (i*2+1<=n && a[pos]<a[i*2+1]) pos=i*2+1; //右孩子存在,并且比左孩子(也包括父亲)大
if (pos==i) break; //不发生调整 退出
swap(a[i],a[pos]); //交换
i=pos; //继续调整
}
}
void sort(int a[],int n){
//原地建堆 自上而下堆化 从非叶子节点开始
for (int i=n/2; i>=1; i--){
heapful(a,n,i);
}
//排序过程:交换 维护
for (int i=n; i>1; i--){
swap(a[1],a[i]);
heapful(a,i-1,1); //这里要尤为注意:堆的元素个数是i-1
}
}
int main(){
int n;
int p[1000];
cin>>n;
for (int i=1; i<=n; i++) cin>>p[i];
sort(p,n);
for (int i=1; i<=n; i++) cout<<p[i]<<" ";
}
4. 建堆的复杂度:O(n)
一直以为建堆的复杂度和向堆中插入元素的复杂度是一样的,都是O(nlogn),没想到是O(n)。
浅显的理解:叶子节点不需要堆化。
证明:
5. 堆排序的复杂度:O(nlogn)
建堆复杂度O(n),排序复杂度n*logn,总复杂度O(nlogn)。并且堆排序是不稳定的。
6. 和快速排序的比较
为什么快速排序要比堆排序性能好?
第一点,堆排序数据访问的方式没有快速排序友好。对于快速排序来说,数据是顺序访问的。而对于堆排序来说,数据是跳着访问的。 比如,堆排序中,最重要的一个操作就是数据的堆化。比如下面这个例子,对堆顶节点进行堆化,会依次访问数组下标是 1,2,4,8 的元素,而不是像快速排序那样,局部顺序访问,所以,这样对 CPU 缓存是不友好的。
第二点,对于同样的数据,在排序过程中,堆排序算法的数据交换次数要多于快速排序。我们在讲排序的时候,提过两个概念,有序度和逆序度。对于基于比较的排序算法来说,整个排序过程就是由两个基本的操作组成的,比较和交换(或移动)。快速排序数据交换的次数不会比逆序度多。