第5章:树与二叉树

105 阅读21分钟

1、树的基本概念

1.1、树的定义

树是n(n0)n(n\ge0)个结点的有限集。当n=0n=0 时,称为空树。在任何一棵非空树中应满足

  1. 有且仅有一个特定的称为根的结点
  2. n>1n>1时,其余结点可分为m(m>0)m(m>0)个互不相交的有限集,其中每个集合本身又是一棵树,并且称为根的子树

树是一种递归的数据结构。树作为逻辑结构,同时也是一种分层结构,具有以下特点

  1. 树的根结点没有前驱,除根节点外的所有结点有且只有一个前驱
  2. 树中所有结点都可以有零个或多个后继
  3. n 个结点的树中有 n-1 条边

1.2、基本术语

  1. 孩子结点、双亲结点、兄弟结点、子孙结点、祖先节点
    • 每个结点的直接后继结点,称为该结点的孩子结点;相应的,该结点称为孩子结点的双亲结点
    • 具有同一双亲结点的孩子结点互为兄弟结点
    • 每个结点对应子树中的所有结点称为该结点的子孙节点
    • 从根节点到达某个结点的路径上经过的所有结点称为该结点的祖先结点
  2. 分支节点、叶子结点
    • 树中度不为零的结点称为非终端结点,又叫分支结点;根据孩子结点的个数分为单分支、双分支、多分支结点
    • 树中度为零的结点称为终端结点,又叫叶子结点
  3. 结点的度、树的度
    • 树中某个结点子树的个数称为该结点的度
    • 树中所有结点度的最大值称为树的度,通常将度为 m 的树称为 m 次数
  4. 路径、路径长度
    • 对于树中任意两个结点 A,B;可从上而下寻找到一个结点序列,则称该结点序列为由 A 到 B 的一条路径
    • 该路径所通过的结点数减 1 ,称为该路径的路径长度
  5. 结点层次、树的高度
    • 树中每一个结点都处在一定层次上,结点层次(树的深度)是从树根开始定义,根结点为第一层,他的孩子结点为第二层,以此类推
    • 树中结点的最大层次称为树的高度或树的深度
  6. 有序树、无序树
    • 若树中各结点的子树按照一定的次序从左向右排列,且相对次序不能随意变换,则称为有序树,否则为无序树。一般情况下没有特别说明都是指有序树
  7. 森林
    • n 棵互不相交的树的集合称为森林。把含有多棵子树的根结点删去就成了森林。反之该 m 棵独立的树加上一个根节点,并把这 m 棵树作为该结点的子树,则森林就变成了一棵树

1.3、树的性质

  1. 树的结点树 nn 等于所有结点的度数之和 +1
  2. 度为 mm 的树中第 ii 层上至多有 mi1m^{i-1} 个结点
  3. 高度为 hhmm叉树至多有 mh1m1\frac{m^h-1}{m-1} 个结点
  4. 度为 mm、具有 nn 个结点的树的最小高度 hhlogm(n(m1))+1\lceil \log_m(n(m-1))+1 \rceil
  5. 度为 mm、具有 nn 个结点的树的最大高度 hhnm+1n-m+1

2、二叉树的概念

2.1、二叉树的定义及主要特性

二叉树的定义

二叉树是一种特殊的树形结构(有五种基本形态),特点是每个结点至多只有两颗子树。并且二叉树是有序树,若将其左右颠倒,则成为另一棵不同的二叉树。即使树中结点只有一颗子树,也要区分他是左子树还是右子树

2 叉树与度为 2 的树异同

  • 度为 2 的树至少有 3 个结点,而二叉树可以为空
  • 度为 2 的树左右次序是基于另一个结点来说的,若只有一个结点则无需分左右,而二叉树即使只有一个结点也要分是左孩子还是右孩子

特殊的二叉树

  1. 满二叉树
    • 二叉树中的每一层都含有最多的结点,一棵高度为 h 的二叉树,就有2h12^h-1个结点
    • 满二叉树的叶子结点都在最下面一层,且除叶结点之外的每个结点度数均为 2
  2. 完全二叉树
    • 满二叉树类似,同样的高度不同的是会缺少结点,并且只存在于最下面一层,有左孩子才会有右孩子
  3. 二叉排序树
    • 左子树上所有结点的关键字均小于根节点关键字;右子树上所有结点的关键字均大于根节点
    • 左子树、右子树又各是一颗二叉树
  4. 平衡二叉树
    • 树中任意一个结点的左子树和右子树的高度之差的绝对值不超过 1
  5. 正则二叉树
    • 树中每个分支结点都有 2 个孩子,即树中只有度为 0 和 2 的结点

