注:本篇内容参考了《Java常用算法手册》、《大话数据结构》和《算法导论(第三版)》三本书籍。
本人水平有限,文中如有错误或其它不妥之处,欢迎大家指正!
目录
1. 树的概述
树结构的数据在生活中应该见的比较多,像国家的行政机构,一个公司的组织机构等。它们有个共同点,就是都可以表示成一个层次关系,这种层次关系可以抽象为树结构。
1.1 树的定义
树(Tree)是 n ()个结点的有限集。当n = 0时称为空树。通俗一点讲,树是 n 个数据结构的集合,在该集合中包含一个根结点,根结点之下分布着一些互不交叉的子集合,这些子集合也就是树结点的子树。就是说每一个子集合本身又是一棵树,称为根(或原树)的子树(SubTree)。若树结构中仅包含一个结点,那这也是一个树,树根便是该结点自身。树的结点包含了一个数据元素及若干指向其子树的分支。
从下图可以看,树结构跟植物的树有所不一样,树结构是根在上,分支在下,是植物树倒过来了。结点B、C及其下面的分支是根结点A的子树。
由于树结构不是线性结构,很难用数学表达式来表示,一般来说,常采用层次括号法来表示。层次括号的基本规则如下:
- 根结点放入一对圆括号中;
- 根结点的子树由左向右顺序放入括号中;
- 对子树做上述相同的处理。
需要注意的是,树根结点之下分布着一些互不交叉的子集合。所以下图中的树是不符合树的定义的,因为它们交叉了。
1.2 树结构的特征
树结构有以下几个特征:
- 树结构是一种非线性结构;
- 在一个树结构中,有且仅有一个结点没有直接前驱,这个结点就是树的根结点,称为根结点或根(Root);
- 除根结点外,其余每个结点有且仅有一个直接前驱;
- 每个结点可以有任意多个直接后继。
在上图的右图中,可以看到越往下层根第分支越多。其中,A是树的根结点,根结点A有两个直接后继结点B、C,而结点E只有一个直接前驱结点C。
1.3 树的基本概念
如下图,下面定义中的举例使用此图。
1.3.1 度
这里的度分为树的度和结点的度。
结点的度:是指一个结点所包含子树的数量,在上图中结点A有两个子树,因此结点A的度为2,结点D的度为3。
树的度:也就是树的宽度,是树所有结点中最大的度。简单来说就是树结点的分支数。在上图中,此树中所有结点中最大度的为3,所以此树的度为3。
1.3.2 树的层次(Level)
简单来说,就是树的层级,从根结点开始算起。因为根结点的层次为1,所以树的层次自然也是从1开始算起。根结点的层次为1,依次向下为2、3、...、n。树是有种层次结构,每个结点都处在一定的层次上。树中结点的最大层次称为树的深度或高度。
1.3.3 树的深度(De)
树的深度是指树中结点的最大层次,如上图,此树的深度为4。有的地方也叫做高度。
1.3.4 路径
对于一棵树中的任意两个不同的结点,若从一个结点出发,按层次自上而下沿着一个个树枝能到达另一个结点,则称这两个结点之间一条路径。可以用路径所经过的结点序列表示路径,路径的长度等于路径上的结点个数减1。
1.3.5 森林(forest)
森林(forest)是指 n (n > 0) 棵互不相交的树的集合。
1.3.6 结点
父结点(Parent):每个结点的子树的根称为该结点的子结点,相应地,该结点称为父结点。上图中结点C是结点E和F的父结点 。
子结点(Child):每个结点的子树的根称为该结点的子结点。上图中结点E和F是结点C的子结点。
兄弟结点:具有同一个父结点的结点。上图中,结点B和C就是兄弟结点,它们的父结点是结点A。
堂兄弟结点:父结点在同一层的结点互为堂兄弟,上图中的D、E、F结点就是堂兄弟结点。
结点的祖先:从根到该节点所经的分支上的所有节点。上图中结点G的祖先A、B、D。
结点的子孙:以某结点为根的子树中任一结点都称为该节点的子孙。上图中结点C的子孙E和J。
叶结点(终端结点):树中度为零的结点称为叶结点或终端结点。上图中,结点G、H、I都是叶结点,因为它们没有子树,度为0。
分支结点:树中度不为零的结点称为分对结点或非终端结点。上图中结点B和C都是分支结点。
1.4 树的分类
1.4.1 根据树中子结点间的有没有顺序分类
根据树中任意节点的子结点之间有没有顺序,可以分为有序树和无序树。
有序树是指若树中各结点的子树(兄弟结点)是按一定次序从左向右安排的,称为有序树。即树中任意结点的子结点之间有顺序关系。有序树是编程领域的基础结构,大部分树的变形都是基于有序树演变而来。有序树又可细分为二叉树、霍夫曼树、B树等。
无序树是指树中任意节点的子节点之间没有顺序关系,也称为自由树。无序树在实际应用中意义不大。
1.5 树的存储方式
树结构有两种存储结构,分别是顺序存储方式和链表存储方式。
1.5.1 顺序存储
树的顺序存储是用数组来存储一棵二叉树,具体存储方法是将二叉树中的结点进行编号,然后按编号依次将结点值存入到一个数组中,这样就完成了一棵二叉树的顺序存储。这种存储结构比较适合存储完全二叉树,存储一般的二叉树会浪费大量的存储空间,因为完全二叉树基本不会浪费数组空间。一般的二叉树如果节点分布不均匀,那就会出现大量空间被浪费。
顺序存储结构有一定的局限性,它不便于存储任意形态的二叉树。
1.5.2 链表存储
上面说了顺序存储结构不便于存储任意形态的二叉树。观察二叉树的形态,可以发现一个根节点与两棵子树有关系,因此设计一个含有一个数据域和两个指针域的链式结点结构,data表示数据域,用于存储对应的数据元素;lchild和rchild分别表示左指针域与右指针域,它们分别用于存储左子结点和右子结点的位置。若没有右子结点,则右指针为空。
1.6 树的存储结构表示方法
上面说明树的两种存储方式,顺序存储结构是用一段连续的存储单元依次存储线性表的数据元素。这在存储线性表时是很自然的,但对于树这样一多对的结构呢?
树结构中某个结点的子结点可以有多个,这就意味着无论按何种顺序将树中所有结点存储到数组中,结点的存储位置都无法直接反映逻辑关系。因为不知道该结点的子结点和父结点信息。可以说简单的顺序存储结构不能满足树结构的实现要求。
但充分利用顺序存储和链式存储结点的特点,完全可以实现对树的存储结点的表示。下面介绍三种表示法:双亲表示法、孩子表示法和孩子兄弟表示法。
1.6.1 双亲表示法
假设以一组连续的空间来存储树的结点,同时在每个结点中,附设一个指针来指示其双亲结点到链表中的位置。每个结点除了自己知道自己是谁以外,还知道它的双亲在哪。如下图所示。
其中,data是数据域,用于存储结点的数据信息。而parent是指针域,用于存储该结点的双亲在数组中的下标。这样以来,可以根据结点的parent指针很容易找到它的双亲结点,时间复杂度为O(1)。但若要知道该结点的孩子时,需要遍历整个结构才行。如下图所示(parent为-1时表示该结点为根结点)。
要知道某个结点的孩子信息时很麻烦,所以需要对此结构进行改进,改进方法就是再增加一个指针域表示该结点的最左边孩子的域,不妨叫它长子域。这样就可以很容易得到结点的孩子信息。如下图所示(parent为-1时表示该结点为根结点,firstchild为-1时表示该结点没有子结点)。
对于有0或1个子结点来说,上来的改进之后已经可以解决查找子结点的问题了。那如果有多个孩子?或者说很关注兄弟结点之间的关系,需要知道兄弟结点的关系呢?也可以增加一个右兄弟域来体现。但子结点一多,只要超过2个,这样增加一个域的表示方法就很麻烦了。
1.6.2 孩子表示法
现在换一种完全不同的考虑方法。由于树中每个结点可能有很多棵子树,可以考虑用多重链表,即每个结点有多个指针域,其中每个指针指向一棵子树的根结点,把这种方法叫做多重链表示法。不过树的每个结点的度,也就是它的孩子的个数是不同的,所以可以设计两种方案来解决。
方案一
指针域的个数就等于树的度。如下图所示。其中data是数据域,child1到childd是指针域,用于指向该结点的子结点。
以上图的树结构的图来说,树的度是3,所以打针域的个数是3,实现如下图所示。可以发现这种方法对于树中各结点的度相差很大的情况,很浪费存储空间,很多指针域都是空的。但当树的各结点的度相差很小时,开辟的空间基本是被充分利用了。
方案二
对于方案一可能存储空间浪费的情况,有了方案二。这里是按需分配空间的。每个结点指针域的个数等于该结点的度,专门取一个位置来存储指针域的个数,其结构如下图。
其中data是数据域,degree为度的域,存储该结点的子结子的个数,child1到childd是指针域,用于指向该结点的子结点。
这种方案克服中浪费存储空间的缺点,提高了存储空间的利用率。但由于各结点的链表是不相同的结构,加上要维护结点的度的值,在运算上会带来时间的损耗。
从上面可以看出,把每个结点放到了一个顺序存储结构的数组中是合理的,效率很高,但每个结点的子结点的数量不确定的,所以再对每个结点的子结点建立一个单链表来体现这些子结点的关系。这就是孩子表示法,具体方法是把每个结点的子结点排列起来,然后以单链表作为存储结点,则 n 个结点有 n 个子链表,若是叶结点则此单链表为空。最后 n 个头指针又组成一个线性表,采用顺序存储结构,存放进一个一维数组中。如下图所示。
为此,设计两种结点结构,一个是子链表的子结点,如下图所示。其中child是数据域,用于存储某个结点在表头数组中的下标,next是指针域,用于存储指向某结点的下一个子结点的指针。
另一个是表头数组的表头结点,如下图所示。其中data是数据域,存储某个结点的数据信息,firstchild是头指针域,用于存储该结点的子链表的头指针。
这样的结构对于要查找某个结点的子结点,或者查找某个结点的兄弟结点,只需要查找该结点的子链表即可。对于遍历整棵树也是很方便的,对头结点的数组循环即可。但这也存在着问题,那就是如何知道某个结点的双亲是谁呢?比较麻烦,需要遍历整棵树才行。把双亲表示法和孩子表示法综合一下就好了。如下图所示。把这种方法称为双亲孩子表示法,可以说是对孩子表示法的改进。
1.6.3 孩子兄弟表示法
上面从双亲和孩子的角度去研究了树的存储结构,如果从树结点的兄弟角度去看,会是怎么样的呢?当然从整棵树的来说,只研究结点的兄弟是不行的。通过观察发现,任意一棵树,它的结点的第一个孩子如果存在就是唯一的,它的右兄弟如果存在也是唯一的。因此设置两个指针,分别指向该结点的第一个子结点和此结点的右兄弟结点。结构如下图。
其中,data是数据域,firstchild为指针域,用于存储该结点的第一个子结点的存储地址,rightsib是指针域,用于存储该结点的右兄弟结点的存储地址。
这种表示法,为查找某个结点的某个子结点带来了方便,只需要通过firstchild找到此结点的长子结点,然后再通过长子结点的rightsib找到它的二弟,接着一直这样下去,直到找到具体的孩子。但若想找到某个结点的双亲结点,这个表示法也是有缺陷的,所以可以增加一个parent指针域来解决快速双亲结点的问题。
此表示法的最大好处是它把一棵复杂的树变成了一棵二叉树。如下图。这样就可以充分利用二叉树的特性和算法来处理这棵树了。
1.7 与线性表的区别
线性表的第一个元素没有直接前驱,最后一个数据元素没有直接后继,中间的元素只有一个直接前驱和直接后继。树结点的根结点是唯一的,叶结点可以有多个,叶结点是没有子结点,中间的结点有一个父结点且有多个子结点。
2. 二叉树介绍
这一节主要介绍二叉树的定义、二叉树的分类及二叉树的存储方式等内容。
2.1 二叉树定义
二叉树(Binary Tree)是树 n ( n >= 0)个结点的有限集合,每个结点最多含有两个子树的树,或者说每个结点最多只能两个子结点。二叉树的一个结点上对应的两个子树分别称为左子树和右子树。一个二叉树结构也可以是空树,空的二叉树中没有数据结点,也就是一个空集合。下图中的左图就是二叉树,而左图就不是二叉树,因为结点D有三个子树。
根据二叉树子树的位置的个数,二叉树一共有五种形式,除了空二叉树和只有一个根结点的二叉树外,还有如下图中的三种形式。对于图(a),只有一个子结点且位于左子树位置,右子树为空。对于图(b),只有一个子结点且位于右子树位置,左子树为空。对于图(c),左子树和右子树都存在,具有完整的两个子结点。
二叉树有如下特点:
- 每个结点最多有两棵树,所以二叉树中不存在度大于2的结点。要注意这里是最多只有棵子树,可以没有或有一棵或有两棵子树;
- 左子树和右子树是有顺序的,次序不能任意颠倒。好比人的双手,左手和右手显然是不一样的;
- 即使树中某个结点只有一棵子树,也要区分它是左子树还是右子树
在普通的树结构中,结点的最大度数没有限制,而二叉树结点的最大度数为2。另外,树结构中没有左子树和右子树的区分,而二叉树中则有这个区别。
二叉树是树结构中最简单的一种形式,在研究树结构时,二叉树一般会是重点。因为二叉树的描述相对简单,处理也相对简单,而且更为重要的是任意的树都可以转换成对应的二叉树。因此二叉树是所有树结构的基础。
2.2 二叉树的性质
二叉树有如下的性质,理解下面的性质,将有助于更好的使用它。
A,二叉树的第 i 层最多拥有个结点(i >= 1)
比如第二层的结点数最多有 = 2个;第三层的结点数最多有=4个。
B,深度为 k 的二叉树最多共有个结点(k >= 1)
比如树有一层,最多有-1 = 1 个结点;树有二层,最多有-1 = 3 个结点。
C, 对任何一棵非空的二叉树T,若其叶结点数为,分支度为2的结点数为,则= + 1。
叶结点数其实就是终端结点数。一棵二叉树,除了叶结点外,剩下的就是度为1或2的结点数了,设为度1的结点数,则树T的结点总数n = + + 。
如下图中,结点总数是10,其中,A、B、C、D结点的度为2,即 = 4,E结点的度为1,即 = 1,F、G、H、I、J结点的度为0,即 = 5。
换一个角度,来数一数它的连接线,由于根结点只有分支出去,没有分支进入,所以分支线总数为结点总数减去1,就是9个分支。A、B、C、D结点都有两个分支线出去,E结点有一个分支线出去。用代数表达就是分支线总数= n - 1 = + 2。刚才有等式 n = + + ,所以 + + - 1 = + 2。得到 = + 1。
2.3 二叉树的分类
2.3.1 斜树
斜树一定是斜的,所有的结点都只有左子树的二叉树叫左斜树;所有结点都是只有右子二叉树叫右斜树,两者统称为斜树。斜树跟线性表很像,所以从这点来说,线性表可以理解为树的一种特殊形式。
2.3.2 满二叉树
在一棵树中,若所有分支结点都存在左子树和右子树,并且所有叶结点都在同一层上,这样的二叉树称为满二叉树。满二叉树是所有叶结点都在最底层的完全二叉树。
单是每个结点都存在左子树和右子树,并不能算满二叉树。还必须要所有的叶结点都在同一层上,这样就使整棵树达到了平衡。因此满二叉树有以下特点:
- 叶结点只能出现在最底层;
- 非叶结点的度一定是2,即都存在左子树和右子树;
- 在同样深度的二叉树中,满二叉树的结点个数最多,叶结点数也最多。
2.3.3 完全二叉树
对一棵具有 n 个结点的二叉树,按层序编号,若编号为 i (1 i n)的结点与同样深度的满二叉树中编号为 i 的结点在二叉树中位置完全相同,则这棵树称为完全二叉树。对于一棵二叉树,假设其深度为 d (d > 1)。除了第 d 层外,其它各层的结点数目均已达到最大值,且第 d 层所有节点从左向右连续地紧密排列。其中完全二叉树又包括了满二叉树。简单来说,完全二叉树就是少了侧的若干结点。
满二叉树和完全二叉树是有区别的。首先满二叉树一定是一棵完全二叉树,但完全二叉树不一定是满的。
其次,完全二叉树的所有结点与同样深度的满二叉树,在按层序编号相同的结点,它们是一一对应的。下图中的树1,因为结点为5的没有左子树却有右子树,那就使得按层序编号的第10个编号空了。同样的道理,下图中的树2,由于结点3没有子树,使得6和7两个编号的位置也空了。下图中的树3又是因为编号为5的结点没有子造成第10和11的位置空了。所以它们都不是完全二叉树,更不是满二叉树。
根据上面的内容,也可以得出一些关于完全二叉树的特点:
- 叶结点只能出现在最下两层;
- 最下层的叶结点一定集中在左部连接位置;
- 倒数第二层,若有叶结点,一定都在右部连续位置;
- 若结点度为1,则该结点只有位于左边的子结点,即不存在只在位于右边子结点的情况;
- 同样结点数的二叉树,完全二叉树的深度最小。
从上面的例子中,也可以了解到一个判断二叉树是否是完全二叉树的办法那就是对树的图示按照满二叉树的结构逐层编号,若序编号出现空档(即中间断了),就说明不是完全二叉树,否则就是。
完全二叉树的性质
(1)具有 n 个结点的完全二叉树的深度为(的结果,是去掉小数后的最大整数)。
对于这条性质,下面来进行推导。深度为k 的满二叉树的结点数 n 一定是。这个值是最多的结点个数。根据结点个数倒推满二叉树的度数为 k = ,比如结点数为15的满二叉树,度为4。
完全二叉树的结点数一定是小于或等于同样度数的满二叉树的结点个数的,但也一定是多于 - 1。即满足 - 1 < n <= 。n 是正整数, n <= 就意味着 n < 。n > - 1也意味着 n >= ,所以 <= n < ,对不等式的两边取对数,得到 k -1 <= < k,而 k 作为度数也只能是整数,因此有 k = ,其中是去掉小数后的整数。
(2)若对一棵有 n 个结点的完全二叉树(其深度为,去掉小数)的结点按层序编号(从第1层到深度值的一层,每层从向左往右),对任意一个结点 i (1 <= i <= n)有:
- 若 i = 1,则结点 i 是二叉树的根结点,无双亲结点;若 i > 1,则结点 i 双亲结点是结点 i / 2(去掉小数后的值);
- 若 2i <= n,则结点 i 左边的子结点是结点2i;若 2 * i > n,则结点 i 无左边的子结点,没有左子树进一步也就没有右子树(即结点 i 为叶结点或终端结点);
- 若2i+1 > n,则结点 i 无右边的子结点;若 2*i + 1 <= n,则结点 i 右边的子结点(即结点 i 的右子树的根结点)是结点2i + 1。
在上图为例,来理解这个性质。上图是一个完全二叉树,度为4,结点总数10。对第1条,i = 1时结点1就是根结点;i = 5时时双亲结点是5/2 = 2.5,取整数为2,没毛病。
对第二条,n = 10,取 i = 6,2*6 = 12 > 10,结点6没有子结点,即它是叶结点;取 i = 4,2*4 = 8 < 10,结点4的左边的子结点是2*4 = 8。
对第三条,取 i = 5, 2*5+1 = 11 > 10,结点5只有左边的子结点10,没有右边的子结点;取 i = 6,2*6 +1 = 13 > 10,结点6
也没有右边的子结点。
完全二叉树的应用
完全二叉树可应用在堆结构中,进行堆排序等。
2.3.4 二叉搜索树
二叉搜索树,Binary Search Tree,也称二叉查找树、有序二叉树、二叉排序树。使用一棵二叉搜索树既可以作为一个字典又可以作为一个优先队列。 它有以下特征:
- 若左子树不为空,则左子树的所有结点的值都小于它的根结点的值;
- 若右子树不为空,则右子树的所有节点的值都大于根节点的值;
- 左右子树也分别为二叉搜索树;
- 没有键值相等的节点。
2.3.5 平衡二叉树(AVL树)
含有相同节点的二叉搜索树可以有不同的形态,而二叉搜索树的平均查找长度与树的深度有关,所以需要找出一个查找平均长度最小的一棵树,那就是平衡二叉树。它是基于二分法的策略提高数据的查找速度的二叉树。平衡二叉树,也叫AVL树,它是一种结构平衡的二叉搜索树,它有以下几点特征:
- 叶结点高度差的绝对值不超过1;
- 左右两个子树都是一棵平衡二叉树;
- 二叉树节点的平衡因子定义为该结点的左子树的深度减去右子树的深度,则平衡二叉树的所有结点的平衡因子只可能是-1,0,1。
红黑树是一种自平衡二叉查找树,它于1972年由鲁道夫 贝尔发明,他称之为“对称二叉B树”。它在平衡二叉树的基础上每个结点又增加了一个颜色的属性,节点的颜色只能是红色或者黑色。它有以下几点特征:
- 根结点只能是黑色;
- 红黑树中所有的叶结点后面再接左右两个空结点,这样可以保证算法的一致性,且所有的空结点都是黑色;
- 其它的结点的颜色只能是红色或黑色,红色结点的父结点和左右子结点都是黑色,及黑色红红相同;
- 在任何一棵子树中,从根结点向下到达空结点的路径上所经过的黑节点的数目相同,从而保证了是一个平衡二叉树。
2.4 二叉树的存储结构
前面讲述过了树的存储,顺序结构对存储树结构这种一对多关系的结构,实现再起比较困难。而二叉树是一种特殊的树结构,也是树结构的基础,鉴于二叉树的特殊性和重要性,这里再研究下它的存储结构。
2.4.1 二叉树的顺序存储
顺序存储方式是最基本的数据存储方式。与线性表类似,二叉树的顺序存储一般也是采用一维数组来表示。关键是要定义合适的次序来存储树中各个层次的数据。
下图中二叉树结点的数据类型为字符。采用顺序存储时可以按层来存储。即先存储根结点,再从左向右依次存储下一个结点的数据,直到所有的结点数据完全存储。
将上图中的二叉树存入数组中,相应的下标对应结点的位置。如下图所示。
由于二叉树定义的严格,所以用顺序结构可以表现出二叉树的结构。对一般的二叉树,尽管层序编号不能反映逻辑关系,但可以将其按完全二叉树编号,只不过把不存在的结点设置为空即可。如下图所示,浅色结点表示不存在此结点。
考虑到一种极端情况,一棵深度为 k 的右斜树,它只有 k 个结点,却需要分配个存储单元空间,这显然是对存储空间的浪费。如下图所示。所以顺序存储结构一般只用于完全二叉树。对于更一般的情况,建议采用链式存储方式。
2.4.2 二叉树的链式存储
即然顺序存储方式适用性不够强,就考虑链式存储方式。二叉树每个结点最多有两个子结点,所以为它设计一个数据域和两个指针域,称这样的链表叫做二叉链表。
如上图,其中data用于存储当前结点的数据,lchild和rchild都是指针域,分别用于存储左边子结点和右边子结点的指针。
有时为了方便,也可以保存该结点的父结点的引用。这样就多了一个指针域,指向父结点的地址。称之为三叉链表。
2.5 树、森林与二叉树的转换
对于树来说,满足树的条件下可以是任意形状,一个结点可以有任意多个子结结点,显然对树的处理要复杂的多,去研究关于树的性质和算法,真的不容易。所以需要找到简单的办法来解决对树处理的问题。
上面介绍了二叉树,二叉树的每个结点最多只能有一个左子树和右子树,这样以来,相比普通树二叉树的变化就少很多了。如果所有树都能像二叉树一样方便简单就好处理了。所以处理上面问题的一个思路就是将普通树转换为二叉树后再处理。
在描述树的存储结构时,提到了树的孩子兄弟法可以将一棵树利用二叉链表进行存储,所以借助二叉链表,树和二叉树可以实现相互的转换。从物理结构来看,它们的二叉链是相同的,只是解释会不太一样。因此只要设置一定的规则,用二叉树来表示树,甚至表示森林都是可以的,这样森林也可以与二叉树进行相互转换。
2.5.1 树转换为二叉树
将树转换为二叉树的一般步骤如下:
- 加线:在所有兄弟结点之间加一连接线;
- 去线:对树中的每个结点,只保留它与第一个子结点的连接线,删除它与其它子结点间的连接线;
- 层次调整:以树的根结点为轴心,将整棵树顺时针旋转一定的角度,使之结构层次分明。这里需要注意的是,第一个子结点是二叉树某个结点的左子结点,兄弟结点转换过来的子结点是某个结点的右子结点。
如下图所示。容易犯的一个错误就是在层次调整时,弄错左右子结点的关系。
2.5.2 森林转换为二叉树
森林是由若干棵树组成的,可理解为:森森中的每棵树都是兄弟关系,所以可以按照兄弟结点的处理办法来操作。步骤如下:
- 把每棵树转换成二叉树;
- 第一棵二叉树不动,从第二棵二叉树开始,依次把后一棵二叉树的根结点作为前一棵二叉树的根结点的右子结点,并用连接线连起来。当所有的二叉树连接起来后就得到了由森林转换而来的二叉树。
如下图所示。将森林的三棵树转换为一棵二叉树。
2.5.3 二叉树转换为树
二叉树转换为树是树转换为二叉树的逆向过程,反过来操作。步骤和示图如下:
- 加线:若某个结点的左子结点存在,则将这个左子结点的右子结点、右子结点的右子结点、右子结点的右子结点的右子结点...,就是左子结点的 n 个右子结点都作为此结点的子结点。将该结点与这些右子结点用连接线连接起来。
- 去线:删除原二叉树中所有结点与其右子结点的连接线;
- 层次调整:使之结点层次分明。
2.5.4 二叉树转换为森林
判断一棵二叉树能够转换成一棵树还是森林,只要看这棵二叉树的根结点有没有右子结点即可:有则可以转换成森林,没有则可以转换成一棵树。步骤如下:
- 从根结点开始,若右子结点存在,则把与右子结点的连接线删除,再查看分离后的二叉树,若右子结点存在,则将连接线删除...,直到所有右子结点的连接线都删除为止,然后得到分离后的二叉树;
- 再将每棵分离后的二叉树转换为树即可。
2.6 二叉树的应用
二叉树的一个性质是一棵平均二叉树的深度要比其结点个数小的多,这个性质很重要。尤其对于特殊类型的二叉树即二叉搜索树而言,其深度的平均值是O(logn),这将大大降低查找的时间复杂度。在普通树的基础上又发展了更为有利于实际应用的特殊二叉树,比如后面将要介绍的平衡树、红黑树等。
但二叉树在运用的不好的情况下,将会产生严重的问题。比如树的深度扩大到了N-1(N是树的结点个数),这样的情况是不允许的。这种树也被称为不平衡树。
3. 二叉树的操作及Java代码实现
二叉树的操作,包含了初始化、添加结点、查找结点、获取左子树、获取右子树、计算二叉树深度、遍历二叉树等操作。在实际用到二叉树时,这些操作基本都会涉及或使用到。下面是二叉树结点类的代码,在二叉树操作的代码示例中会用到。
public class LinkedTree<T> {
private static final int MAXLENGTH = 64;
/**
* 根结点,根结点的父结点为空
*/
private TreeNode<T> root;
/**
* 结点个数
* @return
*/
public int size() {
return getSize(root);
}
private int getSize (TreeNode<T> tree) {
if (null == tree) {
return 0;
} else {
return 1 + getSize(tree.left) + getSize(tree.right);
}
}
/**
* 获取根结点
* @return
*/
public TreeNode<T> getRoot() {
return root;
}
public static void main(String[] args) {
TreeNode<String> root = new TreeNode<String>("A");
TreeNode<String> left = new TreeNode<String>("B");
TreeNode<String> right = new TreeNode<String>("C");
TreeNode<String> left1l = new TreeNode<String>("D");
TreeNode<String> left1r = new TreeNode<String>("E");
TreeNode<String> left2l = new TreeNode<String>("H");
TreeNode<String> left3r = new TreeNode<String>("K");
left2l.right = left3r;
left1l.left = left2l;
left.left = left1l;
left.right = left1r;
TreeNode<String> right1l = new TreeNode<String>("F");
TreeNode<String> right1r = new TreeNode<String>("G");
right.left = right1l;
right.right = right1r;
root.left = left;
root.right = right;
LinkedTree<String> tree = new LinkedTree<>();
TreeNode<String> node = root;
// tree.traversalPreOrderScan(node);
// tree.traversalPreOrder(node);
// tree.traversalInOrder(node);
// tree.traversalPostorder(node);
tree.traversalByLevel(node);
}
/**
* 树结点类
*/
static class TreeNode<T>{
/**
* 当前结点元素数据
*/
private T data;
/**
* 左子结点
*/
private TreeNode<T> left;
/**
* 右子结点
*/
private TreeNode<T> right;
/**
* 父结点
*/
// private TreeNode<T> parent;
public TreeNode() { }
public TreeNode(T data) {
super();
this.data = data;
}
public TreeNode(T data, TreeNode<T> left, TreeNode<T> right) {
this.data = data;
this.left = left;
this.right = right;
}
}
}
3.1 二叉树的初始化
在使用顺序存储方式存储二叉树时,首先要初始化。设置树的根结点为空,其它的一些初始化工作根据具体需要来进行。在代码实现时,可以使用构造函数来实现,也可以使用专门的初始化方法来完成初始化。
/**
* 构造函数,初始化
*/
public LinkedTree() {
root = null;
}
3.2 添加结点
添加一个结点到二叉树中,添加时,要指定其父结点,以及添加的结点是左子树还是右子树。
/**
* 添加结点
* @param node
*/
public void add(TreeNode<T> node) {
if ((node = new TreeNode<T>()) != null) {
throw new RuntimeException("node is null when add");
}
TreeNode<T> pnode = null, parent = null;
T data = null;
/**
* 设置左右子树为空
*/
pnode.data = node.data;
pnode.left = pnode.right = null;
/**
* 查找父结点
*/
parent = findNode(node, data);
if (null == parent) {
pnode = null;
throw new RuntimeException("can not find parent node, please set parent node");
}
parent.left = node.left;
parent.right = node.right;
}
3.3 查找结点
查找结点就是遍历二叉树中的每一个结点,逐个比较数据,当找到目标数据时将返回该数据所在结点的引用。
/**
* 查找指定数据所在的结点
*
* @param node
* @param data
* @return
*/
public TreeNode<T> findNode (TreeNode<T> node, T data) {
TreeNode<T> tn;
if (null == node) {
return null;
} else {
if (node.data.equals(data)) {
return node;
} else if ((tn = findNode(node.left, data)) != null){
return tn;
} else if ((tn = findNode(node.right, data)) != null) {
return tn;
} else {
return null;
}
}
}
3.4 获取子树
获取左子树:就是返回当前结点的左子树结点的数据。
获取右子树:就是返回当前结点的右子树结点的数据。
/**
* 获取左子树
* @param node
* @return
*/
public TreeNode<T> getLeftNode (TreeNode<T> node) {
return null != node ? node.left : null;
}
/**
* 获取右子树
* @param node
* @return
*/
public TreeNode<T> getRightNode (TreeNode<T> node) {
return null != node ? node.right : null;
}
3.5 判断空树
判断空树是判断一个二叉树结构是否为空。若是空树,则表示该二叉树结构中没有数据。
/**
* 是否空树,判断二叉树是否为空,若空则没有数据
* @return
*/
public boolean isEmpty() {
return root == null;
}
/**
* 是否为空
* @param tree
* @return
*/
public boolean isEmpty(TreeNode<T> tree) {
return null == tree ? true : false;
}
3.6 计算二叉树的深度
计算二叉树的深度就是计算二叉树中结点的最大层数。这里往往需要采用递归算法来实现,需要计算左右子树的深度来进行比较。
/**
* 深度
* @return
*/
public int depth() {
return getHeight(root);
}
private int getHeight(TreeNode<T> tree) {
if (null == tree) {
return 0;
} else {
int leftHeight = getHeight(tree.left);
int rightHeight = getHeight(tree.right);
return leftHeight > rightHeight ? 1 + leftHeight : 1 + rightHeight;
}
}
3.7 清空二叉树
清空二叉树就是将二叉树变成一个空树。可能需要使用递归算法来实现,它需要清空该结点的数据,也要清空该结点的左右子树内容。
/**
* 清空,将二叉树变成一个空树,递归清空
* @return
*/
public void clean(TreeNode<T> tree) {
if (null != tree) {
clean(tree.left);
clean(tree.right);
tree = null;
}
}
3.8 显示结点数据
显示结点的数据,这里单纯的在控制台打印一下。实际怎么操作根据需要处理。
/**
* 显示结点数据
* @param node
*/
public void data(TreeNode<T> node) {
System.out.printf("%s", null != node.data ? node.data.toString() : "");
}
3.9 遍历二叉树
遍历二叉树就是逐个查找整个二叉树中所有的结点,它是二叉树的基本操作,很多操作都需要首先遍历整个二叉树。下面举例来说明二叉树的遍历。
假设手里有20张100元和2000张1元的奖励券,同时洒向空中,看谁最终抢的最多。对这个问题,很多人都会先去抢100元的。因为100元比1元面值大很多,可以抵100张1元的券,这样效果好的就不止一点点。同样是抢券,在有限的时间内,要达到最高的效率,次序很重要。对二叉树的遍历来说,次序同样很重要。
二叉树的遍历(traversing binary tree)是指从根结点出发,按照某种次序依次访问二叉树中的所有结点,使得每个结点被访问一次且仅被访问一次。请注意这里出现的两个关键词:访问和次序。
访问的目的是根据需求来的,可以是计算或打印等,它算是一个抽象操作。这里可以假定是简单的输出结点的数据信息。
二叉树的遍历次序不同于线性结构,最多也就是从头到尾、循环、双向等简单的遍历方式。树的结点之间不存在唯一的前驱和后继关系,在访问一个结点后,下一个被访问的结点面临着不同的选择。选择的不同,遍历的次序也就不同。
3.9.1 二叉树的遍历方法
下图表示二叉树中的基本结构,D表示根结点,L表示左子树,R表示右子树。二叉树的遍历方式有很多种,如果用L、D、R分别表示遍历左子树、访问根结点、遍历右子树,则二叉树的遍历方法可以有6种(3的排列组合,3!=6),分别是LDR、LRD、DLR、DRL、RDL、RLD。若限制了从左到右的习惯方式,那主要的方式就可以分为先序遍历、中序遍历、后序遍历。这里的先中后是访问根结点的顺序,都是从左到右的。需要注意的是要区别清楚访问与遍历。
还有很容易想到的按层序遍历。上面的三种遍历方法的最大好处是可以方便的利用递归的思想来实现遍历算法,当然也可以不使用递归思想来实现。这三种遍历方法在使用非递归算法实现时,共同之处在于用栈来保存先前走过的路径,以便可以在访问完子树后利用栈中的信息,回退到当前节点的双亲节点,进行下一步操作。层序遍历法一般不能使用递归算法来编写代码,而是使用一个循环队列来进行处理,它是最直观的遍历算法。
(1)前序遍历:又叫先序遍历、先根遍历,简称为DLR遍历。若二就能树为空,则空操作返回,否则先访问根结点,然后前序遍历左子树,再前序遍历右子树。就是先根后左再右,根左右。如下图的左图所示,遍历的顺序为ABDGHCEIF。可以看出前序遍历是先根结点,再按从上往下的层序在左子树遍历,最后也是按从上往下的顺序在右子树遍历。
(2)中序遍历:也称中根次序遍历,简称LDR遍历。若树为空则空操作返回,否则从根结点开始(注意并不是先访问根结点),中序遍历根结点的左子树,然后访问根结点,最后中序遍历右子树。如上图的右图,遍历的顺序是GDHBAEICF。可以看出中序遍历是先在左子树找到最底层的左子结点开始,有根结点就显示根结点,无则显示右子结点;然后回到上层,如此循环直到根结点,显示根结点后再来到右子树的最底层左子结点开始类似操作。
(3)后序遍历:也称后根次序遍历,简称LRD遍历。若树为空则空操作返回,否则从左到右先叶子后结点的方式遍历访问左右子树,最后访问根结点。如下图的左图所示,遍历顺序为GHDBIEFCA。可以看出后序遍历是先在左子树找到最底层的左子结点开始。
(4)层序遍历:规则是若树为空则空操作返回,否则从树的第一层(也就是根结点开始访问),从上而下逐层遍历,在同一层中,按从左到右的顺序对结点逐个访问。如上图的右图所示,遍历顺序为ABCDEFGHI。
上面提到的四种遍历方法,其实都是在把树中的结点变成某种意义的线性序列,这就程序的实现带来了好处。
3.9.2 前序遍历方法
实现遍历算法时采用递归,简洁明了。假设有如下图这样的一棵二叉树T。这棵树已经用二叉链表结构存储在内存当中。前序遍历算法就是先访问根结点,然后遍历左子树,最后遍历右子树,可记做根左右。
- 首先访问根结点,根结点不为空,所以执行打印(具体操作自行决定),这里打印A;若根结点为空则返回;最终打印结果为A。
- 遍历根结点A的左子树,不为空,打印B;再遍历结点B的左子树,不为打,打印D;再遍历结点D的左子树,不为空,打印H;再遍历结点H的左子树,为空返回;再遍历结点H的右子树,不为空,打印K;遍历结点K的左子树,为空返回;遍历结点K的右子树,为空返回;此时结点K的左右子结点都为空,返回到结点K的上一层结点H,H的操作执行完毕了(结点H的遍历只是打印结点K,已经执行了);再返回到结点H的上一层结点D,其操作也执行完毕了,返回到结点D的上一层结点B,遍历结点B的右结点E,打印结点E,因为结点E无左右子结点,返回结点B的上一层结点A,即返回到根结点;
- 遍历根结点A的右子树C,操作类似上一步,打印的结果依次是F、I、G、J。最终的结果为ABDHKECFIGJ。结束。
从上面的操作可以看出,前序遍历的是先访问根结点;再访问根结点的左子树,按层序从上往依次打印左结点,直到没有左子结点,则打印同层的右子结点,若没有右子结点,则返回到上一层,一直返回到根结点这一层;最后开始遍历根结点的右子树,也是开始按层序不断的打印其左子结点,直到没有左子结点;然后打印同层的右子结点,若同层无右子结点则返回上一层;直到打印完成。
/**
* 前序遍历,使用非递归实现,使用栈
* @param node
*/
public void traversalPreOrder(TreeNode<T> node) {
Stack<TreeNode<T>> stack = new Stack<>();
while (null != node || !stack.isEmpty()) {
while (null != node) {
System.out.println(node.data);
stack.push(node);
node = node.left;
}
if (!stack.isEmpty()) {
node = stack.pop();
node = node.right;
}
}
}
/**
* 前序遍历,使用递归实现
* @param node
*/
public void traversalPreOrderScan(TreeNode<T> node) {
// 先根
if (null != node) {
System.out.println(node.data + " ");
}
// 中左
TreeNode<T> left = node.left;
if (null != left) {
traversalPreOrderScan(left);
}
// 后右
TreeNode<T> right = node.right;
if (null != right) {
traversalPreOrderScan(right);
}
}
再来看一个简单的二叉树,如下图。前序遍历结果为ABDECF。
3.9.3 中序遍历方法
首先遍历左子树,然后访问根结点,最后遍历右子树,可记做左根右。中序遍历算法的空间复杂度均为O(n),时间复杂度为n。还是上面的二叉树,看看是如何执行的。
- 调用中序遍历函数,因为传入的二叉树T不为空,所以访问二叉树的左子树结点B;结点B的左子结点D不为空,继续访问结点D;结点D的左子结点H不为空,继续访问H;结点H的左子结点为空,于是返回,并打印当前结点H;此步操作的打印结果为H;
- 再来访问结点的右子结点,发现存在结点K,再访问结点K发现其无子结点,所以打印结点K;打印结点为HK;
- 因为结点K没有子结点,返回;结点H已经打印,返回到H结点的上一层结点D;打印D;打印结点为HKD;
- 结点D无右子结点,返回;打印B;因为结点B有右子结点E,打印结点E;打印结点为HKDBE;
- 结点E无右子结点,返回; 打印根结点A,打印A;打印结点为HKDBEA;
- 到这里二叉树的左子树打针完成,根结点A也已经访问了,下面继续打印二叉树的右子树,操作与上面类似,最终的结果为HKDBEAIFCGJ。
从上面可以看出,中序遍历是从根结点开始,先访问左子树。按层序从上往下一直访问到左子树的最一个左子结点才开始打印,因为最开始打印的最大层序的左子结点,若该左子结点有右子结点则打印右子结点;没有则返回到上一层,有右子结点则打印,没有则返回到上一层...,直到返回到根结点,打印根结点;最后是遍历右子树。打印右子树时,也是按层序从上往下先打印最底层的左子结点(右子树的左子结点)。
/**
* 中序遍历,使用递归
* @param node
*/
public void traversalInOrder(TreeNode<T> node) {
if (null == node) {
return;
} else {
// 先左
traversalInOrder(node.left);
// 中根
System.out.println(node.data);
// 后右
traversalInOrder(node.right);
}
}
再来看一个简单的二叉树,如下图。中序遍历结果为DBEAFC。
3.9.4 后序遍历方法
它是先遍历左子树,再遍历右子树,最后访问根结点,可记为左右根。此遍历方法是三种顺序中最复杂的,原因在于它是先访问左右子树再访问根结点,而在非递归算法中,利用栈回退时,并不知道是从左子树回退到根节点还是从右子树回退到根结点。若从右子树回退到根结点,此时就应该去访问右子树,而若从右子树回退到根结点,此时就应该去访问根结点。所以相比前序和中序,必须得在压栈时添加信息,以便在退栈时可以知道是从左子树返回,还是从右子树返回而进行下一步操作。
还是以上面的那棵二叉树例。先遍历左子树,再遍历右子树,最后访问根结点。
- 从左子树按层序从上往下遍历,到最后一层的根结点H,结点H没有左子结点,但有右子结点K,结点K无子结点,所以打印K,返回;此步操作打印的结果为K;
- 因为上一层已经返回到了结果H这一层,结点K的子左右已经打印完了,所以打印结果H;到这里的打印结果为KH;
- 返回到上一层结点D,打印结点D;最终结果为KHD;
- 返回到上一层结点B,结点B的左子结点已经打印了,在右子结点E,打印E;再打印B;最终结果为KHDEB;
- 返回到根结点A,因为根结点最后才执行,所以来到二叉树的右子结点C,按层序到最下面一层的结点I,结点I没有子结点,打印I;最终结点为KHBEBI;
- 返回上一层到结点F,结点F无右子结点,打印F;最终结点为KHBEBIF;
- 返回上一层来到结点C,因为结点C有右子结点,所以这里先不打印C,而是按层序到最后一层,来到结点J,结点J没有子结点,所以打印J;最终结果为KHDEBIFJ;
- 返回到上一层,来到结点G,结点G的子结点已经打印结束,所以打印G;最终结果为KHDEBIFJG;
- 返回到上一层,来到结点C,结点C的子结点都已经打印,所以打印结点C;
- 最后返回到根结点A,打印A;最终结果为KHDEBIFJGCA。
/**
* 后序遍历,使用递归算法
* @param node
*/
public void traversalPostorder(TreeNode<T> node) {
if (null == node) {
return;
} else {
// 先左
traversalPostorder(node.left);
// 中右
traversalPostorder(node.right);
// 后根结点
System.out.println(node.data);
}
}
再来看一个简单的二叉树,如下图。后序遍历结果为DEBFCA。
3.9.5 层序遍历方法
由于二叉树代表的一种层次结构,可能首先想到的便是按层来遍历。对于二叉树的按层遍历,一般不能使用递归算法来编写代码,而是使用一个循环队列来进行处理。首先将第一层(根结点)入队,再将第根结点的左右子树(第二层)入队,直到所有的层都入队。就是逐层遍历。此遍历方法是最直观的遍历算法。
/**
* 按层遍历法,即从第一层开始遍历,到最后一层
*/
public void traversalByLevel (TreeNode<T> node) {
/**
* 队首和队尾
*/
int head = 0, tail = 0;
/**
* 先定义一个顺序栈
*/
TreeNode<T> p;
TreeNode<T>[] n = new TreeNode[MAXLENGTH];
/**
* 若队首引用不为空,则设置队尾
*/
if (null != node) {
tail = (tail + 1) % MAXLENGTH;
n[tail] = node;
}
/**
* 若队列不为空则循环
*/
while (head != tail) {
// 计算循环队列的队首序号
head = (head + 1) % MAXLENGTH;
// 获取队首元素
p = n[head];
// 处理队首元素
data(p);
/**
* 若存在左子树
*/
if (null != p.left) {
// 计算循环队列的队尾序号
tail = (tail + 1) % MAXLENGTH;
// 将左子树引用到队列中
n[tail] = p.left;
}
/**
* 若存在右子树
*/
if (null != p.right) {
tail = (tail + 1) % MAXLENGTH;
n[tail] = p.right;
}
}
}
3.9.6 推导遍历结果
上面介绍了前中后序遍历方法和层序遍历方法,下面有一个问题:知道一棵二就能树的前序遍历序列为ABCDEF,中序遍历结点为CBAEDF,问这棵二叉树的后序遍历结果是多少?
前序遍历是先访问根结点,所以这棵二叉树的根结点为A。中序结果为CBAEDF,是先遍历根结点的左子树再访问的根结点,由此可知根结点A的左子树为CB,EDF为根结点A的右子树。如下图所示。
对于前序结果,先打印的B后打印的C,所以B是A的左子结点(因为是从到右的顺序,若B是A的右子结点就不会打印B);对于中序结点中的CB,先打印的C,所以C可能是B的左子结点或右子结点,但具体是右子结点还是左子结点目前尚不确定。
再看中序结果CBAEDF的EDF,那说明结点D为结点A的右子结点,结点E和结点F分别为结点D的左右两个子结点。当然还有一种情况:三个结点EDF有三层,每层只有一个结点,即结点A下面是结点F,结点F下面是结点D,结点D下面是结点E,但若是这样的话,那不会符合前序结果ABCDEF,所以只能是这种情况:明结点D为结点A的右子结点,结点E和结点F分别为结点D的左右两个子结点。
4.10 二叉树的建立
上面介绍了二叉树的一些常用操作,那如何在内存中生成一棵二叉链表的二叉树呢?若要在内存中建立一个如下图中左图这样的树,为了能让每个结点确认是否有左右子结点,对它进行了扩展,变成了右图的样子。也就是将二叉树中每个结点的空指针引出一个虚结点,其值为一个特定值,比如“#”。称这种处理后的二叉树为原二叉树的扩展二叉树。扩展二叉树就可以做到一个遍历序列确定一棵二叉树了。如下图右图的二叉树的前序遍历序列就为AB#D##C##。
有了上面的准备后,来看看如何生成一棵二叉树。假设二叉树的结点均为一个字符,把刚才前序遍历序列AB#D##C##用键盘挨个输入。实现的算法如下:
建立二叉树,也是利用了递归的原理。当然,也完全可以用中序或后序遍历的方式实现二叉树的建立,只不过代码里生成结点和构造左右子树的代码顺序要交换一下。另外,输入的字符也要做相应的更改。上面扩展二叉树的中序遍历字符串应该为#B#D#A#C#,后序遍历序列为###DB##CA。