【数据结构复习之路】树和二叉树(严蔚敏版)万字详解主打基础

专栏:数据结构复习之路

复习完上面四章【线性表】【栈和队列】【串】【数组和广义表】,我们接着复习 树和二叉树,这篇文章我写的非常详细且通俗易懂,看完保证会带给你不一样的收获。如果对你有帮助,看在我这么辛苦整理的份上,三连一下啦 

目录

一、树的定义

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,则它满足如下两个条件:

  1. 有且仅有一个特定的称为根 (Root) 的结点
  2.  其余结点可分为 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 层上至多有 2^{i-1} 个结点 (i ≥1)。

性质 2:深度为 k 的二叉树至多有 2^{k}-1 个结点(k ≥1)。


扩展:深度为 k 的 m 叉树至多有 \frac{m^{k}-1}{m-1} 个结点。

性质 3:高度为 h 的 m 二叉树至少有 h 个结点。


扩展:高度为h、度为 m 的树至少有 h + m - 1 个结点。

性质 4:具有 n 个结点的 m叉树 的最小高度 h 为:log_{m}(n(m-1)+1) 

通过:\frac{m^{h-1}-1}{m-1}< n<= \frac{m^{h}-1}{m-1} 可求出 h

性质 5:对任何一棵二叉树 T,如果其叶子数为 n_{0},度为 2 的结点数为 n_{2},则  n_{0} = n_{2}+1

2.3 满二叉树

特点:每一层上的结点数都达到最大,叶子全部在最底层。

编号规则:从根结点开始,自上而下,自左而右。

 因此:

  • 一棵深度为 k 且有 2^{k}-1个结点的二叉树称为满二叉树。
  • 按层序从1开始编号,结点为 i 的左孩子为 2i ,右孩子为 2i + 1,结点 i 的父节点为 \frac{i}{2}(注意是向下取整)

2.4 完全二叉树

定义:深度为 k 的具有 n 个结点的二叉树,当且仅当其每一个结点都与深度为 k 的满二叉树中编号为 1~ n 的 结点一一对应时,称之为完全二叉树。

特点:叶子只可能分布在层次最大的两层上。 对任一结点,如果其右子树的最大层次为 L,则其 左子树的最大层次必为 L 或 L + 1

因此:

  • 只有最后两层可能有叶子结点
  • 最多只有一个度为1的结点
  • 按层序从1开始编号,结点为 i 的左孩子为 2i ,右孩子为 2i + 1,结点 i 的父节点为 \frac{i}{2}(注意是向下取整)
  • 2i > n,则结点 i 无左孩子(即结点 i 为叶子结点),否则其左孩子为 2i
  • 2i + 1 > n,则结点 i 无右孩子,否则其右孩子为 2i + 1

常见考点:

 考点 1:具有 n 个结点的完全二叉树的深度为:

 \left \lfloor log_{2}n \right \rfloor+1 (向下取整) 或者 \left \lceil log_{2} (n+1)\right \rceil(向上取整)

