6.8 遍历二叉树
假设,我手头有20张100元的和2000张1元的奖券,同时洒向了空中,大家比赛看谁最终捡的最多。如果是你,你会怎么做?
相信所有同学都会说, 一定先捡100元的。道理非常简单,因为捡一张100元等于1元的捡100张,效率好得不是一点点。所以可以得到这样的结论,同样是捡奖券,在有限时间内,要达到最高效率,次序非常重要。对于二叉树的遍历来讲,次序同样显得很重要
。
二叉树的遍历(traversing binary tree)是指从根结点出发,按照某种次序依次访问二叉树中所有结点.使得每个结点被访问一次旦仅被访问一次。
这里有两个关键词:访问和次序。
访问其实是要根据实际的需要来确定具体做什么,比如对每个结点进行相关计算,输出打印等,它算作是一个抽象操作。在这里我们可以简单地假定就是输出结点的数据信息。
二叉树的遍历次序不同于线性结构,线性结构最多也就是从头至尾、循环、双向等简单的遍历方式。然而树的结点之间不存在唯一的前驱和后继关系,在访问一个结点后,下一个被访问的结点面临着不同的选择 。 就像你人生的道路上,高考填志愿要面临哪个城市、哪所大学、具体专业等选择,由于选择方式的不同,遍历的次序就完全不同了 。
6.8.2 二叉树遍历方法
二叉树的遍历方式可以很多,如果我们限制了从左到右的习惯方式,那么主要就分为四种 :
1.前序遍历
规则是若二叉树为空,则空操作返回,否则先访问根结点,然后前序遍历左子树,再前序遍历右子树。如图6-8-2所示,遍历的顺序为:ABDGHCEIF。
2. 中序遍历
规则是若树为空,则空操作返回,否则从根结点开始(注意并不是先访问根结点),中序遍历根结点的左子树,然后是访问根结点,最后中序遍历右子树。如图6-8-3所示, 遍历的顺序为:GDHBAEICF。
3.后序遍历
规则是若树为空,则空操作返回,否则从左到右先叶子后结点的方式遍历访问左右子树,最后是访问根结点。如图6-8-4所示,遍历的顺序为:GHDBIEFCA。
4.层序遍历
规则是若树为空,则空操作返回,否则从树的第一层,也就是根结点开始访问,从上而下逐层遍历,在同一层中,按从左到右的顺序对结点逐个访问。如图6-8-5所示,遍历的顺序为: ABCDEFGHI。
有同学会说,研究这么多遍历的方法干什么呢?
我们用图形的方式来表现树的结构,应该说是非常直观和容易理解,但是对于计算机来说,它只有循环、判断等方式来处理,也就是说,它只会处理线性序列,而我们刚才提到的四种遍历方法,其实都是在把树中的结点变成某种意义的线性序列,这就给程序的实现带来了好处。
另外不同的遍历提供了对结点依次处理的不同方式,可以在遍历过程中对结点进行各种处理。
6.8.3 前序遍历算法
二叉树的定义是用递归的方式,所以,实现遍历算法也可以采用递归,而且极其简洁明了。先来看看二叉树的前序遍历算法。代码如下:
Java实现代码:
/**前序遍历*/
static void PreOrderTraverse(BinTreeList[] t,BinTreeList tree){
if(null == t)
return;
System.out.print(tree.data+" ");/*显示当前结点数据*/
if(tree.leftChild != -1)
PreOrderTraverse(t,t[tree.leftChild]);/*接着前序遍历左子树*/
if(tree.rightChild != -1)
PreOrderTraverse(t,t[tree.rightChild]);/*最后遍历右子树*/
}
/**定义二叉树类*/
class BinTreeList{
String data;//数据
int leftChild,rightChild;//左右孩子
@Override
public String toString() {
return "BinaryList [data=" + data + ", leftChild=" + leftChild + ", rightChild=" + rightChild + "]";
}
}
假设我们现在有如图6-8-6 这样一棵二叉树T。这树已经用二叉链表结构存储在内存当中。
那么当调用PreOrderTraverse(T)函数时,我们来看看程序是如何运行的 。
1.调用PreOrderTraverse(T), T根结点不为nu1l,所以执行printf,打印字母A,如图6-8-7所示。
2.调用PreOrderTraverse(T->lchild);访问了A结点的左孩子,不为null,执行printf显示字母B,如图6-8-8所示。
3.此时再次递归调用PreOrderTraverse(T->lchild);访问了B结点的左孩子,执行printf显示字母D,如图6-8-9所示。
4.再次递归调用PreOrderTraverse(T->lchild);访问了D结点的左孩子,执行
printf显示字母H,如图6-8-10所示。
5.再次递归调用PreOrderTraverse(T->lchild);访问了H结点的左孩子,此时因为H结点无左孩子,所以T==null,返回此函数,此时递归调用PreOrderTraverse(T->rchild);访问了H结点的右孩子,printf显示字母K,如图6-8-11所示。
6. 再次递归调用PreOrderTraverse(T->lchild);访问了K结点的左孩子,K结点无左孩子,返回,调用PreOrderTraverse(T->rchild);访问了 K 结点的右孩子,也是null,返回。于是此函数执行完毕,返回到上一级递归的函数(即打印H结点时的函数),也执行完毕,返回到打印结点D时的函数,调用PreOrderTraverse (T->rchild) ;访问了D结点的右孩子,不存在,返回到B结点,调用PreOrderTraverse(T->rchild);找到了结点E,打印字母E,如图6-8-12所示。
7.由于结点E没有左右孩子,返回打印结点B时的递归函数,递归执行完毕,返回到最初的PreOrderTraverse,调用PreOrderTraverse(T->rchild);访问结点A的右孩子,打印字母C,如图6-8-13所示。
8.之后类似前面的递归调用,依次继续打印F、I 、G 、1,步骤略。
综上,前序遍历这棵二叉树的节点顺序是:ABDHKECFIGJ。
6.8.4中序遍历算法
那么二叉树的中序遍历算法是如何呢?哈哈,别以为很复杂,它和前序遍历算法仅仅只是代码的顺序上的差异。
Java代码实现:
/**中序遍历*/
static void InOrderTraverse(BinTreeList[] t,BinTreeList tree){
if(null == t )
return;
if(tree.leftChild != -1)
InOrderTraverse(t,t[tree.leftChild]);/*先中序遍历左子树*/
System.out.print(tree.data+" ");/*显示当前结点数据*/
if(tree.rightChild != -1)
InOrderTraverse(t,t[tree.rightChild]);/*最后遍历右子树*/
}
换句话说,它等于是把调用左孩子的递归函数提前了,就这么简单。 我们来看看当调用InOrderTraverse(T)函数时,程序是如何运行的。
1.调用InOrderTraverse(T),T的根结点不为null,于是调用InOrderTraverse(T->lchild);访问结点B。当前指针不为null,继续调用InOrderTraverse(T->lchild);访问结点D。不为null,继续调用InOrderTraverse(T->lchild);访问结点H.继续调用InOrderTraverse(T->lchild);访问结点H的左孩子,发现当前指针为null,于是返回。打印当前结点H,如图6-8-14所示。
2.然后调用InOrderTraverse(T->rchild);访问结点 H 的右孩子K,因结点K无左孩子,所以打印K,如图6-8-15所示。
3. 因为结点K没有右孩子,所以返回。打印结点H函数执行完毕,返回。打印字母D,如图6-8-16所示。
4.结点D无右孩子,此函数执行完毕,返回。打印字母B,如图6-8-17所示。
5 . 调用
InOrderTraverse(T->rchild)
;访问结点B的右孩子E,因结点E无左孩子,所以打印E,如图6-8-18所示。
6.结点E无右孩子,返回。结点B的递归函数执行完毕,返回到了最初我们调用InOrderTraverse的地方,打印字母A,如图6-8-19所示。
7.再调用InOrderTraverse(T->rchild);访问结点A的右孩子C,再递归访问结点C的左孩子F,结点F的左孩子I。因为I无左孩子,打印I,之后分别打印F、C、G、I。步骤省略。
综上,中序遍历这棵二叉树的节点顺序是:HKDBEAIFCGj。
6.8.5后序遍历算法
那么同样的,后序遍历也就很容易想到应该如何写代码了。
Java代码实现:
/**后续遍历*/
static void PostOrderTraverse(BinTreeList[] t,BinTreeList tree){
if(null == t )
return;
if(tree.leftChild != -1)
PostOrderTraverse(t,t[tree.leftChild]);/*先后续遍历左子树*/
if(tree.rightChild != -1)
PostOrderTraverse(t,t[tree.rightChild]);/*最后遍历右子树*/
System.out.print(tree.data+" ");/*显示当前结点数据*/
}
如图6-8-20所示,后序遍历是先递归左子树,由根结点A→B→D→H,结点H无左孩子,再查看结点H的右孩子K,因为结点K无左右孩子,所以打印K,返回。
最终,后序遍历的结点的顺序就是:KHDEBIFJGCA。同学们可以自己按照刚才的办法得出这个结果。
6.8.6 推导遍历结果
有一种题目为了考查你对二叉树遍历的掌握程度,是这样出题的。已知一棵二叉树的前序遍历序列为ABCDEF,中序遍历序列为CBAEDF,请问这棵二叉树的后序遍历结果是多少?
对于这样的题目,如果真的完全理解了前中后序的原理,是不难的。
三种遍历都是从根结点开始,前序遍历是先打印再递归左和右。所以前序遍历序列为
A
BCDEF,第一个字母是A被打印出来,就说明A是根结点的数据。再由中序遍历序列是CB
A
EDF,可以知道C和B是A的左子树的结点,E、D、F是A的右子树的结点,如图6-8-21所示。
然后我们看前序中的C和B,它的顺序是A
BC
DEF,是先打印B后打印C,所以B应该是A的左孩子,而C就只能是B的孩子,此时是左还是右孩子还不确定。再看中序序列是
CB
AEDF,C是在B的前面打印,也就说明C是B的左孩子,否则就是右孩子了,如图6-8-22所示。
再看前序中的E、D、F,它的顺序是ABC
DEF
,那就意味着D是A结点的右孩子,E和F是D的子孙,注意,它们中有一个不一定是孩子,还有可能是孙子的。再来看中序序列是CBA
EDF
,由于E在D的左侧,而F在右侧,所以可以确定E是D的左孩子,F是D的右孩子。因此最终得到的二叉树是图6-8-23所示。
为了避免推导中的失误,你最好在心中递归遍历,检查一下这棵树的前序和中序遍历序列是否与题目中的相同。
已经复原了二叉树,要获得它的后序遍历结果就是易如反掌,结果是CBEFDA .
但其实,如果同学们足够熟练,不用画这棵二叉树,也可以得到后序的结果,因为刚才判断了A结点是根结点,那么它在后序序列中,一定是最后一个。刚才推导出C是B的左孩子,而B是A的左孩子,那就意味着后序序列的前两位一定是CB。同样的办法也可以得到EFD这样的后序顺序,最终就自然的得到CBEFDA这样的序列,不用在草稿上面树状图了。
反过来,如果我们的题目是这样:二叉树的中序序列是ABCDEFG,后序序列是BDCAFGE,求前序序列 。
这次简单点,由后序的 BDCAFG
E
,得到E是根结点,因此前序首字母是E。
于是根据中序序列分为两棵树ABCD和FG,由后序序列的
BDCA
FGE,知道A是E的左孩子,前序序列目前分析为EA。
再由中序序列的
ABCD
EFG,知道BCD是A结点的右子孙,再由后序序列的
BDC
AFGE知道C结点是A结点的右孩子,前序序列目前分析得到EAC。
中序序列A
B
C
D
EFG,得到B是C的左孩子,D是C的右孩子,所以前序序列目前分析结果为EACBD。
由后序序列BDCA
FG
E,得到G是E的右孩子,于是F就是G的左孩子。如果你是在考试时做这道题目,时间就是分数、名次、学历,那么你根本不需关心F是G的左还是右孩子,前序遍历序列的最终结果就是EACBDGF。
不过细细分析,根据中序序列ABCDE
FG
,是可以得出F是G的左孩子。
从这里我们也得到两个二叉树遍历的性质。
•己知前序遍历序列和中序遍历序列,可以唯一确定一棵二叉树。
•已知后序遍历序列和中序遍历序列,可以唯一确定一棵二叉树 。
但要注意了,已知前序和后序遍历,是不能确定一棵二叉树的
, 原因也很简单,比如前序序列是ABC,后序序列是CBA。 我们可以确定A一定是根结点,但接下来,我们无法知道,哪个结点是左子树,哪个是右子树。这棵树可能有如图6-8-24所示的
四种可能。
6.9 二叉树的建立
说了半天,我们如何在内存中生成一棵二叉链表的二叉树呢?树都投有,哪来遍历。所以我们还得来谈谈关于二叉树建立的问题。
如果我们要在内存中建立一个如图6-9-1左图这样的树,为了能让每个结点确认是否有左右孩子,我们对它进行了扩展,变成图6-9-1右图的样子,也就是将二叉树中每个结点的空指针引出一个虚结点,其值为一特定值,比如"#"。
我们称这种处理后的二叉树为原二叉树的扩展二叉树
。扩展二叉树就可以做到一个遍历序列确定一棵二叉树了。比如图6-9-1的前序遍历序列就为AB#D##C##。
有了这样的准备,我们就可以来看看如何生成一棵二叉树了。假设二叉树的结点均为一个字符,我们把刚才前序遍历序列AB#D##C##用键盘挨个输入。实现的算法如下:
/*按前序输入二叉树中结点的值(一个字符)*/
/*#表示空数,构造二叉树表表示二叉树T*/
void CreateBiTree(BiTree *T)
{
TElemType ch;
scanf("%c",&ch);
if(ch == '#')
*T = NULL;
else
{
*T = (BiTree)malloc(sizeof(BiTNode));
if(!*T)
exit(OVERFLOW);
(*T) -> data = ch;/*生成根结点*/
CreateBiTree(&(*T) -> lchild);/*构造左子树*/
CreateBiTree(&(*T) -> rchild);/*构造右子树*/
}
}
实现的Java代码如下:
public class CreateBinaryTree {
public static void main(String[] args) {
// TODO Auto-generated method stub
BinTree bt = new BinTree();
PreOrderTraverse(getTree(bt));
}
static BinTree getTree(BinTree bt){
Scanner sc = new Scanner(System.in);
String ch = sc.next();//输入一个字符
if("#".equals(ch))
return bt = null;
else{
if(null == bt){
bt = new BinTree();
}
if(null == bt.lchild || null == bt.rchild){
bt.lchild = new BinTree();
bt.rchild = new BinTree();
}
bt.data = ch;
getTree(bt.lchild);
getTree(bt.rchild);
return bt;
}
}
/**前序遍历*/
static void PreOrderTraverse(BinTree t){
if(null == t)
return;
if(null != t.data && !"".equals(t.data))
System.out.print(t.data+" ");/*显示当前结点数据*/
if(null != t.lchild)
PreOrderTraverse(t.lchild);/*接着前序遍历左子树*/
if(null != t.rchild)
PreOrderTraverse(t.rchild);/*最后遍历右子树*/
}
}
class BinTree{
String data;//数据
BinTree lchild ;//左孩子
BinTree rchild ;//右孩子
}
其实建立二叉树,也是利用了递归的原理。只不过在原来应该是打印结点的地方,改成了生成结点、给结点赋值的操作而已。所以大家理解了前面的遍历的话,对于这段代码就不难理解了。
当然,你完全也可以用中序或后序遍历的方式实现二叉树的建立,只不过代码里生成结点和构造左右子树的代码顺序交换一下。另外,输入的字符也要做相应的更改。比如图6-9-1的扩展二叉树的中序遍历字符串就应该为#B#D#A#C#,而后序字符串应该为###DB##CA。
6.10 线索二叉树
6.10.1 线索二叉树原理
我们现在提倡节约型杜会, 一切都应该节约为本。对待我们的程序当然也不例外,能不浪费的时间或空间,都应该考虑节省。我们再来观察图6-10-1,会发现指针域并不是都充分的利用了,有许许多多的"^
",也就是空指针域的存在,这实在不是好现象,应该要想办法利用起来。
首先我们要来看着这空指针有多少个呢?对于一个有n个结点的二叉链表,每个结点有指向左右孩子的两个指针域,所以一共是2n个指针域。而n个结点的二叉树一共有n-1条分支线数,也就是说,其实是存在2n-(n-1)=n+1个空指针域。比如图6-10-1有10个结点,而带有"^
"空指针域为11。这些空间不存储任何事物,白白的浪费着内存的资源。
另一方面,我们在做遍历时,比如对图6-10-1做中序遍历时,得到了HDIBJEAFCG这样的字符序列,遍历过后,我们可以知道,结点I的前驱是D,后继是B,结点F的前驱是A后继是C。也就是说,我们可以很清楚的知道任意一个结点,它的前驱和后继是哪一个。
可是这是建立在已经遍历过的基础之上的。在二叉链表上,我们只能知道每个结点指向其左右孩子结点的地址,而不知道某个结点的前驱是谁,后继是谁。要想知道,必须遍历一次。以后每次需要知道时,都必须先遍历一次。为什么不考虑在创建时就记住这些前驱和后继呢,那将是多大的时间上的节省。
综合刚才两个角度的分析后,我们可以考虑利用那些空地址,存放指向结点在某种遍历次序下的前驱和后继结点的地址。就好像GPS导航仪一样,我们开车的时候,哪怕我们对具体目的地的位置一无所知,但它每次都可以告诉我从当前位置的下一步应该走向哪里。这就是我们现在要研究的问题。
我们把这种指向前驱和后继的指针称为线索,加上线索的二叉链表称为线索链表,相应的二叉树就称为线索二叉树(ThreadedBinaryTree)。
请看图6-10-2,我们把这棵二叉树进行中序遍历后,将所有的空指针域中的rchild,改为指向它的后继结点。于是我们就可以通过指针知道H的后继是D(图中①
),I的后继是B(图中②),J的后继是E(图中③),E的后继是A(图中④),F的后继是C(图中⑤),G的后继因为不存在而指向NULL(图中⑥)。此时共有6个空指针域被利用。
再看图6-10-3,我们将这棵二叉树的所有空指针域中的lchild,改为指向当前结点的前驱。因此H的前驱是NULL(图中①),I的前驱是D(图中②),J的前驱是B(图中③),F的前驱是A(图中④),G的前驱是C(图中⑤)。一共5个空指针域被利用,正好和上面的后继加起来是11个。
通过图6-10-4(空心箭头实线为前驱,虚线黑箭头为后继),就更容易看出,其实线索二叉树,等于是把一棵二叉树转变成了一个双向链表,这样对我们的插入删除结点、查找某个结点都带来了方便。所以我们
对二叉树以某种次序遍历使其变为线索二叉树的过程称做是线索化。
不过好事总是多磨的,问题并没有彻底解决。我们如何知道某一结点的lchild是指向它的左孩子还是指向前驱? rchild是指向右孩子还是指向后继?比如E结点的lchild是指向它的左孩子J,而rchild却是指向它的后继A。显然我们在决定lchild是指向左孩子还是前驱,rchild是指向右孩子还是后继上是需要一个区分标志的。因此,我们在每个结点再增设两个标志域ltag和rtag,注意ltag和rtag只是存放0或1数字的布尔型变量,其占用的内存空间要小于像lchild和rchild的指针变量。结点结构如表6-10-1所示。
其中 :
• ltag为0时指向该结点的左孩子,为1时指向该结点的前驱。
• rtag为0时指向该结点的右孩子,为1时指向该结点的后继。
• 因此对于图6-10-1的二叉链表图可以修改为图6-10-5的样子。
6.10.2 线索二叉树结构实现
由此二叉树的线索存储结构定义代码如下:
java代码实现:
public class ThreadBinTree {
static BinTree pre = new BinTree();//全局变量 始终指向刚刚访问过的结点
public static void main(String[] args) {
BinTree tree = new BinTree();
PreOrderTraverse(inThreading(getTree(tree)));
}
/**将二叉树转化为线索二叉树*/
static BinTree inThreading(BinTree t){
if(null != t && null != t.data){
if(null != t .lChild.data)
inThreading(t.lChild);//递归左子树线索化
if(null ==t.lChild.data){
t.lTag = LinkThread.Thread.getValue();//前驱线索
t.lChild = pre;//左孩子指针指向前驱
}else
t.lTag = LinkThread.Link.getValue();//指向孩子指针
if(null == pre.rChild || null == pre.rChild.data){
pre.rTag = LinkThread.Thread.getValue();//后继线索
pre.rChild = t;
}else
pre.rTag = LinkThread.Link.getValue();//指向孩子指针
pre = t;//保持pre指向 p的前驱
inThreading(t.rChild);//递归右子树线索化
}
return t;
}
/**中序遍历打印线索二叉树*/
static void PreOrderTraverse(BinTree t){
if(null == t || null == t.data)
return;
if(null != t.lChild && LinkThread.Link.getValue().equals(t.lTag))
PreOrderTraverse(t.lChild);/*接着前序遍历左子树*/
if(null != t.data)
System.out.print("["+t.lTag +" "+t.data+" "+t.rTag+"]");/*显示当前结点数据*/
if(null != t.rChild && LinkThread.Link.getValue().equals(t.rTag))
PreOrderTraverse(t.rChild);/*最后遍历右子树*/
}
/**前序遍历生成普通二叉树*/
static BinTree getTree(BinTree bt){
Scanner sc = new Scanner(System.in);
String ch = sc.next();//输入一个字符
if("#".equals(ch))
return bt = null;
else{
if(null == bt){
bt = new BinTree();
}
if(null == bt.lChild || null == bt.rChild){
bt.lChild = new BinTree();
bt.rChild = new BinTree();
}
bt.data = ch;
getTree(bt.lChild);
getTree(bt.rChild);
return bt;
}
}
}
/**二叉树存储结构*/
class BinTree{
String data;//数据
BinTree lChild,rChild;//左右孩子指针
String lTag,rTag;//左右标识位
@Override
public String toString() {
return "BinTree [data=" + data + ", lChild=" + lChild + ", rChild=" + rChild + ", lTag=" + lTag + ", rTag=" + rTag + "]";
}
}
enum LinkThread{
Link("0"),Thread("1");
private LinkThread(String value) {
this.value = value;
}
private String value;
public String getValue() {
return value;
}
public void setValue(String value) {
this.value = value;
}
}
线索化的实质就是将二叉链表中的空指针改为指向前驱或后继的线索。由于前驱和后继的信息只有在遍历该二叉树时才能得到,所以
线索化的过程就是在遍历的过程中修改空指针的过程。
有了线索二叉树后,我们对它进行遍历时发现,其实就等于是操作一个双向链表结构 。
和双向链表结构一样,在二叉树线索链表上添加一个头结点,如图6-10-6所示,并令其lchild域的指针指向二叉树的根结点(图中的①),其rchild域的指针指向中序遍历时访问的最后一个结点(图中的②)。反之,令二叉树的中序序列中的第一个结点中,lchild域指针和最后一个结点的rchild域指针均指向头结点(圈中的③和④)。这样定义的好处就是我们既可以从第一个结点起顺后继进行遍历,也可以从最后一个结点起顺前驱进行遍历。
不过,并非二叉树遍历就一定要用到递归,只不过递归的实现比较优
雅而已。这点需要明确。
/**使用while循环中序遍历线索二叉树*/
static void InOrderTraverae_Thr(BinTree t){
BinTree p = new BinTree();
p = t.lChild;
while(null != p.data){//遍历结束时,尾结点中data为空
while(p.lTag.equals(LinkThread.Link.getValue()))//当lTag==0时循环到中序遍历第一个结点
p = p.lChild;
System.out.print(p.data+" ");
while(LinkThread.Thread.getValue().equals(p.rTag) && !p.data.equals(t.data)){
p = p.rChild;
System.out.print(p.data+" ");
}
p = p.rChild;
}
}
如果所用的二叉树需经常遍历或查找结点时需要某种遍历序列中的前驱和后继,那么采用线索二叉链表的存储结构就是非常不错的选择。
6.12 哈夫曼树及其应用
6.12.1 哈夫曼树
在计算机和互联网技术中,文本压缩就是一个非常重要的技术那么压缩而不出错是如何做到的呢?
简单说,就是把我们要压缩的文本进行重新编码,以减少不必要的空间。尽管现在最新技术在编码上已经很好很强大,但这一切都来自于曾经的技术积累,我们今天就来介绍一下最基本的压缩编码方法一一哈夫曼编码。
在介绍哈夫曼编码前,我们必须得介绍哈夫曼树,什么叫做哈夫曼树呢?我们先来看一个例子。
过去我们小学、中学一般考试都是用百分制来表示学科成绩的。这带来了一个弊端,就是很容易让学生、 家长,甚至老师自己都以分取人,让分数代表了一切。有时想想也对,90分和95分也许就只是一道题目对错的差距,但却让两个孩子可能受到完全不同的待遇,这并不公平。于是在如今提倡素质教育的背景下,我们很多的学科,特别是小学的学科成绩都改作了优秀、良好、中等、及格和不及格这样模糊的词语,不再通报具体的分数。
不过对于老师来讲,他在对试卷评分的时候,显然不能凭感觉给优良或及格不及格等成绩,因此一般都还是按照百分制算出每个学生的成绩后,再根据统一的标准换算得出五级分制的成绩。比如下面的代码就实现了这样的转换。
图6-12-2粗略看没什么问题,可是通常都认为,一张好的考卷应该是让学生成绩大部分处于中等或良好的范围,优秀和不及格都应该较少才对。而上面这样的程序,就使得所有的成绩都需要先判断是否及格,再逐级而上得到结果。输入量很大的时候,其实算法是有效率问题的。
如果在实际的学习生活中,学生的成绩在5个等级上的分布规律如表6-12-1所示。
那么70分以上大约占总数80%的成绩都需要经过3次以上的判断才可以得到结果,这显然不合理。
有没有好一些的办法,仔细观察发现,中等成绩(70-79分之间)比例最高,其次是良好成绩,不及格的所占比例最少。我们把图6-12-2这棵二叉树重新进行分配。改成如图6-12-3的做法试试看。
从图中感觉,应该效率要高一些了,到底高多少呢。这样的二叉树又是如何设计出来的呢?我们来看看哈夫曼大叔是如何说的吧。
6.12.2 哈夫曼树定义与原理
我们先把这两棵二叉树简化成叶子结点带权的二叉树,如图6-12-4所示。其中A表示不及格、 B表示及格、 C表示中等、 D表示良好、 E表示优秀。每个叶子的分支线上的数字就是刚才我们提到的五级分制的成绩所占比例数。
哈夫曼大叔说,
从树中一个结点到另一个结点之间的分支构成两个结点之间的路径,路径上的分支数目称做路径长度
。图6-12-4的二叉树a中,根结点到结点D的路径长度就为4,二叉树b中根结点到结点D的路径长度为2。
树的路径长度就是从树根到每一结点的路径长度之和
。二叉树a的树路径长度就为1+1+2+2+3+3+4+4=20。二叉树b的路径长度就为1+2+3+3+2+1+2+2=16。
如果考虑到带权的结点,
结点的带权的路径长度为从该结点到树根之间的路径长度与结点上权的乘积
。
树的带权路径长度为树中所有叶子结点的带权路径长度之和
。假设有n个权值{W1,W2,…,Wn},构造一棵有n个叶子结点的二叉树,每个叶子结点带权Wk,每个叶子的路径长度为lk,我们通常记作,则其中
带权路径长度WPL最小的二叉树称做哈夫曼树
。也有不少书中也称为最优二叉树,我个人觉得为了纪念做出巨大贡献的科学家,既然用他们的名字命名,就应该要坚持用他们的名字称呼,哪怕"最优"更能体现这棵树的品质也应该只作为别名.
有了哈夫曼对带权路径长度的定义,我们来计算一下图6-12-4这两棵树的WPL值。
二叉树a的WPL=5×1+15×2+40×3+30×4+10×4=315
注意:这里5是A结点的权,1是A结点的路径长度,其他同理。
二叉树b的WPL=5×3+15×3+40×2+30×2+10×2=220
这样的结果意味着什么呢?如果我们现在有10000个学生的百分制成绩需要计算五级分制成绩,用二叉树a的判断方法,需要做31500次比较,而二叉树b的判断方法,只需要22000次比较,差不多少了三分之一量,在性能上提高不是一点点。
那么现在的问题就是,图6-12-4的二叉树b这样的树是如何构造出来的,这样的二叉树是不是就是最优的哈夫曼树呢?别急,哈夫曼大叔给了我们解决的办法。
1.先把有权值的叶子结点按照从小到大的顺序排列成一个有序序列,即:A5,E10,B15 ,D30,C40。
2.取头两个最小权值的结点作为一个新节点N1的两个子结点,注意相对较小的是左孩子,这里就是A为N1的左孩子,E为Nl的右孩子,如图6-12-5所示。新结点的权值为两个叶子权值的和5+10=15。
3.将Nl替换A与E,插入有序序列中,保持从小到大排列。即:N115,B15,D30,C40。
4.重复步骤2。将N1与B作为一个新节点N2的两个子结点。如图6-12-6所示。N2的权值=15+15=30。
5.将N2替换N1与B,插入有序序列中,保持从小到大排列。即: N230,D30,C40。
6.重复步骤2,将N2与D作为一个新节点N3的两个子结点。如图6-12-7所示。N3的权值=30+30=60 。
7.将N3替换N2与D,插入有序序列中,保持从小到大排列。即:C40,N360。
8.重复步骤2。将C与N3作为一个新节点T的两个子结点,如图6-12-8所示。由于T即是根结点,完成哈夫曼树的构造。
不过现实总是比理想要复杂得多,图6-12-8虽然是哈夫曼树,但由于每次判断都要两次比较(如根结点就是a<80&&a>=70,两次比较才能得到y或n的结果),所以总体性能上,反而不如图6-12-3的二叉树性能高。当然这并不是我们要讨论的重点了。
通过刚才的步骤,我们可以得出构造哈夫曼树的哈夫曼算法描述。
1.根据给定的n个权值{Wl,W2,…,Wn}构成n棵二叉树的集合F={Tl,T2,… Tn},其中每棵二叉树Ti中只有一个带权为Wi根结点,其左右子树均为空。
2.在F中选取两棵根结点的权值最小的树作为左右子树构造一棵新的二叉树,且置新的二叉树的根结点的权值为其左右子树上根结点的权值之和。
3.在F中删除这两棵树,同时将新得到的二叉树加入F中。
4.重复2和3步骤,直到F只含一棵树为止。这棵树便是哈夫曼树。
6.12.3 哈夫曼编码
当然,哈夫曼研究这种最优树的目的不是为了我们可以转化一下成绩。他的更大目的是为了解决当年远距离通信(主要是电报)的数据传输的最优化问题。
比如我们有一段文字内容为"BADCADFEED"要网络传输给别人,显然用二进制的数字(0和1)来表示是很自然的想法。我们现在这段文字只有六个字母ABCDE,那么我们可以用相应的二进制数据表示,如表6-12-2所示。
这样真正传输的数据就是编码后的"00100001101000001101100100011",对方接收时可以按照3位一分来译码。如果一篇文章很长,这样的二进制串也将非常的可怕。而且事实上,不管是英文、中文或是其他语言,字母或汉字的出现频率是不相同的,比如英语中的几个元音字 "aeiou",中文中的"的了有在"等汉字都是频率极高。
假设六个字母的频率为A 27 , B 8, C 15 , D 15 , E 30, F 5 ,合起来正好是100% 。那就意味着,我们完全可以重新按照哈夫曼树来规划它们。
图6-12-9左图为构造哈夫曼树的过程的权值显示。右图为将权值左分支改为0,右分支改为1后的哈夫曼树。
此时,我们对这六个字母用其从树根到叶子所经过路径的0或1来编码,可以得到如表6-12-3所示这样的定义。
我们将文字内容为"BADCADFEED"再次编码,对比可以看到结果串变小了。
原编码二进制串:001000011010000011101100100011(共30个字符)
新编码二进制串:1001010010101001000111100(共25个字符)
也就是说,我们的数据被压缩了,节约了大约17%的存储或传输成本。随着字符的增加和多字符权重的不同,这种压缩会更加显出其优势。
当我们接收到100101001010100100011110。这样压缩过的新编码时,我们应该如何把它解码出来呢?
编码中非0即1,长短不等的话其实是很容易混淆的,
所以若要设计长短不等的编码,则必须是任一字符的编码都不是另一个字符的编码的前缀,这种编码称做前缀编码。
你仔细观察就会发现,表6-12-3中的编码就不存在容易与1001、1000混滑的"10"和"100"编码。
可仅仅是这样不足以让我们去方便地解码的,因此在解码时,还是要用到哈夫曼树,即发送方和接收方必须要约定好同样的哈夫曼编码规则。
当我们接收到1001010Q10101001000111100时,由约定好的哈夫曼树可知,1001得到第一个字母是B,接下来的意味着第二个字符是A,如图6-12-10所示,其余的也相应的可以得到,从而成功解码。
一般地,设需要编码的字符集为{d1,d2,…,dn},各个字符在电文中出现的次数或频率集合为{ w1,w2,…,wn},以d1,d2,……dn作为叶子结点,以wl,w2,…,wn作为相应叶子结点的权值来构造一棵哈夫曼树。规定哈夫曼树的左分支代表0,右分支代表1,则从根结点到叶子结点所经过的路径分支组成的0和1的序列便为该结点对应字符的编码,这就是哈夫曼编码。
引用《大话数据结构》作者:程杰