文章目录
注:内容参考王道2024考研复习指导以及《数据结构》
树的基本概念
树的定义
树是 n ( n > 0 ) n(n > 0) n(n>0)个结点的有限集合, n = 0 n=0 n=0时,称为空树,这是一种特殊情况。在任意一棵非空树中应满足:
- 有且仅有一个特定的称为根的结点。
- 当 n > 1 n > 1 n>1时,其余结点可分为 m ( m > 0 ) m(m > 0) m(m>0)个互不相交的有限集合 T 1 , T 2 , . . . , T m T_1,T_2,...,T_m T1,T2,...,Tm,其中每个集合本身又是一棵树,并且称为根结点的子树。
树,是一种递归定义的数据结构,作为一种逻辑结构,也是一种分层结构,具有以下两个特点:
- 树的根结点没有前驱,除根结点以外的所有结点有且只有一个前驱
- 树中所有结点都可以具有零个或多个后继
基本术语
结点之间的关系描述
双亲,上层的那个结点(直接前驱)
孩子,下层结点的子树的根(直接后继)
兄弟,同一双亲下的同层结点 (孩子之间互称兄弟)
堂兄弟,双亲位于同一层的结点(但并非同一双亲)
祖先,从根到该结点所经分支的所有结点
子孙,该结点下层子树中的任一结点
结点、树的属性描述
结点的层次(深度)——从根节点往下数,默认从1开始,结点所在的层次就是结点的深度
结点的高度——以该结点为根的子树的高度
树的高度(深度)——总共多少层
结点的度——有几个孩子(分支)
树的度——各结点的度的最大值
树的路径长度——树根到每个结点的路径长的总和
有序树和无序树
有序树——逻辑上看,树中结点的各子树从左至右是有次序的,不能互换
无序树——逻辑上看,树中结点的各子树从左至右是无次序的,可以互换
森林
森林。森林是m(m≥0)棵互不相交的树的集合。
注:需要掌握树和森林相互转化的问题。
树的性质
-
树的结点数=所有结点度数之和+1
-
度为m的树和m叉树的区别
-
度为m的树第i层至多有 m i − 1 m^{i-1} mi−1个结点;m叉树第i层至多有 m i − 1 m^{i-1} mi−1个结点 ( i ≥ 1 ) (i \geq 1) (i≥1)
-
高度为h的m叉树至多有 m h − 1 m − 1 \frac{m^h-1}{m-1} m−1mh−1个结点
-
高度为h的m叉树至少有h个结点
-
高度为h、度为m的树至少有h+m-1个结点
-
度为m、具有n个结点的树的最大高度h为n-m+1
-
度为m、具有n个结点的树的最小高度为 ⌈ log m ( n ( m − 1 ) ) + 1 ⌉ \lceil{\log_m(n(m-1))+1}\rceil ⌈logm(n(m−1))+1⌉,高度最小的情况——所有结点都有m个孩子(即m叉树)
二叉树的概念
二叉树的定义及其主要特性
二叉树的定义
二叉树(Binary Tree)是n(n≥0)个结点所构成的集合,它或为空树(n = 0);或为非空树,对于非空树T:
- 有且仅有一个称之为根的结点;
- 除根结点以外的其余结点分为两个互不相交的子集T1和T2,分别称为T的左子树和右子树,且T1和T2本身又都是二叉树。
特点:1.每个结点至多只有两棵子树 2.左右子树不能颠倒(二叉树是有序树)
二叉树的五种状态
几个特殊的二叉树
- 满二叉树
一棵高度为h,且含有 2 h − 1 2^h-1 2h−1个结点的二叉树。
特点:
- 只有最后一层有叶子结点
- 不存在度为 1 的结点
- 按层序从 1 开始编号,结点 i 的左孩子为 2i,右孩子为 2i+1;结点 i 的父节点为 ⌊ i / 2 ⌋ \lfloor {i/2} \rfloor ⌊i/2⌋ (如果有的话)
- 完全二叉树
当且仅当其每个结点都与高度为h的满二叉树中编号为1~n的结点一一对应时,称为完全二叉树。
特点:
- 只有最后两层可能有叶子结点
- 最多只有一个度为1的结点
- 按层序从 1 开始编号,结点 i 的左孩子为 2i,右孩子为 2i+1;结点 i 的父节点为 ⌊ i / 2 ⌋ \lfloor {i/2} \rfloor ⌊i/2⌋ (如果有的话)
- i≤ ⌊ n / 2 ⌋ \lfloor {n/2} \rfloor ⌊n/2⌋ 为分支结点, i> ⌊ n / 2 ⌋ \lfloor {n/2} \rfloor ⌊n/2⌋ 为叶子结点
- 二叉排序树
左子树上所有结点的关键字均小于根结点的关键字;右子树上所有结点的关键字均大于根结点的关键字。左子树和右子树又各是一棵二叉排序树。
- 平衡二叉树
树上任一结点的左子树和右子树的深度之差不超过1。
- 正则二叉树
树中每个分支结点都有2个孩子,即树中只有度为0或为2的结点。
二叉树的性质
- 设非空二叉树中度为0、1和2的结点个数分别为n0、n1和n2,则n0=n2+1(叶子结点比分支结点多一个)
- 二叉树第i层至多有 2 i − 1 2^{i-1} 2i−1个结点
- 高度为h的二叉树至多有 2 h − 1 2^h - 1 2h−1个结点(满二叉树)
完全二叉树的性质
- 具有n个结点的完全二叉树的高度h为 ⌈ log 2 n + 1 ⌉ \lceil {\log_2{n+1} \rceil} ⌈log2n+1⌉或 ⌊ log 2 n ⌋ + 1 \lfloor {\log_2n} \rfloor+1 ⌊log2n⌋+1
- 对于完全二叉树,可以由的结点数 n 推出度为0、1和2的结点个数为n0、n1和n2
- 若完全二叉树有2k个(偶数)个结点,则必有 n1=1, n0 = k, n2 = k-1
- 若完全二叉树有2k-1个(奇数)个结点,则必有 n1=0, n0 = k, n2 = k-1
##二叉树的存储结构
顺序存储
定义一个长度为MaxSize的数组t,按照从上到下、从左到右的顺序依次存储完全二叉树中的各个结点。
#define MaxSize 100
typedef struct TreeNode{
ElemType value;
bool isEmpty;
}TreeNode;
TreeNode t[MaxSize];
特点:结点间关系蕴含在其存储位置中;对于一般二叉树浪费空间,适于存满二叉树和完全二叉树。
链式存储
typedef struct BiTNode{
ElemType data;
struct BiTNode *lchild,*rchild;
}BiTNode,*BiTree;
特点:n个结点的二叉链表共有 n+1 个空链域(必有2n个链域。除根结点外,每个结点有且仅有一个双亲,所以只会有n-1个结点的链域存放指针,指向非空子女结点)。
为了方便查找指定结点的父结点,我们可以添加一个父结点指针,形成三叉链表。
typedef struct BiTNode{
ElemType data;
struct BiTNode *lchild,*rchild;
struct BiTNode *parent;
}BiTNode,*BiTree;
二叉树的遍历和线索二叉树
什么是遍历
遍历定义——遍历定义一顺着某一条搜索路径巡访二叉树中的结点,使得每个结点均被访问一次,而且仅被访问一次(又称周游)。
遍历目的——得到树中所有结点的一个线性排列。
遍历用途——它是树结构插入、删除、修改、查找和排序运算的前提,是二叉树一切运算的基础和核心。
二叉树的遍历
先序遍历:根左右(NLR)
中序遍历:左根右(LNR)
后序遍历:左右根(LRN)
注:后序遍历退回时访问根结点,这样可以从下到上把祖先到子孙的路径输出。
代码实现:
/*
先序遍历代码如下,对于中序遍历(InOrder)和后序遍历(PostOrder)只需要调整visit()函数的位置即可实现对应规则的遍历
*/
void PreOrder(BiTree T){//先序遍历(PreOrder)
if(T!=NULL){
visit(T);//内部规定访问结点后需要做的事情
PreOrder(T->lchild);//递归遍历左子树
PreOrder(T->rchild);//递归遍历右子树
}
}
不管采用哪种遍历算法,每个结点都访问一次且仅访问一次,所以时间复杂度都是O(n)。在递归遍历中,递归工作栈的栈深恰好为树的深度,所以在最坏情况下,二叉树是有n个结点且深度为n的单支树,遍历算法的空间复杂度为O(n)。
非递归代码实现:
void PreOrder2(BiTree T){
InitStack(S);
BiTree p=T;
while(p || !IsEmpty(S)){
if(P){
visit(p);
Push(S,p);
p=p->lchild;
}else{
Pop(S,p);
p=p->rchild;
}
}
}
应用:
//求树的深度
int treeDepth(BiTree T){
if(T == NULL){
return 0;
}else{
int l=treeDepth(T->lchild);
int r=treeDepth(T->rchild);
return l>r ? l+1:r+1;
}
}
层序遍历
算法思想:
- 初始化一个辅助队列
- 根结点入队
- 若队列非空,则队头结点出队,访问该结点,并将其左、右孩子插入队尾(如果有的话)
- 重复3直至队列为空
代码实现:
typedef struct BiTNode{
char data;
struct BiTNode *lchild,rchild;
}BiTNode,*BiTree;
typedef struct LinkQueue{
BiTNode *data;
struct LinkNode *next;
}LinkNode;
typedef struct{
LinkNode *front,*rear;
}LinkQueue;
void LevelOrder(BiTree T){
LinkQueue Q;
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);
}
}
}
由遍历序列构造二叉树
若二叉树中各结点的值均不相同,则二叉树结点的前序序列、中序序列和后序序列都是唯一的。
若只给出一棵二叉树的 前/中/后/层 序遍历序列中的一种,不能唯一确定一棵二叉树。
前序、后序、层序序列的两两组合无法唯一确定一棵二叉树。
关键:找到树的根节点,并根据中序序列划分左右子树,再找到左右子树根节点。
-
前序+中序遍历序列
-
后序+中序遍历序列
-
层次+中序遍历序列
线索二叉树
线索二叉树的基本概念
普通二叉树只能找到结点的左右孩子信息,而该结点的直接前驱和直接后继只能在遍历过程中获得。
若将遍历后对应的有关前驱和后继预存起来,则从第一个结点开始就能很快“顺藤摸瓜”而遍历整个树。
- 若结点有左子树,则lchild指向其左孩子;否则,lchild指向其直接前驱(即线索)
- 若结点有右子树,则rchild指向其右孩子;否则,rchild指向其直接后继(即线索)
typedef struct ThreadNode{
ElemType data;
struct ThreadNode *lchild,*rchild;
int ltag,rtag;
}ThreadNode,*ThreadTree;
中序线索二叉树的构造
二叉树的线索化是将二叉链表中的空指针改为指向前驱或后继的线索,而前驱或后继的信息只有在遍历时才能得到,因此线索化的实质就是遍历一次二叉树。
ThreadNode *pre=NULL;
void CreateInThread(ThreadTree T){
pre=NULL;
if(T!=NULL){
InThread(T);
if(pre->rchild=NULL){//处理遍历的最后一个结点
pre->rtag=1;
}
}
}
void InThread(ThreadTree T){//PostThread只需要调整visit函数的位置即可实现
if(T!=NULL){
InThread(T->lchild);
visit(T);
InThread(T->rchild);
}
}
void PreThread(ThreadTree T){
if(T!=NULL){
visit(T);
if(T-lchild==0){//前序遍历时,需要确保左孩子不是前驱线索,才可以进行左子树遍历,否则会进入死循环
PreThread(T->lchild);
}
PreThread(T->rchild);
}
}
void visit(ThreadTNode *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;
}
线索二叉树的遍历
中序线索二叉树的遍历
-
查找p指针所指结点的前驱
- 若p->LTag为1,则p的左链指示其前驱;
- 若p->LTag为0,则说明p有左子树,结点的前驱是遍历左子树时最后访问的一个结点(左子树中最右下的结点)。
-
查找P指针所指结点的后继
- 若p->RTag为1,则p的右链指示其后继;
- 若p->RTag为0,则说明p有右子树。根据中序遍历的规律可知,结点的后继应是遍历其右子树时访问的第一个结点,即右子树中最左下的结点。
//找到以P为根的子树中,最后一个被中序遍历的点
ThreadNode *Lastnode(ThreadNode *p){
while(p->rtag==0) p=p->rchild;
return p;
}
//找到结点P的前驱结点
ThreadNode *Prenode(ThreadNode *p){
if(p->ltag==0) return Lastnode(p->lchild);
else return p->lchild;
}
//对中序线索二叉树进行逆向中序遍历
void RevInorder(ThreadNode *T){
for(ThreadNode *p=Lastnode(T);p!=NULL;p=Prenode(p))
visit(p);
}
先序线索二叉树的遍历
-
查找p指针所指结点的前驱
- 若p->LTag为1,则p的左链指示其前驱;
- 若p->Ltag为0,需改用三叉链表才可以找到父结点。
- 如果能找到p的父结点且p是左孩子,p的父结点即为其前驱
- 如果能找到p的父结点,且p是右孩子,其左兄弟为空,p的父结点即为其前驱
- 如果能找到p的父结点,且p是右孩子,其左兄弟非空,p前驱为左兄弟子树中最后一个遍历的结点
- 如果p是根结点,则没有前驱
-
查找p指针所指结点的后继
- 若p->RTag为1,则p的右链指示其后继
- 若p->RTag为0,则说明p有右子树。按先序遍历的规则可知,p的后继必为其左子树根(若存在)或右子树根。
后序线索二叉树的遍历
-
查找p指针所指结点的前驱
- 若p->LTag为1,则p的左链指示其前驱
- 若p->LTag为0
- 当p->RTag也为0时,则p的右链指示其前驱
- 若p->LTag为0,而p->RTag为1时,则p的左链指示其前驱。
-
查找P指针所指结点的后继
- 若p->RTag为1,则p的右链指示其后继;
- 若p->Rtag为0,需改用三叉链表才可以找到父结点。
- 如果能找到p的父结点且p是右孩子,p的父结点即为其后继
- 如果能找到p的父结点,且p是左孩子,其右兄弟为空,p的父结点即为其后继
- 如果能找到p的父结点,且p是左孩子,其右兄弟非空,p后继为右兄弟子树中最后一个遍历的结点
- 如果p是根结点,则没有后继
树、森林
树的逻辑结构
树:树是n(n≥0)个结点的有限集合,n = 0时,称为空树,这是一种特殊情况。在任意一棵非空树中应满足:
- 有且仅有一个特定的称为根的结点。
- 当n > 1时,其余结点可分为m(m > 0)个互不相交的有限集合T1, T2,…, Tm,其中每个集合本身又是一棵树,并且称为根结点的子树。
森林:m (m≥0)颗互不相交的树的结合。
树的存储结构
双亲表示法
用数组顺序存储各个结点。每个结点中保存数据元素、指向双亲结点(父节点)的“指针”。
优点:找双亲(父节点)很方便。
缺点:找孩子不方便,只能从头到尾遍历整个数组。
适用于“找父亲” 多,“找孩子” 少 的应用场景。如:并查集。
#define MAX_TREE_SIZE 100
typedef struct{
ElemType data;
int parent;
}PTNode;
typedef struct{
PTNode nodes[MAX_TREE_SIZE];
int n;
}PTree;
注:树的顺序存储结构中,数组下标表示结点的编号,下标中所存的内容表示结点直接的关系;而二叉树的顺序存储中,数组下标即表示结点的编号,也表示了结点之间的关系。
注:用双亲表示法存储”森林“时,需要将每棵树的根节点双亲指针 = -1。
孩子表示法
用数组顺序存储树中各个结点。每个结点中保存数据元素、孩子链表头指针。
顺序存储+链式存储结合。
优点:找孩子很方便。
缺点:找双亲(父节点)不方便,只能遍历每个链表。
适用于“找孩子” 多,“找父亲” 少 的应用场景。如:服务流程树。
struct CTNode{
int child;
struct CTNOde *next;
};
typedef struct{
ElemType data;
struct CTNode *firstChild;
}CTBox;
typedef struct{
CTBox nodes[MAX_TREE_SIZE];
int n,r;
}CTree;
注:用孩子表示法存储森林,需要记录多个根的位置。
孩子兄弟表示法
树的孩子兄弟表示法,与二叉树类似,采用二叉链表实现。每个结点内保存数据元素和两个指针,但两个指针的含义与二叉树结点不同。
typedef struct CSNode{
ElemType data;
struct CSNode *firstChild,*nextsibling;
}CSNode,*CSTree;
最大优点是可以方便实现树转换为二叉树的操作,易于查找结点的孩子等,但缺点是查找双亲比较麻烦(可以为每个结点增设一个双亲指针)。
注:当使用“孩子兄弟表示法”存储树或森林时,从存储视角来看形态上与二叉树类似。
树、森林与二叉树的转换
树转换二叉树
对于一般的树,可以方便地转换成一棵唯一的二叉树与之对应,其详细步骤是:
- 在兄弟结点之间加一连线
- 对每个结点,只保留它与第一个孩子的连线,与其他孩子的连线全部抹掉
- 以树根为轴心,顺时针旋转 4 5 。 45^。 45。
特点:二叉树的根结点没有右子树,只有左子树;左子结点仍然是原来树中相应结点的左子结点,而所有沿右链往下的右子结点均是原来树中该结点的兄弟结点。
森林转二叉树
将森林中各棵树的根节点视为平级的兄弟关系。
二叉树转树
- 先画出树的根节点
- 从树的根节点开始,按“树的层序”恢复每个结点的孩子。在二叉树中,如果当前处理的结点有左孩子,就把左孩子和“一整串右指针糖葫芦” 拆下来,按顺序挂在当前结点的下方。
二叉树转森林
- 先把二叉树的根节点和“一整串右指针糖葫芦”拆下来,作为多棵树的根节点
- 按“森林的层序”恢复每个结点的孩子。在二叉树中,如果当前处理的结点有左孩子,就把左孩子和“一整串右指针糖葫芦” 拆下来,按顺序挂在当前结点的下方。
树、森林的遍历
树的先根遍历
(深度优先遍历)
若树非空,先访问根结点,再依次对每棵子树进行先根遍历。
树的先根遍历序列与这棵树相应二叉树的先序序列相同。
void PreOrder(TreeNode*R){
if(R!=NULL){
visit(R);//访问根节点
while(R还有下一个子树T)
Pre0rder(T);//先根遍历下一棵子树
}
}
树的后根遍历
(深度优先遍历)
若树非空,先依次对每棵子树进行后根遍历,最后再访问根结点。
树的后根遍历序列与这棵树相应二叉树的中序序列相同。
树的层次遍历
(广度优先遍历)
- 若树非空,则根节点入队
- 若队列非空,队头元素出队并访问,同时将该元素的孩子依次入队
- 重复2直到队列为空
森林的先序遍历
若森林为非空,则按如下规则进行遍历(依次对各个树进行先根遍历或依次对森林对应的二叉树的先序遍历):
- 访问森林中第一棵树的根结点。
- 先序遍历第一棵树中根结点的子树森林。
- 先序遍历除去第一棵树之后剩余的树构成的森林
森林的中序遍历
若森林为非空,则按如下规则进行遍历(依次对各个树进行后根遍历或依次对二叉树的中序遍历):
- 中序遍历森林中第一棵树的根结点的子树森林。
- 访问第一棵树的根结点。
- 中序遍历除去第一棵树之后剩余的树构成的森林。
注:当森林转换成二叉树时,其第一棵树的子树森林转换成左子树,剩余树的森林转换成右子树,可知森林的先序和中序遍历即为其对应二叉树的先序和中序遍历。
树和森林的遍历与二叉树的遍历关系如下:
树与二叉树的应用
哈夫曼树和哈夫曼编码
哈夫曼树的定义
结点的权:有某种现实含义的数值(如:表示结点的重要性等)。
结点的带权路径长度:从树的根到该结点的路径长度(经过的边数)与该结点上权值的乘积。
树的带权路径长度:树中所有叶结点的带权路径长度之和(WPL, Weighted Path Length)。
在含有n个带权叶结点的二叉树中,其中带权路径长度(WPL)最小的二叉树称为哈夫曼树,也称最优二叉树。
哈夫曼树的构造
给定n个权值分别为w1, w2,…, wn的结点,构造哈夫曼树的算法描述如下:
-
将这n个结点分别作为n棵仅含一个结点的二叉树,构成森林F。
-
构造一个新结点,从F中选取两棵根结点权值最小的树作为新结点的左、右子树,并且将新
结点的权值置为左、右子树上根结点的权值之和。 -
从F中删除刚才选出的两棵树,同时将新得到的树加入F中。
-
重复步骤2和3,直至F中只剩下一棵树为止。
特点:
- 每个初始结点最终都成为叶结点,且权值越小的结点到根结点的路径长度越大
- 构造过程中新建了n-1个结点,哈夫曼树的结点总数为2n−1
- 哈夫曼树中不存在度为1的结点。
- 哈夫曼树并不唯一,但WPL必然相同且为最优
哈夫曼编码
哈夫曼树不唯一,因此哈夫曼编码不唯一;哈夫曼编码可用于数据压缩。
固定长度编码,每个字符用相等长度的二进制位表示。
可变长度编码,允许对不同字符用不等长的二进制位表示。
前缀编码,若没有一个编码是另一个编码的前缀,则称这样的编码为前缀编码。
由哈夫曼树得到哈夫曼编码——字符集中的每个字符作为一个叶子结点,各个字符出现的频度作为结点的权值,根据之前介绍的方法构造哈夫曼树。
并查集
并查集的概念
并查集是一种简单的集合表示,它支持以下3种操作:
- Initial(S):将集合s中的每个元素都初始化为只有一个单元素的子集合。
- Union(S,Rootl,Root2):把集合S中的子集合Root2并入子集合Rootl。要求Root1和Root2互不相交,否则不执行合并。
- Find(S,x):查找集合s中单元素x所在的子集合,并返回该子集合的根结点。
并查集的存储结构
将各个元素划分为若干个互不相交的子集。
用森林,也即互不相交的树,表示多个”集合“。
用一个数组S[]表示”集合“关系
并查集的基本实现
//结构定义
#define SIZE 13
int UFSets[SIZE];
//并查集初始化操作
void Initial(int S[]){
for(int i=0;i<SIZE;i++){
S[i]=-1;
}
}
如何“查”到一个元素到底属于哪一个集合?从指定元素出发,一路向北,找到根节点。
//并查集的Find操作
int Find(int S[],int x){
while(S[x]>=0){
x=S[x];
}
return x;
}
如何把两个集合“并”为一个集合?让一棵树成为另一棵树的子树即可。
//并查集的Union操作
void Union(int S[],int Root1,int Root2){
if(Root1==Root2){
return ;
}
S[Root2]=Root1;
}
时间复杂度分析
查操作最坏时间复杂度为 O ( n ) O(n) O(n);并操作时间复杂度为 O ( 1 ) O(1) O(1)。
并查集实现的优化
Union操作优化思路
在每次Union操作构建树的时候,尽可能让树不长高。
- 用根节点的绝对值表示树的结点总数;
- Union操作,让小树合并到大树。
void Union(int S[],int Root1,int Root2){
if(Root1==Root2){
return ;
}
if(S[Root2]>S[Root1]){
S[Root1]+=S[Root2];
S[Root2]=Root1;
}else{
S[Root2]+=S[Root1];
S[Root1]=Root2;
}
}
用该方法构造的树高不超过 ⌊ log 2 n ⌋ + 1 \lfloor \log_2n \rfloor +1 ⌊log2n⌋+1。
Union操作优化后,Find操作的最坏时间复杂度为 O ( log 2 n ) O(\log_2n) O(log2n)。
Find操作的优化(压缩路径)
Find 操作,先找到根节点,再将查找路径上所有结点都挂到根结点下。
int Find(int S[],int x){
int root=x;
while(S[root]>=0) root=S[root];
while(x!=root){
int t=S[x];
S[x]=root;
x=t;
}
return root;
}
每次 Find 操作,先找根,再 “压缩路径”,可使树的高度不超过 O ( α ( n ) ) O(\alpha (n)) O(α(n))。 α(n)是一个增长很缓慢的函数,对于常见的n值,通常α(n)≤4。
因此优化后并查集的Find、Union操作时间开销都很低。