堆——神奇的优先队列

目录

图?树?堆?

树的定义

 二叉树

堆排序


图?树?堆?

堆是什么?跟图有什么关系?跟树又有什么关系?

 上面这幅图,可以整理得 “ 好看 ” 一点,像这样,变成了一颗 ” 树 “。

可能这样还不太像树,但是将它倒过来呢?序号 1 是树的 “ 根 ”,而序号 2、3、4 为 ” 枝干 “,序号 5、6、7、8、9、10是 “ 叶 ”。 

这个跟之前的图很像,但是这个树是特殊的图,树是不包含回路的连通无向图,像下面这个例子:

 左边这个是树,而右边这个是图。因为左边这个没有回路,而右边这个存在 1->2->5->3->1 的回路。

正是因为树有着 “ 不包含回路 ” 这个特点,所以树就被赋予了很多特性:

  1. 一棵树中的任意两个结点有且仅有唯一的一条路径连通。
  2. 一棵树如果有 n 个结点,那么它一定恰好有 n-1 条边。
  3. 在一棵树中加一条边将会构成一个回路。

树的定义

树,是指任意两个结点间有且只有一条路径的无向图(没有回路的连通无向图)。

为了确定一棵树的形态,在树中可以指定一个特殊的结点——根。将树中的每一个点称为结点。

 如上图,左边这棵树的树根是 1 号结点,右边这棵树的树根是 3 号结点。

根又叫做根结点,一棵树有且只有一个根。根结点有时候也被称为祖先,既然有祖先,理所当然有父亲和儿子。如上边的左图,1 号结点是 2 号结点和 3 号结点的父亲,然后 2 号结点又是 4 号结点和 5 号结点的父亲,而 3 号结点是 6 号结点和 7 号结点的父亲。

以上边的左图为例:

父亲结点简称为父结点,儿子结点简称为子结点。,2 号结点既是 1 号的子结点,又是 4 号和 5 号的父结点;

如果一个结点没有子结点,那么这个点称为叶结点,例如 4 号、5 号、6 号、7 号都是叶结点;

没有父结点的被称为根结点,例如 1 号结点;

如果一个结点既不是根结点,又不是叶结点,被称为内部结点,例如 2 号结点和 3 号结点。

以上边的右图为例,可以参照下图:

 二叉树

二叉树是一种特殊的树,它的特点是每个结点最多有两个儿子,左边的是左儿子,右边的是右儿子;也可以说每个结点最多有两颗子树。

一颗多叉树可以转化为二叉树,一颗二叉树也可以转化为多棵二叉树。

二叉树中有两种特殊的二叉树,一个是满二叉树,一个是完全二叉树。

满二叉树:是一颗深度为 h 且有 2^{h}-1 个结点的二叉树;

完全二叉树:若设二叉树的高度为 h,除第 h 层外,其他各层( 1 ~ h-1 )的结点数都达到最大个数,第 h 层从右往左连续缺若干个结点。


 那么一颗完全二叉树的一个父结点的编号为 k,那么它的左儿子编号就是 2*k,右儿子的编号就是 2*k+1.

如果已知儿子的编号是 x,那么它的父结点的编号就是 x/2.(向下取整,则 4/2 和 5/2 都是 2)

完全二叉树的应用——堆。

堆排序

堆是一种特殊的完全二叉树。

完全二叉树中,所有父结点的值都比子结点得值要小,被称为最小堆;反之,被称为最大堆。

假如有 14 个数,分别是 99、5、36、7、22、17、46、12、2、19、25、28、1、92,请找出这 14 个数中的最小值,怎么做呢?

最简单的办法就是将这 14 个数从头到尾依次找一遍,用一个循环就可以解决。这个方法的时间复杂度是 O(14),也就是 O(N)。

int min=a[0];
for(i=1;i<=14;i++)
{
    if(a[i]<min)
    min=a[i];
}

但是如果把题目改一下,要删掉其中的最小数,然后增加一个新数 23,再次求这 14 个数中的最小数,怎么做呢?

只能重新扫描所有的数,找到新的最小数,这个的时间复杂度也是 O(N)。假如有 14 次这样的操作(删除最小的数之后在加一个新数),那这样的的时间复杂度就是 O(14^{2}),即 O(N^{2})。这样的确可以实现,但是还有更好的方法,使用堆。

首先将这 14 个数按照最小堆的标准放在一颗完全二叉树内(圆圈里面的是值,圆圈上面的数时这个结点的编号):

我们先不考虑怎么将这 14 个数按照最小堆的标准放入完全二叉树中,图中可以看出,最小的数在堆顶,假设储存这个堆的数组叫做 h 的话,最小值就是 h[1],接下来,将堆顶的数删除,将新增的数 23 放在堆顶,显然加了新数之后就不符合最小堆的标准了,需要做出向下调整。

图一
图二
图三
图四

 加入新数 23 之后就是图一,此时将它与它的两个儿子进行比较,选择一个更小的进行交换;

