数据结构之树
导读
树是一种非线性的数据结构,树结构的应用十分广泛,在很多算法中都有应用。本文对树这种数据结构进行了总结。
目录
1、树的定义
2、树的基本术语
3、树的存储结构(3种)
4、树和森林转化为二叉树
5、树和森林的遍历(树两种、森林两种)
树的定义
树结构可以说是我们日常生活中“树”的一种抽象,树只有一个根,其余都是树的枝叶,数据结构中的树类似一颗“倒立”的树,如下:
这颗树是由若干结点(A~Q)组成,所以树是由唯一的根结点和互不相干的若干子树组成。树的定义:由n(n>=0)个有限节点组成一个具有层次关系的集合。
当结点数n=0时,称为空树,这是一种特殊状态。由上可知,树的定义是递归的,即在整颗树中,若干子树也是树的定义。
树的基本术语
以上图为例进行说明。(这些概念不必死记,很好理解)
结点:A~Q都是结点,结点不仅包含了数据元素,还包含了指向子树的分支信息,如A结点就包含了6个分支信息。(类似链式存储中的节点,既有数据域,又有指针域)。
结点的度:节点拥有的子树个数(或分支个数)。如A结点的度为6,B结点的度为0。
树的度:树中各个节点度的最大值。如该树的度就是A结点的度,为6。
叶子节点:度为0的节点。如B、C、H、I、K、L、M、N、P、Q都是树的叶子节点。
孩子:结点子树的根结点称为孩子。如E的孩子I和J。(所以叶子节点莫有孩子)
双亲:与孩子的定义对应,如E的孩子I和J,那么反过来,I和J的双亲就是E。
兄弟:同一双亲的孩子互相称为兄弟,比如P和Q。
祖先:从根结点到某结点的路径上的所以结点,都是该结点的祖先,如路径:A-E-J-Q,那么Q的节点的祖先就是A、E、J。
子孙:和祖先对应,以某结点作为根结点的所有子树的结点都是该结点的子孙,如路径:A-E-J-Q,那么A的子孙就是E、J、Q。
层次:从根开始为第一层,根的孩子为第二层,根的孩子的孩子为第三层,依次类推。可以理解为家族树中的辈份概念,所有该树有4层。
树的高度:或称为树的深度,表示树中结点的最大层次即为树的高度,例如该树的高度为4。(注意和树的度加以区分)。
结点的高度和深度:两个相反地概念,结点的高度是从叶子节点开启计算层数,而结点的深度是从根结点开始计算层数。例如B的高度为:3,深度为2。
堂兄弟:双亲在同一层的的结点互称为堂兄弟。
有序树:树中结点的子树从左往右是有次序的,不能交换,这样的树称为有序树。(类似兄弟有长幼之分,兄必须在左,第必须在右)。
无序树:与有序树对应,子树的的左右顺序可以交换的树。
丰满树:即理想平衡树,除了最底层外其他层都是满的(即叶子结点全部在最底层的树)。
森林:若干互不相交的树组成的集合称为森林。
树的存储结构
为了方便讲解,我们使用下图作为案例树进行说明。
1、双亲存储结构
我们利用树的双亲唯一的这个特点,存储每个结点时只需要存储自身数据和双亲即可完成整颗树的存储,这就是双亲存储的原理。双亲存储结构是一种树的顺序存储结构,使用二维数组即可实现。在存储时我们先从上往下、从左往右依次为树节点标上序号,这个序号也代表数组的角标,这个角标就用来表示这个节点的索引。
ps:双亲存储结构在克鲁斯卡尔算法中有重要应用。
2、孩子储存结构
除了围绕双亲来存储我们还可以围绕孩子来存储树,但是树中每个结点可能有多棵子树,可以考虑用多重链表,即每个结点有多个指针域,其中每个指针指向一棵子树的根结点,我们把这种方法叫做多重链表表示法(或说孩子存储法)。
孩子存储结构的结点设计如下:
对于结点的设计,有两种方案可选:
方案一:每个结点都设计对应的结构。
如A结点有两个孩子,所以A的指针域就设计为两个;C结点只有一个孩子,所以指针域就设计为1个;以此类推。上图案例的树就可以设计为:
缺点:这样设计有个很明显的缺点,如果有很多结点的结构都不一样,那么就要设计多种结点结构来保存结点信息,这样不利于统一管理。
方案二:针对方案一的缺点,我们应该将树的结点结构统一(统一的关键在于指针域数量的统一),比如A有两个孩子,而C有一个孩子,那么我们需要使用一样的结构保存A和C就需要设计至少两个指针域的结点。依次类推,我们要使用一个全部结点都能使用的结构,那么我们就需要将指针域的数量设计为最多孩子的结点的度(孩子的数量)。即该树的度作为指针域的数量。所以我们将案例树的结点设计为2个指针域。
缺点:这样设计可以统一管理结点,但是当结点之间的度相差太大时会是否浪费存储空间,比如A结点有1个孩子,B结点有10个孩子,那么按照10个指针域的设计,A结点就会浪费9个指针域。
方案三(重要):换一种思维。
首先树可以由两个关系表示出来:结点——孩子——兄弟。(双亲与孩子之间的关系;孩子与孩子之间的兄弟关系)。所以这两个关系都可以用“数据域——指针域”的设计来表示。并且每一个结点都可以这样来设计:结点——孩子——兄弟。
例如A结点即它的孩子们就可以表示为:
依次类推,我们可以将所有结点都用这种“双亲——孩子——兄弟”的结构表示出来。但这样如果有n个结点就会得到n个单链表,而这n个单链表之间都没有联系,所以我们按照还是按照从上到下、从左到右的顺序为各个结点排序,并用这些下标表示各个结点的位置,这样就可以将这些分散的单链表联系起来。如图:
改进:该方案解决了结点结构不统一和空间浪费的问题,但任然可以进行改进,注意方案三的设计,我们将双亲和孩子串联起来,知道了某一结点,很容易就知道它的孩子,但如果要找到该结点的双亲需要进行遍历比较麻烦,而我们知道双亲是唯一的,所以我们可以将新增一个指针域指向双亲,所以改进结构就变成了:结点——双亲——孩子——兄弟。
ps:其实孩子存储结构的实质就是图的邻接表存储结构(树是一种特殊的图)。
3、孩子兄弟存储结构(重要)
这种存储方式是将树转换为二叉树然后再进行孩子兄弟存储。孩子兄弟存储结构与二叉树的关系十分密切,使用也比较广泛,因为这种存储结构能够把树或森林转换为二叉树。
(由于案例树已经是二叉树,这里我使用下图的树作为新的案例树):
树和森林转化为二叉树
1、普通树转化为二叉树
将普通树转换为二叉树的规则:
1)将同一层的孩子结点用虚线串起来(如图1)
2)将每个结点的分支(实线)除了最左边的第一条线全部剪掉(如图2)
3)将虚线变为实线就形成了二叉树。(如图3)
孩子兄弟存储结构其实是利用二叉树的规范的优点,先将树转化为二叉树再进行存储问题就变得简单多了。
ps:我们将一棵以孩子兄弟存储后,只需要使用二叉树的各种遍历(先序、中序和后序)就可以还原普通的树,下文树和森林的遍历会讲到。
2、森林转换为二叉树
如果你懂了普通树转换为二叉树的方法,那么森林转化为二叉树也就变得十分简单了。
注意普通树转换为的二叉树有一个特点:根结点没有右兄弟(这里说的右兄弟是根结点的孩子的兄弟)。而森林是由若干不相连的树组成的,所以我们先将森林里的没颗树转化为二叉树,然后利用“没有右兄弟”这个特性,第二颗树的根结点作为第一颗树的右兄弟,第三颗树作为第二颗树的右兄弟,依次类推。这样就将所有分散的二叉树连接成为一颗二叉树。
将森林转换为二叉树的规则:
1)分别将森林里的树转化为二叉树(如图1)
2)从左往右,右边的树的结点作为左边树结点的右兄弟连接起来即可。
树和森林的遍历
我们常说的树的三种遍历方式,其实是二叉树的三种遍历方式:前序遍历、中序遍历和后序遍历。
注意,普通树的遍历只有先序遍历和后序遍历两种(即普通树没有中序遍历);而森林的遍历方式只有先序遍历和中序遍历两种(即森林没有后序遍历)。不要混淆。由于树和森林都可以转换为二叉树来存储,所以我们一般只需要重点掌握二叉树的三种遍历方式就可以了。
1、三者之间遍历的联系:
1)树转化为二叉树,那么树的先序遍历对应二叉树的先序遍历;树的后序遍历对应二叉树的中序遍历。
2)森林转化为二叉树,那么森林的先序遍历对应二叉树的先序遍历;树的中序遍历对应二叉树的中序遍历。
2、遍历规则(注意,由于树的定义本身就存在递归,所以遍历是也存在递归)
树的先序遍历:先访问根结点,再依次先序遍历根结点的每颗子树。
树的后序遍历:先依次访问根结点的子树,再访问根结点。
(森林是由树组成的,所以森林的遍历是在树的遍历继承之上的)
森林的先序遍历:先访问森林第一棵树的根结点,先序遍历第一颗树的子树,然后再先序遍历森林中除第一颗树外的其他树。(其实就是从左往右分别对每棵树进行先序遍历的效果)。
森林的中序遍历:中序遍历第一颗树根结点的子树,访问第一颗树的根结点,然后再中序遍历森林中除第一颗树外的其他树。(其实就是从左往右分别对每棵树进行后序遍历的效果)。
3、结论
1、森林的遍历其实就是若干树从左往右进行(先序或后序)遍历的结果。(所以其实森林的中序遍历其实是树后序遍历的结果,我们称为中序遍历而不是后序遍历是因为,后序遍历遇到根结点表示遍历结束,而森林中的根结点不止一个,遇到根结点可能并没有结束,所以不宜称为后序遍历)。
2、那为什么普通树没有中序遍历呢?因为普通树的孩子为若干个,不像二叉树刚好分为“左根右”,根结点正好符合中序遍历的“中间位置”,而普通树如果有中序遍历,那么这个根结点什么时候遍历(即放在哪个子树之间)算是中呢?所以,树没有中序遍历。