数据结构进阶 第六章 树与二叉树
第六章 树与二叉树
6.1 树、二叉树基本术语与性质
6.1.1 树的基本概念
树的特点
- 层次结构:树具有层次结构,是一对多(1:m)的关系
- 基本术语:
- 叶子(终端)结点:度为0的结点
- 分支(非终端)结点:度不为0的结点
- 层次、高度:结点的层次从根开始定义,根为第1层
- 孩子、双亲、兄弟、祖先、子孙:结点间的关系
- 有序树、无序树:是否考虑子树的顺序
树的基本性质
- 树中的结点数等于所有结点的度数加1:
n = B + 1
- 度为m的树中第i层上至多有m^(i-1)个结点(i≥1)
- 高度为h的m叉树至多有(m^h-1)/(m-1)个结点
- 具有n个结点的m叉树的最小高度为⌈log_m(n(m-1)+1)⌉
6.1.2 二叉树的定义与性质
二叉树的定义
- 二叉树不是度为2的树:二叉树是有序树,左右子树有区别
- 每个结点最多有两个孩子
- 二叉树是有序树
二叉树的5个基本性质
性质1:在二叉树的第i层上至多有2^(i-1)个结点(i≥1)
性质2:深度为k的二叉树至多有2^k-1个结点(k≥1)
性质3:对任意一棵二叉树T,若终端结点数为n₀,而其度数为2的结点数为n₂,则n₀ = n₂ + 1
证明过程:
- 设二叉树总结点数为n,度为1的结点数为n₁
- 则:n = n₀ + n₁ + n₂
- 边数关系:B = n - 1 = 2n₂ + n₁
- 由此可得:n₀ = n₂ + 1
性质4:具有n个结点的完全二叉树的深度为⌊log₂n⌋ + 1
性质5:对于具有n个结点的完全二叉树,如果按照从上到下和从左到右的顺序对二叉树中的所有结点从1开始顺序编号,则对于任意的序号为i的结点有:
- 如果i = 1,则序号为i的结点是根结点,无双亲结点;如果i > 1,则序号为i的结点的双亲结点序号为⌊i/2⌋
- 如果2×i > n,则序号为i的结点无左孩子;如果2×i ≤ n,则序号为i的结点的左孩子结点的序号为2×i
- 如果2×i+1 > n,则序号为i的结点无右孩子;如果2×i+1 ≤ n,则序号为i的结点的右孩子结点的序号为2×i+1
满二叉树与完全二叉树
满二叉树:除最后一层外,每一层上的所有结点都有两个孩子结点
完全二叉树:除最后一层外,每一层都是满的,最后一层的结点都集中在左边
重要推论:完全二叉树上,度为1的结点个数是0或1个
- 总结点个数为偶数,度为1的结点个数为1
- 总结点个数为奇数,度为1的结点个数为0
6.1.3 练习题解析
题目1:一棵树的度为4,其中度为1,2,3,4的结点分别为5,4,2,2个,则该树中有(15)个叶子结点。
解答:
- 度为k的树中叶子结点个数公式:n₀ = (k-1)nₖ + (k-2)nₖ₋₁ + … + n₂ + 1
- n₀ = 3×2 + 2×2 + 1×4 + 0×5 + 1 = 6 + 4 + 4 + 0 + 1 = 15
题目2:一棵完全二叉树,共666个结点。该二叉树共有(333)个叶子结点,(1)个度为1的结点,(332)个度为2的结点。
解答:
- 由于666是偶数,所以度为1的结点个数为1
- n₀ = n₂ + 1,n₀ + n₁ + n₂ = 666
- 得:n₀ = 333,n₁ = 1,n₂ = 332
6.2 二叉树存储及遍历
6.2.1 二叉树的存储结构
顺序存储
- 将二叉树补为完全二叉树,从上到下,从左到右依次存储每个结点
- 利用性质5来计算结点之间的关系
- 空间效率问题:对于一般二叉树可能造成空间浪费
链式存储
二叉链表:
typedef struct BiTNode {ElemType data;struct BiTNode *lchild, *rchild;
} BiTNode, *BiTree;
三叉链表:
typedef struct TriTNode {ElemType data;struct TriTNode *lchild, *rchild, *parent;
} TriTNode, *TriTree;
空链域分析:
- n个结点的二叉树用二叉链表表示,共有n+1个空链域
- n个结点的二叉树用三叉链表表示,共有n+2个空链域
6.2.2 二叉树遍历
遍历方法
- DLR(先序遍历):根→左→右
- LDR(中序遍历):左→根→右
- LRD(后序遍历):左→右→根
- 层次遍历:逐层从左到右
递归遍历算法
中序遍历(LDR):
void LDR(BiTree root) {if (root != NULL) {LDR(root->LChild);Visit(root->data);LDR(root->RChild);}
}
层次遍历算法
int LayerOrder(BiTree bt) {Queue Q; BiTree p;InitQueue(&Q);if(bt == NULL) return ERROR;EnterQueue(&Q, bt);while (!IsEmpty(&Q)) {DeleteQueue(&Q, &p);visit(p->data);if(p->LChild) EnterQueue(&Q, p->LChild);if(p->RChild) EnterQueue(&Q, p->RChild);}return OK;
}
非递归遍历算法
先序遍历(非递归):
void preOrder(BiTree root) {Stack S;BiTree p = root;InitStack(S);while(p != NULL || !IsEmpty(S)) {if(p != NULL) {visit(p->data); // 访问根结点Push(S, p);p = p->lchild;} else {Pop(S, &p);p = p->rchild;}}
}
中序遍历(非递归):
void InOrder(BiTree root) {Stack S;BiTree p = root;InitStack(S);while(p != NULL || !IsEmpty(S)) {if(p != NULL) {Push(S, p);p = p->lchild;} else {Pop(S, &p);visit(p->data); // 出栈时访问p = p->rchild;}}
}
6.2.3 二叉树遍历的应用
重要应用场景
- 某二叉树采用三叉链表存放,但在建树时,忘记了给双亲域parent赋值:编写算法,给每个结点的parent赋值
- 假设Huffman树采用二叉链表存放:编写算法求该Huffman树的带权路径长度
- 设计算法,实现二叉链表存储的二叉树自上而下、从右到左的层次遍历
- 某二叉树采用顺序存储,编写算法对其进行后序遍历
- 判断某个顺序存储的完全二叉树是否为大根堆
- 给定一个先序和中序遍历序列,编写算法构建二叉链表存储的二叉树
由遍历序列构造二叉树
已知先序和中序序列构造二叉树的方法:
- 先序序列的第一个元素是根结点
- 在中序序列中找到根结点,左边是左子树,右边是右子树
- 递归构造左子树和右子树
判断二叉搜索树
- 方法:LDR遍历,如果得到递增序列,则是二叉搜索树
判断完全二叉树
- 方法:层次遍历,空孩子也进队;遇到出队为空,跳出循环。如果队列中都为空结点,则是完全二叉树;否则不是
求二叉树最大宽度
- 方法:层次遍历,统计每层结点个数
6.2.4 表达式树
表达式与二叉树的关系
- 中缀表达式相当于对二叉树表达式进行中序遍历
- 后缀表达式相当于对二叉树表达式进行后序遍历
中缀表达式转二叉树
算法思想:
- 从当前传入的表达式data[start…end]中找出不在括号内、最靠后、优先级最低的一个运算符,作为二叉树的根节点
- 分别构建该二叉树的左子树data[start…mid-1]和右子树data[mid+1…end]
二叉树转中缀表达式(加括号)
算法思想:当根结点运算符优先级大于左子树或右子树根结点运算符时,相应左或右子树前需要加括号
void InOrder2(BiTree T) {int tag = 0;if (T == NULL) return;else if (T->LChild == NULL && T->RChild == NULL) cout << T->data;else {if (isOp(T->LChild->data) && compare(T->data, T->LChild->data) == 1) {cout << "("; tag = 1;}InOrder2(T->LChild);if (tag) cout << ")";cout << T->data;tag = 0;if (isOp(T->RChild->data) && compare(T->data, T->RChild->data) == 1) {cout << "("; tag = 1;}InOrder2(T->RChild);if (tag) cout << ")";}
}
6.3 线索二叉树
6.3.1 线索二叉树的概念
为什么需要线索?
- 在二叉链表中,有大量的空指针域
- 可以利用这些空指针域存储结点的前驱和后继信息
线索二叉树的结构
typedef struct ThreadNode {ElemType data;struct ThreadNode *lchild, *rchild;int ltag, rtag; // 线索标志
} ThreadNode, *ThreadTree;
标志位说明:
- ltag = 0:lchild指向左孩子
- ltag = 1:lchild指向前驱
- rtag = 0:rchild指向右孩子
- rtag = 1:rchild指向后继
6.3.2 线索二叉树中查找前驱后继
中序线索二叉树中找前驱
- 如果ltag = 1,则lchild就是前驱
- 如果ltag = 0,则前驱是左子树中最右边的结点
中序线索二叉树中找后继
- 如果rtag = 1,则rchild就是后继
- 如果rtag = 0,则后继是右子树中最左边的结点
6.3.3 线索化算法
void InThreadTree(ThreadTree root) {if (root != NULL) {InThreadTree(root->lchild); // 线索化左子树// 处理当前结点if (root->lchild == NULL) {root->lchild = pre;root->ltag = 1;}if (pre != NULL && pre->rchild == NULL) {pre->rchild = root;pre->rtag = 1;}pre = root; // 当前结点成为下一个结点的前驱InThreadTree(root->rchild); // 线索化右子树}
}
6.4 树与二叉树的转换
6.4.1 树的存储形式
三种存储形式
- 双亲表示法:每个结点存储其双亲的位置
- 孩子链表示法:每个结点的所有孩子用链表连接
- 孩子-兄弟表示法:每个结点只保存第一个孩子和右兄弟
6.4.2 树和二叉树之间的转换
转换规则:左孩子右兄弟
- 每个结点的第一个孩子作为其左孩子
- 每个结点的兄弟结点作为其右孩子
遍历对应关系
- 树的先根遍历 ⟺ 二叉树的先序遍历
- 树的后根遍历 ⟺ 二叉树的中序遍历
6.4.3 森林和二叉树之间的转换
转换步骤
- 将森林中的每棵树转换为二叉树
- 将各二叉树的根结点视为兄弟关系,用右指针连接
遍历对应关系
- 森林的先序遍历 ⟺ 二叉树的先序遍历
- 森林的中序遍历 ⟺ 二叉树的中序遍历
- 森林的后序遍历 ⟺ 二叉树的后序遍历
6.4.4 练习题解析
题目:如图的二叉树是一个森林转化而来,则该森林中有(5)个叶子结点。
解答方法:
- 将二叉树转换回森林
- 在森林中,叶子结点是那些既没有孩子又没有右兄弟的结点
- 在对应的二叉树中,这些结点的左右指针都为空
6.5 Huffman树与编码
6.5.1 基本概念
相关术语
- 结点路径长度:从某结点到其子孙结点所经边的个数
- 树的路径长度PL:所有结点的路径长度之和
- 结点带权路径长度:从根结点到该结点的路径长度与该结点权值的乘积
- 树的带权路径长度WPL:所有叶子结点的带权路径长度之和
Huffman树的定义
- Huffman树是WPL最短的树
- 前缀码:任意一个编码都不是其他编码的前缀
- Huffman编码是最优前缀码
- 编码规则:在Huffman树上按左0右1或左1右0的规则进行编码
6.5.2 Huffman树的构造算法
构造步骤
- 将所有权值作为叶子结点,构成森林
- 在森林中选择两个权值最小的根结点合并为一个新树
- 重复步骤2,直到森林中只有一棵树
重要性质
- n个不同权值构成的Huffman树中没有度为1的结点
- 树中两个最小的权值结点一定是兄弟结点
- 任一非叶结点的权值一定不小于下一层任一结点的权值
6.5.3 Huffman编码
编码过程
- 构造Huffman树
- 从根到叶子的路径确定编码(左0右1)
- 每个字符的编码就是从根到该字符叶子结点的路径编码
译码过程
- 从根结点开始,根据编码中的0和1沿着对应的左右分支走
- 到达叶子结点时,输出该字符,然后回到根结点继续
6.5.4 练习题解析
题目1:以给定权值{1,3,6,7,9,14,20}作为叶子,构造WPL最短的二叉树,并计算其WPL。
解答:
- 按照Huffman算法构造树
- 计算WPL = 1×4 + 3×4 + 6×3 + 7×3 + 9×2 + 14×2 + 20×1 = 122
题目2:一棵二叉哈夫曼树共有301个结点,对其进行哈夫曼编码,共能得到(151)种编码。
解答:
- Huffman树只有度为0和度为2的结点
- 设叶子结点数为n₀,度为2的结点数为n₂
- n₀ = n₂ + 1,n₀ + n₂ = 301
- 得:n₀ = 151,n₂ = 150
6.5.5 应用实例
切割木板问题
问题:将长度为100的木板切割成长度为{2, 10, 15, 20, 25, 28}的木板,每次切割的代价是被切割木板的长度,求最小代价。
解法:使用Huffman树的思想,反向思考为合并问题
priority_queue<int, vector<int>, greater<int>> q;
for(int i = 0; i < n; i++) {cin >> t; q.push(t);
}
sum = 0;
while(q.size() > 1) {t1 = q.top(); q.pop();t2 = q.top(); q.pop();t = t1 + t2; sum += t; q.push(t);
}
cout << sum << endl;
6.6 并查集
6.6.1 并查集的概念
应用场景
经常对若干集合进行如下操作时,可采用并查集:
- 合并两个子集
- 查找某个元素属于哪个子集
- 查找两个元素是否属于同一子集
存储方式
- 将每个子集组织成树的形式,谁当根(家长)都可以
- 若干个子集组织成森林的形式
- 按照树的双亲表示法存储每一棵树
6.6.2 并查集的基本操作
数据结构
typedef struct {ElemType data;int parent;
} UFSTree;
Find操作(查找)
int Find_1(UFSet *S, int x) {while(S->tree[x].parent >= 0) {x = S->tree[x].parent;}return x;
}
Union操作(合并)
int Merge_1(UFSet *S, int root1, int root2) {if (root1 < 0 || root1 > S->nodenum-1) return ERROR;if (root2 < 0 || root2 > S->nodenum-1) return ERROR;S->tree[root2].parent = root1;return OK;
}
6.6.3 并查集的优化
路径压缩
在Find操作中,将查找路径上的所有结点都直接连到根结点上,减少树的高度。
int Find_2(UFSet *S, int x) {if (S->tree[x].parent < 0) return x;elsereturn S->tree[x].parent = Find_2(S, S->tree[x].parent);
}
按秩合并
总是将较小的树合并到较大的树上,避免树退化成链。
6.6.4 并查集的应用
- 判断无向图的连通分量个数
- 判断无向图是否连通
- 判断无向图是否有环
- Kruskal算法(最小生成树)
本章总结
重要知识点回顾
- 二叉树性质(推广到树)与存储(顺序、链式)
- 二叉树遍历算法(递归、非递归)
- 线索二叉树(线索化、找前驱后继)
- 二叉树与树、森林的转化
- 哈夫曼树(二叉、k叉)
- 并查集
考试重点
- 二叉树的5个基本性质及其应用
- 完全二叉树的结点编号规律
- 各种遍历算法的实现(递归与非递归)
- 线索二叉树的构造和应用
- 树与二叉树的转换规则
- Huffman树的构造和编码
- 并查集的基本操作和优化
算法复杂度分析
- 二叉树遍历:时间复杂度O(n),空间复杂度O(h),其中h为树的高度
- Huffman树构造:时间复杂度O(nlogn)
- 并查集操作:经过路径压缩和按秩合并优化后,平均时间复杂度接近O(1)
常见题型
- 根据遍历序列构造二叉树
- 完全二叉树相关计算题
- Huffman编码的构造和应用
- 树与森林的转换
- 并查集的应用场景识别