一、树的基本概念

1.树的定义

树是一种逻辑结构,一种分层结构,更是一种递归的数据结构
树具有以下两个特点:

  • 根节点没有前驱,除了根节点以外的所有节点有且只有一个前驱(即不可能出现一个结点有多个双亲,两个结点不能共享一个儿子)
  • 树中所有结点都可以有零个或多个后继

树中的某个结点(除根节点)最多只和上一层的一个结点有直接关系,n个结点的树有n-1条边
树中的每个结点可以与其下一层的零个或多个结点有直接关系

2.基本术语

  • 祖先,子孙,双亲,孩子,兄弟,堂兄弟
  • 结点的度:结点的孩子个数;树的度:树中结点的最大度数
  • 分支结点,叶结点
  • 结点的层次:根节点是第1层,从上往下依次增加层数
    结点的深度:结点所在的层数
    结点的高度:以该结点为根的子树高度
    树的高度(深度):树中结点的最大层数
  • 有序树,无序树
  • 路径:两结点之间经过结点的序列(从上到下,同一双亲的两个孩子之间不存在路径)
    路径长度:路径上经过的边的个数

3.区分两个概念

  • 度为m的树
    *至少有一个结点的度=m
    *一定是非空树
  • m叉树
    *允许所有结点的度数<m,即不一定非得有度为m的结点
    *可以是空树

4.树的性质

1.结点数n =所有结点的度数之和+1
解释:所有结点的度数之和其实就是树中边数之和,边数和所有节点数相差1
2.度为m的树的第i层最多有m的(i-1)次方个结点(同m叉树)
请添加图片描述
3.高度为h的m叉树最多有(m的h次方-1)/(m-1)个结点等比数列,首项1,公比m,项数为h
上图例子中,总节点数:1+3+3²+3³+……=(3的h次方-1)/2
概括一下,知道度数m,高度h的情况下:

  • 用h直接可以得到最多(完全二叉树)的总节点数
  • 要得到单独这一层的结点树,需要用h-1,用到的公式如上

如果求最少有多少个结点:
请添加图片描述

4.若求度为m,具有n个结点的树的最小高度是多少
利用不等式,因为是最小高度,所以孩子越多越好,考虑完全二叉树
(m的h-1次方-1)/(m-1)≤n≤(m的h次方-1)/(m-1)
5.度为m,具有n个结点的树的最大高度h为 n-m+1(见上图2)

二、二叉树

1.定义

每个节点至多只有两棵子树,度为2,左右次序不可颠倒,是有序树

2.几种特殊的二叉树

  • 满二叉树
    没有度为1的结点
    若高度为h,一共有2的h次方-1个结点(公式再记一下)
    叶节点都集中在最下一层
    若从根节点开始为1,从上到下从左至右编号,i号结点左儿子为2i,右儿子为2i+1
    叶节点数=上面的非叶节点数+1=2的h-1次方
  • 完全二叉树
    满二叉树是完全二叉树的一个子集
    和满二叉树的规则几乎一样,但是可以不满
    最多有1个度为1的结点,且这个节点只可能有左孩子不可能有右孩子
    若一个节点只有左孩子,那么它之后的所有结点全部都是叶节点
    n为奇数,则不会出现度为1的点;n为偶数,就有一个结点只有左孩子
    叶节点只会在最后两层出现
    以n/2(向下取整)为分界线,编号i小于分界就是分支节点,大于就是叶节点
  • 二叉排序树(左<根<右)
  • 平衡二叉树(任何一个节点的左右子树高度之差绝对值不超过1)
  • 正则二叉树(树中只有度为0和2的结点)

3.二叉树的性质

1. 非空二叉树的叶节点数=度为2的节点数+1,即n0=n2+1
证明(这个思想做题常见):
我们想一棵树结点个数有这样两种的表示方法

  • 度为0的点个数+度为1的点个数+度为2的点个数+……+度为m的点个数
  • 所有结点的度数之和+1(上节提过)

所以表示一棵树的结点总数n,我们有
n0+n1+n2=n1+2×n2+1
化简得n0=n2+1
在完全二叉树中,可以有以下的推论:
由n0=n2+1
可得n0+n2=2×n2+1,这说明度为0和度为2得结点数之和是个奇数
我们还知道度为1的节点数是0或者1
所以总结点数n:
n=2k-1奇数,说明没有n1,n0=k,n1=0,n2=k-1
n=2k偶数,说明有一个n1,n0=k,n1=1,n2=k-1
2. 某层节点数,高度为h的节点数分别可以用上届的公式计算
3. 结点i所在的层次为log2i(向下取整)+1,和上节不等式的公式一样

