一、树的基本概念
1. 树的定义
树是N个结点的有限集合,N=0,为空树。树是一种逻辑结构,同时也是一种分层结构。树的定义是递归的:
- 有且仅有一个根节点
- 当N>1时,其余结点可分为m个互不相交的子集,每一个子集又是一棵树,称为根节点的子树
每个结点只有一个前驱但是可以有0个或多个后继
树适合表示具有层次关系的数据结构。
2. 基本术语
- 祖先结点/子孙结点/孩子结点/兄弟结点
- 结点的度:树中一个结点的子节点个数,树中结点最大的度称为树的度
- 分支结点(非终端结点):度大于0的结点;叶节点(终端节点):度为0
- 结点的层次/结点的高度/结点的深度/树的高度(最大层数)
- 有序数/无序树:树中结点的子树从左到右是有序的
- 路径/路径长度:两个结点之间所经过的结点序列/路径上经过的边数(同一双亲结点的孩子结点之间不存在路径)
- 森林:m棵不相交的子树的集合
3. 树的性质
- 树中结点数等于所有节点的度数+1
- 度为m的树中第i层上至多有mi-1个节点
- 高度为h的m叉树至多有(mh-1)/(m-1)个结点(求和公式)
- 具有n个结点的m叉树的最小高度为[logm(n(m-1)+1)]
补充:
- 树的路径长度是所有路径长度的总和
- 总结点数=N0+N1+…+Nn
- 总分支数=1xN1+2xN2+…+nxNn
- 总结点数=总分支数+1
二、二叉树
- 二叉树与度为2的树的区别
(1)度为2的树至少有3个结点,而二叉树可以为空
(2)度为2的树的孩子结点的所有次序是相对于另一个孩子结点来说的,如果只有一个孩子结点就不分次序;如果是二叉树,结点的次序不是相对于另一个节点而言的而是其自己本身就具有顺序性 - 几个特殊的二叉树
(1)满二叉树
如果对二叉树按层次编号,则有对于编号为i的结点,如果有双亲,则双亲为i/2(取地板),左孩子为2i,右孩子为2i+1【前提是在他们都存在的情况下】
(2)完全二叉树
按照满二叉树的编号方式能够一一对应上
- 当i<=n/2( 取地板)的时候,都是分支结点,否则为叶子结点
- 叶子结点只能出现在层次最大的两层上,对于最大层次中的叶子结点,都依次在该层的最左边的位置上
- 如果有度为1的结点,只能有1个,且该结点只有左孩子没有右孩子
- 按照层序编号后,一旦出现某结点为叶子结点或者只有一个左孩子,则该节点后面的结点全都是叶子结点
- 如果n为奇数,则每个分支结点都有左孩子和右孩子,如果为偶数,则编号最大的分支结点只有左孩子
(3)二叉排序树
左子树的关键字都小于根,右子树都大于根。
(4)平衡二叉树
树上任一结点的左子树和右子树的深度差不超过1
- 二叉树的性质
(1)叶子结点个数等于度为2的节点个数+1
(2)高度为H的二叉树至多有2H-1
(3)含有N个结点的完全二叉树的高度是log2(N+1)【取天棚】 - 二叉树的存储结构
(1)顺序存储结构
树的顺序存储只是代表从上到下从左到右的关系,但是二叉树的顺序存储不仅是单纯的存储数据,还表示了结点之间的关系。
(2)链接存储结构
一般二叉树都采用链接存储结构,适用一个链表来存储一棵二叉树,至少包括3个域:数据域data,左指针域lchild,右指针域rchild,根据不同的需要增加不同的指针域。
typedef struct BiTNode{
Elemtype data;
struct BiTNode *lchild,*rchild;
}BiTNode,*BiTree;
n个结点的二叉链表中含有n+1个空指针域
二叉树的遍历
1. 先序遍历/中序/后序(就是改变visit的位置)
void PreOrder(BiTree T){
if(T!=NULL)
{
visit(T);
PreOrder(T->lchild);
PreOrder(T->rchild);
}
}
在递归遍历中,递归工作栈的栈深恰好为树的深度,所以在最坏的情况下,二叉树是有n个结点且深度为n的单支树,时间复杂度是O(n)
非递归形式:
//中序遍历
/*
将根节点及其左子树全部入栈操作,但不进行访问,当结点为空,停止入栈;
访问栈顶元素作为当前结点并出栈,如果当前结点有右子树则遍历访问其右子树
栈顶元素上一个元素就是当前元素的父节点
*/
void InOrder2(BiTree T){
InitStack(S);
BiTree p = T;
while(p||!IsEmpty(s))
{
if(p){
Push(S,p);
p=p->lchild;
}
else{
Pop(S,p);
visit(p->data);
p=p->rchild;
}
}
}
//先序遍历
/*
当栈不为空或当前结点不为空时,执行操作;
取栈顶元素为当前结点,并出栈,如果当前结点有右子树,遍历其右子树
*/
void PreOrder2(BiTree T)
{
BiTree p=T;
while(p||IsEmpty(s))
{
if(p){
visit(p->data);
Push(S,p);
p=p->lchild;
}
else{
Pop(S,p);
p=p->rchild;
}
}
}
//后序遍历
/*后序遍历和前面的有所区别,要后序遍历需要节点访问两遍,所以需要一个专门的count部分记录当前节点被访问的次数*/
typedef struct NewNode{
BiTree t;
int count;
}Node,*pNode;
void PostOrder2(BiTree t){
if(T == nullptr)
return nullptr;
Node p;
p.count = 0;
p.t = t;
InitStack(S);
Push(S,p);
while(!IsEmpty(S)){
Node q;
Pop(S,q);
if(q.count == 0)
{
q.count ++;
Push(S,q);
if(q.t.lchild != nullptr)
{
q.t = q.t.lchild;
Push(S,q);
}//开始第一次一定是先访问左节点此时count=0,在第一次进入的时候判断是否有左节点可以进入
}
else if (q.count ==1 )//当count是1的时候,已经访问一次了该访问右节点了
{
q.count ++;
Push(S,q);
if(q.t.rchild!=nullptr)
{ q.t = q.t.rchild;
Push(S,q);
}
}
else if(q.count == 2)//计数两次可以访问了
{
visit(q.t.data);
}
}
}
2. 层次遍历
思路:使用层次遍历需要一个队列,首先将根节点入队,然后判断当前根节点是否有左右子树,如果有左右子树则按左右的顺序进入,按照正常的出队的顺序弹出
void LevelOrder(BiTree t){
InitQueue(Q)
BiTree p;
EnQueue(Q,p);
//不用再设置一个变量更新p没有用
while(!IsEmpty(Q)){
DeQueue(Q,p);
visit(p->data);
if(p->lchild!=nullptr)
{
//temp =temp-> lchild;
EnQueue(Q,p->lchild);
}
if(p->rchild!=nullptr)
{
//temp = temp -> rchild;
EnQueue(Q,p->rchild);
}
}
}
对于一棵已知树求节点的双亲、求结点的孩子结点、求二叉树的深度、求二叉树的叶子节点个数、判断两棵二叉树是否相同
3. 由遍历序列构造二叉树
- 二叉树的先根序列和‘#’(随便指定一个分界符)表示空指针,可以创建一棵二叉树
可以利用一个栈来操作 - 二叉树的先序序列和中序序列可以唯一构建一棵二叉树
根据先序序列确定当前要判断的一组节点的根节点,然后去中序序列中找到这个节点,将所有元素划分成两个部分,左侧的属于左子树,右侧属于右子树(很明显就递归) - 二叉树的后序序列和中序序列可以唯一构建一棵二叉树
和上面同理,不过后序序列从最后一个往前遍历,先右后左,然后和上面的方法相同 - 二叉树的层次序列和中序序列可以唯一构建一棵二叉树
4. 线索二叉树
- 二叉树的各种遍历实际上是非线性结构的线性化操作,使在访问中的每一个节点都有一个直接前驱和直接后继。
- 利用空指针存储各种遍历的前驱和后继指针,加快查找前驱和后继节点的速度。
- 线索二叉树的存储结构:
/*
结构说明;
data:数据域
lchild:ltag=1指向结点的前驱;ltag=0指向结点的左子树
rchild:rtag=1指向结点的后继;rtag=0指向结点的右子树
*/
typedef struct ThreadNode{
ElemType data;
struct ThreadNode *lchild,*rchild;
int ltag,rtag;
}ThreadNode,*ThreadTree;
由这种结点结构构成的二叉链表作为二叉树的存储结构,叫做线索链表;指向结点前驱和后继的指针叫做线索;加上线索的二叉树叫做线索二叉树;对线索二叉树的某种次序的遍历过程叫线索化
- 线索二叉树的构造
实际上只是遍历一次二叉树,只是在遍历过程中,检查当前结点左右指针是否为空,若为空,则将它们改为指向前驱结点或后继结点的线索。最后还要处理一下最后一个结点。
void InThread(ThreadTree &p,ThreadTree &pre){
if(p!=nullptr){
InThread(p->lchild,pre);
if(p->lchild==nullptr){
p->lchild = pre;
p->ltag = 1;
}
if(pre->rchild==nullptr&&pre!=nullptr)
{
pre->rchild = p;
pre->rtag = 1;
}
pre = p;
InThread(p->rchild,pre);
}
}
void CreateInThread(ThreadTree T){
ThreadTree pre = nullptr;
if(T!=nullptr){
InThread(T,pre);
pre->rchild = nullptr;
pre->rtag=1;
}
}
也会在二叉树的线索链表上添加一个头节点,其lchild域的指针指向二叉树的根节点,其rchild域的指针指向中序遍历是访问的最后一个节点。反之,第一个结点的lchild域和最后一个节点的rchild域均指向头节点。(建立一个双向线索链表,可以从第一个节点起后继遍历,也可以从最后一个结点起顺前驱遍历)
- 线索二叉树的遍历
/*
可以利用线索二叉树实现二叉树遍历的非递归算法
a/如果rtag=1 则rchild指向中序后继
b/如果rtag=0 则中序后继应该是以当前节点为根的子树的中根序列的首节点
*/
ThreadNode *Firstnode(ThreadNode *p){
while(p->ltag==0)
p=p->lchild;
return p;
}
ThreadNode *Nextnode(ThreadNode *p){
if(p->rtag == 1) return p->rchild;
return Firstnode(p->rchild);
}
//主函数
void Inorder(ThreadNode *T){
for(ThreadNode *p=Firstnode(T);p!=nullptr;p=Nextnode(T))
visit(p);
}
三、树、森林
1. 树的存储结构
树的存储方式有很多种,既可以采用链式存储也可采用顺序存储,关键在于能够唯一的反映出树中各结点之间的逻辑关系。常用三种表示方法如下:
- 双亲表示法
采用一组连续的存储空间来存储每个结点,同时在每个结点中增设一个伪指针,指示其双亲结点在数组中的位置。根节点的下标为0,其伪指针域为-1。该结构可以很快的得到每个结点的位置,但是求结点的孩子结点就需要遍历整个结构。
# define MaxSize 100
//树的结点定义
typedef struct{
ElemType data;
int parent;
}PTNode;
//树的类型定义
typedef struct {
PTNode nodes[MaxSize];//双亲表示
int n;//结点数
}PTree;
- 孩子表示法
将每个结点的孩子结点都用单链表链接起来形成一个线性结构,N个结点就有N个孩子链表。叶子节点的孩子链表是空表。在寻找当前节点的双亲节点的时候需要遍历整个结构。
- 孩子兄弟表示法
以二叉链表作为树的存储结构。孩子兄弟表示法是使每个结点包含三部分内容:结点值、指向结点第一个孩子结点的指针和指向结点下一个兄弟结点的指针(沿此域可以找到所有的兄弟结点)
typedef struct CSNode{
ElemType data;
struct CSNode *firstchild,*nextsibling;
}CSNode,*CSTree;
缺点:查找双亲结点比较麻烦,如果为每个结点增设一个parent域指向其父节点,则查找父节点也很方便。
2.树、森林与二叉树的转换
树和二叉树都可以用二叉链表表示,可以由二叉链表导出二者的对应关系:给定一棵树🈶️唯一一棵二叉树与之对应。
从物理结构上说,树的孩子兄弟表示法与二叉树的二叉链表表示法相同.可以用同一种结构的不同解释将一棵树转化为二叉树。
- 树转换成二叉树的规则:
每个结点的左指针指向它的第一个孩子结点,右指针指向它在树中的相邻兄弟结点,由于根节点没有兄弟,所以,由树转换成的二叉树没有右子树。
- 森林转换成二叉树的规则:
先将森林中的每棵树都转换成二叉树,然后将第一个二叉树的根节点作为转化后的二叉树的根节点,第一棵树的左子树作为转换后二叉树根的左子树,第二棵树作为转换后的右子树,第三棵树作为转化后的二叉树的根的右子树。
(利用了树转换成的二叉树没有右子树) - 二叉树转换成森林的规则:
如果二叉树非空,则二叉树根及其左子树为第一棵树的二叉树形式。二叉树根的柚子树又可以看作是一个由除第一棵树外的森林转换成的二叉树,直到最后产生一棵没有右子树的二叉树为止。
3. 树和森林的遍历
没有中序遍历,一个节点有多个子树,没有办法看哪个是中间的。
树的遍历
(1)先根遍历
如果树非空,则先访问根结点,再按从左到右的顺序遍历根结点的每一棵子树,其访问顺序与这棵树的对应的二叉树的先序遍历顺序相同。
(2)后根遍历
若树非空,则按从左到右的顺序遍历根节点的每一棵子树,之后再访问根结点的每一棵子树,之后再访问跟节点,其访问顺序和这棵树对应的二叉树的中序遍历顺序相同。
(3)层次遍历
层次遍历与二叉树的层次遍历思想基本相同,即按层序依次访问各结点。
森林的遍历
(1)先序遍历森林
- 访问森林中的第一棵树的根结点
- 先序遍历第一棵树的根结点的子树森林
- 先序遍历除去第一棵树之后剩余的树构成的森林
(2)中序遍历森林
- 中序遍历森林中的第一棵树的根结点的子树森林
- 访问第一棵树的根结点
- 中序遍历除去第一棵树之后剩余的树构成的森林
树和森林的遍历和二叉树遍历的关系
树 | 森林 | 二叉树 |
---|---|---|
先根遍历 | 先序遍历 | 先序遍历 |
后根遍历 | 中序遍历 | 中序遍历 |
树的顺序表示:
- 先根序列和结点次数
- 后根序列和结点次数
4. 树的应用——并查集
并查集是一种简单的集合表示,支持三种操作:
Union(S,Root1,Root2):
把集合S中子集合Root2并入子集合Root1中。要求Root1和Root2互不相交,否则不执行合并Find(S,x):
查找集合S中单元素x所在的子集合并返回该子集合的名字Initial(S):
将集合S中的每一个元素都初始化为只有一个元素的集合
使用双亲表示作为并查集的存储结构,每个子集合以一棵树表示,所有表示子集合的树,构成表示全集合的森林,存放在双亲表示数组内,通常用数组元素的下标表示元素名,根结点的下标代表子集合名,根结点的双亲结点为负数。
两个子集合合并,只需将其中一个子集合的根结点的双亲指针指向另一个集合的根结点即可
#define SIZE 100
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){
//要求Root1和Root2是不同的,且表示子集合的名字
S[Root2]=Root1;
}
四、树与二叉树的应用
(一)二叉排序树(BST树)/二叉查找树
. 定义:
- 若左子树非空,则左子树上所有结点的关键字值均小于根结点的关键字值
- 若右子树非空,则右子树上所有结点的关键字值均大于根结点的关键字值
- 左右子树本身也分别是一棵二叉排序树
对二叉排序树进行中序遍历可以得到一个递增的有序序列
1. 二叉排序树的查找
思路:
从根结点开始,沿某一个分支逐层向下进行比较。如果二叉排序树非空,将给定值与根节点的关键字比较,若相等则查找成功;若不等,如果根节点大于给定关键字值,在根结点的左子树中查找;如果根结点小于给定关键字值,在根结点的右子树中寻找。
/*
函数功能:查找函数返回指向关键字值为key的结点指针,若不存在,则返回null
p:指向被查找结点的双亲,用于插入和删除操作中
*/
BSTNode *BST_Search(BiTree T,ElemType key,BSTNode *&p){
p=nullptr;
while(T!=nullptr&&key!=T->data){
p=T;
if(key<T->data) T = T->lchild;
else T = T->rchild;
}
return T;
}
2. 二叉排序树的插入
二叉排序树作为一种动态集合,其特点是树的构造不是一次生成的,而是在查找过程中不存在关键字等于给定值的结点时再进行插入。插入的新节点一定是某个叶节点。
int BST_Insert(BiTree &T,KeyType k){
if(T == nullptr)
{
T = (BiTree)malloc(sizeof(BSTNode);
T->key = k;
T->lchild = T->rchild = nullptr;
return 1;
}
else if(k == T->key)
return 0;
else if(k < T->key)
BST_Insert(T->lchild,k);
else{
BST_Insert(T->rchild,k);
}
}
3. 二叉排序树的构造
二叉树的构造实际上就是从输入的第一个元素开始构建排序二叉树。所以只需要一个循环调用二叉树插入部分即可。
void Creat_BST(BiTree &T,KeyType str[],int n){
T = nullptr;
int i = 0;
while(i<n){
BST_Insert(T,str[i]);
i++;
}
}
4. 二叉排序树的删除
思路:把删除结点从存储二叉排序树的链表中取下来,将因删除而断开的链表重新连接,但是要保留二叉排序树本来的性质。
如果是删除考虑以下三种情况:(二叉树中结点也就这三种位置情况)
- 如果被删结点是叶节点——直接删除
- 如果被删结点只有一棵左子树或者右子树——使用左子树或者右子树直接代替被删结点的位置
- 如果被删结点有左右两棵子树——(想办法转换为前两种情况)将被删结点的中序排列序列的第一个复制到被删除结点的位置上,然后删除这个中序排列的第一个元素,如果仍然是有左右两棵子树继续重复这个操作,直到到情况1和情况2,而且也一定能到情况1 和情况2
思考:若在二叉排序树中删除并插入某结点,得到的二叉排序树是否和原来的相同?
如果删除的结点是叶结点的话,得到的二叉树和原来的相同;
如果不是叶节点的话,由于插入节点一定是插入在二叉树的叶节点部分,而删除的时候结点是二叉树的非叶节点,所以二者一定不同。
5. 二叉排序树查找效率分析
-
二叉排序树的高度为H,则复杂度为
O(H)
如果二叉树退化(输入有序序列形成单链表),复杂度为O(n)
-
从查找过程上看,二叉排序树与二分查找相似;就平均时间性能来看,二叉排序树上的查找和二分查找差不多。但
二分查找的判定树唯一,二叉排序树不唯一
。相同的关键字插入顺序不同,得到的二叉树不同。
什么时候用二叉排序树?什么时候用二分查找?
【二叉排序树的构造是为了查找比较,所以和二分查找的最终目的是一样的,但是二叉排序树在插入和删除结点只需要更改指针的指向。插入和修改时,二叉排序树O(logN)
,二分查找O(N)
】
当有序表是静态查找表时,使用顺序表作为存储结构,采用二分查找实现查找操作;
当有序表时动态查找表时,使用二叉排序树作为逻辑结构。
(二)二叉平衡树(AVL树)
在插入和删除时保证任意结点的左右子树的高度差的绝对值不超过1,这样的二叉树称为二叉平衡树。
平衡因子:结点左子树和右子树的高度差(-1,1,0)
(一般用左节点-右节点)
1. 二叉平衡树的插入
思路:首先要检查其插入路径上的结点是否因为这次操作导致了不平衡,如果导致不平衡,首先应该找到插入路径上离插入节点最近的平衡因子绝对值大于1的结点A,再对以A为根的子树,在保持二叉排序树特性的前提下,调整各结点之间的位置关系,使其重新达到平衡。(从下往上一点点调整)
每次调整的都是最小不平衡子树——在插入路径上离插入节点最近的平衡因子的绝对值大于1 的结点作为根的子树。
根据失去平衡后进行调整的规律归纳为以下4种情况:
LL、RR、RL、LR
左孩子的左子树插入新结点:
(1)A结点向下旋转变成B结点的右子树
(2)B结点代替A变成根结点
(3)原B结点的右子树变成A结点的左子树
右孩子的右子树插入新结点:
左边变化同上方法。
右孩子的左子树插入新结点:
先将A结点的右孩子B的左子树的根结点C向右上旋转到(右上旋转包括根结点的替换和替换上去的结点的有一个子树被拆下换到原来根结点的子树部分)B结点位置,然后再将C结点旋转到左上替换到A结点的位置。
左孩子的右子树插入新结点:
同上。
2. 二叉平衡树的查找
- 含有n个结点的平衡二叉树的最大深度为
O(logN)
,也是平衡二叉树的平均查找长度 - Nh表示深度为h的平衡树中含有的最少结点数,N0=0,N1=1,N2=2,并且有Nh=Nh-1+Nh-2+1
- 求解给定结点数的平衡二叉树的查找所需的最多比较次数(或树的最大高度)——给结点数直接用其去求当这些作为最少的时候最深的高度是多少。
(三)哈夫曼树和哈夫曼编码
1. 相关定义
内通路长度:从根到每个内节点的路径长度之和
外通路长度:从根到每个外结点的路径长度之和
带权路径长度:从树的根结点到任意结点的路径长度(经过的边数)与该结点上权值的乘积
树的带权长度:树中所有带权路径长度之和
WPL = 0;
for( int i=1;i<=n;i++)
WPL = WPL+wi*Li;
最优二叉树/哈夫曼树:WPL最小的二叉树
扩充二叉树:每当二叉树中出现空子树时,就增加特殊的结点——空树叶,由此生成的二叉树。
2. 哈夫曼树的构造
哈夫曼算法:
- 将这N个结点分别作为N棵含一个结点的二叉树,构成森林F
- 构造一个新节点,并从中选取两棵根结点权值最小的树作为新结点的左右子树,并且将新节点的权值置为左右子树上根结点的权值之和
- 从F中删除刚才选择的两棵子树,同时将新得到的树加入F中
- 重复2、3,直至F中只剩下一棵树为止
3. 哈夫曼编码
- 固定长度编码:每个字符用同样长度的二进制位来表示
- 可变长度编码:不同字符使用不等长的二进制位来表示
- 数据压缩编码
- 前缀编码:没有一个编码是另一个编码的前缀
哈夫曼编码:将每个出现的字符作为一个独立的结点,其权值为它出现的频度,构造出对应的哈夫曼树。将字符的边编码解释为从根到该字符的路径上边标记的序列,其中边标记为0表示“转向左孩子”,标记为1表示“转向右孩子”
⚠️每个结点的权值都是其所有直接子节点之和
⚠️构造的哈夫曼树不唯一,但是各哈夫曼树的带权路径相同且为最优