数据结构与算法7:树和二叉树
文章目录
- 第五章 树和二叉树
- 5.1、树和二叉树的定义
- 5.1.1、树的定义
- 5.1.2、树的基本术语
- 5.1.2、线性结构和树结构的区别
- 5.1.2、二叉树的定义
- 5.2、案例引入
- 5.3、树和二叉树的抽象数据类型定义
- 5.4、二叉树的性质和存储结构
- 5.4.1、两种特殊形式的二叉树
- 5.4.1.1、满二叉树
- 5.4.1.2、完全二叉树(Complete binary tree)
- 5.4.2-a、二叉树顺序存储结构
- 5.4.2.1、二叉树的顺序存储缺点:
- 5.4.2-b、二叉树链式存储结构
- 5.4.2-c、三叉链表——二叉树链表的扩展
- 5.5、遍历二叉树和线索二叉树
- 5.5.1、遍历二叉树
- 1、遍历二叉树算法描述
- 2、先、中、后序遍历二叉树的练习。
- 3、遍历的算法实现——先序,中序和后序遍历
- 4、遍历算法的分析
- 5、遍历二叉树的非递归算法——以中序为例
- 6、二叉树的层次遍历及其算法实现
- (1)二叉树的层次遍历
- (2)二叉树的算法实现
- 7、二叉树遍历算法的应用
- 二叉树的建立
- 复制二叉树
- 计算二叉树的深度
- 计算二叉树结点总数
- 计算二叉树叶子结点数(补充)
- 5.5.2、线索二叉树
- 5.6、树和森林
- 5.6.1、常见的树的结构
- 1、双亲表示法
- 2、孩子链表
- 3、孩子兄弟表示法(二叉树表示法,二叉树链表表示法)
- 5.6.2、树、森林与二叉树的转换
- 1、树转换成二叉树
- 2、二叉树转换成树——逆序操作
- 3、森林转化二叉树(二叉树与多棵树之间的关系)
- 4、二叉树转化森林
- 5.6.3、树与森林的遍历
- 1、树的遍历(三种方式)
- 2、森林的遍历
- 5.7 、哈夫曼树及其应用
- 5.7.1 、哈夫曼树的基本概念
- 5.7.2 、哈夫曼树的构造算法
- 1、哈夫曼算法(构造哈夫曼树的方法):口诀+详细步骤描述
- 2、哈夫曼树构造算法的实现
- 5.7.3 、哈夫曼编码
- 5.7.4 、哈夫曼编码的算法实现
- 5.7.5 、哈夫曼编码的应用举例
第五章 树和二叉树
5.1、树和二叉树的定义
5.1.1、树的定义
树(Tree)是n(n>=0)个结点的有限集。
● 若n = 0,则称为空树;
● 若n > 0,则它满足如下两个条件:
○ (1)、有且仅有一个特定的称为根(Root)的结点;
○ (2)、其余结点可以分为m(m >= 0)个互不相交的有限集T1,T2,T3…,Tm,其中每一个集合本身又是一个棵树,并称为根的子树(SubTree)。
树是n个结点的有限集,显然,树的定义是一个递归的定义。
树的其他表示方式
5.1.2、树的基本术语
————————-----------上下关系
根结点:非空书中无前驱结点的结点。
结点的度:结点拥有的子树数。
● 度 != 0,非终端结点,分支结点。
● 度 = 0,叫做==终端结点==,也叫叶子。
● 根结点以外的分支结点称为内部结点。
树的度:树内各结点的度的最大值。
结点的子树的根称为该结点的孩子,该结点称为孩子的双亲。
————————————水平关系
兄弟:有共同的双亲的结点,就是兄弟结点。比如HIJ有共同的双亲D。H,I,J相互为兄弟。
堂兄弟:他们没有共同的双亲,但是他们的双亲都在同一层结点。比如G和H就是堂兄弟,从根结点(记为第一层)开始,它们的双亲,G的双亲C,H的双亲D,都在第2层。
结点的祖先:从根结点到该结点所经分支上的所有结点。比如M,从M开始:M——>H——>D——>A,到根结点结束,这条线中,H,D,A都是M的祖先,包括它的双亲H。
结点的子孙:以某结点为根的子树中的任一结点。比如A,从A开始:A——>D——>H——>M,到根结点结束,这条线中,H,D,M都是A的子孙。
树的深度:树中结点的最大层次。也叫做树的高度,本文中树的深度为第4层。
注解:
● 树的度:a有3个分支,b有2个分支,c有1个分支,d有3个分支,…,里面分支最多的就是3,所以3是这个树的度。
● 内部结点:a是根结点,所以剔除掉,剩下的所有结点就是内部结点。
● 双亲和孩子:比如A结点生出BCD三个分支,所以A结点是BCD的双亲,BCD结点是A的孩子。
有序树:树中结点的各子树从左到右有次序(最左边的为第一个孩子)。比如A下面三个分支B所在的T1,C所在T2,D所在的T3,必须按照T1为左,T2为中,T3为右的顺序,这就是有序树。
无序树:树中结点的各子树无次序。
森林:是m(m >= 0)棵互不相交的树的集合。把根结点删除树就变成了森林。一棵树可以看成是一个特殊的森林。给森另种的子树加上一个双亲结点,森林就变成了树。
5.1.2、线性结构和树结构的区别
线性结构 | 树结构 |
第一个数据元素 无前驱 | 根结点(只有一个) 无双亲 |
最后一个数据元素 无后继 | 叶子结点(可以有多个)无孩子 |
其他数据元素 一个前驱一个后继 | 一个双亲,多个孩子 |
一对一 | 一对多 |
5.1.2、二叉树的定义
为何要重点研究每结点最多只有两个“叉”的树?
● 二叉树的结构最简单,规律性最强;
● 可以证明,所有树能转换为唯一对应的二叉树,不失一般性。
普通树(多叉树)若不转换成为二叉树,则运算很难实现
二叉树在树结构的应用中起着非常重要的作用,因为对二叉树的许多操作算法简单,而任何树都可以与二叉树相互转换,这样就解决了树的存储结构以其运算中存在的复杂性。
二叉树是n(n >= 0)个结点的有限集,它或者是空集(n = 0),或者由一个根结点及两棵互不相交的分别称作这个根的左子树和右子树的二叉树组成。
特点
1、每个结点最多有俩孩子(二叉树中不存在大于2的结点:0,1,2)
2、子树有左右之分,其次序不能颠倒。
3、二叉树可以是空集合,根可以有空的左子树或空的右子树。
注意:
二叉树不是树的特殊情况,它们是两个概念。
二叉树结点的子树要区分左子树和右子树,即使只有一棵子树也要区分,说明它是左子树,还是右子树。
树当结点只有一个孩子时,就无须区分它时左还是右的次序。因此二者时不同的。这是二叉树与树的最主要的差别。
(也就是二叉树每个结点位置或者说次序都是固定的,可以是空,但是不可以说它没有位置,而树的结点位置是相对于别的结点来说的,没有别的结点时,它就无所谓左右了)
思考:
具有3个结点的二叉树可能有几种不同的形态?普通树呢?
二叉树有5种形态:
而树有2种状态:
二叉树的5种基本形态
注意:虽然二叉树于与树概念不同,但是有关树的基本术语对二叉树都适用。
5.2、案例引入
案例1:数据压缩问题
将数据文件转换成由0、1组成的二进制串,称之为编码。
案例2:利用二叉树求解表达式的值
以二叉树表示表达式的递归定义如下:
(1)若表达式为数或简单变量,则相应二叉树中仅有一个根结点,其数据域存放该表达式信息;
(2)若表示式为“第一操作数 运算符 第二操作数”的形式,则相应的二叉树中以左子树表示第一操作数,右子树表示第二操作数,根结点的数据域存放运算符(若为一元运算,则左子树为空),其中,操作数本身又为表达式。
5.3、树和二叉树的抽象数据类型定义
二叉树的抽象数据类型定义
ADT BinaryTree{数据对象D: D是具有相同特性的数据元素的集合数据关系R:若D = Ø,则R = Ø;若D != Ø,则R = {H};H是如下二元关系:(1)root 唯一 //关于根的说明(2) Dj∩Dk = Ø //关于子树不相交的说明(3)... //关于数据元素的说明(4) ... //关于左子树和右子树的说明基本操作P: //至少有20个
}ADT BinaryTree
最常用的基本操作
CreateBiTree(&T,definition)初始条件:defeinition 给出二叉树T的定义。操作结构:按definiton构造二叉树T。
PreOrderTraverse(T)初始条件: 而叉树T存在。操作结果: 先遍历T,对每个结点访问一次。
InOrderTraverse(T)初始条件: 二叉树T存在。操作结构: 中序遍历T,对每个结点访问一次。
PostOrderTraverse(T)初始条件:二叉树T存在。操作结构: 后遍历T,对每个结点访问一次。
5.4、二叉树的性质和存储结构
性质一:在二叉树的第i层至多有2i-1个结点(i >= 1)。
证:采用归纳证明此性质。
归纳基:当i = 1 时,只有一个根结点,2i-1 = 20 = 1,命题成立。
归纳假设: 设对于所有的j(1<= j < i),命题成立, 即第j层至多有2j-1 个结点。那么可以证明j = i 时命题也成立。
归纳证明: 由归纳假设可知,第 i - 1 层上至多有 2i-2 个结点。由于二叉树每个结点的度最大为2,故在第i层上最达结点为第i - 1 层上最大结点的2倍,即:2* 2i-2 = 2 i-1。证毕。
提问:第i层至少有_个结点? 1个。
性质二: 深度为k的二叉树至多有2k - 1 个结点(k >= 1)。
提问:深度为k时至少有_个结点? k个
性质三:对于一棵二叉树T,如果其叶子树为n0, 度为2的结点树为n2,则n0 = n2 + 1。
直观上看
证明过程——从引入边的角度证明
注解:性质三结论不重要,重要的是2种分析边和结点关系的方式,
这个性质怎么证明它呢?从分支个数来,先从下往上看,每个结点到它的双亲结点都有一个分支(边),只有根结点没有(从下往上看,根结点没有双亲结点,所有说只有根结点没有边),记所有结点为n,则产生的边数为n-1;从上往下看,度为2的结点产生2条边,度为1的结点产生1条边,叶子结点没有边,度为2的结点记作n2,度为1的结点记作n1,所有种类的结点加起来就是所得边数:n22 + n11 + n0 * 1。
5.4.1、两种特殊形式的二叉树
5.4.1.1、满二叉树
定义:一棵深度为k且有2k-1 个结点的二叉树称为满二叉树。
特点:
● 1、每一层上的结点数都是最大结点树(即每层都满),
● 2、叶子节点全部在最底层
对满二叉树结点位置编号
● 编号规则: 从根结点开始,自上而下,自左而右。
● 每一个结点位置都有元素。
思考:下面的二叉树是满二叉树吗?
不是,从以下角度分析。
从满二叉树定义来看:一棵深度为k且有2k-1 个结点的二叉树称为满二叉树。此二叉树深度为4,但是实际结点(9个)少于定义要求结点(24-1=15个),所以此不为满二叉树。
从满二叉树特点来看:
1、每一层上的结点数都是最大结点树(即每层都满),此二叉树第一层(20)1个,满足,第二层(21)2个满足,第三层(22) ,实际为2个不满足。
2、最后一层结点个数不满。
编号核对:8,9,10, 11,12,13,14,15位置都是空着的。
补充:满二叉树在同样深度的二叉树中结点个数最多。
满二叉树在同样深度的二叉树中叶子结点个数最多。
5.4.1.2、完全二叉树(Complete binary tree)
定义:深度为k的具有n个结点的二叉树,当且仅当其每一个结点都于深度为k的满二叉树中编号为1~n的结点一 一对应时,称之为完全二叉树。
判断:
注意:在满二叉树中,从最后一个结点开始,连续去掉任意个结点,即时一棵完全二叉树。一定是连续的去掉,(包括数字位置和里面的)
特点:
● 1、叶子只可能分布在层次最大的两层上。
● 2、对于任一结点,如果其右子树的最大层次为i,则其左子树的最大层次必为i或i + 1。
性质4:具有n个结点的完全二叉树的深度为|Log2n| +1。
注意:|x|:称作x的底,表示不大于x的最大整数
这个性质是如果知道完全二叉树的结点个数(数据元素个数)就知道了它的深度了。
性质4表明了完全二叉树结点数n与完全二叉树深度k之间的关系。
性质5:如果对一棵有n个结点的完全二叉树(深度为|log2n| + 1 )的结点按层编号(从第1层第|log2n| + 1层,每层从左到右),则对任一结点i(1<= i<= n),有:
性质5表明了完全二叉树中双亲结点编号与孩子结点编号之间的关系。
只需会计算即可。
5.4.2-a、二叉树顺序存储结构
实现:按满二叉树的结点层次编号,依次存放二叉树中的数据元素。
//二叉树顺序存储表示
#define MAXTSIZE 100 //定义数组的最大空间为100
Typedef TElemType SqBiTree[MAXSTIZE] //TElemType 中T(TREE)表示树,ElemType可以是int也可以示char类型
SqBiTree bt;//定义数组变量bt
例如:
5.4.2.1、二叉树的顺序存储缺点:
最坏的情况:深度为k的且只有k个结点的单支树需要长度为2k-1 的数组。
当树很高的时候就会浪费非常多的空间。
特点:结点间关系蕴含在其存储位置中,浪费空间,适于存满二叉树和完全二叉树
5.4.2-b、二叉树链式存储结构
typedef struct BiNode{TElemType data;struct BiNode *lchild,*rchild;//左右孩子指针
}BiNode,*BiTree; //*BiTree指向结点的指针
练习
在n个结点的二叉链表中,有________个空指针域。
5.4.2-c、三叉链表——二叉树链表的扩展
typedef struct TriTNode{TelemType data;struct TriTNode *lchild,*parent,*rchild;
}TriTNode,*TriTree;
5.5、遍历二叉树和线索二叉树
5.5.1、遍历二叉树
● 遍历定义——顺着某一条搜索路径巡防二叉树中的结点,使得每个结点均被访问一次,而且仅被访问一次(又称周游)。"访问”的含义很广,可以是对结点作各种处理,如:输出结点的信息、修改结点的数据值等,但要求这种访问不破坏原来的数据结构。
● 遍历目的——得到树中所有结点得一个线性排列。
● 遍历用途——它是树结构插入、删除、修改、查找和排序运算得前提,是二叉树一切运算得基础和核心。
1、遍历二叉树算法描述
重点了解前三种,若规定先左后右,则只有前三种情况:
● DLR——先(根)序遍历
● LDR——中(根)序遍历
● LRD——后(根)序遍历
规则:先左后右,根在哪里就是那种序遍历。
先序遍历二叉树 | 中序遍历二叉树 | 后序遍历二叉树 |
若二叉树为空,则空操作;否则如下 | 若二叉树为空,则空操作;否则如下 | 若二叉树为空,则空操作;否则如下 |
(1)访问根结点; | (1)中序遍历左子树; | (1)后序遍历左子树; |
(2)先序遍历左子树; | (2)访问根结点; | (2)后序遍历右子树; |
(3)先序遍历右子树。 | (3)中序遍历右子树。 | (3)访问根结点。 |
由二叉树的递归定义可知,遍历左子树和遍历右子树可如同遍历二叉树一样“递归”进行。
2、先、中、后序遍历二叉树的练习。
例题:
例:用二叉树表示算数表达式
写出下图所示二叉树的先序、中序和后序遍历顺序。
例——已知先序和中序序列求二叉树
例:已知二叉树的先序和中序序列,构造出相应的二叉树
● 先序: ABCDEFGHIJ
● 中序: CDBFEAIHGJ
分析:由先序序列确定根;由中序序列确定左右子树。
解:1、由先序知根为A,则由中序知左子树为CDBFE,右子树为IHGJ
2、再分别在左、右子树的序列中找出根、左子树序列、右子数序列。
3、依次类推,直到得到二叉树
例:已知中序序列和后序序列求二叉树
实例分析:已知一棵树的中序:BDCEAFHG,后序:DECBHGFA,请画出这个树二叉树。
3、遍历的算法实现——先序,中序和后序遍历
二叉树先序遍历算法:先序遍历
Status PreOrderTraverse(BiTree T){if(T == NULL)return OK; //空二叉树else{visit(T); //访问根结点PreOrderTraverse(T -> lchild); //递归遍历左子树PreOrderTraverse(T -> rchild); //递归遍历右子树}
}
递归过程详解
二叉树先序遍历算法:中序遍历
Status InOrderTraverse(BiTree T){if(T == NULL) return OK; //空二叉树else{InOrderTraverse(T -> lchild);//递归遍历左子树visit(T); //访问根结点;InOrderTraverse(T -> rchild); //递归遍历右子树}
}
二叉树先序遍历算法:后序遍历
三个算法分别是:
//前序遍历算法
Status PreOrderTraverse(BiTree T){if(T == NULL) return OK;else{cout<<T->data;PreOrderTraverse(T -> lchild);PreOrderTraverse(T -> rchild);}
}//中序遍历算法
Status InOrderTraverse(BiTree T){if(T == NULL) return OK;InOrderTraverse(T -> lchild);cout<<T -> data;InOrderTraverse(T -> rchild);
}//后序遍历算法
Status PostOrderTraverse(BiTree T){if(T == NULL) return OK;else{PostOrderTraverse(T -> lchild);PostOrederTraverse(T -> rchild);cout<<T -> data;}
}
4、遍历算法的分析
如果去掉输出语句,从递归的角度看,三种算法是完全相同的,或说这三种算法的访问路劲是相同的,只是访问点的实际不同。从虚线的出发点到终点的路劲上,每个结点经过3次。
第1次经过时访问 | 先序遍历 |
第2次经过时访问 | 中序遍历 |
第3次经过时访问 | 后续遍历 |
注解:时间复杂度都是一样的,都要每个结点经过一次,如果有n个结点的话,它的时间复杂度就是O(n);空间复杂度,遇上一个结点,如果我们不访问它,那就需要一个地方把它存上,然后等回来的时候访问,递归中记录这个地址的就是栈(BiTree T),最坏的情况是单只的,需要记录n个结点,所以空间复杂度为O(n)。
● 时间效率:O(n) //每个结点只访问一次
● 空间效率:O(n) //栈占用的最大辅助空间
5、遍历二叉树的非递归算法——以中序为例
中序遍历非递归算法:二叉树中序遍历的非递归算法的关键:在中序遍历过某结点的整个左子树后,如何找到该结点的根以及右子树。
基本思想:
● 1、建立一个栈
● 2、根结点进栈,遍历左子树
● 3、根结点出栈,输出根结点,遍历右子树。
中序遍历的非递归操作演示:
遍历二叉树的非递归算法
Status InOrderTraverse(BiTree T){BiTreep;InotStack(S);p = T;while(p || !StackEmpty(S)){if(p){Push(S,p);p = p -> lchild;}else{Pop(S,q);p = q -> rchild;}}return OK;
}
6、二叉树的层次遍历及其算法实现
(1)二叉树的层次遍历
对于一个棵二叉树,从根结点开始,按从上到下、从左到右的顺序访问每一个结点。每一个结点仅仅访问一次。
算法设计思路:使用一个队列
● 1、将根结点进队;
● 2、队不空时循环:从队列中出列一个结点*p,访问它;
○ 若它有左孩子结点,将左孩子进队;
○ 若它有右孩子结点,将右孩子结点进队。
二叉树的层次遍历示意图
(2)二叉树的算法实现
使用队列类型定义如下:
typedef struct{BTNode data[MaxSize]; //存放队中元素int front, rear; //队头和队尾指针
}SqQueue; //顺序循环队列类型
二叉树层次遍历算法:
void LevelOrder(BTNode *b){BTNode *p;SqQueue *p;InitQueue(qu); //初始化队列enQueue(qu); //根结点指针进入队列while(!QueueEmpty(qu)){ //队不为空,则循环deQueue(qu,p); //出队结点pprintf("%c",p -> data); //访问结点p if(p -> lchild != NULL)enQueue(qu,p -> lchild); //有左孩子时将其进队if(p -> rchild != NULL)enQueue(qu,p -> rchild); //有右孩子时将其进队}
}
7、二叉树遍历算法的应用
二叉树的建立
按先序遍历序列建立二叉树的二叉链表
例:已知先序序列为:ABCDEGF
(1)从键盘输入二叉树的结点信息,建立二叉树的存储结构;
(2)在建立二叉树的过程中按照二叉树先序方式建立;
通过对这2个二叉树进行先序遍历会发现输出序列都为ABCDEGF,我们说对于一种方式(比如上述只有先序)很难确定是唯一的二叉树,那怎么才能确定就是左边一种而不是右边的一种呢?方法是给补充空(“#”)结点,这样我们构造出来的空结点就不一样啦。
对于右图所示二叉树,按下列顺序读入字符:
Status CreateBiTree(BiTree &T){scanf(&ch); //C++ 为 cin>>ch;if(ch == "#") T = NULL;else{if(!(T = (BiTNode *)malloc(sizeof(Node))))exit(OVERFLOW); //C++为 T = new BiTNode;T -> data = ch; //生成根结点CreateBiTree(T -> lchild); //构造左子树CreateBiTree(T -> rchild); //构造右子树}return OK;
}//CreateBiTree
复制二叉树
如果是空树,递归结束;
否则,申请新结点空间,复制根结点
● 递归复制左子树
● 递归复制右子树
int Copy(BiTree T, BiTree &NewT){if(T == NULL){ //如果是空树返回0NewT = NULL; return 0;}else{NewT = new BiTNode; NewT -> data = T -> data;Copy(T -> lChild, NewT -> lchild);Copy(T -> rChild, NewT -> rchild);}
}
计算二叉树的深度
如果是空树,则深度为0;
否则,递归计算左子树的深度记为m,递归计算右子树的深度记为n,二叉树的深度则为m与n的较大者加1。
int Depth(BiTree T){if(T == NULL) return 0;else{m = Depth(T -> lChild);n = Depth(T -> rChild);if( m > n) return(m + 1);else return(n + 1);}
}
计算二叉树结点总数
如果是空树,则结点个数为0;
否则, 结点个数为左子树的结点个数 + 右子树的结点个数再 + 1。
int NodeCount(BiTree T){if(T == NULL) return 0;else return NodeCount(T -> lchild)+ NodeCount(T -> rchild) + 1;
}
计算二叉树叶子结点数(补充)
如果是空树,则叶子结点个数为0;
否则,为左子树的叶子结点个数 + 右子树的叶子结点个数。
int LeadCount(BiTree T){if(T == NULL) return 0;//如果是空树返回0if(T -> lchild == NULL && T -> rchild == NULL) return 1; //如果是叶子结点返回1else return LeafCount(T -> lchild) + LeafCount(T -> rchild);
}
5.5.2、线索二叉树
问题:为什么要研究线索二叉树?
当用二叉链表作为二叉树的存储结构时,可以很方便地找到某个结点的左右孩子;但一般情况下,无法直接找到该结点再某种遍历序列中的前驱和后继结点。
提出问题:如何寻找特定遍历序列中二叉树结点的前驱和后继???
解决的方法:
● 1、通过遍历寻找——费时间
● 2、再增设前驱、后继指针域——增加了存储负担。
● 3、利用二叉链表中的空指针域。
利用二叉链表中空指针域:
如果某个结点的左孩子为空,则将空的左孩子指针域改为指向其前驱;如果某结点的右孩子为空,则将空的右孩子指针域改为指向其后继——这种改变指向的指针称为“线索”加上这了线索的二叉树称为线索二叉树(Threaded Binary Tree),对二叉树按某种遍历次序使其变为线索二叉树的过程叫线索化。
线索二叉树
为区分lrchild和rchild指针到底指向孩子的指针,还是指向前驱或者后继的指针,对二叉链表中每个结点增设两个标志域ltag和rtag,并约定:
ltag = 0 lchild 指向该结点的左孩子
ltag = 1 lchild 指向该结点的前驱
rtag = 0 rchild 指向该结点的右孩子
rtag = 1 rchild 指向该结点的后继
这样, 结点的结构为:
typedef struct BiThrNode{int data;int ltag,rtag;struct BiThrNode *lchild, rchild;
}BiThrNode, *BiThrTree;
练习
画出以下二叉树对应的中序线索二叉树。
该二叉树中序遍历结果为:H,D,I,B,E,A,F,C,G
增加一个头结点:
ltag = 0, lchild 指向根结点,
rtag = 1,rchild 指向遍历序列中最后一个结点
遍历序列中第一个结点的lc域和最后一个结点的rc域都指向头结点
5.6、树和森林
5.6.1、常见的树的结构
1、双亲表示法
实现:定义结构数组存放树的结点,每个结点含两个域:
● 数据域:存放结点本身的信息。data
● 双亲域:指示本结点的双亲结点在数组中的位置。parent
特点:找双亲容易,找孩子难。
C语言的类型描述:
typedef struct PTNode{TElemType data;int parent; //双亲位置域
}PTNode;#define MAX_TREE_SIZE 100
typedef struct{PTNode nodes[MAX_TREE_SIZE];int r,n; //根结点的位置和结点个数
}PTree;
2、孩子链表
把每个结点的孩子结点列起来,看成是一个线性表,用单链表存储则n个结点有n个孩子链表(叶子的孩子链表为空表)。而n个头指针又组成一个线性表,用顺序表(含个元素的结构数组)存储。
特点:找孩子容易,找双亲难。
C语言的类型描述:
//孩子结点结构
typedef struct CTNode{int child;struct CTNode *next;
}*ChildPtr;//双亲结点结构:
typedef struct{TElemType data;ChildPtr frrstchild;//孩子链表头指针
}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、树、森林与二叉树的转换
1、树转换成二叉树
● 将树转换成为二叉树进行处理,利用二叉树的算法来实现对树的操作。
● 由于树和二叉树都可以用二叉链表作存储结构,则以二叉链表作媒介可以导出树与二叉树之间的一个对应关系。
总结规律
● 1、加线:在兄弟之间加一连线
● 2、抹线:对每个结点,除了其左孩子外,去除其与其余孩子之间的关系
● 3、旋转:以树的根结点为轴心,将整个树顺时针转45°。
记为:兄弟相连留长子。(树变二叉树)
例如:将树转换成二叉树
2、二叉树转换成树——逆序操作
● 加线:若p结点是双亲结点的左孩子,则将p的右孩子,右孩子的右孩子…沿分支找到的所有右孩子,都与p的双亲用线连接起来。
● 抹线:抹掉原二叉树中双亲与右孩子之间的连线
● 调整:将结点按层次排列,形成树结构
例如:将二叉树转换成树
3、森林转化二叉树(二叉树与多棵树之间的关系)
● 1、将各棵树转换成二叉树
● 2、将每棵树的根结点用线相连
● 3、以第一棵树根结点为二叉树的根,再以根结点为轴心,顺时针旋转,构成二叉树型结构。
记作森林变二叉树:树变二叉树根相连。
例如:森林转化成二叉树
4、二叉树转化森林
● 1、抹线:将二叉树中根结点与其右孩子连线,及沿右分支搜索到的所有右孩子间连线全部抹掉,使之变成孤立的二叉树
● 2、还原:将孤立的二叉树还原成树。
记二叉树变森林:去掉全部右孩线,孤立二叉再还原。
例如:二叉树转换成森林
5.6.3、树与森林的遍历
1、树的遍历(三种方式)
● 先根(次序)遍历:若树不空,则先访问根结点,然后依次先根遍历各棵子树。
● 后根(次序)遍历:若树不空,则先依次后根遍历各棵子树,然后方法问根结点。
● 按层次遍历:若树不空,则自上而下,自左至右访问树中每个结点。
2、森林的遍历
将森林看作由三部分构成:
● 1、森林中第一棵树的根结点;
● 2、森林中第一棵树的子树森林;
● 3、森林中其他树构成的森林。
先序遍历:
若森林不空,则
● 1、访问森林中第一棵树的根结点;
● 2、先序遍历 森林中第一棵树的子树森林;
● 3、先序遍历森林中(除第一棵树之外)其余树构成的森林。
即:依次从左到右对森林中的每一棵树进行先根遍历。
中序遍历:
若森林不空,则
● 1、中序遍历森林中第一棵树的子树森林;
● 2、访问森林中第一棵树的根结点;
● 3、中序遍历森林中(除第一棵树之外)其余树构成的森林。
即:依次从左到右对森林中的每一棵树进行后根遍历。
例如:森林的遍历
5.7 、哈夫曼树及其应用
哈夫曼:(霍夫曼、赫夫曼)David Albert Huffman (August 9,1925-October 7,1999)。计算机科学的先驱,以他的哈夫曼编码闻名,在他的一生中,对于有限状态自动机,开关电路,异步过程和信号设计有杰出的贡献。
他发明的Huffman编码能够使得我们通常的数据传输数量减少到最小。这个编码的发明和这个算法一样十分引人入胜。1950年,Huffman在MIT的信息理论与编码研究生班学习。Robert Fano教授让学生们自己决定是参加期末考试还是做一个大作业。而Huffman选择了后者,原因很简单,因为解决一个大作业可能比期末考试更容易通过。这个大作业促使了Huffman算法的诞生。
离开MIT后,Huffman来到University of Califrnia的计算系任教,并为此系的学术做出了许多杰出的工作。而他得算法也广泛应用于传真机,图像压缩和计算机安全领域。但是Huffman却从为算法申请过专利或其他相关能够为他带来经济利益的东西,他将他全部的精力放到教学上,以他自己的话来说,“我所要带来的就是我的学生。”
5.7.1 、哈夫曼树的基本概念
【例】编程:将学生的百分制成绩转换为五分制成绩
<60:E 60-90:D 70-79:C 80-89:B 90-100:A
if(score<60) grade == 'E';
else if(score < 70)grade == 'D';
else if(score < 80)grade == 'C';
else if(score < 90)grade == 'B';
else grade == 'A';
判断树:用于描述分类过程的二叉树
例如:如果每的输入量很大,则应考虑程序的操作时间。
若学生成绩数据共10000个:则5%的数据需1次比较,15%的数据需2次比较,40%的数据需3次比较,40%的数据需4次比较,因此10000个数据比较的次数为:10000(15% + 215% + 340% + 410%)= 31500次
怎么才能让输入数据小一些呢?修改如下
这种10000个数据比较的次数为:10000(320% + 280%)= 22000次
显然:两种判别树的效率是不一样的。问题:能不能找到一种效率最高的判别树呢?——————哈夫曼树(最优二叉树)
路径:从树中一个结点到另一个结点之间的分支构成这个两个结点间的路径。
结点的路径长度:两结点间路径上的分支数。
树的路径长度:从树根到每一个结点的路径长度之和。记作TL。
注意:结点数目相同的二叉树中,完全二叉树是路径长度最短的二叉树。
权(weight):将树中结点赋给一个有着某种含义的数值,则这个数值称为该结点的权。比如前面例子中60分以下的同学占5%,这些就是权,就是所占的比例。
结点的带权路径长度:从根结点到该结点之间的路径长度于该结点的权的乘积。
树的带权路径长度:树中所有叶子结点的带权路径长度之和。计算过程如例子所举
哈夫曼树:最优树 带权路径长度(WPL)最短的树
注意:“带权路径长度最短”是在“度相同“的树中比较而得的结果,因此有最优二叉树、最优三叉树之称等等。
哈夫曼树:最优二叉树 带权路径(WPL)最短的二叉树
因此构造这种树的算法是由哈夫曼教授于1952年提出的,所以被称为哈夫曼树,相应的算法称为哈夫曼算法。
哈夫曼树的特点:
● 结论一:满二叉树不一定是哈夫曼树。
● 结论二:哈夫曼树中权越大的叶子离根越近。
● 结论三:具有相同带权结点的哈夫曼树不唯一。
5.7.2 、哈夫曼树的构造算法
1、哈夫曼算法(构造哈夫曼树的方法):口诀+详细步骤描述
● (1)构造森林全是根:根据n个给定的权值{w1,w2,…,wn}构造n棵二叉树的森林F={T1,T2,…,Tn},其中Ti只有一个带权为wi的根结点。
● (2)选用两小造新树:在F中选取两棵根结点的权值最小的树作为左右子树,构造一棵新的二叉树,且设置新的二叉树的根结点的权值为其左右子树上根结点的权值之和
● (3)删除两小添新人:在F中删除这两棵树,同时将新得到的二叉树加入森林中
● (4)重复2、3剩单根:重复(2)和(3),直到森林中只有一棵树为止,这棵树即为哈夫曼树。
哈夫曼算法口诀:构造森林全是根,选用两小造新树,删除两小添新人,重复2、3剩单根。
注意:
哈夫曼树的结点的度数为0或2,没有度数为1的结点。
包含n个叶子结点的哈夫曼树中共有2n-1个结点。
包含n棵树的森林要经过n - 1次才能形成哈夫曼树,共产生n - 1个新结点。
如何构造哈夫曼树
总结:
1、在哈夫曼算法中,初始时有n棵二叉树,要经过n - 1次合并最终形成哈夫曼树。
2、经过n - 1次合并并产生n - 1个新结点,且这n - 1个新结点都是具有两个孩子的分支结点。
可见:哈夫曼树中共有n + n -1 = 2n -1 个结点,且其所有的分支结点的度均不为1。
2、哈夫曼树构造算法的实现
采用顺序存储结构——一维结构数组 HuffmanTree H
;
结点类型定义
例如:
怎么实现哈夫曼算法呢?我们分3步来实现。
第一步,初始化HT [1…2n-1]:Ich = rch = parent = 0;
第二步,输入初始n个叶子结点:置HT[1…n]的weight值;
void CreatHuffmanTree(HuffmanTree HT, int n){//构造哈夫曼树——哈夫曼算法if(n <= 1) return;m = 2*n - 1;//数组共2n - 1个元素HT = new HTNode[m + 1]; //0号单元未用,HT[m]表示根结点for(i = 1; i <= m; ++i){//将2n - 1个元素的lch、rch、parent置为0HT[i].lch = 0;HT[i].rch = 0;HT[i].parent = 0;}for(i = 1; i <= n; ++i) cin >> HT[i].weight;//输入前n个元素的weight值//初始化结束,下面开始建立哈夫曼树
}
第三步,进行以下n -1次合并,依次产生n -1 个结点HT[i],i = n + 1…2n -1:
● (1)在HT[1…i-1]中选两个未被选过(从parent == 0 的结点中选)weight最小的两个结点HT[s1]和HT[s2],s1,s2为两个最小结点下标;
● (2)修改HT[s1]和HT[s2]的parent值:HT[s1].parent = i; HT[s2].parent = i;
● (3)修改新产生的HT[i]:
○ HT[i].weight = HT[s1].weight + HT[s2].weight;
○ HT[i].lch = s1; HT[i].rch = s2;
续
续上
for(i = n + 1;i <= m; i++){//合并产生n - 1个结点——构造Huffman树Select(HT,i - 1,s1,s2 ); //在HT[k](1<= k <= i-1)中选择两个其双亲域为0;//且权值最小的结点,并返回它们在HT中的序号s1和s2HT[s1].parent = i; HT[s2].parent = i; //表示从F中删除s1,s2HT[i].lch = s1; HT[i].rch = s2; //s1,s2分别作为i的左右孩子HT[i].weight = HT[s1].weight + HT[s2].weight; //i的权值为左右孩子权值之和
}
5.7.3 、哈夫曼编码
● 在远程通讯中,要将待传字符转换成由二进制的字符串:
● 设要传送的字符为: ABACCDA
若编码为 | 则ABACCDA为 |
A-00 B-01 C-10 D-11 | 00 01 00 10 10 11 00(中间没有空格,仅是为方便表示) |
若是编码设计长度不等的二进制编码,即让待传字符串中出现次数较多的字符采用尽可能短的编码,则转换的二进制字符串便可能减少。
——————进一步优化
● 设要传送的字符为: ABACCDA
若编码为 | 则ABACCDA为 |
A-0 B-00 C-1 D-01 | 0 00 0 11 01 0(中间没有空格,仅是为方便表示) |
此时0 00 0可以解读为0 0 0 0:AAAA or 00 00 :BB or 0 00 0: ABA等,使得不能还原成原来的样子。这样该如何解决呢?
关键:要设计长度不等的编码,则必须使任一字符的编码都不是另一个字符的编码的前缀。——————这种编码称作前缀编码。
问题:什么样的前缀码能使得电文总长度最短? ——哈夫曼编码
方法:
● 1、统计字符集中大每个字符在电文中出现的平均概率(概率越大,要求编码越短)。
● 2、利用哈夫曼树的特点:权越大的叶子离根越近;将每个字符的概率值作为权值,构造哈夫曼树。则概率越大的结点,路径越短。
● 3、在哈夫曼树的每个分支上标上0或1:结点的左分支标0,右分支标1,把从根到每个叶子的路径上的标号连接起来,作为该叶子代表的字符的编码。
例子如下
虽然哈夫曼编码是如此智慧,解决了电文最短的问题,但是怎么才能证明哈夫曼码确实不会产生歧义呢,或者说哈夫曼编码的原理是怎么样的?
两个问题:
1、为什么哈夫曼编码保证是前缀编码(个人暂且理解具有唯一性,不会产生歧义)?
因为没有一树叶是另一片树叶的祖先,所以每个叶结点的编码就不可能是其它叶结点编码的前缀。(每个叶子结点的路径唯一,且不同,所以谁也不可能包含谁)
2、为什么哈夫曼编码能够保证字符编码总长最短?
因为哈夫曼树的带权路径长度最短,故字符编码的总长度最短。
总结:哈夫曼编码的性质
● 性质1:哈夫曼编码是前缀码;
● 性质2:哈夫曼编码是最优前缀码。
练习
答案:
5.7.4 、哈夫曼编码的算法实现
回溯的时候最开始从第n个元素开始,上图中由G开始,沿着路径往根结点走,最后确定根结点了之后,就开始从根结点开始寻找其他结点的位置,编制上其他结点哈夫曼编码。
注解:因为有n个结点,也就是决定了深度为n-1,最长回溯路径为n - 1,我们需要存储回溯中的0,1记录下来,这个长度怎么算了,有n-1个长度就够了,但在存储的数组中下标是从0——>n-1的,所以往往多出一个长度来,由于最长回溯路径只需要n-1个记录的就行,所以把数组中多出的这个用结束字符“\0”恰好合适。
注解:B的ip地址是2,所以找到了它在12号结点的右侧,记为1,根据parent查出12号元素的双亲是13号元素,且在13号元素的右侧,记为1,最后找到13号元素的双亲是0,说明13号元素是根结点,所以遇到根结点结束。至此寻B的哈夫曼编码确定,为11,记录在数组B行。
Void CreatHuffmanCode(HuffmanTree HT,HuffmanCode &HC, int n){
//从叶子到根逆向求每个字符的哈夫曼编码,存储在编码表HC中HC = new char *[n + 1];cd = new char[n];cd[n-1] = '\0';for(i = 1; i <= n; ++i){start = n - 1; c = i; f = HT[i].parent;while(f != 0){--start;if(HT[f].lchild == c) cd[start] = '0';else cd[start] = '1';c = f; f = HT[f].parent; }HC[i] = new char[n - start];strcpy(HC[i],&cd[strart]); }delete cd;
}//CreatHuffanCode
5.7.5 、哈夫曼编码的应用举例
1、文件的编码和解码
编码:
● 1、输入各字符及其权值:根据统计在单词中,英语书籍中的字母频率知道了权值。
● 2、构造哈夫曼树——HT[i]:结构数组,有n + n - 1=2n-1个结点,每个结点有权重w,双亲p,左孩子l和r右孩子。
● 3、进行哈夫曼编码——HC[i]:指针数组,
● 4、查HC[i],得到各字符的哈夫曼编码
英语中各字母出现频率
把明文转换成哈夫曼码的过程就叫做编码,利于计算机传输。同样的,也需要将哈夫曼码转换成明文,这叫做解码,方便人们识别穿过来的信息。
解码:
● 1、构造哈夫曼树
● 2、依次读入二进制码
● 3、读入0,则走向左孩子;读入1,则走向右孩子
● 4、一旦达到某叶子时,即可译出字符
● 5、然后再从根出发继续译码,指导结束
2、文件的编码和解码的案例
哈夫曼编码——解码