堆的性质、堆的实现、堆排序

目录

一、堆的性质

二、堆的实现

1. 存储方式

2. 向堆中插入元素

3. 删除堆顶元素

4. 其他操作

5. 完整代码

三、堆排序

1. 原地建堆

2. 排序

3. 完整代码

4. 建堆的复杂度:O(n)

5. 堆排序的复杂度:O(nlogn) 

6. 和快速排序的比较


一、堆的性质

堆是一种特殊的树。

只要满足以下两点,它就是一个堆:

  • 堆是一个完全二叉树;
  • 堆中每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值。

第一点,堆必须是一个完全二叉树。完全二叉树要求,除了最后一层,其他层的节点个数都是满的,最后一层的节点都靠左排列,自然堆也具有完全二叉树的所有性质。

第二点,堆中的每个节点的值必须大于等于(或者小于等于)其子树中每个节点的值。实际上,我们还可以换一种说法,堆中每个节点的值都大于等于(或者小于等于)其左右子节点的值。这两种表述是等价的。

对于每个节点的值都大于等于子树中每个节点值的堆,我们叫做“大顶堆”。对于每个节点的值都小于等于子树中每个节点值的堆,我们叫做“小顶堆”。

以下讲解都用大根堆为例。

 

二、堆的实现

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 缓存是不友好的。

  第二点,对于同样的数据,在排序过程中,堆排序算法的数据交换次数要多于快速排序。我们在讲排序的时候,提过两个概念,有序度和逆序度。对于基于比较的排序算法来说,整个排序过程就是由两个基本的操作组成的,比较和交换(或移动)。快速排序数据交换的次数不会比逆序度多。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值