4.二叉树的存储结构

  • 顺序存储结构
    取一段连续的存储空间,按从上到下,从左至右的顺序依次存储完全二叉树中的各个结点
    在这里插入图片描述
    它实际上只存储了结点数据域之值,而未存储其左孩子和右孩子地址,通过下标的计算可找到一个结点的子结点和父结点。T[0]=NULL,保证数组下标和节点编号一致
    非常适合于完全二叉树。但是,应用到非完全二叉树时,空的话在数组里也空着,造成空间浪费
    在遇见非完全二叉树时也无法表示父子的逻辑关系(完全二叉树有i,2i,2i+1的关系)
struct TreeNode{
       ElemType value;
       bool IsEmpty;//判断结点是否为空
}
  • 链式存储结构
    在这里插入图片描述
    描述如下:
typedef struct BiTNode{
        ElemType data;
        struct BiTNode *lchild,*rchild;
}BiNode,*BiTree;

在含有n个节点的二叉链表中,有n+1个空链域
解释:2n个指针域,n-1个头上有东西(除根结点),相减得n+1
在这里插入图片描述

5.二叉树的遍历

  • 递归遍历(需要用栈,递归工作栈)
    先序遍历,中序遍历,后序遍历
先序遍历
void PreOrder(BiTree T){
     if(T==NULL) return;
     visit(T);
     PreOrder(T->lchild);
     PreOrder(T->rchild);
}
中序遍历
void InOrder(BiTree T){
     if(T==NULL) return;
     InOrder(T->lchild);
     visit(T);
     InOrder(T->rchild);
}
后序遍历
void PostOrder(BiTree T){
     if(T==NULL) return;
     PostOrder(T->lchild);
     PostOrder(T->rchild);
     visit(T);
}

在上面三种遍历方式中,每个节点都访问一次且只访问一次,时间复杂度是O(N)
在递归遍历中,递归工作栈深恰好为树的深度,所以最坏情况n个节点深度为n的单支树,空间复杂度O(N)
特别的,后序遍历是逐步压栈的,在遍历完左右子树之后弹栈可返回根结点,也就是说,给定任一个结点,我能通过后序遍历序列找到他的双亲,于是后序遍历可以找到从m到n的路径

  • 非递归遍历(也需要用栈),防止递归太多层导致系统栈溢出
  • 层次遍历(需要用到队列)
    按层数由小至大,同层从左至右的顺序访问二叉树
    在这里插入图片描述
void LevelOrder(BiTree T){
     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);
  }
}

完全二叉树的辅助队列最大规模:n/2(向上取整)
想:队列元素最多的时候应该是全是最后一层的叶结点的时候,叶节点数=n/2

  • 由遍历序列构造二叉树
    中序序列+任意一种序列=一棵确定的二叉树
    因为中序能找到根的左和右子树
    做题时常出,给定两种序列让求对应的二叉树或者可能的二叉树
    先序,中序的关系相当于:先序为入栈顺序,中序为出栈顺序
    于是若问一个先序序列能对应多少种不同的二叉树,就按卡特兰数公式计算

6.线索二叉树

二叉树是一种逻辑结构,而线索二叉树既表示逻辑又有存储,所以是一种物理结构

  • 动机
    在二叉树(结点无父指针域)上只能找到结点的左孩子、右孩子,找结点的中(先、后)根前驱和后继只能通过从头到尾遍历
  • 如何通过遍历找前驱后继?(pre指针)
    我们在没有对二叉树进行任何改进的时候,想找到某一个结点的遍历序列(比如中序)的前驱和后继,注意这里的前驱后继不是结点的双亲孩子这一套,而是在中序遍历后得到的遍历序列中某一个结点的前驱与后继。
    我们可以使用指针pre来记录前驱的结点
    就比如,假设我们要研究的结点是p,先用一个指针q指向序列的第一个节点(因为这是只能从前往后遍历),对应pre指针指向NULL(第一个结点没有前驱结点),随后看看q是否等于p,如果是则pre为所找的前驱,否则q,pre指针向后移。当然,如果pre和p相等,那么此时的q就自然是p的后继
