目录
0. 前言
树结构是一种重要的非线性结构。它是一种层次结构,如人类社会的族谱,公司中人员分配的管理都可以用树来形象表示。树在计算机中也得到广泛应用,在操作系统中,树来表示文件目录的组织结构。
1.树和二叉树的定义
1)树的定义
树(Tree)是n(n>=0)个结点的有限集,它或为空树(n=0);或非空树(有结点的树)
对于非空树T的特点
- 有且仅有一个称之为根的结点
- 除根节点以外其余结点可分为m(m>0)个互不相交的有限集T1,T2...Tm,其中每个集合本身也是一棵树,称之为根的子树(SubTree)
如图,a是只有根节点的树,而b是一般的树。
2)树的表示方法
树的定义本身就是一个递归的定义(即在树中包含树),树可以有其他表现形式,可以有嵌套集合,广义表的形式,还有凹入表示法(类似书的编目),表示的方法多样。一般来说,分等级的分类都可用层次结构来表示,也就是说,都可以用树表示
嵌套集合
广义表
凹入表示法
3)树的基本术语
- 结点: 树中的一个独立单元,包含独立的数据元素,及指向其他子树的分支,例如,上面树中的A,B,C,D结点。
- 结点的度: 结点拥有的子树个数是结点的度,例如,A的度为3,B的度为2。
- 树的度:树的度是树内各结点的度的最大值,例如,上图树的度为3(B,C,D)(H,I,J)。
- 叶子:度为0的结点称为叶子或终端结点,如,结点K,L,F,G,M,I,J是树的叶子
- 双亲和孩子,兄弟,祖先...等各种关系: 树中结点的关系理解可以像一个家庭,例如,双亲和孩子,一个结点的子树是该结点的孩子,该结点是这些子树的双亲;兄弟,同一个双亲的孩子之间互称兄弟...等等关系。
- 层次:结构的层次从根开始定义起,根为第一层,根的孩子为第二层。
- 树的深度:树中结点的最大层次数称为树的深度或高度,图中所示树的深度为4。
- 有序树和无序树:如果树中的结点各子树看成从左至右有次序的(不能互换结点位置),则该树称为有序树,否则是无序树。
- 森林:是m(m>=0)棵不相交的树的集合,对于树中的结点而言,子树的集合为森林。
2.二叉树
1)基本定义
二叉树(Binary Tree)是n(n>=0)个结点所构成的集合,它或为空树(n=0);或非空树
对于非空二叉树有以下特点
- 有且仅有一个称之为根的结点(树的基本特点)
- 除根结点外其余结点分为两个互不相交的子集T1和T2,分别为T的左子树和右子树,且T1和T2本身都是二叉树
2)二叉树与树的区别
二叉树本身就是树,它也是递归性质,主要区别有以下两点
- 二叉树的每个结点至多有2棵子树(二叉树不存在度大于2的结点,且二叉树的度最多为2)
- 二叉树是有序树,有左右之分,且次序不能颠倒
3)二叉树的种类
a.满二叉树
满二叉树是深度为k,且含有2^k-1个结点的二叉树(结点个数拉满的二叉树),如图为深度为4的满二叉树。编号约定从结点起,自上而下,从左往右
满二叉树的基本关系
b.完全二叉树
深度为k,有n个结点的完全二叉树,要依据树中的每一个结点都应该与深度为k的满二叉树中编号从1至n的结点一一对应,称之为完全二叉树,否则是非完全二叉树
完全二叉树的结点与满二叉树一一对应
该树的6,7结点不对应,是非完全二叉树
该树的6结点不对应,是非完全二叉树
完全二叉树的特点
假设完全二叉树的层次为k
- 完全二叉树的前k-1层一定是满二叉树
- 叶子结点只可能出现在第k层和第k-1层
4)二叉树的性质
- 在层次为k的二叉树上,该层最多有2^(k-1)个结点个数
- 在深度为k(k>=1)的二叉树上,最多有2^k-1个结点(满二叉树)
- 对于任意的二叉树,假设度为0的结点有n0个,度为2的结点有n2个,则关系为:n0 = n2+1
- 具有n个结点的完全二叉树,则深度为[log2(n)]+1 (运算符[x]的意义是不超过x的最大正数)
- 对于完全二叉树,根据满二叉树的性质逆推,则有以下性质,假设某结点的序号为i
- 如果i为1,则无双亲,对于i>1,则双亲结点为[i/2]
- 如果2i>n,则说明该结点没有左孩子,否则左孩子的结点为2i
- 如果2i+1>n,则说明该结点没有右孩子,否则右孩子的结点为2i+1
5)二叉树性质的推导
性质3推导
性质4推导
6)二叉树的存储
a.顺序存储
用顺序存储来存储二叉树,用一维数组来储存二叉树。要反应逻辑关系,得按照一定规律来存储
按照满二叉树的对应结点的索引位置来存储二叉树,例如,下面数组中用0表示该位置没有元素
完全二叉树的顺序存储
非完全二叉树的顺序存储
很明显,用顺序存储二叉树,不仅有的空间无法使用,而且动态管理十分麻烦,顺序存储二叉树十分麻烦。
b.链式存储
链式存储二叉树,不仅很好的满足了二叉树的逻辑关系,也容易实现动态管理,树用链表存储,十分好。对于二叉树,链式存储为了方便分为两种,二叉链和三叉链
对于二叉树的结点,最基本应该有数据域(存放数据),左孩子指针,右孩子指针。
二叉链能够实现二叉树的所有功能,存储小,简洁,但是不便于访问双亲节点
三叉链表相比二叉链表,多了一个双亲指针,便于访问双亲结点
为了简洁性,使用二叉链表这个存储结构表示二叉树,二叉链表的代码为
typedef char elem; //定义基本元素类型
//二叉树的基本数据类型定义
typedef struct BTNode
{
elem data; //数据域
struct BTNode* rchild, * lchild; //左孩子和右孩子的指针
}BTNode,*LBTree;
3.对二叉树的操作
1)二叉树的遍历
a.遍历二叉树的定义
遍历二叉树(Traversing Binary Tree)是指按照某条搜索路径寻访树中的每个结点,使得每个结点均被访问一次,而且仅被访问一次。遍历二叉树是二叉树的基本操作。
b.遍历二叉树的方法种类
遍历二叉树可分为,前序遍历,中序遍历和后序遍历。 其中前序,中序和后序是指结点的数据的访问顺序,前序遍历是先访问结点的数据,中序是中间再访问,后序是最后在访问结点数据。如果没有其他规则,默认,先访问左子树,再访问右子树。
举例
例如,访问表达式(a + b*(c-d) - e/f)的二叉树
前序遍历:- + a * b - c d / e f
中序遍历:a + b * c - d - e / f
后序遍历: a b c d - * + e f / -
可以发现,前序遍历刚好是波兰表达式,中序遍历是中缀表达式(1+1是中缀表达式),而后续遍历是逆波兰表达式。
c.代码实现(递归方法)
前序遍历
//先序遍历法,展示二叉表
int LBTreeShow_P(const LBTree* T)
{
if (*T == NULL) //如果T的该结点是NULL,返回上一层
printf("#");
else //如果T的结点不为NULL,则打印,并且遍历左子树再遍历右子树
{
printf("%c", (*T)->data);
LBTreeShow_P(&((*T)->lchild)); //遍历左子树
LBTreeShow_P(&((*T)->rchild)); //遍历右子树
}
return 1;
}
//先序遍历法,展示二叉表
int LBTreeShow_Pre(const LBTree* T)
{
LBTreeShow_P(T);
printf("\n");
return 1;
}
中序遍历(交换前序的代码位置)
//中序遍历
int LBTreeShow_I(const LBTree* T)
{
if (*T == NULL)
printf("#");
else
{
LBTreeShow_I(&((*T)->lchild));
printf("%c", (*T)->data);
LBTreeShow_I(&((*T)->rchild));
}
return 1;
}
//中序,是先左子树,中间,后边是右子树
int LBTreeShow_In(const LBTree* T)
{
LBTreeShow_I(T);
printf("\n");
return 1;
}
后序遍历
//后续遍历子树,先是左子树,再是右子树,再是结点
int LBTreeShow_A(const LBTree* T)
{
if (*T == NULL)
printf("#");
else //如果结点有意义
{
LBTreeShow_A(&((*T)->lchild));
LBTreeShow_A(&((*T)->rchild));
printf("%c", (*T)->data);
}
return 1;
}
//后续遍历子树,先是左子树,再是右子树,再是结点
int LBTreeShow_Post(const LBTree* T)
{
LBTreeShow_A(T);
printf("\n");
return 1;
}
递归可以用栈来写非递归遍历的方法,这里就不举例了。
2)二叉树的创建
学会了遍历,可以用任意一种遍历方法来创建二叉树。
//先序遍历法创建二叉链树,递归法
int LBTreeCreat(LBTree* T)
{
//如果二叉链树输入正确,问题是输入缓冲区中还有一个换行符,需要清掉
//但是如果输入错误,则字符会读取到换行符,就导致异常输入的问题了
char ch = getchar();
if (ch == '#') //如果ch是#,则T直接为NULL
*T = NULL;
else
{
//将元素放入结点中
*T = (BTNode*)malloc(sizeof(BTNode));
(*T)->data = ch;
LBTreeCreat(&((*T)->lchild)); //遍历左结点
LBTreeCreat(&((*T)->rchild)); //遍历右结点
}
return 1;
}
//先序遍历法,创建二叉表,清掉换行符
int LBTreeCreat_Pre(const LBTree* T)
{
LBTreeCreat(T);
char ch = getchar(); //清掉换行符
return 1;
}
3)二叉树的复制
//遍历复制二叉树
int LBTreeCopy(LBTree* T1, const LBTree* T2)
{
if (*T2 == NULL)
*T1 = NULL;
else
{
*T1 = (BTNode*)malloc(sizeof(BTNode));
(*T1)->data = (*T2)->data;
LBTreeCopy(&((*T1)->lchild), &((*T2)->lchild)); //复制左子树
LBTreeCopy(&((*T1)->rchild), &((*T2)->rchild)); //复制右子树
}
return 1;
4)二叉树的深度计算
//计算树的深度
int LBTreeDepth(const LBTree T)
{
if (T == NULL)
return 0;
else
{
int m = LBTreeDepth(T->lchild);
int n = LBTreeDepth(T->rchild);
return (m > n) ? m + 1 : n + 1;
}
}
5)二叉树的结点个数计算
//统计树的结点个数
int LBTreeCount(const LBTree T)
{
if (T == NULL)
return 0;
else
return LBTreeCount(T->lchild) + LBTreeCount(T->rchild) + 1;
}
//统计树中度为0的结点个数
int LBTreeCount0(const LBTree T)
{
if (T == NULL) //如果T为空节点直接返回
return 0;
else if (T->lchild == NULL && T->rchild == NULL) //如果T的度为0的结点
return 1;
else //如果是其他情况,说明度为1或者2,则计算俩结点的个数返回
return LBTreeCount0(T->lchild) + LBTreeCount0(T->rchild);
}
//统计树中度为1的结点个数
int LBTreeCount1(const LBTree T)
{
if (T == NULL) //如果T为空结点,直接返回0
return 0;
else if ((T->lchild != NULL && T->rchild == NULL) || (T->lchild == NULL && T->rchild != NULL))
return LBTreeCount1(T->lchild) + LBTreeCount1(T->rchild) + 1;
else
return LBTreeCount1(T->lchild) + LBTreeCount1(T->rchild);
}
//统计树中度为2的结点个数
int LBTreeCount2(const LBTree T)
{
if (T == NULL)
return 0;
else if (T->lchild && T->rchild)
return LBTreeCount2(T->lchild) + LBTreeCount2(T->rchild) + 1;
else
return LBTreeCount2(T->lchild) + LBTreeCount2(T->rchild);
3.线索二叉树
线索二叉树(Thread Binary Tree)是按照前序,中序或后续的顺序将二叉树串联起来,为每个结点找到了前驱和后继,将非线性关系转化成线性的一种二叉树,其中按照某种规律,将结点连接起来,就是将二叉树线索化。
1)二叉链结构的改进
对于有n个结点的二叉树,如果用二叉链结构,则一定会有n+1个连接都指向了NULL(假设将NULL看为一个结点,则非NULL结点的度肯定为2,而NULL结点度为0,由二叉树的性质可以知道),如何利用好这n+1个NULL链接?
2)线索二叉树的结构
将NULL的链接可以指向结点的前驱和后继,为了标志该指针到底是指向子树还是前驱和后继结点,可以设立标志,区分,这就是线索二叉树的标志。
代码实现
typedef char elem; //声明元素类型
typedef enum{chd,pnt} PTag; //子代标签
//线索二叉树
typedef struct ThrBTNode
{
elem data; //数据域
struct ThrBTNode* lchild, * rchild; //左子树和右子树的标签
PTag ltag, rtag; //左标签和右标签
//若ltag为chd,则指代结点的左子树
//若rtag为pnt,则ltag指向前继结点,rtag指向后继节点
}ThrBTNode,*ThrBTree;
3)线索二叉树的线索化
在遍历过程中,可以将结点项连接起来,例如下图,中序遍历线索化连接
如果设立头结点,可以实现双向线索链表,即可以从第一个结点遍历,也可以从最后一个结点遍历
代码实现,采取中序遍历线索化
static ThrBTNode* pre = NULL; //静态全局变量,用于线索查找函数
//中序对二叉树进行线索化的基本函数
Status ThrBTreeThread_I(ThrBTree* T)
{
//出现的问题,最后一个结点的右子树并没有线索化也没有检查
if (*T) //如果T不为NULL
{
ThrBTreeThread_I(&((*T)->lchild)); //首先遍历左子树
//对结点进行线索化
if ((*T)->lchild == NULL) //如果T的左子树指针为NULL,则更改
{
(*T)->ltag = pnt;
(*T)->lchild = pre; //将左子树指针连接到前继结点
}
else
(*T)->ltag = chd;
if (pre) //如果Pre不为空,则继续判断
{
if (pre->rchild == NULL) //如果pre的右子树指针为NULL,则更改
{
pre->rtag = pnt;
pre->rchild = *T;
}
else //否则pre的rtag为chd
pre->rtag = chd;
}
pre = *T;
ThrBTreeThread_I(&((*T)->rchild)); //最后遍历右子树
}
return OK;
}
//中序对二叉树进行线索化
Status ThrBTreeThread_In(ThrBTree* T)
{
pre = NULL; //重置全局变量Pre
ThrBTreeThread_I(T);
pre->rtag = pnt;
pre->rchild = NULL;
return OK;
}
//头结点对二叉树进行线索化,构建双向循环链表,中序线索化
Status ThrBTreeThreadHead_In(ThrBTNode** head, ThrBTree* T)
{
pre = NULL; //重置全局变量NULL
*head = (ThrBTNode*)malloc(sizeof(ThrBTNode));
(*head)->rchild = *head; //将头结点的后继位置
(*head)->rtag = pnt; //将rtag设置为child
if (*T == NULL) //如果树为空,则head指向自己
{
(*head)->ltag = pnt;
(*head)->lchild = *head;
}
else
{
(*head)->ltag = chd;
(*head)->lchild = *T;
ThrBTreeThread_In(T);
pre->rchild = *head; //更改最后一个结点的后继位置,改为head
(*head)->rchild = pre; //同时更改头结点的右子树后继结点指向
}
return OK;
}
4)利用线索进行遍历
中序线索遍历,实现条件较为简单,因此用中序线索较好
代码实现
/遍历头结点的线索二叉树,线索遍历,中序遍历
Status ThrBTreeShow_HT(const ThrBTree HT)
{
ThrBTNode* p = HT->lchild; //p首先是HT的根结点,HT的第一个结点是头结点
//找到遍历的第一个结点
while (p->ltag == chd) //如果p还有左孩子,说明p并不是第一个起始的结点
p = p->lchild; //直到p没有左孩子,说明p才是第一个结点
while (p != HT) //当p为HT时,遍历结束,因为最后HT的最后一个结点的后继是HT
{
printf("%c", p->data); //打印结点p
//找到结点p的下一个位置
if (p->rtag == pnt) //如果rtag为pnt,说明p的rchild是后继位置
p = p->rchild;
else //如果不是,说明p有右子树,应该是右子树的最左下角位置
{
ThrBTNode* temp = p->rchild;
while (temp->ltag == chd) //如果temp还有左孩子,说明temp并不是最左下角
temp = temp->lchild;
p = temp;
}
}
printf("\n");
return OK;
}
5)构建线索的意义
a.时空复杂度的改良
对于遍历结点,二叉树和线索二叉树的时间复杂度都为O(n),都要遍历每一个结点。
对于空间复杂度,二叉树的空间复杂度为O(n),而线索二叉树的空间复杂度为O(1),线索二叉树大大改良了空间的占用。
b.充分利用信息
对于二叉树的指针NULL,如果不用线索二叉树结构,则会有n+1个指针指向NULL,浪费了空间。而且根据某一顺序找前驱和后继也麻烦。
而用线索二叉树,不仅将n+1个指针利用了起来,指向结点的前驱和后继,这样更方便。
因此,线索二叉树改良了空间复杂度,将NULL指针指向前驱和后继,充分利用了空间。