写在前面:
- 本系列笔记主要以《数据结构(C语言版)》为参考(本章部分图片以及知识点整理来源于王道),结合下方视频教程对数据结构的相关知识点进行梳理。所有代码块使用的都是C语言,如有错误欢迎指出。
- 视频链接:第01周a--前言_哔哩哔哩_bilibili
- 哈夫曼树部分的代码参考了一位小伙伴分享的代码,特此说明一下,其它C代码均由笔者根据书上的类C代码进行编写。
一、树和二叉树的定义
1、树的定义
(1)树是n(n≥0)个结点的有限集,它或为空树(n=0),或为非空树。对于非空树T,有且仅有一个称之为根的结点(一棵树可以只有根结点,没有其它结点),除根结点以外的其余结点可分为m(m>0)个互不相交的有限集,其中每个集合本身又是一棵树,并且称为根的子树(下图中结点A有3棵子树)。
(2)树的结构定义是一个递归的定义,即在树的定义中又用到树的定义。
2、树的基本术语
(1)结点:树中的一个独立单元,包含一个数据元素及若干指向其子树的分支,如上图中的A、B、C、D等。(下面的术语均以上图为例来说明)
(2)结点的度:结点拥有的子树数称为结点的度,例如A的度为3,C的度为1,F的度为0。
(3)树的度:树的度是树内各结点度的最大值。上图所示的树的度为3。
(4)叶子:度为0的结点称为叶子或终端结点。结点K、L、F、G、M、I、J都是树的叶子。
(5)非终端结点:度不为0的结点称为非终端结点或分支结点,除根结点(非空树中无前驱结点的结点)之外,非终端结点也称为内部结点。
(6)双亲和孩子:结点的子树的根称为该结点的孩子,相应地,该结点称为孩子的双亲,例如B的双亲为A,B的孩子有E和F。
(7)兄弟:同一个双亲的孩子之间互称兄弟,例如H、I和J互为兄弟。
(8)祖先:从根到该结点所经分支上的所有结点,例如M的祖先为A、D和H。
(9)子孙:以某结点为根的子树中的任一结点都称为该结点的子孙,如B的子孙为E、K、L和F。
(10)层次:结点的层次从根开始定义,根为第一层,根的孩子为第二层,树中任一结点的层次等于其双亲结点的层次加1。
(11)堂兄弟:双亲在同一层的结点互为堂兄弟,例如结点G与E、F、H、1、J互为堂兄弟。
(12)树的深度:树中结点的最大层次称为树的深度或高度,上图所示的树的深度为4。
(13)有序树和无序树:如果将树中结点的各子树看成从左至右是有次序的(不能互换),则称该树为有序树,否则称为无序树,在有序树中最左边的子树的根称为第一个孩子,最右边的称为最后一个孩子。
(14)森林:m(m≥0)棵互不相交的树的集合。对树中每个结点而言,其子树的集合即为森林。由此,也可以用森林和树相互递归的定义来描述树。
3、树的性质
(1)树的结点数=总度数+1,这是因为除了根结点外,其它结点均在其双亲的子树中。
(2)度为m的树和m叉树的区别:
(3)度为m的树,第i层至多有个结点(i≥1);m叉树的第i层至多有个结点(i≥1)。
(4)高度为h、度为m的树至多有个结点;高度为h的m叉树至多有个结点。
(5)高度为h、度为m的树至少有h+m-1个结点;高度为h的m叉树至少有h个结点。
(6)具有n个结点的m叉树的最小高度为。
4、二叉树的定义
(1)二叉树(Binary Tree)是n(n≥0)个结点所构成的集合,它或为空树(n=0),或为非空树。对于非空树T,有且仅有一个称之为根的结点,除根结点以外的其余结点分为两个互不相交的子集T1和T2,分别称为T的左子树和右子树,且T1和T2本身又都是二叉树。
(2)二叉树与树一样具有递归性质,二叉树与树的区别主要有以下两点:
①二叉树每个结点至多只有两棵子树(二叉树中不存在度大于2的结点)。
②二叉树的子树有左右之分,其次序不能任意颠倒。
(3)二叉树的递归定义表明二叉树或为空,或由一个根结点加上两棵分别称为左子树和右子树的、互不相交的二叉树组成,由于这两棵子树也是二叉树,则由二叉树的定义,它们也可以是空树,由此,二叉树可以有5种基本形态。
(4)树与二叉树的关系:
①所有的树都能转为唯一对应的二叉树,二叉树的结构最简单,规律性最强。
②二叉树不是树的特殊情况,它们是两个概念,不过树的基本术语对二叉树都适用。
③二叉树每个结点位置或者说次序都是固定的,可以是空,但不可以说它没有位置,而树的结点位置是相对于别的结点来说的,没有别的结点时,它就无所谓左右了。
(5)两个特殊的二叉树:
①满二叉树:一棵高度为h,且含有个结点的二叉树。
[1]满二叉树只有最后一层有叶子结点。
[2]满二叉树不存在度为1的结点。
[3]按层序从1开始编号,结点i的左孩子为2i,右孩子为2i+1,结点i的父结点为(如果有的话)。
②完全二叉树:当且仅当其每个结点都与高度为h的满二叉树中编号为1~n的结点一一对应时,称为完全二叉树。
[1]完全二叉树只有最后两层可能有叶子结点。
[2]完全二叉树最多只有一个度为1的结点。
[3]按层序从1开始编号,结点i的左孩子为2i,右孩子为2i+1,结点i的父结点为(如果有的话)。
[4]的结点为分支结点,的结点为叶子结点。
[5]对任一结点,若其有分支下的子孙的最大层次为l,则其左分支下的子孙的最大层次必为或l+1。
[6]在满二叉树中,从最后一个结点开始,连续去掉任意个结点,即是一棵完全二叉树。
5、二叉树的性质
(1)设非空二叉树中度为0、1和2的结点个数分别为、和,则(叶子结点比二分支结点多一个)。
(2)二叉树第i层至多有个结点(i≥1)。
(3)高度为h的二叉树至多有个结点(结点数最大时为满二叉树),高度为h的完全二叉树至少有个结点。
(4)具有n(n > 0)个结点的完全二叉树的高度h为或。
(5)对于完全二叉树,可以由的结点数n(n > 0)推出度为0、1和2的结点个数为、和。
(6)如果对一棵有n个结点的完全二叉树(其深度为)的结点按层序编号(从第1层到第层,每层从左到右),则对任一结点i(1≤i≤n),以下结论成立。
①如果i=1,则结点i是二叉树的根,无双亲;如果i>1,则其双亲PARENT(i)是结点。
②如果2i>n,则结点i无左孩子(结点i为叶子结点);否则其左孩子LCHILD(i)是结点2i。
③如果2i+1>n,则结点i无右孩子;否则其右孩子RCHILD(i)是结点2i+1。
二、二叉树的存储结构
1、顺序存储结构
(1)顺序存储结构使用一组地址连续的存储单元来存储数据元素。为了能够在存储结构中反映出结点之间的逻辑关系,必须将二叉树中的结点依照一定的规律安排在这组单元中。
①对于完全二叉树,只要从根起按层序存储即可,依次自上而下、自左至右存储结点元素,即将完全二叉树上编号为i的结点元素存储在如上定义的一维数组中下标为i-1的分量中。
②对于一般二叉树,则应将其每个结点的编号与完全二叉树上的结点相对照,存储在一维数组的相应分量中。
(2)二叉树的顺序存储表示:
#define MAXSIZE 100 //二叉树的最大结点数
typedef char TElemType; //以字符型为例
struct TreeNode
{
TElemType value; //结点中的数据元素
bool isEmpty; //结点是否为空
}t[MAXSIZE + 1]; //0号位置不使用
enum Status
{
OVERFLOW,
ERROR,
OK
};
(3)很显然,这种顺序存储结构仅适用于完全二叉树,对于一般二叉树,可能会有大部分结点为空,但是必须为它们划分存储空间,这是非常不划算的。
2、链式存储结构
(1)由二叉树的定义得知,二叉树的结点由一个数据元素和分别指向其左、右子树的两个分支构成,则表示二叉树的链表中的结点至少包含3个域——数据域和左、右指针域(二叉链表,n个结点的二叉链表共有n+1个空链域);有时为了便于找到结点的双亲,还会在结点结构中增加一个指向其双亲结点的指针域(三叉链表)。
(2)二叉树的链式存储表示(二叉链表):
#define MAXSIZE 100
typedef char TElemType; //以字符型为例
typedef struct BiTNode
{
TElemType data;
struct BiTNode *lchild, *rchild; //左右孩子指针
}BiTNode, *BiTree;
enum Status
{
OVERFLOW,
ERROR,
OK
};
三、遍历二叉树
1、遍历二叉树算法描述
(1)遍历二叉树是指按某条搜索路径巡访树中每个结点,使得每个结点均被访问一次,而且仅被访问一次。访问的含义很广,可以是对结点进行各种处理,包括输出结点的信息,对结点进行运算和修改等。
(2)二叉树由3个基本单元组成——根结点、左子树和右子树,若能依次遍历这3个部分,便是遍历了整个二叉树。假如用L、D、R分别表示遍历左子树、访问根结点和遍历右子树,则可有DLR、LDR、LRD、DRL、RDL、RLD这6种遍历二叉树的方案。若限定先左后右,则只有前3种情况,分别称之为先(根)序遍历、中(根)序遍历和后(根)序遍历。
(3)三种遍历算法的具体步骤和实现:
①先序遍历:访问根结点,先序遍历左子树,先序遍历右子树。
void PreOrderTraverse(BiTree T) //先序遍历
{
if (T) //结点不存在,返回
{
printf("%c ", T->data); //访问结点数据域
PreOrderTraverse(T->lchild);
PreOrderTraverse(T->rchild);
}
}
②中序遍历:中序遍历左子树,访问根结点,中序遍历右子树。
void InOrderTraverse(BiTree T) //中序遍历
{
if (T) //结点不存在,返回
{
InOrderTraverse(T->lchild);
printf("%c ", T->data); //访问结点数据域
InOrderTraverse(T->rchild);
}
}
③后序遍历:后序遍历左子树,后序遍历右子树,访问根结点。
void PostOrderTraverse(BiTree T) //后序遍历
{
if (T) //结点不存在,返回
{
PostOrderTraverse(T->lchild);
PostOrderTraverse(T->rchild);
printf("%c ", T->data); //访问结点数据域
}
}
(4)三种算法的访问路径相同,只是访问结点的时机不同,每个结点会被经过三次:
①第一次经过时被访问——先序遍历。
②第二次经过时被访问——中序遍历。
③第三次经过时被访问——后序遍历。
(5)除了上述三种遍历算法外,还有一种层次遍历算法,其基本思想是初始化一个辅助队列,首先根结点入队,接着若队列非空,则队头结点出队,访问该结点,并将其左、右孩子插入队尾(如果有的话),然后重复上一步操作,直至队列为空。
void LevelOrder(BiTNode* T) //层次遍历
{
BiTNode* p;
SqQueue qu;
InitQueue(&qu); //初始化队列(注意这里的QElemType应为BiTNode*)
EnQueue(&qu, T); //根结点指针进入队列
while (qu.front != qu.rear) //队不为空,则循环
{
DeQueue(&qu, &p); //出队结点P
printf("%c ", p->data); //访问结点P
if (p->lchild != NULL) //有左孩子时将其进队
EnQueue(&qu, p->lchild);
if (p->rchild != NULL) //有右孩子时将其进队
EnQueue(&qu, p->rchild);
}
}
2、根据遍历顺序确定二叉树
(1)若二叉树中各结点的值均不相同,则二叉树结点的先序序列、中序序列和后序序列都是唯一的。
(2)由二叉树的先序序列和中序序列,或由二叉树的后序序列和中序序列可以确定唯一一棵二叉树。
①由二叉树的先序序列和中序序列确定二叉树:
在先序序列中,第一个结点一定是二叉树的根结点,另一方面,中序遍历是先遍历左子树,然后访问根结点,最后遍历右子树,这样,根结点在中序序列中必然将中序序列分割成两个子序列,前一个子序列是根结点的左子树的中序序列,而后一个子序列是根结点的右子树的中序序列,根据这两个子序列,在先序序列中找到对应的左子序列和右子序列。
在先序序列中,左子序列的第一个结点是左子树的根结点,右子序列的第一个结点是右子树的根结点,这样,就确定了二叉树的3个结点,同时左子树和右子树的根结点又可以分别把左子序列和右子序列划分成两个子序列,如此递归下去,当取尽先序序列中的结点时,便可以得到一棵二叉树。
②由二叉树的后序序列和中序序列确定二叉树:
依据后序历和中序遍历的定义,后序序列的最后一个结点,如同先序序列的第一个结点一样,可将中序序列分成两个子序列、分别为这个结点左子树的中序序列和右子树的中序序列,再拿出后序序列的倒数第二个结点、并继续分割中序序列,如此递归下去,当倒着取尽后序序列中的结点时,便可以得到一棵二叉树。
3、二叉树遍历算法的应用
(1)创建二叉树的存储结构——二叉链表(假设使用先序遍历):
①算法步骤:
[1]对于给定的一个字符序列,读入一个字符ch。
[2]如果ch是一个“#”字符,则表明该二叉树为空树,否则申请一个结点空间T,将ch赋给T->data,递归创建T的左子树,递归创建T的右子树。
②算法实现:
void CreatBiTree(BiTree* T) //创建二叉树的存储结构——二叉链表
{
TElemType n;
scanf("%c", &n); //比较好的是使用getchar函数获取字符赋给n
if (n == '#') //递归结束,建空树
*T = NULL;
else
{
*T = (BiTree)malloc(sizeof(BiTNode)); //生成根结点
(*T)->data = n;
CreatBiTree(&((*T)->lchild)); //递归创建左子树
CreatBiTree(&((*T)->rchild)); //递归创建右子树
}
}
(2)复制二叉树(假设使用先序遍历):
①算法步骤:若二叉树不空,则首先复制根结点,这相当于二叉树先序遍历算法中访问根结点的语句,然后分别复制二叉树根结点的左子树和右子树,这相当于先序遍历中递归遍历左子树和右子树的语句。
②算法实现:
void Copy(BiTree T, BiTree *NewT) //复制二叉树
{
if (T == NULL) //如果是空树,递归结束
{
NewT = NULL;
return;
}
else
{
*NewT = (BiTree)malloc(sizeof(BiTNode));
(*NewT)->data = T->data; //复制根结点
Copy(T->lchild, &((*NewT)->lchild)); //递归复制左子树
Copy(T->rchild, &((*NewT)->rchild)); //递归复制右子树
}
}
(3)计算二叉树的深度(假设使用先序遍历):
①算法步骤:如果是空树,递归结束,深度为;否则递归计算左子树的深度记为m,递归计算右子树的深度记为n,如果m大于n,二叉树的深度为m+1,否则为n+1。
②算法实现:
int Depth(BiTree T) //计算二叉树的深度
{
if (T == NULL)
return 0; //如果是空树,深度为0,递归结束
else
{
int m = Depth(T->lchild); //递归计算左子树的深度
int n = Depth(T->rchild); //递归计算右子树的深度
if (m > n) //二叉树的深度为m与n的较大者+1
return m + 1;
else
return n + 1;
}
}
(4)统计二叉树中结点的个数:
①算法步骤:如果是空树,则结点个数为0,递归结束;否则结点个数为左子树的结点个数加上右子树的结点个数再加上1。
②算法实现:
int NodeCount(BiTree T) //统计二叉树中结点的个数
{
if (T == NULL)
return 0;
else
return NodeCount(T->lchild) + NodeCount(T->rchild) + 1;
}
(5)统计二叉树中叶子结点的个数:
①算法步骤:如果是空树,则叶子结点个数为0,递归结束;否则递归遍历二叉树,如果一个结点没有左右孩子,说明是一个叶子结点。
②算法实现:
int LeafCount(BiTree T) //统计二叉树中叶子结点的个数
{
if (T == NULL)
return 0;
if (T->lchild == NULL && T->rchild == NULL) //没有左右孩子,是一个叶子结点
return 1;
else
return LeafCount(T->lchild) + LeafCount(T->rchild);
}
四、线索二叉树
1、线索二叉树的基本概念
(1)遍历二叉树是以一定规则将二叉树中的结点排列成一个线性序列,这实质上是对一个非线性结构进行线性化操作,使每个结点(除第一个和最后一个外)在这些线性序列中有且仅有一个直接前驱和直接后继。但是,当以二叉链表作为存储结构时,只能找到结点的左、右孩子信息,而不能直接得到点在任一序列中的前驱和后继信息,这种信息只有在遍历的动态过程中才能得到,为此引入线索二叉树来保存这些在动态过程中得到的有关前驱和后继的信息。
(2)由于有n个结点的二叉链表中必定存在n+1个空链域,因此可以充分利用这些空链域来存放结点的前驱和后继信息,规定如下:若结点有左子树,则其lchild域指示其左孩子,否则令lchild域指示其前驱;若结点有右子树,则其rchild域指示其右孩子,否则令rchild域指示其后继。为了避免混淆,尚需改变结点结构,增加两个标志域,其结点形式如下图所示。
(3)二叉树的二叉线索存储表示:
#define MAXSIZE 100
typedef char TElemType; //以字符型为例
typedef struct BiThrNode
{
TElemType data;
struct BiThrNode *lchild, *rchild; //左右孩子指针
int LTag, RTag; //左右标志
}BiThrNode, *BiThrTree;
enum Status
{
OVERFLOW,
ERROR,
OK
};
(4)以这种结点结构构成的二叉链表作为二叉树的存储结构叫作线索链表,其中指向结点前驱和后继的指针叫作线索,加上线索的二叉树称之为线索二叉树,对二叉树以某种次序遍历使其变为线索二叉树的过程叫作线索化。
(5)下图所示为中序线索二叉树,为了方便起见,可以仿照线性表的存储结构,在二叉树的线索链表上也添加一个头结点,并令其lchild域的指针指向二叉树的根结点,令其rchild域的指针指向中序遍历时访问的最后一个结点,同时令二叉树中序序列中第一个结点的lchild域指针和最后一个结点的rchild域指针均指向头结点(原本它们都为NULL),这好比为二叉树建立了一个双向线索链表,既可从第一个结点起顺后继进行遍历,也可从最后一个结点起顺前驱进行遍历。
(6)先序遍历线索二叉树和后序遍历线索二叉树之例:
2、构造线索二叉树
(1)由于线索二叉树构造的实质是将二叉链表中的空指针改为指向前驱或后继的线索,而前驱或后继的信息只有在遍历时才能得到,因此线索化的过程即在遍历的过程中修改空指针的过程,可用递归算法。对二叉树按照不同的遍历次序进行线索化,可以得到不同的线索二叉树,包括先序线索二叉树、中序线索二叉树和后序线索二叉树。
(2)中序线索化的实现(不带头结点):
BiThrTree pre = NULL; //声明全局变量,表示在中序遍历过程中当前结点的前驱
void InThreading(BiThrTree p)
{
if (p)
{
InThreading(p->lchild); //左子树递归线索化
if (!p->lchild) //p的左孩子为空
{
p->LTag = 1; //给p加上左线索
p->lchild = pre; //p的左孩子指针指向pre(前驱)
}
else
p->LTag = 0;
if (pre && !pre->rchild) //p的右孩子为空
{
pre->RTag = 1; //给p加上右线索
pre->rchild = p; //pre的右孩子指针指向p(后继)
}
else if (pre)
pre->RTag = 0;
pre = p; //保持pre指向p
InThreading(p->rchild); //右子树递归线索化
}
}
void CreateInThread(BiThrTree p) //以结点p为根的子树中序线索化
{
if (p != NULL) //非空二叉树才能线索化
{
InThreading(p);
if (pre->rchild == NULL)
pre->RTag = 1; //处理遍历的最后一个结点
}
}
(3)先序线索化的实现(不带头结点):
BiThrTree pre = NULL; //pre初始化为NULL
void PreThreading(BiThrTree p)
{
if (p)
{
if (!p->lchild) //p的左孩子为空
{
p->LTag = 1; //给p加上左线索
p->lchild = pre; //p的左孩子指针指向pre(前驱)
}
else
p->LTag = 0;
if (pre && !pre->rchild) //p的右孩子为空
{
pre->RTag = 1; //给p加上右线索
pre->rchild = p; //pre的右孩子指针指向p(后继)
}
else if (pre)
pre->RTag = 0;
pre = p; //保持pre指向p
if(p->LTag == 0) //判断lchild是不是前驱线索,不是才需要线索化
PreThreading(p->lchild); //左子树递归线索化
PreThreading(p->rchild); //右子树递归线索化
}
}
void CreatePreThread(BiThrTree p) //以结点p为根的子树先序线索化
{
if (p != NULL) //非空二叉树才能线索化
{
PreThreading(p);
if (pre->rchild == NULL)
pre->RTag = 1; //处理遍历的最后一个结点
}
}
(4)后序线索化的实现(不带头结点):
BiThrTree pre = NULL; //pre初始化为NULL
void PostThreading(BiThrTree p)
{
if (p)
{
PostThreading(p->lchild); //左子树递归线索化
PostThreading(p->rchild); //右子树递归线索化
if (!p->lchild) //p的左孩子为空
{
p->LTag = 1; //给p加上左线索
p->lchild = pre; //p的左孩子指针指向pre(前驱)
}
else
p->LTag = 0;
if (pre && !pre->rchild) //p的右孩子为空
{
pre->RTag = 1; //给p加上右线索
pre->rchild = p; //pre的右孩子指针指向p(后继)
}
else if (pre)
pre->RTag = 0;
pre = p; //保持pre指向p
}
}
void CreatePostThread(BiThrTree p) //以结点p为根的子树后序线索化
{
if (p != NULL) //非空二叉树才能线索化
{
PostThreading(p);
if (pre->rchild == NULL)
pre->RTag = 1; //处理遍历的最后一个结点
}
}
3、遍历线索二叉树
(1)在中序线索二叉树中找p指针所指结点的中序后继:
①若p->RTag为1,则p的右链指示其后继。
②若p->RTag为0,则说明p有右子树,根据中序遍历的规律可知,结点的后继应是遍历其右子树时访问的第一个结点,即右子树中最左下的结点。
(2)在中序线索二叉树中找p指针所指结点的中序前驱:
①若p->LTag为1,则p的左链指示其前驱。
②若p->LTag为0,则说明p有左子树,结点的前驱是遍历左子树时最后访问的一个结点,即左子树中最右下的结点。
(3)在先序线索二叉树中找p指针所指结点的先序后继:
①若p->RTag为1,则p的右链指示其后继。
②若p->LTag为0,则说明p有左子树,若*p是其双亲的左孩子,则其前驱为其双亲结点,否则应是其双亲的左子树上先序遍历最后访问到的结点。
(4)在先序线索二叉树中找p指针所指结点的先序前驱:
①若p->LTag为1,则p的左链指示其前驱。
②若p->RTag为0,则说明p有右子树,根据中序遍历的规律可知,结点的后继应是遍历其右子树时访问的第一个结点,即右子树中最左下的结点。
(5)在后序线索二叉树中找p指针所指结点的后序后继:
①若*p是二叉树的根,则其后继为空。
②若*p是其双亲的右孩子,则其后继为双亲结点。
③若*p是其双亲的左孩子,且*p没有右兄弟,则其后继为双亲结点。
④若*p是其双亲的左孩子,且*p有右兄弟,则其后继为双亲的右子树上按后序遍历列出的第一个结点(右子树中最左下的叶结点)。
(6)在后序线索二叉树中找p指针所指结点的后序前驱:
①若p->LTag为1,则p的右链指示其后继。
②若p->LTag为0,当p->RTag也为0时,则p的右链指示其前驱;若p->LTag为0,而p->RTag为1时,则p的左链指示其前驱。