伪代码,找前驱
while(q!=p){
  pre=q;
  q=q->next;
}
return pre;
找后继
while(pre!=p){
  pre=q;
  q=q->next;
}
return q;
  • 线索二叉树的基本概念
    我们知道,在n个节点的二叉树中,一共有n+1个空指针域(2n-(n-1)),于是我们想利用这些空指针来保存其指向前驱或后继的指针
    在这里插入图片描述
    这里,lthread(或lchild),rthread(或rchild)表示左右两个指针
    在线索二叉树中,这些指针域会被完全使用
    于是乎,这些指针要不指向自己的孩子,要不就指向前驱后继
    如何区分指针到底是二者中的哪一个含义呢?我们又引入int型的left(ltag)和right(rtag)的标志域
    标志域的含义如下
    前驱左,后驱右
    标志域等于0,代表指向左右孩子
    标志域等于1,代表指向前驱后驱,我们把这种指针成为线索
typedef struct ThreadNode{
   ElemType data;
   struct ThreadNode *lchild,*rchild;
   int ltag,rtag;
}
  • 中序线索化
    所谓线索化,无论什么序,其主要思想是,一开始二叉树里只有指向孩子的指针,现在要遍历找到那些空的指针,如果p的左指针空就指向前驱,如果pre的右指针就指向后继
void InThread(ThreadTree &q,ThreadTree &pre){
     if(q!=NULL){
       InThread(q->lchild,pre);//递归线索化左子树
       //以下为visit
       if(q->lchild!=NULL){
         q->lchild=pre;
         q->ltag=1;
       }
       if(pre!=NULL&&q->rchild!=NULL){
         pre->rchild=q;
         pre->rchild=1;
       }
       pre=q;
       InThrea(q->rchild,pre);//递归线索化右子树
     }
}

最后记得把最后一个节点的右指针指向NULL,表示没有后继

void CreatInThread(ThreadTree T){
     ThreadTree pre=NULL;
     if(T!=NULL){
       InThread(T,pre);
       pre->rchild=NULL;
       pre->rtag=1;
     }
}

先序,后序线索化只用调整visit的位置即可,和遍历相同
考虑这样的情况(先序):
在这里插入图片描述
在先序线索化的过程中,先visit,再线索化左子树,最后是右子树
但是上图中q在第三个点D的时候,visit时由于左指针为空指向pre,之后线索化左子树的时候就走回去了
为了防止这种倒退,转圈的情况,我们加一句限制:跳过已经被改为线索的指针

if(q->ltag==0){
  PreThread(q->lchild,pre);
}
  • 中序线索二叉树的遍历
    求中序线索二叉树中序序列下的第一个节点
ThreadNode *FirstNode(ThreadNode *p){
    while(p->ltag==0) p->p-lchild;//考虑中序第一个节点的特点,最左下结点(不一定是叶节点)
    return p;
}

求中序线索二叉树p在中序序列下的后继

ThreadNode *NextNode(ThreadNode *p){
    if(p->rtag==0) return FirstNode(p->rchild);//右子树的左下结点
    else return p->rchild;//如果右指针是线索直接就是后继了 
} 

推论:叶节点没有左右儿子,所以叶节点的前后继直接就能通过线索找到
求中序遍历序列

void Inorder(ThreadNode *T){
     for(ThreadNode *p=FirstNode(T);p!=NULL;p=NextNode(p)) visit(p);
}

不同方式遍历,求前后继的方法是不一样的
分析时想这么一幅图
在这里插入图片描述
把红点当作现在要分析的点,求他的前驱后继在各规则下的结果是什么
比如中根求前驱:如果左指针是线索直接找到,否则是p的左子树的中根序列的最后一个结点
求中根序列的最后一个结点方法:沿右分支下行,找第一个没有右孩子的结点

  • 特殊说明
    1.若结点结构中没有父指针:
    ✓前序线索二叉树不能解决高效查找结点的先根前驱的问题。
    后驱:有左儿子就是左儿子,没左儿子有右儿子是右儿子,叶节点直接是右指针
    ✓后序线索二叉树不能解决高效查找结点的后根后继的问题。
    前驱:右子树最后一个结点
    2.前序,中序线索树遍历不需要使用栈
    后序线索树,普通二叉树需要使用栈
    解释一下后序线索,因为我们说后序线索找后继困难,右子树遍历完没有指针回到根了,所以需要栈来保存信息;二叉树不用说,递归非递归都得使用栈

