# 树 ## 基本概念 ### 树的基本概念 + 树:n个结点的有限集(树是一种递归的数据结构,适合于表示具有层次的数据结构)。是递归定义的。 + 根结点:只有子结点没有父结点的结点。除了根结点外,树任何结点都有且仅有一个前驱。 + 分支结点:有子结点也有父结点的结点。 + 叶子结点:没有子结点只有父结点的结点。 + 空树:结点数为0的数。 + 子树:当n>1时,其余结点可分为m个互不相交的有限集合,每个集合本身又是一棵树,其就是根结点的子树。 + 结点的度:一个结点的孩子(分支)个数。 + 树的度:树中结点的最大度数。 + 结点的层次(深度):从上往下数。 + 结点的高度:从下往上数。 + 树的高度(深度):多少层。 + 两结点之间的路径:由两个结点之间所经过的结点序列构成。 + 两结点之间的路径长度:路径上所经过的边的个数。 + 树的路径长度:指树根到每个结点的路径长的总和,根到每个结点的路径长度的最大值是树的高。 + 有序树:树各结点的子树从左至右有次序不能互换。 + 无序树:树各结点的子树从左至右无次序可以互换。 ### 森林的基本概念 + 森林时m棵互不相交的树的集合。 + 一颗树可以被分为森林。 ### 树的性质 + 结点数=总度数+1。 + 树的度m代表至少一个结点度是为m,且一定是非空树,至少有m+1个结点;而m叉树指所有结点的度都小于等于m,可以是空树。 + 度为m的树以及m叉树第i层至多有$m^{i-1}$个结点。 + 高度为h的m叉树至多有$\dfrac{m^h-1}{m-1}$个结点。 + 高度为h的m叉树至少有$h$个结点,度为m的树至少有$h+m-1$个结点。 + 具有n个结点的m叉树最小高度为$\lceil\log_m(n(m-1)+1)\rceil$。已知高度最小时所有结点都有m个孩子,所以$\dfrac{m^{h-1}-1}{m-1}\lfloor\dfrac{n}{2}\rfloor$为叶子结点。 + 二叉排序树:左子树上所有结点的关键字均小于根结点的关键字;右子树上所有结点的关键字均大于根结点的关键字;左右子树又各是一棵二叉排序树。 + 平衡二叉树:树上任一结点的左子树和右子树的深度之差不超过1。 ### 二叉树的性质 + 设非空二叉树中度为0、1和2的结点个数分别为$n_0$,$n_1$、$n_2$,则$n_0=n_2+1$(叶子结点比二分结点多一个)。假设树中结点的总数为$n$,则$n=n_0+n_1+n_2$,又根据树的结点等于总度数+1得到$n=n_1+2n_2+n_0$,所以相减就得到结论。 + 二叉树的第$i$层至多有$2^{i-1}$个结点。 + 高度为$h$的二叉树至多有$\dfrac{m^h-1}{m-1}$个结点。 + 具有$n$个结点的完全二叉树的高度$h=\lceil\log_2(n+1)\rceil$或$\lfloor\log_2n\rfloor+1$。$2^{h-1}-1\lfloor\dfrac{n}{2}\rfloor$。 如果不是完全二叉树,则让二叉树的编号与完全二叉树相对应再存入数组,其他的结点为空,这种存储方法会浪费较多内存,最坏情况下高度为$h$,且只有$h$个结点的单支树也需要$2^h-1$个存储单元。 #### 链式存储 链式树具有两个分别指向左右子树的指针。 如果要保存父结点的位置,可以添加一个父结点指针,从而变成三叉链表。 ### 二叉树的遍历 遍历是按照某种次序将所有结点都访问一遍。 #### 顺序遍历 顺序遍历就是深度优先的遍历,分为三种: + 先序遍历:根左右NLR。 + 中序遍历:左根右LNR。 + 后序遍历:左右根LRN。 根据算算数表达式的分析树的不同先序、中序、后序遍历方式可以得到前缀、中缀、后缀表达式。 若树的高度为$h$,则时间复杂度为$O(h)$。 #### 层序遍历 层序遍历就是广度优先的遍历。 1. 初始化一个辅助队列。 2. 根结点入队。 3. 若队列非空,则队头结点出队,访问该结点,如果有并将其左右孩子入队。 4. 重复步骤三直至队列空。 ### 遍历序列构造二叉树 若只给出一棵二叉树的前/中/后/层序遍历序列中的一种不能唯一确定一棵二叉树。只有给出中序遍历序列才可能推出唯一二叉树,因为无法确定根结点相对于左右结点的位置: + 前序+中序。 + 后序+中序。 + 层序+中序。 #### 前序+中序 前序:根+左+右;中序:左+根+右。所以根据三个部分对应相同可以推出。 #### 后序+中序 后序:左+根+右;中序:左+根+右。所以根据三个部分对应相同可以推出。 #### 层序+中序 层序:根+左根+右根;中序:左+根+右。所以根据根结点和左右子树的根结点来确定。 ### 线索二叉树 对于二叉树的遍历,只能从根结点开始遍历,如果给任意一个结点是无法完成遍历的。 所以我们就想能否保存结点的前驱和后继,从而能减少重复遍历树。因为一棵树很多结点的左右结点可能是空的,那么这些空闲的指针可以不代表左右子树的根结点,而是用来表示当前遍历方法的前驱或后继。当这个指针表示的是前驱或后继就称为线索,指向前驱的就是前驱线索,由左孩子指针担当,指向后继的就是后继线索,由右孩子指针担当。 #### 线索化 为了区分其左右孩子指针是指向什么,要在结点中新建两个tag位,如当ltag=0表示lchild指向的是左孩子结点,而为1表示其指向前驱。 + 确定线索二叉树类型——中序、先序或后序。 + 按照对应遍历规则,确定每个结点访问顺序并写上编号。 + 将n+1个空链域连上前驱后继。 #### 查找前驱后继 + 中序线索二叉树中找到结点*P的中序后继next: + 若p右孩子指针指向后继:p->rtag==1,则next=p->rchild。 + 若p右孩子指针指向右子树根结点:p->rtag==0,则next=p右子树中最左下结点。 + 所以可以利用线索对二叉树实现非递归的中序遍历。 + 中序线索二叉树中找到结点*P的中序前驱pre: + 若p左孩子指针指向前驱:p->ltag==1,则pre=p->lchild。 + 若p左孩子指针指向左子树根结点:p->ltag==0,则pre=p左子树中的最右下结点。 + 所以可以利用线索对二叉树实现非递归的逆向中序遍历。 + 先序线索二叉树中找到结点*P的先序后继next: + 若p右孩子指针指向后继:p->rtag==1,则next=p->rchild。 + 若p右孩子指针指向右子树根结点:p->rtag==0,如果p有左孩子,则p->next=p->lchild,如果p没有左孩子,则p->next=p->rchild。 + 所以可以利用线索对二叉树实现非递归的先序遍历。 + 先序线索二叉树中找到结点*P的中序前驱pre: + 若p左孩子指针指向前驱:p->ltag==1,则pre=p->lchild。 + 若p左孩子指针指向左子树根结点:p->ltag==0,先序遍历中左右子树的根结点只可能是后继,所以这时候就找不到p的前驱,如果没有父结点只能从头开始先序遍历。 + 如果有父结点,则又有三种情况: + p为左孩子,则根据根左右,p的父结点为根所以在p的前面,p->pre=p->parent。 + p为右孩子,其左兄弟为空,则根据根左右,顺序为根右,所以p->pre=p->parent。 + p为右孩子且有左兄弟,根据根左右,p的前驱就是左兄弟子树中最后一个被先序遍历的结点,即在p的左兄弟子树中优先右子树遍历的底部。 + 后序线索二叉树中找到结点*P后中序后继next: + 若p右孩子指针指向后继:p->rtag==1,则next=p->rchild。 + 若p右孩子指针指向右子树根结点:p->rtag==0,则根据左右根顺序,左右孩子结点必然是p的前驱而不可能是后继,所以找不到后序后继,如果没有父结点只能使用从头开始遍历的方式。 + 如果有父结点则又有三种情况: + p为右孩子,根据左右根,所以p->next=p->parent。 + p为左孩子,右孩子为空,根据左右根,所以p->next=p->parent。 + p为左孩子,右孩子非空,根据左右根,所以p->next=右兄弟子树中第一个被后序遍历的结点。 + 后序线索二叉树中找到结点*P后中序前驱pre: + 若p左孩子指针指向前驱:p->ltag==1,则pre=p->lchild。 + 若p左孩子指针指向左子树根结点:p->ltag==0,则又有两种情况: + 若p有右孩子,则按照左右根的情况遍历,右在根的前面,所以p->pre=p->rchild。 + 若p没有右孩子,按照左根的顺序,则p->pre=p->lchild。 ### 二叉排序树 即BST,是一种用于排序的二叉树 #### 二叉排序树的定义 二叉排序树也是二叉查找树。左子树上所有结点的关键字均小于根结点的关键字;右子树上所有结点的关键字均大于根结点的关键字;左右子树又各是一棵二叉排序树。 中序遍历二叉排序树会得到一个递增的有序序列。 #### 二叉排序树的查找 1. 若树非空,目标值与根结点的值比较。 2. 若相等则查找成功,返回结点指针。 3. 若小于根结点,则在左子树上查找,否则在右子树上查找。 4. 遍历结束后仍没有找到则返回NULL。 遍历查找的时间复杂度是$O(\log_2n)$,则递归查找的时间复杂度是$O(\log_2n+1)$,其中$\log_2n+1$代表二叉树的高度。 查找成功的平均查找长度ASL,二叉树的平均查找长度为$O(\log_2n)$,最坏情况是每个结点只有一个分支,平均查找长度为$O(n)$。 #### 二叉排序树的插入 + 若原二叉排序树为空,就直接插入结点。 + 否则,若关键字小于根结点值,插入左结点树。 + 若关键字大于根结点值,插入右结点树。 #### 二叉排序树的删除 + 搜索到对应值的目标结点。 + 若被删除结点1p是叶子结点,则直接删除,不会破坏二叉排序树的结构。 + 若被删除结点只有一棵左子树或右子树,则让该结点的子树称为该结点父结点的子树,来代替其的位置。 + 若被删除结点有左子树和右子树,则让其结点的直接后继(中序排序该结点后一个结点,其右子树的最左下角结点)或直接前驱(中序排序该结点前一个结点,其左子树的最右下角结点)替代该结点,并从二叉排序树中删除该的结点直接后继后直接前驱,这就变成了第一种或第二种情况。 ### 平衡二叉树 即AVL树,树上任意一结点的左子树和右子树的高度之差不超过1。 结点的平衡因子=左子树高-右子树高。 在插入一个结点时,查找路径上的所有结点都可能收到影响。 从插入点往回(从下往上)找到第一个不平衡的结点,调整以该结点为根的子树。每次调整的对象都是最小不平衡树。 ### 调整最小不平衡树 最重要的是根据二叉排序树的大小关系算出从大到小的序列,然后把最中间的作为新根,向两侧作为左右子树。 #### 从结点的左孩子的左子树中插入导致不平衡 ```terminal A(2) | ------------ | | B(1) AR(H) | ------------ | | BL(H+1) BR(H) ``` BL < B < BR < A < AR 由于在结点A的左孩子B的的左子树BL上插入了新结点,A的平衡因子由1变成了2,导致以A为根的子树失去了平衡,需要进行一次向右的旋转操作。 将A的左孩子B向右上旋转代替A成为根节点,将A结点向右下选择为成B的右子树的根结点,而B的原右子树则作为A结点的左子树。 ```terminal B(0) | ------------ | | BL(H+1) A(0) | ------------ | | BR(H) AR(H) ``` #### 从结点的右孩子的右子树中插入导致不平衡 ```terminal A(-2) | ------------ | | AL(H) B(-1) | ------------ | | BL(H) BR(H+1) ``` AL < A < BL < B < BR 由于在结点A的右孩子(R)的右子树(R)上插入了新结点,A的平衡因子由-1减至-2,导致以A为根的子树失去平衡,需要一-次向左的旋转操作。 将A的右孩子B向左上旋转代替A成为根结点,将A结点向左下旋转成为B的左子树的根结点,而B的原左子树则作为A结点的右子树。 ```terminal B(0) | ------------ | | A(0) BR(H+1) | ------------ | | AL(H) BR(H) ``` #### 从结点的左孩子的右子树中插入导致不平衡 ```terminal A(2) | ------------ | | B(-1) AR(H) | ------------ | | BL(H) C(-1) | ------------ | | CL(H-1) CR(H) ``` ```terminal A(2) | ------------ | | B(-1) AR(H) | ------------ | | BL(H) C(-1) | ------------ | | CL(H) CR(H-1) ``` 将BR拆分为C和CL、CR,假设插入的是CR部分,插入CL也同理 BL < B < CL < C < CR < A < AR 由于在A的左孩子(L)的右子树(R)上插入新结点,A的平衡因子由1增至2,导致以A为根的子树失去平衡,需要进行两次旋转操作,先左旋转后右旋转先将A结点的左孩子B的右子树的根结点C向左上旋转提升到B结点的位置,然后再把该C结点向右上旋转提升到A结点的位置。 ```terminal C(0) | -------------------- | | B(1) A(0) | | ------------ ------------ | | | | BL(H) CL(H-1) CR(H) AR(H) ``` ```terminal C(0) | -------------------- | | B(0) A(-1) | | ------------ ------------ | | | | BL(H) CL(H) CR(H-1) AR(H) ``` #### 从结点的右孩子的左子树中插入导致不平衡 ```terminal A(-2) | ------------ | | AL(H) B(1) | ------------ | | C(1) BR(H) | ------------ | | CL(H) CR(H-1) ``` ```terminal A(-2) | ------------ | | AL(H) B(1) | ------------ | | C(1) BR(H) | ------------ | | CL(H-1) CR(H) ``` AL < A < CL < C < CR < B < BR 由于在A的右孩子(R)的左子树(L). 上插入新结点,A的平衡因子由-1减至-2,导致以A为根的子树失去平衡,需要进行两次旋转操作,先右旋转后左旋转。先将A结点的右孩子B的左子树的根结点C向右上旋转提升到B结点的位置,然后再把该C结点向左上旋转提升到A结点的位置。 ```terminal C(0) | -------------------- | | A(0) B(-1) | | ------------ ------------ | | | | AL(H) CL(H) CR(H-1) BR(H) ``` ```terminal C(0) | -------------------- | | A(0) B(-1) | | ------------ ------------ | | | | AL(H) CL(H-1) CR(H) BR(H) ``` ### 哈夫曼树 #### 哈夫曼树的定义 + 路径和路径长度:从树中的一个结点到另一个结点之间的分支构成这两个结点之间的路径,路径上的分支数目称作路径长度。 + 结点的权:有某种现实含义的数值。 + 结点的带权路径长度:从根到该结点的路径长度(经过边数)与该结点权的乘积称为结点的带权路径长度。 + 树的带权路径长度:树中所有**叶子**的带权路径长度之和称为树的带权路径长度$WPL=\sum_{i=1}^nw_il_i$。 + 哈夫曼树(最优二叉树):带权路径长度最短的二叉树。 #### 构造哈夫曼树 给定n个权值分别为$w_1, w_2\cdots w_n$的结点,构造哈夫曼树的算法描述如下: 1. 将这n个结点分别作为n棵仅含一个结点的二叉树,构成森林F。 2. 构造一个新结点,从F中选取两棵根结点权值最小的树作为新结点的左、右子树,并且将新结点的权值置为左、右子树上根结点的权值之和。 3. 从F中删除刚才选出的两棵树,同时将新得到的树加入F中。 4. 重复步骤二和三,直至F中只剩下一棵树为止。 + 每个初始结点最终都会变成叶子结点,且权值越小到根结点的路径长度越长。 + 哈夫曼树的结点总数为2n-1。 + 构建哈夫曼树时,都是两个两个合在一起的,所以没有度为一的结点,即n1=0。 + 哈夫曼树不唯一,但是WPL必然最优。 哈夫曼树适合采用顺序结构:已知叶子结点数n0,且n1=0,则总结点数为2n2+1(或2n0-1),且哈夫曼树构造过程需要不停地修改指针,用链式存储的话很容易造成指针偏移。 #### 哈夫曼编码 哈夫曼编码基于哈夫曼树,利用哈夫曼树对01的数据进行编码,来表示不同的数据含义,因为哈夫曼树必然权值最小,所以对于越常使用的编码越短,越少使用的编码越长,所以发送信息的总长度是最小的。 将编码使用次数作为权值构建哈夫曼树,然后根据左0右1的原则,按根到叶子结点的路径就变成了哈夫曼编码。 哈夫曼编码是可变长度编码,即允许对不同字符用不等长的二进制表示,也是一个前缀编码,没有一个编码是另一个编码的前缀。 同样哈夫曼编码也可以用于压缩。 ## 树与森林 ### 树的存储结构 + 双亲表示法:是一种顺序存储方式,每个结点中保存指向双亲的指针。查找双亲方便,但是查找孩子就只能从头遍历。 + 孩子表示法:是顺序加链式存储方法,顺序存储所有元素,添加一个firstChild域,指向第一个孩子结构体的指针,孩子结构体包括元素位置索引与指向下一个孩子结构体的next指针。 + 孩子兄弟表示法:是一种链式存储方式,定义了两个指针,分别指向第一个孩子与右兄弟,类似于二叉树,可以利用二叉树来实现对树的处理 。 ### 森林与树的转换 树与森林的转换,树与二叉树的转换都可以使用孩子兄弟表示法来实现,左孩子右兄弟,如果是森林则认为其根结点为兄弟。 ### 树的遍历 + 先根遍历:若树非空,先访问根结点,再依次对每棵子树进行先根遍历。 + 后根遍历:若树非空,先依次对每棵子树进行后根遍历,最后访问根结点。 + 层次遍历:用辅助队列实现: 1. 若树非空,根结点入队。 2. 若队列非空,队头元素出队并访问,同时将该元素的孩子依次入队。 3. 重复步骤二直到队列为空。 ### 森林的遍历 先序遍历森林: 1. 访问森林中第一棵树的根结点。 2. 先序遍历第一棵树中根结点的子树森林。 3. 先序遍历除去第一棵树之后剩余的树构成的森林。 中序遍历森林: 1. 先序遍历第一棵树中根结点的子树森林。 2. 访问森林中第一棵树的根结点。 3. 中序遍历除去第一棵树之后剩余的树构成的森林。 可以把每个树先按序遍历再合在一起,也可以先转换为二叉树再遍历。