第五章:树与二叉树

树的基本概念

树是一种新的数据结构,我们可以用前面学到的前驱和后继的关系来理解他,对于树来说除了第一个节点以外,其余节点都只有一个前驱,每一个节点都可能有0个或多个后继,这就是树,其实用一张图更好理解
1695941036.jpg
这就是一棵树,我们把上面的认为是前驱,下面的认为是后继,B,C,D都拥有同一个前驱A,B后面又拥有E和F两个后继。
接下来抛出一些概念

  • 空树:一个节点也没有的树就是空树,可以参考前面的空表来理解
  • 非空树:不是空树的树都是非空树,非空树至少有一个节点
  • 父节点:就是我们前面说的前驱节点,上图B就是E和F的父节点
  • 子节点:就是我们前面说的后继节点,上图E和F就是B的子节点
  • 根节点:没有父节点的节点就是根节点,上图的A就是根节点,一棵非空树里只能有一个根节点
  • 分支节点:有子节点的节点就是分支节点,例如上图的B,C,E都是分支节点,因为他们都有子节点
  • 叶子节点:没有子节点的节点称为叶子结点,上图的K,L,F这些都是叶子结点
  • 祖先节点:一个节点从父节点到根节点路径上所有的节点都是祖先节点,例如K的祖先节点有E,B,A,而G的祖先节点有C,A
  • 子孙节点:和祖先节点相反,就是他下面的所有节点,D的子孙节点有H,I,J,M四个
  • 兄弟节点:如果多个节点拥有同一个父节点,那么他们是兄弟节点,例如E和F就是兄弟节点
  • 堂兄弟节点:位于同一层的节点就是堂兄弟节点,例如E和G
  • 边:连接两个节点的那条线称为边,在树中,边只能从上往下,属于有方向的边
  • 节点的深度(层次):顾名思义,该节点从上往下数,处于第几层,就是他的深度,深度默认从1开始,有时候可能从0开始,上图中G的深度就是3
  • 节点的高度:高度和深度相反,深度是从上往下数,高度是从下往上数
  • 树的高度:数一共有多少层,上图中的数的高度为4
  • 节点的度:有几个孩子节点,度就为几,H节点的度为1,E节点的度为2,所有叶子节点的度均为0
  • 树的度:树中所有节点度的最大值,上图中树的度为3
  • 森林:很多个树放在一起就是一个森林,这些树互不相交,森林中树的个数可以是多个,也可以是0个
  • 有序树:子节点有顺序的树就是有序树(交换两个子节点的位置,树发生改变)
  • 无序树:子节点们无顺序的树就是无序树(交换两个子节点的位置,树不发生改变)
  • 子树:以某一节点的子节点作为根节点的树,称为该节点的子树,例如上图中的A可以拆分为3个子树,分别以B,C,D作为根节点,其中以C作为根节点的树拥有C和G两个节点

以上就是树的基本概念,可能概念性的内容比较多,但是没办法,他就是很多,这里重点介绍一下子树的概念,因为后面的算法可能会用的比较多
1695942262.jpg
如果能看懂这张图,那么子树的概念也就清晰明了了,值得注意的是,任何一个节点都拥有子树,哪怕是叶子节点也不例外,叶子结点可以视为空树
1695942333.jpg

由于一棵树可以看做是一个节点和两个子树构成的,所以树是一种递归定义的结构

接下来介绍一下树的性质,这些性质都比较重要
在介绍性质之前,先介绍一下m叉树,m叉树就是度小于等于m的树,要区分开 “m叉树” 和 “度为m的树” 的概念
由于每一个节点(除了根节点外)都可以看做是其父节点的一个度,所以除了根节点以外所有的节点都对应了一个度,所以自然就有节点数 = 总度数+1,也就是将所有节点的度数加起来的和再加一,就可以知道节点的个数
m叉树的第i层最多有mi-1个节点,同样,度为m的树第i层最多有mi-1个节点
高度为h的m叉树最多有 1 − m h 1 − m \frac { 1- m ^ { h } } { 1-m } 1m1mh个节点。(这个公式不用记,可以用等比数列求和公式秒推)
高度为h的m叉树至少有h个节点,高度为h且度为m的树至少有h+m-1个节点,这个结论非常显而易见
具有n个节点的m叉树最小高度为 [ log ⁡ m ( n ( m − 1 ) + 1 ) ] \left[ \log _{m}(n(m-1)+1)\right] [logm(n(m1)+1)](括号表示向上取整)
记不住的话考场上可以现推该公式,每一层都长满,或者说节点个数最多的时候就是最小高度,我们假设高度为h,我们知道h-1层最多有 1 − m h − 1 1 − m \frac { 1- m ^ { h-1 } } { 1-m } 1m1mh1个节点,h层最多有 1 − m h 1 − m \frac { 1- m ^ { h } } { 1-m } 1m1mh个节点,我们就可以建立如下不等式
$\frac { 1- m ^ { h-1 } } { 1-m } < n \leq \frac { 1- m ^ { h } } { 1-m } \
h - 1 \lt \log _ { m } ( n ( m - 1 ) + 1 ) \leq h\$
此时$\log _ { m } ( n ( m - 1 ) + 1 ) \leq h\$我们要解出h,只需要让节点个数向上取整即可,这里要求掌握证明过程即可,推理过程非常简单,考场上可以现推。

二叉树的概念

二叉树的概念及其性质

二叉树是一种非常重要的树,前面我们说过m叉树的概念,二叉树可以等价为度小于等于2的有序树,这里抓住两点,首先二叉树只要求度小于等于2,所以哪怕是一个空树也可以认为是二叉树,并且二叉树是一个有序树,即树的左右两边是有顺序的,如果调换顺序后得到的树则是另一个新树。
接下来给出几种特殊的二叉树
满二叉树:由于二叉树的度小于等于二,所以高度为h的二叉树最多有 2 h − 1 2^{h}-1 2h1个节点,我们把这种最多节点的情况,称为满二叉树,即高度为h的且节点有 2 h − 1 2^{h}-1 2h1的二叉树就是满二叉树
满二叉树具有一些性质,首先,我们如果将满二叉树从上往下从左往右编号,如下图所示
1696194613.jpg

  • 只有最后一层有叶子节点
  • 不存在度为1的节点
  • 编号为n的节点左孩子(如果存在)节点编号为2n,右孩子节点(如果存在)编号为2n+1

接下来介绍完全二叉树,完全二叉树和满二叉树很像,我们如果把编号最大的几个拿掉,得到的就是完全二叉树,如下图所示
1696194797.jpg

  • 完全二叉树只有最后两层有叶子节点
  • 完全二叉树最多只有一个度为1的节点
  • 编号为n的节点左孩子(如果存在)节点编号为2n,右孩子节点(如果存在)编号为2n+1
  • 设完全二叉树有n个节点,那么编号小于[n/2]的节点为分支节点,编号大于[n/2]的节点为叶子节点(这里括号表示向下取整)

二叉排序树是用于元素排序搜索的一种树状结构,二叉排序树是用递归定义的,首先,二叉排序树要求根节点左子树上所有的节点都小于根节点,根节点所有右子树的节点都大于根节点,而左子树和右子树又分别是一个二叉排序树。
1696195165.jpg
有了二叉排序树之后,我们可以很快地搜索元素,如果元素比根节点大,我们就找他的右子树,如果元素比根节点小,我们就找他的左子树,以此类推。

接下来介绍平衡二叉树,平衡二叉树要求任意一个节点的左子树和右子树的层次差均不超过1
1696195351.jpg
1696195362.jpg
以上都是二叉排序树,一个平衡二叉树,另一个是非平衡二叉树,如果我们要找到70这个元素,非平衡二叉树的搜索效率显然非常低下,所以将二叉排序树转换为平衡的二叉排序树更有助于提高搜索效率。

接下来给出二叉树的一些常考性质
n i n_{i} ni表示度为i的节点个数,则对于非空二叉树而言, n 0 = 1 + n 2 n_{0} = 1+n_{2} n0=1+n2
这里给出该结论的证明过程:
设二叉树节点总个数为n,则有 n = n 0 + n 1 + n 2 n = n_{0}+n_{1}+n_{2} n=n0+n1+n2,由于除了根节点外,每一个度都对应了一个节点,所以 n = 1 + n 1 + 2 n 2 n = 1+n_{1}+2n_{2} n=1+n1+2n2,我们根据这两个式子建立等式,即: n 0 + n 1 + n 2 = 1 + n 1 + 2 n 2 n_{0} +n_{1}+n_{2} = 1+n_{1}+2n_{2} n0+n1+n2=1+n1+2n2,化简后证明完毕

