4.1 树概述
← 内容总览 15 / 24 下一节 →
强化讲义 · 原始转写 · 原书检索 · 文字用于定位,公式与图表请回原页核验 · 本次导入快照
## 4.1 树概述
## 4.1.1 思考流程
树和图的题目一般不会特别要求复杂度,如果没有特别要求的话,考试中只要做出来就可以直接写,不用考虑优化问题。一般都会考察树的遍历,除非特别说明,否则遍历就用递归写,不要写非递归。
<!-- page 148 -->
## 4.1.2 考察结构
树和森林
树和森林(孩子兄弟表示法)
firstchild 是指向该结点第一个儿子结点的指针,nextbro 是指向下一个兄弟结点的指针
<!-- verified-by-hand -->
> [!success] 已结合原文第 148 页及 Markdown 上下文人工校对
> 孩子兄弟表示法结构体左花括号丢失。
typedef struct TNode
{
ElemType data ;
struct TNode * firstchild , * nextbro ;
} TNode ;
树和森林(结点用双亲表示法存储,常用于并查集)
一般采用数组表示,father数组保存每个结点的父亲结点在数组中的下标:
int father [ MAX_SIZE ]; //保存每个结点的父亲结点在数组中的下标
或者
<!-- verified-by-hand -->
> [!success] 已结合原文第 148 页及 Markdown 上下文人工校对
> 双亲表示法结构体左花括号丢失。
typedef struct TNode
{
ElemType data ;
struct TNode * father ;
} TNode ;
## 二叉树
二叉树 (二叉链表)
<!-- verified-by-hand -->
> [!success] 已结合原文第 148 页及 Markdown 上下文人工校对
> 二叉链表结构体左花括号丢失。
typedef struct BTNode
{
ElemType data ;
struct BTNode * lchild , * rchild ;
} BTNode ;
## 二叉树(顺序)
<!-- verified-by-hand -->
> [!success] 已结合原文第 148 页及 Markdown 上下文人工校对
> 顺序二叉树结构体左花括号丢失。
typedef struct
{
int SqBiTNode [ MAX_SIZE ];
int ElemNum ;
} SqBiTree ;
<!-- page 149 -->
对于完全二叉树,只要从根起按层序存储即可,依次自上而下、自左至右存储结点元素,即将完全二叉树上编号为i的结点元素存储在如上定义的一维数组中下标为i-1的位置。可以用-1或者0表示对应结点不存在。
T1.SqBiTNode
<table><tr><td rowspan=1 colspan=1>40</td><td rowspan=1 colspan=1>25</td><td rowspan=1 colspan=1>60</td><td rowspan=1 colspan=1>-1</td><td rowspan=1 colspan=1>30</td><td rowspan=1 colspan=1></td><td rowspan=1 colspan=1>80</td><td rowspan=1 colspan=1>-1</td><td rowspan=1 colspan=1>-1</td><td rowspan=1 colspan=1>27</td><td rowspan=1 colspan=1></td><td rowspan=1 colspan=1></td></tr></table>
T1.ElemNum = 10
40
60
30
80
27
<!-- page 150 -->
## 4.2 树的递归遍历
树的遍历是408代码题的常考点,包括2014、2017、2022年,只要考察树必然需要用到遍历,一般是考察树的遍历+某种树的定义(如二叉搜索树),而二叉树的遍历包括先序中序后序遍历,他们又分为递归和非递归两种方式,每一种遍历的非递归逻辑都完全不一样,复习起来很痛苦也没有必要,而递归的代码逻辑非常简单,所以考试中除了特殊情况,树的遍历请一定使用递归(递归简单好用)。 XX
## 4.2.1 二叉树的递归遍历
递归,从本质上讲,是分治思想的一种完美体现。分治策略就是“分而治之”,它将一个复杂的大问题分解成若干个规模更小、但结构与原问题相同的子问题,然后逐个解决。
在二叉树的遍历中,分:将“遍历整棵树”这个大问题,分解为三个更小的问题:
访问根结点本身。
遍历左子树(一个规模更小的相同问题)。
遍历右子树(另一个规模更小的相同问题)。
因此,我们可以把递归想象成一个“任务分包”的过程。你作为“总指挥”,只需要应用分治思想,关注当前结点,并把子树的任务“外包”出去。
处理二叉树我们总共需要完成三个工作:
处理好你当前手头的工作(即访问结点p本身)。
把遍历左子树的整个任务,完全甩给你的“左下属”(处理左子树order(p->lchild))。
把遍历右子树的整个任务,完全甩给你的“右下属”(处理右子树order(p->rchild))。
核心信念:当你调用order(p->lchild)时,你不需要关心它内部是如何一步步执行的。你只需要完全信任这个调用,相信它一旦执行,就一定能帮你把整个左子树完美地遍历一遍。同理,order(p->rchild)也一样。
有了这个“信任”,你的工作就变得极其简单:你只需要考虑三件事(访问自己、调用左下属、调用右下属)的执行顺序即可。把复杂的大问题,分解成了在当前结点的一个极小问题。
## 遍历 order
先序遍历 PreOrder:
1. 访问自己 visit(p);
2. 调用左 PreOrder(p->lchild);
3. 调用右 PreOrder(p->rchild);
中序遍历 InOrder:
1. 调用左 InOrder(p->Ichild);
<!-- page 151 -->
<!-- code-audit: false_positive -->
> [!info] 已人工审读:这是分步讲解中的伪代码短句,不是独立 C 程序。
<!-- code-audit: false_positive -->
> [!info] 已人工审读:这是分步讲解中的伪代码短句,不是独立 C 程序。
3. 调用右 InOrder ( p -& gt ; rchild );
后序遍历 PostOrder:
1. 调用左 PostOrder(p->lchild);
2. 调用右 PostOrder(p->rchild);
3. 访问自己 visit(p);
先访问自己是先序,在中间访问自己是中序,最后访问自己是后序
先中后序的递归遍历时间复杂度为 O(n),空间复杂度为 O(h),n 是总结点数,h 是树高,如果不确定树高则空间复杂度为O(n)。
<!-- verified-by-hand -->
> [!success] 已结合原文第 151 页及 Markdown 上下文人工校对
> PreOrder/PostOrder 中 O 被识别成 0,后序函数头和左花括号整行丢失。
void PreOrder ( BTNode * p )
{
if ( p == NULL ) return ;
visit ( p ); PreOrder ( p -& gt ; lchild ); PreOrder ( p -& gt ; rchild );
}
void InOrder ( BTNode * p )
{
if ( p == NULL ) return ;
InOrder ( p -& gt ; lchild ); visit ( p ); InOrder ( p -& gt ; rchild );
}
void PostOrder ( BTNode * p )
{
if ( p == NULL ) return ;
PostOrder ( p -& gt ; lchild ); PostOrder ( p -& gt ; rchild ); visit ( p );
}
<!-- page 152 -->
## 4.2.2 树和森林的递归遍历
树和森林的递归遍历本质上是对孩子兄弟表示法结构的递归操作,重点在于掌握:
firstchild 表示第一个孩子(相当于“左子树”)。
nextbro 表示兄弟结点(相当于“右子树”)。
即“左孩子右兄弟”。
也就是说,这种结构可以转化为“二叉树”模型来理解递归逻辑。
树和森林的先根遍历等价于二叉树的先序遍历。
树和森林的后根遍历等价于二叉树的中序遍历。
先根遍历(等价于先序遍历):
void TreePre0rder ( TNode * T )
{
if ( T == NULL ) return ;
visit ( T ); // 访问当前结点
TreePre0rder ( T -& gt ; firstchild ); // 递归访问第一个孩子
TreePre0rder ( T -& gt ; nextbro ); //递归访问兄弟结点
}
后根遍历(孩子都访问完再访问根,等价于中序遍历):
void TreePost0rder ( TNode * T ) // 王進
{
if ( T == NULL ) return ;
TreePostOrder ( T -& gt ; firstchild ); //先访问所有孩子
visit ( T ); // 最后访问根
TreePost0rder ( T -& gt ; nextbro ); //然后访问兄弟结点
}
关键还是掌握二叉树的三种遍历,在二叉树遍历的基础上稍加修改就得到树和森林的遍历
<!-- page 153 -->
## 4.3 树的做题方法
408中树算法考察的核心是树的遍历,所以在做题应确定遍历,以对应遍历作为框架做题。建议分成四步:审题—写框架—填充——调整,其中第一步是最重要的。
请阅读2017 年41题
(15分)请设计一个算法,将给定的表达式树(二叉树)转换为等价的中缀表达式(通过括号反映操作符的计算次序)并输出。例如,当下列两棵表达式树作为算法的输入时,输出的等价中缀表达式分别为(a+b)×(c×(-d))和(a×b)+(-(c-d))。
原资料配图 · 转写内容请回源核验
二叉树结点定义如下:
原资料配图 · 转写内容请回源核验
typedef struct node {
char data [ 10 ]; //存储操作数或操作符
struct node * left , * right ;
} BTree ;
## 4.3.1 审题并分析
根据题目的要求,从根结点开始想一下按递归的方式需要当前结点做什么?然后根据访问顺序确定x序遍历。确定思路以后再回头看题检查是否满足了题目要求。
选择遍历方式的依据:处理p时需要子树的结果吗?不需要则先序;需要则后序;需要有序则中序;需要按层则层序。
先序(根→左→右):不依赖子树。如统计叶子、复制树、序列化、查找结点。
中序(左→根→右):利用有序性。如 BST 判合法、求第 k小、输出中缀表达式。
后序(左→右→根):依赖子树结果。如求深度、判平衡、删树、求直径。
层序(逐层扫描):需要层级信息。如最短路径、判完全二叉树、求宽度、按层输出。需队列。 X算
2017年41题分析:本题需要输出中缀表达式,因此考虑使用中序遍历。在访问每一个结点的时候需要输出该结点的data域。对于所有的不为原树也不为叶结点的子树,这种子树对应
<!-- page 154 -->
的式子两边都需要加括号。
## 4.3.2 草稿纸上书写框架
写法1:只写递归过程部分(递归实现遍历,确定是什么序遍历),输出靠递归返回的参数。框架以中序为例:
<!-- code-audit: false_positive -->
> [!info] 已人工审读:这是用“返回值/xx”作占位符的教学模板,刻意不是可编译代码。
// 需要返回值
int In0rder ( BTNode * p ) //返回类型不—定是int
{
if ( p == NULL ) return 返回值 ;
In0rder ( p -& gt ; lchild ); // 调用根据题目修改
visit ( p ); //是最重要的部分,根据题目要求决定
InOrder ( p -& gt ; rchild );
return xx ;
}
// 不需要返回值
void InOrder ( BTNode * p )
{
if ( p == NULL ) return ;
In0rder ( p -& gt ; lchild ); // 调用根据题目修改
visit ( p ); //是最重要的部分,根据题目要求决定
InOrder ( p -& gt ; rchild );
}
如果递归过程即可完成所有功能则使用写法1,大部分问题都可以使用,建议优先使用。
写法2:程序分为两个部分:本题主过程部分ans(负责调用递归过程以及输入输出)、和对应的递归过程部分(递归实现遍历,确定是什么序遍历)。框架以中序为例:
<!-- code-audit: false_positive -->
> [!info] 已人工审读:这是展示递归框架的教学伪代码,中文是有意的占位说明。
int InOrder ( BTNode * p )
{
if ( p == NULL ) return // 返回值;
In0rder ( p -& gt ; lchild ); // 调用根据题目修改
visit ( p ); //是最重要的部分,根据题目要求决定
InOrder ( p -& gt ; rchild );
return // 返回值;
}
void ans ( BTNode * T )
{
输出InOrder ( T );
完成其他工作
}
当写法1 无法完全实现功能时,就需要补充主体过程部分,即此处的 ans()。
2017年41题分析:本题不需要返回参数,框架如下:
<!-- page 155 -->
void InOrder ( BTNode * p )
{
if ( p == NULL ) return ; // p是空结点
InOrder ( p -& gt ; lchild ); //递归调用左子树
visit ( p );
InOrder ( p -& gt ; rchild ); // 递归调用右子树
}
## 4.3.3 填充 visit(p)
确定对于p需要处理什么,并补充 visit(p)部分
2017 年 41 题 分析:visit(p)需要输出该结点的 data 域(注意是输出字符数组)。
printf ( & quot ; % s & quot ;, p → & gt ; data ); // 输出操作数
或
<!-- code-audit: false_positive -->
> [!info] 已人工审读:这是算法步骤中的伪代码输出操作,不是完整 C 代码。
## 4.3.4 再次读题并调整
把前面几步得到的基本代码写出来,并比对题目要求,补充或修改对应的语句。
<!-- verified-by-hand -->
> [!success] 已结合原文第 155 页及 Markdown 上下文人工校对
> 逻辑或被识别成 |l,printf 后混入水印和未注释中文。
void InOrder ( BTree * p , BTree * root )
{
if ( p == NULL ) return ;
bool needParen = ( p != root ) & amp ; & amp ; ( p -& gt ; left != NULL || p -& gt ; right != NULL );
if ( needParen ) printf ( & quot ;( & quot ;);
InOrder ( p -& gt ; left , root );
printf ( & quot ; % s & quot ;, p -& gt ; data );
InOrder ( p -& gt ; right , root );
if ( needParen ) printf ( & quot ;) & quot ;);
}
对于括号还未处理,需要判断以p为根的树是否为原树或者叶结点。如果满足要求则在开头输出左括号,在末尾输出右括号。
不为叶结点:if (p->left!=NULL ∥ p->right!=NULL)。
不为根:需要主要过程调用时增加一个参数——根 root。if (p!=root)。
调整后:
<!-- suspect-code -->
> [!warning] 这段代码需要核对(原文第 155 页)
> OCR 体检未通过:中文出现在代码行但缺注释符。扫描件的两栏排版(代码 + 右侧注释)有时会被交错读取,产生看起来合理但语义错误的代码。背之前请对照原文。
void InOrder ( BTree * p , BTree * root ) // root是原二叉树的根
{
if ( p == NULL ) return ; // p是空结点
if (( p -& gt ; left != NULL | l p -& gt ; right != NULL ) & amp ; & amp ; ( p != root ))
printf ( & quot ;( & quot ;);
InOrder ( p -& gt ; left , root ); //递归调用左子树
printf ( & quot ; % s & quot ;, p -& gt ; data ); 11 车 输出操作数
InOrder ( p -& gt ; right , root ); // 递归调用右子树
if (( p -& gt ; left != NULL || p -& gt ; right != NULL ) & amp ; & amp ; ( p != root ))
printf ( & quot ;) & quot ;);
}
<!-- page 156 -->
## 4.3.5 返回值或全局变量
很多时候题目需要我们统计一些信息,可以采用函数返回值或者全局变量的方式来统计,有时候两种方法都可以。
以计算二叉树的带权路径长度 WPL 为例:WPL = 所有(叶结点的权值 Wi ×该结点深度 Di)求和。
## 全局变量
这种方式更像是在遍历过程中“收集”信息。
核心思想:定义一个在所有递归调用中都共享的变量,在遍历的任何一步,只要满足条件,就直接修改这个共享变量。
适用场景:全局变量是一个全局的累加、计数或状态标记。
int WPL = 0 ; // WPL是全局变量
void PreOrder ( BTNode * p , int depth ) // 当前结点p,深度depth
{
if ( p == NULL ) return ;
//
visit ( p );
// p是叶结点
WPL += depth * p -& gt ; weight ;
Pre0rder ( p -& gt ; left , depth + 1 ); //递归调用左孩子
Pre0rder ( p -& gt ; right , depth + 1 ); // 递归调用右孩子
}
## 函数返回值
这种方式是“自底向上”传递信息的核心。
核心思想:子问题的解(即对子树的递归调用)通过return 语句返回给父问题,父问题利用这些返回的结果来计算自己的解。A
适用场景:某过程的返回值表示的是以该子树为核心的二叉树的对应值。
<!-- verified-by-hand -->
> [!success] 已结合原文第 156 页及 Markdown 上下文人工校对
> 空结点注释符损坏,递归函数名中的 O 被识别成 0;叶结点返回和左右子树结果求和被错误移到代码块外。
int postOrder ( BTNode * p , int depth )
{
if ( p == NULL ) return 0 ; // p 是空结点
int L_WPL = postOrder ( p -& gt ; left , depth + 1 );
int R_WPL = postOrder ( p -& gt ; right , depth + 1 );
if ( p -& gt ; left == NULL & amp ; & amp ; p -& gt ; right == NULL )
return depth * p -& gt ; weight ;
return L_WPL + R_WPL ;
}
<!-- page 157 -->
if (p->left==NULL && p->right==NULL) // p是叶结点 return depth\*p->weight;
else return L\_WPL + R\_WPL;
<!-- page 158 -->
## 4.3.6 课堂练习
对于下列问题,请思考他们分别应该使用什么序遍历、visit(p)实现什么功能?
例题4-1:求二叉树的叶子结点个数。
例题4-2:求二叉树的深度。
例题4-3:判定二叉树(顺序存储)是否为完全二叉树。
例题4-4:判定二叉树是否为二叉排序树。
例题4-5:统计森林的叶结点数。
例题4-6:判定二叉排序树是否为平衡二叉树。
请大家思考并写下来。
核心思想:如何确定遍历方式与visit()功能在处理任何二叉树的算法问题时,你都需要回答两个核心问题: 7
1. 我应该用哪种遍历顺序?(先序、中序、后序、层序)
2. 在遍历到每个结点 p时,我需要做什么?(这就是 visit(p)的功能)
visit(p)的功能:visit(p)是你算法的核心逻辑。它定义了在访问到结点p时,具体要执行的操作。这个操作可能很简单(比如打印结点值),也可能很复杂(比如比较、计算、更新全局变量等)。 道
例题分析
现在,我们用上面的核心思想来逐一分析每个例题。
例题4-1:求二叉树的叶子结点个数
遍历方式选择:判断一个结点 p 是否为叶子结点(即 p->left == NULL && p->right完成任务。
visit(p)功能实现:我们需要一个全局计数器(比如 int leaf\_count = 0;)。
visit(p)的逻辑非常简单:
1. 检查当前结点p是否为叶子结点。
2. 如果是,则 leaf\_count++。
例题4-2:求二叉树的深度
遍历方式选择:一个结点p的深度(或以此结点为根的子树的高度)是 1 + max(左子树高度,右子树高度)。显然,为了计算p的深度,你必须先知道它左、右子树的深度。这是一个典型的“自底向上”的计算过程,因此必须使用后序遍历。
<!-- page 159 -->
visit(p)功能实现:这里的visit(p)不再是简单地修改全局变量,而是通过函数的返回值来传递信息。整个求深度的递归函数本身就体现了后序遍历的思想。
← 内容总览 15 / 24 下一节 →