一.相关概念
1.树是n(n>=0)个结点的有限集。n=0时称为空树。在任意非空树中有且仅有一个特定的结点称为根结点。n>1时其余结点可以分为若干个互不相交的有限集,每个有限集本身也是一棵树,可以称为根的子树。
2.结点的子树数称为结点的度。度为0的结点称为叶子结点或终端结点,度不为0的结点称为非终端结点或分支结点。除根结点外的分支结点也成为内部节点。将树中所有结点的度的最大值作为整个树的度。
3.结点子树的根结点称为该结点的子结点,同样的,该结点称为其子结点的双亲结点。拥有同一个双亲结点的子结点互称为兄弟结点。双亲结点在同一层的子结点互称为堂兄弟结点。结点子树中的任意其他结点称为该结点的子孙,同样的,该结点是其子孙结点的祖先。
4.在树中,我们可以将根结点定义为第一层结点,将根节点的子结点定义为第二层,将根节点子结点的子结点定义为第三层......以此类推,就定义出了任意结点所在的层,这个层也称为深度或高度。将树中所有结点的深度的最大值作为树的深度。
5.n棵互不相交的树的集合称为森林。
二.树的存储
不论使用顺序存储还是链式存储,在存储树时如何存储结点之间的相互关系(兄弟结点、双亲结点、子结点等关系)都是存储树的主要考虑因素,不同的存储方式会影响查找或遍历的时间复杂度。一个基本结点的存储结构可以包括数据域和指针域两部分,数据域存储结点中包含的数据信息,指针域存储和当前结点有关系的其他结点的位置信息。
1.双亲表示法:
1)在结点的指针域中存储双亲结点的位置,显然这样存储在查找双亲结点时的时间复杂度为O(1),但是查找子结点时需要遍历整个树,时间复杂度为O(n),因此我们可以进行一些优化:
2)在结点的指针域中不仅存储双亲结点的位置信息,同时存储最左侧子结点(长子结点)的位置信息(不存在记为-1),这样可以分辨一个结点的不同子结点;
3)在结点的指针域中存储双亲结点的位置信息、长子结点的位置信息和右侧第一个兄弟结点的信息(不存在同样记为-1),这样在查找子结点时就不再需要遍历,直接找到长子结点再依次遍历右侧兄弟结点即可,时间复杂度降为O(1)。
2.孩子表示法:
1)在结点的指针域中存储所有孩子结点的位置信息,这样做显然存在一些问题:由于无法事先得知子结点的个数,因此一般根据树的度开辟固定的空间作为指针域,造成了空间浪费。可以采用下面方案进行优化:
2)在指针域中存储结点的度和所有子结点的位置信息,这样就可以根据结点的度灵活开辟空间,没有了空间损耗,但是在运算中还需要注意维护结点的度和指针域的空间长度,造成了性能上的浪费;
3)将所有结点存储为一个数组,将指针域存储为一个链表。指针域只需要存储链表的第一个结点位置指针,这个指针域中的链表的结点存储子结点的位置指针和下一个指针域中的链表的结点的位置指针。
4)将双亲表示法和孩子表示法可以结合为双亲孩子表示法,也就是既存储双亲的指针,也存储孩子的指针。
3.孩子兄弟表示法:
1)在指针域种存储长子结点的指针和第一个右边兄弟结点的指针。这个方案同样存在访问双亲结点需要遍历的过程;
2)可以将双亲结点指针也存储到指针域种,就是双亲表示法中的3)。
三.二叉树基本概念
1.在一个树中,如果每个结点的子结点个数都小于等于2,这个树就可以称为二叉树。每个结点最多有两个子树,称为左子树和右子树,左子树和右子树是有顺序的。
2.二叉树的五种基本形态:空二叉树、只有一个根节点、根节点只有左子树、根节点只有右子树、根节点同时具有左右子树。
3.特殊二叉树:斜树(所有结点都没有右节点的二叉树称为左斜树,所有结点都没有左结点的二叉树称为右斜树)、满二叉树(所有分支结点都同时存在左子树和右子树,所有的叶子结点都在同一层的二叉树)、完全二叉树(满二叉树的最后一层结点都应该是叶子结点,如果这个满二叉树最后一层的叶子结点最右边的0个或若干个叶子结点缺失,这个二叉树就称为完全二叉树)。
四.二叉树的性质
1.在二叉树的第i层上最多有2i-1个结点;
2.深度为k的二叉树最多有2k-1个结点;
3.对任何一颗二叉树T,如果其终端结点数为n0,度为2的结点数为n2,则n0=n2+1;
4.结点数为n的完全二叉树的深度为[log2n]+1;
5.将完全二叉树按照层序从1到n(结点总数)编号,编号后有以下性质:
1)对于结点i,i=1的结点时根节点,i>1时双亲结点编号是[i/2];
2)对于结点i,它的子结点编号理论上是2i和2i+1,所以当2i>n时结点i是叶子结点,2i=n时这个结点只有左子结点。
五.二叉树的存储
1.存储二叉树使用顺序存储或者链式存储都可以;
2.对于一个完全二叉树,根据而擦函数地性质5,可以使用数组进行存储,根据下标去计算二叉树结点之间地关系;
3.对于一个不完全二叉树,使用2中的方式存储会造成空间浪费,尤其是二叉树是斜树时空间浪费非常严重,所以可以使用链式存储的方式存储二叉树(在指针域中存储左子结点和右子结点,可以选择是否存储双亲结点)。
六.二叉树的遍历
二叉树的遍历根据双亲结点的访问顺序可以分为前序遍历、中序遍历、后序遍历。除此以外,还可以根据层级进行层序遍历。
1.前序遍历:先遍历双亲结点,再遍历左子树,最后遍历右子树;
2.中序遍历:先遍历左子树,再遍历双亲结点,最后遍历右子树;
3.后序遍历:先遍历左子树,再遍历右子树,最后遍历双亲结点;
4.层序遍历:从树的第一层开始,每一层按照从左到右依次遍历。
PS:前中后序遍历三种方式在遍历使用链式存储的二叉树时会比较方便,而层序遍历在遍历使用顺序存储(数组)的二叉树时更为方便。
线索二叉树:在二叉树中,我们会发现有很多空间的浪费,如叶子结点存储左右子结点指针的空间浪费,没有左子树时存储左子结点指针的空间浪费,没有右子树时存储右子结点指针的空间浪费。在一个有n个结点的二叉树中,存在2n个指针域,但是只有n-1条分支线路,所以一定存在n+1个空指针,如12个结点的二叉树中,就会有13个空指针域,空间资源浪费是非常严重的。我们可以将这些空指针域利用起来,用于存放前驱和后继结点的指针(空的左指针域用于存储前驱结点指针,空的右指针域用于存放后驱结点指针),这些被利用起来存放前驱结点和后继结点的指针称为线索,加上线索的二叉链表称为线索链表,相应的二叉树称为线索二叉树。对于某节点的前驱结点和后序结点分别是什么结点,根据遍历出来的顺序指定,这个遍历可以是前序遍历、中序遍历或者后序遍历均可。当然,在具体的实现中,必须增添一个标志位用于标识指针域存储的是左右子结点的指针还是前驱后序的指针。二叉树按照某种次序遍历使其变为线索二叉树的过程称为线索化。显然,线索二叉树在找某个结点的前后结点时会方便很多,尤其是叶子结点的前驱或后序结点,不使用线索二叉树时往往需要重新遍历整棵树才能找到叶子结点的前驱结点或后序结点。
七.赫夫曼树
1.原理:
下面是一个简单的决策树(来自于博客:吹弹牛皮之Unity AI决策-行为树 - 云+社区 - 腾讯云 (tencent.com))
在决策树中,每一个叶子结点都是一种AI行为。下图我给每个行为添加权重(权重代表经过测试后得到的不同行为执行次数的比例关系,这个是随意赋值的,不用在意合不合适,仅作说明使用):
当不同的行为有了权重后我们就可以知道执行不同行为的可能性大小,也就可以知道执行不同行为需要运行判断的次数。如执行Unlock Door这个行为,我们会发现比执行Close Door这个行为的可能性大,但是要进行Unlock Door这个行为需要进行更多的校验判断,这样看来就更加浪费性能,还可以优化。我们可以将从树中一个结点到另一个结点的分支数目称为路径长度,如从根结点到Unlock Door结点的路径长度是4,而所有结点的带权路径长度之和最小的二叉树称作赫夫曼树。计算赫夫曼树是可以将结点的权重依次罗列,将权重小的结点放到离根结点尽量远的位置,这样可以依次构建出赫夫曼树。对于赫夫曼树的构建参考:哈夫曼树(赫夫曼树、最优树)详解 (biancheng.net)。
赫夫曼树可以用于进行数据压缩。在一个文件中,不同的二进制编码串出现的次数是不同的,如一个中文文本文档中,像“的”、“我”等等常用的汉字出现的频率是比较高的,而一些生僻字出现的频率几乎为0,那么我们可以计算出不同汉字编码出现的比重,使用赫夫曼树来重新规划它们,然后将规划好的叶子结点(每个叶子结点都是一个汉字编码)重新编码,这样就可以节约编码的字符串长度。下面图片来源于:大话数据结构电子书。