二叉树的性质

  1. 非空二叉树的叶结点数等于度为 2 的结点数加 1,即n0=n2+1n_0=n_2+1
  2. 非空二叉树的第 k 层最多有2k12^{k-1}个结点
  3. 高度为 h 的二叉树至多有2h12^h-1个结点
  4. 对完全二叉树按从上到下、从左到右的顺序从 1 依次进行编号,特点如下
    • 最后一个分支节点的编号为n/2\lfloor n/2 \rfloor,若in/2i\le\lfloor n/2 \rfloor,则ii为分支结点,否则为叶结点
    • 叶结点只可能在最后两层出现
    • 若有度为 1 的结点,则最多可能有一个,且该结点只有左孩子、没有右孩子
    • nn为奇数,则每个分支结点都有左右孩子;若nn为偶数,则编号最大的分支结点只有左孩子
    • i>1i>1时,结点ii的双亲结点编号为i/2\lfloor i/2 \rfloor
    • 若结点ii有左右孩子,则左孩子编号为2i2i,右孩子编号为2i+12i+1
    • 结点ii所在层次(深度)为log2i+1\lfloor \log_2i \rfloor+1
  5. 具有 n 个结点的完全二叉树的高度为log2(n+1)\lceil \log_2(n+1) \rceillog2n+1\lfloor \log_2n \rfloor+1
  6. 具有 n 个结点的二叉树的高度为log2n+1hn⌊\log2n⌋+1≤h≤n

2.2、二叉树的存储结构

顺序存储结构

指用一组连续的存储单元依次自上而下、自左至右存储完全二叉树上的结点元素,即将完全二叉树上编号为 i 的结点元素存储在一维数组下标为 i-1 的分量中。但对于一般的二叉树,为了让数组下标能反映二叉树中结点之间的逻辑关系,只能添加一些并不存在的空结点,浪费存储空间

建议从数组下标为 1 开始存储树中结点,保证数组下标和结点编号一致

链式存储结构

顺序存储的空间利用率低,因此二叉树一般采用链式存储结构,用链表结点来存储二叉树中每个结点。在二叉树中,结点结构通常包含若干数据域和指针域,二叉链表至少包含 3 个域:数据域(data)、左指针域(lchild)、右指针域(rchild)

typedef struct BiTNode{
    ElemType data;
    struct BiTNode, *lchild, *rchild;
}

在含有 n 个结点的二叉链表中,含有 n+1 个空链域(可以利用空链域构成线索链表)

3、二叉树的遍历与线索二叉树

3.1、二叉树的遍历

概述

二叉树的遍历是指按照某条搜索路径访问树中每个结点,使得每个结点均被访问一次

遍历是二叉树各种操作的基础。对于一棵给定的二叉树求结点的双亲、求结点的孩子、求二叉树的深度、求叶结点个数、判断两棵二叉树是否相等这些操作都是在遍历的过程中进行的

先序遍历(NLR)

  1. 访问根节点
  2. 先序遍历左子树
  3. 先序遍历右子树
void PreOrder(BiTree T){
    if(T != NULL){
        visit(T);				//访问根节点
        PreOrder(T->lchild);	//递归遍历左子树
        PreOrder(T->rchild);	//递归遍历右子树
    }
}

中序遍历(LNR)

  1. 中序遍历左子树
  2. 访问根节点
  3. 中序遍历右子树
void InOrder(BiTree T){
    if(T != NULL){
        InOrder(T->lchild);		//递归遍历左子树
        visit(T);				//访问根节点
        InOrder(T->rchild);		//递归遍历右子树
    }
}

后序遍历(LRN)

后序遍历左子树

后序遍历右子树

访问根节点

void PostOrder(BiTree T){
    if(T != NULL){
        PostOrder(T->lchild);	//递归遍历左子树
        PostOrder(T->rchild);	//递归遍历右子树
        visit(T);				//访问根节点
    }
}

层次遍历

从根开始为第一层,根的所有孩子结点为第二层,以此类推(从上到下、从左到右),对二叉树各结点进行逐层访问,进行层次遍历时,需要借助一个队列,遍历思想如下

  1. 首先将根结点入队
  2. 若队列非空,则队头结点出队,访问该结点,若它有左右孩子,则左右孩子分别入队
  3. 重复步骤 2
  4. 直至队列为空
void LevelOrder(BiTree T){
    InitQueue(Q);			//初始化辅助队列
    BiTree p;
    EnQueue(Q,T);			//将根节点入队
    while(!IsEnpty(Q)){		//队列不空则循环
        DeQueue(Q,p);		//队头结点出队
        visit(p);			//访问出队结点
        if(p->lchild != NULL)
            EnQueue(Q,p->lchild);	//若左孩子不空,则左孩子入队
        if(p->rchild != NULL)
            EnQueue(Q,p->rchild);	//若右孩子不空,则右孩子入队
    }
}

