树是一种非线性的数据结构,它是由n(n>=0)个有限结点组成一个具有层次关系的集合。把它叫做树是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。
下面的图中红色线的两个结点之间都是不符合要求的,所以不能称之为树:
注意:树形结构中,子树之间不能有交集,否则就不是树形结构。
子树是不相交的
除了根结点外,每个结点有且仅有一个父结点
一颗N个结点的树有N-1条边。
通过下面这颗树我们来认识一下树的相关概念:
节点的度:一个节点含有的子树的个数称为该节点的度; 如上图:A结点的度为3,B结点的度为2
叶节点或终端节点:度为0的节点称为叶节点; 如上图:J、F、K、L、H、I为叶节点
非终端节点或分支节点:度不为0的节点; 如上图:B、C、D、E、G为分支节点;根结点也是分支结点,除根结点外,分支结点也称为内部结点
双亲节点或父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点; 如上图:A是B的父节点
孩子节点或子节点:一个节点含有的子树的根节点称为该节点的子节点; 如上图:B是A的孩子节点
兄弟节点:具有相同父节点的节点互称为兄弟节点; 如上图:B、C是兄弟节点
树的度:一棵树中,最大的节点的度称为树的度; 如上图:树的度为3
节点的层次:从根开始定义起,根为第1层,根的子节点为第2层,以此类推;如上图A是第一层、B、C、D是第二层……
树的高度或深度:树中节点的最大层次; 如上图:树的高度为4
堂兄弟节点:双亲在同一层的节点互为堂兄弟;如上图:E、G互为堂兄弟节点
节点的祖先:从根到该节点所经分支上的所有节点;如上图:A是所有节点的祖先
子孙:以某节点为根的子树中任一节点都称为该节点的子孙;如上图:所有节点都是A的子孙
森林:由m(m>0)棵互不相交的树的集合称为森林
树结构相对线性表就比较复杂了,要存储表示起来就比较麻烦,既要保存值域,也要保存结点和结点之间的关系,实际中树有很多种表示方式如:双亲表示法,孩子表示法、孩子双亲表示法以及孩子兄弟表示法等。最常用的表示方法是孩子兄弟表示法。
typedef int DataType;//重命名
struct Node {
struct Node* firstChild;//第一个孩子结点
struct Node* pNextBrother;//指向其下一个兄弟结点
DataType data;//结点中的数据域
};
一颗二叉树是结点的有限集合,该集合或者为空,或者由一个根节点加上两棵别称为左子树和右子树的二叉树的组成。
从上图可以看出:
1.二叉树不存在度大于2的结点;
2.二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序树。
注意:对于任意的二叉树都是由以下几种情况复合而成的
满二叉树:一个二叉树,如果每一层的结点数都达到最大值,则这个二叉树就是满二叉树。也就是说,如果一个二叉树的层数为K,且结点总数是2k-1,则它就是满二叉树。
满二叉树中如果第k层满了,则第k层的结点个数是2k-1;如果满二叉树的深度为h,则满二叉树一共有2h-1个结点。
扩展:满二叉树第一层有一个结点即20,第二层有2个结点21,第三层有4个节点22……所以第k层节点个数为2k-1。
当我们知道满二叉树的深度为h时,可以根据k层的结点个数通过错位相减推导出该满二叉树的结点个数。
结点个数为每层结点个数相加之和,从第一层开始:T(n) = 20 + 21 + 22 + …… + 2h-1;乘以2:2T(n) = 21 + 22 + 23 …… + 2h,相减之后可以求出该满二叉树一共有2h-1个结点。
**完全二叉树:完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为K,有n个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从1至n的结点一一对应时称之为完全二叉树。 **
注意:满二叉树是一种特殊的完全二叉树。
完全二叉树前N-1层都是满的,最后一层可以不满,但是必须从左到右是连续的。
假设完全二叉树的高度是h,则该完全二叉树最多情况下(即该完全二叉树是满二叉树)有2h-1个结点,最少情况下(即该完全二叉树第h层只有一个节点)有2h-1个结点。
扩展:推导最少情况下(即该完全二叉树第h层只有一个节点)有2h-1个结点
满二叉树时第h层有2h-1个结点,当该完全二叉树第h层只有一个节点时,完全二叉树节点个数最少,拿满二叉树的节点个数减去满二叉树最后一层的节点个数再加上1得到的就是完全二叉树节点最少的情况,即(2h-1) - (2h-1) + 1 = 2h-1。
1、若规定根节点的层数为1,则一棵非空二叉树的第i层上最多有2i-1个结点
2、若规定根节点的层数为1,则深度为h的二叉树的最大结点数是2h-1
3、对任何一棵二叉树, 如果度为0的叶结点个数为n0,度为2的分支结点个数为n2,则有n0=n2+1
4、若规定根节点的层数为1,具有n个结点的满二叉树的深度,h= log2(n + 1) (ps:log2(n + 1)是log以2为底,n+1为对数)
5、对于具有n个结点的完全二叉树,如果按照从上至下从左至右的数组顺序对所有节点从0开始编号,则对于序号为i的结点有:
练习:
1.某二叉树共有399个结点,其中199个度为2的结点,则该二叉树中的叶子结点数为( )。
A.不存在这样的二叉树
B.200
C.198
D.199
解析:对任何一棵二叉树, 如果度为0的叶结点个数为n0,度为2的分支结点个数为n2,则有n0=n2+1,根据此性质可知叶子节点数为200,故选A。
2.下列数据结构中,不适合采用顺序存储结构的是( )
A 非完全二叉树
B 堆
C 队列
D 栈
解析:堆、队列、栈都适合采用顺序存储结构,非完全二叉树不适合,故选A。
3.在具有2n个结点的完全二叉树中叶子结点个数为( )。
A.n
B.n+1
C.n-1
D.n/2
解析:设叶子节点为n0,度为2的节点为n2,度为1的节点为n1,n0+n2+n1 = 2n;由n0 = n2 + 1和n0+n2+n1 = 2n这两个公式可以推出2n0 + n1 = 2n;完全二叉树中度为1的节点个数要么为0要么为1,此时为1才合法,所以n0 = n,故选A。
4.一棵完全二叉树的结点数为531,那么这棵树的高度为( )。
A.11
B.10
C.8
D.12
解析:完全二叉树最多情况下节点个数为2h-1;最少情况下节点个数为2h-1,当h = 10的时候节点个数在该范围内,所以这颗树的高度为10,故选B。
5.一个具有767个结点的完全二叉树,其叶子结点个数为( )。
A.383
B.384
C.385
D.386
解析:由上一题可以知道该完全二叉树的高度为10,那么我们先计算前9层的节点个数:29 - 1 = 511,计算第10层的节点个数为:767 - 511 = 256;然后通过第10层的节点个数可以得出第9层中不是叶子节点的个数:256 / 2 = 128;再计算第9层的节点个数:29-1 = 256,然后求出第9层的叶子节点个数:256 -128 = 128,加上第10层的节点个数可以得出叶子节点个数为384,故选B。
二叉树一般可以使用两种结构存储,一种顺序结构,一种链式结构。
顺序结构存储就是使用数组来存储,一般使用数组只适合表示完全二叉树,因为不是完全二叉树会有空间的浪费。而现实中使用中只有堆才会使用数组来存储。二叉树顺序存储在物理上是一个数组,在逻辑上是一颗二叉树。
二叉树的链式存储结构是指用链表来表示一棵二叉树,即用链来指示元素的逻辑关系。通常的方法是链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别用来给出该结点左孩子和右孩子所在的链结点的存储地址。链式结构又分为二叉链和三叉链。
typedef int BTDataType;
// 二叉链
struct BinaryTreeNode
{
struct BinTreeNode* _pLeft; // 指向当前节点左孩子
struct BinTreeNode* _pRight; // 指向当前节点右孩子
BTDataType _data; // 当前节点值域
};
// 三叉链
struct BinaryTreeNode
{
struct BinTreeNode* _pParent; // 指向当前节点的双亲
struct BinTreeNode* _pLeft; // 指向当前节点左孩子
struct BinTreeNode* _pRight; // 指向当前节点右孩子
BTDataType _data; // 当前节点值域
};
普通的二叉树是不适合用数组来存储的,因为可能会存在大量的空间浪费,而完全二叉树更适合使用顺序结构存储。现实中我们通常把堆(一种二叉树)使用顺序结构的数组来存储。
需要注意的是这里的堆和操作系统虚拟进程地址空间中的堆是两回事,这里的堆指的是数据结构,而操作系统虚拟进程地址空间中的堆是操作系统中管理内存的一块区域分段。
如果有一个关键码的集合K = {k0,k1,k2……kn-1},把它的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,并满足:Ki <= K2 * i + 1且Ki <= K2 * i + 2 (Ki >= K2 * i + 1且Ki >= K2 * i + 2 )i = 0,1,2……,则称为小堆(或大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。
堆的性质:
现在我们给出一个数组,逻辑上看做一颗完全二叉树。我们通过从根节点开始的向下调整算法可以把它调整成一个小堆。向下调整算法有一个前提:左右子树必须是一个堆,才能调整。(若想将其调整为小堆,那么根结点的左右子树必须都为小堆;若想将其调整为大堆,那么根结点的左右子树必须都为大堆。)
向下调整法的基本思想(以小堆为例):
从根结点处开始,选出左右孩子中值较小的孩子,让值较小的孩子与其父亲进行比较:
如果孩子比父亲节点值小,则该孩子与父亲节点进行交换,并将原来孩子节点的位置当作父结点继续向下进行调整,直到调整完成;
如果孩子比父亲节点值大,就不需要处理了,说明此时调整完成,该树已经是小堆了。
以小堆为例:
int array[] = {27,15,19,18,28,34,65,49,25,37};
向下调整法的代码如下(以小堆为例):
//交换函数
void Swap(HPDataType* x, HPDataType* y)
{
HPDataType tmp = *x;
*x = *y;
*y = tmp;
}
//堆的向下调整(小堆)
void AdjustDown(HPDataType* a, int n, int parent)
{
int child = (parent * 2) + 1;//求出左孩子节点
while (child < n)
{
if (child + 1 < n && a[child] > a[child + 1])//找出孩子节点中较小的
{
child++;
}
//当父结点大小孩子节点时,交换位置并更新父结点和子节点
if (a[parent] > a[child])
{
Swap(&a[parent], &a[child]);//交换
parent = child;
child = (parent * 2) + 1;
}
//堆已经形成
else
{
break;
}
}
}
使用堆向下调整算法,最坏情况下(一直需要交换节点),假设树的高度为h,那么需要交换的次数为h - 1;假设该树的节点个数为N,那么h = log2(N+1)(按照满二叉树计算),所以可以得出堆的向下调整算法的时间复杂度为:O(log2N)。
使用堆的向下调整算法需要满足其根结点的左右子树均为大堆或是小堆,那么如何将一个树调整为堆呢?
可以从倒数第一个非叶子节点开始进行向下调整,并且从该节点开始向前依次进行向下调整:第一个非叶子节点也就是最后一个叶子节点的父结点,假设节点个数为n,则最后一个叶子节点下标为n-1,由child = (parent * 2) + 1,可得其父结点的下标为(n-1 -1)/2;
代码(以小堆为例):
//建堆
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(php->a, php->size, i);
}
建堆的时间复杂度:
因为堆是完全二叉树,而满二叉树也是完全二叉树,此处为了简化使用满二叉树来证明(时间复杂度本来看的就是近似值,多几个节点不影响最终结果) :
由上图计算建堆过程中总的调整次数:T(n) = 1 * (h - 1) + 2 * (h - 2) + ……+2h-2 * 1;再通过错位相减法最后求得:T(n) = 1- h + 21 + 22 +
…… + 2h-1,等比数列求和得:T(n) = 2h - h - 1,设N是满二叉树的节点个数,由 N = 2h - 1和h = log2(N + 1)可以求出T(n) = N - log2(N+1),则建堆的时间复杂度为O(N)。
总结:
堆的向下调整法的时间复杂度:O(logN)
建堆的时间复杂度:O(N)
当我们在一个堆的末尾插入一个数据后,如果要继续保持这是个堆,就需要对堆进行调整,需要用到堆的向上调整法。
向上调整法的基本思想(以建小堆为例):
将插入结点作为目标节点,和其父结点比较,如果目标结点的值比父结点的值小,则将目标结点与父结点进行交换,并将目标结点的父结点当作新的目标结点继续进行向上调整;如果目标结点的值比父结点的值大,则停止向上调整,说明该树已经是小堆。
代码(以小堆为例):
//交换函数
void Swap(HPDataType* x, HPDataType* y)
{
HPDataType tmp = *x;
*x = *y;
*y = tmp;
}
//堆的向上调整(小堆)
void AdjustUp(HPDataType* a, int child)
{
int parent = (child - 1) / 2;//求父结点位置
while (child > 0)//调整到根结点的位置截止
{
if (a[child] < a[parent])//孩子结点的值小于父结点的值
{
//将父结点与孩子结点交换
Swap(&a[child], &a[parent]);
//继续向上进行调整
child = parent;
parent = (child - 1) / 2;
}
else//已成堆
{
break;
}
}
}
typedef int HPDataType;//堆中存储数据的类型
typedef struct Heap
{
HPDataType* a;//存储数据的数组
int capacity;//当前堆的最大容量
int size;//堆中元素的个数
}HP;
//初始化函数
void HeapInit(HP* php)
{
assert(php);
php->a = NULL;
php->size = php->capacity = 0;
}
数据插入时直接插入到数组的尾部,为了保证继续保持堆结构,所以要使用堆的向上调整法将插入的数据调整到合适的位置。
void HeapPush(HP* php, HPDataType x)
{
assert(php);
//判断是否需要扩容
if(php->size == php->capacity)
{
int newCapacity = php->capacity == 0 ? 4 : php->capacity * 2;
HPDataType* tmp = (HPDataType*)realloc(php->a, sizeof(HPDataType) * newCapacity);
if(tmp == NULL)
{
perror("realloc fail");
exit(-1);
}
php->a = tmp;
php->capacity = newCapacity;
}
//插入数据
php->a[php->size] = x;
php->size++;
//向上调整
AdjustUp(php->a, php->size - 1);
}
堆的删除要删除的是堆顶的元素,首先想到的就是直接删除堆顶元素,但是直接删除堆顶的数据,原堆后面的父子关系就全部打乱了,需要重新建堆,时间复杂度为O(N)。
所以我们可以选择先交换堆顶元素与堆尾元素,删除堆尾元素,最后把堆顶元素向下调整到适合它的位置,此时根结点的左右子树都是小堆(大堆),我们只需要在根结点处进行一次向下调整即可,时间复杂度为O(logN)。
//堆的删除
void HeapPop(HP* php)
{
assert(php);
assert(!HeapEmpty(php));
Swap(&php->a[0], &php->a[php->size - 1]);//交换堆顶和最后一个结点的位置
php->size--;//删除最后一个结点(也就是删除原来堆顶的元素)
AdjustDown(php->a, php->size, 0);//向下调整
}
堆顶数据即数组下标为0的元素,所以直接返回数组下标为0的元素即可。
//获取堆顶的数据
HPDataType HeapTop(HP* php)
{
assert(php);
assert(!HeapEmpty(php));
return php->a[0];//返回堆顶数据
}
获取堆的数据个数,即返回堆结构体中的size变量。
int HeapSize(HP* php)
{
assert(php);
return php->size;
}
堆的判空,即判断堆结构体中的size变量是否为0。
//堆的判空
bool HeapEmpty(HP* php)
{
assert(php);
return php->size == 0;//判断堆中数据是否为0
}
将堆按照物理结构进行打印,即按照数组下标打印。
//按照物理结构进行打印,即按照数组下标打印
void HeapPrint(HP* php)
{
assert(php);
int i = 0;
for (i = 0; i < php->size; i++)
{
printf("%d ", php->a[i]);
}
printf("\n");
}
//销毁堆
void HeapDestroy(HP* php)
{
assert(php);
free(php->a);//释放动态开辟的数组
php->a = NULL;//及时置空
php->size = 0;//元素个数置0
php->capacity = 0;//容量置0
}
测试:
void TestHeap()
{
int array[] = { 27, 15, 19, 18, 28, 34, 65, 49, 25, 37 };
HP hp;
HeapInit(&hp);
for (int i = 0; i < sizeof(array) / sizeof(int); ++i)
{
HeapPush(&hp, array[i]);//插入
}
HeapPrint(&hp);//打印
printf("%d\n", HeapSize(&hp));//有效数据个数
printf("%d\n", HeapTop(&hp));//堆顶元素
printf("%d\n", HeapEmpty(&hp));//判断是否为空
HeapDestroy(&hp);//销毁
}
在进行堆排序之前,需要满足给定数据的数据结构必须是堆,建堆有两种方式,分别为向上调整建堆和向下调整建堆。
前面我们分析过向下调整建堆的时间复杂度为O(N),再次分析我们会发现向上调整建堆的时间复杂度为O(N * logN),所以向下调整建堆更好。
如果我们想利用堆排序做到升序,选择建大堆还是小堆呢?
如果建小堆,最小的数即堆顶,每次都要将堆顶的数据固定,再处理其他的数据时还要重新建堆,这样太麻烦;要么就是将堆顶数据放入新开辟的空间中,然后再找次小的,依次向后,但是这样要开辟新的空间。
如果建大堆,堆顶的数据是最大的,每次将堆顶的数据和最后一个数据交换,这样最大的数就放到了最后,然后只处理前N-1个数据,把堆顶数据向下调整,调整之后堆顶数据就是次大的数据,将其和第N-1个数交换,再去处理前N-2个数据这样依次处理,最终就可以实现升序。
堆排序即利用堆的思想来进行排序,总共分为两个步骤:
1、建堆
2、利用堆删除思想来进行排序
建堆和堆删除中都用到了向下调整,因此掌握了向下调整,就可以完成堆排序:
//交换函数
void Swap(HPDataType* x, HPDataType* y)
{
HPDataType tmp = *x;
*x = *y;
*y = tmp;
}
//向下调整法建大堆
void AdjustDown(HPDataType* a, int n, int parent)
{
int child = (parent * 2) + 1;//求出左孩子节点
while (child < n)
{
if (child + 1 < n && a[child] < a[child + 1])//找出孩子节点中较大的
{
child++;
}
//当父结点小于孩子节点时,交换位置并更新父结点和子节点
if (a[parent] < a[child])
{
Swap(&a[parent], &a[child]);
parent = child;
child = (parent * 2) + 1;
}
else
{
break;
}
}
}
//堆排序--升序---向下调整法建大堆
void HeapSort(HPDataType* a, int n)
{
//向下调整法建大堆
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(a, n, i);
}
//建好大堆开始调整
int end = n - 1;
while (end)
{
//将堆顶数据放到最后
Swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
end--;
}
}
int main()
{
int array[] = { 27, 15, 19, 18, 28, 34, 65, 49, 25, 37 };
HeapSort(array, sizeof(array) / sizeof(array[0]));
for (int i = 0; i < sizeof(array) / sizeof(array[0]); i++)
{
printf("%d ", array[i]);
}//输出15 18 19 25 27 28 34 37 49 65
printf("\n");
}
堆排序的时间复杂度为:O(N * logN)。
TOP-K问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。
比如:高校前10名、专业前5名、最受玩家喜爱的前10款游戏等。
对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能数据都不能一下子全部加载到
内存中)。最佳的方式就是用堆来解决,当我们要从N个数中找最大的前K个数字,可以选择下面这两种方法:
1、建立一个N个数的大堆,取堆顶数据之后Pop,向下调整找到次大的取完之后再Pop,依次循环,直至找到前K个数字。
2、建立一个K个数的小堆,依次遍历数据,如果数据比堆顶的数据大就替换堆顶,然后通过向下调整将k个数堆中最小的数据放到堆顶,遍历完全部数据之后最后小堆中就是最大的K个数据(此方法找到的前K个数不区分大小)。
当N比较小时,方法一是可行的,但是如果N太大比如N = 100亿,100亿个整数就是400亿个字节,232 = 4GB(也就是说40亿个字节约等于4GB),100亿个数据就需要占据40G内存,而一般的电脑的内存总大小都不够40G,所以此时方法一是不可行的。
我们可以使用方法二:当N太大时,把数据都放在磁盘中,只取前K个数建小堆。
为什么要建小堆?
因为如果建的是大堆,前K个数中最大的数据就被放到了堆顶,如果后面的数据中有比堆中除堆顶外的其他数据大,但是没堆顶数据
大的元素则无法进入堆中,如果使用小堆则堆中最小的数据放到堆顶,如果后面有比堆顶数据大的则可以进入堆中。
前K个数的小堆建好之后,依次遍历数据,数据比堆顶的数据大就替换堆顶,再把堆顶向下调整,最后小堆中就是最大的K个数据。
建堆的时候,为了方便,我们就用数组的前K个数建堆。基本思路如下:
1、用数据集合中前K个元素来建堆
2、用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素
分析时间复杂度和空间复杂度:
建堆的时间复杂度为O(N),所以我们建了一个K个数的堆时间复杂度为O(K);
遍历剩下的N - K个数据的时间复杂度为O(N-K),如果剩下的所有数据都要进堆并且向下调整那么时间复杂度为O(( N - K) * logK) ;
所以总体时间复杂度为O(K + (N - K) * logK),所以最终时间复杂度为 O(N * logK) 。
空间复杂度为 O(K) 。
//交换函数
void Swap(HPDataType* x, HPDataType* y)
{
HPDataType tmp = *x;
*x = *y;
*y = tmp;
}
//堆的向下调整(小堆)
void AdjustDown(HPDataType* a, int n, int parent)
{
int child = (parent * 2) + 1;//求出左孩子节点
while (child < n)
{
if (child + 1 < n && a[child] > a[child + 1])//找出孩子节点中较小的
{
child++;
}
//当父结点大小孩子节点时,交换位置并更新父结点和子节点
if (a[parent] > a[child])
{
Swap(&a[parent], &a[child]);
parent = child;
child = (parent * 2) + 1;
}
else
{
break;
}
}
}
int main()
{
// 造数据
int n,k;
printf("请输入n和k:");
scanf("%d%d",&n,&k);
srand(time(0));
FILE* fin = fopen("data.txt", "w");//打开文件
if (fin == NULL)
{
perror("fopen fail");
exit(0);
}
//向文件中写入数据
for (size_t i = 0; i < n; i++)
{
int val = rand() % 10000;
fprintf(fin, "%d\n", val);
}
fclose(fin);
//找topk
FILE* fout = fopen("data.txt", "r");
if (fout == NULL)
{
perror("fopen fail");
exit(0);
}
//int minHeap[5];
int* minHeap = (int*)malloc(sizeof(int) * k);
if (minHeap == NULL)
{
perror("malloc fail");
exit(0);
}
for (int i = 0; i < k; ++i)
{
fscanf(fout, "%d", &minHeap[i]);
}
// 建小堆
for (int i = (k - 1 - 1) / 2; i >= 0; --i)
{
AdjustDown(minHeap, k, i);
}
int val = 0;
while (fscanf(fout,"%d", &val) != EOF)
{
if (val > minHeap[0])
{
minHeap[0] = val;
AdjustDown(minHeap, k, 0);
}
}
for (int i = 0; i < k; ++i)
{
printf("%d ", minHeap[i]);
}
printf("\n");
fclose(fout);
return 0;
}
打印:
练习:
1.下列关键字序列为堆的是:()
A 100,60,70,50,32,65
B 60,70,65,50,32,100
C 65,100,70,32,50,60
D 70,65,100,32,50,60
E 32,50,100,70,65,60
F 50,100,70,65,60,32
解析:堆为完全二叉树,所以我们可以通过将上述用树的形式表示出来,很容易就能够得出答案:A。要注意顺序存储时不能依靠是否是升序或者降序来判断是否为堆。
2.已知小根堆为8,15,10,21,34,16,12,删除关键字 8 之后需重建堆,在此过程中,关键字之间的比较次数是()。
A 1
B 2
C 3
D 4
解析:删除时堆顶数据和堆中最后一个元素交换位置,删除之后12为堆顶,然后再进行向下调整,两个节点之间先比较一次找出较小的值,然后12再和较小值比较,交换;交换之后和左孩子比较:
所以答案选C。
3.一组记录排序码为(5 11 7 2 3 17),则利用堆排序方法建立的初始堆为
A(11 5 7 2 3 17)
B(11 5 7 2 17 3)
C(17 11 7 2 3 5)
D(17 11 7 5 3 2)
E(17 7 11 3 5 2)
F(17 7 11 3 2 5)
堆排序刚开始建堆使用向下调整法从第一个非叶子节点开始:
所以答案选C。
4.最小堆[0,3,2,5,7,4,6,8],在删除堆顶元素0之后,其结果是()
A[3,2,5,7,4,6,8]
B[2,3,5,7,4,6,8]
C[2,3,4,5,7,8,6]
D[2,3,4,5,6,7,8]
解析:删除操作是将堆顶和堆的最后一个元素交换,然后删除堆的最后一个元素,再通过向下调整法调整成为堆:
所以选择C。
二叉树的链式结构中结构体的定义以及结点的创建和链表的创建:
typedef int BTDataType;//结点中存储的元素类型
typedef struct BTNode
{
BTDataType data;//结点中存储的元素类型
struct BTNode* left;//左指针域(指向左孩子)
struct BTNode* right;//右指针域(指向右孩子)
}BTNode;
BTNode* BuyNode(BTDataType x)
{
BTNode* newnode = (BTNode*)malloc(sizeof(BTNode)); //开辟空间
if (newnode == NULL) //开辟失败直接结束程序
{
perror("malloc fail");
exit(-1);
}
newnode->data = x;
newnode->left = newnode->right = NULL; //初始化为空
return newnode;
}
//创建一个前序为123456的链式结构的二叉树
BTNode* CreatBinaryTree()
{
BTNode* node1 = BuyNode(1);
BTNode* node2 = BuyNode(2);
BTNode* node3 = BuyNode(3);
BTNode* node4 = BuyNode(4);
BTNode* node5 = BuyNode(5);
BTNode* node6 = BuyNode(6);
node1->left = node2; //把二叉树结构连接起来
node1->right = node4;
node2->left = node3;
node4->left = node5;
node4->right = node6;
return node1;
}
前序遍历又叫先根遍历。
遍历顺序:根 -> 左子树 -> 右子树
前序递归遍历代码:
//前序遍历
void PrevOrder(BTNode* root)
{
if (root == NULL)
{
return;
}
//根 左子树 右子树
printf("%d ", root->data); //打印当前结点值
PrevOrder(root->left);//遍历左边结点
PrevOrder(root->right);//遍历右边结点
}
中序遍历又叫中根遍历。
遍历顺序:左子树 -> 根 -> 右子树
中序递归遍历代码:
//中序遍历
void InOrder(BTNode* root)
{
if (root == NULL)
{
return;
}
//左子树 根 右子树
InOrder(root->left);//遍历左边结点
printf("%d ", root->data); //打印当前结点
InOrder(root->right);//遍历右边结点
}
后序遍历又叫后根遍历。
遍历顺序:左子树 -> 右子树 -> 根
后序递归遍历代码:
//后序遍历
void PostOrder(BTNode* root)
{
if (root == NULL)
{
return;
}
//左子树 右子树 根
PostOrder(root->left);//遍历左边结点
PostOrder(root->right);//遍历右边结点
printf("%d ", root->data); //打印当前结点值
}
设二叉树的根节点所在层数为第一层,层序遍历就是从二叉树的根节点出发,先访问第一层的根节点,然后从左到右访问第2层上的节点,以此类推,自上而下,自左至右逐层访问树的结点的过程就是层序遍历。
要想用代码实现队列的层序遍历我们需要借助队列:
1、先把根结点入队列,然后开始从队头出数据;
2、出队头的数据,把它的左孩子和右孩子依次从队尾入队列(NULL不入队列);
3、重复进行操作2,直到队列为空为止。
借助队列先进先出的特性,上一层数据出队列的时候将下一层数据带入到队列中。
代码:
//层序遍历
void LevelOrder(BTNode* root)
{
Queue q;
QueueInit(&q);//队列初始化
if (root != NULL)
{
QueuePush(&q, root);//将根结点插入
}
while (!QueueEmpty(&q))//如果队列不为空
{
//读取队头元素
BTNode* front = QueueFront(&q);
//删除队头元素
QueuePop(&q);
//打印队头元素
printf("%d ", front->data);
//如果左孩子不为空,插入左孩子结点
if (front->left)
{
QueuePush(&q, front->left);//将左结点插入
}
if (front->right)
{
QueuePush(&q, front->right);//将右节点插入
}
}
QueueDestroy(&q);//销毁队列
}
测试前中后序遍历和层序遍历:
//队列结构体的声明和定义
struct BinaryTreeNode;
typedef struct BinaryTreeNode* QDataType;
typedef struct QueueNode
{
QDataType val;
struct QueueNode* next;
}QNode;
typedef struct Queue {
QNode* head;
QNode* tail;
int size;
}Queue;
//测试
int main()
{
BTNode* root = CreatBinaryTree();
PrevOrder(root);//输出1 2 3 4 5 6
printf("\n");
InOrder(root);//输出3 2 1 5 4 6
printf("\n");
PostOrder(root);//输出3 2 5 6 4 1
printf("\n");
LevelOrder(root);//输出1 2 4 3 5 6
printf("\n");
}
练习:
1.某完全二叉树按层次输出(同一层从左到右)的序列为 ABCDEFGH 。该完全二叉树的前序序列为( )
A ABDHECFG
B ABCDEFGH
C HDBEAFCG
D HDEBFGCA
解析:完全二叉树,层序遍历为 ABCDEFGH,所以前序遍历为ABDHECFG,故选A。
2.二叉树的先序遍历和中序遍历如下:先序遍历:EFHIGJK;中序遍历:HFIEJKG.则二叉树根结点为()
A E
B F
C G
D H
解析:由先序知道E是根结点,所以选A。再由中序知道HFI在根节点的左边,JKG在根结点的右边:
3.设一课二叉树的中序遍历序列:badce,后序遍历序列:bdeca,则二叉树前序遍历序列为()
A adbce
B decab
C debac
D abcde
由上图可知前序遍历为abcde。故选D。
4.某二叉树的后序遍历序列与中序遍历序列相同,均为 ABCDEF ,则按层次输出(同一层从左到右)的序列为()
A FEDCBA
B CBAFED
C DEFCBA
D ABCDEF
解析:后序和中序相同,由后序知根结点为F,根据排除法可知选A。
假设二叉树如下图所示:
要想知道二叉树的节点个数,我们通常会想到遍历二叉树的同时使用一个变量来记录,假设变量为size,使用前序遍历,每遍历一个结点让size++,可设置程序如下:
int TreeSize(BTNode* root)
{
int size = 0;
if (root == NULL)
{
return 0;
}
size++;
TreeSize(root->left);
TreeSize(root->right);
return size;
}
但是我们需要注意的是size变量是局部变量,是建立在函数栈帧中的,在递归程序中,每建立一个函数栈帧都会创建一个size局部变量,因此这里的size++是对不同函数栈帧中的size进行处理的,并不能达到我们想要的效果。
那如果我们设置一个size静态变量是否可以呢?
int TreeSize(BTNode* root)
{
static int size = 0;
if (root == NULL)
{
return 0;
}
size++;
TreeSize(root->left);
TreeSize(root->right);
return size;
}
size被static修饰之后就不再存储在栈区,而是存储在静态区,静态局部变量(静态局部变量只能在定义函数内使用)是在编译期间就指定的,所以运行期间每次递归都不会再重新创建变量size,所以每次++的时候用的是同一个size。
但是设置静态变量后第一次调用TreeSize函数时,可以正确的计算出结点个数,但是当我们再次调用的时候就不能正确计算出来了,会发现结果越来越大,因为静态变量和全局变量的作用域都是整个程序,只有在第一次进入函数时才会进行初始化,
这是因为静态变量与全局变量的作用域都是整个程序,所以只有在第一次进入函数时才会在定义的同时进行初始化,可以通过将size = 0直接把 size 赋值为0,但是如果这样的话找不到合适的位置将size赋值为0。
我们可以设置一个全局变量size,然后每次在调用TreeSize函数的时候将size变量赋值为0,同时TreeSize函数不用再有返回值。
int size = 0;
void TreeSize(BTNode* root)
{
if (root == NULL)
{
return;
}
size++;
TreeSize(root->left);
TreeSize(root->right);
}
测试:
int main()
{
BTNode* root = CreatBinaryTree();
TreeSize(root);
printf("%d\n", size);//输出6
size = 0;
TreeSize(root);
printf("%d\n", size);//输出6
}
这种办法并不好,每次还要将size置为0,我们可以通过使用分治思想,把大问题分解成小问题,逐层统计:通过计算节点子树的节点数量,并把统计到的节点数量加一(即加上结点本身),返回给该节点的父节点。
int TreeSize(BTNode* root)
{
if (root == NULL)
{
return 0;
}
return TreeSize(root->left) + TreeSize(root->right) + 1;
}
此种方法是计算二叉树结点数量的最好方法。
当结点的左右孩子都为空时,说明这是个叶子结点,通过递归计算子树上的叶子结点。
如果结点为空,就返回0;不为空,就判断它是不是叶子结点,是返回1;不是就返回它的左子树的叶子结点个数+右子树的叶子结点个数。
//计算叶子结点个数
int LeafSize(BTNode* root)
{
if (root == NULL)//如果结点为空直接返回
{
return 0;
}
//判断左右孩子是否为空,如果是说明为叶子结点
if (root->left == NULL && root->right == NULL)
{
return 1;
}
return LeafSize(root->left) + LeafSize(root->right);//递归计算左子树和右子树叶子结点个数
}
默认根结点层次为1。如果为空树,高度为0;如果不是空树,树的高度就是左右子树中高度的较大者+1(+1是包含当前层次的高度)。
//计算树的高度
int TreeHeight(BTNode* root)
{
if (root == NULL)
{
return 0;
}
//左右子树中的较大者+1返回
return TreeHeight(root->left) > TreeHeight(root->right) ?
TreeHeight(root->left) + 1 : TreeHeight(root->right) + 1;
}
但是上面的程序在每次比较完左子树与右子树的高度之后,直接就把已经得到的结果抛弃了,在返回结果时还要重新再同故宫递归重新计算一次高度,造成了非常大的性能浪费,所以需要再定义两个变量来保存已经得到的结果。
int TreeHeight(BTNode* root)
{
if (root == NULL)
{
return 0;
}
//左右子树中的较大者+1返回
int LeftHeight = TreeHeight(root->left);
int RightHeight = TreeHeight(root->right);
return LeftHeight > RightHeight ?
LeftHeight + 1 : RightHeight + 1;
}
可以使用分治思想:如果是空树,就直接返回0;如果二叉树不为空,K == 1时,第一层就是根结点直接返回1;如果K大于1,返回相对于其左子树和右子树的第K-1层的结点个数。
//计算第K层的结点个数
int LevelSize(BTNode* root,int k)
{
if (root == NULL)
{
return 0;
}
if (k == 1)
{
return 1;
}
return LevelSize(root->left, k - 1) + LevelSize(root->right, k - 1);
}
在一棵二叉树中查找值为X的结点,并返回这个结点地址。
如果二叉树是空树,直接返回NULL;
如果不为空,先判断根结点的值是不是我们要找的结点值,如果是直接返回根结点地址,如果不是,那就判断左右子树能不能找到;
如果左右子树都找不到,说明不存在要找的结点,返回NULL。
//二叉树查找值为x的结点
BTNode* TreeFind(BTNode* root, BTDataType x)
{
if (root == NULL)
return NULL;
if (root->data == x)
{
return root;
}
BTNode* ret1 = TreeFind(root->left, x);//去左子树找
if (ret1)
return ret1;
BTNode* ret2 = TreeFind(root->right, x);//去右子树找
if (ret2)
return ret2;
return NULL;
}
题目:二叉树遍历
编一个程序,读入用户输入的一串先序遍历字符串,根据此字符串建立一个二叉树(以指针方式存储)。 例如如下的先序遍历字符串: ABC##DE#G##F### 其中“#”表示的是空格,空格字符代表空树。建立起此二叉树以后,再对二叉树进行中序遍历,输出遍历结果。
输入描述:
输入包括1行字符串,长度不超过100。
输出描述:
可能有多组测试数据,对于每组数据, 输出将输入字符串建立二叉树后中序遍历的序列,每个字符后面都有一个空格。 每个输出结果占一行。
示例:
输入:
abc##de#g##f###
输出:
c b e g d f a
思路:
从头遍历字符串,如果遍历到字符“#”(根据题意表示空树),直接返回NULL;如果不是,就创建一个结点,结点的值就是对应的字符,然后去递归构建它的左子树,接着是右子树,并把构建好的左右子树的根结点链接在它的左右孩子指针上。再通过中序遍历即可。
代码:
#include
#include
typedef char BTDataType;
typedef struct BinaryTreeNode{
BTDataType val;
struct BinaryTreeNode* left;
struct BinaryTreeNode* right;
}BTNode;
BTNode* BTCreate(char* arr,int* pi)
{
//当arr[(*pi)] == '#',说明该结点为空
if(arr[(*pi)] == '#')
{
(*pi)++;
return NULL;
}
BTNode* root = (BTNode*)malloc(sizeof(BTNode));//创建一个结点
if(root == NULL)
{
exit(-1);
}
root->val = arr[(*pi)];
(*pi)++;
root->left = BTCreate(arr,pi);
root->right = BTCreate(arr,pi);
return root;
}
void InOrder(BTNode* root)
{
if(root == NULL)
return ;
InOrder(root->left);
printf("%c ",root->val);
InOrder(root->right);
}
int main()
{
char arr[100];
scanf("%s",arr);//读取字符串
int i = 0;
BTNode* root = BTCreate(arr,&i);
InOrder(root);
return 0;
}
因为申请的内存是在堆上所以在程序结束之前要把这些内存释放,否则就会造成内存泄漏。
最好的方法就是通过后序遍历去销毁这颗二叉树,因为后序遍历是最后才会遍历到根结点,方便通过根结点去找其他的结点;如果我们使用先序或者中序去遍历销毁,在没把左右子树销毁的情况下就将根结点销毁了,不方便再去找左右子树了。
//后序遍历销毁
void TreeDestory(BTNode* root)
{
if (root == NULL)
return ;
TreeDestory(root->left);
TreeDestory(root->right);
free(root);
}
注意我们参数是一级指针所以要注意在出函数之后将root置空。
完全二叉树的概念:完全二叉树前N-1层都是满的,最后一层可以不满,但是必须从左到右是连续的。
这颗题重点就是要理解层序遍历:先让根结点入队列,出队头数据,并把队头数据的孩子结点带入队列;但是我们之前的层序遍历中如果结点的左孩子或右孩子为空,没有入队列(不打印空NULL),但是在本题为了后面的判断,必须把空孩子也入队列。
当队列队头数据为空结点时就结束,然后进行判断:如果队列中此时剩下的结点全是空结点,说明是完全二叉树;如果队列中剩下的结点还有非空结点,则此二叉树不是完全二叉树。
代码实现:
//判断是否是完全二叉树
bool BinaryTreeJudge(BTNode* root)
{
//空树也是完全二叉树
if (root == NULL)
return true;
Queue q;
QueueInit(&q);//队列初始化
if (root)
{
QueuePush(&q, root);//将根结点插入队列
}
while (!QueueEmpty(&q))//队列不为空时
{
BTNode* front = QueueFront(&q);
QueuePop(&q);
if (front == NULL)//头节点为空直接跳出循环
{
break;
}
else
{
//将左右结点加入队列
QueuePush(&q, front->left);
QueuePush(&q, front->right);
}
}
//循环判断队列中是否有非空结点
while (!QueueEmpty(&q))
{
BTNode* front = QueueFront(&q);
QueuePop(&q);
if (front != NULL)
{
QueueDestroy(&q);
return false;
}
}
QueueDestroy(&q);
return true;
}
//BTree.h
#pragma once
#include
#include
#include
#include
#include
#include "Queue.h"
typedef int BTDataType;
typedef struct BinaryTreeNode {
BTDataType data;
struct BinaryTreeNode* left;
struct BinaryTreeNode* right;
}BTNode;
BTNode* BuyNode(BTDataType x)
{
BTNode* newnode = (BTNode*)malloc(sizeof(BTNode)); //开辟空间
if (newnode == NULL) //开辟失败直接结束程序
{
perror("malloc fail");
exit(-1);
}
newnode->data = x;
newnode->left = newnode->right = NULL; //初始化为空
return newnode;
}
BTNode* CreatBinaryTree()
{
BTNode* node1 = BuyNode(1);
BTNode* node2 = BuyNode(2);
BTNode* node3 = BuyNode(3);
BTNode* node4 = BuyNode(4);
BTNode* node5 = BuyNode(5);
BTNode* node6 = BuyNode(6);
node1->left = node2; //把二叉树结构连接起来
node1->right = node4;
node2->left = node3;
node4->left = node5;
node4->right = node6;
return node1;
}
//前序遍历
void PrevOrder(BTNode* root)
{
if (root == NULL)
{
return;
}
//根 左子树 右子树
printf("%d ", root->data); //打印当前结点值
PrevOrder(root->left);//遍历左边结点
PrevOrder(root->right);//遍历右边结点
}
//中序遍历
void InOrder(BTNode* root)
{
if (root == NULL)
{
return;
}
//左子树 根 右子树
InOrder(root->left);//遍历左边结点
printf("%d ", root->data); //打印当前结点
InOrder(root->right);//遍历右边结点
}
//后序遍历
void PostOrder(BTNode* root)
{
if (root == NULL)
{
return;
}
//左子树 右子树 根
PostOrder(root->left);//遍历左边结点
PostOrder(root->right);//遍历右边结点
printf("%d ", root->data); //打印当前结点值
}
//层序遍历
void LevelOrder(BTNode* root)
{
Queue q;
QueueInit(&q);//队列初始化
if (root != NULL)
{
QueuePush(&q, root);//将根结点插入
}
while (!QueueEmpty(&q))//如果队列不为空
{
//读取队头元素
BTNode* front = QueueFront(&q);
//删除队头元素
QueuePop(&q);
//打印队头元素
printf("%d ", front->data);
//如果左孩子不为空,插入左孩子结点
if (front->left)
{
QueuePush(&q, front->left);//将左结点插入
}
if (front->right)
{
QueuePush(&q, front->right);//将右节点插入
}
}
QueueDestroy(&q);//销毁队列
}
//int size = 0;
//void TreeSize(BTNode* root)
//{
// if (root == NULL)
// {
// return;
// }
// size++;
// TreeSize(root->left);
// TreeSize(root->right);
//}
//计算结点个数
int TreeSize(BTNode* root)
{
if (root == NULL)
{
return 0;
}
return TreeSize(root->left) + TreeSize(root->right) + 1;
}
//计算叶子结点个数
int LeafSize(BTNode* root)
{
if (root == NULL)//如果结点为空直接返回
{
return 0;
}
//判断左右孩子是否为空,如果是说明为叶子结点
if (root->left == NULL && root->right == NULL)
{
return 1;
}
return LeafSize(root->left) + LeafSize(root->right);//递归计算左子树和右子树叶子结点个数
}
//计算树的高度
int TreeHeight(BTNode* root)
{
if (root == NULL)
{
return 0;
}
//左右子树中的较大者+1返回
//return TreeHeight(root->left) > TreeHeight(root->right) ?
// TreeHeight(root->left) + 1 : TreeHeight(root->right) + 1;
int LeftHeight = TreeHeight(root->left);
int RightHeight = TreeHeight(root->right);
return LeftHeight > RightHeight ?
LeftHeight + 1 : RightHeight + 1;
}
//计算第K层的结点个数
int LevelSize(BTNode* root,int k)
{
if (root == NULL)
{
return 0;
}
if (k == 1)
{
return 1;
}
return LevelSize(root->left, k - 1) + LevelSize(root->right, k - 1);
}
//二叉树查找值为x的结点
BTNode* TreeFind(BTNode* root, BTDataType x)
{
if (root == NULL)
return NULL;
if (root->data == x)
{
return root;
}
BTNode* ret1 = TreeFind(root->left, x);
if (ret1)
return ret1;
BTNode* ret2 = TreeFind(root->right, x);
if (ret2)
return ret2;
return NULL;
}
//后序遍历销毁
void TreeDestory(BTNode* root)
{
if (root)
{
TreeDestory(root->left);
TreeDestory(root->right);
free(root);
}
}
//判断是否是完全二叉树
bool BinaryTreeJudge(BTNode* root)
{
//空树也是完全二叉树
if (root == NULL)
return true;
Queue q;
QueueInit(&q);//队列初始化
if (root)
{
QueuePush(&q, root);//将根结点插入队列
}
while (!QueueEmpty(&q))//队列不为空时
{
BTNode* front = QueueFront(&q);
QueuePop(&q);
if (front == NULL)//头节点为空直接跳出循环
{
break;
}
else
{
//将左右结点加入队列
QueuePush(&q, front->left);
QueuePush(&q, front->right);
}
}
//循环判断队列中是否有非空结点
while (!QueueEmpty(&q))
{
BTNode* front = QueueFront(&q);
QueuePop(&q);
if (front != NULL)
{
QueueDestroy(&q);
return false;
}
}
QueueDestroy(&q);
return true;
}
#pragma once
#define _CRT_SECURE_NO_WARNINGS 1
#pragma once
#include
#include
#include
#include
#include "BTree.h"
struct BinaryTreeNode;
typedef struct BinaryTreeNode* QDataType;
typedef struct QueueNode
{
QDataType val;
struct QueueNode* next;
}QNode;
typedef struct Queue {
QNode* head;
QNode* tail;
int size;
}Queue;
void QueueInit(Queue* pq)//初始化
{
assert(pq);
pq->head = pq->tail = NULL;
pq->size = 0;
}
void QueueDestroy(Queue* pq)//销毁
{
assert(pq);
QNode* cur = pq->head;
while (cur)
{
QNode* del = cur->next;
free(cur);
cur = del;
}
pq->head = pq->tail = NULL;
}
void QueuePrint(Queue* pq)//打印
{
assert(pq);
QNode* cur = pq->head;
while (cur)
{
printf("%d ", cur->val);
cur = cur->next;
}
printf("\n");
}
bool QueueEmpty(Queue* pq)//判断是否为空
{
assert(pq);
return pq->head == NULL && pq->tail == NULL;
}
void QueuePush(Queue* pq, QDataType x)//入队
{
assert(pq);
QNode* newnode = (QNode*)malloc(sizeof(QNode));
if (newnode == NULL)
{
perror("malloc");
exit(-1);
}
newnode->val = x;
newnode->next = NULL;
if (pq->head == NULL)
{
pq->head = pq->tail = newnode;
}
else
{
pq->tail->next = newnode;
pq->tail = newnode;
}
pq->size++;
}
void QueuePop(Queue* pq)//出队列
{
assert(pq);
assert(!QueueEmpty(pq));
if (pq->head->next == NULL)
{
free(pq->head);
pq->head = pq->tail = NULL;
}
else
{
QNode* cur = pq->head;
pq->head = cur->next;
free(cur);
}
pq->size--;
}
QDataType QueueFront(Queue* pq)//返回队头的值
{
assert(pq);
assert(!QueueEmpty(pq));
return pq->head->val;
}
QDataType QueueBack(Queue* pq)//返回队尾的值
{
assert(pq);
assert(!QueueEmpty(pq));
return pq->tail->val;
}
int QueueSize(Queue* pq)//返回队列中有效值个数
{
assert(pq);
//int size = 0;
//QNode* cur = pq->head;
//while (cur)
//{
// size++;
// cur = cur->next;
//}
//return size;
return pq->size;
}
#include "Tree.h"
#include "BTree.h"
int main()
{
BTNode* root = CreatBinaryTree();
PrevOrder(root);//输出1 2 3 4 5 6
printf("\n");
InOrder(root);//输出3 2 1 5 4 6
printf("\n");
PostOrder(root);//输出3 2 5 6 4 1
printf("\n");
LevelOrder(root);//输出1 2 4 3 5 6
printf("\n");
printf("%d\n", TreeSize(root));//输出6
printf("%d\n", TreeSize(root));//输出6
printf("%d\n",LeafSize(root));//输出3
printf("%d\n", TreeHeight(root));//输出3
printf("%d\n", LevelSize(root,3));//输出3
printf("%p\n",TreeFind(root, 4));
printf("%d\n", TreeFind(root, 4)->data);
printf("%d", BinaryTreeJudge(root));
}
路径:在一棵树中,从一个结点到另一个结点之间的通路,称为路径。
路径长度:某一路径所经过的“边"的数量,称为该路径的路径长度。
结点的带权路径长度:若将树中结点赋给一个带有某种含义的数值,则该数值称为该结点的权。从根结点到该结点之间的路径长度与该结点的权的乘积,称为该结点的带权路径长度。
树的带权路径长度(WPL):树中从根到所有叶子结点的带权路径长度之和,记为WPL。
从根结点a到叶子结点d的路径为:a -> b -> d;
该路径的路径长度为2;
叶子结点d的带权路径长度为:3 * 2 = 6;
该树的带权路径长度WPL = 2 * 3 + 2 * 4 + 2 * 5 + 2 * 6 = 36;
在含有n个带权叶节点的二叉树中,其中带权路径长度(WPL)最小的二叉树称为哈夫曼树,也称最优二叉树。
例如,下图中的3棵二叉树都有四个叶子结点d、e、f、g,分别带权3、4、5、6,它们的带权路径长度分别为:
(a)WPL = 2 * (3 + 4 + 5 + 6 ) = 36;
(b)WPL = 2 * 3 + 3 * (4 + 5) +1* 6 = 39;
(c)WPL = 1 * 3 + 2 * 4 + 3 * (5 + 6) = 44;
其中图(a)中树的WPL最小,可以验证,它恰好为哈夫曼树。
根据树的带权路径长度的计算规则,可以知道:**树的带权路径长度与其叶子结点的分布有关。**即便是两棵结构相同的二叉树,也会因为其叶子结点的分布不同,而导致两棵二叉树的带权路径长度不同。
如何才能使一棵二叉树的带权路径长度达到最小呢?
根据树的带权路径长度的计算规则,我们应该尽可能地让权值大的叶子结点靠近根结点,让权值小的叶子结点远离根结点,便能使二叉树的带权路径长度达到最小。
给定的n个权值分别为w1,w2,……,wn的结点,构造哈夫曼树的算法描述如下:
1)将这n个结点分别作为n棵仅含一个结点的二叉树,构成森林F。
2)构造一个新结点,从F中选取两颗根结点权值最小的树作为新结点的左、右子树,并且将新结点的权值置为左、右子树上根结点的权值之和。
3)从F中删除刚才选出的两棵树,同时将新得到的树加入F中。
4)重复步骤2)和3),直至F中只剩下一颗树为止。
从上述构造过程中可以看出哈夫曼树具有如下特点:
1)每个初始结点最终都成为叶节点,且权值越小的结点到根结点的路径长度越大。
2)构造过程中共新建了n-1个结点(双分支结点),因此哈夫曼树的结点总数为2n - 1。
3)每次构造都选择2棵树作为新节点的孩子,因此哈夫曼树中不存在度为1的结点。
注意:由于构造时并未规定顺序,所以左右孩子结点的顺序是任意的,所以构造出的哈夫曼树并不唯一,但各哈夫曼树的带权路径长度WPL相同且为最优。此外,如有若干权值相同的结点,则构造出的哈夫曼树更可能不同,但WPL必然相同且是最优的。
在数据通信中,若对每个字符用相等长度的二进制位表示,称这种编码方式为固定长度编码。若允许对不同字符用不等长的二进制位表示,则这种编码方式称为可变长度编码。可变长度编码比固定长度编码要好得多,其特点是对频率高的字符赋以短编码,而对频率较低的字符赋以较长一些的编码,从而可以使字符的平均编码长度减短,起到压缩数据的效果。哈夫曼编码是一种被广泛应用而且非常有效的数据压缩编码。
若没有一个编码是另一个编码的前缀,则称这样的编码为前缀编码。举例:设计字符 A,B和C对应的编码0,101和100是前缀编码。对前缀编码的解码很简单,因为没有一个编码是其他编码的前缀。所以识别出第一个编码,将它翻译为原码,再对余下的编码文件重复同样的解码操作。例如,码串 00101100可被唯一地翻译为0,0,101和100。另举反例:如果再将字符D的编码设计为00,此时0是00的前缀,那么这样的码串的前两位就无法唯一翻译。
由哈夫曼树得到哈夫曼编码是很自然的过程。首先,将每个出现的字符当作一个独立的结点,其权值为它出现的频度(或次数),构造出对应的哈夫曼树。显然,所有字符结点都出现在叶结点中。我们可将字符的编码解释为从根至该字符的路径上边标记的序列,其中边标记为0表示“转向左孩子”,标记为1表示“转向右孩子”。
下图所示为一个由哈夫曼树构造哈夫曼编码的示例,矩形方块表示字符及其出现的次数:
这棵哈夫曼树的WPL为:WPL = 1 * 45 + 3 * (13+12+16) + 4 * (5+9) = 224
此处的WPL可视为最终编码得到二进制编码的长度,共224位。若采用3位固定长度编码,则得到的二进制编码长度为300位,因此哈夫曼编码共压缩了25%的数据。利用哈夫曼树可以设计出总长度最短的二进制前缀编码。
注意:0和1究竟是表示左子树还是右子树没有明确规定。左、右孩子结点的顺序是任意的,所以构造出的哈夫曼树并不唯一,但各哈夫曼树的带权路径长度 WPL 相同且为最优。此外,如有若干权值相同的结点,则构造出的哈夫曼树更可能不同,但 WPL 必然相同且是最优的。