三、树与森林

1.树的存储结构

  • 双亲表示法
    在这里插入图片描述
    右边一列是伪指针,代表其双亲结点在数组中的位置
  • 孩子表示法
    在这里插入图片描述
  • 孩子兄弟表示法
    在这里插入图片描述
    孩子兄弟表示法又称为二叉树表示法
    结点包含三部分内容:**第一个孩子的指针,data,右边下一个兄弟的指针
typedef struct CSNode{
  ElemType data;
  struct CSNode *firstchild,*nextsibling;
}CSNode,*CSTree;

这种方法方便实现树转换为二叉树的操作,但是想从当前节点找到其双亲比较麻烦。

2.树,森林,二叉树的转换

完成这三种数据结构的核心是孩子兄弟表示法

  • 树转为二叉树
    无论每个结点的度是多少,转换为孩子兄弟表示法也就可以转换为二叉树
    事实上按着左孩子右兄弟的规则推就行
    当然,也有专门的画法:
    1.在兄弟结点之间连一条线
    2.对于每个节点,保留它与第一个孩子的连线,而与其他孩子的连线全部抹掉
    3.以树根为轴心,顺时针旋转45°(事实上这一步就是美观一下)
  • 森林转为二叉树
    画法:
    1.把每一棵树转为二叉树
    2.把每一棵树的根连线连起来,当作兄弟
    3.以第一棵树根为轴心,顺时针旋转45°(美观)
  • 二叉树转为森林
    事实上就是森林转二叉树的过程退回去,是个逆过程
    画法:
    1.先把第一级的根拆开
    2.分别按孩子兄弟法进行还原

3.树和森林的遍历

  • 树的遍历
    遵循先,中,后根遍历
    -森林的遍历
    1.先序遍历
    法1:对各个子树按照先序遍历的规则进行遍历
    法2:先把森林转化为二叉树,再对二叉树进行先序遍历
    2.中序遍历
    法1:依次对各个子树进行后序遍历(注意)
    法2:先把森林转化为二叉树,再对二叉树进行先序遍历
树森林二叉树
先根遍历先序遍历先序遍历
后根遍历中序遍历中序遍历

树的后根遍历等于森林或二叉树的中序遍历

4.分支法分析树

做题时遇见的一个比较巧妙的方法,就是说一棵树是由一条分支为开始,从某个节点延伸出很多很多分支的情况请添加图片描述
二叉树如此,三叉树等也是一样的
每伸出一个分支,代表那个结点多了一个兄弟
每条分支的尽头是一个叶节点,所以一棵树有多少个叶节点代表着有多少个分支
【2011统考】已知一棵有2011个结点的树,其叶节点的个数为116,求该树对应的二叉树中无右孩子的节点个数是多少?
分析:采用分支的思想
先看题目,树对应的二叉树是孩子兄弟表示法,没有右孩子就说明此结点没有兄弟
于是二叉树的问题被我们转换为了树的问题,分支法启动
有116个叶节点,说明有116个分支,第一个主分支不算,延申出了115个分支
也就是有115个结点有了兄弟
没有兄弟的节点数:2011-115=1896个

四、树与二叉树的应用

1.哈夫曼树和哈夫曼编码

  • 定义
    路径:树中一个结点到另一个结点的分支
    路径长度:路径上的分支数目
    权:结点被赋予的数值
    结点的带权路径长度:根到这个结点的路径长度与该点权值的乘积
    树的带权路径长度:所有叶节点的带权路径长度之和
    WPL=w1l1+w2l2+…+wnln
    哈夫曼树:含有n个带权节点的二叉树中,WPL最小的二叉树。也称最优二叉树
    注意一点,哈夫曼树不是唯一的,但是WPL一定是最小且最优的
  • 哈夫曼树的构造
    方法:
    1.把n个结点分别作为仅含有一个节点的二叉树,构成森林F
    2.不断挑最小权值的两个结点合并,生成一个新的结点,权值为两权值之和
    3.直至F中只剩下一棵树为止
  • 哈夫曼树的性质
    1.每个初始结点最终都称为叶节点,权值越小的离根节点的路径长度越大
    2.新建了n-1个结点,所以哈夫曼树一共有2n-1个结点
    3.不存在度为1的结点
  • 哈夫曼编码
    这是哈夫曼树真正的应用,我们想要用不同的编码表示不同的字符,同时又想方便简单,即使用频率最多的字符编码简单,频率少的字符编码便没那么重要,所以WPL就是需要考虑到的因素
    编码分为两种:
    固定长度编码:每个字符用相等长度的二进制位表示
    可变长度编码:允许长度不等,可利用哈哈夫曼编码进行数据压缩
    前缀编码:没有一个编码是另一个编码的前缀,避免歧义
    在这里插入图片描述
    规则是自己定的,并没有统一的标准,比如上图左分支为0,右分支为0
    哈夫曼树的结点权值就是出现频率,从根到叶节点路径上的分支标记的字符串作为该字符的编码。
    任意一个叶结点不可能是其他叶结点的祖先,每个叶结点对应的编码不可能是其他叶结点对应的编码
    的前缀,故哈夫曼编码是无前缀冲突编码。

