第五章 树与二叉树
第五章 树与二叉树
5.1 树的基本概念
一、树的定义
树(Tree):()个结点的有限集合。 时称为空树。在任一非空树中:
- 有且仅有一个特定的称为**根(Root)**的结点
- 当 时,其余结点可分为 ()个互不相交的有限集合 ,其中每一个集合本身又是一棵树,称为根的子树(SubTree)
🗣️ 大白话:树就像一棵倒过来的真实的树——根在最上面,树枝往下分叉。一个根可以有很多树枝(子树),每个树枝又可以继续分叉。
二、基本术语
| 术语 | 定义 | 大白话 |
|---|---|---|
| 结点的度 | 结点拥有的子树数目 | 这个人有几个亲儿子 |
| 树的度 | 树中结点的最大度数 | 整棵树里儿子最多的那个人有几个儿子 |
| 叶子结点 | 度为0的结点 | "绝后"的结点,没有儿子 |
| 分支结点 | 度不为0的结点 | 有儿子的结点 |
| 结点的层次 | 根为第1层,根的孩子为第2层,以此类推 | 第几代 |
| 树的高度(深度) | 结点的最大层次 | 一共有几代人 |
| 路径 | 从结点 到结点 所经过的结点序列 | 从祖先到子孙走的路 |
| 路径长度 | 路径上经过的边的条数 | 走了几步 |
| 森林 | ()棵互不相交的树的集合 | 多棵树组成的树林 |
三、树的重要性质
-
树中的结点数 所有结点的度数之和 (即总边数 )
证明:每条边对应一个非根结点(边连接父→子),故边数 = 。又边数 = 所有结点度数之和(每条边由父结点的度贡献)。因此 ,即 。
-
度为 的树中第 层上至多有 个结点()
-
高度为 的 叉树至多有 个结点(等比数列求和)
-
具有 个结点的 叉树的最小高度为
树的6个重要性质总结:
| 性质 | 公式/说明 |
|---|---|
| 结点数 = 总度数 + 1 | ( 为度为 的结点数) |
| 第 层最多结点数 | |
| 高 的 叉树最多结点 | |
| 个结点的 叉树最小高 | |
| 叶子结点数公式 | (由性质1推导) |
| 个结点的树边数 |
⚠️ 度为 的树 vs 叉树:
- 度为 的树:至少有一个结点的度为 (树中确实存在度为 的结点)
- 叉树:每个结点的度最多为 (允许所有结点度都小于 ,甚至可以是空树)
- 度为 的树至少有 个结点(根结点+ 个孩子); 叉树可以是空树
5.2 二叉树
一、二叉树的定义
二叉树(Binary Tree):每个结点最多只有两棵子树,且子树有左右之分,次序不能颠倒。
五种基本形态:
- 空二叉树
- 只有根结点
- 只有左子树
- 只有右子树
- 左右子树都有
特殊二叉树:
| 类型 | 定义 | 特点 |
|---|---|---|
| 满二叉树 | 所有分支结点都有两个孩子且叶子都在同一层 | 高度为 的满二叉树有 个结点 |
| 完全二叉树 | 与满二叉树对比,只有最后一层可能不满,且最后一层结点从左到右连续 | 若有度为1的结点,则只有一个且它只有左孩子 |
| 二叉排序树(BST) | 左子树所有结点值 < 根 < 右子树所有结点值 | 中序遍历得到递增序列 |
| 平衡二叉树(AVL) | 任意结点的左右子树高度差的绝对值 ≤ 1 | 保证查找效率 |
二、二叉树的性质(高频考点!)
- 非空二叉树上,叶子结点数 与度为2的结点数 的关系:
🗣️ 大白话:叶子结点数 = 双分支结点数 + 1。这是二叉树最重要的公式之一!
证明:设 分别为度为 0、1、2 的结点数。
- 结点总数:
- 边数:(每个度为1的结点贡献1条边,度为2的贡献2条边)
- 两式相减:,即 。
- 非空二叉树第 层上至多有 个结点()
- 高度为 的二叉树至多有 个结点
- 完全二叉树的性质:
- 具有 个结点的完全二叉树的高度为 或
- 对完全二叉树按层序编号(从1开始),对于结点 :
- 其父结点为 ( 时)
- 左孩子为 ( 时存在)
- 右孩子为 ( 时存在)
- 若 ,则结点 是分支结点;否则是叶结点
💡 完全二叉树推算 的技巧:
- 完全二叉树中,度为1的结点 只可能是0或1
- 若 为奇数:,,
- 若 为偶数:,,
- 简记:(完全二叉树的叶子数始终占总结点数的一半左右)
⚠️ 更快捷的方法(2011年真题):完全二叉树的叶结点数 。
- 例:768个结点的完全二叉树,最后一个分支结点序号 = ,叶结点数 = 。
💡 度 树求叶结点数公式:设度为 的结点有 个,总结点 ,边数 ,可解出 。
- 例(2010真题):度4的树,,边 ,,。⟹ 利用 快速计算。
三、二叉树的存储结构
顺序存储:用数组按层序编号存储(适合完全二叉树)
链式存储(二叉链表,最常用):
typedef struct BiTNode {
ElemType data;
struct BiTNode *lchild, *rchild;
} BiTNode, *BiTree;
class BiTNode {
int data;
BiTNode lchild, rchild;
BiTNode(int data) {
this.data = data;
this.lchild = null;
this.rchild = null;
}
}
⚠️ 含 个结点的二叉链表中有 个空链域(可利用这些空链域构成线索二叉树)。
⚠️ 完全二叉树编号陷阱(高频选择题!):
- 结点 的左孩子编号为 ,右孩子为 ,父结点为
- 但上述公式仅适用于编号从1开始的情况!若编号从0开始,则左孩子 ,右孩子 ,父结点
- 考试中必须注意题目编号起始是0还是1,否则全盘出错
- 判断结点 是叶子:(编号从1开始时)
- 完全二叉树中度为1的结点最多1个,且若存在则只有左孩子
🔗 【跨学科联动·OS】 树结构在操作系统中常见应用:
- 文件系统目录结构:就是一棵树(根目录→子目录→文件),
ls -R/tree命令本质是树的遍历- 进程树:Linux 中所有进程构成一棵以 init/systemd 为根的进程树,
fork()创建子进程就是添加子结点- 页表:多级页表本质是一棵多叉树(如 x86 的 4 级页表)
5.3 二叉树的遍历
一、先序、中序、后序遍历
| 遍历方式 | 访问顺序 | 递归过程 |
|---|---|---|
| 先序遍历(Pre-order) | 根→左→右 | NLR |
| 中序遍历(In-order) | 左→根→右 | LNR |
| 后序遍历(Post-order) | 左→右→根 | LRN |
// 先序遍历
void PreOrder(BiTree T) {
if (T != NULL) {
visit(T); // 访问根结点
PreOrder(T->lchild); // 递归遍历左子树
PreOrder(T->rchild); // 递归遍历右子树
}
}
// 中序遍历
void InOrder(BiTree T) {
if (T != NULL) {
InOrder(T->lchild);
visit(T);
InOrder(T->rchild);
}
}
// 后序遍历
void PostOrder(BiTree T) {
if (T != NULL) {
PostOrder(T->lchild);
PostOrder(T->rchild);
visit(T);
}
}
// 先序遍历
void preOrder(BiTNode T) {
if (T != null) {
visit(T);
preOrder(T.lchild);
preOrder(T.rchild);
}
}
// 中序遍历
void inOrder(BiTNode T) {
if (T != null) {
inOrder(T.lchild);
visit(T);
inOrder(T.rchild);
}
}
// 后序遍历
void postOrder(BiTNode T) {
if (T != null) {
postOrder(T.lchild);
postOrder(T.rchild);
visit(T);
}
}
时间复杂度:,空间复杂度:( 为树高,递归栈深度)
二、层序遍历(借助队列)
void LevelOrder(BiTree T) {
InitQueue(Q);
BiTree p;
EnQueue(Q, T); // 根结点入队
while (!IsEmpty(Q)) {
DeQueue(Q, p); // 出队
visit(p);
if (p->lchild != NULL) EnQueue(Q, p->lchild);
if (p->rchild != NULL) EnQueue(Q, p->rchild);
}
}
void levelOrder(BiTNode T) {
Queue<BiTNode> queue = new LinkedList<>();
queue.offer(T); // 根结点入队
while (!queue.isEmpty()) {
BiTNode p = queue.poll(); // 出队
visit(p);
if (p.lchild != null) queue.offer(p.lchild);
if (p.rchild != null) queue.offer(p.rchild);
}
}
三、由遍历序列构造二叉树(重要!)
已知哪些序列可以唯一确定一棵二叉树?
| 组合 | 能否唯一确定 |
|---|---|
| 先序 + 中序 | ✅ 能 |
| 后序 + 中序 | ✅ 能 |
| 层序 + 中序 | ✅ 能 |
| 先序 + 后序 | ❌ 不能 |
💡 核心规律:中序序列是必需的!因为中序序列能帮助确定左右子树的划分。先序/后序/层序确定根结点,中序确定左右子树范围。
⚠️ 先序 + 后序虽不能唯一确定二叉树,但能确定祖先关系:对于两个结点 、,若前序中 在 前且后序中 在 前(即前序 ,后序 ),则 是 的祖先。
- 2012年真题应用:前序
a,e,b,d,c,后序b,c,d,e,a。前序ae/后序ea,可知 是 的祖先;前序eb/后序be,可知 是 的祖先。由此推出根 的孩子只有 。- 特殊情况:若前序与后序恰好相反(如
1,2,3,4和4,3,2,1),则每个结点最多只有一个孩子,二叉树高度等于结点数。
构造方法(以"先序+中序"为例):
- 先序的第一个元素是根
- 在中序中找到根,根左边是左子树,右边是右子树
- 递归处理左右子树
5.4 线索二叉树
一、线索化的目的
普通二叉链表中有很多空指针( 个)。线索二叉树利用这些空指针存储前驱/后继信息,加速遍历。
typedef struct ThreadNode {
ElemType data;
struct ThreadNode *lchild, *rchild;
int ltag, rtag; // 0=指向孩子,1=指向线索(前驱/后继)
} ThreadNode, *ThreadTree;
class ThreadNode {
int data;
ThreadNode lchild, rchild;
int ltag, rtag; // 0=指向孩子,1=指向线索(前驱/后继)
ThreadNode(int data) {
this.data = data;
this.lchild = null;
this.rchild = null;
this.ltag = 0;
this.rtag = 0;
}
}
- 当
ltag = 0时,lchild指向左孩子;当ltag = 1时,lchild指向中序前驱 - 当
rtag = 0时,rchild指向右孩子;当rtag = 1时,rchild指向中序后继
🗣️ 大白话:线索二叉树就是把原来浪费的空指针利用起来,存上"前一个是谁""后一个是谁"的信息,这样遍历时就不需要递归(不需要栈)了。
在线索二叉树中找前驱/后继的方法总结:
| 线索类型 | 找后继 | 找前驱 |
|---|---|---|
| 中序线索 | 若 rtag=1,后继 = rchild;若 rtag=0,后继 = 右子树最左下结点 | 若 ltag=1,前驱 = lchild;若 ltag=0,前驱 = 左子树最右下结点 |
| 先序线索 | 若 rtag=1,后继 = rchild;若有左孩子则后继 = 左孩子;否则后继 = 右孩子 | 较复杂,可能需要从根重新遍历或使用三叉链表 |
| 后序线索 | 较复杂,可能需要从根重新遍历或使用三叉链表 | 若 ltag=1,前驱 = lchild;若有右孩子则前驱 = 右孩子;否则前驱 = 左孩子 |
⚠️ 考研重点:中序线索二叉树的操作最常考。先序线索找前驱、后序线索找后继比较困难,可能需要三叉链表(增加parent指针)。有时考题会问"哪种线索二叉树不能直接找到某类前驱/后继"。
"三次路过"法理解遍历:在二叉树的递归遍历过程中,每个结点会被"路过"三次:
- 第一次路过时访问 → 先序遍历
- 第二次路过时访问 → 中序遍历
- 第三次路过时访问 → 后序遍历
💡 用"三次路过"法可以快速确定任意遍历序列:沿着树的外轮廓画一条线,每个结点被线"路过"三次,按照先/中/后的规则决定何时访问即可。
5.5 树、森林
一、树的存储结构
| 存储方式 | 说明 |
|---|---|
| 双亲表示法 | 每个结点存储其父结点的位置(数组实现),找父亲 ,找孩子 |
| 孩子表示法 | 每个结点有一个链表指向所有孩子 |
| 孩子兄弟表示法 | 每个结点有两个指针:一个指向第一个孩子,一个指向下一个兄弟 |
💡 孩子兄弟表示法本质上就是用二叉树来存储树/森林!
二、树、森林与二叉树的转换
树 → 二叉树:
- 在所有兄弟之间加一条连线
- 对每个结点,只保留与第一个孩子的连线,删除与其他孩子的连线
- 以根为轴心顺时针旋转 45°
森林 → 二叉树:
- 先把每棵树转换为二叉树
- 把每棵二叉树的根相连(后一棵作为前一棵根的右子树)
⚠️ 树转二叉树后"无右孩子"的结点数(2011年真题核心考点):
在孩子兄弟法中,右指针指向"下一个兄弟"。无右孩子 = 没有下一个兄弟 = 每组兄弟中的最后一个。设树有 个结点、 个分支结点(非叶结点),则有 组兄弟(每个分支结点的所有孩子构成一组),每组最后一个没有右兄弟,加上根结点也没有右兄弟,所以:
例:2011 年真题——2011个结点、116个叶结点的树,分支结点数 ,无右孩子结点数 。
三、树和森林的遍历
| 树的遍历 | 对应的二叉树遍历 |
|---|---|
| 树的先根遍历 | 对应二叉树的先序遍历 |
| 树的后根遍历 | 对应二叉树的中序遍历 |
| 森林的遍历 | 对应的二叉树遍历 |
|---|---|
| 森林的先序遍历 | 对应二叉树的先序遍历 |
| 森林的中序遍历 | 对应二叉树的中序遍历 |
5.6 哈夫曼树与哈夫曼编码
一、哈夫曼树(最优二叉树)
带权路径长度WPL:
其中 是第 个叶子结点的权值, 是该叶子到根的路径长度。
哈夫曼树:在含有 个带权叶子结点的二叉树中,WPL最小的二叉树称为哈夫曼树。
构造方法(贪心策略):
- 将 个权值作为 棵只含根结点的树,构成森林
- 从 中选取权值最小的两棵树,合并为一棵新树(根的权值为两棵子树之和)
- 从 中删除这两棵树,加入新树
- 重复2-3,直到只剩一棵树
哈夫曼树的特点:
- 没有度为1的结点(每个内部结点的度都为2)
- 个叶子结点的哈夫曼树共有 个结点
- 哈夫曼树不唯一(左右子树可以互换),但WPL唯一
- 哈夫曼树不一定是完全二叉树(2010年真题考点!)
- 两个权值最小的结点一定是兄弟结点
- 任一非叶结点的权值一定不小于下一层任一结点的权值(因为非叶结点权值 = 左右子树权值之和)
二、哈夫曼编码
- 将字符的使用频率作为权值构建哈夫曼树
- 左分支标记为0,右分支标记为1
- 从根到叶子的路径上标记序列即为该字符的编码
- 哈夫曼编码是前缀编码(任何一个编码都不是其他编码的前缀)
🗣️ 大白话:使用频率高的字符用短编码,频率低的用长编码。这样总的编码长度最短——这就是数据压缩的基本原理。
📝 树的真题高频考点汇编
【WPL计算】 2021年真题: 个叶子权值 ,最小WPL?
构造哈夫曼树:,,,?不对。实际最优:。答案200。
💡 WPL 计算也可用"内结点权值之和"法:每个内部结点权值 = 左右子树的权值之和,WPL = 所有内部结点的权值之和。
【哈夫曼编码平均长度】 2023年真题:频次 ,加权平均编码长度?
【 叉哈夫曼树(多叉哈夫曼树)】 2013年真题考点:
⚠️ 叉哈夫曼树的构造要点:
- 虚段补充判断:若 ,需补 个权值为0的虚叶结点
- 每次从当前所有结点中选取权值最小的 个合并
- 第一次合并时,若需补虚段,先合并虚段+最小权值结点
例:权值 ,构造三叉哈夫曼树。,,无需补虚段。
- 第1次合并: → ,剩余 ,WPL贡献 深度
- 第2次合并: →
- WPL =
【2013年408真题】 在 BST 中删除某结点后再将其插入,是否能恢复原来的 BST?
答案:不一定能恢复。 若被删除的结点有两个子树,删除操作会用中序后继(或前驱)替代该结点,树的结构已经改变。重新插入原结点后,它会被插入为叶结点(BST插入总是在叶结点位置),树的形态与原来不同。
💡 只有被删结点是叶结点或仅有一个子树时,删后重新插入才能恢复原树。
【BST的顺序存储验证】 2022年真题·应用题:用数组保存二叉树(data[]),空节点值为-1,判断是否为BST。
方法:中序遍历检查是否升序。数组中第 个结点的左/右孩子分别在 和 (下标从0开始)。用递归中序遍历即可实现。
【完全二叉树的数组编号】 考研高频考点:
- 结点 的父结点:(下标从0开始)或 (下标从1开始)
- 结点 的左孩子:(从0)或 (从1)
- 结点 的右孩子:(从0)或 (从1)
【森林中树的棵数的确定】 2021年真题:森林 对应的二叉树 ,已知 的后序遍历和中序遍历,求 中树的棵数。
关键公式:由二叉树的后序+中序可唯一确定二叉树结构。确定 后, 的根的右子树链长度 = 森林中树的棵数(根的右链对应森林中各棵树的根)。
【度为 的正则树的叶结点数公式】 2016年真题考点:
设度为 的正则树有 个非叶结点(每个非叶结点的度恰好为 ),则叶结点数为:
💡 利用 和 (总边数),联立即得。
【二叉树先序=中序 → 仅有右子女】 2017年真题考点:若一棵二叉树的先序遍历和中序遍历完全相同,则该树中任何结点都没有左孩子(退化为右斜树)。
5.7 并查集
并查集(Union-Find):一种用于管理元素分组的数据结构,支持两种操作:
- Find(x):查找元素 属于哪个集合(返回根结点)
- Union(x, y):将 和 所在的集合合并
用双亲表示法(数组)实现:
int UFSets[SIZE]; // 初始化:每个元素的父结点为-1(自己是根)
// 查找x所属集合(返回根)
int Find(int UFSets[], int x) {
while (UFSets[x] >= 0)
x = UFSets[x];
return x;
}
// 合并x和y所在的集合
void Union(int UFSets[], int Root1, int Root2) {
UFSets[Root2] = Root1; // 将Root2连到Root1下面
}
class UnionFind {
int[] parent; // 初始化每个元素的父结点为-1(自己是根)
UnionFind(int size) {
parent = new int[size];
Arrays.fill(parent, -1);
}
// 查找x所属集合(返回根)
int find(int x) {
while (parent[x] >= 0)
x = parent[x];
return x;
}
// 合并x和y所在的集合
void union(int root1, int root2) {
parent[root2] = root1;
}
}
优化:
- 按秩合并(Union by Rank):小树合并到大树下,避免退化为链
- 路径压缩(Path Compression):查找时将路径上的结点直接连到根下
// 路径压缩的Find
int Find(int UFSets[], int x) {
int root = x;
while (UFSets[root] >= 0)
root = UFSets[root];
while (x != root) {
int t = UFSets[x];
UFSets[x] = root; // 路径上所有结点直接指向根
x = t;
}
return root;
}
// 路径压缩的Find
int findWithPathCompression(int x) {
int root = x;
while (parent[root] >= 0)
root = parent[root];
while (x != root) {
int t = parent[x];
parent[x] = root; // 路径上所有结点直接指向根
x = t;
}
return root;
}
优化后 Find 和 Union 的时间接近 (严格来说是 ,其中 是阿克曼函数的反函数,对任何实际可能的 ,,可视为常数)。
💡 并查集优化效果对比:
| 实现方式 | Find 时间 | Union 时间 |
|---|---|---|
| 无优化 | (最坏,退化为链) | |
| 仅按秩合并 | ||
| 仅路径压缩 | 均摊 | |
| 按秩合并 + 路径压缩 | 均摊 |
⚠️ 考研注意:并查集中 Union 操作的时间复杂度取决于 Find(需要先找到根才能合并),严格来说 Union 的时间 = 两次 Find 时间 + 。
📝 真题剖析
【2017年408真题】 对于一棵有 个结点、度为4的树,若用孩子兄弟表示法转换为二叉树 ,则 中无右孩子的结点个数为()。
A. 1 B. C. D. 与树的形态有关
解析:
在孩子兄弟表示法中:
- 左指针指向"第一个孩子"
- 右指针指向"下一个兄弟"
无右孩子的结点 = 没有"下一个兄弟"的结点。在原树中,每组兄弟中最后一个没有下一个兄弟。
树中有 个结点,边数 = 。每条边对应一个"孩子"关系。在兄弟链中,每组兄弟的第一个不通过"兄弟"关系连接,而是通过"孩子"关系。
设树中有 个分支结点(有孩子的结点),那就有 组兄弟。每组兄弟中最后一个结点无右孩子,加上根结点也无右孩子(根没有兄弟),所以无右孩子的结点 = 分支结点数 + 1。
但实际上更直接的分析:在原树中,每个结点要么有下一个兄弟(有右孩子),要么没有。没有下一个兄弟的结点 = 每组兄弟中的最后一个。树中共有 个结点,其中 个结点有"上一个兄弟或是父亲的第一个孩子"关系。每组兄弟的最后一个结点就是"没有下一个兄弟"的。设分支结点数为 ,则这样的结点有 个(每个分支结点的最后一个孩子)加上根结点 = 。但这与树的具体形态有关吗?
实际上, 个结点的树转成的二叉树中,没有右孩子的结点恰好等于原树中每个结点的最后一个孩子的数量 + 根结点 = 分支结点数 + 1。而不同形态的树会有不同的分支结点数。
不过,本题答案为 A. 1 吗?不,仔细看——原题说的是" 个结点"的树。
重新分析:无右孩子 = 没有下一个兄弟的结点个数。每个非叶结点有若干孩子,最后一个孩子没有下一个兄弟。所以无右孩子结点数 = 非叶结点数(即分支结点数)(根也没有右兄弟)。但 且边数 ,这与具体形态有关。
答案选A(无右孩子的结点个数 = 减去有右兄弟的结点数 = 叶子结点中非末尾的...)
实际本题的正确答案考虑到无右孩子的结点 = 有"最后一个孩子"身份的结点 + 根结点。
本题答案与树的形态有关,答案是A不对。实际上应该是每个节点的最后一个孩子没有右兄弟,因此没有右孩子的节点个数=分支结点个数+1(根也没有右兄弟)。答案为 非最后孩子节点数。这与具体的树的形态有关。但正确答案应该是 A:1(不对)。让我们重新查看:实际上无右孩子指的是没有下一个兄弟。总结点数 ,有兄弟关系的边数 = 分支结点数(每个分支结点贡献"孩子数-1"条兄弟边)。所以有右孩子的结点 = 边数 分支结点数 ,无右孩子 = 。这取决于分支结点数 。所以答案选D。
📌 第五章总结
| 核心知识点 | 考研要求 |
|---|---|
| 树的定义、基本术语 | 必须牢记 |
| 二叉树的性质( 等) | 高频考点,每年必考 |
| 完全二叉树的性质(父子编号关系) | 必须掌握 |
| 二叉树的先中后序遍历(递归实现) | 重中之重 |
| 层序遍历(借助队列) | 需要掌握 |
| 由遍历序列构造二叉树 | 大题常考 |
| 线索二叉树 | 理解概念,掌握构造方法 |
| 树、森林与二叉树的转换 | 高频考点 |
| 树/森林的遍历与对应二叉树遍历的关系 | 必须掌握 |
| 哈夫曼树的构造与WPL计算 | 常考 |
| 哈夫曼编码 | 需要掌握 |
| 并查集(Find & Union 操作) | 近年热门考点 |