考纲要求 💕
知识点(考纲要求)
1.树的定义和基本术语
2.二叉树的定义和基本性质;二叉树的存储结构;二叉树的遍历;
3.树的存储结构、森林与二叉树的转换、树和森林的遍历。
考核要求
1.掌握树的定义和基本术语,掌握二叉树的递归定义.表示方式;
2.重点掌握二树叉的遍历。
3.掌握二叉排序树的定义和建立、显示与删除二叉排序树的基本操作。
4.熟练掌握树、森林和二叉树之间的转换方法;
5.熟练掌握哈夫曼树的建立过程和哈夫曼编码。
▶️ 1. 树的定义和基本用语和常考性质 ✨
❗ 1.1 树的基本定义和特性
1.1.1 ✨ 树的定义
树(Tree)
:这是一种非线性结构
。是 n( n ≥ 0 )个有限结点组成的一个具有层次关系的集合
,与现实生活中的树十分相像,只不过它是倒挂的。 n=0时
称这样的树为空树
1.1.2 ✨ 非空树的特性
非空树的特性
:
-
有且仅有
一个根节点
-
没有后继的结点称为
“叶子结点”(或终端结点)
-
有后继的结点称为
“分支结点”(或非终端结点)
-
除了根节点外
,任何一个结点都有且仅有一个前驱
-
每个结点可以
有0个或多个后继
。
重点注意:
除了根节点外
,任何一个结点都有且仅有一个前驱
常用来判断是否是正确的树,如下图就不对:
1.1.3 ✨ 子树
树是n(n≥0)个结点的有限集合,n = 0时,称为空树,这是一种特殊情况。
在任意一棵非空树
中应满足:
- 有且仅有一个特定的称为
根
的结点。 - 当n > 1时,其余结点可
分为m(m > 0)个互不相交的有限集合T1, T2,…, Tm
,其中每个集合本身又是一棵树,并且称为根结点的子树
.
比如下图:
下面来学习不同的结点分类和树的属性描述
1.1.4 ❗✨ 树的属性描述和结点描述
术语 | 描述 | 举例 |
---|---|---|
结点 | A,B,C等都是结点,结点不仅包含数据元素,而且包含指向子树的分支。 | A结点不仅包含元素A,而且包含三个指向子树的指针 |
结点的度 | 结点拥有的子树或分支个数 | A结点有三颗子树,A的度为3 |
树的度 | 树中各结点度的最大值 | A,D结点的度最大为3,故树的度为3 |
叶子结点 | 度为0的结点 | F,G,I,J,K,L,M均为叶子结点 |
非叶结点 | 度不为0的结点 | A,B,C,D,E,H,均为非叶结点 |
孩子 | 某结点子树的根 | A结点的孩子为B,C,D |
双亲 | 与孩子的定义对应 | B,C,D的双亲都是A |
兄弟 | 同一个双亲的孩子互为兄弟 | B,C,D互为兄弟 |
祖先 | 从根到某结点的路径上所有的结点,都是该结点的祖先 | K的祖先是A,B,E |
子孙 | 以某结点为根的子树中的所有结点 | D的子孙是H,I,J,M |
层次 | 根节点为第一层,根的孩子是第二层次,以此类推 | 结点F处在第三层 |
结点的深度 | 是指从根节点到该结点路径上的结点个数 | 跟层次一样,结点F深度为3 |
结点的高度 | 从某结点往下走可能到达多个叶子结点,对应了通往这些叶子结点的路径,其中最长的那条路径上的结点的个数称其为结点的高度 | (从下往上数)D的高度为3 |
树的高度(深度) | 树中结点的最大层次 | 根节点的高度就是树的高度 |
堂兄弟 | 双亲在同一层的结点互为堂兄弟 | G和H互为堂兄弟 |
有序树 | 树中结点的子树从左至右是有次序的,不能交换 | \ |
无序树 | 树中结点的子树没有顺序,可以任意交换 | \ |
丰满树 | 除了最底层外,其他层都是满的 | \ |
森林 | 若干互不相交的树的集合 | 上面的树,将根结点A去除,剩余的就是一个森林 |
有序树 | 树中结点从左至右是有次序的,不能交换 | \ |
无序树 | 树中结点从左至右是无次序的,可以交换 | \ |
- 注意结点的
层次(深度)默认从1
开始
树与森林:
1.1.5 ❗✨ 结点分类
树结点结构
:包含一个数据元素
和指向其子树的分支(指针)
(边)
根节点
:只有后继没有前驱,对于非空树,有且只有一个叶子结点(终端结点)
:没有后继结点的结点(度为0)分支结点(非终端结点)
:有后继结点的结点,除根节点外分支结点也称为内部结点
1.1.6 ❗✨ 树的常考性质(公式)
- 性质 1:结点数=总度数+1
- 结点数为13,总度数为3+2+1+3+2+1=12
- 性质 2:度为m的树和m叉树区别
m叉树
指的是--每个结点最多只能有m个孩子的树
度为 m的树 | m叉树 |
---|---|
任意结点的度 ≤ m(最多m个孩子) | 任意结点的度 ≤ m(最多m个孩子) |
至少有一个结点度 = m(有m个孩子) | 允许所有结点的度都 < m |
一定是非空树,至少有 m + 1 个结点 | 可以是空树 |
- 性质 3:度为m的树第 i 层至多有 m^i-1 个结点(i≥1)
- 性质 4:高度为h的m叉树至多有 (m^h-1)/(m-1) 个结点
m叉树
指的是--每个结点最多只能有m个孩子的树
根据等比数列求和公式:
比如下面这个例子:从上往下数是1(m^0),3(m^1),9(m^2),27(m^3) (满树),他们是一个等比数列,q是m ,运用等比数列公式求出最大结点数目为
高度为4,3叉树,至多(3^4-1)/(3-1)=40 个结点
- 性质 5:高度为h的m叉树至少有 h 个结点。 高度为h、度为m的树至少有 h+m-1 个结点。
- 性质 6:具有n个结点的m叉树的最小高度为 【logm (n(m-1)+1)】
关于上面的m叉树,二叉树是不是不太了解,咱们下面具体讲讲它
下面具体讲解下二叉树,每个结点最多有二个孩子(子树),度最大为2
▶️ 2. 二叉树
2.1 ❗ ✨二叉树基本概念
2.1.1 ❗ 二叉树的定义
二叉树(Binary Tree)
: 是n(n≥0)个结点的有限集合,其中每个结点最多有两颗子树
,也即二叉树度最大为2
,同时二叉树子树有次序之分
,不能颠倒。
结点数为0的二叉树称之为空二叉树
2.1.2 ❗ 二叉树五种形态
我们所见到的二叉树无外乎以下五种
- 空二叉树
- 只有左子树
- 只有右子树
- 只有根节点
- 左右子树都存在
2.1.3 ❗ 特殊二叉树
2.1.3.1 ❗ 满二叉树
满二叉树
:满二叉树它的每一层的结点数都达到了最大值。如果一个满二叉树有 h层,那么结点总数为2^{h}- 1个
- 只有最后一层是叶子结点
- 不存在度为1的结点
- 若按层序从1开始编号,结点i的左孩子的编号就为 2i,结点i的右孩子的编号就为2i+1,同时其父节点为 i/2(取余)
2.1.3.2 ❗ 完全二叉树
完全二叉树
:当且仅当其每个结点都与高度为h的 满二叉树中编号为1~n的结点一一对应时,称为完全二叉树
一个完全二叉树是由对应的满二叉树进行删除而来的,删除的时候必须从右向左,从下到上,不能跳着删除
-
只有最后两层可能有叶子结点
-
最多只有一个度为1的结点
-
如果某个结点只有一个孩子,那么它一定是左孩子
-
若按层序从1开始编号,结点i的左孩子的编号就为2i,结点i的右孩子的编号就为 2i+1,同时其父节点为 i/2(取余)
-
(n是结点编号) 若按层序从1开始编号,当 i ≤ n/2(取余)时该结点为分支结点,当 i >n/2(取余)时该结点为叶子结点
这个很容易混淆,注意抓住这几个特性来判断是不是完全二叉树
比如:
- 如果某个结点只有一个孩子,那么它一定是左孩子
-
最多只有一个度为1的结点
-
若按层序从1开始编号,结点i的左孩子的编号就为2i,结点i的右孩子的编号就为 2i+1,同时其父节点为 i/2(取余)
就像这样,如果有二个度为1的结点肯定不行.
而且他这个编号也不对,应该是左边的是结点2i,右边是2i+1,父节点是i/2(取余)
2.1.3.3 ❗ 二叉排序树
二叉排序树
:一颗二叉树若具有以下性质,则称为二叉排序树
- 左子树上所有结点的关键字值均小于根结点的关键字
- 右子树上所有结点的关键字值均大于根结点的关键字
- 左子树和右子树又各是一颗二叉排序树
2.1.3.4 ❗ 平衡二叉树
平衡二叉树
:树上任一结点的左子树和右子树的高度之差不超过1
如上,左边的是<=1的,是平衡二叉树,右边不是
2.1.3 ❗ 二叉树常考性质
性质1
:非空二叉树中,叶子结点(度为0的结点)总比度为2的结点多1个
常见考点1:设非空二叉树中度为0、1和2的结点个数分别为n0、n1和n2,则 n0 = n2 + 1 (叶子结点比二分支结点多一个)
假设树中结点总数为 n,则
- n = n0 + n1 + n2
- n0=n2+1
- n = n1 + 2n2 +1
如下图: 结点总数为n=12,叶子结点n0=6,度为1的结点n1=1,度为1的结点n2=5
这条性质要注意灵活应用,很多时候不会这样直接考。
比如某道题问“二叉树的总的结点数为n,空指针多少?”
我们可以把所有的空指针都看作叶子结点,也就是求叶子结点数目
性质2
:二叉树第 i 层至多有 2^i -1 个结点
根据m叉树的性质,m叉树第 i 层至多有 m^i -1 个结点(i≥1)
性质3:高度为 h 的二叉树至多有 2^{h}-1 个结点(也就是满二叉树)
根据m叉树的性质:高度为h的m叉树至多有m^h-1/m-1 个结点
至多是满二叉树
2.1.4 ❗ 完全二叉树常考性质
性质1:具有n个结点的完全二叉树的高度为
注意:这里是
完全二叉树
的高度
推导过程如下:
记忆下
性质2:具有n个结点的完全二叉树 结点数
- 常见考点2:对于完全二叉树,可以由结点数 n 推出度为0、1和2的结点个数为n0、n1和n2
完全二叉树最多只有一个度为1的结点,即
n1=0或1
根据上面二叉树 n0=n2+1结论得到:
n0 = n2 + 1
n0 + n2 一定是奇数
得到下面结论:
- 若完全二叉树有
2k
个(偶数)个结点,则 必有n1=1,n0 = k,n2 = k-1
- 若完全二叉树有
2k-1
个(奇数)个结点,则 必有n1=0,n0 = k,n2 = k-1
比如看图:
上面的结论试题时候应用,尽量记住,一般选择,填空,判断
下面看看看树的结构咋定义的,还有树的存储结构
▶️ 3. ❗ ✨ 树的存储结构
首先我们回忆下树的逻辑结构:
3.1 ❗ 双亲表示法(顺序存储)
3.1.1 ❗ 定义
双亲表示法
:每个结点中保存指向双亲的“指针”
具体说: 在树中,除了根节点外的其余每个结点,它不一定有孩子,但是一定有且只有一个双亲
。
使用一组连续的存储空间来存放结点,结点按一定顺序(一般是从上到下,从左到右)依次存放在数组中,数组的下标表示了该结点的位置
,每个结点有一个数据域和一个指针域
,指针域
保存的是该结点的双亲结点在数组中的下标
比如下图:
-1 表示根结点,没有双亲。
3.1.2 ❗ 结构定义
其结构体定义如下:
#define MaxSize 100 // 树中最多结点数
typedef struct PTNode //树的结点结构
{
DataType data; //数据元素
int parent; //双亲位置域
}PTNode;
typedef struct PTree//树结构
{
PTNode nodes[MaxSize]; //结点maxsize个,双亲表示
int r,n; //根的位置和结点数
}PTree;
3.1.3 ❗ 增删改查
3.1.3.1 增加&删除第一种方法
- 假设原图是:
- 增加一个元素M: 就在数组后面加一个元素,位置域指向它的双亲结点H的位置3
- 删除一个元素G,删除数据元素=NULL,把位置域写入-1
3.1.3.2 删除第二种方法
- 还是上面那个图,删除完后,把尾部的L填入删除的元素位置
删除后还有结点数-1
但是如果上面那种删除的不是叶子结点,就不能这二种删除了
这样的话,就会删除掉D下面整个分支
所以需要先查找到它
3.1.3.3 查找
找到双亲结点很容易,看编号和位置域就行
但是如果要查它下面的子节点,就需要遍历数组,查询位置域为D编号3的所以结点
❗ 3.1.4 总结优缺点
因此,可以根据结点的parent
指针很容易找到其双亲结点,时间复杂度为 O(1),且当parent
为-1时,就找到了根,但是这种结构不利于寻找孩子且不利于表示结点间关系
因为删除时候空数据也会导致数据遍历慢
但是如果要查它下面的子节点,就需要遍历数组,查询位置域为D编号3的所有结点
3.2 ❗ 孩子表示法(顺序+链序)
孩子表示法:顺序存储各个节点,每个结点中保存孩子 链表头指针。
具体来说就是:
孩子表示法:可以将每个结点的孩子结点排列起来,以单链表作为存储结构,于是n个结点就有n个孩子链表
,若为叶子结点则此单链表为空,然后n个头指针又会组成n个线性表
,将其存放在一个一维数组中。其本质也是图的邻接表
结构
^表示空
因此上图中反映了两种结点结构:
- 一个是表头数组的表头结点。其中data是数据域,存放某结点的数据信息;firstchild是头指针域,存储该结点的孩子链表的头指针
- 一个是孩子链表的孩子结点。其中child是数据域,用来存储某个结点在表头数组中的下标;next是指针域,用于指向某结点的下一个孩子结点
用代码进行结构定义:
#define MaxSize 100
typedef struct CTNode//孩子结点
{
int child;//下标
struct CTNode* next;
]ChildPtr;
typedef struct CTBox //表头结构
{
DataType data;
ChildPtr* fistrchild;//头指针,第一个孩子
}CTBox
typedef struct CTree //整体树结构,其实就是一个数组,用来封装所有表头
{
CTBox Nodes[MaxSize];
int r,n; //结点的根和结点数目
}CTree;
上面的还可以更加改进
3.3 ❗ 孩子兄弟表示法(链序)
任意一棵树,其结点的第一个孩子如果存在那么就是唯一的,它的右兄弟如果存在也是唯一的。因此,设置两个指针,分别指向该结点的第一个孩子和此结点的右兄弟
如下,其中data是数据域,firstchild为指针域,存储该结点的第一个孩子结点的地址,rightsib是指针域,存储该结点的右兄弟结点的地址
代码表示结构体:
//树的存储——孩子兄弟表示法
typedef struct CSNode
{
DataType data;
struct CSNode* firstchild,*rightsib; //第一个孩子和右兄弟指针
}CSNode, *CSTree;
因此上面的树,采用这种方式实现如下
▶️ 4. ❗ ✨ 二叉树的存储结构
❗ 4.1 二叉树的顺序存储结构
❗ 4.1.1 结构定义
前面谈到了树的存储结构,大家可能也有体会:采用顺序结构存储树实现起来是比较困难的。但是对于二叉树并不是这样,因为它很特殊
二叉树顺序存储结构
:利用一维数组存储二叉树中的结点,并且结点的存储位置,也就是数组的下标要能体现结点之间的逻辑关系
,比如双亲与孩子的关系,左右兄弟的关系等等
如下的完全二叉树:
- 需要注意,如果结点不存在,可以设置为^
- 还需要注意可以让数组第一个位置空缺,保证数组下标和结点编号一致
❗ 4.1.2 代码表示
用代码表示如下:
#define MaxSize 100
typedef struct TreeNode
{
DatatType value;
bool isEmpty;//结点是否为空
}
TreeNode t[MaxSize]; //定义使用它
//定义一个长度为 MaxSize 的数组t ,按照从上至下、从左至右的顺序依次存储完全二叉树中的各个结点
初始化时候需要所有节点标记为空
for(int i=0;i<MaxSize;i++){
t[i].isEmpty=true;
}
❗ 4.1.3 完全二叉树的顺序存储基本操作
利用二叉树和完全二叉树的性质来完成下列操作
另外需要特别注意,顺序存储结构一般只用于完全二叉树,否则会导致空间浪费。数组结构特别适用于堆(堆本身就是完全二叉树)
❗ 4.1.4 二叉树顺序存储=完全二叉树
思考一个问题:如果不是完全二叉树,仍然按层序将各节点顺序存储,那么....
那么上面的操作无法实现:无法从结点编号反映 出结点间的逻辑关系
再思考如果不是完全二叉树,判断是否有左右孩子不能用上面的操作公式
只能使用isEmpty来进行判空
比如上面已经排好序号了,i=2时候,2i<12,但是2结点没左孩子
所以不能使用这个性质,可以使用isEmpty判空
五号结点,左孩子2i=10,isEmpty(10)=true,10结点为空,说明没有左孩子
❗ 4.1.5 二叉树顺序存储的复杂度
最坏情况:高度为h且只有h个结点的单支树(所有结点只有右孩子),也至少需要2^h-1个存储单元(如下:数组2^4-1=15个)
最后得出结论: 二叉树的顺序存储结构,只适合存储完全二叉树
因此实际使用时候,二叉树一般不使用顺序结构存储
❗ 4.2 二叉链表
- 二叉树的链式存储
二叉链表
:二叉树每个结点最多有两个孩子,所以为其设置一个数据域和两个指针域
,称这样的链表为二叉链表
如下,其中data为数据域,lchild和rchild都是指针域,分别指向该结点的左孩子和右孩子
其结点定义如下:
typedef struct BiTNode
{
DataType data;
struct BTNode* lchild;
struct BTNode* rchild;
}BTNode,*BiTree;
这里有条性质:n个结点的二叉链表共有n+1个空链域
如上图,空闲的链域有9个^ ,总共8个结点
▶️ 5. ❗ ✨ 二叉树的遍历(先序、中序、后序)
二叉树遍历(traversing binary tree):从根节点
开始,按照某种次序
依次访问二叉树中的所有结点,使得每个结点被访问一次且仅被访问一次
二叉树的遍历主要有三个遍历次序:
N:node根结点 | L:left 左结点 | R:Right 右结点
- 先序遍历:
根左右(NLR)
- 中序遍历:
左根右(LNR)
- 后序遍历:
左右根(LRN)
❗ 5.1 先序遍历——根左右(NLR)
若二叉树为空,则返回空,否则先访问根节点,然后先序遍历左子树,再先序遍历右子树
❗ 5.1.1 先序遍历算法:采用二叉树链表递归的方式
typedef struct BiTNode
{
DataType data;
struct BTNode* lchild;
struct BTNode* rchild;
}BTNode,*BiTree;
void PreOrder(BiTree T)
{
if(T==NULL) //若访问二叉树为空
return NULL;
//下面是二叉树不为空
visit(T); //访问根结点
PreOrder(T->lchild);//递归遍历左子树
PreOrder(T->rchild);//递归遍历右子树
}
❗ 5.2 中序遍历——左根右(LNR)
若树为空,则返回空,否则从根节点开始(注意并不是先访问根节点),中序遍历根节点的左子树,然后是访问根节点,最后中序遍历右子树
❗ 5.2.1 中序遍历算法:采用二叉树链表递归的方式
typedef struct BiTNode
{
DataType data;
struct BTNode* lchild;
struct BTNode* rchild;
}BTNode,*BiTree;
void InOrder(BiTree T)
{
if(T==NULL) //若访问二叉树为空
return NULL;
//下面是二叉树不为空
InOrder(T->lchild);//递归遍历左子树
visit(T); //访问根结点
InOrder(T->rchild);//递归遍历右子树
}
❗ 5.3 后序遍历——左右根(LRN)
若树为空,则返回空。否则从左到右先叶子后结点的方式遍历访问左右子树,最后根节点
❗ 5.3.1 后序遍历算法:采用二叉树链表递归的方式
typedef struct BiTNode
{
DataType data;
struct BTNode* lchild;
struct BTNode* rchild;
}BTNode,*BiTree;
void PostOrder(BiTree T)
{
if(T==NULL) //若访问二叉树为空
return NULL;
//下面是二叉树不为空
PostOrder(T->lchild);//递归遍历左子树
PostOrder(T->rchild);//递归遍历右子树
visit(T); //访问根结点
}
参考: (王道408考研数据结构)第五章树-第三节1:二叉树遍历(先序、中序和后序)
5.4 ✨ 二叉树的遍历(前缀后缀表达式)
使用二叉树的遍历可以很好的表示前缀后缀表达式
5.5 ✨ 求树的深度(应用)
int treeDepth(BiTree T){
if(T==NULL){
return 0;
}
else {
int l=treeDepth(T->lchild);
int r=treeDepth(T->rchild);
//树的深度=Max(左子树深度,右子树深度)+1
return l>r ? l+1:r+1;
}}
▶️ 6. ❗ ✨ 二叉树的层次遍历
层次遍历
:需要借助队列
完成。若树为空,则返回空,然后从上至下,从左至右
依次访问结点
算法思想:
- 初始化一个辅助队列
- 根结点入队
- 若队列非空,则队头结点出队,访问该结点,并将其左、右孩子插入队尾(如果有的话)
- 重复3直至队列为空
6.1 ✨ 分析理解过程
具体过程如下图:
6.2 ❗ ✨ 二叉树的层次遍历代码
void LevelOrder(BiTree T)
{
LinkQueue Q;
InitQueue(Q);
BiTree p;//辅助结点
EnQueue(Q,T);//先将根节点入队列
while(!isEmpty(Q)) //队列不为空
{
DeQueue(Q,p);//出队列,p拿到结点
visit(p); //访问出队结点
if(p->lchild!=NULL) //p的左孩子不为空
EnQueue(Q,p->lchild); //左孩子入队
if(p->rchild!=NULL)
EnQueue(Q,p->rchild);
}
}
其他的结构定义如下:
//二叉树的结点
typedef struct BiTNode
{
DataType data;
struct BTNode* lchild;
struct BTNode* rchild;
}BTNode,*BiTree;
//链式队列结点
typedef struct LinkNode{
BiTNode *data; //存指针而不是结点,节省空间 本来是char data
struct LinkNode *next;
}LinkNode;
typedef struct{
LinkNode *front,*rear;
} LinkQueue;
上面了解了基本的遍历过程,下面反过来,咋样用遍历顺序构造二叉树
▶️ 7. ❗ ✨ 二叉树的构造
若只给出一棵二叉树的 前/中/后/层 序遍历序列中的一种,不能唯一确定一棵二叉树
所以给出二种可以确定
7.1 ✨ 前序+中序构造二叉树
基本思想:前序遍历可以确定一个子树的根节点,而中序遍历可以在此基础上,依据该结点再次划分为左右子树
举个例子:
- 前序遍历序列:ABDECFGH
- 中序遍历序列:DBEACGFH
分析过程如下:
- 首先根据前序遍历确定这棵树的根节点为A,然后根据中序遍历确定A的左右子树在中序遍历中的范围(DBE是左子树的,CGFH是右子树的)
- 下一步 根据 前序遍历可得到左右子树的根节点为B和C
- 剩下的跟上面一样,通过中序遍历得D是左子树,E是右子树 , CGFH,右子树只有右孩子GFH,再通过前序FGH得到F是根结点,中序GFH得到G是左孩子,H是右孩子。
7.2 ✨ 后序+中序构造二叉树
基本思想:后序遍历可以确定一个子树的根节点,而中序遍历可以在此基础上,依据该结点再次划分为左右子树
-
后序遍历序列:E F A H C I G B D
-
中序遍历序列:E A F D H C B G I
-
通过后序得到根结点是D
- 通过后序得到EFA的跟结点是A,中序EAF,左根右
- 同理得到右边的根结点是B,中序HCBGI ,左根右
- 同理后序HC,c是根结点,后序HC,H是左结点。前序IG,G是根结点,中序GI,I是右结点
下面如果是层次遍历咋办?
7.3 ✨ 层次+中序构造二叉树
基本思想:层次遍历可以确定一个子树的根节点,而中序遍历可以在此基础上,依据该结点再次划分为左右子树
-
层序遍历序列:D A B E F C G H I
-
中序遍历序列:E A F D H C B G I
-
根据层次遍历 先得到D是根结点
- 再根据层次遍历AEF,A是结点,右边B开头,B是结点。根据中序遍历HCBGI,左边是HC,右边是GI
- 根据中序EAF,左根右。HC根据层次CH,C应该是根结点,再看中序HC,H是左结点。GI看层次GI,G是根结点。再看中序GI,I是右结点
7.4 总结(代码实现见专栏C语言实现)
前/后/层次 遍历 + 中序遍历
可以确定一个二叉树
除了这几个其他的排列不可以
▶️ 8. ❗ ✨ 二叉树的重建
输入一个字符串代表一个二叉树的先序遍历结果,其中#
代表空结点,请建立这棵二叉树,并输出其中序遍历结果
解决: 实则是一个递归过程。每遇到一个新节点,就把它当做先序遍历的根节点进行构造,遇到#
就为NULL
,当一个结点的左右子树构造完成时,可以将该节点连接到上方结点,作为上一个结点的孩子结点
二叉树的重建代码见C语言实现专栏
▶️ 9. ❗ ✨ 线索二叉树
9.1 前言(需要线索二叉树的情景)
相比于链表,二叉树的递归结构为其操作带来了一定的便利。如下二叉树的中序遍历结果为 D − G − B − E − A − F − C
我们都明白中序遍历是递归的,但是现在如果让此二叉树从结点 B 开始中序遍历却无法办到,因为对于一颗二叉树来说,用户只能拿到它的根节点,其余所有结点均需要通过遍历完成
那么如果将 D − G − B − E − A − F − C 装入一个链表呢?这样的话这些结点之前就形成了特定的前后关系,也就是中序遍历关系,那么后续对于任意结点的操作就无需在重复遍历下进行,而只需要访问这样一个特定的序列就行了
- 比如对于结点 D 的访问,采用中序遍历的情况下,它要重复访问三次(第二次访问时进行操作),而如果这样的中序遍历关系被链表保存了,那么就是只是一次访问了
9.2 ❗ 线索二叉树基本概念
线索二叉树
:为了充分利用空间
和保存特定遍历情况下前后结点的关系
,我们用指针指向某个结点的前驱和后继,这样的指针称之为线索
,加上线索的二叉链表称之为线索链表
,相应的二叉树就称之为线索二叉树(Threaded Binary Tree)
-
如下将下面的二叉树中序遍历后( H − D − I − B − J − E − A − F − C − G),把所有指向NULL的rchild指向其
后继结点
。
所以 H 的后继就是 D (①);所以 I的后继就是 B (②);所以 J 的后继就是 E(③);所以 E的后继就是 A (④);所以 F 的后继就是 C(⑤); G 的后继没有所以是NULL(⑥)
-
如上将下面的二叉树中序遍历后( H − D − I − B − J − E − A − F − C − G),把所有指向NULL的lchild指向其
前驱结点
; H 无前驱,因此是NULL;所以 I的前驱是 D(②);所以 J的前驱是 B(③);所以 F的前驱是 A(④);所以 G的前驱是 C (⑤) -
总共有10个结点,相应就有11个空指针域,这样11个空指针就被完美利用了起来
二叉树的线索化
:通过上面的描述我们可以感受到,线索二叉树等于是把一棵二叉树变成了一个双向链表
。因此我们对二叉树以某种次序遍历使其变为线索二叉树
的过程称作为线索化
如图:
- 找到指定节点p在中序遍历中的前驱,当p=q时候,pre为前驱
- 找到指定节点p在中序遍历中的后继节点,当p=pre时候,q指向下个为后继
- 总结: 线索化
9.3 ❗ ✨ 线索二叉树的存储结构
他是一个双向链表:
typedef struct BLNode
{
DataType data;
struct BLNode* lchild,*rchild;
}BLNode,*ThreadTree;
但是这样的结构有一个严重的问题:无法区分lchild是指向前驱结点还是左孩子又或者rchild是指向后继结点还是右孩子
- 如下图结点 E的lchild指向了它的左孩子J,但是rchild却指向的是它的后继A
所以我们需要线索二叉树存储结构
:显然我们在决定lchild和rchild的指向时,是需要一个区分标志
的.
- 因此我们在每个结点上再增设
两个标志域ltag和rtag
,ltag和rtag只存放0或1
。如下图
- ltag==0时指向
左孩子
; ltag==1时指向前驱
- rtag==0时指向
右孩子
; rtag==1时指向后继
所以正确的线索二叉树结构定义应如下
typedef struct BLNode
{
DataType data;
struct BLNode *lchild,*rchild;
int ltag;
int rtag;
}
最后的结构图如下:
▶️ 10. ❗ ✨ 线索二叉树的线索化
线索化的实质就是将二叉链表中的空指针改为指向前驱或后继的线索。由于前驱和后继的信息只有在遍历该二叉树时才能得到,所以线索化的过程就是在遍历的过程中修改空指针的过程
先举个例子吧:用遍历找到中序前驱
10.1 用遍历找到中序前驱
还是这张图,找p指向的结点F的前驱就是A
土方法就是直接重新遍历一遍得到中序遍历,找到F前面的A
10.1.1 执行过程
10.1.2 代码
typedef struct BiTNode
{
DataType data;
struct BTNode* lchild;
struct BTNode* rchild;
}BTNode,*BiTree;
void InOrder(BiTree T) //中序遍历
{
if(T==NULL) //若访问二叉树为空
return NULL;
//下面是二叉树不为空
InOrder(T->lchild);//递归遍历左子树
visit(T); //访问根结点
InOrder(T->rchild);//递归遍历右子树
}
//访问结点q 找到q
void visit(BiTNode *q){
if(q==p) //当前访问结点刚好是结点p
final=pre; //找到p的前驱
else
pre=q; //pre指向当前访问的结点
}
//辅助全局变量,用于查找结点p的前驱
BiTNode *p; //p指向目标结点
BiTNode *pre=NULL; //指向当前访问结点的前驱
BiTNode *final=NULL; //用于记录最终结果
10.2 ❗ ✨ 中序线索二叉树
对上边的遍历查找进行修改。
10.2.1 ✨ 结构定义
所以:
//线索二叉树结点
typedef struct ThreadNode{
ElemType data;
struct ThreadNode *lchild,*rchild;
int ltag,rtag; //左右线索标志
}ThreadNode,*ThreadTree;
相当于就加了个左右线索标志
10.2.2 ✨ 中序线索化流程
- q指向第一个结点D,pre代表前继结点指向NULL
- 看ltag和rtag, 刚开始都等于0代表左右孩子。现在进入遍历函数,visit,q的左子树为空,建立前驱线索ltag=1; pre成为q的前驱:q->lchild=pre;
- 然后pre=q,让pre=q。以便于访问下一个结点
- inThread访问下一个结点 visit
- q的左子树为空,建立前驱线索ltag=1; pre成为q的前驱:q->lchild=pre;
- 访问下一个结点。B 有左右孩子,但是pre前驱不为空且右孩子为空
- 设置后继线索 pre->rchild=q; pre的rtag=1
- 后续如动画
- 注意当最后一个结点后,pre=q=c时候,之后不会有结点被visit。但是有个问题,这里C即pre的右孩子为空,没有线索化。所以
最后要检查pre->rchild是否为空,如果,则令rtag=1;
。即对最后一个线索化
10.2.3 ✨ 中序线索化代码
-
全局变量pre,指向当前结点的前驱
ThreadNode *pre=NULL;
-
中序遍历二叉树,一边遍历一边线索化
void InThread(ThreadTree T){
if(T!=NULL){
InThread(T->lchild); //中序遍历左子树
visit(T); //访问根结点
InThread(T->rchild); //中序遍历右子树
}
}
- 访问根节点visit函数 建立线索
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;
}
❗ 汇总代码
//线索二叉树结点
typedef struct ThreadNode{
ElemType data;
struct ThreadNode *lchild,*rchild;
int ltag,rtag; //左右线索标志
}ThreadNode,*ThreadTree;
ThreadNode *pre=NULL;//全局变量pre,指向当前结点的前驱
void CreateInTread(ThreadTree T){
pre=NULL; //pre初始化为空
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 InThread(ThreadTree p,ThreadTree &pre){
if(p!=NULL){
InThread(p->lchild,pre); //递归,线索化左子树
if(p->lchild==NULL){ //如果左子树为空,建立前驱线索
p->lchild=pre;
p->ltag=1;
}
if(pre!=NULL && pre->rchild==NULL){ //没有右孩子
pre->rchild=p; //建立前驱结点的后继线索
pre->rtag=1;
}
pre=p; //让pre始终指向p,作为前驱
InThread(p->rchild,pre); //递归,线索化右子树
}
10.3 ❗ ✨ 前序线索二叉树
根据上面的流程,但是只需要修改遍历的顺序,根左右
✨ 可能遇到的问题
当pre指向B时候,q指向D时候,这时候q->lchild==NULL,所以建立前驱线索, q->lchild指向pre即B,就如上图那样。
- 但是if结束后,pre=q,pre指向q即D。这样的话
这样的话就形成了一个循环了,处理D左子树,q结点再一次指回B,循环圈了
❗ 改进后的代码
使用ltag判断它是不是前驱线索还是左子树结点
也就是
//前序遍历二叉树,一边遍历一边线索化
void PreThread(ThreadTree T){
if(T!=NULL){
visit(T); //访问根结点
if(T->ltag==0) //lchild不是前驱线索
PreThread(T->lchild); //前序遍历左子树
PreThread(T->rchild); //前序遍历右子树
}
}
❗ 总体代码
❗ 王道书上前序线索化代码
//先序线索化
void PreThread(ThreadTree p,ThreadTree &pre){
if(p!=NULL){
if(p->lchild==NULL){ //如果左子树为空,建立前驱线索
p->lchild=pre;
p->ltag=1;
}
if(pre!=NULL && pre->rchild==NULL){ //没有右孩子
pre->rchild=p; //建立前驱结点的后继线索
pre->rtag=1;
}
pre=p; //让pre始终指向p,作为前驱
if(p->ltag==0) //如果p结点是孩子
PreThread(p->lchild,pre);// 递归,线索化左子树
PreThread(p->rchild,pre); //递归,线索化右子树
}
//先序线索化二叉树T
void CreatePreThread(ThreadTree T){
ThreadTree pre=NULL; //初始化为空
if(T!=NULL){ //非空
PreThread(T,pre); //线索化二叉树
if(pre->rchild==NULL) //处理遍历的最后一个结点
pre->rtag=1;
}
}
10.3 ❗ ✨ 后序线索二叉树
后序因为是左右根,不会根处理完再去访问左子树,所以不会有转圈问题。
王道的风格写法是:
void PostThread(ThreadTree p,ThreadTree &pre){
if(p!=NULL){
PostThread(p->lchild,pre); //递归,线索化左子树
PostThread(p->rchild,pre); //递归,线索化右子树
if(p->lchild==NULL){ //如果左子树为空,建立前驱线索
p->lchild=pre;
p->ltag=1;
}
if(pre!=NULL && pre->rchild==NULL){ //没有右孩子
pre->rchild=p; //建立前驱结点的后继线索
pre->rtag=1;
}
pre=p; //让pre始终指向p,作为前驱
}
}
//后序线索化二叉树T
void CreatePreThread(ThreadTree T){
ThreadTree pre=NULL; //初始化为空
if(T!=NULL){ //非空
PostThread(T,pre); //线索化二叉树
if(pre->rchild==NULL) //处理遍历的最后一个结点
pre->rtag=1;
}
}
▶️ 11. ❗ ✨ 树与二叉树和森林的转换
11.1 ❗ ✨树转换为二叉树
树转化为二叉树的步骤如下
加线
:在所有兄弟结点之间加一条连线去线
:对树中的每一结点,只保留它与第一个孩子结点的连线,删除它与其他孩子之间的连线层次调整
:以树的根节点为轴心,将整棵树顺时针旋转一定的角度,使结构层次分明。需要注意的是第一个孩子是二叉树结点的左孩子,兄弟转换过来的孩子是结点的右孩子
如下:
11.2 ❗ ✨ 森林转换为二叉树
森林转化为二叉树的步骤如下
- 每棵树按照上面的方法转化为二叉树
- 第一棵二叉树不动,从第二棵二叉树开始,依次把后一棵二叉树的根节点作为前一棵二叉树的根节点的
右孩子
,用线连接起来。当所有二叉树连接起来后就森林就转化为了一棵二叉树
11.3 ❗ ✨ 二叉树转换为树
二叉树转为树的步骤如下
- 加线:若某结点的左孩子存在,则将这个左孩子的右孩子结点、右孩子的右孩子结点…也即
左孩子的n个右孩子结点都作为此结点的孩子
。然后该结点与这些孩子结点用线连接起来 - 去线:删除原二叉树中所有结点与其右孩子结点的连线
- 层次调整
11.4 ❗ ✨ 二叉树转化为森林
二叉树转化为森林步骤如下
- 从根节点开始,若右孩子存在,则把与右孩子结点的连线删除,再查看分离后的二叉树,若右孩子存在,则连线删除…,直到所有右孩子连线都删除为止,得到分离的二叉树
- 再把
每一颗分离后的二叉树转化为树
即可
注意:还需要把分离的二叉树转换为树
如下图:
▶️ 12. ❗ ✨ 树与森林的遍历
12.1 ❗ ✨ 树的遍历
12.1.1 ❗ ✨ 树的先序遍历
先序遍历
:若树不空,先访问根节点,然后依次对每一棵子树进行先序遍历
- A−B−E−K−F−C−G−D−H−I−J
如果将此树转化为与之对应的二叉树,会发现树的先序遍历正对应其二叉树的先序遍历
12.1.2 ❗ ✨ 树的后序遍历
后序遍历
:若树不空,依次对每一棵子树进行后序遍历,最后访问根节点
- K−E−F−B−G−C−H−I−J−D−A
如果将此树转化为与之对应的二叉树,会发现 树的后序遍历正对应其二叉树的中序遍历
12.1.3 ❗ ✨ 树的层次遍历
层次遍历
:步骤如下
- 若树非空,则根节点入队
- 若队列非空,队头元素出队并访问,同时将该元素的孩子依次入队
- 重复步骤②直至队列为空
- A−B−C−D−E−F−G−H−I−J−K
12.2 ❗ ✨ 森林的遍历
12.2.1 ❗ ✨ 森林的先序遍历
先序遍历
:简单点说就是依次对每一棵树进行先序遍历
- 访问森林中第一棵树的根节点
- 先序遍历第一棵树中根节点的子树森林
- 先序遍历除去第一棵树之后剩余的树构成的森林
- B−E−K−L−F−C−G−D−H−M−I−J
如果将此森林转化为与之对应的二叉树,会发现森林的前序遍历正对应其二叉树的前序遍历
12.2.2 ❗ ✨ 森林的中序遍历
中序遍历
:简单点说就是依次对每一棵树进行后序遍历
- 中序遍历森林中第一棵树的根节点的子树森林
- 访问第一棵树的根节点
- 中序遍历除去第一棵树之后剩余的树构成的森林
- K−L−E−F−B−G−C−M−H−I−J−D
如果将此森林转化为与之对应的二叉树,会发现森林的中序遍历正对应其二叉树的中序遍历
可以看得出,树、二叉树和森林在遍历上存在等价关系
树 | 二叉树 | 森林 |
---|---|---|
先序遍历 | 先序遍历 | 先序遍历 |
后序遍历 | 中序遍历 | 中序遍历 |
▶️ 13. ❗ ✨ 二叉排序树及操作
13.1 ❗ ✨ 二叉排序树基本概念
二叉排序树(Binary Sort Tree)
::又称之为二叉搜索树
,它具有下面的性质
- 若其左子树不空,则左子树上所有结点的值均
小于
根结点的值 - 若其右子树不空,则右子树上所有结点的值均
大于
根结点的值 - 其左、右子树也分别是二叉排序树
由以上性质可知,二叉排序树的中序遍历
是一个递增序列
13.2 ❗ ✨ 二叉排序树查找
✨ 13.2.1 基本概念
二叉排序树查找
:若树非空,让目标值与根节点的值进行比较。查找成功返回结点指针,失败则返回NULL
- 如果相等,那么
查找成功
- 如果小于,则在
左子树
上继续查找 - 如果大于,则在
右子树
上继续查找
举个例子: 查找93
✨ 13.2.2 代码
✨ 代码如下(递归实现)
其中bstSearch(BSTNode* root,int key,BTNode* f,BTNode* p)
函数调用时的语句为bstSearch(root,94,NULL,p)
root
是二叉链表key
代表待查询关键字,目前要查询93- 指针
f
指向root
的双亲。且当root
指向根节点时,f
初值为NULL
- 指针
p
为是为了在查询成功时获取结点位置
1:bool bstSearch(BSTNode* root,int key,BTNode* f,BTNode*& p)
2:{
3: if(root==NULL)
4: {
5: *p=f;
6: return false;
7: }
8: else if(key==root->data)
9: {
10: *p=root;
11: return true;
12:}
13: else if(key<root->data)
14: return bstSearch(root->lchild,key,root,p);
15:else
16: return bstSearch(root->rchild,key,root,p);
}
执行过程如下
-
第3~7行:用于判断现在是否已经到叶子结点了。第一次进入时
root
指向了62的位置,这一语句块不执行。 -
第8~12行:用于查找成功时返回位置。此时62 ≠ 93,故这一语句块不执行。
-
第13~14行:用于待查询关键字小于当前结点时执行。由于此时93>62,故这一语句块不执行
-
第15~16行:用于待查询关键字大于当前结点时执行。由于此时93>62,所以需要递归调用
bstSearch(root->rchild,key,root,p)
。此时root
指向的是62,故第一个参数要传入62的右孩子88,进入下一层递归后root
就指向了88
- 此时来到了下一层的
bstSearch
,由于93>88,所以会执行第16行,再次递归调用bstSearch(root->rchild,key,root,p)
。于是root
就指向了99
- 接着来到了第三层的
bstSearch
,由于93<99,所以会执行第14行,递归调用bstSearch(root->lchild,key,root,p)
,于root
是就指向了93
✨ 代码如下(非递归实现)
//二叉链表树节点
typedef struct BSTNode{
int key;
struct BSTNode *lchild,*rchild;
}BSTNode,*BSTree;
BSTNode *BST_Search(BSTNode* root,int key)
{
while(root!=NULL&&key!=root->key){ //若树空或等于根结点值,则结束循环
if(key<root->key) root=root->lchild; //小于,则在左子树上查找
else root=root->rchild; //大于,则在右子树上查找
}
return root;
}
❗ 区别
空间复杂度不一样,递归实现需要申请个新节点,不如非递归效率好
13.3 ❗ ✨ 二叉排序树插入/构建
✨ 13.3.1 二叉排序树的插入
演示:插入关键字为62的结点:
二叉排序树插入
:若原二叉排序树为空,则直接插入结点;否则,若关键字k小于根结点值,则插入到左子树,若关键字k大于根结点值,则插入到右子树
递归实现
//在二叉树排序树插入关键字为k的新节点(递归实现) 空间复杂度为0(h)
int BST_Insert(BSTree &T,int k){
if(T==NULL){ //原树为空,新插入的结点为根结点
T=(BSTree)malloc(sizeof(BSTNode));
T->key=K;
T->lchild=T->rchild=NULL;
return 1; //返回1表示插入成功
}
else if(k==T->key) //树中存在相同关键字的结点,插入失败
return 0;
else if(k<T->key) //如果k小于,插入左子树
return BST_Insert(T->lchild,k);
else
return BST_Insert(T->rchild,k); //如果k大于,插入T的右子树
}
非递归实现
//非递归建立二叉排序树
BSTNode* nonRecusInsertNode(BSTree &T,ElemType key) {
BSTNode* p = T; //用来查找
BSTNode* q=NULL; //用来指明当前插入位置的父节点
//此处用来寻找插入的位置和当前插入位置的父节点
//q记录插入位置的父节点用来连接插入的孩子
while (p != NULL) {
if (p->key == key) {
return NULL; //已经存在,插入失败
}
else if (p->key > key) {
q = p;
p = p->lchild; //找到对应插入为NULL的位置
}
else {
q = p;
p = p->rchild;
}
}
//初始化一个插入结点
p = (BSTNode*)malloc(sizeof(BSTNode));
p->key = key;
p->lchild = NULL;
p->rchild = NULL;
//将插入结点与之父节点相连
if (!q) {//q为空 //要插入根节点,直接用T指针相连
T = p;
}
else if (q->key > key) { //插入父节点的左边,将父节点的左孩子指向插入的结点
q->lchild = p;
}
else q->rchild = p; //插入父节点的右边,将父节点的右孩子指向插入的结点
return p;
✨ 13.3.2 二叉排序树的构建
二叉排序树构建
:有了插入操作,构建就是调用插入函数将结点一个个插入的过程,比如
int i;
int a[10]={21,28,14,32,25,18,11,30,19};
BTNode* root=NULL;
for(int i=0;i<10;i++)
{
bstInsert(root,a[i]);
}
类似这样.
13.4 ❗ ✨ 二叉排序树删除
二叉排序树的删除操作需要仔细分析,因为插入操作能保证每次插入后仍然是一颗二叉排序树,但是删除操作可能导致整个树的特性发生变化
二叉树排序树删除某结点时需要考虑三种情况:
- 待删除结点为叶子结点
- 待删除结点的左子树或右子树为空
- 待删除结点 的左子树和右子树都存在
当然叶子结点可以归结为左子树为空或右子树为空那一种情况,因此共有 左为空,右为空和左右都不为空
这么三种情况。
✨ 13.4.1 如果左子树为空
处理办法:如果待删除结点左子树为空,那么让父亲的左子树或者右子树指向删除节点的右子树
- 需要注意,如果删除的是根结点,那么就让根结点的右孩子结点直接作为根结点
✨ 13.4.2 如果右子树为空
处理办法:如果待删除结点右子树为空,那么让父亲的左子树或者右子树指向我的左子树
✨ 13.4.3 如果左右子树都不为空
处理办法:从要删除的结点位置开始,寻找左子树的最右结点(也就是左子树的最大结点)或右子树的最左结点(也就是右子树的最小节点)替代要删除的结点。替代后,这个问题就转化为了删除左为空或右为空的结点了
如下:
-
这里删除根结点5。
-
首先寻找5的右子树的最小结点,是6,用
submin
标记,同时记录6的父亲结点7,用submin_pre
标记; -
然后将submin处的6直接赋值给要删除的结点5,这样结点5等于就删除了
-
接着只需要将
submin
删除即可。在这种情况下找到的submin
一定满足左子树为空,所以符合上面的那种情况,删除后让其父亲结点的左子树或右子树连接到它的右子树11即可
- 第二种方案就是找左子树的最右下节点,也就是最大结点,进行替换
比如这个删除50,找左子树最大结点,替换
❗ 13.4.4 代码
bool bstDelete(BTNode* root,int key)
{
//首先进行查询工作
Node* pre=NULL;
Node* cur=root;
while(cur)
{
if(key>cur->data)
{
pre=cur;
cur=cur->right;
}
else if(key < cur->data)
{
pre=cur;
cur=cur->left;
}
else//找到了要删除的结点
{
//情况1:左子树为空
if(cur->left==NULL)
{
if(cur==root)//特判:待删除结点为根节点
{
root=cur->right;//右子树直接作为根节点
free(cur);
}
else//正常情况
{
//让父亲的左子树或右子树指向待删除结点的右子树
if(pre->left==cur)//如果父亲左子树不空
pre->left=cur->right;//父亲左指向我的右子树
else//如果父亲右子树不空
pre->right=cur->right;//父亲右指向我的右子树
}
}
//情况2:右子树为空
else if(cur->right==NULL)
{
if(cur==root)
{
root=cur->left;
free(cur)
}
else
{
if(pre->left==cur)
pre->left=cur->left;
else
pre->right=cur->left;
}
}
//情况3:
else
{
BTNode* submin_pre=cur;//指向待删除结点
BTNode* submin=cur->right;//准备寻找右子树最小结点
while(submin->left)
{
submin_pre=submin;
submin=submin->left;
}
//此时submin找到了待删除结点cur的右子树的最小结点
//下面进行具体操作替换操作
cur->key=submin->key;
//此时待删除结点的cur就被替换到了submin的位置
//在这种情况下,submin左子树一定为空(相反如果按照寻找左子树最大结点进行,那么submin右子树一定为空)
//所以现在的逻辑等同于删除一个左子树为空的结点
if(submin_pre->left=submin)//如果父亲结点左不空
submin_pre->left=submin->right;//那么就让其指向待删除的右子树
else
submin_pre->right=summin->right;
free(submin);
}
return true;
}
}
return false;
}
参考:(王道408考研数据结构)第五章树-第四节1:二叉树排序树(BST)及其操作
✨ 13.4.5 查找效率分析
查找长度
查找长度
――在查找运算中,需要对比关键字的次数称为查找长度,反映了查找操作时间复杂度
- 如下图查找70,需要查3次
❗ 查找成功平均查找长度ASL
如果需要分析整个二叉排序树的查找效率,就需要平均查找长度ASL
如上图的ASL=1*1 (解释下 第1层要查1次,有1个结点) / 几个结点
-
ASL=(1 * 1 + 2 * 2 + 3 * 4 + 4 * 1)/8 =2.625
-
例子:
❗ 总结
-
它查找再坏也不会大于它的高度,比如上面的是7,即最坏时间复杂O(n)
-
最好情况: n个结点的二叉树最小高度为[ log_2 n」+1。平均查找长度=o(log_2 n)
-
所以尽量让他接近最小高度为[ log_2 n」+1 ,时间复杂度低,效率高
❗ 查找失败平均查找长度ASL
比如:
查找失败只能有以下几种。
失败的asl=(查找长度*层个数) /结点数
-
ASL=(3 * 7+ 4 * 2)/9=3.22
-
例子:
▶️ 14. ❗ ✨ 平衡二叉树AVL及其旋转
14.1 ❗ ✨ 平衡二叉树AVL概念
✨ 14.1.1 判断和概念
二叉排序树有一个缺陷:树的高度会直接影响其查找效率,且树越高效率越差,效率最差时为一棵单分支树
平衡二叉树就是尽可能"胖",解决这个问题。
平衡二叉树
(Self-Balancing Binary Search Tree):它首先是一颗二叉排序树
,其中每个节点的左右子树高度之差绝对值不超过1
。将二叉树上结点左子树和右子树高度之差称之为平衡因子BF
(Balance Factor),因此,平衡因子BF的取值只可能是-1、0和1中的一种
- -1:表示该结点左子树高度小于右子树高度
- 0:表示该结点左子树高度等于右子树高度
- 1:表示该结点左子树高度大于右子树高度
以下是一些例子:
- ①是AVL树
- ②不是AVL树,因为
AVL树前提必须首先是二叉排序树
- ③不是AVL树,
因为结点58左子树高度为2,而右子树为高度为0,BF>1
- ④是AVL树
✨ 14.1.2 结构定义
typedef struct AVLNode{
int key; //数据域
int balance; //平衡因子
struct AVLNode *lchild,*rchild;
}AVLNode,*AVLTree;
14.2 ❗ ✨ 平衡二叉树AVL的插入
在二叉排序树中插入新结点后,如何保持平衡?
如图不符合要求了。
✨ 14.2.1 最小不平衡子树
最小不平衡子树
:距离插入点最近的,且平衡因子绝对值大于1的结点为根的子树
- 也就是
从插入点往回找到第一个不平衡结点,以该结点为根的子树。
如上图,就是70-68-67的子树。
✨ 14.2.2 旋转恢复平衡
在插入操作中,只要将最小不平衡子树调整平衡,则其他祖先结点都会恢复平衡
总共四种形式。
✨ 1 LL-> 右单旋转调整
下图中为抽象树,三角形表示的树为高度平衡的二叉树排序树。如下情况中,结点A的平衡因子绝对值为1,左子树较高
此时来了一个新的结点恰好插入到了B结点的左树,导致A结点的平衡因子变为2,树不平衡,需要进行调整
调整时:将结点A下移一个高度,B上移一个高度,然后把B的右子树挂在A的左子树处(这样做可以保证二叉排序树的特性)
✨ 2 RR-> 左单旋转调整
左单跟上面相反,是右孩子的右子树下面出入一个新节点
左单调整和右单调整情况恰好相反,调整时结点B上移,结点A下移,让结点B的左子树做结点A的右子树
✨ 3 LR-> 先左后右双旋转
如果此时将新结点插入到较高左子树的右侧
此时如果继续使用右单旋转调整,你会发现怎么也调整不过去,依然不平衡
在这种情况下就要使用到双旋转调整了
看下面
-
比如 下面这个例子:
-
首先把增加结点的那个右子树BR的根节点定义为C
- 左旋C
- 右旋C
-
成功满足
-
其他例子:
✨ 4 RL-> 先右后左双旋转
在左单旋转调整中,面对的情况是新节点插入到了较高右子树的右侧,而如果新节点插入到了较高右子树的左侧,那么就要使用先右后左双旋转调整
- 跟上面一样,让新插入的根节点为C,进行选择
- 先右旋
- 再左旋
- 完成
- 其他例子:
❗ 总结
通关左边的选择得出,
只有左孩子能进行右上旋,右孩子才能左旋
❗❗ 题目练习总结:
首先看题目图:插入个67
-
- 每次调整这个二叉树的是调节它的
最小不平衡子树
。
- 每次调整这个二叉树的是调节它的
这里也就是从67这个插入点查找,到70不平衡。
-
- 所以看70-68-67这个子树
-
是LL型的,也就是左子树的左孩子插入结点
-
- 进行旋转调整
-
- 结果:
再看下这个
- 题目:
-
最小不平衡子树从63到50,也就是整个树
-
然后判断它是右子树的左孩子,也就是RL型号
-
找到50下面右孩子的左子树,66
- 进行旋转调整
- 完成,检查左边的小于右边的
关于代码实现参考: 数据结构—AVL树
▶️ 15. ❗ ✨ 哈夫曼树
15.1 ✨ 带权路径长度
为了方便介绍,有一些术语大家是必须要明白的
结点的权值
:是一种数值,该数值代表了某种含义(比如说可以代表结点的重要性)
结点的带权路径长度
:从树的根结点到该结点的路径长度(也就是经过多少条边)与该结点上权值的乘积
树的带权路径长度
:树中所有叶子结点
的带权路径长度之和,计算公式为:
-
WPL=2∗(1+3+4+5)=26
-
W P L = 1 ∗ 5 + 2 ∗ 4 + 3 ( 1 + 3 ) = 25
-
W P L = 2 ∗ 4 + 3 ∗ ( 3 + 1 ) + 1 ∗ 5 = 25
-
W P L = 2 ∗ 3 + 3 ∗ ( 5 + 4 ) + 1 ∗ 1 = 34
15.2 ❗ ✨ 哈夫曼树定义
哈夫曼树
:给定n个结点,其权值分别为{w 1 , w 2 , . . . , w n },构造一棵有n个叶子结点的二叉树,这样的二叉树可能有多个,我们把其中树的带权路径长度WPL最小的那个二叉树
称作为哈夫曼树
,也叫做最优二叉树
所以上面第二和第三个都是哈夫曼树
15.3 ❗ ✨ 哈夫曼树的构造
哈夫曼树构造方法
:给定n个权值{ w 1 , w 2 , . . . , w n}的结点,构造哈夫曼树的算法如下:
- 将这n个结点分别作为 n棵
仅含有一个结点的二叉树
,构成森林 F - 构造一个新的结点,从F中选取
两棵根结点权值最小的树
作为新结点的左右子树,并将新结点的权值置为左右子树上根结点的权值之和
- 从 F中
删除
刚才选出的两棵树,同时将新得到的树纳入F中 - 重复步骤 2和步骤 3,直到F中只剩下一棵树为止
下面是构造一个哈夫曼树的过程:
- WPL=1∗7+4∗(1+2)+3∗2+2∗3=31
15.4 ❗ ✨ 哈夫曼树的特点
从上面的构造过程中,我们可以总结出哈夫曼树有如下特点:
- 每个
初始结点最终都会成为叶子结点
,并且 权值越小的结点到根结点的路径长度越大 - 哈夫曼树
结点总数为 2 n − 1
- 哈夫曼树中不存在度为1的结点
- 哈夫曼树并不唯一,但是 WPL必然相同
对于第4点,上面的哈夫曼树还可以这样构造,但是它们的WPL都是31
15.5 ❗ ✨ 哈夫曼编码
固定长度编码--每个字符用相同长度的二进制位表示
也可以换成树是这样的
实际就是计算树的带权路径长度。
有没有比上面的编码方法更优秀的。
可变长度编码
——允许对不同字符用不等长的二进制位表示
比如现在用这些结点构造哈夫曼树
用左边表示0,右边表示1
就可以表示字母
此时的树带权路径长度WPL=130
- 检测:CAAABD:0101010111110
这种形式不行:把它作为分支结点或根结点
所以通过这二个的区别:
若没有一个编码是另一个编码的前缀,则称这样的编码为 前缀编码
所以哈夫曼编码就是:
哈夫曼编码——字符集中的每个字符作为一个叶子结点
,各个字符出现的频度
作为结点的权值
,根据之前介绍的方法构造哈夫曼树。
▶️ 16. ❗ ✨ 并查集
16.1 集合概念
- 全集
- 子集
- 用代码表示:用不同森林表示
- 查找属于哪个集合:
从指定元素除法,一路向北,找到根结点
- 如何判断二个元素同属于一个集合:
看二个查找的根结点是否相同即可
。
- 如何把两个集合合并:
其中一个树成为另一个树的子树即可
所以并查集要实现 并,查,和集合结构
16.2 ❗ 结构定义
使用的树的结构是双亲表示法(顺序结构)
#define MaxSize 100 // 树中最多结点数
typedef struct PTNode //树的结点结构
{
DataType data; //数据元素
int parent; //双亲位置域
}PTNode;
typedef struct PTree//树结构
{
PTNode nodes[MaxSize]; //结点maxsize个,双亲表示
int r,n; //根的位置和结点数
}PTree;
使用双亲表示法很容易找到,查:一路向北,找到根结点。并:把一颗树的根结点指向另一个树的根结点,成为子树
例子如下:
- 定义数组
-
查:从叶子结点开始一路向北,找数组=-1的结点
-
并:让一颗树的数组内容指向另一个树的数组下标
-
比如C的S【】变为0,指向A的数组下标,成为它子树
16.2.1 ❗ 并查集结构代码 初始化
#define SIZE 13
int UFSets[SIZE]; //集合元素数组
//初始化并查集
void initial(int S[]){
for(int i=0;i<SIZE;i++)
S[i]=-1;
}
如图:
16.2.2 ❗ 并查代码实现
//FInd ,找x所属集合(返回x树的根结点)
int Find(int S[],int x)
{
while(S[x]>=0) //循环找x的根
x=s[x];
return x; //根的s[]小于0
}
//Union ,并,将二个集合合并成一个
void Union(int S[],int Root1,int Root2){
//要求ROOt1和ROOt2是不同集合
if(Root1==Root2) return;
//将根root2连接在另一根root1下面
s[Root2]=Root1;
}
- 查询
-
合并:要先查到根结点,再把二个根结点进行合并
16.2.3 ❗ 时间复杂度
说明find操作时间复杂度是O(n),说明跟树的高度有关
所以优化思路就是:尽量不让树长高
16.2.4 ❗ 优化操作
所以优化思路就是:尽量不让树长高
-
- 用根节点的绝对值表示树的结点总数
用绝对值来表示树的结点个数,然后小树合并大树
合并后:
-
- Union操作,让小树合并到大树
- 合并后:
- 再合并
并优化代码
- 代码如下:
//Union ,并,将二个集合合并成一个,小树合并大树
void Union(int S[],int Root1,int Root2){
//要求ROOt1和ROOt2是不同集合
if(Root1==Root2) return;
if(S[Root2]>S[Root1]) { //Root2结点数更少,因为是负值
S[Root1]+=S[Root2]; //累加结点总数
S[Root2]=Root1; //小树合并到大树
}
else {
S[Root2]+=S[Root1]; //累加结点总数
S[Root1]=Root2; //小树合并大树
}
}
总结:使用这种方法,树的高度不超过[long_2 n]+1
; 时间复杂度为0(long_2 n);
16.3 ✨ 并查集的进一步优化
也就是对find操作的优化操作
这里我们使用的压缩路径
的方法:
压缩路径--find操作,先找到根节点,再将查找路径上所有结点都挂到根结点下
举个例子:
- 原图:find(s[],11)
- 挂起
✨ 16.3.1 代码实现
//FInd ,‘查’操作优化,先找到根节点,再进行‘压缩路径’
int Find(int S[],int x)
{
int root=x;
while(S[root]>=0) //循环找到根
root=S[root];
while(x!=root){ //压缩路径
int t=S[x]; //t指向x的父节点
S[x]=root; //x直接挂到根结点下
x=t;
}
return root; //返回根节点编号
}
举个例子:
- x=11,指向L,先找到根结点A
- while(x!=根结点) x=t 表示从x这个节点往上路径 通过下面那个while循环,把x往上的路径结点都挂到了根结点下面
- 最后返回root ,根结点编号
效率
每次Find操作,先找根,再“压缩路径”,可使树的高度不超过0(a(n))。α(n)是一个增长很缓慢的函数,对于常见的n值,通常α(n)<=4,因此优化后并查集的Find、Union操作时间开销都很低。
- 相当于O(1)
16.4 ✨ 优化总结
▶️ 17. ✨ 红黑树
参考:
后续可能会自己写点补充到专栏上。