目录
一、树的基础知识
1.1 树的定义
首先形象地想象一棵现实中的树,从树根开始,不断向上生长、分叉、长出叶子……
抽象数据结构的树和现实中的树并没有什么不同,无非是“逻辑上”向下生长的。
在了解树之前,必须得弄懂递归是什么,因为树就是一种递归定义的数据类型,并且在几乎所有操作中都离不开递归的思想。
形象地说,如果一棵现实中的树,我们从它第一个分叉点把这一整个分支都砍下来插在地上,又变成了一棵新树,再从这棵新树的第一个分叉点把这一整个分支都砍下来插在地上,又变成了一棵新树,子子孙孙无穷匮也……假如一直砍,砍到最后没得砍了,我们会发现插在地上的只有一堆树叶。
这就是递归定义的精髓。
在正式展开树的定义之前,还需介绍什么是空树。
空树,即一个分支都没有,连树叶都没有,空空如也。有根就不叫空树了,光有一个树干那也叫树呢。
然后从思维导图的第一行开始看,树是 n 个结点的有限集。这里的结点和链表中的结点本质上是差不多的,都是一个存储数据和指针的结构体。但是这 n 个结点有着一些特殊的逻辑关系,而树的逻辑结构,就叫树,所以,树是一种逻辑结构(集合、线性表、树、图)。
当 n=0 时,也就是没有任何结点,此时这棵树叫空树。
当 n>0 时,树就非空了,一棵树,有且仅有一个根结点。并且当 n>1 时,除了根结点,还会有很多分支结点和叶子结点,这时候如果我们直接把连着根结点的几个分支给拿下来,就会形成若干棵子树,并且每棵子树也是都有一个根结点的树,以此类推。
光文字说明白比较抽象,请看下图:
单看这棵树,根结点为 A,A 连着 B 和 C。如果把 A-B 和 A-C 切断,此时会得到 A 的两棵子树,其中左子树以 B 为根结点,右子树以 C 为根结点,并且 B 和 C 还分别连着 D E 和 F G。那同理,把以 B 为根结点的这棵树的 B-D 和 B-E 切断,会得到 B 的两棵子树,其中左子树以 D 为根结点(并且只有一个根结点),右子树以 E 为根结点(并且只有一个根结点)。
于是,一棵非空树由一个根结点和一棵左子树及右子树构成,其左子树和右子树右分别是一棵树,然后一层套一层,一直到最后一个没有子树(其实有,但子树为空)的结点。
树的特点,如果我们从根结点往下看的话,每个结点是可以有很多个后继的,但是往上看,每个结点只有一个前驱(根结点没有)。
1.2 术语解释
看图吧…
1.3 常见性质
① 结点数 = 总度数+1
上面解释过相关概念了,结点的度是这个结点的孩子结点的数量,比如下面这棵树,DEFG 的度是 0,BC 的度是 2,A 的度也是 2。把它们都加起来,会发现把 BCDEFG 这些结点都算进去了,但是根结点还没算,所以最后还要 +1。
② 度为 m 的树和 m 叉树的区别
树的度等于所有结点的度里最大的那个。
所谓度为 m 的树,只要满足有一个节点度为 m 即可,并且至少有 m+1 个结点(见①)。
所谓 m 叉树,是指每个结点最多分叉出 m 个子孩子,比如上面那棵树,可以叫二叉树,还可以叫三叉树四叉树五叉树,只要分支数量不超过 m 即可。并且极端情况,m 叉树还可以为空。
③ 度为 m 的树第 i 层至多有 m^(i-1) 个结点(m 叉树相同)
注意是最多,如果是度为 m 的树,那最多的时候每层都有 m 个分支,第一层有 1 个结点,第二层有 m 个结点,第三层有 m^2 个结点,第 i 层就有 m^(i-1) 个结点。m 叉树同理。
④ 高度为 h 的 m 叉树至多有 [1-m^(h-1)]/(1-m) 个结点
高度和深度概念是一致的,指的就是树的层数,把③的结论拿过来,从第 1 层加到第 h 层:m^0 + m^1 + m^2 + ... + m^(h-1) = 等比数列求和。
⑤ 高度为 h 的 m 叉树至少有 h 个结点
m 叉树,最少的时候每层要有一个结点,有 h 层就是 h 个结点。
⑥ 高度为 h 度为 m 的树至少有 h+m-1 个结点
度为 m 的树,最少的时候每层要有一个结点,有 h 层就是 h 个结点,但是有一个结点要有 m 个分支,其他 h-1 个结点都只连着一个孩子,所以总数是 h-1+m。
⑦ 具有 n 个结点的 m 叉树的最小高度为 ⌈logm(n(m-1)+1⌉
上面的公式是 log 以 m 为底应该能看出来吧…
高度最小的时候就假设每层都有 m 个,因为结点总数不变,这样树才能最宽。假设高度为 h,最后一个结点肯定在第 h 层,那么前 h-1 层的结点数算出来为 a,前 h 层的结点数算出来为 b,则 a<n<=b,然后反解出 h,c<=h<d,这时候对 c 向上取整即可,c 就是标题里的表达式,这里实在是不方便打函数……可以自己算算试一下。
二、二叉树的基础知识
2.1 二叉树的定义与常见性质
二叉树是一种特殊的树,即每个结点最多只有两棵子树(分两个叉)。
值得一提的是二叉树是一种有序树,左右结点顺序是不能颠倒的,当然,如果不在乎每个结点存储数据的先后顺序,也可以当无序树处理,但一般当成有序树来用。
2.2 常见的特殊类型二叉树
① 满二叉树
满二叉树要求每个结点都得有两个分支,满嘛,当然要把能存的地方都存了。
可以注意到这里给编了个号,这其实是是为了下面的完全二叉树服务的,注意满二叉树本身是无所谓有没有编号的。
② 完全二叉树
上面那个编号的方法,其实就是从上到下,从左到右,从 1 开始编号。对于任何一颗树都可以这样编号,但是只有编号和满二叉树的编号一一对应的,才叫完全二叉树,比如如下两个例子。(画工很抽象……)
不知道叫什么树就叫“上面那棵树”
完全二叉树
在上面那棵树中,我们会发现编号 5、6 和满二叉树的编号是不对应的,但是完全二叉树虽然少了一个结点但能编号的结点都和满二叉树的编号一一对应了。而且我们会发现一些规律:
Ⅰ 只有最后两层可能有叶子结点
就拿这棵完全二叉树来说,如果把 F 结点也给去掉,会发现编号也是对应的,此时 C 就变成了叶子结点,在倒数第二层。
Ⅱ 最多只有一个度为 1 的结点
还拿这棵完全二叉树来说,C 结点的度就为 1,如果把 F 拿掉,就没有度为 1 的了。那可能出现多个度为 1 的情况吗?不可能,必定会出现“上面那棵树”的情况。
Ⅲ i<=⌊n/2⌋ 为分支结点,i>⌊n/2⌋ 为叶子结点
上面的两个符号都是向下取整,对于一棵三层的满二叉树来说,前两层一共有 3 个结点(对应编号1-3),第三层有 4 个结点(对应编号4-7);对于一棵四层的满二叉树来说,前三层一共有 7 个结点(对应编号1-7),第四层一共有 8 个结点(对应编号8-15);以此类推,是满足标题的条件的。
Ⅳ 如果某个结点只有一个孩子,那一定是左孩子
③ 二叉排序树
对于一棵二叉排序树来说,每个结点的左子树均小于(或大于)根结点,右子树均大于(或小于)根结点,并且递归来看,左子树的左子树和右子树、右子树的左子树和右子树都要满足这个条件。
图自 王道课件
④ 平衡二叉树
树越宽(胖)越平衡,越窄(瘦)越不平衡。这就要求根结点的左右子树深度之差不超过 1。
⑤ 哈夫曼树
见后文。
2.3 二叉树的常见性质
@20221102 思维导图中log2(n+1)应该是向上取整。
设度为 0、1、2 的结点个数分别为 n0、n1、n2。
首先二叉树只有这三种结点,即总结点数 n = n0+n1+n2;
其次二叉树的结点数等于总度数+1,即 n = n1+2n2+1;
联立即可得到 n0 = n2+1。
而在完全二叉树中,度为 1 的结点最多只有一个,结合上述条件即可推出:
当完全二叉树有 2k 个结点,则 n0 = k,n1=1,n2 = k-1;
当完全二叉树有 2k-1 个结点,则 n0=k,n1=0,n2 = k-1。
其他的几个,计算方法和普通数都相同,把 m 叉树换成 2 叉树代公式即可。
2.4 二叉树的存储方式
①顺序存储
前面我们知道,一棵完全二叉树是有编号的,也就是说可以用一个结构体数组来存储一棵完全二叉树。结构体包含数据元素和编号两个域,这样就可以通过下标唯一访问一个结点。并且因为完全二叉树中父结点和子结点是有数值上的关系的,再看一下:
不难发现,
一个结点的编号除以二,再向下取整,就得到了它的父结点的编号;
一个结点的编号乘以二,就得到了它的左孩子的编号;
一个结点的编号乘以二,再加一,就得到了它的右孩子的编号。
这样,一棵完全二叉树就用连续的存储单元存储了起来,并且具有随机存取特性(已知编号访问元素)。
但是对于一棵普通的二叉树来说,它的编号和完全二叉树不同,起不到这样的作用,比如:
不难发现,刚才提到的通过编号计算父子结点的方法失效了,那么处理办法可以为,将每个结点按照一棵完全二叉树来编号,例如上面这棵树可以这样编号:
注意到 5 那个位置是空结点,对应数组内的元素也为空,这样虽然实现了随机存取特性,但是如果树的结点数非常多,就会有非常多的空位,造成存储空间的浪费(毕竟是连续的空间)。
②链式存储
既然顺序存储用途有限,那就只能用链式存储了。
我们关注到,对于一棵二叉树来说,每个结点最多只有它的左孩子和右孩子,并且还有一个父结点(根结点除外),那么就有两种具体实现方式:
Ⅰ 二叉链表
即结构体内有一个数据域,还有两个指针域,分别指向其左子树的根结点和右子树的根结点;
Ⅱ 三叉链表
在上面的基础上,再增加一个指向父结点的指针域。
各自的优缺点也很明显,如果需要较多地用到找父结点的操作,可以考虑使用三叉链表。
(后文仅针对二叉链表进行描述)
typedef struct BiTNode{
Elemtype data;
struct BiTNode *lchild, *rchild;
//int ltag, rtag;
}BiTNode, *BiTree;
注释掉的那一行将在后文的线索二叉树中发挥作用,暂时不用管。
另外一提,我们会发现对于一棵链表实现的树来说,无论进行什么操作,都必须定位到某一个结点,就和单链表中的增删改查插入一样,都要从头开始,一直向后找,直到满足条件位置。这个过程其实就是遍历,几乎所有操作的实现都离不开遍历。所以,下文将先从遍历开始。
三、二叉树的遍历
3.1 遍历的定义(前序、中序、后序、层序)
遍历就是依照某种次序,依次对结点进行访问(自定义操作)的过程。至于具体的几种遍历方式,放在下面和方法一块说。
3.2 遍历的方法
3.2.1 前序遍历
前序遍历,也叫前根遍历,本质上是一个递归的过程。
我们前面说过,一棵二叉树也是递归定义的,即从根结点开始,左孩子是它左子树的根结点,右孩子是它右子树的根结点,这两棵子树分别又是一棵树……
所以顾名思义,前序遍历,就是先访问根结点,然后对左子树做前序遍历,最后对右子树做前序遍历。
比如这样一棵树:
如果递归来看,我们是这样处理的:
A 有左子树 B,无右子树,所以写遍历序列 { A (B) }(括号括起来说明这个结点还有孩子,还要进一步处理);
B 有左子树 C,有右子树 D,所以把 B 展开,写遍历序列 { A B C (D) };
D 有左子树 E,有右子树 G,所以把 D 展开,写遍历序列 { A B C D (E) F };
E 无左子树,有右子树 F,所以把 E 展开,写遍历序列 { A B C D E G F }
再举个例子:
A 有左子树 B,有右子树 C,写遍历序列 { A (B) (C) }
B 有左子树 D,有右子树 E,所以把 B 展开,写遍历序列 { A B D E (C) };
C 有左子树 F,有右子树 G,所以把 C 展开,写遍历序列 { A B D E C F G };
另外一种“走路法”:从根结点出发,能往左走就往左走,不能往左走就往右走,路都走完了就回头,第一次经过某个结点时就访问。
比如:
- 第一次经过 A 结点,访问,往左走;{ A }
- 第一次经过 B 结点,访问,往左走;{ A B }
- 第一次经过 C 结点,访问,往左走;{ A B C }
- 左边空,回头;
- 第二次经过 C 结点,无操作,往右走;
- 右边空,回头;
- 第三次经过 C 结点,无操作,往上走;
- 第二次经过 B 结点,无操作,往右走;
- 第一次经过 D 结点,访问,往左走;{ A B C D }
- 第一次经过 E 结点,访问,往左走;{ A B C D E }
- 左边空,回头;
- 第二次经过 E 结点,无操作,往右走;
- 第一次经过 G 结点,访问,往左走;{ A B C D E G }
- 左边空,回头;
- 第二次经过 G 结点,无操作,往右走;
- 右边空,回头;
- 第三次经过 G 结点,无操作,往上走;
- 第三次经过 E 结点,无操作,往上走;
- 第二次经过 D 结点,无操作,往右走;
- 第一次经过 F 结点,访问,往左走;{ A B C D E G F }
- 左边空,回头;
- 第二次经过 F 结点,无操作,往右走;
- 右边空,回头;
- 第三次经过 F 结点,无操作,往上走;
- 第三次经过 D 结点,无操作,往上走;
- 第三次经过 B 结点,无操作,往上走;
- 第二次经过 A 结点,无操作,往右走;
- 右边空,回头;
- 第三次经过 A 结点,无操作,向上走;
- 结束。
自己画一画,其实就很明白了。
至于代码实现,其实就更简单了。
void PreOrderTraverse(BiTree T){
if(T){
visit(T);
PreOrderTraverse(T->lchild);
PreOrderTraverse(T->rchild);
}
}
我的 visit 里面只写了一个 printf。重点是思路,if 判断非空,if 里面先访问根结点,然后对左子树做先序遍历,等左子树递归完了,再对右子树做先序遍历。
(后面中序和后序我尽量复制粘贴,方便各位对比)
3.2.2 中序遍历
中序遍历,也叫中根遍历,本质上是一个递归的过程。
我们前面说过,一棵二叉树也是递归定义的,即从根结点开始,左孩子是它左子树的根结点,右孩子是它右子树的根结点,这两棵子树分别又是一棵树……
所以顾名思义,中序遍历,就是先对左子树做中序遍历,然后访问根节点,最后对右子树做中序遍历。
比如这样一棵树:
如果递归来看,我们是这样处理的:
A 有左子树 B,无右子树,所以写遍历序列 { (B) A }(括号括起来说明这个结点还有孩子,还要进一步处理);
B 有左子树 C,有右子树 D,所以把 B 展开,写遍历序列 { C B (D) A };
D 有左子树 E,有右子树 G,所以把 D 展开,写遍历序列 { C B (E) D F A };
E 无左子树,有右子树 F,所以把 E 展开,写遍历序列 { C B E G D F A }
再举个例子:
A 有左子树 B,有右子树 C,写遍历序列 { (B) A (C) }
B 有左子树 D,有右子树 E,所以把 B 展开,写遍历序列 { D B E A (C) };
C 有左子树 F,有右子树 G,所以把 C 展开,写遍历序列 { D B E A F C G };
另外一种“走路法”:从根结点出发,能往左走就往左走,不能往左走就往右走,路都走完了就回头,第二次经过某个结点时就访问。
比如:
- 第一次经过 A 结点,无操作,往左走;
- 第一次经过 B 结点,无操作,往左走;
- 第一次经过 C 结点,无操作,往左走;
- 左边空,回头;
- 第二次经过 C 结点,访问,往右走;{ C }
- 右边空,回头;
- 第三次经过 C 结点,无操作,往上走;
- 第二次经过 B 结点,访问,往右走;{ C B }
- 第一次经过 D 结点,无操作,往左走;
- 第一次经过 E 结点,无操作,往左走;
- 左边空,回头;
- 第二次经过 E 结点,访问,往右走;{ C B E }
- 第一次经过 G 结点,无操作,往左走;
- 左边空,回头;
- 第二次经过 G 结点,访问,往右走;{ C B E G }
- 右边空,回头;
- 第三次经过 G 结点,无操作,往上走;
- 第三次经过 E 结点,无操作,往上走;
- 第二次经过 D 结点,访问,往右走;{ C B E G D }
- 第一次经过 F 结点,无操作,往左走;
- 左边空,回头;
- 第二次经过 F 结点,访问,往右走;{ C B E G D F }
- 右边空,回头;
- 第三次经过 F 结点,无操作,往上走;
- 第三次经过 D 结点,无操作,往上走;
- 第三次经过 B 结点,无操作,往上走;
- 第二次经过 A 结点,访问,往右走;{ C B E G D F A }
- 右边空,回头;
- 第三次经过 A 结点,无操作,向上走;
- 结束。
自己画一画,其实就很明白了。
至于代码实现,其实就更简单了。
void InOrderTraverse(BiTree T){
if(T){
InOrderTraverse(T->lchild);
visit(T);
InOrderTraverse(T->rchild);
}
}
我的 visit 里面只写了一个 printf。重点是思路,if 判断非空,if 里面先对左子树做中序遍历,然后等左子树递归完了,然后访问根结点,再对右子树做中序遍历。
(后面后序我尽量复制粘贴,方便各位对比)
3.2.3 后序遍历
后序遍历,也叫后根遍历,本质上是一个递归的过程。
我们前面说过,一棵二叉树也是递归定义的,即从根结点开始,左孩子是它左子树的根结点,右孩子是它右子树的根结点,这两棵子树分别又是一棵树……
所以顾名思义,后序遍历,就是先对左子树做后序遍历,然后对右子树做后序遍历,最后访问根结点。
比如这样一棵树:
如果递归来看,我们是这样处理的:
A 有左子树 B,无右子树,所以写遍历序列 { (B) A }(括号括起来说明这个结点还有孩子,还要进一步处理);
B 有左子树 C,有右子树 D,所以把 B 展开,写遍历序列 { C (D) B A };
D 有左子树 E,有右子树 F,所以把 D 展开,写遍历序列 { C (E) F D B A };
E 无左子树,有右子树 G,所以把 E 展开,写遍历序列 { C G E F D B A }
再举个例子:
A 有左子树 B,有右子树 C,写遍历序列 { (B) A (C) }
B 有左子树 D,有右子树 E,所以把 B 展开,写遍历序列 { D B E A (C) };
C 有左子树 F,有右子树 G,所以把 C 展开,写遍历序列 { D B E A F C G };
另外一种“走路法”:从根结点出发,能往左走就往左走,不能往左走就往右走,路都走完了就回头,第三次经过某个结点时就访问。
比如:
- 第一次经过 A 结点,无操作,往左走;
- 第一次经过 B 结点,无操作,往左走;
- 第一次经过 C 结点,无操作,往左走;
- 左边空,回头;
- 第二次经过 C 结点,无操作,往右走;
- 右边空,回头;
- 第三次经过 C 结点,访问,往上走;{ C }
- 第二次经过 B 结点,无操作,往右走;
- 第一次经过 D 结点,无操作,往左走;
- 第一次经过 E 结点,无操作,往左走;
- 左边空,回头;
- 第二次经过 E 结点,无操作,往右走;
- 第一次经过 G 结点,无操作,往左走;
- 左边空,回头;
- 第二次经过 G 结点,无操作,往右走;
- 右边空,回头;
- 第三次经过 G 结点,访问,往上走;{ C G }
- 第三次经过 E 结点,访问,往上走;{ C G E }
- 第二次经过 D 结点,无操作,往右走;
- 第一次经过 F 结点,无操作,往左走;
- 左边空,回头;
- 第二次经过 F 结点,无操作,往右走;
- 右边空,回头;
- 第三次经过 F 结点,访问,往上走;{ C G E F }
- 第三次经过 D 结点,访问,往上走;{ C G E F D }
- 第三次经过 B 结点,访问,往上走;{ C G E F D B }
- 第二次经过 A 结点,无操作,往右走;
- 右边空,回头;
- 第三次经过 A 结点,访问,向上走;{ C G E F D B A }
- 结束。
自己画一画,其实就很明白了。
至于代码实现,其实就更简单了。
void PostOrderTraverse(BiTree T){
if(T){
PostOrderTraverse(T->lchild);
PostOrderTraverse(T->rchild);
visit(T);
}
}
我的 visit 里面只写了一个 printf。重点是思路,if 判断非空,if 里面先对左子树做后序遍历,然后等左子树递归完了,对右子树做后序遍历,等右子树递归完了,然后访问根结点。
3.2.4 层序遍历
层序遍历就是一层一层遍历,比如上面两个例子的层序遍历序列都为 { A B C D E F G }。
这部分就不细说了,知道实现方法就好:
借助队列,先把根结点入队,队列非空则出队一个结点,同时该结点的孩子结点入队。
//核心代码
void LevelOrderTraverse(BiTree T){
Queue Q;
InitQueue(Q);
EnQueue(Q, T);
while(Q.rear!=Q.front){
BiTNode *p = DeQueue(Q);
visit(p);
if(p->lchild) EnQueue(Q, p->lchild);
if(p->rchild) EnQueue(Q, p->rchild);
}
}
//辅助队列的操作
typedef struct Queue{
BiTNode *node[MAX_TREE_SIZE];
int front, rear;
}Queue;
bool InitQueue(Queue &Q){
Q.front = Q.rear = 0;
return true;
}
bool EnQueue(Queue &Q, BiTNode *n){
Q.node[Q.rear] = (BiTNode *)malloc(sizeof(BiTNode));
Q.node[Q.rear]->data = n->data;
Q.node[Q.rear]->lchild = n->lchild;
Q.node[Q.rear]->rchild = n->rchild;
Q.rear++;
}
BiTNode * DeQueue(Queue &Q){
return Q.node[Q.front++];
}
这部分都没有错误处理啊,比如内存分配、非空什么的,详细的处理请看队列那篇。
3.3 遍历的应用
3.3.1 算法分析树
对于这样一个表达式:
a + b * ( c - d ) - e / f
它的前中后缀表达式分别为:
- 前缀:- + a * b - c d / e f
- 中缀:a + b * ( c - d ) - e / f
- 后缀:a b c d - * + e f / -
至于怎么转换,请参考栈那篇:
把中缀表达式的每个字符(除括号)当成一个结点,按照运算的先后顺序,将结点组合排列起来。
得到如下这棵树:
我们会发现,对这棵树进行前中后序遍历,得到的序列分别就是前中后缀表达式的序列,可以自己尝试一下。
至于怎么构造的,在 a + b * ( c - d ) - e / f 这个表达式中,先算 c - d,减号当根结点,c 为左孩子,d 为右孩子;然后算 b * 刚才那个结果,乘号当根结点,b 为左孩子,刚才那棵树的根结点(减号)是右孩子;然后算 a + 刚才那个结果,加号当根结点,a 为左孩子,刚才那棵树的根结点(乘号)当右孩子,现在有了一棵树,这棵树是最后一个操作减法的左孩子,右孩子就是在减号之前算的 e / f 所构造的树。
3.3.2 求树的深度
还是递归。
要求一棵树的深度,只需要求它左子树或者右子树深度的最大值,然后加上根结点所在的那一层。然后再递归进去算左子树的左子树和右子树的深度加一……代码一看便知。
int BiTreeDepth(BiTree T){
if(!T) return 0;
else{
int l = BiTreeDepth(T->lchild);
int r = BiTreeDepth(T->rchild);
return l>r? l+1: r+1;
}
}
3.3.3 已知遍历序列逆推树
①前序+中序
- 前序:ABDECFG
- 中序:DBEAFCG
首先,前序遍历序列的第一个结点肯定是根结点,那么对应着中序遍历序列找到这个结点,左边的那部分就是根结点左子树的中序遍历,右边的那部分就是根结点右子树的中缀序列。然后再对着左边那部分,看在前序遍历序列里第一次出现的是谁;右边也是一样,我们拆开来看:
左边,能看出来中序的 D B E 在前序里第一个出现的是 B,拿 B 按照上面的步骤来拆中序:
- 前序:ABDECFG
- 中序:DBE
再往下拆就没了,至此,我们构造了一棵树:根节点为 A;A 左孩子为 B,右孩子还不知道;B 左孩子为 D,右孩子为 E。
然后回到第一步拆的右半部分,能看出来中序 F C G 在前序里第一个出现的是 C,拿 C 按照前面的步骤来拆中序:
- 前序:ABDECFG
- 中序:FCG
如此一来 A 的右孩子为 C;C 的左孩子为 F,右孩子为 G。构造结束。
其实就是它。
②后序+中序
- 后序:DEBFGCA
- 中序:DBEAFCG
后序序列的最后一个结点肯定是根结点,所以还是拆。左边为左子树,右边为右子树。和刚才找第一个不同,这次要找最后一个。看左边,D B E 最后一个出现的是 B,用 B 再拆:
- 后序:DEBFGCA
- 中序:DBE
剩下的过程就很类似了,结果还是上面那棵树。
③层序+中序
- 层序:ABCDEFG
- 中序:DBEAFCG
层序的出现顺序为:根结点,左子树的根结点,右子树的根结点,左子树的左子树的根结点,左子树的右子树的根结点,右子树的左子树的根结点,右子树的右子树的根结点……就用这个特性,把中序拆得不能再拆,但是还是需要通过中序来确定左子树和右子树到底是谁,毕竟有可能不存在。这里就不细说了,比较简单。最后的结果还是上面那棵树。
四、线索二叉树
通过上面对遍历的介绍,我们已经可以获得某种顺序遍历的序列了,但是如果给定一个结点,问它在遍历序列的前驱和后继是谁,就需要从头开始遍历,时间开销比较大。
我们注意到,对于一个有 n 个结点的二叉链表,它一共有 2n 个链域(左孩子和右孩子的指针),但是除了根结点外,指向剩下的 n-1 个结点只需要 n-1 个链域,这就意味着还空闲了 n+1 个链域出来。那么就可以利用这 n+1 个空链域,来存储指向结点遍历序列中的前驱或者后继,这种指向序列中前驱或后继的指针,就被称作线索。
注:在后面提到的前驱和后继,如非特别说明,均指结点在某种顺序遍历序列中的前驱和后继。
4.1 线索二叉树的定义
那么线索二叉树,就是将所有的空链域都当成线索的二叉树。对于这样一棵树来说,找前驱和后继十分方便。
4.2 二叉树线索化
顺序遍历有三种(除了层序),对应的线索二叉树也有三种。
其实说白了,怎么把一棵普通的二叉树变成线索二叉树呢?
通过定义,我们知道线索化是针对空链域来说的,既然有空链域,就说明其左孩子和右孩子至少有一个为空。但是常规的二叉树有 lchild 和 rchild 两个指针(分别指向左孩子和右孩子),线索化也用的这两个指针,为了区分这两个指针是线索还是指向其孩子,所以在结构体里加上标志位 ltag 和 rtag。
typedef struct BiTNode{
Elemtype data;
struct BiTNode *lchild, *rchild;
int ltag, rtag;
}BiTNode, *BiTree;
ltag==0,代表 lchild 不是线索,即 lchild 指向其左孩子;
ltag==1,代表 lchild 是线索,即 lchild 指向其前驱;
rtag==0,代表 rchild 不是线索,即 rchild 指向其右孩子;
rtag==1,代表 rchild 是线索,即 rchild 指向其后继。
于是,就需要对所有结点都进行判断,如果它没有左孩子,那么将 lchild 指向前驱同时 ltag 置为 1;如果它没有右孩子,那么将 rchild 指向后继同时 rtag 置为 1。
所以在创建二叉树的时候,将所有结点的 ltag 和 rtag 都置为 0,然后对所有结点都访问一遍。
访问,即遍历,前序遍历的同时线索化就叫前序线索化,得到的树叫前序线索二叉树;中序遍历的同时线索化就叫中序线索化,得到的树叫中序线索二叉树;后序遍历的同时线索化就叫后序线索化,得到的树叫后续线索二叉树。
①中序线索二叉树和中序线索化
Ⅰ 中序线索化
void InThread(BiTNode *p, BiTNode *&pre);
void InOrderThreading(BiTree &T){
BiTNode *pre = NULL;
if(T){
InThread(T, pre);
if(pre->rchild==NULL) pre->rtag = 1;
}
}
void InThread(BiTNode *p, BiTNode *&pre){
if(p){
if(!p->ltag) InThread(p->lchild, pre);
if(!p->lchild){
p->lchild = pre;
p->ltag = 1;
}
if(pre && pre->rchild==NULL){
pre->rchild = p;
pre->rtag = 1;
}
pre = p;
if(!p->rtag) InThread(p->rchild, pre);
}
}
因为存在将结点指向其前驱的情况,所以在正儿八经线索化之前需要先创建一个变量 pre,用于始终指向当前结点的前驱。 这也是实参为什么是 *&pre 而不是 *pre 了。pre 的本质是一个地址,即一个 int 型变量。传 *pre 其实传进去的是地址,在递归的过程中,每次进递归局部函数都会创建一个 *pre 地址的拷贝变量,即使修改了 pre,在退出递归的时候,pre 也会恢复到进入递归前的那个值。所以要加引用,&pre 是 pre 地址的引用,对引用进行修改,会直接影响到原本的pre,不论在递归哪一层,都会作用到其他层。至于为什么还有个 *,我姑且理解为代表指针吧。这里也算是我不太理解,望大佬指教。
上面的代码光看不太好看,我们稍微进行一些处理:
void InThread(BiTNode *p, BiTNode *&pre){
if(p){
if(!p->ltag) InThread(p->lchild, pre);
operation();
if(!p->rtag) InThread(p->rchild, pre);
}
}
把第一段代码的两个 if 收起来,变成一个 operation(),即要进行的操作。然后这段代码就完全变成了中序遍历的代码,无非就是多了个 pre。
if(!p->lchild){
p->lchild = pre;
p->ltag = 1;
}
if(pre && pre->rchild==NULL){
pre->rchild = p;
pre->rtag = 1;
}
pre = p;
而两个 if,就是前面提到的。
如果当前结点没有左孩子,就把 lchild 当线索指向前驱,同时 ltag 置为 1。
如果前驱没有右孩子,就把前驱的 rchild 当线索指向当前结点(pre 的后继),同时 rtag 置为 1。
但是记得处理最后一个结点,最后一个结点是没有后继的,它的后继为 NULL,需要手动将 rtag 置为 1,也就是下面代码注释下面的那行。
void InOrderThreading(BiTree &T){
BiTNode *pre = NULL;
if(T){
InThread(T, pre);
//看这里
if(pre->rchild==NULL) pre->rtag = 1;
}
}
Ⅱ 中序前驱和后继
BiTNode * FindInNext(BiTNode *p){
if(p->rtag) return p->rchild;
else{
p = p->rchild;
while(!p->ltag) p = p->lchild;
return p;
}
}
BiTNode * FindInPrior(BiTNode *p){
if(p->ltag) return p->lchild;
else{
p = p->lchild;
while(!p->rtag) p = p->rchild;
return p;
}
}
有了前面中序遍历的知识,找后继应该也好理解了。
如果右子树为空,那么 rchild 是指向后继的线索。
如果右子树不为空:
中序遍历是按照 左子树 -> 根结点 -> 右子树 的顺序进行的,所以后继一定是其右子树中第一个遍历的。
对于右子树的中序遍历,同样是按照 左子树 -> 根结点 -> 右子树 的顺序进行的,所以第一个遍历的结点一定在右子树的左边。此时分为两种情况,当右子树的左子树为空,右子树的根结点本身就是第一个遍历的;当右子树的左子树不为空,则第一个遍历的在右子树的最左下角。
这里说的“第一个遍历的”,是指在右子树中第一个遍历的,也就是指定结点的后继。
找前驱也是同理,
如果左子树为空,那么 lchild 是指向前驱的线索。
如果左子树不为空:
中序遍历是按照 左子树 -> 根结点 -> 右子树 的顺序进行的,所以前驱一定是其左子树中最后一个遍历的。
对于左子树的中序遍历,同样是按照 左子树 -> 根结点 -> 右子树 的顺序进行的,所以最后一个遍历的结点一定在左子树的右边。此时分为两种情况,当左子树的右子树为空,左子树的根结点本身就是最后一个遍历的;当左子树的右子树不为空,则最后一个遍历的结点在左子树的右下角。
掌握了找前驱和后继的方法,就可以从任一结点开始向前或者向后中序遍历这棵树了。
下面是遍历中序线索二叉树的代码:
void InThreadTraverse(BiTree T,int REVERSE=0){
if(REVERSE==0){
BiTNode *p = T;
while(p->ltag==0) p=p->lchild;
for(; p; p=FindInNext(p))
visit(p);
}
else if(REVERSE==1){
BiTNode *p = T;
while(p->rtag==0) p=p->rchild;
for(; p; p=FindInPrior(p))
visit(p);
}
}
REVERSE 是指示前向还是后向遍历,如果是后向,则从根结点开始依次输出后继即可;如果是前向,则需要先定位到中序遍历的最后一个结点(最右下角的那个),再从这个结点开始依次输出前驱。
②前序线索二叉树
Ⅰ 前序线索化
和中序原理差不太多的,就是遍历的时候先处理根结点,然后再对左子树和右子树分别线索化。
void PreThread(BiTNode *p, BiTNode *&pre);
void PreOrderThreading(BiTree &T){
BiTNode *pre = NULL;
if(T){
PreThread(T, pre);
if(pre->rchild==NULL) pre->rtag = 1;
}
}
void PreThread(BiTNode *p, BiTNode *&pre){
if(p){
if(!p->lchild){
p->lchild = pre;
p->ltag = 1;
}
if(pre && pre->rchild==NULL){
pre->rchild = p;
pre->rtag = 1;
}
pre = p;
if(!p->ltag) PreThread(p->lchild, pre);
if(!p->rtag) PreThread(p->rchild, pre);
}
}
有了中序的介绍,这里就不细说了。
Ⅱ 前序前驱和后继
BiTNode * FindPreNext(BiTNode *p){
if(p->rtag) return p->rchild;
else{
if(!p->ltag) return p->lchild;
else return p->rchild;
}
}
BiTNode * FindPrePrior(BiTNode *p){
}
找后继:
如果右子树为空,那么 rchild 是指向后继的线索。
如果右子树不为空:
前序遍历是按照 根结点 -> 左子树 -> 右子树 的顺序进行的,所以后继一定是其左子树中第一个遍历的(如果左子树不存在,那后继就是右子树中第一个遍历的)。
对于左子树的前序遍历,同样是按照 根结点 -> 左子树 -> 右子树 的顺序进行的,所以第一个遍历的结点有两种情况:当左子树的根结点不为空,左子树的根节点本身就是第一个遍历的;当左子树的根结点为空,那第一个遍历的就变成了右子树的根结点。
至于前驱,前序遍历是不保存前驱的信息的,因为从根结点开始遍历,后面的都是它的后继。所以如果想要找前驱,则需要从头开始,或者使用三叉链表(其前驱就是父结点,三叉链表的 parent 指针就指向前驱)。
下面是遍历前序线索二叉树的代码:
void PreThreadTraverse(BiTree T){
for(BiTNode *p=T; p; p=FindPreNext(p))
visit(p);
}
③后序线索二叉树和后序线索化
Ⅰ 后序线索化
和中序差不多的,就是遍历的时候先对左子树和右子树线索化,最后处理根结点。
void PostThread(BiTNode *p, BiTNode *&pre);
void PostOrderThreading(BiTree &T){
BiTNode *pre = NULL;
if(T){
PostThread(T, pre);
if(pre->rchild==NULL) pre->rtag = 1;
}
}
void PostThread(BiTNode *p, BiTNode *&pre){
if(p){
if(!p->ltag) PostThread(p->lchild, pre);
if(!p->rtag) PostThread(p->rchild, pre);
if(!p->lchild){
p->lchild = pre;
p->ltag = 1;
}
if(pre && pre->rchild==NULL){
pre->rchild = p;
pre->rtag = 1;
}
pre = p;
}
}
Ⅱ 后序前驱和后继
BiTNode * FindPostNext(BiTNode *p){
}
BiTNode * FindPostPrior(BiTNode *p){
if(p->ltag) return p->lchild;
else{
if(p->rchild) return p->rchild;
else return p->lchild;
}
}
后序遍历的顺序是 左子树 -> 右子树 -> 根结点,所以后序遍历序列中只保存了某个结点前驱的信息,而无法寻找后继。
如果左子树为空,那么 lchild 是指向前驱的线索。
如果左子树不为空:
后序遍历是按照 左子树 -> 右子树 -> 根结点 的顺序进行的,所以前驱一定是其右子树中最后一个遍历的(如果右子树不存在,那后继就是左子树中最后一个遍历的)。
对于右子树的后序遍历,同样是按照 左子树 -> 右子树 -> 根结点 的顺序进行的,所以最后一个遍历的结点有两种情况:当右子树的根结点不为空,右子树的根节点本身就是最后一个遍历的;当右子树的根结点为空,那最后一个遍历的就变成了左子树的根结点。
下面是后续线索二叉树的遍历代码:
void PostThreadTraverse(BiTree T){
for(BiTNode *p=T; p; p=FindPostPrior(p))
visit(p);
}
五、最优二叉树(哈夫曼树)
5.1 哈夫曼树的定义
首先介绍权值的定义,权值就是人为地给树中每个结点以不同的重要性,往往可以用 int 型变量表示。
结点的带权路径长度,指的是从根结点开始按最短路径寻找到这个结点所经过的分支数量乘以这个结点的权值;
树的带权路径长度(WPL),指的是树中所有叶子结点的带权路径长度之和。
而哈夫曼树,就是通过这些结点所构造的若干棵树中 WPL 最小的树。
给定若干结点,组合成一棵哈夫曼树,结果不唯一,但 WPL 值相同。
还有一些其他的特性,可以参考思维导图。
5.2 哈夫曼树的应用
哈夫曼树最常见的应用就是哈夫曼编码了。
比如我们要对 A B C D 这四个字母进行编码,按照固定长度的编码方式,可以用 2 位二进制表示四个编号,即 A 00,B 01,C 10,D 11。
假如现在要发个电报,是某场考试客观题的答案,其中用到 100 次 A,50 次 B,20 次 C,10 次 D。此时一共要发 270 个 0 和 90 个 1。
那哈夫曼编码是怎么做的呢?
5.3 哈夫曼树的构造
哈夫曼树的构造方法是,每次取两个权值最小的树,连到一个新结点上,组成一棵新树,新树的权值为两棵子树的权值之和。
我们用上面的例子来说。
现在要发个电报,是某场考试客观题的答案,其中用到 100 次 A,50 次 B,20 次 C,10 次 D。
不妨设 A 的权值为 100,B 50,C 20,D 10。
先取权值最小的两棵树 C 和 D,拼成一棵新树假如叫 E 吧,E 的权值为 C D 权值之和 30。
再取权值最小的两棵树 B 和 E,拼成一棵新树假如叫 F 吧,F 的权值为 B E 权值之和 80。
再取权值最小的两棵树 A 和 F,拼成一棵新树假如叫 G 吧,G 的权值为 A F 权值之和 180。
(鼠标画的比较丑,凑合一下)
可以发现,最初的 A B C D 最后都变成了叶子结点。
记得哈夫曼树是无序树吗?所以 A B C D 这四个叶子结点的左右顺序是无所谓的。
现在可以对这四个字母进行编码了,假如左分支代表 0,右分支代表 1。
得到的编码为:A 0,B 10,C 110,D 111。
此时要发 170 个 0,120 个 1。要发的总位数从 360 变成了 290,并且 270 个 0 变成了 170 个 0。哈夫曼编码的思想是,如果一个字符用的频次多,那么就让这个字符结点的加权路径长度最短,并且通过适当增加频次少的结点的加权路径长度来达到最优的 WPL。
可能不是很形象的例子,那假如把上面的例子乘以一百倍。
假如按键有寿命,
按照原本的编码,是 27000 个 0,9000 个 1,换一个 1 的按键就得换 3 个 0。
按照哈夫曼编码,是 17000 个 0,12000 个 1,换一个 1 的按键只需换 1.4 个 0。
这里还有一点,哈夫曼编码是一种前缀编码,任何一个编码都不是另一个编码的前缀,这样在解读的时候是有唯一性的。
比如:A 1,B 111,C 0,D 110,这就是一种非前缀编码。
发了 11110,那是 AAAAC 呢还是 BD 呢。
六、普通的树
普通的树说的就是没有分支数和度要求的树,只要在树基本定义的前提下,想怎么长就怎么长。
6.1 树的存储方式
6.1.1 双亲表示法
双亲表示法其实很好理解,举个例子:
图自 《数据结构(C语言版)》
创建了一个结构体数组,每个结点包含数据内容和下标两个域。
R 是根结点,其“指针”为 -1。
A 是 R 的孩子结点,其指针为 0,指向其父结点的数组下标。
以此类推。
比如我们想从 G 结点开始向上遍历:
G 的指针指向 6,代表其父结点在数组下标为 6 的位置,访问到 F。
F 的指针指向 3,代表其父结点在数组下标为 3 的位置,访问到 C。
C 的指针指向 0,代表其父结点在数组下标为 0 的位置,访问到 R。
R 的指针指向 -1,遍历结束。
也不难发现,这种找前驱的操作是很容易进行的,但是找后继就比较麻烦了,需要从根结点开始,比较其数组下标和每个指针的值是否相同。
6.1.2 孩子表示法
这部分光说还是比较抽象的,给出课本的结构体写法:
typedef struct CTNode{ //孩子结点
int child;
struct CTNode *next;
}*ChildPtr;
typedef struct{
TElemType data; //孩子链表头指针
ChildPtr firstChild;
}CTBox;
typedef struct{
CTBox nodes[MAX_TREE_SIZE];
int n, r; //结点数和根的位置
}CTree;
最下面那个结构体就是树了,树中包含一个 CTBox 类型的数组,而 CTBOX 类型,又包含一个数据和 ChildPtr 类型的指针,这个指针,其实就是链表的头指针。
图自 《数据结构(C语言版)》
上图就是孩子表示法呈现出的逻辑结构,A 结点包含一个链表,链表中的各个结点代表它的孩子,第一个孩子在数组下标 3 的位置,为 D;第二个孩子在数组下标 5 的位置,为 E;没有第三个孩子。这样一来已知结点找后继就很方便实现,找前驱变得不方便,但是可以增设一个 parent “指针”,如下图:
其实就是把两种方法结合起来了。
6.1.3 孩子兄弟表示法
通过概念就很好理解了,和二叉链表类似,其也有两个指针域,但是一个指向它的第一个孩子,另一个指向它的下一个兄弟结点。
用前文提到的这棵树举个例子:
R 第一个孩子是 A,没有下一个兄弟。R->firstchild=A, R->nextsibling=NULL
A 第一个孩子是 D,下一个兄弟是 B。A->firstchild=D, A->nextsibling=B
B 没有第一个孩子,下一个兄弟是 C。B->firstchild=NULL, B->nextsibling=C
C 第一个孩子是 F,没有下一个兄弟。C->firstchild=F, C->nextsibling=NULL
D 没有第一个孩子,下一个兄弟是 E。D->firstchild=NULL, D->nextsibling=E
E 没有第一个孩子,没有下一个兄弟。E->firstchild=NULL, E->nextsibling=NULL
F 第一个孩子是 G,没有下一个兄弟。F->firstchild=G, F->nextsibling=NULL
G 没有第一个孩子,下一个兄弟是 H。G->firstchild=NULL, G->nextsibling=H
H 没有第一个孩子,下一个兄弟是 K。H->firstchild=NULL, A->nextsibling=K
K 没有第一个孩子,没有下一个兄弟。K->firstchild=NULL, A->nextsibling=NULL
这就是这棵树最终的样子,变成了一棵二叉树。这也是为什么孩子兄弟表示法又被叫做二叉树表示法了。
6.2 普通树与二叉树的转化
普通树转二叉树的流程参考上文。
二叉树转成普通树,只要把某个结点的右孩子当成它的兄弟,逆向转化就可以了。
七、森林
7.1 森林的定义
森林,就是有很多棵树。但是这些树不能相交。
7.2 森林的存储方式
简而言之,就是用二叉链表来存储树。
把第一棵树的根结点当成新树的根结点,其他几棵树的根结点是它的兄弟。
然后用孩子兄弟表示法就可以了。
图自 《数据结构(C语言版)》
7.3 森林和二叉树的转化
应该不用解释了吧……
反过来,二叉树转换成森林的话,就是从根结点开始往右走,经过的每个结点都是一棵树的根结点。然后按照孩子兄弟表示法的逆处理单独转换成树即可。
7.4 森林和树的遍历
这部分其实比较简单了,对于普通树和森林,对其遍历有两种方法:
①直接法
直接按照遍历的方法定义,对树做遍历。例如先序遍历,从根节点开始,再到第一棵子树,第二棵子树...第 n 棵子树,以此类推。
而森林略微特殊一些,即把一个森林中的树从左到右挨个遍历,然后把这些遍历序列连在一起。
②走路法
从根节点出发、先往左走,左边没路往左边第二条路,左边第二条路往左边第三条路...都没路了回头,再走上一层的第二条路,以此类推。
森林也是一样,对每棵树做遍历然后加在一起。
值得注意的是,
普通树的先序遍历和转换成的二叉树的先序遍历序列相同;
普通树的后序遍历和转换成的二叉树的中序遍历序列相同;
森林的先序遍历和转换成的二叉树的先序遍历相同;
森林的中序遍历和转换成的二叉树的中序遍历相同。
==总结==