遍历序列构造二叉树

若已知中序序列,再给出其他三种遍历序列,就可以唯一确定一棵二叉树

对于先序+中序、后序+中序、层序+中序这三种搭配来说,先序、后序、层序都是用来确定根的,而中序是用来利用确定的根进行左右子树划分(先后层分根,中序分左右)

3.2、线索二叉树

概述

传统二叉链表存储仅能体现一种父子关系,不能直接得到结点在遍历中的前驱和后继,因此可以利用二叉树中存在的空指针用来指向其对应结点的前驱和后继结点,这样就可以像遍历单链表那样方便地遍历二叉树。引入线索二叉树正是为了加快查找前驱和后继的速度

**规定:**若无左子树,令 lchild 指向其前驱结点;若无右子树,则 rchild 指向其后继结点。此外还需增加两个标志域,以标识指针域是指向左右孩子还是前驱后继

  1. ltag、rtag=0:表示指向左右孩子
  2. ltag、rtag=1:表示指向前驱后继
lchildltagdatartagrchild
typedef struct ThreadNode{
    ElemType data;		//数据元素
    struct ThreadNode *lchild, *rchild;		//左右孩子标志
    int ltag, rtag;							//左右线索标志
}ThreadNode, *ThreadTree;

以这种结点结构构成的二叉链表作为二叉树的存储结构,称为线索链表,其中指向结点前驱和后继的指针称为线索。加上线索的二叉树称为线索二叉树

中序线索二叉树的构造

二叉树线索化是将二叉链表中的空指针改为指向前驱或后继的线索。而前驱和后继的信息只有在遍历时才能得到,因此二叉树线索化实质就是遍历一次二叉树

**算法思想:**附设指针 pre 指向刚刚访问过的结点,指针 p 指向正在访问的结点,即 pre 指向 p 的前驱。在中序遍历过程中,检查 p 的左指针是否为空,若为空就将他指向 pre;检查 pre 的右指针是否为空,若为空就将它指向 p

为了方便,可在二叉搜索链表上添加一个头结点,令其 lchild 域的指针指向二叉链表的根结点,其 rchild 域的指针指向中序遍历时访问的最后一个结点;令二叉树中序序列中的第一个结点的 lchild 和最后一个结点的 rchild 均指向头结点。这好比为二叉树建立了一个双向线索链表

void CreateInThread(ThreadTree T){
    ThreadTree pre = NULL;
    if(T!=NULL){
        InThread(T,pre);
        pre->rchild = NULL;
        pre->rtag = 1
    }
}
void InThread(ThreadTree &p, ThreadTree &pre){
    if(p!=NULL){
        InThread(p->lchild,pre);	//递归、线索化左子树
        if(p->lchild == NULL){		//当前结点的左子树为空
            p->lchild = pre;		//建立当前结点的前驱线索
            p->ltag = 1;
        }
        if(pre!=NULL && pre->rchild==NULL){	//前驱结点非空且 
            pre->rchild = p;
            pre->rtag = 1;
        }
        pre = p;
        InThread(p->rchild,pre);
    }
}

中序线索二叉树的遍历

中序线索二叉树的结点中隐含了线索二叉树的前驱和后继信息。在对其进行遍历时,只要先找到序列中的第一个结点,然后依次找结点的后继,直至其后继为空。在中序线索二叉树中找结点后继的规律是:若其右标志为 1,则右链为线索,指示其后继,否则遍历右子树中第一个访问的结点(右子树中最左下的结点)为其后继

ThreadNode *Firstnode(ThreadTree *p){
    while(p->ltag == 0)
        p = p->lchild;
    return 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);
    }
}

先序线索二叉树的构造与遍历

后序线索二叉树的构造与遍历

4、树与森林

4.1、树的存储结构

双亲表示法

采用一组连续空间来存储每个结点,同时在每个结点中增设一个伪指针,指示其双亲结点在数组中的位置

双亲表示法利用了每个结点只有唯一双亲的性质,可以很快地得到每个结点的双亲结点,但求结点的孩子时需要遍历整个结构

#define MAX_TREE_SIZE 100
typedef struct{		//树的结点定义
    ElemType data;	//数据元素
    int parent;		//双亲位置域
}PTNode;
typedef struct{		//树的类型定义
    PTNode nodes[MAX_TREE_SIZE];	//双亲表示
    int n;			//结点数
}PTree;