该定理可以推广到m叉树,对于一个m叉树而言,有 n 0 = 1 + ∑ k = 2 m ( k − 1 ) n k = 1 + n 2 + 2 n 3 + 3 n 4 + ⋯ ( m − 1 ) n m n_{0} = 1+ \sum_{k=2}^{m}(k-1)n_{k} = 1+n_{2}+2n_{3}+3n_{4}+\cdots (m-1)n_{m} n0=1+k=2m(k1)nk=1+n2+2n3+3n4+(m1)nm

二叉树的第h层最多有 2 h − 1 2^{h-1} 2h1个节点,高度为h的二叉树最多有 2 h − 1 2^{h}-1 2h1个节点
具有n个节点的完全二叉树高度为 [ log ⁡ 2 ( n − 1 ) ] (向上取整)  或   [ log ⁡ 2 n ] + 1 (向上取整) [\log_{2}(n-1)](向上取整)\ \ 或\ \ [\log_{2}n]+1(向上取整) [log2(n1)](向上取整)    [log2n]+1(向上取整)
接下来给出两个公式的推理过程,我们设高为h
我们知道,高为h的完全二叉树最多有 2 h − 1 2^{h}-1 2h1个节点,高为h-1的完全二叉树最多有 2 h − 1 − 1 2^{h-1}-1 2h11,我们可以建立如下一个不等式: 2 h − 1 − 1 < n ≤ 2 h − 1 2 ^ { h - 1 } - 1 \lt n \leq 2 ^ { h } - 1 2h11<n2h1,我们化简后得到 h − 1 < log ⁡ 2 ( n + 1 ) ≤ h h - 1 \lt \log _ { 2 } ( n + 1 ) \leq h h1<log2(n+1)h,由于h为整数,我们对其向上取整,就可以解出h
接下来看第二个式子,我们知道,一个高为h的满二叉树最少有 2 h − 1 2^{h-1} 2h1个节点,最多有 2 h − 1 2^{h}-1 2h1个节点,我们当然可以列出下面的等式: 2 h − 1 ≤ n < 2 h 2 ^ { h - 1 } \leq n \lt 2 ^ { h } 2h1n<2h,我们取对数得到 h − 1 ≤ log ⁡ 2 n < h h - 1 \leq \log _ { 2 } n \lt h h1log2n<h,这里我们向下取整再加一,就可以把h解出来
接下来我们就可以运用我们学过的知识来解决一类问题,如果给了一个完全二叉树节点的个数,我们是可以直接推出 n 0 , n 1 , n 2 n_{0},n_{1},n_{2} n0,n1,n2的,接下来给出具体步骤
首先我们前面提到过,完全二叉树最多只能有一个度为1的节点,我们有 n 1 = 1 或 n 1 = 0 n_{1} = 1 或n_{1} = 0 n1=1n1=0
接下来,我们有 n 0 = 1 + n 2 n_{0} = 1+n_{2} n0=1+n2,我们对这个式子两边同时加上 n 2 n_{2} n2,可得 n 0 + n 2 = 1 + 2 n 2 n_{0}+n_{2} = 1+2n_{2} n0+n2=1+2n2,从而可以得出 n 0 + n 2 n_{0}+n_{2} n0+n2必定为奇数
我们还有个浑然天成的条件,即 n = n 0 + n 1 + n 2 n = n_{0}+n_{1}+n_{2} n=n0+n1+n2
要使用到的条件就叙述完毕了,接下来我们利用这些条件来求解。
n 0 + n 2 n_{0}+n_{2} n0+n2为奇数,而 n 1 n_{1} n1要么是0要么是1,所以若 n n n为奇数,则 n 1 = 0 n_{1}=0 n1=0,若 n n n为偶数,则 n 1 = 1 n_{1}=1 n1=1
我们根据 n = n 0 + n 1 + n 2 n = n_{0}+n_{1}+n_{2} n=n0+n1+n2,改写为 n − n 1 = n 0 + n 2 n-n_{1} = n_{0}+n_{2} nn1=n0+n2,此时 n 1 , n n_{1},n n1n均为已知,而我们知道 n 0 = 1 + n 2 n_{0} = 1+n_{2} n0=1+n2,我们联立这两个方程,有 n − n 1 = 1 + 2 n 2 n-n_{1} = 1+2n_{2} nn1=1+2n2,从而解出 n 2 n_{2} n2,再带入原式解出 n 0 n_{0} n0即可

二叉树的存储结构

二叉树的存储结构也分为顺序结构和链式结构,首先来看顺序结构,我们将一个二叉树从上往下,从左往右进行编号,如下图所示:1696200534.jpg
我们就可以直接按照编号给他存入一个数组中,如下图所示
1696200557.jpg
所以顺序存储本质上还是存到一个数组里面,下面给出结构体定义

#define MAX 100
typedef struct {
	int value;
	bool isEmpty;
}TreeNode;
TreeNode t[MAX];//通过定义一个数组,就可以存储节点

我们前面分析过,对于完全二叉树而言,编号为n的节点左孩子为2n,右孩子为2n+1,父节点为[n/2],我们也给出过求层次的公式,所以这种方式要找到他的左右孩子以及父节点非常简单。
但以上算法仅限于完全二叉树,如果不是完全二叉树,以上算法将失效,所以对于一个非完全二叉树,我们依旧要按照完全二叉树的方式去存,我们必须假设他是完全二叉树,也就是添加空元素将他填充为完全二叉树,如下图所示
1696200918.jpg
这样就可以像完全二叉树一样存入一个数组了,但缺点也很明显,那就是会产生大量的空间浪费,我们下面给出一种极端的情况
1696200961.jpg
哪怕该树只有4个节点,我们也必须要用15个节点的空间去存储他,所以顺序存储并不常用,只适用于存储完全二叉树,如果存储非完全二叉树,可能导致严重的空间浪费。

接下来我们考虑链式存储,先给出链式存储的结构体定义

typedef struct Node{
	int data;
	struct Node *lchild,*rchild;
}Node,*Tree;

每一个节点由一个数据域和两个指针域构成,两个指针域分别指向左孩子和右孩子,我相信有了前面的学习,读者完全可以根据这个数据结构实现很多算法,这里强调一点,该结构要找到左右孩子非常简单,但如果要找到父节点,那么就需要从根节点一个一个向下遍历,所以,如果我们要频繁寻找父节点,那我们需要加一个指针域,如下代码所示

typedef struct Node{
	int data;
	struct Node *lchild,*rchild,parent;
}Node,*Tree;

这样加上父节点指针的链表,我们称之为三叉链表,同样如果不加这个父节点的指针域,那就是二叉链表。
这里还有二叉链表的一个重要性质,请读者牢记:n个结点的二叉链表共有 n+1 个空链域。
以下对于这个结论作出解释,我们假设节点有n个,每个节点都包含两个指针域,所以指针域的个数一共有2n个,而除了根节点以外,所有节点都需要消耗其父节点的一个指针域,所以一共需要消耗n-1个指针域,还剩下n+1个指针域,所以我们说n个节点的二叉链表共有n+1个指针域。

二叉树的遍历和线索二叉树

二叉树的遍历

二叉树的先中后序遍历

遍历一词在前面的学习中已经多次提到,所谓遍历就是把一个数据结构中的所有节点按照一定顺序输出,但是二叉树不同于线性结构,对于线性表而言,我们只需要将其从前往后输出即可,但树是一种相对复杂的数据结构,所以其输出顺序也有很多种,最常见的分为先序,中序,后序,以及层次遍历,本节先介绍先中后三种遍历算法。
前面提到过,树其实是一种递归定义的数据结构,也就是一棵非空树包含了根节点和一些子树(0个或多个),而这些子树本身又是一棵树。

递归定义严格来说是在定义一个概念时,这个定义中包含了要定义的概念本身。