考点 2 对于完全二叉树,可以由结点数 n 推出度为0、1和2的结点个数n_{0}n_{1} 和 n_{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^{k-1} 的一维数组,因此链式存储的优势也就显而易见了。

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 二叉树的层次遍历

所谓层次遍历二叉树,就是从树的根结点开始,一层一层按照从左往右的次序依次访问树中的结点。

层次遍历用链表存储的二叉树,可以借助链式队列存储结构实现,具体方案是:

  1. 将根结点入队;
  2. 从队列的头部提取一个结点并访问它,将该结点的左孩子和右孩子依次入队;
  3. 重复执行第 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 通过二叉树的先、中、后序遍历重构二叉树

由二叉树的遍历序列构造唯一的二叉树:

  1. 先序+中序
  2. 后序+中序
  3. 层序+中序

  • 给定二叉树的前序和后序,判断二叉树是否唯一?
  • 关于二叉树先序遍历和后序遍历为什么不能唯一确定一个二叉树分析?

 看了这两篇博客,我想让大家明白两点:

  1. 为什么包含中序遍历就能唯一确定一个二叉树
  2. 先序+后序,在某些情况下也能唯一确定一个二叉树

三、线索二叉树

3.1 基本概念 

在二叉树的结点上加上线索的二叉树称为线索二叉树,对二叉树以某种遍历方式(如先序、中序、后序或层次等)进行遍历,使其变为线索二叉树的过程称为对二叉树进行线索化

对于n个结点的二叉树,在二叉链存储结构中有n+1个空链域,利用这些空链域存放在某种遍历次序下该结点的前驱结点和后继结点的指针,这些指针称为线索,加上线索的二叉树称为线索二叉树。

根据线索性质的不同,线索二叉树可分为前序线索二叉树、中序线索二叉树和后序线索二叉树三种。
⚠️注意:线索链表解决了无法直接找到该结点在某种遍历序列中的前驱和后继结点的问题。

线索二叉树中的线索能记录每个结点前驱和后继信息。为了区别线索指针和孩子指针,在每个结点中设置两个标志 ltagrtag
当 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”,则向右走,一旦到达叶子结点,则译出 一个字符;再重新从根出发,直到电文结束。

​ 结尾

最后,非常感谢大家的阅读。我接下来还会更新 图 ,如果本文有错误或者不足的地方请在评论区(或者私信)留言,一定尽量满足大家,如果对大家有帮助,还望三连一下啦!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/178318.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Linux虚拟化的模式

三种虚拟化方式&#xff1a;完全虚拟化&#xff08;Full virtualization&#xff09;、硬件辅助虚拟化&#xff08;Hardware-Assisted Virtualization&#xff09;、半虚拟化&#xff08;Paravirtualization&#xff09;。 服务器上的虚拟化软件&#xff0c;多使用 qemu&#…

蚁剑低版本反制

蚁剑低版本反制 漏洞概述 中国蚁剑是一款开源的跨平台网站管理工具&#xff0c;它主要面向于合法授权的渗透测试安全人员以及进行常规操作的网站管理员。影响范围 AntSword <2.0.7 蚁剑实验版本&#xff1a;2.0.7 环境搭建&#xff1a; 172.16.1.233&#xff08;蓝队服…

idea打开.class文件没有反编译

1 问题描述 新安装的idea开发工具&#xff0c;打开.class文件查看内容时发现没有将文件进行反编译&#xff0c;所以具体的代码实现看不到。如图所示&#xff1a; 尝试了各种办法解决&#xff0c;最终都没有解决我的问题&#xff0c;其他同事的idea开发工具都可以打开.class文件…

js闭包的必要条件及创建和消失(生命周期)

>创建闭包的必要条件&#xff1a; 1.函数嵌套 2.内部函数引用外部函数的变量 3.将内部函数作为返回值返回 >闭包是什么&#xff1f; 就是可以访问外部函数&#xff08;作用域&#xff09;中变量的内部函数 > 闭包是什么时候产生的&#xff1f; - 当调用外部函数…

HIT_OS_LAB4 系统调用

实验内容 编写iam.c和whoami.c iam.c #define __LIBRARY__ #include <unistd.h>// 定义系统调用 iam&#xff0c;参数为字符串 name _syscall1(int, iam, const char*, name);int main(int argc, char **argv) {int wlen 0;// 检查命令行参数数量if (argc < 2) {pri…

ELK+Filebeat

Filebeat概述 1.Filebeat简介 Filebeat是一款轻量级的日志收集工具&#xff0c;可以在非JAVA环境下运行。 因此&#xff0c;Filebeat常被用在非JAVAf的服务器上用于替代Logstash&#xff0c;收集日志信息。实际上&#xff0c;Filebeat几乎可以起到与Logstash相同的作用&…

Android11编译第八弹:root用户密码设置

问题&#xff1a;user版本增加su 指令以后&#xff0c;允许切换root用户&#xff0c;但是&#xff0c;root用户默认没有设置密码&#xff0c;这样访问不安全。 需要增加root用户密码。 一、Linux账户管理 1.1 文件和权限 Linux一切皆文件。文件和目录都有相应的权限&#x…

函数式编程:简洁与效率的完美结合

&#x1f90d; 前端开发工程师&#xff08;主业&#xff09;、技术博主&#xff08;副业&#xff09;、已过CET6 &#x1f368; 阿珊和她的猫_CSDN个人主页 &#x1f560; 牛客高级专题作者、在牛客打造高质量专栏《前端面试必备》 &#x1f35a; 蓝桥云课签约作者、已在蓝桥云…

还在担心发抖音没素材跟文案?[腾讯云HAI] AIGC带你蹭热度“今年你失去了什么?”

目录 &#x1f433;前言&#xff1a; &#x1f680;了解高性能应用服务 HAI &#x1f47b;即插即用 轻松上手 &#x1f47b;横向对比 青出于蓝 &#x1f424;应用场景-AI作画 &#x1f424;应用场景-AI对话 &#x1f424;应用场景-算法研发 &#x1f680;使用HAI进行…

蓝桥杯day01——根据给定数字划分数组

题目描述 给你一个下标从 0 开始的整数数组 nums 和一个整数 pivot 。请你将 nums 重新排列&#xff0c;使得以下条件均成立&#xff1a; 所有小于 pivot 的元素都出现在所有大于 pivot 的元素 之前 。所有等于 pivot 的元素都出现在小于和大于 pivot 的元素 中间 。小于 piv…

orcad模块化绘制电路

当我们的板子上需要绘制大量的重复电路的时候&#xff0c;手动去绘制就很浪费时间。 orcad 的软件可以进行模块化绘制&#xff0c;将几个原理图包装成一个模块&#xff0c;然后直接去复制模块就可以。 相对来说大大的简化了原理图的设计麻烦程度 下面就是整个的操作流程 最后做…

一天之内“三个离职群都满了”;飞行出租车的时代就此开启?丨 RTE 开发者日报 Vol.94

开发者朋友们大家好&#xff1a; 这里是 「RTE 开发者日报」 &#xff0c;每天和大家一起看新闻、聊八卦。我们的社区编辑团队会整理分享 RTE &#xff08;Real Time Engagement&#xff09; 领域内「有话题的 新闻 」、「有态度的 观点 」、「有意思的 数据 」、「有思考的 文…

每日一练 | 华为认证真题练习Day138

1、IPv6地址FE80::2EO:FCFF:FE6F:4F36属于哪一类&#xff1f; A. 组播地址 B. 任播地址 C. 链路本地地址 D. 全球单播地址 2、如果IPv6的主机希望发出的报文最多经过10台路由器转发&#xff0c;则应该修改IPv6报文头中的哪个参数&#xff1f; A. Next Header B. Version …

【考研or就业】关键的时间节点,暑期实习/秋招/春招大揭秘(文末附时间线图)

【考研or就业】关键的时间节点&#xff0c;暑期实习/秋招/春招大揭秘 一些引言考研初复试暑期实习秋招春招 【考研or就业】关键的时间节点&#xff0c;暑期实习/秋招/春招大揭秘&#xff08;视频版&#xff09; 一些引言 ● 之前我个人的选择是比较离谱的那种&#xff0c;考研…

【实验】配置用户通过IPv6方式上网

【赠送】IT技术视频教程&#xff0c;白拿不谢&#xff01;思科、华为、红帽、数据库、云计算等等https://xmws-it.blog.csdn.net/article/details/117297837?spm1001.2014.3001.5502【微/信/公/众/号&#xff1a;厦门微思网络】 组网需求 运营商为企业分配了WAN侧的IPv6地址11…

SageMath安装

Sagemath工具是免费开源的&#xff0c;针对数学计算的一个工具。 网页版免安装&#xff1a;https://sagecell.sagemath.org/ Sagemath是根据Linux系统编写的&#xff0c;所以Windows上使用的话&#xff0c;会创建一个Linux系统运行。 1. 安装 Windows本地安装参考&#xff1…

oracle闪回恢复表数据

oracle闪回恢复表数据 1.打开监听和数据库&#xff0c;进入需要操作的表的所属用户下 [oraclemydb ~]$ lsnrctl start [oraclemydb ~]$ sqlplus / as sysdba SQL> startup SQL> conn test/123456 SQL> select * from test1&#xff1b;2.删除任意数据&#xff1a; …

WIFI模块(esp-01s)实现天气预报代码实现

目录 前言 实现图片 一、串口编程的实现 二、发送AT指令 esp01s.c esp01s.h 三、数据处理 1、初始化 2、cjson处理函数 3、核心控制代码 四、修改堆栈大小 前言 实现图片 前面讲解了使用AT指令获取天气与cjson的解析数据&#xff0c;本章综合将时间显示到屏幕 一、…

MyBatis的解析和运行原理

文章目录 MyBatis的解析和运行原理MyBatis的工作原理 MyBatis的解析和运行原理 MyBatis编程步骤是什么样的&#xff1f; 1、 创建SqlSessionFactory 2、 通过SqlSessionFactory创建SqlSession 3、 通过sqlsession执行数据库操作 4、 调用session.commit()提交事务 5、 调用…

lettcode 1089. 复写零

代码&#xff1a; class Solution {public void duplicateZeros(int[] arr) {int cur 0, dest -1, n arr.length;// 1. 先找到最后⼀个需要复写的数while (cur < n) {if (arr[cur] 0) dest 2;else dest 1;if (dest > n - 1) break;cur;}// 2. 处理⼀下边界情况if …