2.并查集

一些应用问题涉及将n个不同的元素分成一组不相交的集合。
经常需要进行两种操作:
①查询某个元素所属的集合
②合并两个集合。
将维护该不相交集合的数据结构称为并查集。
如何实现集合的概念?使用树,同一集合一棵树,不同集合用根节点区分

  • 并查集的存储结构
    我们通常使用双亲表示法,因为需要进行查找元素属于那个集合的操作时,可以用来一次一次找根结点
    在这里插入图片描述
    并且使用双亲表示数组存储,数组元素下标表示元素名,用根节点的下标代表元素名
    废话不多说,直接上图:
    在这里插入图片描述
    为了得到两个集合的并,秩序将一个自己和的根节点的双亲指针指向另一个集合的根节点
    我们可以做出改进:根节点的双亲域为负数,且绝对值为子集合元素的个数
  • 并查集的操作
    初始化:
void Initial(int s[]){
    for(int i=0;i<SIZE;i++) s[i]=-1;
}

在并查集s中查找元素x并且返回元素x的树的根(时间复杂度:O(d),d为树的深度)

void Find(int s[],int x){
     while(s[x]>0) x=x[x];
     return x;
}

求两个不相交的集合的并集,就是把一个集合树的根指向另一棵树的根(时间复杂度O(1))

void Union(int s[],int root1,int root2){
    if(root1==root2) return ;
    s[root2]=root[1];
}
  • 并查集的优化
    我们知道,在上面思路下,Find在最坏情况下的时间复杂度为O(d),Union的时间复杂度为O(1)
    我们要解决的问题是,将n个独立元素通过多次Union合为一个集合
    既然Union已经是O(1),所以我们尽可能优化的是Find
    1.改进的Union
    如果要优化,我们就想办法让其中Find里的深度d尽可能小,或者说不要增加的那么快
    所以在Union的时候做出改进,使小树接在大树上
void Union(int s[],int root1,int root2){
    if(root1==root2) return ;
    if(root1>root2){//都是负数,数值大代表绝对值小,是小树
         s[root2]=s[root2]+s[root1];
         s[root1]=root[2];
    }
    else{
         s[root1]=s[root1]+s[root2];
         s[root2]=root[1];
    }
}

用这种方法得到的集合树,其深度不超过floor(log2n)+1
所以Find变成O(logn),Union还是O(1)
2.改进的Find
这次的优化同样也是针对于树的深度来的,从Find的角度出发,while(s[x]>0)这一步减少循环的次数,让其更快的找到自己的根,是这个方法的核心,即对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;
}

它的意思是说,压缩路径,因为我们已经找到了x的根了,x就在一个确定的集合里,由于我们Find的时候是让x一步一步找父节点最后找到根节点,所以x这条路径上的所有结点肯定属于这个集合,也就是说让传入的x及它的祖先们全部挂到根root底下。这样我们下次while循环到这些节点的时候就能一步到位,不用一层一层向上找
这种“压缩路径”可使集合树的深度不超过O(α (n)),其中α (n)是一个增长极其缓慢的函数

综合来看,两种改进最后优化的都是Find,Union始终是O(1)
再回到一开始的问题,将n个独立元素通过多次Union合为一个集合
无论怎么优化,时间复杂度都应是O(n×Find的时间复杂度)
这里的n意思是做了(n-1)次的Union

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