树与二叉树
如下图,这是什么?是一个图?不对。是一棵树
图1 树
是不是很像一颗倒挂的树?也就是说它是根朝上,而叶子朝下的。和下面的图是不是很像啦。
图2 现实中的树
树的特点:
1. 一棵树中的任意两个结点有且仅有唯一的一条路连通。
2. 一棵树如果有n个结点,那么它一定恰好有n-1条边。
3. 在一棵树中加一条边将会构成一个回路。
树是指任意两个结点间有且仅有一条路径的无向图。或者说,只要是没有回路的连通无向图就是树。喜欢思考的同学可能会发现一棵树可以有多种形态,比如下面这两棵树。
图3 树的多种形态
为了确定一棵树的形态,在树中可以指定一个特殊的结点——根。我们在对一棵树进行讨论的时候,将树中的每个点称为结点,有的书中也称为节点。有一个根的树叫做有根树。比如上图左边这棵树的树根是1号结点,右边为3号结点。
根又叫根结点, 一棵树有且只有一个根结点。根结点有时候也称为祖先。既然有祖先,理所当然有父亲和儿子。比如上图右边这棵树中,3号结点是1、6和7号结点的父亲,1、6和7号结点是3号结点的儿子。同时1号结点又是2号结点的父亲,2号结点是1号结点的儿子,……。
父亲结点简称为父结点,儿子结点简称为子结点。2号结点既是父结点又是子结点,它是1号结点的子结点,同时也是4和5号结点的父结点。另外如果一个结点没有子结点(即没有儿子),那么这个结点称为叶结点,例如4、5、6和7号结点都是叶结点。没有父结点(即没有父亲)的结点称为根结点。如果一个结点既不是根结点也不是叶结点,则称这个结点为内部结点。最后每个结点还有深度,比如4号结点的深度为4。深度是指从根到这个结点的层数(根为第一层)。不理解的请看下图:
图4 树的示例
二叉树
二叉树是一种特殊的树。二叉树的特点是每个结点最多有两个儿子,左边的叫做左结点(左二子),右边的叫做右结点(右儿子)。或者说每个结点最多有两棵子树。
二叉树的递归定义为:
二叉树要么为空,要么由根结点、左子树和右子树组成,而左子树和右子树分别是一棵二叉树。
下面这棵树就是一棵二叉树。
图5 二叉树
二叉树的适用范围很广,一颗多叉树也可以转化为二叉树。二叉树中有两种特殊的二叉树,叫做满二叉树和完全二叉树。如果二叉树中每个内部结点都有两个儿子,这样的二叉树叫做满二叉树。或者说满二叉树所有的叶结点都有同样的深度。比如图8-5,就是一棵满二叉树,是不是感觉很“丰满”。满二叉树严格的定义是一棵深度为h且有2h-1个结点的二叉树。
如果一棵二叉树除了最右边位置上有一个或者几个叶结点缺少外,其他是丰满的,那么这样的二叉树就是完全二叉树。完全二叉树的严格定位为:若设一棵二叉树的高度为h,除第h层外,其他各层(1 ~ h-1) 的结点数都达到最大个数,第h层从右向左连续缺若干个结点,则这个二叉树就是完全二叉树。也就是说如果一个结点有右子结点,那么它一定也有左子结点。例如下面三棵树都是完全二叉树。
图6 完全二叉树示例
说到这里,我们就要领略完全二叉树的魅力了。想一想:一棵完全二叉树如何存储呢?其实完全二叉树中父亲和儿子之间有着神奇的规律,我们只需要用一个一维数组就可以存储完全二叉树。首先将完全二叉树进行从上到下,从左到右编号。
图7 完全二叉树的编号
完全二叉树的性质:
如果一个父结点编号为k,那么它的左儿子的编号为2*k,右儿子的编号就是2*k+1。
如果一个儿子(左或右)的编号为x,那么它的父结点的编号为x/2(取整)。
如果一棵完全二叉树有n个结点,那个这个完全二叉树的高度为long2n。
完全二叉树的最典型应用就是——堆。
堆——神奇的优先队列
堆是什么?是一种特殊的完全二叉树,就像下面这棵树一样。
图8-2-1 最小堆
有没有发现这棵二叉树有一个特点?就是所有父结点都比子结点要小(注意:上图中,圆圈里面的数是值,圆圈上面的数是这个结点的编号)。符合这样特点的完全二叉树我们称之为最小堆。反之,如果所有父结点都比子结点要大,这样的完全二叉树称为最大堆。那么,这一特性究竟有什么用呢?
假如有14个数,分别是99、5、36、7、22、17、46、12、2、19、25、28、1和92,请找出这14个数中最小的数,请问怎么办呢?最简单的方法就是将这14个数从头到尾依次扫一遍,用一个循环就可以解决。时间复杂度是O(14),也就是O(n)。
for(i=1; i<=14; i++)
if (a[i] < min) min = a[i];
现在我们需要删除其中最小的数,并增加一个新的数23,再次求这14个数中最小的一个数。该怎么办呢?只能重新扫描所有的数,才能找出新的最小的数,这个时间复杂度也是O(n)。假如现在有14次这样的操作(删除最小的数后再添加一个新数),整个时间复杂度为O(142)即O(n2)。
那有没有更好的方法呢?堆这个特殊的结构恰好能够解决这个问题。
首先我们把这14个数按照最小堆的要求(就是所有父结点都比子结点要小)放入一棵完全二叉树,就像下面这棵树一样。
图8-2-2 最小堆的应用
很显然最小的数就是堆顶,假设存储这个堆的数组叫做h的话,最小数就是h[1]。接下来,我们将堆顶的数删除。将新增加的数23放入堆顶。显然加了新数后已经不符合最小堆的特性,我们需要将新增加的数调整到合适的位置。那如何调整呢?
向下调整!我们需要将这个数与它的两个儿子2和5比较,选择较小的一个与它交换,交换之后如下:
我们发现此时还是不符合最小堆的特性,因此需要继续向下调整!于是继续将23与它的两个儿子12和7比较,选择较小一个交换,交换之后如下。
到此,还是不符合最小堆的特性,继续向下调整!直到符合最小堆的特性为止。
现在我们发现已经符合最小堆的特性了。综上所述,当新增一个数被放置到堆顶时,如果此时不符合最小堆的特性,则需要将这个数向下调整,直到找到合适的位置为止,使其重新符合最小堆的特性。
向下调整(删除)的代码:
void down(int i){ //传入一个需要向下调整的结点编号 i,这里传入1,即从堆的顶点开始向下调整
int t, flag=0; //flag用来标记是否需要继续向下调整
//当i结点有儿子(其实是至少有左儿子的情况下)并且有需要调整的时候,循环就执行
while ( i*2<=n && flag==0){
//首先判断它和左儿子的关系,并用t记录值较小的结点编号
if (h[i] > h[i*2]) t=i*2;
else t=i;
//如果它有右儿子,再对右儿子进行讨论
if (i*2+1 <= n){
//如果右儿子的值更小,更新较小的结点编号
if (h[t] > h[i*2+1]) t=i*2+1;
}
//如果发现最小的结点编号不是自己,说明子结点中有比父结点更小的
if (t!=i){
swap(t, i); //交换它们,注意swap函数自己编写
i=t; //更新i为刚才与它交换的儿子结点的编号,便于接下来继续向下调整
} else flag=1;
//否则说明当前的父结点已经比两个子结点都要小,不需要再进行调整了
}
}
我们刚才在对23进行调整的时候,竟然只用了3次比较,就重新恢复了最小堆的特性,现在最小的数依然在堆顶,为2。而使用之前从头到尾扫描的方法需要14次比较,现在只需要3次就够了。并求当前最小数的时间复杂度为O(3),这恰好是O(log214)即O(log2n) 。
如果我们新增一个值,而不是删除最小值,如何操作呢?
只需要直接将新元素插入到末尾,再根据情况判断新元素是否需要上移,直到满足堆的特性为止。例如我们要增加一个数3。
先让3与他的父结点25比较,发现比父结点小,为了维护最小堆的特性,需要与父结点的值进行交换。交换之后发现比它的父结点5小,再次交换,到此就重新满足了最小堆的特性,向上调整完毕。
向上调整(增加)的代码:
void up(int i){ //传入一个需要向下上调整的结点编号 i
int flag=0; //用来标记是否需要继续向上调整
if (i==1) return; //如果是堆顶,就返回,不需要调整了
//不在堆顶,并且当前结点i的值比父结点小的时候就继续向上调整
while ( i!=1 && flag==0){
//首判断它是否比父结点的小
if (h[i] < h[i/2]) swap(i, i/2); //交换它和它爸爸的位置
else flag=1; //表示不需要调整了,当前结点的值比父结点的值要大
i=i/2; //这句话很重要,更新编号i为它父结点的编号,从而便于下一次继续向上调整
}
}
说了半天,我们忽略了一个很重要的问题!如何建立这个堆。可以从空的堆开始,然后依次往堆中插入每一个元素,直到所有数都被插入(转移到堆中)为止。代码如下:
建立堆的代码:
n=0;
for(i=1; i<=m; i++){
n++;
h[n]=a[i];
up();
}
其实,我们还有更快的方法来建立堆。它是这样的。
直接将99、5、36、7、22、17、46、12、2、19、25、28、1和92这14个数放入一个完全二叉树中(这里我们还用一维数组来存储完全二叉树)。
在这棵完全二叉树中,我们从最后一个结点开始,依次判断以这个结点为根的子树是否符合最小堆的特性。如果所有的子树都符合,那么整棵树就是最小堆了。如果这句话没有理解不要着急,继续往下看。
首先我们从叶结点开始。因为叶结点没有儿子,所以所有以叶结点为根结点的子树(其实这个子树只有一个结点)都符合最小堆的特性,即父结点的值比子结点的值小。这里所有的叶结点连子结点都没有,当然符合这个特性。因此所有叶结点都不需要处理,直接跳过。从第n/2个结点(n为完全二叉树的结点总数,这里即7号结点)开始处理这棵完全二叉树。注意完全二叉树有一个性质:最后一个非叶结点是第n/2个结点。
以7号结点为根的子树不符合最小堆的特性,因此需要向下调整:
同理,以6号、5号和4号结点为根的子树也不符合最小堆的特性,都需要往下调整。最终,所有的结点都调整完毕之后,整棵树就符合最小堆的特性了。
总结建堆的算法:
把n个元素建立一个堆,首先将n个结点自顶向下、从左到右的方式从1到n编码。这样可以把这n个结点转换成一棵完全二叉树。
接着,从最后一个非叶结点(结点编号n/2)开始到根结点(结点编号1),逐个扫描所有的结点,根据需要将当前结点向下调整,直到以当前结点为根结点的子树符合堆的特性。代码太简单了,如下:
for(i=n/2; i>=1; i--) down(i);
堆排序
堆还有一个作用,就是堆排序,与快速排序一样,堆排序的时间复杂度也是O(NlogN)。堆排序的实现很简单,比如我们现在要从小到大排序,可以先建立最小堆,然后每次删除顶部元素并将顶部元素输出或者放入一个新的数组中,直到堆为空为止。最终输出的或者存放在新数组中的数就已经是排好序的了。
删除最大的元素:
int delmax(){
int t=h[1]; //用一个临时变量记录堆顶点的值
h[1]=h[n]; //将堆的最后一个点赋值到堆顶
n--; //堆的元素减少1
down(1); //向下调整
return t; //返回之前记录的堆的顶点的最大值
}
堆排序的完整代码:
#include <iostream>
using namespace std;
int h[10001]; //用来存放堆的数组
int n; //堆中元素的个数,也就是堆的大小
void swap(int x, int y){
int t;
t=h[x]; h[x]=h[y]; h[y]=t;
}
void down(int i){
int t, flag=0;
while (i*2<=n && flag==0){
if(h[i] > h[i*2]) t=i*2;
else t=i;
if(i*2+1 <= n){
if(h[t] > h[i*2+1]) t=i*2+1;
}
if(t!=i){
swap(t, i);
i=t;
}else flag=1;
}
}
void creat(){ //建立堆的函数
for(int i=n/2; i>=1; i--) down(i);
}
int delmax(){ //删除最大的元素
int t;
t=h[1];
h[1]=h[n];
n--;
down(1);
return t;
}
int main(){
int i, num;
cin >> num;
for(i=1;i<=num;i++) cin >> h[i];
n=num;
creat(); //建堆
//删除顶部元素,连续删除n次,其实也就是从大到小输出来
for(i=1;i<=num;i++) cout << delmax() << ' ';
return 0;
}
当然,堆排序还有一种更好的方法。从小到大排序的时候不建立最小堆而建立最大堆,最大堆建立好后,最大的元素在h[1],因为我们的需求是从小到大排序,希望最大的放在最后。因此我们将h[1]和h[n]交换,此时h[n]就是数组中的最大的元素。请注意,交换后还需将h[1]向下调整以保持堆的特性。现在最大的元素已经归位,需要将堆的大小减1即n--,然后再将h[1]和h[n]交换,并将h[1]向下调整,如此反复,直到堆的大小变成1为止。此时数组h中的数就已经是排好序的了,代码如下:
堆排序heapsort:
void heapsort(){
while(n>1){
swap(1,n);
n--;
down(1);
}
}
堆排序的完整代码(需建最大堆):
#include <iostream>
using namespace std;
int h[10001], n;
void swap(int x, int y){
int t;
t=h[x]; h[x]=h[y]; h[y]=t;
}
void down(int i){
int t, flag=0;
while (i*2<=n && flag==0){
if(h[i] > h[i*2]) t=i*2;
else t=i;
if(i*2+1 <= n){
if(h[t] > h[i*2+1]) t=i*2+1;
}
if(t!=i){
swap(t, i);
i=t;
}else flag=1;
}
}
void creat(){
for(int i=n/2; i>=1; i--) down(i);
}
void heapsort(){
while(n>1){
swap(1, n);
n--;
down(1);
}
}
int main(){
int i, num;
cin >> num;
for(i=1;i<=num;i++) cin >> h[i];
n=num;
creat();
heapsort();
for(i=1; i<num; i++) cout << h[i] << ' ';
cout << h[num] << endl;
return 0;
}
优先队列
像这样支持插入元素和寻找最大(小)值元素的数据结构称为优先队列。堆就是一种优先队列的实现,可以很好地解决这两种操作。
另外,dijkstra算法中每次找离源点最近的一个顶点也可以用堆来优化,可以降低算法的时间复杂度。堆还经常用来求一个数列中第k大的数,只需要建立一个大小为k的最小堆,堆顶就是第k大的数。举个例子:假如有10个数,要求第3大的数,第一步选取任意3个数,比如说是前3个数,将这3个数建成最小堆,然后从第4个数开始,与堆顶的数比较,如果比堆顶的数要小,那么这个数不要,如果比堆顶的数要大,则舍弃当前堆顶而将这个数作为新的堆顶,并再去维护堆,用同样的方法去处理剩下的数。