专栏:数据结构复习之路
复习完上面四章【线性表】【栈和队列】【串】【数组和广义表】,我们接着复习 树和二叉树,这篇文章我写的非常详细且通俗易懂,看完保证会带给你不一样的收获。如果对你有帮助,看在我这么辛苦整理的份上,三连一下啦
目录
一、树的定义
1.1 基本术语
1.2 扩展:树的四种表示形式
二、二叉树
2.1 二叉树的定义
2.2 二叉树的性质
2.3 满二叉树
2.4 完全二叉树
2.5 二叉排序树
2.6 平衡二叉树
2.7 二叉树的顺序存储结构
2.8 二叉树的链式存储结构
2.9 二叉树的先中后序遍历
① 先序遍历
② 中序遍历
③ 后序遍历
相关题目练习:
2.10 先中后序遍历的扩展
2.11 递归实现(顺序表存储)
2.12 二叉树的层次遍历
2.13 通过二叉树的先、中、后序遍历重构二叉树
三、线索二叉树
3.1 基本概念
3.2 中序线索化
构建:
3.3 先序线索化
构建:
3.4 后序线索化
3.5 线索二叉树找前驱和后继
【1】中序线索二叉树
【2】先序线索二叉树
【3】后序线索二叉树
四、树的存储结构
4.1 双亲表示法(顺序存储)
4.2 孩子表示法(顺序+链式)
4.3 孩子兄弟表示法(链式存储)
4.4 森林和二叉树的转换
五、树和森林的遍历
5.1 树的先根遍历
5.2 树的后根遍历
5.3 树的层次遍历
5.4 森林的先序遍历
5.5 森林的中序遍历
六、哈夫曼树
基本概念:
定义:
哈夫曼树的构造
哈夫曼编码
译码
结尾
一、树的定义
树(Tree) 是 n(n≥0)个结点的有限集。若 n=0, 称为空树;
若 n > 0,则它满足如下两个条件:
- 有且仅有一个特定的称为根 (Root) 的结点。
- 其余结点可分为 m (m≥0) 个互不相交的有限集 T1 , T2 , T3 , …, Tm, 其中每一个集合本身又是一棵树,并称为根的子树 (SubTree)。
1.1 基本术语
1、结点拥有的子树数称为 :结点的度(degree),如上图结点A的度为3,B的度为2。
- 度为0的结点叫:叶子(leaf)(终端结点)
- 度不为0的结点叫:分支结点(非终端结点)
- 内部结点(B、C、D、E、H)
- 树的度是树中各结点的最大的度(上图树的度为3)
2、结点的子树的根称为该结点的孩子(child)
- 双亲(parent)(D为H、I、J的双亲)
- 兄弟(sibling)(H、I、J互为兄弟)
- 祖先,子孙(B的子孙为E、K、L、F;M的祖先为A、D、H)
3、结点的层次
- 根结点为第一层
- 某结点在第 i 层,其孩子在第 i+1 层
- 树中结点的最大层次称为树的深度
- 其双亲在同一层的结点互为堂兄弟
4、森林(forest) 是 m (m≥0) 棵互不相交的树的集合。比如上图中除去 A 结点,那么分别以 B、C、D 为根结点的三棵子树就可以称为森林。
树可以理解为是由根结点和若干子树构成的,而这若干子树本身就是一个森林,因此树还可以理解为是由根结点和森林组成的。
1.2 扩展:树的四种表示形式
一、树形表示法(正如上述所介绍)
二、嵌套集合(文氏)表示法
注:它是以嵌套集合的形式表示的(集合之间绝不能相交,即任意两个圆圈不能有交集)
三、凹入表示法
注:最长条为根结点,相同长度的表示在同一层次。例如 B、C、D 长度相同,都为 A 的子结点,E 和 F 长度相同,为 B 的子结点,K 和 L 长度相同,为 E 的子结点,依此类推。
四、广义表表示法(正如在上一章广义表所述)
二、二叉树
二叉树在树结构的应用中起着非常重要的作用,因为对二叉树的许多操作算法简单,而任何树均可与二叉树相互转换,这样就解决了树的存储结构及其运算中存在的复杂性。
2.1 二叉树的定义
二叉树是 n (n≥0) 个结点的有限集,它或者是 空集 (n = 0),或者由一个根结点及两棵互不相交的 分别称作这个根的左子树和右子树的二叉树组成。
特点:
- 每个结点最多有俩孩子 (二叉树中不存在度大于 2 的结点) 。
- 子树有左右之分,其次序不能颠倒。
- 二叉树可以是空集合,根可以有空的左子树或空的右子树。
⚠️注意:二叉树不是树的特殊情况,它们是两个概念。
理由:二叉树结点的子树要区分左子树和右子树,即使只有一棵子树也要进行区分,说明它是左子树,还是右子树。树当结点只有一个孩子时,就无须区分它是左还是右。(也就是 二叉树每个结点位置或者说次序都是固定的,可以是空,但是不可以说它没有位置,而树的结点位置是相对于别的结点来说的,没有别的结点时,它就无所谓左右了),因此二者是不同的。这是二叉树与树的最主要的差别。
⚠️注意:虽然二叉树与树概念不同, 但有关树的基本术语对二叉树都适用。
2.2 二叉树的性质
性质 1:在二叉树的第 i 层上至多有 个结点 (i ≥1)。
性质 2:深度为 k 的二叉树至多有 个结点(k ≥1)。
扩展:深度为 k 的 m 叉树至多有 个结点。
性质 3:高度为 h 的 m 二叉树至少有 h 个结点。
扩展:高度为h、度为 m 的树至少有 h + m - 1 个结点。
性质 4:具有 n 个结点的 m叉树 的最小高度 h 为:
通过: 可求出 h
性质 5:对任何一棵二叉树 T,如果其叶子数为 ,度为 2 的结点数为 ,则
2.3 满二叉树
特点:每一层上的结点数都达到最大,叶子全部在最底层。
编号规则:从根结点开始,自上而下,自左而右。
因此:
- 一棵深度为 k 且有 个结点的二叉树称为满二叉树。
- 按层序从1开始编号,结点为 i 的左孩子为 2i ,右孩子为 2i + 1,结点 i 的父节点为 (注意是向下取整)
2.4 完全二叉树
定义:深度为 k 的具有 n 个结点的二叉树,当且仅当其每一个结点都与深度为 k 的满二叉树中编号为 1~ n 的 结点一一对应时,称之为完全二叉树。
特点:叶子只可能分布在层次最大的两层上。 对任一结点,如果其右子树的最大层次为 L,则其 左子树的最大层次必为 L 或 L + 1。
因此:
- 只有最后两层可能有叶子结点
- 最多只有一个度为1的结点
- 按层序从1开始编号,结点为 i 的左孩子为 2i ,右孩子为 2i + 1,结点 i 的父节点为 (注意是向下取整)
- 2i > n,则结点 i 无左孩子(即结点 i 为叶子结点),否则其左孩子为 2i
- 2i + 1 > n,则结点 i 无右孩子,否则其右孩子为 2i + 1
常见考点:
考点 1:具有 n 个结点的完全二叉树的深度为:
(向下取整) 或者 (向上取整)
考点 2: 对于完全二叉树,可以由结点数 n 推出度为0、1和2的结点个数、 和
2.5 二叉排序树
二叉排序树。一棵二叉树或者是空二叉树, 或者是具有如下性质的二叉树:
- 左子树上所有结点的关键字均小于根结点的关键字
- 右子树上所有结点的关键字均大于根结点的关键字
左子树和右子树又各是一棵二叉排序树。
好处:二叉排序树可用于元素的排序、搜索,都是相当高效的!
2.6 平衡二叉树
平衡二叉树:树上任一结点的左子树和右子树的深度之差不超过1。
好处:搭配二叉排序树可以达到更高的搜索效率!
2.7 二叉树的顺序存储结构
#define MaxSize 100
struct TreeNode{
ElemType value; //结点中的数据元素
bool isEmpty; //结点是否为空
} t[MaxSize + 1];
【1】对于完全二叉树:用一组地址连续的存储单元依次自上而下、自左至右存储结点元素,即将编号为 i 的结点元素存储在一维数组中下标为 i 的分量中。
这里常见的考点就是:
- i 的左孩子
- i 的右孩子
- 判断 i 是否有右孩子?
- 判断 i 是否是叶子结点或分支结点?
这些都是完全二叉树的重要性质,上文已讲解。
⚠️注意:如果 i 从0开始,那么将现有编号 + 1,再按公式算,算出结果后,再 -1,不要糊涂!
对于一般二叉树:将其每个结点与完全二叉树上的结点相对照,存储在一 维数组的相应分量中。 但是这种存储已经不满足完全二叉树的性质。
因此为了能够满足这种性质,就需要为这个非完全二叉树依次补上虚拟结点空间。
但是这种存储形式,在最坏情况下,深度为 k 的且只有 k 个结点的右单支树需要 长度为 的一维数组,因此链式存储的优势也就显而易见了。
2.8 二叉树的链式存储结构
typedef struct BiTNode{
ElemType data; //数据域
struct BiTNode *lchild , *rchild; //左、右孩子指针
} BiTNode , *BiTree;
除了根结点外,每个结点上面都必与一个指针 "相连" ,共有 n - 1 个指针,那么剩余的空指针数量应为: 2n - (n - 1) = n + 1(个空指针)。
找某一结点P的左孩子和右孩子(相当轻松):
printf("p结点的左孩子结点为:%d\n", p->lchild->data);
printf("p结点的右孩子结点为:%d\n", p->rchild->data);
但是如果要找到指定结点 P 的父结点,就只能从根结点开始遍历寻找,因此你可以考虑在结构体中再加一个父节点指针。
typedef struct BiTNode{
ElemType data; //数据域
struct BiTNode *lchild , *rchild; //左、右孩子指针
struct BiTNode *parent; //父节点指针
} BiTNode , *BiTree;
这种形式也叫做 " 三叉链表 "。
2.9 二叉树的先中后序遍历
遍历:就是按某种次序把所有结点都访问一遍。
这种递归式的遍历,只有搞懂递归过程,才能真正的理解它的遍历过程,因此请务必理解它们的递归代码!
对于这三种遍历,我将基于如下这种图进行讲述:
① 先序遍历
void PreOrderTraverse(BiTree T) {
//如果二叉树存在,则遍历二叉树
if (T) {
printf("%d",T->data); //输出结点值
PreOrderTraverse(T->lchild);//访问该结点的左孩子
PreOrderTraverse(T->rchild);//访问该结点的右孩子
}
}
先访问根节点,再遍历左子树,最后遍历右子树。
遍历结果:5 2134 8697
⚠️注意:根据它的代码,我们发现,在递归左右子树前,一定是先输出当前结点值,并且只有左子树递归完后,才会返回上一层递归右子树,说明对于每个子树:先输出父结点、再输出左孩子,最后输出右孩子。因此记住这三句代码的顺序就对遍历过程了如指掌了。
详细递归过程:
输出根节点 5;
进入 5 的左子树,执行同样的步骤:
输出结点 2;
进入 2 的左子树,执行同样的步骤:
输出结点 1;
结点 1 没有左子树;
结点 1 没有右子树;
进入 2 的右子树,执行同样的步骤:
输出结点 3;
进入 3 的左子树,执行同样的步骤:
输出结点4;
结点 4 没有左子树;
结点 4 没有右子树;
进入 3 的右子树,没有,直接返回(因为 5 的左子树都已经遍历完了,所有一直返回到进入 5 的右子树)
进入 5 的右子树,执行同样的步骤:
输出结点 8;
进入 8 的左子树,执行同样的步骤:
输出结点 6;
进入 6 的左子树,没有,直接返回,再进入 6 的右子树;
进入 6 的右子树,执行同样的步骤:
输出结点9;
结点 9 没有左子树;
结点 9 没有右子树;
进入 8 的右子树,执行同样的步骤:
输出结点 7;
结点 7 没有左子树;
结点 7 没有右子树;
//运行到这里结点 5 的右子树都已经遍历完了,所有就递归回去,直到函数结束!
② 中序遍历
void INOrderTraverse(BiTree T) {
if (T) {
INOrderTraverse(T->lchild);//遍历当前结点的左子树
printf("%d ",T->data); //输出当前结点
INOrderTraverse(T->rchild);//遍历当前结点的右子树
}
}
先遍历左子树,再访问根节点,最后遍历右子树。
遍历结果:1243 5 6987
⚠️注意:输出结点值的位置放在了中间,说明对于每个子树:先输出左孩子,再输出它的父结点,最后输出其右孩子。
递归过程同上!可以自己动手试试画出递归过程~
③ 后序遍历
void PostOrderTraverse(BiTree T) {
if (T) {
PostOrderTraverse(T->lchild);//遍历左孩子
PostOrderTraverse(T->rchild);//遍历右孩子
printf("%d ", T->data);
}
}
先遍历左子树,再遍历右子树,最后访问根节点。
遍历结果:1432 9678 5
⚠️注意:输出结点值的位置放在了最后面,说明对于每个子树:先输出左孩子,再输出右孩子,最后输出其父结点
相关题目练习:
【1】二叉树的先序遍历中,任一结点均先于它的左、右子女(如果存在)访问,所有这句话是对的。
【2】在二叉树的定义那里已经讲的非常清楚了,这是错的。
【3】因为后序遍历的最后一个结点一定是根结点,并且前序遍历的第一个结点也是根结点,所以排除法,选择D。
【4】 根据前序遍历的结果可知, a 是根结点。由中序遍历的结果dgbaechf 可知,d、g、b 是左子树的结点, e、c、h、f 是右子树的结点。再由前序遍历的结果bdg 可知, b 是a左边子树的根,由cefh 可知, c 是a右边子树的根。再由中序遍历的结果dgb 可知, d,g 是b 左边子树的结点 ,g为d的右孩子。至此,a 的左子树已完全弄清楚了。同样的道理,可以弄清楚以c为根的子树的结点位置。所以可知后序遍历的结果是D。
【5】很明显选择A。
【6】 过程如下:
2.10 先中后序遍历的扩展
【1】我们只需要对先序、中序、后序遍历的过程稍加修改,就可以设计出构建二叉树的函数:
比如通过先序遍历,构建下图二叉树,只需要少许代码量就能构建好:
void CreateBiTree(BiTree* T) {
int num;
scanf("%d", &num);
//如果输入的值为 0,表示无此结点
if (num == 0) {
*T = NULL;
}
else
{
//创建新结点
*T = (BiTree)malloc(sizeof(BiTNode));
(*T)->data = num;
CreateBiTree(&((*T)->lchild));//创建该结点的左孩子
CreateBiTree(&((*T)->rchild));//创建该结点的右孩子
}
}
当我们输入的num数,依次为:5 210034000 860900700
就能将如图的二叉树用链表存储起来!超级方便!
【2】统计二叉树中叶子结点的个数 ,实现此操作只需对二叉树“遍历”一遍,并在遍历过程中对 “叶子结点计数”即可。显然这个遍历的次序可以随意,只是为了在遍历时进行计数,需要在算法的参数中设一个“计数器”。
void CountLeaf (BiTree T, int &count)
{
// 先序遍历二叉树以 count 返回二叉树中叶子结点的数目
if ( T )
{
if ((!T−>Lchild) && (!T−>Rchild)) // 无左、右子树
{
count ++; // 对叶子结点计数
}
CountLeaf ( T−>Lchild, count);
CountLeaf ( T−>Rchild, count);
}
}
【3】求二叉树的深度(后序)。
二叉树的深度 = MAX(左子树深度,右子树深度)+ 1 。
int BiTreeDepth(BiTree T)
{
if (!T) depth = 0;
else {
depthleft = BiTreeDepth(T->Lchild);
depthright = BiTreeDepth(T->Rchild);
depth = max(depthleft, depthright) + 1;
}
return depth;
}// BiTreeDepth
【4】 通过这种左右子树递归的思想,可以计算二叉树的所有叶子的带权路径长度之和WPL。一个叶子结点的带权路径长度为: 该结点的权重weight * 该结点的深度depth.
(1)算法思想:
递归遍历二叉树的所有叶节点,计算每个叶节点的带权路径长度,然后累加得到二叉树的带权路径长度WPL。
(2)
typedef struct TreeNode { int weight; struct TreeNode* left; struct TreeNode* right; } TreeNode;
(3)
int calculate_WPL(TreeNode *root , int depth) { if (root == NULL) return 0; //如果为空树 WPL为 0 if (root -> left == NULL && root -> right == NULL) return root -> weight * depth; //当前叶子结点的权值乘路径长度 return calculate_WPL(root -> left , depth + 1) + calculate_WPL(root -> right , depth + 1); //递归求二叉树中所有叶结点的带权路径长度之和 }
【5】 通过这种左右子树递归的思想,我们还可以处理下述问题:
(1) 算法思想:
因为括号反映操作符的计算次序,观察这两个表达式树的输出结果,不难看出,应采取中缀表达式的递归遍历算法,对于每递归到 "深度 > 1"并且不是叶子结点时,打印左括号 “( ”,当该结点的子孙遍历完后,再打印右括号“ )”。
(2)
#include <stdio.h> #include <stdlib.h> // 表达式树的结构体 typedef struct node { char data[10]; struct node* left , *right; } BTree; void infixExpression(BTree* root, int depth) { if (root == NULL) return; else if (root -> left == NULL && root -> right == NULL){ //叶子结点需格外输出 printf("%c",root -> data[0]); return; } else { if (depth > 1) printf("("); infixExpression(root -> left , depth + 1); printf("%c", root -> data[0]); infixExpression(root -> right , depth + 1); if (depth > 1) printf(")"); } } int main() { // 构造第一个表达式树(可以运行检验自己是否写错哦) BTree* root1 = (BTree*)malloc(sizeof(BTree)); root1->data[0] = '*'; root1->left = (BTree*)malloc(sizeof(BTree)); root1->left->data[0] = '+'; root1->left->left = (BTree*)malloc(sizeof(BTree)); root1->left->left->data[0] = 'a'; root1->left->left->left = NULL; root1->left->left->right = NULL; root1->left->right = (BTree*)malloc(sizeof(BTree)); root1->left->right->data[0] = 'b'; root1->left->right->left = NULL; root1->left->right->right = NULL; root1->right = (BTree*)malloc(sizeof(BTree)); root1->right->data[0] = '*'; root1->right->left = (BTree*)malloc(sizeof(BTree)); root1->right->left->data[0] = 'c'; root1->right->left->left = NULL; root1->right->left->right = NULL; root1->right->right = (BTree*)malloc(sizeof(BTree)); root1->right->right->data[0] = '-'; root1->right->right->left = NULL; root1->right->right->right =(BTree*)malloc(sizeof(BTree)); root1->right->right->right->data[0] = 'd'; root1->right->right->right->left = NULL; root1->right->right->right->right = NULL; printf("该表达式树的带括号的等价中缀表达式为:"); infixExpression(root1, 1); return 0; }
2.11 递归实现(顺序表存储)
先序遍历:
这里数组的下标是从0开始的,即根节点是从0开始存储的。
void PreOrderTraverse(Tree T, int p_node) {
if (T[p_node].empty) {
printf("%d ", T[p_node].value);
//先序遍历左子树
if ((2 * p_node + 1 < MaxSize) && (T[2 * p_node + 1].empty) {
PreOrderTraverse(T, 2 * p_node + 1);
}
//最后先序遍历右子树
if ((2 * p_node + 2 < MaxSize) && (T[2 * p_node + 2].empty)) {
PreOrderTraverse(T, 2 * p_node + 2);
}
}
}
中序遍历:
void INOrderTraverse(Tree T, int p) {
if (T[p].empty)
{
//递归遍历左子树
if (((2 * p + 1) < MaxSize) && (T[2 * p + 1].empty)) {
INOrderTraverse(T, 2 * p + 1);
}
//访问当前结点
printf("%d ", T[p].value);
//递归遍历右子树
if (((2 * p + 2) < MaxSize) && (T[2 * p + 2].empty)){
INOrderTraverse(T, 2 * p + 2);
}
}
}
后序遍历:
void PostOrderTraverse(Tree T, int p) {
if (T[p].empty)
{
if ((p * 2 + 1 < MaxSize) && (T[p * 2 + 1].empty)) {
PostOrderTraverse(T, 2 * p + 1);
}
if ((p * 2 + 2 < MaxSize) && (T[p * 2 + 2].empty)) {
PostOrderTraverse(T, 2 * p + 2);
}
printf("%d ", T[p].value);
}
}
2.12 二叉树的层次遍历
所谓层次遍历二叉树,就是从树的根结点开始,一层一层按照从左往右的次序依次访问树中的结点。
层次遍历用链表存储的二叉树,可以借助链式队列存储结构实现,具体方案是:
- 将根结点入队;
- 从队列的头部提取一个结点并访问它,将该结点的左孩子和右孩子依次入队;
- 重复执行第 2 步,直至队列为空;
这里以构建这个二叉树,以此为例,进行层次遍历。
#include <stdio.h>
#include <stdlib.h>
//定义二叉树
typedef struct BiTNode {
int data;//数据域
struct BiTNode* lchild, * rchild;//左右孩子指针
}BiTNode, * BiTree;
//定义链队列
typedef struct LinkNode{
BiTNode *value;
struct LinkNode *next;
} LinkNode;
typedef struct{
LinkNode *front , *rear;
}LinkQueue;
void InitQueue(LinkQueue &q) //初始化带头结点的链队列
{
q.front = q.rear = (LinkNode *)malloc(sizeof(LinkNode));
q.front->next = NULL;
}
//通过先序遍历构建二叉树
void CreateBiTree(BiTree* T) {
int num;
scanf("%d", &num);
//如果输入的值为 0,表示无此结点
if (num == 0) {
*T = NULL;
}
else
{
//创建新结点
*T = (BiTree)malloc(sizeof(BiTNode));
(*T)->data = num;
CreateBiTree(&((*T)->lchild));//创建该结点的左孩子
CreateBiTree(&((*T)->rchild));//创建该结点的右孩子
}
}
//入队函数
void EnQueue(LinkQueue &q, BiTree node) {
LinkNode *x = (LinkNode *)malloc(sizeof(LinkNode));
x->value = node;
x->next = NULL;
q.rear->next = x;
q.rear = x;
}
//出队函数
BiTNode* DeQueue(LinkQueue &q) {
if (q.front == q.rear)
{
printf("队列已空");
exit(0);
}
LinkNode *x = q.front->next;//从队头开始出队
BiTNode* p_node = x->value;
q.front->next = x->next;
if (q.rear == x)
{
q.front = q.rear;
}
return p_node;
}
//层次遍历二叉树
void LevelOrderTraverse(BiTree T) {
//如果二叉树存在,才进行层次遍历
if (T) {
LinkQueue q;
InitQueue(q);
//根结点入队
EnQueue(q, T);
//重复执行,直至队列为空
while (q.front != q.rear)
{
//从队列取出一个结点
BiTNode *p = DeQueue(q);
//访问当前结点
printf("%d ", p->data);
//将当前结点的左右孩子依次入队
if (p->lchild) {
EnQueue(q, p->lchild);
}
if (p->rchild) {
EnQueue(q, p->rchild);
}
}
}
}
//后序遍历二叉树,释放树占用的内存
void DestroyBiTree(BiTree T) {
if (T) {
DestroyBiTree(T->lchild);//销毁左孩子
DestroyBiTree(T->rchild);//销毁右孩子
free(T);//释放结点占用的内存
}
}
int main() {
BiTree Tree;
CreateBiTree(&Tree);
LevelOrderTraverse(Tree);
DestroyBiTree(Tree);
return 0;
}
输入:
5 2 1 0 0 3 4 0 0 0 8 6 0 9 0 0 7 0 0
输出:
5 2 8 1 3 6 7 4 9
2.13 通过二叉树的先、中、后序遍历重构二叉树
由二叉树的遍历序列构造唯一的二叉树:
- 先序+中序
- 后序+中序
- 层序+中序
- 给定二叉树的前序和后序,判断二叉树是否唯一?
- 关于二叉树先序遍历和后序遍历为什么不能唯一确定一个二叉树分析?
看了这两篇博客,我想让大家明白两点:
- 为什么包含中序遍历就能唯一确定一个二叉树
- 先序+后序,在某些情况下也能唯一确定一个二叉树
三、线索二叉树
3.1 基本概念
在二叉树的结点上加上线索的二叉树称为线索二叉树,对二叉树以某种遍历方式(如先序、中序、后序或层次等)进行遍历,使其变为线索二叉树的过程称为对二叉树进行线索化。
对于n个结点的二叉树,在二叉链存储结构中有n+1个空链域,利用这些空链域存放在某种遍历次序下该结点的前驱结点和后继结点的指针,这些指针称为线索,加上线索的二叉树称为线索二叉树。
根据线索性质的不同,线索二叉树可分为前序线索二叉树、中序线索二叉树和后序线索二叉树三种。
⚠️注意:线索链表解决了无法直接找到该结点在某种遍历序列中的前驱和后继结点的问题。
线索二叉树中的线索能记录每个结点前驱和后继信息。为了区别线索指针和孩子指针,在每个结点中设置两个标志 ltag 和 rtag。
当 ltag 和 rtag 为0时 ,leftChild 和 rightChild分别是指向左孩子和右孩子的指针;否则,leftChild是指向结点前驱的线索(pre),rightChild是指向结点的后继线索(suc)。由于每个标志只占用一个int,每个结点所需要的存储空间节省很多。现将二叉树的结点结构重新定义如下:
其中:ltag = 0 时lchild指向左儿子;ltag = 1 时lchild指向前驱;rtag=0 时rchild指向右儿子;rtag=1 时rchild指向后继。
线索二叉树存储结构:
typedef struct ThreadNode{ ElemType data; struct ThreadNode *lchild , *rchild; int ltag , rtag; }ThreadNode , *ThreadTree;
3.2 中序线索化
以中序遍历序列为依据进行 “线索化”。
构建:
#include <stdio.h>
#include <stdlib.h>
typedef struct ThreadNode{ //将此二叉树线索化
char data;
ThreadNode *lchild , *rchild;
int ltag , rtag ;
}ThreadNode , *ThreadTree;
ThreadNode *pre = NULL; //全局变量pre,指向当前访问结点前驱
void CreateTree(ThreadTree &tree) //先序遍历构建二叉树
{
char node;
scanf(" %c",&node); //%c前面加空格,为了过滤空格和回车的影响
if (node == '0')
{
tree = NULL;
}
else{
tree = (ThreadNode *)malloc(sizeof(ThreadNode)) ;
tree->data = node;
tree->ltag = 0;
tree->rtag = 0;
CreateTree(tree->lchild);
CreateTree(tree->rchild);
}
}
void InThread(ThreadTree &T) ;
void visit(ThreadNode *&q) ;
void Create_ThreadTree(ThreadTree &T) //中序遍历二叉树,一边遍历,一边线索化
{
if (T != NULL)
{
InThread(T) ;
if (pre->rchild == NULL) //处理遍历的最后一个结点
{
pre->rtag = 1;
}
}
}
void InThread(ThreadTree &T) //中序遍历
{
if (T != NULL)
{
InThread(T->lchild);
visit(T);
InThread(T->rchild);
}
}
void visit(ThreadNode *&q) //线索化
{
if (q->lchild == NULL){ //左子树为空,建立前驱线索
q->lchild = pre;
q->ltag = 1;
}
if (pre != NULL && pre->rchild == NULL)
{
pre->rchild = q; //建立前驱结点的后继线索
pre->rtag = 1;
}
pre = q;//记得修改当前访问结点的前驱
}
void Demo(ThreadTree tree , ThreadNode *G)
{
G = tree->lchild->lchild->rchild; // 求指定结点“G ” 的前驱结点和后继结点
if (G->ltag == 1)
{
printf("结点“G ”的前驱结点为 %c \n" , G->lchild->data);
}
if (G->rtag == 1)
{
printf("结点“G ”的后继结点为 %c" , G->rchild->data);
}
}
int main()
{
ThreadTree threadtree;
CreateTree(threadtree);
Create_ThreadTree(threadtree);
ThreadNode G_node;
Demo(threadtree , &G_node);
}
输入:
A B D 0 G 0 0 E 0 0 C F 0 0 0
输出:
结点“G ”的前驱结点为 D
结点“G ”的后继结点为 B
3.3 先序线索化
构建:
void Create_ThreadTree(ThreadTree &T) //先序遍历二叉树,一边遍历,一边线索化
{
if (T != NULL)
{
PreThread(T) ;
if (pre->rchild == NULL) //处理遍历的最后一个结点
{
pre->rtag = 1;
}
}
}
void PreThread(ThreadTree &T)
{
if (T != NULL)
{
visit(T);
if (T->ltag == 0) //【解释】
{
PreThread(T->lchild);
}
PreThread(T->rchild);
}
}
void visit(ThreadNode *&q)
{
if (q->lchild == NULL){ //左子树为空,建立前驱线索
q->lchild = pre;
q->ltag = 1;
}
if (pre != NULL && pre->rchild == NULL)
{
pre->rchild = q; //建立前驱结点的后继线索
pre->rtag = 1;
}
pre = q;//记得修改当前访问结点的前驱
}
上述代码,基本和中序线索化一模一样,只是PreThread函数做了一点变化!
【解释】
假设此时,pre 在 B结点 ,q 在D结点,即执行完visit(D)后,D -> lchild = pre = B ; D -> ltag = 1 ; pre = q。然后接着执行PreThread(D -> lchild) ,但是D -> lchild 已经被修改成了指向 B 结点。如果这里不加 if (T -> ltag== 0) 这个判断,那么接下来,就要重新又执行回到B结点,开始不断的在B 和 D 间 ”转圈圈“ …………
3.4 后序线索化
后序没有 ”转圈圈“ 的问题!
void Create_ThreadTree(ThreadTree &T) //后序遍历二叉树,一边遍历,一边线索化
{
if (T != NULL)
{
PosThread(T) ;
if (pre->rchild == NULL) //处理遍历的最后一个结点
{
pre->rtag = 1;
}
}
}
void PosThread(ThreadTree &T)
{
if (T != NULL)
{
PosThread(T->lchild);
PosThread(T->rchild);
visit(T);
}
}
void visit(ThreadNode *&q)
{
if (q->lchild == NULL){ //左子树为空,建立前驱线索
q->lchild = pre;
q->ltag = 1;
}
if (pre != NULL && pre->rchild == NULL)
{
pre->rchild = q; //建立前驱结点的后继线索
pre->rtag = 1;
}
pre = q;//记得修改当前访问结点的前驱
}
3.5 线索二叉树找前驱和后继
【1】中序线索二叉树
上面我们已经讲解了中序线索二叉树的线索化,并且通过线索化,我们可以在O(1)的复杂度找到某个结点(Tag == 1)的前驱和后继结点,例如G结点的前驱为D,后继为B。
但是如果 Tag == 0,就不能通过线索直接找到它的前驱和后继结点。例如B结点的中序后继我们无法直接得到,【这里不要以为 B -> rchild就可以了,这里只是一种特殊的情况,如果E结点后面还连接很多子树,那么B的中序后继结点就可能在其子树中】。
找后继
假设 E结点 后还有p1 、p2、p3 三个结点
那么根据中序遍历的规则,肯定是……B p3 p1 E p2.......
所以B的中序后继结点一定是 B 右子树中最左下结点,即 p3
//在中序线索二叉树中找到结点P的后继结点
ThreadNode *Nextnode(ThreadNode *p)
{
if (p->rtag == 0) return Firstnode(p->rchild);
else return p->rchild;
}
//找到 E 为根的子树中,第一个被中序遍历的结点
ThreadNode * Firstnode(ThreadNode *p)
{
while(p->ltag == 0) p= p->lchild;
return p;
}
理解了这个算法思想,我们还可以利用它们,将递归式的线索化过程,改为非递归的线索化过程。
这样做的好处,可以将空间复杂度降到O(1)。
void Inorder(ThreadNode *&T)
{
for (ThreadNode *p = Firstnode(T) ; p != NULL ; p = Nextnode(p))
{
visit(p);
}
}
找前驱
同理,找上图中B结点的前驱结点,因为其B -> ltag = 0,所以不能直接通过线索找到它的前驱结点。因此根据中序遍历的特点,某结点 p 的中序前驱结点一定是其左子树中最右下结点。
ThreadNode * Prenode(ThreadNode *p)
{
if (p->ltag == 0) return Firstnode(p->lchild);
else return p->lchild;
}
ThreadNode* Lastnode(ThreadNode *p)
{
while(p->rtag == 0) p = p->rchild;
return p;
}
根据这个算法思想,我们还可以对中序线索二叉树进行逆向的中序遍历。
void RevInorder(ThreadNode *T)
{
for (ThreadNode *p = Lastnode(T) ; p != NULL ; p = Prenode(p))
{
visit(p);
}
}
【2】先序线索二叉树
找后继:
根据先序遍历的特点,若 p -> rtag = 0。若 p 结点有左孩子,则其先序后继一定为其左孩子。若 p结点没有左孩子,则其先序后继一定为其右孩子。
ThreadNode* findnextnode(ThreadNode *p)
{
if (p->rtag == 0)
{
if (p->lchild != NULL) return p->lchild;
else return p->rchild;
}
else return p->rchild;
}
找前驱:
因为先序遍历中,左右子树中的结点只可能是根的后继,不可能是其前驱,因此要想找到 p->ltag = 0 的 p结点的先序前驱结点,只能再先序遍历一遍。
BiTNode *p ; //p结点是目标结点(即找它的前驱结点)
BiTNode *pre = NULL; //指向当前访问结点的前驱
BiTNode *final = NULL;//用于记录最终结果(即p的前驱结点)
void findPrenode(BiTree T)
{
if (T != NULL)
{
visit(T);
findPrenode(T->lchild);
findPrenode(T->rchild);
}
}
void visit(BiTNode *q)
{
if (q == p)
{
final = pre;
}
else pre = q;
}
【3】后序线索二叉树
找前驱:
根据后序遍历的特点,若 p -> ltag = 0。若 p 结点有右孩子,则其后序前驱一定为其右孩子。若 p结点没有右孩子,则其后序前驱一定为其左孩子。
ThreadNode* findprenode(ThreadNode *p)
{
if (p->ltag == 0)
{
if (p->rchild != NULL) return p->rchild;
else return p->lchild;
}
else return p->lchild;
}
找后继:
因为后序遍历中,左右子树中的结点只可能是根的前驱,不可能是其后继,因此要想找到 p->rtag = 0 的 p结点的后序后继结点,只能再后序遍历一遍。
BiTNode *p ; //p结点是目标结点(即找它的后继结点)
int flag = 0;
BiTNode *final = NULL;//用于记录最终结果(即p的后继结点)
void findnextnode(BiTree T)
{
if (T != NULL)
{
findnextnode(T->lchild);
findnextnode(T->rchild);
visit(T);
}
}
void visit(BiTNode *q)
{
if (flag == 1)
{
final = q;
Flag = 0;
}
if (q == p)
{
Flag = 1;
}
}
四、树的存储结构
4.1 双亲表示法(顺序存储)
实现:定义结构数组存放树的结点.
每个结点含两个域:
- 数据域:存放结点本身信息
- 双亲域:指示本结点的双亲结点在数组中的位置。
#define MAX_TREE_SIZE 100
typedef struct PTNode {
TElemType data;
int parent; // 双亲位置域
} PTNode;
typedef struct {
PTNode nodes[MAX_TREE_SIZE];
int r, n; // 根结点的位置和结点个数
} PTree;
从这个顺序存储结构,我们不难看出查找指定结点的双亲结点很方便,但是查找指定结点的孩子只能从头开始遍历一遍。
特点:找双亲容易,找孩子难
4.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;
4.3 孩子兄弟表示法(链式存储)
实现:用二叉链表作树的存储结构,链表中每个结点的两个指针域分别指向其第一个孩子结点和下一个兄弟结点
⚠️注意:孩子兄弟链表的结构形式与二叉链表完全相同,但存储结点中指针的含义不同:二叉链表中结点的左右指针分别指向该结点的左右孩子;而孩子兄弟链表结点的左右指针分别指向它的“长子” 和“大弟”。
typedef struct CSNode{
ElemType data; //数据域
struct CSNode *firstchild , *nextsibling; //第一个孩子 和 右兄弟指针
} CSNode , *CSTree;
这种解释上的不同正是 树 与 二叉树 相互转化的内在基础!
4.4 森林和二叉树的转换
森林:森林是 m (m >= 0) 棵互不相交的树的集合。
你可以假想有一个结点是 B、C、D的父节点。
同理二叉树转换成森林,也是一样的。
总结:
五、树和森林的遍历
5.1 树的先根遍历
若树不空,则先访问根结点,然后依次先根遍历各棵子树。
和先序遍历换汤不换药!
5.2 树的后根遍历
若树不空,则先依次后根遍历各棵子树,然后访问根结点。
5.3 树的层次遍历
若树不空,则自上而下自左至右访问树中每个结点。
5.4 森林的先序遍历
先序遍历森林中(除第一棵树之外)其余树构成的森林。 即:依次从左至右对森林中的每一棵树进行先根遍历。
当然将森林转换成二叉树,然后根据二叉树的先序遍历,得出的结果也是一样的。
5.5 森林的中序遍历
中序遍历森林中(除第一棵树之外)其余树构成的森林。 即:依次从左至右对森林中的每一棵树进行后根遍历(注意不是中序哦!!!)
当然将森林转换成二叉树,然后根据二叉树的后序遍历,得出的结果也是一样的。
六、哈夫曼树
基本概念:
路径:从树中一个结点到另一个结点之间的分支构成这两个结点间的路径。
结点的路径长度:两结点间路径上的分支数。
树的路径长度:从树根到每一个结点的路径长度之和。记作:TL
TL(a)=0+1+1+2+2+3+3+4+4=20
TL(b)=0+1+1+2+2+2+2+3+3=16
完全二叉树是路径长度最短的二叉树。
权:将树中结点赋给一个有着某种含义的数值,则这个数值称为该结点的权。
结点的带权路径长度:从根结点到该结点之间的路径长度与该结点的权的乘积。
树的带权路径长度:树中所有叶子结点的带权路径长度之和。 记作:
定义:
哈夫曼树(也叫最优树) :带权路径长度 (WPL) 最短的树。
⚠️注意:“带权路径长度最短”是在“度相同”的树中比较而得的结果,因此有最优二叉树、最优三叉树之称等等。
带权路径长度 (WPL) 最短的二叉树叫最优二叉树。
因为构造这种树的算法是由哈夫曼于 1952 年提出的, 所以被称为哈夫曼树,相应的算法称为哈夫曼算法。
哈夫曼树的构造
观察上图,我们可以推出如下重要的结论:
-
包含 n 棵树的森林要经过 n–1 次合并才能形成哈夫曼树,共产生 n–1 个新结点
-
包含 n 个叶子结点 的哈夫曼树中共有 2n – 1 个结点。
-
哈夫曼树的结点的 度数为 0 或 2, 没有度为 1 的结点。
-
权值越小的叶子结点到根节点的路径长度越大
-
哈夫曼树并不唯一,但WPL必然相同且为最优
哈夫曼编码
哈夫曼树的应用很广,哈夫曼编码就是其在电讯通信中的应 用之一。在电讯通信业务中,通常用二进制编码来表示字母或其他字符,并用这样的编码来表示字符序列。
一个好的编码一定:
- 编码总长度更短
- 译码的唯一性问题
首先解决编码总长度更短的问题,就是解决数据的最小冗余编码问题
实际应用中各字符的出现频度不相同 ,为了达到数据的最小冗余编码,就要用短(长)编码表示频率大(小)的字符,使得编码序列的总长度最小,使所需总空间量最少 。
为了解决译码的唯一性问题,要求任一字符的编码都不能是另一字符编码的前缀!
这种编码称为前缀编码(其实是非前缀码)。
而利用最优二叉树可以很好地解决上述两个问题
由哈夫曼树得到的二进制前缀编码称为哈夫曼编码
当然,上述发送的电文还比较短,如果几百上千,那么它的总长度就会大幅度缩短,这种可变的二进制长度编码显然比固定二进制长度编码好!
译码
从哈夫曼树根开始,对待译码电文逐位取码。若编码是“0”, 则向左走;若编码是“1”,则向右走,一旦到达叶子结点,则译出 一个字符;再重新从根出发,直到电文结束。
结尾
最后,非常感谢大家的阅读。我接下来还会更新 图 ,如果本文有错误或者不足的地方请在评论区(或者私信)留言,一定尽量满足大家,如果对大家有帮助,还望三连一下啦!