因为树结构中的内容较多,所以分开介绍。这篇主要介绍二叉树。
目录
一. 霍夫曼树(Huffman Tree)
1.1 霍夫曼树定义
1.2 霍夫曼树的构造
1.3 霍夫曼算法描述
1.4 霍夫曼树的应用
1.4.1 霍夫曼编码
1.4.2 其它
二. 二叉搜索树
2.1 二叉搜索树定义特征
2.2 二叉搜索树的原理
2.3 查找二叉搜索树
2.3.1 查找
2.3.2 最小关键字元素和最大关键字元素
2.3.3 后继和前驱
2.4 插入和删除
2.4.1 插入
2.4.2 删除
2.5 随机构建二叉搜索树
5.6 二叉树的使用及问题
三. 平衡二叉搜索树(AVL树)
四. 红黑树
4.1 红黑树定义性质
4.2 红黑树操作
4.3 红黑树(RBT)与AVL树的区别
4.4 红黑树的应用
举个例子说明下。现在考试的结果都以百分制来表示学科的成功。但这带来了一个弊端,那就是很容易让学生、家长和老师以分取人,这样就让分数代表了一切。这样也容易间接导致92与95这种分差很接近的学生受到不公平的待遇,这样自然是不公平的。于是在如今提倡素质教育的背景下,很多学科,特别是小学的学科成绩很多改作了优秀、良好、中等、及格和不及格这样的评测结果,不再通报具体的学科分数。
但这对老师而言,在对席卷评分时,显然不能凭感觉给出优良或及格等,必须要给出每个评测结果对应的分数范围。这样老师才好按照试卷的分数,根据每个评测结果的范围对照,最后给出评测试结果。如下代码(a 表示分数,b 表示评测结果)。
if (a < 60) {
b = "不及格";
} else if (a >= 60 && a < 70) {
b = "及格";
} else if (a >= 70 && a < 80) {
b = "中等";
} else if (a >= 80 && a < 90) {
b = "良好";
} else if (a >= 90 && a <= 100) {
b = "优秀";
}
上面的逻辑没有问题,也很简单。可是假设有一种情况:一张好的试卷应该是让学生成绩大部分处于中等或良好的范围,优秀和不及格都应该比较少。上面这样的程序,使得所有的成绩都需要先判断是否及格,再逐级而上得到结果。当输入量很大的时候,这个算法的效率还是有问题的。虽然就试卷来说不一定有这假设,但在生活中其实是有不少这样的情况的。
如下表所示,70分以上的学生大约占到了总数的85%,但这些都需要经过3次以上的判断才可以得出结果,显然不太合适。虽然也可以通过调整代码来优化,但在遇到其它的一些情况时,并不是屡试不爽。
仔细观察后发现,中等成绩(70~79)比例最高,其实是良好成功,不及格所占的比例最少。把上面的结果以二叉树重新进行分配,如下图所示。这样来看效率是提高了一些。那到底提高了多少呢?这样的二叉树又如何设计出来的?
先把下面的两棵二叉树简化成叶结点带权的二叉树。其中A表示不及格、B表示及格、C表示中等、D表示良好、E表示优秀。每个叶结点的分支线上数字刚好就是分数在这个评测范围内的占比。
路径长度
从树中的一个结点到另一个结点之间的分支构成两个结点之间的路径,路径上的分支数目称做路径长度。上图中的二叉树a中,根结点到结点D的路径长度就是4,上图中的二叉树b中根结点到结点D的路径长度为2。一棵树的路径长度就是从树根结点到每一个结点的路径长度之和。二叉树a的树路径长度为1 + 1 + 2 + 2 + 3 + 3 + 4 +4 = 20,二叉树b的路径长度为 1 + 1 + 2 + 2 + 2 + 2 + 3 + 3 = 16。若根结点的层数为1,则从根结点到第 d 层结点的路径长度为 d-1。
带权的路径长度
若考虑到带权的结点(这里的权可以理解为赋给树中每个结点的一种带有某种含义的数值),结点的带权路径长度为从该结点到树根结点之间的路径长度与结点上权的乘积。树的带权路径长度为树中的所有叶结点的带权路径长度之和。假设有 n 个权值{},构造一棵有 n 个叶结点的二叉树,每个叶结点带权为,每个叶结点的带权路径长度为路径长长 * 权,,其中带权路径长度WPL最小的二叉树称做霍夫曼树(Huffman Tree),也称赫夫曼树、哈夫曼树、最优二叉树。霍夫曼树也是一种有序树。
根据上面的定义描述,上图中二叉树 a 的WPL = 5*1 + 15*2 + 40*3 + 30*4 + 10 * 4 = 315,二叉树 b 的WPL = 5*3 + 15*3 + 40*2 + 30*2 + 10*2 = 220。从WPL的结果意味着:若需要知道100个学生百分制成绩的评测结果,用二叉树 a 的判断方法需要做315次比较;而二叉树 b 则只需要做220次判断。从判断次数来看,差的比较大,接近1/3,由引可见性能提高的不只是一点点。
下面来看看二叉树 b 是如何构造出来的。根据上面的成绩表构造霍夫曼树 的步骤如下:
上图中的右图,其二叉树的带权路径长度WPL = 40*1 + 30*2 + 15*3 + 10*4 + 5*4 = 205。比上面的二叉树 b 的WPL值220还少了15,由此来看上图中右图的二叉树才是最优的霍夫曼树。
但现实往往比理想要复杂很多。刚构造的优霍夫曼树树,由于每次判断都要进行两次比较(如根结点就是a >= 70 && a < 80,两次比较后才能得到结果),所以从总体性能上看,反而不如下图的高。
通过上面的构造步骤,可以得出霍夫曼树的算法描述。
在日常办法中常遇到的文件传输,如果文件很大一般先进行压缩。尽管以现在最新技术来说,编码已经很好很强大,但这都来自于曾经的技术积累。这里不得不提一下霍夫曼编码,因为它是最基本的压缩编码方法。它于1952年由美国数学家霍夫曼(David Huffman)发明。为了纪念他的成就,于是把他在编码中用到的特殊的二叉树称之为霍夫曼树。
霍夫曼当初研究这种树的目的主要是为了解决当时远距离通信(主要是电报)的数据传输的最优化问题。比如有一段文字内容为“BADCADFEED”要网络传输给其他人,显然用二进制的数字(0和1)来表示是很自然的想法。现在这段文字只有6个字母ABCDEF,那可以用相应的二进制数据来表示,如下图所示。
用二进制表示后,真正传输的数据就是“001000011010000011101100100011”,对方接收时可以按照3位数据分段来进行译码。但对一篇内容很长的文章,这样的二进制串就太长了。而且事实上,不管是英文、中文还是其他语言,字母或汉字的出现频率是不相同的。
假设有6个字母的频率为A 27,B 8,C 15,D 15,E 30,F 5,频率和正好是100%。这意味着完全可以重新按照霍夫曼树来规划它们。
如下图所示,左图为构造霍夫曼树的过程中的权值显示。右图为将权值左分支改为0,右分支改为1后的霍夫曼树。
此时对6个字母用其从树根到叶子所经过的路径的0或1编码,可以得到如下表所示这样的定义。
将文字内容为“BADCADFEED”再次编码,对比可以看出使用霍夫曼树来表示后的结果串变小了。
新编码后的数据被压缩了,节约了大约17%的存储或传输成本。随着字符的增加和多字符权重的不同,这种压缩会更加显出其优势。
反过来,当接收到1001010010101001000111100这样经过压缩后的编码时,该如何把它解码呢?编码中非0即1,长短不等的话其实很容易混淆的,所以若要设计长短不等的编码,则必须是任一个字符编码都不是另一个字符的编码的前缀,这种编码称做前缀编码。
仔细观察会发现,上表的编码就不存在容易与1001、1000混淆的“10”和“100”编码。但仅仅是这样也不足以方便的解码,因此在解码时还是要用到霍夫曼树,即发送方和接收方必须要约定好同样的霍夫曼编码规则。
在接收到1001010010101001000111100时,由约定好的霍夫曼树可知,1001得到第一个字母是B,接下来01意味着第二个字符是A,如下图所示,其余的字母也可以相应的得到,这样就解码成功了。
一般地,设需要编码的字符集为{},各个字符在电文中出现的次数或频率集合为{},以作为叶结点,以作为相应叶结点的权值来构造一棵霍夫曼树。规定霍夫曼树的左分支代表0,右分支代表1,则从根结点到叶结点所经过的路径分支组成的0和1的序列便是该结点对应字符的编码,这就是霍夫曼编码。
霍夫曼树,除了霍夫曼编码外,还有一些其它方面的应用,上面举例中说的成绩转换问题就是其中之一。
二叉搜索树的基本操作所花费的时间与这棵树的高度成正比。对于有 n 个结点的一棵完全二叉树来说,这些操作的最坏运行时间为O(lgn)。因此这样一棵树上的动态集合的基本操作的平均运行时间是Θ(lgn)。
一棵二叉搜索树是以一棵二叉树来组织的,如下图所示。这样的一棵树可以使用一个链表来表示,其中每个结点就是一个对象。除了关键字和卫星数据外,每个结点还包含属性left、right、parent,它们分别指向结点的左子结点、右子结点和双亲。根结点是树中唯一双新引用为空的结点。
上图(a)是一棵包含6个结点、高度为2的二叉搜索树。树根的关键字为6,在其左子树中有关键字2、5和5,它们均不大于6,而在其右子树中有关键字7和8,它们均不小于6。图(b)是一棵包含相同关键字、高度为4的低效二叉搜索树。
二叉搜索树,Binary Search Tree,也称二叉查找树、有序二叉树、二叉排序树。使用一棵二叉搜索树既可以作为一个字典又可以作为一个优先队列。 它有以下特征:
二叉搜索树的查找过程和平衡二叉树类似,通常采用二叉链表作为二叉搜索树的存储结构。中序遍历二叉树可得到一个关键字的有序排列,一个无序排列可以通过构造一棵二叉搜索树变成一个有序序列,构造树的过程即为对无序序列进行排序的过程。每次插入的新的结点都是二叉搜索树上新的叶结点,在进行插入操作时,不必移动其它结点,只需要改动某个结点的指针,由空变为非空即可。搜索、插入、删除的复杂度等于树高,为O(logn)。
经常需要查找一个存储在二叉搜索树中的关键字。除了search操作外,二叉搜索树还能支持如minimum(最小元素)、maximum(最大元素)、successor(前驱元素)和predecessor(后继元素)的查找操作。这里将讨论这些操作,并且说明在任何高度为 h 的二叉搜索树上,如何在O(h)时间度执行完每个操作。
使用下面的过程在一棵二叉搜索树中查找一个具有给定关键字的结点。输入一个指向树根的结点和一个关键字,若这个结点存在,则返回一个指向关键字的结点的指针,否则返回空。如下图的左图。
这个过程从树根开始查找,并沿着这棵树中的一条简单路径向下进行。如下图的右图,对于遇到的每个结点 x,比较关键字 k 与结点 x 的关键字。若两个关键字相等,查找终止。若 k 小于结点 x 的关键字,则继续在 x 的左子树中进行查找,因为二叉搜索树的性质中蕴涵了 k 不可能被存储于右子树中(二叉搜索树中的左子树的所有结点都要小于其根结点的值)。对称地,若 k 大于结点 x 的关键字,则继续在 x 的右子树中进行查找。从树根开始递归期间遇到的结点就形成了一条向下的简单路径。所以运行时间为O(h),其中 h 是树的高度。
如上图的右图,为了查找这棵树中关键字为13的结点,从树根开始沿着15—>6—>7—>13路径进行查找。这棵树中最小的关键字为2,它是从树根开始一直沿着左边指针被找到的。最大的关键字为20,是从树根开始一直沿着右边指针被找到的。关键字15的结点的后继是关键字为17的结点,因为它是15的右子树中最小的关键字。关键字为13的结点没有右子树,因此它的后继是最低的祖先并且其右子结点也是一个祖先。这种情况下,关键字为15的结点就是它的后继。
可以采用while的循环展开递归,用一种迭代方式重写这个过程。对大多数计算机,迭代版本的效率要高得多。
通过树根开始沿着左子结点指针直到遇一个空元素,总能在一棵二叉搜索树中找到一个元素,上面的二叉搜索树图例就是这样的。下面的过程返回了一个指向在以给定结点 x 为根的子树中的最小元素的指针,这里假设不为空。
二叉搜索树的性质保证了minimum是正确的。若结点 x 没有左子树,那么由于 x 右子树中的每个关键中都不小于 x 的关键字,则认为以 x 为根的子树中的最小关键字是 x 的关键字。若 x 有左子树,则以 x 为根的子树中的最小关键字一定在以 x 的右子结点的子树中。
有时候需要按中序遍历的次序查找它的后继。若所有的关键字都不相同,则一个结点 x 的后继是大于 x 的关键字的最小关键字的结点。二叉搜索树的结构允许通过没有任何关键字的比较来确定一个结点的后继。若后继存在,下面的过程将返回一棵二叉搜索树中的结点 x 的后继;若 x 是这棵树中的最大关键字,则返回空。
把上图的伪代码分为两种情况。若结点 x 的右子树非空,那么 x 的后继恰是 x 右子树中的最左结点(伪代码的第2行)。例如在上面的二叉搜索树图例中,关键字为15的结点的后继就是关键字为17的结点。
另一方面,若结点 x 的右子树非空且有一个后继 y,那么 y 就是 x 的有右子结点的最底层祖先,并且它也是 x 的一个祖先。在上面的二叉搜索树图例中,关键字为13的结点的后继是关键字为15的结点。为了找到 y,只需简单的从 x 开始沿树而上直到遇到一个其双亲有左子结点的结点。上面伪代码中的第3~7行正是这种情况。
插入一个新结点带来的树修改要相对简单些,而删除的处理有些复杂。插入和删除操作都要保证二叉搜索树的性质不变。
将一个新值 v 插入到一棵二叉搜索树T中,该过程以结点 z 作为输入,其中 z 的关键字等于 v,z 的左子结点和右子结点为空。这个过程要修改T和 z 的某些属性,来把 z 插入到树中的相应位置上。
从树根开始,指针 x 记录了一条向下的简单路径,并查找要替换的输入项 z 的空结点。该过程保持遍历指针 y 作为 x 的双亲。初始化后,第3~7行的while循环使得这两个指针沿树向下移动,向左或向移动取决于 z 的关键字和 x 的关键字的比较,直到 x 变为空。这个空结点占据的位置就是输入项 z 要放置的地方。需要遍历指针 y,是因为找到空结点时要知道 z 属于哪个结点。第8~13行设置了相应的指针,使得 z 插入其中。插入到一棵高度为 h 的树上的运行时间为O(h)。
如上图,将关键字为13的数据项插入到二叉搜索树中,浅色阴影结点指示了一条从树根向下到要插入数据项位置处的简单路径。虚线表示了为插入数据项而加入的树中的一条链。
从一棵二叉搜索树T中删除一个结点 z 的整个策略分为三种基本情况,但只有一种情况有点棘手。
从一棵二叉搜索树T中删除一个结点 z ,这个过程取指向 T 和 z 的指针作为输入参数。考虑到下图中的4种情况,它与前面概括的三种情况有些不同。
为了在二叉搜索树内移动子树,定义一个子过程 plan,它是用另一棵子树替换一棵子树并成为其双亲的子结点。当 plan 用一棵以 v 为根的子树来替换一棵以 u 为根的子树时,结点 u 的双亲就变为结点 v 的双亲,并且最后 v 成为 u 的双亲的相应子结点了。
第1~2行处理 u 是 T 树根的情况。若不是,u 是其双亲的左子结点或右子结点。若 u 是一个右子结点,第3~4行负责u.p.left的更新;若 u 是一个右子结点,第5行更新u.p.right。允许 v 为空,若 v 非空时,第6~7行更新v.p。注意到,此方法并没有处理 v.left 和 v.right 的更新;这些更新都由此函数的调用者来负责。
利用现成的TRANSPLANT(上图中的函数名)过程,来组织从二叉搜索树 T 中删除结点 z 的删除过程:
上面提到了二叉搜索树上的每个基本操作都能在O(h)时间内完成,其中 h 为这棵树的高度。然后,随着元素的插入和删除,二叉搜索树的高度是变化的。当树是由插入操作单独生成的,分析就会变得容易很多。因此定义 n 个关键字的一棵随机构建二叉搜索树(randomly built binary search tree)为随机次序插入这些关键字到一棵初始的空树中而生成的树,这里输入关键字的 n!个排列中的每个都是等可能地出现。
因为二叉搜索树遍历的结果是单调的,可以用来进行二分查找,左小右大,在查找时若关键字大于根节点,那只需要从一个分支查下去即可。但二叉搜索树在插入或删除结点时,会改变二叉树的结构,多次插入和删除后,可能变成下面这样的结构。
这样再来查找,就是线性的了。同样的关键字集合导致不同的树结构。可以明显看出,这棵是非常不平衡的。比如查找53这个数,若是平衡的树(就是有左右两个子结点),那从树的层级角度来看,肯定不用进入更深的层级来查找。所以说,二叉搜索树的一个很大问题就是树的平衡性问题,因为它无法保证树的平衡,给查找带来了问题。下面就来看几种平衡的树。
含有相同节点的二叉搜索树可以有不同的形态,而二叉搜索树的平均查找长度与树的深度有关,所以需要找出一个查找平均长度最小的一棵树,那就是平衡二叉树。平衡二叉树,也叫AVL树,它是一种结构平衡的二叉搜索树,也可为空树,它有以下几点特征:
如上图所示,图(a)为平衡树,图(b)为非平衡树。AVL树是严格平衡树,因此在增加或删除节点时,根据不同情况,旋转的次数比红黑树要多。
上面介绍了二叉搜索树,它可以支持任何一种基本动态集合操作,时间复杂度为O(h)。h 为树的高度,因此,若搜索树的高度较低时,这些集合操作会执行的较快。然而,若树的高度较高时,这些集合操作可能并不比在链表上执行的快。红黑树(red-black tree)是许多“平衡”搜索树中的一种,可以保证在最坏情况下基本动态集合操作的时间复杂度为O(lgn)。
红黑树是一种自平衡二叉查找树,它于1972年由鲁道夫 贝尔发明,他称之为“对称二叉B树”。它在平衡二叉树的基础上每个结点又增加了一个颜色的属性,节点的颜色只能是红色或者黑色。通过对任何一条从根到叶子的简单路径上各个结点的颜色进行约束,确保没有一条路径会比其他路径长出2倍,因而是近似于平衡的。其左右子树的高度差有可能超过1。所以它不是严格意义上的平衡二叉树。
树中每个结点包含5个属性:颜色(color)、key(关键字)、left(左子结点)、right(右子结点)和p(父结点)。一棵红黑树是满足以下红黑性质的二叉搜索树:
如上图,表示一棵红黑树(忽略了叶结点的空结点)。从某个结点 x 出发(不含该结点)到达一个叶结点的任意一条简单路径上的黑色结点个数称为该结点的黑高(black-height),记为 bh(x)。从该结点出发的所有下降到其叶结点的简单路径的黑结点个数都相同,于是定义红黑树的黑高为其根结点的黑高。
可以将结点 x 的空子结点视为一个普通结点,其父结点为 x。常将注意力放在红黑树的内部结点上,因为它们存储了关键字的值。一棵有 n 个内部结点的红黑树的高度至多为2 lg(n+1)。假设树的高度为 h,根据性质4可知,从根到叶结点(不包含根结点)的任何一条简单路径上都至少有一半的结点为黑色。因此,根的黑高至少为 h/2,于是有,把1移到不等式的左边,再两边取对数,得到,或。动态集合操作查找、最小值、最大值、前驱和后继可在红黑树上在O(lgn)时间内执行。
搜索树操作插入和删除在含有 n 个关键字的红黑树上,时间复杂度为O(lgn)。由于这两个操作对树做了修改,结果可能违反了红黑性质。为了维护这些性质,必须要改变树中某些结点的颜色以及指针结构。
指针结点的修改是通过旋转(ratation)来完成的,这是一种能保持二叉搜索树性质的搜索局部操作。下图给出了两种旋转:左旋和右旋。当在某个结点 x 上做左旋转时,假设它的右子结点是 y,x 可以为其右子结点不为空的树内的任意结点。左旋以 x 到 y 链为“支轴”进行。它使 y 成新子树的根结点, x 成为 y 的左子结点, y 的左子结点成为 x 的右孩子。
下面是左旋转的伪代码。假设 x.right T.nil,且根结点的父结点为 T.nil。
下图给出了一个左旋转操作修改二叉搜索树的例子。左旋转和右旋转都在O(1)时间内完成 ,在旋转操作内只有指针改变,其它所有属性都保持不变。
AVL树是严格平衡树,因此在增加或删除节点时,根据不同情况,旋转的次数比红黑树要多;而红黑树是弱平衡的,用非严格的平衡来换取增删结点时旋转次数的降低。所以简单来说,若搜索的次数远远大于插入和删除次数,那建议使用AVL树,若搜索、插入、删除次数差不多,那建议使用红黑树。
更通俗的说,平衡二叉树的优点在于快速查找,搜索任意元素者是O(logn),一般插入和删除也是O(logn)。平衡二叉树相对于二叉搜索树,是较为平衡的,能把查找时间控制在O(logn),但仍不是最佳的。因为平衡树要求每个节点的左右子树的高度差不超过1,这个要求有点严格了,导致每次进行插入或删除操作后,几乎都会破坏这个规则。这时需要通过左旋和右旋来进行调整,使这棵树成为一棵平衡二叉树。若在插入和删除操作频繁的情况下,就意味着要对树进行频繁的调整,这样性能自然就不高了。
由于红黑树的这些性质,使得它能在最坏的情况下,仍能以O(logn)的时间复杂度查找。若仅从查找的效率来说,平衡树比红黑树快,因为它的规则就是有利于查找的。可以说,红黑树解决了平衡树在插入、删除等操作下需要频繁调整树结构的情况。
Java集合容器中的HashMap、TreeMap,内部就使用了红黑树。
未完待续.....!!!