二叉树既然属于树,其定义也是递归的,二叉树要么为空,要么就包含了根节点和左右两个子树。
我们本节涉及到的先中后序遍历,正是一种利用二叉树的递归性质产生的遍历顺序,下介绍先中后序遍历的具体流程:
先序遍历(先根遍历):若根节点不为空,先访问根节点,再遍历左子树,再遍历右子树
中序遍历(中根遍历):若根节点不为空,先遍历左子树,再访问根节点,再遍历右子树
后序遍历(后根遍历):若根节点不为空,先遍历左子树,再遍历右子树,再访问根节点
我相信根据这个描述,读者其实可以很轻松地用递归实现三种遍历算法,在介绍计算机算法之前,我们首先要掌握手算方法。
我们这里介绍两种手算算法,我们均以例子的形式来说明,先介绍第一种算法:
1696293264.jpg
我们分别求这个树的先中后序遍历序列
首先计算先序序列,先序序列其实就是先根序列,也就是先访问根节点,再访问左子树和右子树,我们从根节点开始看,根节点为A,左子树根节点为B,右子树根节点为C,根据先序的规则我们知道,对于这三个节点的顺序而言应该是A B C,此时对于B和C来说还是一棵树,所以我们对B和C还要分别展开,B的左孩子是D,右孩子是E,所以对于B这个子树来说,顺序应该是B D E,而对于C来说,C的左子树为F,右子树是空树,空树我们是不访问的,所以C这个树展开是C F,这里我们就有了以下序列:A B D E C F,此时对于D来说,他还是一棵树,我们还要展开,展开为D G,所以最终的先序遍历序列为A B D G E C F
接下来计算中序遍历序列,中序顺序应该是左,根,右,所以对于根节点来说,就是B A C,此时B和C都是树,还要继续展开,B按照中序展开是D B E,而C按照中序展开是F C,展开后就是D B E A F C,此时对于D来说还是一棵树,我们还要继续展开,D展开是D G,所以最终的中序遍历序列为D G B E A F C
后续遍历序列读者可以自行计算,最后结果为G D E B F C A
接下来再介绍一个计算方法,我们考虑下面一棵树
1696293881.jpg
在遍历之前,我们应该先将空节点补齐,之后我们按照一个顺序画上一个路径,具体顺序如下
1696293949.jpg
这个路径相信读者能总结出规律,就是从左边开始依次给他绕起来,当箭头指向的节点是第一次路过的时候,我们的箭头颜色为红色,第二次路过时颜色变成了绿色,第三次路过时颜色变成了紫色,读者也可以仔细观察一样,这里的每一个节点,其实都被访问了三次,这里给出规则

  • 先序遍历:路径第一次路过时访问该节点
  • 中序遍历:路径第二次路过时访问该节点
  • 后续遍历:路径第三次路过时访问该节点

这种规律是通过递归调用算法总结出来的,感兴趣的读者可以自行推导,以上给出的两种算法读者掌握一种即可做题,笔者推荐第一种。
会手算之后,我们给出遍历的计算机算法,对于计算机而言,遍历树的算法相当简单,如下代码所示

typedef struct Node{
	int data;
	struct Node *lchild,*rchild;
} Node,*Tree;
void visit(Node *t){
    cout << t->data;//该函数用于访问,这里我们假定访问为输出数据
}
void preorder(Tree t){//先序遍历
    if(t != NULL){//如果不是null
        visit(t);
        preorder(t -> lchild);
        preorder(t -> rchild);
    }
}
void inorder(Tree t){//中序遍历
    if(t != NULL){//如果不是null
        preorder(t -> lchild);
        visit(t);
        preorder(t -> rchild);
    }
}
void postorder(Tree t){//后序遍历
    if(t != NULL){//如果不是null
        preorder(t -> lchild);
        preorder(t -> rchild);
        visit(t);
    }
}

我们假设树的节点个数为n,高度为h,则树的遍历算法时间复杂度为O(n),空间复杂度为O(h)

虽然递归算法有很多缺点,但在这里不必担心,因为该算法的时间复杂度为O(n),从时间复杂度的角度来说,任何遍历算法都不可能小于O(n),所以此处的O(n)可以被认为是最优时间复杂度,没有再优化的空间,尽管空间复杂度为h,这里确实可以优化,但实际应用中树的高度也不太可能会导致栈溢出(对于目前常见的编程语言以及运行环境而言,基本都可以递归一万层以上)

二叉树的层次遍历

树的层次遍历就是将树中的每一个元素按照从上往下,从左往右的方式进行遍历,队列的层次遍历需要一个辅助队列来帮助实现,在前面章节介绍队列的应用的时候笔者提到过这个应用,忘记的读者可以往前翻看,下面来看具体实现流程

  1. 初始化一个辅助队列
  2. 根节点入队
  3. 若队列非空,队头节点出队,否则则遍历结束
  4. 访问出队的队头节点
  5. 将队头节点的左孩子和右孩子按顺序添加到队尾(如果有的话)
  6. 跳到3

有了这个算法思想,我们可以轻松写出遍历算法

void levelOrder(Tree T){
	Queue Q;//声明辅助队列
    initQueue(Q);//初始化辅助队列
    treeNode *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);
    }
    
    
}

细心的读者会发现,这里出队元素我是用一个指针来保存的,事实上该队列中的数据域就是一个指向树中一个节点的指针,这样做可以节省空间

根据遍历序列构造二叉树

本节讨论的问题是,我们是否可以通过遍历序列确定一棵树,答案是否定的,我们不可能通过某一个遍历序列唯一确定一棵树,不管是前,中,后序还是层次遍历,都做不到这一点,所以我们想要通过遍历序列来构造一个二叉树,至少需要两个。
我们自然就会考虑,哪两个序列可以唯一确定一个二叉树呢,这里给出结论,中序遍历序列必不可缺,在有中序遍历序列的前提下,任给后序,前序或者层序遍历中的任何一个,都可以唯一确定一个二叉树,我们这里要求掌握其手算方式,这里记注一个原则,中序序列确定左右子树,另一个序列确定根节点。
我们分别介绍中序和前,后,层次序列是如何唯一确定二叉树的。

中序序列+前序序列

根据我们的原则,中序序列区分左右子树,另一个序列确定根节点。
我们这里以例子来说明
中序:E A F D H C B G I
前序:D A E F B C H G I
前序序列用于确定根节点,前序的第一个节点一定是根节点,所以根节点为D,就定出来了,再通过中序节点看左右子树,将根节点D带入中序序列中看,D左边的一定是左子树上的节点,D右边的一定是右子树上的节点,我们就有下图
1696384350.jpg
接下来看左子树,左子树的前序序列为A E F,中序序列为E A F,由于前序序列的第一个节点一定是根节点,所以左子树的根节点就是A,再看A在中序序列的位置,A左边的是左子树,A右边的是右子树,可知E是A的左孩子,F是A的右孩子,这样左子树就确定了。
再看右子树,右子树中序列为H C B G I,前序序列为B C H G I,可知右子树的根节点为B,左子树包含H C两个节点,右子树包含G I两个节点。
由此,我们画出图
1696384521.jpg
接下来分析H C和G I就很简单了,先看H C这个子树,这个子树的中序遍历为H C ,前序遍历为C H,可知C为根节点,H为左孩子。右子树中序遍历为G I,前序遍历为G I,可知根节点为G,I为其右子树,我们画出图。
1696384665.jpg
以下我们就完成了前序+中序还原二叉树。
这里并不要求掌握计算机算法,只要求手算,我的个人博客给出了先序+中序构造二叉树的计算机算法,感兴趣的读者可以参阅。

中序序列+后序序列

和中+前同理,只不过后序遍历中最后一个节点才是根节点,我们依然用一个例子来体会。
后序遍历:E F A H C I G B D
中序遍历:E A F D H C B G I
首先,后序遍历的最后一个节点为根节点,可知根节点为D,再查看D在中序序列中的位置,在D左边的位于左子树,在D右边的位于右子树,我们就可以画出下图:
1696384885.jpg
接下来看左子树,左子树中序遍历为E A F,后序遍历为E F A,后序遍历最后一个节点为根节点,所以根节点为A,再看A位于中序遍历的位置,可知根节点为A,E位于左子树,F位于右子树,此时我们就可以画出整个左子树了。
再看右子树,右子树的中序序列为H C B G I,后序遍历为H C I G B,可知根节点为B,左子树上有H C,右子树上有G I,我们根据已知信息画出下图:
1696385071.jpg
接下来还剩B节点的左右两个树没有确定,先看左子树,B节点的左子树中序序列为H C,后序序列为H C,可知根节点为C,H为右孩子。再看右子树,中序为G I,后序为I G,可知根节点为 G,I为其右孩子,确认完这些信息后,就可以画出整个树:
1696385198.jpg
以上就完成了后序+中序还原为树的过程。