交换后如图二,还是不满足最小堆的特性,因此还需要向下调整;

交换后如图三, 23 大于它的右儿子,与 22 交换后如图四。

从上述向下调整可以看出,当新增加一个数放到堆顶时,如果不满足最小堆的特性,就需要将这个数向下调整,直至找到合适的位置为止,使其满足最小堆的特性。

 向下调整的代码如下:

//传入 i,从 i开始向下调整 
void siftdown(int i)
{
	int t,flag=0;//t记录最小值下标,flag判断是否继续向下调整 
	while(i*2<=n&&flag==0)
	{
		if(h[i]>h[i*2])//如果父结点的值大于左儿子的值 
		t=i*2;
		else//记得将父结点的值放入记录值的 t中 
		t=i;
		if(i*2+1<=n&&h[t]>h[i*2+1])//判断是否存在右儿子且右儿子的值是否小于当前记录的最小值 
		t=i*2+1;
		if(t!=i)//如果父结点和子结点中最小值不是父结点,也就是 t!=i 
		{
			swap(i,t);//交换两个下标对应的数组值 
			i=t;//接着从下标 t开始继续向下调整 
		}
		else//如果从当前的点已经满足最小堆的特性了,那么不需要再进行向下调整了 
		flag=1;//标记为 1,退出循环 
	}
	return ;
} 

 在上述代码中,对于 23 的调整,只进行了 3 次比较,时间复杂度为 O(3)也就是 O(log_{2}14),相对比先前的全部遍历快多了。

如果每次删除最小的数再新增一个数,并求当前最小值的时间复杂度为 O(log_{2} N),简写为 O(logN)。

继续下一步,如果不需要删除一个数再新增,而是只新增一个数,该怎么做呢?

可以将新元素放在末尾,再判断新元素是否需要向上移,直至满足最小堆的特性。

 向上调整的代码如下:

//传入 i,从 i开始向上调整 
void siftup(int i)
{
	int t,flag=0;
	//如果父结点的值大于当前的 i结点,t记录父结点的下标
	//flag判断是否继续向上调整 
	while(n/2>1&&flag==0)//往上的子结点不能超过数组范围,并且如果当前访问到堆顶了,也不需要继续调整了 
	{
		if(h[i]<h[i/2])//如果子结点小于父结点 
		t=i/2;
		else//否则退出循环 
		flag=1;
		i=i/2;//一个循环结束要更新需要调整的下标 
	}
	return ;
} 

 仔细想想,是不是漏了什么,难道给出的 14 个数字一定会满足最小堆吗?

所以在说过的调整之前,我们还需要建立一个符合标准的堆。

第一个方法:从空的堆开始,依次往堆中插入数,直到所有的数都被插入。插入第 i 个数的时间复杂度是 O(log i),所以插入所有元素的时间复杂度为 O(Nlog N)。


第二个方法:直接把这些数放入一个完全二叉树中,然后从最后一个结点开始,判断以这个结点为根结点的子树是否满足最小堆的特性。

首先从叶结点开始,因为叶结点没有儿子,所以所有的以叶结点为根结点的子树(这个子树只有一个结点)都满足最小堆的特性。这里所有的叶结点连子结点都没有,当然满足这个特性。所有所有的叶结点都不用处理。

从 n/2 开始处理这棵完全二叉树。(最后一个非叶结点是第 n/2 个结点,可以去找找有没有反例)

例如这 14 个数:99、5、36、7、22、17、46、12、2、19、25、28、1、92,放入完全二叉树中,从 14/2 开始,向下调整:

 同理,以 6 号、5 号、4 号为根结点的子树也不符合最小堆的特性,都需要向下调整。

 调整完之后,这棵树依然不满足最小堆的特性,继续从 3 号结点为根结点的子树,从 3 号开始向下调整。

 

同理,将以 2 号结点为根结点的子树进行调整。

最后调整以 1 号结点为根结点的子树,调整完之后,就是最小堆了。用这种方法建立堆的时间复杂度为 O(N)。

 堆可以用作堆排序,与快速排序一样,堆排序的时间复杂度也是 O(N log N)。

堆排序是这样的:我们先建立一个最小堆,然后每次删除堆顶,并把堆顶放入一个新数组中,直至堆空。最后输出新数组的值,存在新数组中的值救赎已经排序好的。

堆排序代码如下(最小堆):