孩子表示法

孩子表示法是将每个结点的孩子结点视为一个线性表,且以单链表作为存储结构,则nn个结点就有nn个孩子链表(叶结点的孩子链表视为空表)。而nn个头指针又组成一个线性表,为便于查找,可采用顺序存储结构

与双亲表示法相反,孩子表示法寻找孩子操作非常方便,而寻找双亲操作需要遍历nn个结点中孩子链表指针域所指向的nn个孩子链表

孩子兄弟表示法

孩子兄弟表示法也称为二叉树表示法,即以二叉链表作为树的存储结构。孩子兄弟表示法使每个节点包括三部分内容:结点值、指向结点第一个孩子结点指针、指向结点下一个兄弟结点的指针

孩子兄弟表示法比较灵活,其最大的优点是可以方便地实现树转换为二叉树,易于查找结点的孩子,但缺点是从当前结点查找其双亲结点比较麻烦(可以再设置一个指向双亲结点的指针)

typedef struct CSNode{
    ElemType data;	//数据域
    struct CSNode *firstchild, *nextsibling  //第一个孩子和右兄弟指针
}CSNode, *CSTree;

4.2、树、森林与二叉树的转换

概述

二叉树和树都可以用二叉链表作为存储结构。从物理结构上看,树的孩子兄弟表示法与二叉树的二叉链表表示法是相同的,因此可以将同一存储结构的不同解释将一棵树转换为二叉树

树转换为二叉树

规则:每个结点的左指针指向它的第一个孩子,右指针指向它在树中的相邻右兄弟,这个规则也称左孩子右兄弟。根结点没有右兄弟,因此树转换得到的二叉树没有右子树


森林转换为二叉树

将森林转为二叉树的规则与树类似。先将森林中的每棵树转为二叉树,由于任意一棵树对应的二叉树的右子树必空,森林中各棵树可视为兄弟关系,将后一棵树视为前一棵树的兄弟即可

二叉树转换为森林

规则正好与森林转为二叉树相反,先将二叉树的右链断开,直到最后只剩一棵没有右子树的二叉树为止,然后再将每棵二叉树转为树即可

结论

树和森林中每个非终端节点(分支节点),在转换为二叉树时,其最后一个孩子的右链域必然为空,且这种 “右链域为空” 的情况仅由非终端节点产生,因此两者数量相等

4.3、树和森林的遍历

树的遍历

树的遍历指用某种方式访问树中的每个结点,且仅访问一次

先根遍历

  • 先访问根结点
  • 再依次遍历根结点的每棵子树,遍历子树时仍遵守先根后子树的规则

遍历序列与这棵树对应的二叉树的先序序列相同

后根遍历

  • 先依次遍历根结点的每棵子树,遍历子树时仍遵守先根后子树的规则
  • 再访问根节点

遍历序列与这棵树对应的二叉树的中序序列相同

森林的遍历

先序遍历森林

  • 访问森林中第一棵树的根节点
  • 先序遍历第一棵树中根结点的子树森林
  • 先序遍历出去第一棵树之后剩余的树构成的森林

中序遍历森林

  • 中序遍历森林中第一棵树的根结点的子树森林
  • 访问第一棵树的根节点
  • 中序遍历除去第一棵树之后剩余的树构成的森林

5、树与二叉树的应用

5.1、哈夫曼树与哈夫曼编码

概述

结点的权:树中结点常常被赋予一个表示某种意义的值

结点的带权路径长度:从树的根到一个结点的路径长度与该结点上权值的乘积

树的带权路径长度:树中所有结点的带权路径长度之和,记为WPL=i=0nwiliWPL=\sum_{i=0}^nw_il_i

  • wiw_i:第 i 个叶结点所带的权值
  • lil_i:该叶结点到根结点的路径长度

哈夫曼树:在含有 n 个带权叶结点的二叉树中,带权路径长度最小的二叉树称为哈夫曼树,也称最优二叉树(不唯一)

哈夫曼树的构造

给定 n 个权值分别为w1,w2wnw_1,w_2……w_n的结点,构造哈夫曼树的算法描述如下

  1. 将这 n 个结点分别作为 n 棵树仅含一个结点的二叉树,构成森林 F
  2. 构造一个新结点,从 F 中选取两棵根结点权值最小的树作为新结点的左、右子树,并且将新结点的权值置为左右子树权值之和
  3. 从 F 中删除刚才选中的两棵树,同时将新得到的树加入 F 中
  4. 重复步骤 2、3,直至 F 中只剩下一棵树为止