中序序列+层次序列

我们也可以通过层次序列和中序序列来还原树,思路一样,也是根据层次序列确定根节点,根据中序序列确定左右子树,我们以例子来说明
层次序列:D A B E F C G H I
中序序列:E A F D H C B G I
层次序列第一个访问的肯定是根节点,所以D一定是根节点,再通过中序序列判断其左右孩子都有哪些,我们可以画出下图:
1696385402.jpg
由于D的左右两个树都存在,所以层次遍历的第二个,第三个节点一定是第二层的两个根节点,我们就确定了左子树的根节点为A,右子树的根节点为B,找到左右子树根节点后,左子树根节点为A,中序为E A F,所以E在A的左子树上,F在A的右子树上,而右子树根节点为B,中序为H C B G I,所以H C在他的左子树上,G I在他的右子树上,可以画出下图:
1696385505.jpg
再带入层次序列看,此处只缺B的左右子树了,层次遍历中D A B E F已经显而易见了,接下来的C G必然就是B的左右子树的根节点,确定了根节点后,我们再把两个根节点分别带入B的左右子树的中序序列上去看,就可以求的最终树:
1696385693.jpg

通过两个子树确定一个二叉树的时候,中序遍历序列必不可少,换句话说,层序序列,前序序列,后序序列,这三者两两组合无法唯一确定一个二叉树,不仅如此,哪怕是三个都给出,也无法唯一确定一个二叉树,下给出反例:
1696386009.jpg
对于a和b两个二叉树,读者可以验证,他们虽然长得不一样,但是先序,后序,层次遍历均相同。
还必须要强调这里给出的遍历序列都是不包含空节点的,如果我们标注空节点,也就是把任何一个节点的左右子树都给补全,哪怕没有也要添一个空节点,遍历序列也要求给出对空节点的遍历,那么此时任给任何一个遍历序列都可以唯一确定一个二叉树。

线索二叉树

线索二叉树的概念

首先请读着回忆一下,在我们前面介绍二叉树的时候,提到过一个知识点,拥有n个节点的二叉树一共有n+1个空链域,也就是指向null的指针,线索二叉树就是用这些空链域来保存一些信息,以帮助我们进行遍历。
首先,线索二叉树分为三个种类,分别是先序线索二叉树,中序线索二叉树,后序线索二叉树,从名字也不难看出,线索二叉树和遍历有关。
我们考察下面这个树
1696395110.jpg
这棵树的中序序列为D G B E A F C
这个中序序列其实是一个线性结构,除了第一个节点外,其余节点都有一个直接前驱,除了最后一个节点外,每一个节点也都有一个直接后继。但在线性表中,我们说过,我们假设此时只有一个G节点,我们是可以向后找到他的直接后继的,但在树中,如果只给一个G节点,我们是不可能找到他中序序列的直接后继的,哪怕给了整棵树,我要找到G的直接后继也是非常麻烦的,需要重新挨个算一遍,但正好G的左右孩子都是空链域,我们就可以将其利用起来,用来记录中序遍历的前驱和后继,以方便我们查找前驱后继节点。
我们制定以下规则,如果一个节点的左孩子为空链域,那么就指向其中序遍历序列中的前驱节点,如果一个节点的右孩子为空链域,那么就指向其中序遍历中的后继节点。遵循这个规则的二叉树,我们就称之为中序线索二叉树。
1696395859.jpg
以上就画出了该树的中序线索二叉树,我们把普通的树转变为线索二叉树的过程,就是二叉树的线索化,可以看到,对于指向后继节点和前驱节点的指针,他虽然指向的也是一个节点,但和其他普通边的含义不太一样,所以这里用虚线表示,并且给这些指针一个新的名字,称为线索,指向后继节点的指针称为后继线索,指向前驱节点的指针称为前驱线索,很显然,在真实的数据结构中,为了区分一个节点的左右孩子是真实孩子的还是线索,我们必须额外设立标志位,如下图所示。
1696396265.jpg
上面的先序线索二叉树的存储结构就可以用以下图表示
1696396321.jpg
以上是以中序线索二叉树为例,同理还有先序线索二叉树和后序线索二叉树,先序线索二叉树用空链域指向先序遍历节点的前驱和后继,后序线索二叉树用空链域指向后序遍历节点的前驱和后继。
在本节主要要求读者掌握如何用手画出线索二叉树,首先,看清题目要求的是哪一种线索二叉树,按照规定的线索二叉树类型,先写出其对应的遍历序列,例如先序线索二叉树就写先序序列,然后一个一个看树中的每一个节点,如果这个节点的左右孩子存在空链域,就需要去前面写好的序列中找,让他的左孩子指向其遍历序列的前驱结点,让他的右孩子指向遍历序列的后继节点。(如果没有那依旧指向NULL)。

二叉树的线索化

上一节介绍了如何用手画出线索二叉树,接下来我们来考虑计算机应该如何实现二叉树转线索二叉树。
我们的基本思路是在遍历算法中将该树线索化,我们只需要设置一个pre指针,pre指针初始值为NULL,始终指向当前访问的上一个节点,也就是遍历序列当前节点的前驱节点,在访问节点的时候,如果当前访问节点的左孩子为NULL,我们就可以让让他指向pre节点。同理,当前访问的节点也一定是pre节点的后继节点,所以如果pre指针指向节点的右孩子为NULL(pre不为空的情况下),我们就可以让他指向当前访问的节点,这就是该算法的基本思路,接下来我们给出中序线索化的具体代码:

TreeNode * pre = NULL;
void visit(TreeNode *p){
    if(!p -> lchild){//如果p的左节点为NULL
        p -> ltag = 1;//左孩子线索化
        p -> lchild = pre;
    }
    if(pre && !pre -> rchild){//如果pre不为空,并且其的右孩子为NULL
        pre -> rtag = 1;//右孩子线索化
        pre -> rchild = p;
    }
    pre = p;
}
void findPre(Tree &t){
    if(t){
        findPre(t -> lchild);
        visit(p);
        findPre(t -> rchild);
    }
}
int main(void){
	Tree t;//这是一个树
    findPre(t);//线索化
    if(!pre -> rchild){
        //单独处理最后一个元素
        //如果最后访问元素的右孩子为NULL,则需要将他线索化
        pre -> rtag = 1;
    }
    return 0;
}

这代码和我们的描述几乎没有出入,如果p的左孩子为空,将其线索化,如果pre的右孩子为空,将其线索化,同时每次更新pre,保证pre始终指向前一个访问的节点。
但细心的读者会发现,在代码23行,我对最后一个元素进行了单独处理,这个单独处理的作用是将最后一个元素线索化,笔者这里解释一下为什么要将最后一个元素作单独处理。
1696407657.jpg
以这个树为例,我们对右孩子的线索化是通过访问下一个节点实现的,但该树运行该算法的时候,访问到最后,pre和p(图中为q,无伤大雅)都会指向C,此时遍历过程结束了,p无法再指向下一个节点,所以我们也就无法将C的右孩子线索化,所以我们需要对C的右孩子单独处理,如果其右孩子为NULL,就要将其线索化。
给出中序遍历后,我们也可以同理给出后序遍历

TreeNode * pre = NULL;
void visit(TreeNode *p){
    if(!p -> lchild){//如果p的左节点为NULL
        p -> ltag = 1;//左孩子线索化
        p -> lchild = pre;
    }
    if(pre && !pre -> rchild){//如果pre不为空,并且其的右孩子为NULL
        pre -> rtag = 1;//右孩子线索化
        pre -> rchild = p;
    }
    pre = p;
}
void findPre(Tree &t){
    if(t){
        findPre(t -> lchild);
        findPre(t -> rchild);
        visit(p);
    }
}
int main(void){
	Tree t;//这是一个树
    findPre(t);//线索化
    if(!pre -> rchild){
        //单独处理最后一个元素
        //如果最后访问元素的右孩子为NULL,则需要将他线索化
        pre -> rtag = 1;
    }
    return 0;
}

最后我们来考虑先序遍历的线索化,我们先给出代码