#include<stdio.h>
int h[105];//存放堆的一维数组 
int n;//存储堆中元素的个数 
//通过数组的下标,来交换数组的值的函数 
void swap(int a,int b)
{
	int t;
	t=h[a];
	h[a]=h[b];
	h[b]=t;
	return ;
}
//向下调整的函数 
void siftdown(int i)//传入向下调整的结点的编号 
{
	int t;// t用来记录当前最小的值的编号 
	int flag=0;// flag用来判断是否还需要向下判断
	//如果当前的 i结点有儿子,并且需要继续调整时,继续循环 
	while(flag==0&&i*2<=n)
	{
	    //如果当前的 i下标对应的值大于它的左儿子的值,就记录下左儿子的下标 
		if(h[i]>h[i*2])
		t=i*2;
		else//当然如果不满足要把 t赋值为 i,否则如果循环中所有的 if都不满足就会出现错误 
		t=i;
		if(i*2+1<=n&&h[t]>h[i*2+1])//判断是否有右儿子,然后将右儿子对应的值与先前存储的 t下标对应的值比大小 
		t=i*2+1;
		if(i!=t)//如果刚刚记录的 t的值与先前传入的函数值不相等,说明子结点中有比父结点更小的 
		{
			swap(i,t);//交换他们的值 
			i=t;//更新 i为刚刚的子结点,然后继续向下调整 
		}
		else
		flag=1;//如果相等,说明父结点和子结点的关系没有问题,不需要再进行调整了 
	}
	return ;
}
//将堆的第一个值从堆中删除,并且将堆最后一个值放在第一个,然后通过向下调整得到一个新的堆 
int deletemin()
{
	int t;
	t=h[1];//记录堆的第一个值 
	h[1]=h[n];//最后一个赋值给堆的第一个 
	n--;//将堆的个数减少 
	siftdown(1);//向下调整 
	return t;//返回记录的堆的第一个值 
}
void creat()
{
	int i;
	for(i=n/2;i>=1;i--)
	{
		siftdown(i);
	}
	return ;
}
int main()
{
	int i,num;
	scanf("%d",&n);
	num=n;//这个步骤不能省,因为从要不断更新堆中元素的个数,但是输出时,要用到输入时堆元素的个数 
	for(i=1;i<=n;i++)
	scanf("%d",&h[i]);
	creat();//建堆 
	for(i=1;i<=num;i++)//循环输出 
	printf("%d ",deletemin());//利用函数一边输出,一边删除,建立新的堆以找到删除后的最小值 
	return 0;
}

 上述是建立最小堆,来输出从小到大排序的数列,还有另一种方法——建立最大堆。

最大堆建立好之后,堆顶就是最大值,我们需要从小到大排序,是将最大值放在后面。那我们将 h[1] 与 h[n] 交换,并且将堆的元素 -1,此时 h[n] 就是最大的数,将交换后的新 h[1] 向下调整,如此反复,知道堆的大小变成 1 为止。

堆排序代码如下(最大堆):

#include<stdio.h>
int n;//存放堆中元素及更新后堆中的元素 
int h[105];//用来存放堆的数组 
void swap(int a,int b)//通过数组的下标,来交换数组的值的函数
{
	int t;
	t=h[a];
	h[a]=h[b];
	h[b]=t;
	return ;
} 
//向下调整的函数
//(跟最小堆的处理类似,不过最大堆的特性是父结点的值大于子结点 
void siftdown(int i)//传入向下调整的结点的编号 i 
{
	int t,flag=0;
	//t用来记录当前最小值的下标 
	//flag判断是否继续进行循环 
	while(i*2<=n&&flag==0)
	{
		if(h[i]<h[i*2])//判断父结点是否小于左儿子 
		t=i*2;
		else
		t=i;
		if(i*2+1<=n&&h[t]<h[i*2+1])//判断当前最大的值是否大于右儿子 
		t=i*2+1;
		if(i!=t)//如果左儿子或右儿子的值大于父结点,就将父结点与更大的值交换 
		{
			swap(i,t);
			i=t;//将 i赋值为 t,继续进行调整 
		}
		else//如果从当前开始,就已经满足最大堆的特性了,就不用不用调整了 
		flag=1;//标记为 1,退出循环 
	}
	return ;
}
void heapsort()
{
    //每一次将最大堆的堆顶放在最后,并更新堆中的元素个数,使每一次最大值都放在后面 
	while(n>1)//到堆顶时不用继续循环
	{
		swap(1,n);
		n--;
		siftdown(1);
	}
	return ;
}
void creat()
{
	int i;
	for(i=n/2;i>=1;i--)
	{
		siftdown(i);
	}
	return ;
}
int main()
{
	int num,i;
	scanf("%d",&n);
	//这个步骤不能省,因为从要不断更新堆中元素的个数,但是输出时,要用到输入时堆元素的个数 
	num=n;
	for(i=1;i<=num;i++)
	scanf("%d",&h[i]);
	creat();//建堆 
	heapsort();
	for(i=1;i<=num;i++)//输出 
	printf("%d ",h[i]);
	return 0;
}

 总结

可以删除元素,插入元素,并且寻找最大值或最小值的数据结构被称为优先队列。

如果使用普通队列来实现这两个功能,需要枚举整个队列,时间复杂度很高。

而堆可以很好地解决,例如,要求 n 个数中第 k 小的值,可以从堆中元素为 n 一直更新为堆中元素为 k,这时有 k 个元素的堆,时间复杂度就是 O(N log N)。

 

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

明里灰

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值