哈夫曼树的性质
  1. 每个初始结点最终都会成为叶结点,且权值越小的结点到根结点的路径长度最大
  2. 构造过程中新建了 n-1 个结点,因此哈夫曼树的结点总数为 2n-1
  3. 每次构造都选择了 2 棵树作为新结点的孩子,因此哈夫曼树中不存在度为 1 的结点
哈夫曼编码

**固定长度编码:**在数据通信中,对每个字符用相等长度的二进制位表示

**可变长度编码:**在数据通信中,允许对不同字符用不等长的二进制位表示

可变长度编码比固定长度编码好得多,其特点是对频率高的字符赋予短编码,对频率低的字符赋予长编码,从而可以使字符的平均编码长度变短,起到压缩数据的效果

**前缀编码:**没有一个编码是另一个编码的前缀(A=0,B=10,C=110),解码只需要识别出什么就直接翻译即可

哈夫曼编码设计

先构造出一个哈夫曼树,然后约定左分支为 0、有分支为 1,从根到叶结点的路径上用分支标记组成的序列,作为该叶结点字符的编码,即哈夫曼编码

5.2、并查集

概述

是一种简单的集合表示,支持下面三种操作 (用来高效处理集合的合并和查询)

  1. Initial(S):将集合 S 中的每个元素都初始化为只有一个单元素的集合
  2. Union(S, Root1, Root2):将集合 S 中的子集 Root2 并入子集合 Root1。要求 Root1 和 Root2 互不相交,否则不执行合并
  3. Find(S, x):查找集合 S 中单元素 x 所在的子集合,并返回该子集合的根结点
并查集的存储结构

通常用树的双亲表示作为并查集的存储结构,每个子集合以一棵树表示。所有表示子集合的树,构成表示全集合的森林,存放在双亲表示数组内。通常用数组元素下标代表元素名,用根结点下标代表子集合名称,根结点的双亲域为负数(可设置为该子集合元素数量的相反数)

为了得到两个子集合的并,只需要将其中一个子集合根结点的双亲指针指向另一个集合的根结点即可

并查集的基本实现
#define SIZE 100
int UFsets[SIZE];	//集合元素数组(双亲指针数组)
void Initial(int S[]){			//S即并查集
    for(int i=0; i<SIZE; i++)	//每个自成单元素集合
        S[i] = -1;
}
//在并查集S中查找并返回包含元素x的树的根
int Find(int S[], int x){
    while(S[x]>=0)	//循环寻找x的根
        x=S[x];
    return x;		//根的S[]小于0
}
//判断两个元素是否属于同一集合,只需要分别找到他们的根,在进行比较即可
//求两个不相交子集合的并集;需要先找到两个元素的根,再令一子集棵树的根指向另一棵子集树根
void Union(int S[], int Root1, int Root2){
    if(Root1 == Root2)	//要求Root1与Root2是不同的集合
        return;
    S[Root2] = Root1;	//将根Root2连接到Root1下面
}

Find 操作和 Union 操作的时间复杂度分别为 O(d)和 O(1),其中 d 为树的深度

并查集实现的优化

在极端情况下 n 个元素构成的集合树的深度为 n,则 Find 操作最坏时间复杂度为 O(n);改进方法是在做 Union 操作之前,首先判断子集中成员的数量,然后令成员少的根指向成员多的根,即把小树合并到大树

采用这种方法构造得到的集合树,其深度不超过 log2n+1\lfloor \log_2n \rfloor+1

void Union(int S[], int Root1, int Root2){
    if(Root1 == Root2) return;
    if(S[Root1]>S[Root2]){		//Root2结点总数更少
        S[Root1] += S[Root2];	//累加集合树的结点总数
        S[Root2] = Root1;		//小树并到大树
    }else{
        S[Root2] += S[Root1];
        S[Root1] = Root2;
    }
}

随着子集的合并,集合树的深度越来越大,为了进一步减少确定元素所在集合的时间,还可以对 Find 进行优化,当所查元素 x 不在树的第二层时,在算法中添加一个压缩路径的功能,即将从根到元素 x 路径上的所有元素都变成根的孩子

int Find(int S[], int x){
    int root = x;
    while(S[root] > 0)	//循环找到根结点
        root = S[root];
    while(x != root){	//压缩路径
        int t = S[x];	//t指向x的父节点
        S[x] = root;	//x直接挂到根结点下面
        x = t;
    }
    return root;	//返回根结点编号
}

Find 操作通过压缩路径优化后,可使集合树的深度不超过 O(α(n))O(\alpha(n)),其中 α(n)\alpha(n) 是一个增长极其缓慢的函数,对于常见的正整数 n,通常 α(n)4\alpha(n)\le4