TreeNode * pre = NULL;
void visit(TreeNode *p){
    if(!p -> lchild){//如果p的左节点为NULL
        p -> ltag = 1;//左孩子线索化
        p -> lchild = pre;
    }
    if(pre && !pre -> rchild){//如果pre不为空,并且其的右孩子为NULL
        pre -> rtag = 1;//右孩子线索化
        pre -> rchild = p;
    }
    pre = p;
}
void findPre(Tree &t){
    if(
        visit(p);
        if(t -> ltag == 0)//只有在左孩子为真实节点的情况下,才允许递归左节点
        	findPre(t -> lchild);
        findPre(t -> rchild);
    }
}
int main(void){
	Tree t;//这是一个树
    findPre(t);//线索化
    if(!pre -> rchild){
        //单独处理最后一个元素
        //如果最后访问元素的右孩子为NULL,则需要将他线索化
        pre -> rtag = 1;
    }
    return 0;
}

可以看到,大致思路是没有变化的,唯一的区别在于第16行代码,为什么要加上这句代码呢,我们这里来分析一下
1696408099.jpg
我们考虑这种情况,此时由于p(图中为q,无伤大雅)的左孩子为NULL所以我们将其线索化,指向pre,按照我们的遍历算法,接下来我们应该对p的左孩子进行遍历,这个时候就出问题了,我们刚才将p的左孩子设置为了pre,所以如果再遍历他就会再把p置为B,然后pre置为D,然后就会循环往复陷入死循环,为了解决这个问题,我们就需要对其进行限制,只有当左孩子是真实的节点时,我们才允许遍历。
本节的算法都要求掌握,难度不高,主要注意两点,其一是对最后一个元素的单独处理,其二是对于先序线索化,我们要防止这种死循环的问题,需要在对左子树线索化的时候判断其是否为真实节点,这两点务必牢记。

在线索二叉树中寻找前驱和后继

相信读到这里的读者可能会有一些疑问,我们找一个线索二叉树
1696395859.jpg
在这个线索二叉树中,对于G节点,我们确实可以很方便找到其后继,但对于B节点而言,他的右孩子是真实的节点,我怎么找到他的后继呢?本节就来解决读者可能会有的这个疑问。
我们分三个情况讨论,分别是中序,后序,先序,三种线索二叉树

中序线索二叉树

在介绍这个知识之前,我们需要补充一点知识,这里的知识对于后面的学习是必不可少的,对于一个中序线索二叉树而言,第一个被访问到的节点一定是最左下角的节点,最后一个被访问到的节点一定是最右下角的节点,我们先看为什么。
首先我们知道,中序遍历的顺序,应该是左子树,根节点,右子树,所以第一个被访问到的节点一定在左子树上,同理,在左子树中,也是最先访问左子树,所以左子树的左子树就是被最先访问到的,这样递归下去,就可以得出,树的中序遍历中第一个被访问到的节点一定是最左下角的节点。
再来看最后一个被访问到的,最后一个被访问到的节点一定在右子树上,而在右子树中,最后一个被访问到的节点也在右子树上,同理一直递归下去,最后一个被访问到的节点就一定在最右下角的位置。
我们可以通过下面两个函数来计算一个二叉树中序遍历中第一个被访问的元素和最后一个被访问的元素

TreadNode *first(ThreadNode *p){//中序遍历第一个被访问到的元素
    if(p -> ltag == 0)//如果左孩子是真实的元素
        p = p->lchild;//p指向左孩子
	return p;//返回p
}
TreadNode *last(ThreadNode *p){//中序遍历最后一个被访问到的元素
    if(p -> rtag == 0)//如果右孩子是真实的元素
        p = p->rchild;//p指向右孩子
	return p;//返回p
}

有了这个前置知识,接下来学起来就会轻松很多,我们先来看,如果给了一个节点,如何找到其中序遍历后继节点,显然,根据中序遍历的规则,顺序为左,根,右,此时其后继节点就应该是其右子树中第一个被访问到的节点,我们可以调用前面给出的first方法找到其后继节点。同理,其前驱节点就是其左子树中最后一个被访问到的节点,我们可以调用前面给出的last方法来找到前驱节点。

ThreadNode *nextNode(ThreadNode *p){//后继节点
    if(p -> rchild == 1)
        return p -> rchild;
    return first(p -> rchild);//找到右子树第一个被访问到的节点
}
ThreadNode *beforeNode(ThreadNode *p){//后继节点
    if(p -> lchild == 1)
        return p -> lchild;
    return last(p -> lchild);//找到左子树最后一个被访问到的节点
}
void inOrder(ThreadNode *p){//我们可以根据nextNode实现中序遍历
    for(ThreadNode *temp = p;temp;temp = nextNode(temp)){
        visit(temp);
    }
}
void inOrder(ThreadNode *p){//甚至可以实现逆序遍历
    for(ThreadNode *temp = lastNode(p);temp;temp = beforeNode(temp)){
        visit(temp);
    }
}
先序线索二叉树

我们先来分析一下在先序线索二叉树中,给了一个节点,如何找到其后继,很显然,对于先序序列而言飞,访问顺序为根,左,右,所以先序线索二叉树中,一个节点的后继就是其左子树中第一个被访问到的节点,也就是左子树的根节点,或者所其左孩子,如果没有左孩子呢,那么其后继节点就是右孩子的根节点。

如果右孩子不是线索,则必有右孩子

我们这里给出代码

ThreadNode *nextNode(ThreadNode *p){
    if(p -> rtag == 1)
        return p -> rchild;
    if(p -> ltag == 0)//如果有左孩子
        return p -> lchild;
    else
        return p -> rchild;
}

接下来我们考虑怎么找前驱,根据先序二叉树的访问顺序,根,左,右,似乎左子树和右子树都在自己后面,好像找不到前驱,确实找不到,这就是为什么我要先说后继。所以如果真的面临这种情况,唯一的解决办法就是从根节点往下一个一个找。
但我们也可以分析以下为什么找不到,本质上还是找不到父节点,我们在介绍二叉树的时候提到过一个三叉链表的概念,三叉链表就是在数据结构上添加了一个父节点的指针,接下来我们讨论一下在三叉链表的条件下,我们如何找到其前驱节点,我们分情况讨论。

  • 该节点为其父节点的左孩子:此时根据根,左,右的规则,访问完父节点之后,下一个访问的应该就是自己,所以前驱节点就是父节点
  • 该节点是其父节点的右孩子,但是父节点没有左孩子,此时根据根,左,右的规则,由于没有左孩子,所以他的前驱节点依旧为父节点
  • 该节点是父节点的右孩子,并且父节点同时拥有左右孩子:那么根据根,左,右的规则,其前驱就应该是其父节点的左子树最后一个被访问的节点。
  • 该节点是根节点,没有前驱。

下面我们考虑一下,其父节点的左子树最后一个被访问到的孩子,说白了,也就是看一个树的先序遍历中最后一个被访问到的孩子是啥,根据根,左,右的规则,最后一个被访问到的孩子应该是最右边的孩子,但却不像上一节那样是最右下角的孩子,我们可以这样考虑,根,左,右这个规则,如果存在左和右,那么根节点就不可能是最后被访问到的,所以我们可以说先序遍历访问到的最后一个节点一定是叶子结点,也可以这么说,最右下角的叶子结点,就是先序遍历最后访问到的节点,我们可以用算法实现它。

ThreadNode *last(ThreadNode *p){
    while(p -> lchild && p -> rchild){//如果不是叶子结点
        if(p->rchild)//如果有右孩子,就让p指向右孩子
            p = p -> rchild;
        else
            p= p -> lchild;//否则指向左孩子
    }
}
后序线索二叉树

后序线索二叉树和先序基本思想一样,我们先考虑其前驱节点,如果该节点的左孩子是线索,那直接就能找到前驱,如果不是的话,根据后序遍历左,右,根的特点,我们要找的就是其右子树的最后被访问到的元素,而右子树最后被访问到的元素一定是根元素,所以其前驱节点就是右子树的根节点。如果没有右孩子呢,那就应该是左孩子的根节点。

ThreadNode *beforeNode(ThreadNode *p){
    if(p -> ltag == 1)
        return p -> lchild;
    if(p -> rtag == 0)//如果有右孩子
        return p -> rchild;
    else
        return p -> lchild;
}

