内容很多,建议收藏慢慢看
第五章 树与二叉树
5.1 树与二叉树的定义
首先,先看一下数据的逻辑结构:
我们的树形结构:
- 结点之间有分支
- 具有层次关系
例子:
5.1.1 树的定义
树(Tree)是n(n >= 0)个结点的有限集.
- 若n = 0; 称为空树
- 若n > 0; 则他满足如下两个条件:
- 有且仅有一个特定的称为根(Root)的结点
- 其余的结点可分为m(m >= 0)个互不相交的有限集 T1,T2,T3…,其中,每一个集合本身又是一棵树,并称为根的子树(Sub Tree)
树的其他表示方式
5.1.2 树的基本术语
树也可分为
- 有序树: 树中结点的各子树从左到右有次序(最左边为第一个孩子)
- 无序树: 树中的结点的各子树无次序
森林: 是m(m >= 0) 棵互不相交的树的集合
- 把根节点删除的树就变成了森林
- 一棵树可以看成是一个特殊的森林,给森林各子树加上一个双亲结点,森林就变成了树
-
树结构和线性结构的比较
5.1.3 二叉树的定义
为什么我们要研究二叉树?
- 因为二叉树结构性最简单,规律性最强
- 可以证明,所有的树都可以转为唯一对应的二叉树,不失一般性
- 二叉树在计算机中实现比较理想
二叉树是n(n >= 0)个结点的有限集,它或者是空集(n == 0), 或者由一个根结点及两棵互不相交的分别称作这个根的左子树和右子树的二叉树组成
特点
- 每个结点最多有两个孩子
- 子树有左右之分,其次序不能颠倒
- 二叉树可以是空集合,根可以有空的左子树或者空的右子树
注意: 二叉树不是树的特殊情况,他们是两个概念
- 原因就是当树只有一个孩子的时候,不需要知道他是左还是右
- 二叉树的结点的位置都是固定的,可以说是空,但不能说没有
区别举例
5.2 案例引入
案例5.2用到的是LDR遍历
5.3 树与二叉树的抽象数据类型定义
二叉树的抽象数据类型定义
几个常用的方法:
5.4 二叉树的性质和存储结构
5.4.1 二叉树的性质
性质1: 二叉树的第i层上至多有 2 的(i -1)次方 个结点
- 相对的,第i层上至少有1个结点
性质2: 深度为k二叉树的上至多有 2的k次方 -1个结点
- 相对的, 深度为k二叉树的上至少有 k 个结点
性质3: 对任何一棵二叉树T,如果他的叶子数是n0, 度为2的结点数为n2,那么n0 = n2 + 1
性质4(完全二叉树的性质)
性质5: (完全二叉树的性质)
5.4.2 两种特殊形式的二叉树
- 满二叉树
- 完全二叉树
满二叉树
特点
- 每一层上的结点数都是最大结点数
- 叶子结点全部在最底层
- 编号方式如图所示
完全二叉树
特点
- 有叶子只有可能分布在最大的两层上面
- 若是右子树的最大层数为 i ,那么左子树最大层数是 i 或者 i+1 (i+1 的情况也只不过是下一层多出来一个结点)
注意: 满二叉树一定是完全二叉树, 但是完全二叉树不一定是满二叉树
5.4.3 二叉树的顺序存储结构
先看看二叉树的存储结构
我们现在要介绍的就是二叉树的顺序结构
二叉树的顺序存储
- 按满二叉树的结点层次依次编号,依次存放二叉树中的数据元素
代码表示
#define MAXTSIZE 100
Typedef TElemType SqBiTree[MAXSTSIZE];
SqBiTree bt;
二叉树顺序存储结构的特点
- 结点间的关系蕴含在它的物理上的存储位置上
- 浪费空间,适用于满二叉树和完全二叉树
5.4.3 二叉树的链式存储结构
代码表示:
typedef struct BiNode{
TElemType data;
struct BiNode *lchild, rchild;
}BiNode, *BiTree
特殊的规律
三叉链表
适用于向上查找子节点的双亲结点
5.5 遍历二叉树和线索二叉树
5.5.1 遍历二叉树
- 遍历的定义: 顺着某一条搜索路径巡防二叉树中的结点,使得每个结点均被访问依次,而且仅被访问依次,而且仅被访问一次(又称周游)
- "访问"的含义很广,可以是对结点作的各种处理
- 遍历的目的: 得到树中的所有结点的一个线性排列
- 遍历用途: 他是树结构插入,删除,修改,查找和排序运算的前提,是二叉树一切运算的基础和核心
遍历的方法
我们只使用前三种遍历方法
先序遍历的操作定义
中序遍历二叉树的操作定义
后序遍历二叉树的操作定义
例题及解答
5.5.2 根据遍历序列确定二叉树
- 我们可以通过中序+后序或者先序+中序推导出唯一的二叉树
- 但是先序 + 后序 不行
不多bb,直接来个先序+中序例题
后面以此类推
再来一个中序+ 后序的序列
5.5.3 二叉树遍历算法实现(递归)
原理上面已经说了,算法懂得都懂,这个很简单了
二叉树先序遍历算法
Status PreOrderTraverse(BiTree T){
if(T == NULL) return OK;
else{
visit(T);//访问根节点
PreOrderTraverse(T -> lchild);
PreOrderTraverse(T -> rchild);
}
return OK;
}
二叉树中序遍历算法
Status InOrderTraverse(BiTree T){
if(T == NULL) return OK;
else{
PreOrderTraverse(T -> lchild);
visit(T);//访问根节点
PreOrderTraverse(T -> rchild);
}
return OK;
}
二叉树后序遍历算法
Status InOrderTraverse(BiTree T){
if(T == NULL) return OK;
else{
PreOrderTraverse(T -> lchild);
PreOrderTraverse(T -> rchild);
visit(T);//访问根节点
}
return OK;
}
该算法本质探究
每个结点都是遵从这种规律
5.5.4 中序遍历算法(非递归)
懂得都懂
基本思想
- 建立一个栈
- 根节点进栈 , 建立左子树
- 根节点出栈 , 输出根节点,遍历右子树
算法实现
Status InOrderTraverse(BiTree T){
BiTree p,q; InitStack(S); p = T;
while(p || !StackEmpty(S)){
if(p){
Push(S,p); p = p -> lchild;
}else{
Pop(S,q);
printf("%c",p -> data)
p = q -> rchild;
}
}
return OK;
}
5.5.5 二叉树的层次遍历(队列)
遍历的顺序如图所示
算法设计思路
- 将根节点进队
- 队不空的时候循环: 从队列中出列一个结点p,访问它
- 若他有左孩子结点,那么将左孩子结点进队
- 若他有右孩子结点,那么将有孩子结点进队
队列的定义
typedef struct{
BTNode data[MaxSize];
int front, rear;
}Squeue;
算法实现:
Status LevelOrder(BTNode *T)//这个参数其实就是数的根节点
{
BTNode *q; InitQueue(S); enQueue(S,T);//进队
while(!QueueEmpty(S)){
deQueue(qu,q);
visit(q);//访问并输出q结点的数据
if(q -> lchild != NULL){
enQueue(S,q -> lchild);
}
if(q -> rchild != NULL){
enQueue(S,q -> rchild);
}
}
return OK;
}
5.5.6 二叉树遍历算法的运用
二叉树的建立
按照先序序列建立二叉树的二叉链表
- 从键盘输入一个二叉树的结点信息,建立二叉树的存储结构
- 在建立二叉树的过程中按照二叉树先序方式建立
建立的过程中我们会发现一些问题: 如果只输入结点信息的话,仅仅只有一个先序序列的话无法确定一个确定的二叉树
已知先序的序列是: ABCDEGF
但是却有多种不同的结果
所以我们将加入"#"符号来代表根节点下面的空结点,这样一来就有了一个确定的二叉树
算法实现(这个是伪代码,输入那一部分是不对的,思想学到就好)
Status CreateBiTree(BiTree &T){
scanf(&ch); //就是c++里面的 cin>> ch
if(ch == "#") T = NULL;
else{
if(!(T = (BiTNode*)malloc(sizeof(BiTNode))))
exit(OVERFLOW);//T = new BiTNode;
//下面是生成结点
T ->data = ch;
CreateBiTree(T -> lchild);
CreateBiTree(T -> rchild);
}
return OK
}
二叉树的复制
算法思路(采用递归先序遍历)
- 如果是空树,那么直接结束
- 否则,申请新的节点空间,复制根结点
- 递归复制左子树
- 递归复制右子树
int Copy(BiTree T,BiTree &NewT){
if(T == NULL) {
NewT = NULL;
return 0;//空数就返回0
}else{
NewT = new BiTNode;
NewT -> data = T -> data;
Copy(T -> lchild, NewT -> rchild);
Copy(T -> rchild, NewT -> rchild);
}
}
二叉树的深度
算法步骤
- 如果是空树,那么深度是0
- 否则,递归计算左子树的深度记为m,递归计算右子树的深度记为n,二叉树的深度则是m与n的较大者+1
算法实现
int Depth(BiTree T){
int m;
int n;
int max;
if(T == NULL) {
return 0;//空树就返回0
}else{
m =Depth(T -> lchild);
n =Depth(T -> rchild);
max = (m >n)? m:n;
return (max +1);
}
}
计算结点的总个数
算法步骤
- 如果是空数,那么节点个数为0
- 否则,节点个数为左子树的节点个数+ 右子树的节点个数 +1
算法实现:
int NodeCount(BiTree T){
if(T ==NULL) return 0;
else return NodeCount(T -> lchild) + NodeCOunt(T -> rchild) +1;
}
计算二叉树的叶子结点数
算法步骤
- 如果是空树,则叶子节点的个数是0
- 否则,每当遇到叶子节点的时候,就return1 ,总叶子节点的个数就是左子树+右子树的总叶子结点个数
算法实现
int LeafCount(BiTree T){
if(T == NULL) return 0;
if(T -> lchild == NULL && T -> rchild == NULL) return 1;
else return LeafCount(T -> lchild) + LeafCount(T -> rchild);
}
5.5.7 线索二叉树(Threaded Binary Tree)
为什么要研究线索二叉树?
当用二叉链表作为二叉树的存储结构时,可以很方便地找到某个结点地左右孩子; 但一般情况下,无法直接找到该结点在某种遍历序列中地前驱和后继结点.
如何寻找特定遍历序列中二叉树结点地前驱和后继??
解决方法
- 通过遍历寻找-- 浪费时间
- 再增设前驱,后继指针域------增加了存储负担
- 利用二叉链表中的空指针域
利用二叉链表中的空指针域
如果某个结点的左孩子为空,那么则将左孩子指针域改为指向其前驱;
如果某个结点的右孩子为空,那么将空的右孩子指针域改为指向其后继;
这种改变指向的指针称为"线索"
加上了线索的二叉树称为线索二叉树
对二叉树按某种遍历次序使其变为线索二叉树的过程叫线索化
线索化举例
引出的问题: 为了区分lchild和rchild指针域到底指向的是孩子还是指向的是线索,我们对二叉链表中的每个结点增设两个标志域Itag和rtag,并约定:
-
Itag = 0; lchild指向该结点的左孩子
-
Itag = 1; lchild 指向该结点的前驱
-
rtag = 0; rchild指向该结点的右孩子
-
rtag = 1; rchild 指向该结点的后继
结构如下图
代码如下
typedef struct BiThrNode{
int data;
int ltag,rtag;
struct BiThrNode *lchild,rchild;
}BiThrNode, *BiThrTree;
增设一个头结点
尽管这样的情况,最后一个结点的右孩子的指针域或者第一个结点的左孩子的指针域有可能是空的,不太好,因此,我们新增一个头结点,同时让遍历顺序中第一个结点或者最后一个结点的指针域指向头结点
5.6 树和森林
森林: m(m >= 0)棵互不相交的树的集合
5.6.1 树的存储结构
1 双亲表示法
特点: 找双亲容易,但是找孩子难
结构数组代码
typedef struct PTNode{
TElemType data; //数据域
int partent; //双亲域
}PTNode;
树结构代码
#define MAX_TREE_SIZE 100
typedef struct{
PTNode nodes[MAX_TREE_SIZE];
int r,n; //根节点的位置和结点个数
}PTree;
2 孩子链表
把每个结点的孩子结点排列起来,看成是一个线性表,用单链表存储.
则n个结点有n个孩子链表(叶子的孩子链表为空表).而n个头指针又组成一个线性表,用顺序表(含n个元素的结构数组)存储(这个叫双亲结点结构).
如图所示
孩子结点结构
typedef struct CTNode{
int child;
struct CTNode *next;
}*ChildPtr;
双亲结点结构
typedef struct{
TElemType data;
ChildPtr firstchild;//孩子链表头指针
}CTBox;
树结构
typedef struct{
CTBox nodes[MAX_TREE_SIZE];
int n r;//结点个数和根节点的位置
}CTree;
特点: 找孩子容易,找双亲难,同时这里为了多存储信息又牺牲了空间
3 孩子兄弟表示法(二叉树表示法,二叉链表表示法)
实现: 用二叉链表作为树的存储结构,链表中的每个节点的两个指针域分别指向其第一个孩子结点和下一个兄弟结点
结构实现
typedef struct CSNode{
ElemType data;
struct CSNode *firstchild, *nextsibling;
}CSNode, *CSTree;
孩子兄弟表示法如图所示
5.6.2 树与二叉树的转换
- 关键思想就是用孩子兄弟表示法作为存储结构,将树粗存储起来,然后我们将这个二叉链表转化为二叉树,这样,就达成了树和二叉树之间的转换
注意: 这里转化为的二叉树不是寻常的二叉树,而是根据孩子兄弟表示法转化过来的,二叉树的左孩子代表树的第一个孩子,右孩子代表树的第一个兄弟
给定一棵树,可以找到唯一对应的一棵二叉树与之对应
如图所示
当然我们可以快捷的转化为二叉树
- 加线: 在兄弟之间加上一条线
- 抹线: 除了第一个孩子,双亲将其余孩子之间的线都抹掉
- 旋转: **以树的根节点作为轴心.**将整棵树顺时针旋转45°
口诀: 兄弟相连留长子
举例(从左往右,从上到下看):
将二叉树转化为树的话,就是倒过来步骤执行就完事了
5.6.3 森林与二叉树的转换
森林转化为二叉树(二叉树与多棵树之间的关系)
- 将各棵树分别转换成二叉树
- 每棵树的根节点用线相连
- 最后以第一棵树根节点作为二叉树的根,再以根节点为轴心,顺时针旋转45°
举例:
二叉树转换成森林就是反过来,你懂的
5.6.4 树和森林的遍历
1 树的遍历(三种方式)
先根(次序)遍历
- 若树不空,则先访问根结点,然后依次先根遍历各个子树
后根(次序)遍历
- 若树不空,则先依次后根遍历各棵子树,然后访问根节点
层次遍历
- 若树不空,那么从上到下从左到右访问树的每一个结点
2 森林的遍历
将森林看作三部分
- 森林中的第一棵树的根结点
- 森林中第一棵树的子树森林
- 森林中其他树构成的森林
先序遍历(递归实现)
- 访问森林中第一棵树的根结点
- 先序遍历森林中第一颗树的子树森林
- 先序遍历森林中其他树构成的森林
中序遍历
- 中序遍历森林中第一颗树的子树森林
- 访问森林中第一棵树的根结点
- 中序遍历森林中其他树构成的森林
5.7 哈夫曼树及其应用
5.7.1 哈夫曼树的基本概念
- 路径: 从树中的一个结点到另一个结点之间的分支构成这两个结点间的路径。
- 结点的路径长度: 两结点间路径上的分支数
举例:
- 树的路径长度: 从树根到每一个结点的路径长度之和。记作: TL.
举例:
注意: 长度最短的二叉树不一定是完全二叉树,有可能是其他的
- 权: 将树中结点赋给一个有着某种意义的数值,则这个数值称为该结点的权.
- 结点的带权路径长度: 从根到结点的路径长度与该节点的权的乘积
- 树的带权路径长度(WPL): 树中所有叶子节点的带权路径长度之和
举例:
哈夫曼树
最优树: 树的带权路径长度最短的树
注意: 这里的最短是和树的度相同的树之间比较的出的结果,比如: 最优二叉树,最优三叉树等等
最优二叉树: 树的带权路径长度最短的二叉树
因为构造这种树的算法是由哈夫曼教授于1952年提出的,所以被称为哈夫曼树,相应的算法称为哈夫曼算法
注意: 我们应该知悉,满二叉树不一定是哈夫曼树,具有相同的树的路径长度的哈夫曼树不唯一
5.7.2 哈夫曼树的构造算法
算法的基础:贪心算法: 构造哈夫曼树时首先选择权值小的叶子结点
算法步骤
算法口诀:
- 构造森林全是根
- 选用两小造新树
- 删除两小添新人
- 重复2,3 剩单根
举例:
注意: 哈夫曼树的结点的度为0或2, 没有度为1的结点
包含n个叶子结点的哈夫曼树中共有(2n -1)个结点 (因为构造产生n -1 个新的没有权值的结点)
算法实现
采用顺序存储结构 ------ 一维结构数组
结点类型定义:
typedef struct{
int weight;
int parent,lch,rch;
}HTNode,*HuffmanTree
数组如图所示
注意: 哈夫曼树中共有(2n -1)个结点,我们不使用0下标,数组大小定为2n
实现步骤:
- 初始化HT[1,2,3,4,5,6,7,…,2n-1]的所有值置为0
- 初始化n个叶子结点: 置HT[1…n]的weight值;
- 进行下面的n-1 次合并,一次产生n-1个结点,i = n+1, n+2,…2n-1;
- 在HT[1…i-1]中选两个没有被删除(parent ==0)且weight最小的结点HT[s1]和HT[s2],s1,s2为两个最小结点的下标
- 修改HT[s1]和HT[s2]的parent的值,HT[s1].parent =i;HT[s2].parent =i;
- 修改新产生的HT[i]:
- HT[i].weight = HT[s1].parent + HT[s2].parent
- HT[i].lch = s1; HT[i].rch = s2;
void CreatHuffmanTree ( HuffmanTree HT, int n){
if(n <= 1) return;
m = 2*n -1;
HT = new HTNode[m + 1];//创建一个大小为2n的结构数组
//下面将所有的元素的所有值先置为0
for(int i =1; i<=m; i++){
HT[i].lch = 0;
HT[i].rch = 0;
HT[i].partent = 0;
}
//下面是为叶子节点输入权重值
for(int i =1; i<=n; i++){
cin>> HT[i].weight;
}
//初始化结束,下面开始构造哈夫曼树
for(int i = n+1; i<=m; i++){ //进行n-1 次合成的操作
Select(HT,i-1,s1,s2); //这个函数就是在i-1 中选择两个weight最小且双亲域为0的两个结点,返回他们的下标
//下面的都是套路
HT[s1].parent =i;
HT[s2].parent =i;
HT[i].weight = HT[s1].parent + HT[s2].parent;
HT[i].lch = s1; HT[i].rch = s2;
}
}
5.7.3 哈夫曼编码
问题引入:
因此,哈夫曼编码就是用来解决上面两个问题的
解决方法:
举例:
两个问题
-
为什么哈夫曼编码能够保证是前缀编码
因为所有的带有权值的结点都是叶子节点,而叶子节点不可能是另一个叶子结点的双亲,所以能保证是前缀编码,他们的编码不会重复
-
为什么哈夫曼编码能够保证字符编码总长最短?
因为哈夫曼树的带权路径长度最短,故编码的总长最短
哈夫曼编码的算法实现
右上的表存储编码**(就是我们要求得哈夫曼编码表**),HC[i]是一个字符型数组
右下的表是一个临时的存储编码的数组,最后那个是字符串写入结束的标记
代码实现:
void CreadHuffmanCode(HuffmanTree HT,HuffmanCode &HC, int n){
HC = new char*[n+1]; //分配n个字符编码的头指针矢量
cs = new char[n]; //分配临时存放编码的动态数组空间
cd[n-1] = '\0'; //编码结束符
for(int i =1; i<=n; ++i){
start = n-1; c = i; f = HT[i].parent;
while(f!=0){ //从叶子结点开始向上回溯,知道根节点
start--; //回溯一次start指向前一个位置
if(HT[f].lchild == c) cd[start] = '0'; //如果c是双亲的左孩子,那么在cd数组相应位置写上编码0
else cd[start] = '1'; //这个就是1
c =f; f = HT[f].partent;//双亲变成孩子,继续回溯执行
}
HC[i] = new char [n-start];//为第i个字符编码分配内存空间
strcpy(HC[i],&cd[start]);//这个函数就是将球的的编码从临时空间cd复制到HC当前行中
delete cd; //clear临时空间cd
}
}
两个问题
-
为什么哈夫曼编码能够保证是前缀编码
因为所有的带有权值的结点都是叶子节点,而叶子节点不可能是另一个叶子结点的双亲,所以能保证是前缀编码,他们的编码不会重复
[外链图片转存中…(img-pKY443yW-1617794343510)]
-
为什么哈夫曼编码能够保证字符编码总长最短?
因为哈夫曼树的带权路径长度最短,故编码的总长最短
哈夫曼编码的算法实现
[外链图片转存中…(img-bAYTY0XT-1617794343510)]
右上的表存储编码**(就是我们要求得哈夫曼编码表**),HC[i]是一个字符型数组
右下的表是一个临时的存储编码的数组,最后那个是字符串写入结束的标记
代码实现:
void CreadHuffmanCode(HuffmanTree HT,HuffmanCode &HC, int n){
HC = new char*[n+1]; //分配n个字符编码的头指针矢量
cs = new char[n]; //分配临时存放编码的动态数组空间
cd[n-1] = '\0'; //编码结束符
for(int i =1; i<=n; ++i){
start = n-1; c = i; f = HT[i].parent;
while(f!=0){ //从叶子结点开始向上回溯,知道根节点
start--; //回溯一次start指向前一个位置
if(HT[f].lchild == c) cd[start] = '0'; //如果c是双亲的左孩子,那么在cd数组相应位置写上编码0
else cd[start] = '1'; //这个就是1
c =f; f = HT[f].partent;//双亲变成孩子,继续回溯执行
}
HC[i] = new char [n-start];//为第i个字符编码分配内存空间
strcpy(HC[i],&cd[start]);//这个函数就是将球的的编码从临时空间cd复制到HC当前行中
delete cd; //clear临时空间cd
}
}