目录
图?树?堆?
堆是什么?跟图有什么关系?跟树又有什么关系?
上面这幅图,可以整理得 “ 好看 ” 一点,像这样,变成了一颗 ” 树 “。
可能这样还不太像树,但是将它倒过来呢?序号 1 是树的 “ 根 ”,而序号 2、3、4 为 ” 枝干 “,序号 5、6、7、8、9、10是 “ 叶 ”。
这个跟之前的图很像,但是这个树是特殊的图,树是不包含回路的连通无向图,像下面这个例子:
左边这个是树,而右边这个是图。因为左边这个没有回路,而右边这个存在 1->2->5->3->1 的回路。
正是因为树有着 “ 不包含回路 ” 这个特点,所以树就被赋予了很多特性:
- 一棵树中的任意两个结点有且仅有唯一的一条路径连通。
- 一棵树如果有 n 个结点,那么它一定恰好有 n-1 条边。
- 在一棵树中加一条边将会构成一个回路。
树的定义
树,是指任意两个结点间有且只有一条路径的无向图(没有回路的连通无向图)。
为了确定一棵树的形态,在树中可以指定一个特殊的结点——根。将树中的每一个点称为结点。
如上图,左边这棵树的树根是 1 号结点,右边这棵树的树根是 3 号结点。
根又叫做根结点,一棵树有且只有一个根。根结点有时候也被称为祖先,既然有祖先,理所当然有父亲和儿子。如上边的左图,1 号结点是 2 号结点和 3 号结点的父亲,然后 2 号结点又是 4 号结点和 5 号结点的父亲,而 3 号结点是 6 号结点和 7 号结点的父亲。
以上边的左图为例:
父亲结点简称为父结点,儿子结点简称为子结点。,2 号结点既是 1 号的子结点,又是 4 号和 5 号的父结点;
如果一个结点没有子结点,那么这个点称为叶结点,例如 4 号、5 号、6 号、7 号都是叶结点;
没有父结点的被称为根结点,例如 1 号结点;
如果一个结点既不是根结点,又不是叶结点,被称为内部结点,例如 2 号结点和 3 号结点。
以上边的右图为例,可以参照下图:
二叉树
二叉树是一种特殊的树,它的特点是每个结点最多有两个儿子,左边的是左儿子,右边的是右儿子;也可以说每个结点最多有两颗子树。
一颗多叉树可以转化为二叉树,一颗二叉树也可以转化为多棵二叉树。
二叉树中有两种特殊的二叉树,一个是满二叉树,一个是完全二叉树。
满二叉树:是一颗深度为 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(),即 O(
)。这样的确可以实现,但是还有更好的方法,使用堆。
首先将这 14 个数按照最小堆的标准放在一颗完全二叉树内(圆圈里面的是值,圆圈上面的数时这个结点的编号):
我们先不考虑怎么将这 14 个数按照最小堆的标准放入完全二叉树中,图中可以看出,最小的数在堆顶,假设储存这个堆的数组叫做 h 的话,最小值就是 h[1],接下来,将堆顶的数删除,将新增的数 23 放在堆顶,显然加了新数之后就不符合最小堆的标准了,需要做出向下调整。
![](https://i-blog.csdnimg.cn/blog_migrate/5cbdc0ced48ae028520ea3a6bcdb83da.png)
![](https://i-blog.csdnimg.cn/blog_migrate/a0f050ea57f8325e0aafdc71990b6a84.png)
![](https://i-blog.csdnimg.cn/blog_migrate/38448053c5fb58558c2d030ed0b25087.png)
![](https://i-blog.csdnimg.cn/blog_migrate/41cbcad121b07817b922ed9566cc9bd9.png)
加入新数 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(),相对比先前的全部遍历快多了。
如果每次删除最小的数再新增一个数,并求当前最小值的时间复杂度为 O(),简写为 O(
)。
继续下一步,如果不需要删除一个数再新增,而是只新增一个数,该怎么做呢?
可以将新元素放在末尾,再判断新元素是否需要向上移,直至满足最小堆的特性。
向上调整的代码如下:
//传入 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(),所以插入所有元素的时间复杂度为 O(
)。
第二个方法:直接把这些数放入一个完全二叉树中,然后从最后一个结点开始,判断以这个结点为根结点的子树是否满足最小堆的特性。
首先从叶结点开始,因为叶结点没有儿子,所以所有的以叶结点为根结点的子树(这个子树只有一个结点)都满足最小堆的特性。这里所有的叶结点连子结点都没有,当然满足这个特性。所有所有的叶结点都不用处理。
从 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()。
堆排序是这样的:我们先建立一个最小堆,然后每次删除堆顶,并把堆顶放入一个新数组中,直至堆空。最后输出新数组的值,存在新数组中的值救赎已经排序好的。
堆排序代码如下(最小堆):
#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()。