后序二叉树的后继节点也是找不到的,也得从其父节点找,所以也是考虑三叉链表的情况,这里分情况讨论

  • 该节点是父节点的右孩子:根据左,右,根的原则,其后继节点就应该是根节点,也就是其父节点
  • 该节点是父节点的左孩子,并且没有右孩子,根据左,右,根的原则,由于没有左孩子,所以其后继节点也是根节点
  • 该节点是其父节点的左孩子,并且父节点同时具有左右孩子,此时根据左,右,根的原则,其后继节点就应该是父节点的右子树第一个被访问到的节点。
  • 如果是根节点,则自己就是最后一个被访问的,则没有后继。

这里也涉及到一个问题,就是一个后序遍历中,第一个被访问的节点是什么,肯定是左子树上的节点,如果没有左子树,就得往右子树找,和上一节的情况差不多,就是这个树最左下角的叶子结点,我们用算法实现

ThreadNode *last(ThreadNode *p){
    while(p -> lchild && p -> rchild){//如果不是叶子结点
        if(p->lchild)
            p = p -> lchild;
        else
            p= p -> rchild;
    }
}

本节内容到此结束,不要求全部默写出代码,考场上可以现推。

树、森林

树的存储结构

我们前面介绍的都是和二叉树有关的内容,但考试肯定不止考你二叉树,对于一个普通树的存储,我们这里给出三种方案。

双亲表示法

双亲表示法是一种顺序的存储结构,说到顺序存储结构,我们的二叉树也有一个顺序存储结构,但和这里逻辑其实完全不一样,二叉树的顺序存储结构是将二叉树补充成一个完全二叉树,由于完全二叉树的元素编号和元素位置是一一对应的,通过这种一一对应的关系就可以确定一棵二叉树,但普通的树由于节点个数,分支个数,啥都不知道,所以就不能采用这种方式。
我们前面在说树的性质的时候,应该提到过,除了根节点以外,其他节点均有且仅有一个父节点,所以我们可以通过记录其父节点的方式来表示一棵树。
我们以这棵树为例
1696416374.jpg
我们可以这样存储他
1696416399.jpg
我们用data记录值,将其保存到一个数组里,每一个值都对应一个下标,将每一个节点父节点的下标放到其对应的parent上即可。
结构体定义如下:

#define MAX 100
typedef struct{
	int data;//数据
	int parent;//父节点下标
}Node;
typedef struct{
	Node nodes[MAX];//定义数组
	int n;//保存节点数量
}Tree;

这种方式的有点非常明显,我们如果要找到某个节点的父节点,那么我们只需要找其parent元素即可,非常简单。但如果反过来,我们要找一个节点的子节点,那就得挨个遍历,谁的父节点是他,谁就是他的子节点,这样显然非常麻烦,所以这种方式的优缺点可以用一句话概括:找爹容易找儿难。

该方法也可以保存森林当用于保存森林的时候,会出现多个parent值为-1的节点

孩子表示法

孩子表示法不同于双亲表示法,是一种链式+顺序存储杂交的存储方式,还是以这棵树为例
1696416374.jpg
1696418212.jpg
可以看到,孩子表示法本质上还是一个数组,只不过为了存储每一个节点的孩子节点,还需要在每一个节点后面单独跟一个链表。接下来给出数据结构定义

#define MAX 100
struct childNode{//保存孩子节点
	int child;//孩子节点在数组中的下标
	struct childNode *next;//下一个孩子节点
}
typedef struct{
    int data;//数据
	struct childNode *firstChild;//保存第一个孩子的指针
}Node;//数组的节点
typedef struct{
    Node nodes[MAX];
	int n,r;//分别表示节点个数和根节点的位置
}Tree;

由于我们无法像双亲表示法那样通过判断parent是否为-1来判断是否为根节点,所以在数据结构里需要存储根节点的位置。
同样,这种方法也可以用来保存森林,我们只需要在数据结构中记录多个根节点即可。
孩子表示法的优点是可以很快速地找到某个节点的所有子节点,但反过来,要找双亲节点就显得很麻烦,也是需要对整个树进行遍历,优缺点和双亲表示法正好互补,总结来说就是找儿容易找爹难

孩子兄弟表示法

这种表示法也可以同时用于存储树和森林,这种表示法的核心是将一棵树或者森林转变为二叉树,从而用一个二叉链表进行存储。
至于如何将树转换为二叉树,参考下一节,将树/森林转变为二叉树之后,我相信读者通过前面的学习应该知道如何用二叉链表存储二叉树。

树,森林,二叉树三者的转换

我们主要学习树和二叉树的转变,树和二叉树的转换就记住一句话左孩子,右兄弟,这里解释一下这句话,左孩子,就是指二叉树的左边需要放树的第一个孩子节点,右兄弟就是指二叉树的右边需要放该节点的兄弟节点,我们举例说明。
1696419495.jpg
我们上来就来一个节点比较多的题,我们也只举着一个例子,首先,我们必须要把根节点先画出来,之后就是左孩子,右兄弟,我们看到,A节点的第一个孩子节点为B,所以在二叉树中,A节点的左孩子就是B,而B又有一个兄弟节点C,所以在二叉树中B的右孩子是C。

接下来我们看到B的第一个孩子为D,在二叉树中,我们应该把第一个孩子放在左边,所以B的左边就是D,同理,C的左边是E,如下

而D节点又有H和E两个兄弟节点,兄弟节点应该放在右边,E节点又有J和K两个兄弟节点,如下

H节点第一个孩子节点为G,所以H节点的左孩子为G,G又有I和L两个兄弟节点,这两个兄弟节点又应该放在G的右孩子上,所以最终图如下

以上就通过举例说明了树转二叉树的过程,接下来看森林转二叉树,森林转二叉树其实非常简单,只需要将森林的多个根节点看成兄弟节点即可,也是遵循上面这种左孩子右兄弟的方式。
接下来再看一个反过来转换的例子,也就是将二叉树转为树
1696420296.jpg
A节点为根节点,根据左孩子右兄弟,我们知道,B一定是A的第一个子节点,而C是B的兄弟节点,F是C的兄弟节点,L是F的兄弟节点,说白了,B,C,D,L都是兄弟节点,都是A的子节点。B的左孩子就是其子节点,B的左孩子为D节点,可知D是B的第一个子节点,并且H和D是兄弟节点,所以B和D都是B的子节点。G在D的左孩子上,所以G应该是D的第一个子节点。我们再看右边,C有一个孩子节点E,E有一个兄弟节点J,所以E和J就是C的子节点。同时E的子节点又是I。最后F还有一个子节点K。最后画出的图如下所示:
1696420498.jpg

相信将树和森林转换为二叉树之后,读者有能力自行用二叉链表表示一棵树了,将树转换为二叉树之后,用二叉链表进行表示,这就是我们上一节提到的孩子兄弟表示法

树和森林的遍历

树的遍历

树的遍历分为先根遍历和后根遍历,先根遍历类似于二叉树的先序遍历,后根遍历类似于二叉树的后根遍历。
我们以一个树为例,就看上一节中的树
1696420498.jpg
先根遍历,也就是先访问根节点,再依次递归遍历所有子节点,首先根据规则我们有以下序列
A B C F L
之后我们看B本身是一棵树,我们对B这个子树进行先根遍历得到B D H,D本身又是一棵树,对其进行先根遍历,得到D G,我们带入,得到新序列为
A B D G H C F L
此时C本身又是一棵树,所以我们还要对C进行展开,也就是先根遍历,C先根遍历为C E J,其中E又是一棵树,对其进行先根遍历为E I,依次带入得
A B D G H C E I J F L
此时只有F还是一棵树了,对F进行先根遍历得到F K,带入
A B D G H C E I J F K L
这就是先根遍历的思想,和先序遍历其实差不多
这里给出一个结论,树的先根遍历序列和该树转化为二叉树之后的先序序列一样,读者可以自行举例验证。
树的后根遍历同理,原理类似于二叉树的后序遍历,按照二叉树的后序遍历的方法走一遍即可。
这里也给出一个结论,树的后根遍历序列和该树转化为二叉树之后的中序遍历序列一样

请务必记牢,虽然后根遍历算法的思想和二叉树的后序遍历一致,但其遍历结果和对应二叉树的中序遍历一致

接下来介绍树的层次遍历,其实完全可以不用说,树的层次遍历我们已经讲过了,和前面二叉树的层次遍历一模一样,实现方式也和二叉树的层次遍历一模一样,树的层次遍历是通过队列实现的,如果读者遗忘了可以去翻一下前面的章节。

树的先根遍历和后根遍历又称为深度优先遍历,层次遍历又称为广度优先遍历

我们说完了树的遍历,接下来说一下森林的遍历,森林的遍历其实非常简单,几句话就能说明白,森林的遍历一共有两种,分别是先序森林遍历,中序森林遍历,其中先序森林遍历实际上就是依次对每一个树进行先根遍历,而中序森林遍历则是依次对每一棵树进行后根遍历。

森林的先序遍历相当于其对应二叉树的先序遍历,森林的中序遍历相当于其对应二叉树的中序遍历

注意区分,先根遍历,后根遍历,先序遍历,后序遍历,中序遍历,这几个关系微妙,二叉树的中序遍历等同于他所对应树的后根遍历

普通树的遍历一般不会考算法题,如果考到了,我们可以用二叉链表的方式存储他,用二叉树的遍历算法去解决

树与二叉树的应用

哈夫曼树

在介绍哈夫曼树之前,需要介绍权的概念,一个节点的权就是给这个节点一个数值,这个数值作为权重可以表示一个现实的含义,比如节点的重要性,节点中数据的出现频率等等,如果理解起来有困难,就可以把权理解为给节点新添加的一个属性。
接下来介绍一下节点的路径长度,节点的路径长度就是从根节点出发,到该节点所需要经过的路径长度,从数值上来说,节点的路径长度等于该节点的深度(从0计算),如果深度从1开始计算则需要减一。

树的边都是有向边,路径只能从上往下找,不能从下向上找

介绍完权和路径长度之后,就可以将二者结合,我们将一个节点的路径长度乘以他的权,就得到了该节点的带权路径长度
对于一棵树来说,带权路径长度是树中所有叶子结点带权路径长度之和,一般用WPL表示。
接下来就可以引入哈夫曼树的概念了,在含有n个带权叶子结点的二叉树中,带权路径长度最短的就是哈夫曼树。
如下图:
1696499681.jpg1696499698.jpg
对于上面两张图,他们的叶子结点相同,但带权路径长度缺不相同。读者可以验证,这四个节点作为叶子结点,不管以何种形式组成一棵树,带权路径长度均不会低于25,上右图中的树带全路径长度就为25,所以可以认为这是一个哈夫曼树。
哈夫曼树的形态不唯一,如下图所示
1696499817.jpg
这两个树的带权路径长度同为25,都已经是最小值了,所以他们都是哈夫曼树。
接下来我们就来考虑,如果给了我们n个带权叶子结点,我们如何构造哈夫曼树,哈夫曼树的构造可以归纳为以下几个步骤

  1. 把这n个带权节点看做n个仅包含一个根节点的二叉树,这n个节点构成了一个森林。
  2. 在这个森林中,找出根节点权重最低的两棵树,为这两棵树的根节点添加一个共同的父节点,让他们分别作为左子树和右子树,从而构成一棵新树,新树的根节点权值为这两颗子树根节点权值之和。
  3. 将这新树添加到森林中,并且将刚才用于构成这棵树的两个子树从森林中删除掉
  4. 如果森林中树的个数大于1,则跳转到第二步

以下就是一颗哈夫曼树的具体构造步骤
1696500159.jpg
从哈夫曼树的构造过程而言,我们可以归纳出以下关于哈夫曼树的性质

  • 哈夫曼树之中不存在度为1的节点
  • 设哈夫曼树有n个叶子结点,由于每次都要选两个节点构成一个新树,其构造次数为n-1次,每次又会产生一个新的节点,所以哈夫曼树中一共有2n-1个节点。从数学的角度去理解,哈夫曼树没有度为1的节点,所以 n 1 = 0 n_{1}=0 n1=0 n 0 = 1 + n 2 , n = n 0 + n 1 + n 2 n_{0}=1+n_{2},n=n_{0}+n_{1}+n_{2} n0=1+n2,n=n0+n1+n2,叶子结点个数为n,可以推出总结点个数为2n-1
  • 哈夫曼树并不唯一,但具有相同的带权路径长度

说完了哈夫曼树的构造,接下来介绍哈夫曼编码,这是哈夫曼树的一种应用,在学哈夫曼编码之前,先了解什么是编码
由于计算机只认识0和1这样的二进制,所以所谓的编码,就是把人类的语言翻译成计算机的语言,也就是0和1这样的二进制,相对应的,解码就是将计算机的语言翻译为人类的语言。
常见的编码分为两种类型,等长编码和变长编码,等长编码就是每个字符长度都相等的编码,比如ASCII码就是一个典型的等长编码,在ASCII码中,A就是01000001,B就是01000010
这种等长编码显然不是最优的,因为每一位字符的位数都是一样的,在实际应用中,我们肯定希望传输的速度最快,也就是在同等速度下,传输的数据最小,ASCII编码的这么多字符在传输过程中出现的频率肯定不一样,不同频率的字符使用同样的编码长度,这是不合理的,如果我们能设计出一种编码,让出现频率越高的字符编码长度小,这样我们传输的内容一定是最小的。
举个实际的例子,我要传输的字符有A,B,C,D四个,A字符出现1次,B字符出现1次,C字符出现1000次,D字符出现2次,很显然,如果我让C字符的编码比其他字符短1位,那么1000个C字符最后总编码长度就会短1000位,这是很可观的。
我们这种思路就是一种典型的变长编码。在我们这个编码思路中,出现频率越高的字符应当使用越短的编码长度,从而达到整体压缩的目的。
但我们这种变长编码也需要考虑一些问题,这里举个例子,例如我要传输A,B,C,D四个字符,我给他们如下编码
A:10 B:01 C:0 D:010
在这个编码中,A,B,C,D的长度是不同的,这是一种变长编码,但如果我传入的是这样一个数据:010010,就会产生歧义,比如01 | 0 | 010和010 | 0 | 10,可以看到,可变长的编码不同于定长编码,定长编码由于每一位都是固定的编码长度,所以不会产生歧义,但变长编码很有可能导致歧义,我们必须在编码阶段解决这个问题。
解决歧义的方法有很多,最容易想到的就是可以传入一个特定的编码来表示分隔符,但哈夫曼编码使用了另一种解决方案,就是前缀编码。
所谓的前缀编码,就是没有任何一个编码是另一个编码的前缀,我这里对A,B,C,D以前缀码重新编码
A:10 B:111 C:0 D:110
这就是一个前缀编码,不可能产生歧义,要知道为什么不会产生歧义,我们得分析最开始的编码为什么产生歧义,在最开始的编码中,我们编码规则是这样的A:10 B:01 C:0 D:010,在这个编码规则中,B是D的前缀,C也是D的前缀,C还是B的前缀,所以当我们收到一个010的时候,由于前缀01也在我们的编码表中表示B,所以我们不知道应该解读为01|0还是010一个整体,但前缀码是不可能出现这个问题的。
例如我收到编码110,我一定知道这个编码是D,因为我如果不把他识别为D,把它从中间分开,他在我们的编码表中表达不了任何一个字符,所以你只能给他识别为D,如果实在理解不了,读者可以记注,前缀码不可能产生歧义即可。
哈弗曼编码就是一种变长编码,他的核心思路就是出现频率高的字符编码长度短,出现频率低的字符编码长度长,从而达到整体编码长度缩短,提高传输效率,同时也可用于压缩存储。
接下来我们来看应该如何进行哈夫曼编码。
我们假设有A,B,C,D四个字符要传递,其中A出现10次,B出现8次,C出现80次,D出现2次,这些频率我们可以看做权重,我们用着四个节点构造一个哈夫曼树,如下:
1696503218.jpg
构造好了之后,我们让朝左走的边为0,朝右走的边为1,就像上图一样,我们就可以对其进行编码了
我们从根节点开始,一个一个找叶子节点,比如C节点,我们编码为0,而A节点,我们编码为10,D节点编码为110,B节点编码为111,相信读者已经可以总结出规律,其实就是找叶子结点的路径,从根节点开始,朝左走一步就写个0,朝右走一步就写个1,依次类推。

我们默认为左0右1,也可以左1右0。
由于哈夫曼树本身就不唯一,所以哈夫曼编码也可以不唯一
题目中可能还要计算数据压缩率,实际上就是压缩后的大小除以压缩前的大小

并查集

并查集其实是一个新的数据结构,数据元素之间是集合结构,其实可能有的读者学到这还不知道集合结构要如何存储,其实集合结构你怎么存都可以,主要根据你的操作类型。
并查集作为一个集合结构,他要实现的功能其实就是并和查两个操作。
首先,一个集合结构,我们可以给他划分成多个互不相交的子集,相当于分类,划分成多个子集后,我们肯定要查到某一个元素属于哪个集合,与此同时,我们还要知道如何将两个集合合并。这也就是并查集的两个操作,并和查。
1696506758.jpg

虽然分成了多个字集,但这些元素均属于同一个并查集,这个关系要理解清楚

既然并查集要放在树这里学习,很显然,并查集就是一个通过树实现的数据结构。
我们可以把每一个子集都构成一棵树,一个并查集就可以看做成一个森林,如下图所示
1696506874.jpg
此时并查集的两个操作其实已经很好实现了,如果我要查询两个节点是否属于同一个集合,我可以判断这两个节点的根节点是否相同,如果相同,那么就属于同一个集合,也就是说,我们可以用根节点来代表这个子集。而我们如果要将两个集合合并,我只需要将一个树的根节点挂载到另一个树的根节点上即可,如下图所示
1696506971.jpg
这样就实现了C和A这两个集合和合并。
这里我们已经用口头描述了并查集的实现思路,接下来我们具体实现它。
首先,既然是森林,我们就要考虑其存储结构是什么,我们学过森林的三种存储结构,分别是双亲表示法,孩子表示法,孩子兄弟表示法,如果有遗忘请读着自行回顾。
由于我们的查找操作是一直向上找其父节点,而双亲表示法的优劣势我们归结为找爹容易找儿难,完美符合双亲表示法的优势,所以我们就使用双亲表示法来表示这个森林。

回顾一下双亲表示法,双亲表示法就是用一个静态的数组来表示其逻辑结构,数组中包含两项,一项为节点数据,另一项为该节点父节点的下标。

我们先给出这个森林的数据结构定义

#define MAX 100
typedef struct {
	char data;//数据
	int parent;//父节点
} Node;
typedef struct {
	Node nodes[MAX];//存储节点
	int num;//节点个数
} Tree;

我们这里并查集的节点存储的数据为字符,我们首先应该有一个录入数据的操作,录入操作可以看成是两个步骤,第一步是将所有节点录入,第二步是将其所属集合的录入。我们优先考虑录入所有节点,录入节点后,我们可以把所有节点都初始化为不同的种类,也就是他们每一个节点都属于不同的子集,之后我们通过并操作将他们一个一个分类即可。
这里先给出录入数据的代码

void input(Tree t,char *data,int len){
	for(int i = 0;i<len;i++){
		t.nodes[i].data = data[i];
		t.nodes[i].parent = -1;//每一个节点都初始化为-1
	}
	t.num = len;
}

以上我们就实现了对数据的录入,我们规定初始化的时候大家都作为单独的子集,所以每一个元素的初始化都-1。
读者可能会发现,我们这样写其实会让代码变得非常繁琐,所以我们可以对代码进行优化,把其分为两个数组,一个数组存储数据,一个数组存储字符。

#define SIZE 7
char c[] = "ABCDEFG";//存储数据
int t[SIZE];//存储关系

用们用c数组存储数据,用t数组存储数据间的关系,这样哪怕不用结构体也可以表示这种结构,此时我们就再给出新的初始化算法

void init(int *t){
	for(int i = 0;i<SIZE;i++){
		t[i] = -1;
	}
}

由于我们初始化的时候只需要将他们的关系进行初始化,其数据我们已经存储到c字符串中了,所以传入的参数也只有t数组,我们依旧默认最开始每一个元素都有单独的一个分类,所以都初始化为-1,也就是让他们每一个节点都作为一个根节点。
接下来我们实现并和查的操作,这两个操作非常简单,这里只给出代码

int Find(int *t,int x){//查询x所在树的根元素,x表示位置
	while(t[x] >= 0){//如果当前位不是根元素
		x = t[x];//修改下标,继续找
	}
	return x;//返回当前位
}
void Union(int *t,int x1,int x2){//将x2,合并到x1上
	if(x1 == x2)//同一个节点不能合并
		return;
	t[x2] = x1;//x2的父节点改为x1
}

这其实就是实现了并和查的操作,读者可以分析一下这个代码,非常简单。
我们分析一下这个代码的时间复杂度,首先是Union,也就是并操作,并操作的时间复杂度为O(1),相信没有难度,再看这个Find操作,这个操作的时间复杂度应该和高度有关,高度越高,复杂度就越高,所以最坏时间复杂度为O(n)
既然find操作的时间复杂度和高度有关,那么我们就可以试着朝着这个方向优化并查集,我们可以在构建树的时候,尽可能不让树变高,我们看我们对树的并操作有两种可能,一种是高的树并到矮树上,第二种是矮的树并到高树上,很显然,第一种方案是会让树整体长高的,而第二种方案并不会让树长高,比如一个高度为3的树并到高度为5的树上,高度依然是5,我们就可以在union的时候,永远让矮的树并到高树上。
这里还有一个问题,我们如何知道树的高度,我们可以用节点个数来估计树的高度,遵循节点个数少的树并入节点个数多的树的算法,这个节点数存在哪呢?我们前面对于根节点存储的都是-1,我们可以把这个位置利用起来,用其绝对值来存储节点个数,例如-6就可以表示有6个节点,这种根据节点个数,将个数少的节点并入个数多的节点的策略,叫做按大小合并
有的读者可能会有一个疑问,就是我们明明是说高度越矮越好,为什么不直接存储一个树的高度呢?实际上也可以,这种策略就叫按高度合并,笔者学到这的时候也存在同样的疑问,按高度合理论上确实可以得到最矮的树,并且按大小合并得到的也不一定是最矮的数,但是按高度合并有时候并非最优算法,我这里举个极端的例子来感受一下

比如上面这两棵树,这两个树的高度都为4,如果采用按大小合并,则是如下状态

在这种情况下,只有一个元素的路径加了一,但是如果采用按高度合并,则是随机选取的,完全有可能反着来,就像下图所示

虽然这两种方式合并出来的高度都是5,但是这种情况下按高度合并会让五个节点的深度加一,显然没有前者优秀。
所以这里给出结论,按高度合并可以得到高度最低的树,按大小合并则是得到一个更为平衡的树,这两种策略都是可以的,关键看使用场景。
这里我们使用按大小合并来优化Union算法

void Union(int *t,int x1,int x2){//将x2,合并到x1上
	if(x1 == x2)//同一个节点不能合并
		return;
	if(t[x1]>t[x2]){//如果x2节点多,注意存储的是负数,需要取反
		t[x2] += t[x1];//修改节点个数
		t[x1] = x2;//让x1合并入x2
	}else{
		t[x1] += t[x2];//修改节点个数
		t[x2] = x1;//让x2合并入x1
	}
}

这样优化后,可以使得Find操作的时间复杂度为 O ( log ⁡ 2 n ) O(\log_{2}n) O(log2n)
以上我们说完了对Union操作的优化,对Find操作我们同样是可以优化的。
我们肯定是希望树越矮越好,所以如果每一个节点都是直接挂载到根节点上,那查找效率简直不要太高,但如果我们每一次Union的时候都把被合并树直接挂到合并树的根节点上,其实是不现实的,会非常影响效率,所以我们其实可以在Find的时候完成这样的操作,我们可以把每一次Find走过的路径全部都挂载到根节点上,这样实现在查找的过程中进行优化,改进后的算法如下所示。

int Find(int *t,int x){//查询x所在树的根元素,x表示位置
	int root = x;
	while(t[root] >= 0){//如果当前位不是根元素
		root = t[root];//修改下标,继续找
	}
	//root为根节点,x为原节点
	//遍历压缩
	while(x != root){
		int temp = t[x];//temp指向t的父节点
		t[x] = root;//让x直接指向根节点
		x = temp;//让x指向其父节点,继续
	}
	return root;
}

这样优化之后最坏时间复杂度为 O(α(n)) ,这是阿克曼函数的反函数,读者只需要知道其增长速度极其缓慢即可。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值