1. 二叉树遍历算法
1.1 前序遍历算法
二叉树的定义是用递归的方式,所以,实现遍历算法也可以采用递归,而且极其简洁明了。先来看看二叉树的前序遍历算法。代码如下:
假设我们现在有如下图这样一棵二叉树T。这树已经用二叉链表结构存储在内存当中。
那么当调用PreOrderTraverse(T)函数时,我们来看看程序是如何运行的。
1.调用PreOrderTraverse(T),T根结点不为null,所以执行printf,打印字母A,如下图所示。
2.调用PreOrderTraverse(T->lchild);访问了A结点的左孩子,不为null,执行printf显示字母B,如下图所示。
3.此时再次递归调用PreOrderTraverse(T->lchild);访问了B结点的左孩子,执行printf显示字母D,如下图所示。
4.再次递归调用PreOrderTraverse(T->lchild);访问了D结点的左孩子,执行printf显示字母H,如下图所示。
5.再次递归调用PreOrderTraverse(T->lchild);访问了H结点的左孩子,此时因为H结点无左孩子,所以T==null,返回此函数,此时递归调用PreOrderTraverse(T->rchild);访问了H结点的右孩子,printf显示字母K,如下图所示。
6.再次递归调用PreOrderTraverse(T->lchild);访问了K结点的左孩子,K结点无左孩子,返回,调用PreOrderTraverse(T->rchild);访问了K结点的右孩子,也是null,返回。于是此函数执行完毕,返回到上一级递归的函数(即打印H结点时的函数),也执行完毕,返回到打印结点D时的函数,调用PreOrderTraverse(T->rchild);访问了D结点的右孩子,不存在,返回到B结点,调用PreOrderTraverse(T->rchild);找到了结点E,打印字母E,如下图所示。
7.由于结点E没有左右孩子,返回打印结点B时的递归函数,递归执行完毕,返回到最初的PreOrderTraverse,调用PreOrderTraverse(T->rchild);访问结点A的右孩子,打印字母C,如下图所示。
8.之后类似前面的递归调用,依次继续打印F、I、G、J,步骤略。综上,前序遍历这棵二叉树的节点顺序是:ABDHKECFIGJ。
1.2 中序遍历算法
那么二叉树的中序遍历算法是如何呢?哈哈,别以为很复杂,它和前序遍历算法仅仅只是代码的顺序上的差异。
换句话说,它等于是把调用左孩子的递归函数提前了,就这么简单。我们来看看当调用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,如下图所示。
2.然后调用InOrderTraverse(T->rchild);访问结点H的右孩子K,因结点K无左孩子,所以打印K,如下图所示。
3.因为结点K没有右孩子,所以返回。打印结点H函数执行完毕,返回。打印字母D,如下图所示。
4.结点D无右孩子,此函数执行完毕,返回。打印字母B,如下图所示。
5.调用InOrderTraverse(T->rchild);访问结点B的右孩子E,因结点E无左孩子,所以打印E,如下图所示。
6.结点E无右孩子,返回。结点B的递归函数执行完毕,返回到了最初我们调用InOrderTraverse的地方,打印字母A,如下图所示。
7.再调用InOrderTraverse(T->rchild);访问结点A的右孩子C,再递归访问结点C的左孩子F,结点F的左孩子I。因为I无左孩子,打印I,之后分别打印F、C、G、J。步骤省略。
综上,中序遍历这棵二叉树的节点顺序是:HKDBEAIFCGJ。
1.3 后序遍历算法
那么同样的,后序遍历也就很容易想到应该如何写代码了。
如下图所示,后序遍历是先递归左子树,由根结点A→B→D→H,结点H无左孩子,再查看结点H的右孩子K,因为结点K无左右孩子,所以打印K,返回。
最终,后序遍历的结点的顺序就是:KHDEBIFJGCA。同学们可以自己按照刚才的办法得出这个结果。
1.4 推导遍历结果
有一种题目为了考查你对二叉树遍历的掌握程度,是这样出题的。已知一棵二叉树的前序遍历序列为ABCDEF,中序遍历序列为CBAEDF,请问这棵二叉树的后序遍历结果是多少?对于这样的题目,如果真的完全理解了前中后序的原理,是不难的。
三种遍历都是从根结点开始,前序遍历是先打印再递归左和右。所以前序遍历序列为ABCDEF,第一个字母是A被打印出来,就说明A是根结点的数据。再由中序遍历序列是CBAEDF,可以知道C和B是A的左子树的结点,E、D、F是A的右子树的结点,如下图所示。
然后我们看前序中的C和B,它的顺序是ABCDEF,是先打印B后打印C,所以B应该是A的左孩子,而C就只能是B的孩子,此时是左还是右孩子还不确定。再看中序序列是CBAEDF,C是在B的前面打印,这就说明C是B的左孩子,否则就是右孩子了,如下图所示。
再看前序中的E、D、F,它的顺序是ABCDEF,那就意味着D是A结点的右孩子,E和F是D的子孙,注意,它们中有一个不一定是孩子,还有可能是孙子的。再来看中序序列是CBAEDF,由于E在D的左侧,而F在右侧,所以可以确定E是D的左孩子,F是D的右孩子。因此最终得到的二叉树是下图所示。
为了避免推导中的失误,你最好在心中递归遍历,检查一下这棵树的前序和中序遍历序列是否与题目中的相同。
已经复原了二叉树,要获得它的后序遍历结果就是易如反掌,结果是CBEFDA。
但其实,如果同学们足够熟练,不用画这棵二叉树,也可以得到后序的结果,因为刚才判断了A结点是根结点,那么它在后序序列中,一定是最后一个。刚才推导出C是B的左孩子,而B是A的左孩子,那就意味着后序序列的前两位一定是CB。同样的办法也可以得到EFD这样的后序顺序,最终就自然的得到CBEFDA这样的序列,不用在草稿上画树状图了。
反过来,如果我们的题目是这样:二叉树的中序序列是ABCDEFG,后序序列是BDCAFGE,求前序序列。
这次简单点,由后序的BDCAFGE,得到E是根结点,因此前序首字母是E。
于是根据中序序列分为两棵树ABCD和FG,由后序序列的BDCAFGE,知道A是E的左孩子,前序序列目前分析为EA。
再由中序序列的ABCDEFG,知道BCD是A结点的右子孙,再由后序序列的BDCAFGE知道C结点是A结点的右孩子,前序序列目前分析得到EAC。
中序序列ABCDEFG,得到B是C的左孩子,D是C的右孩子,所以前序序列目前分析结果为EACBD。
由后序序列BDCAFGE,得到G是E的右孩子,于是F就是G的孩子。前序遍历序列的最终结果就是EACBDGF。
从这里我们也得到两个二叉树遍历的性质。
■ 已知前序遍历序列和中序遍历序列,可以唯一确定一棵二叉树。
■ 已知后序遍历序列和中序遍历序列,可以唯一确定一棵二叉树。
但要注意了,已知前序和后序遍历,是不能确定一棵二叉树的,原因也很简单,比如前序序列是ABC,后序序列是CBA。我们可以确定A一定是根结点,但接下来,我们无法知道,哪个结点是左子树,哪个是右子树。这棵树可能有如下图所示的四种可能。
2. 二叉树的建立
说了半天,我们如何在内存中生成一棵二叉链表的二叉树呢?树都没有,哪来遍历。所以我们还得来谈谈关于二叉树建立的问题。
如果我们要在内存中建立一个如下图左图这样的树,为了能让每个结点确认是否有左右孩子,我们对它进行了扩展,变成下图右图的样子,也就是将二叉树中每个结点的空指针引出一个虚结点,其值为一特定值,比如“#”。我们称这种处理后的二叉树为原二叉树的扩展二叉树。扩展二叉树就可以做到一个遍历序列确定一棵二叉树了。比如下图的前序遍历序列就为AB#D##C##。
有了这样的准备,我们就可以来看看如何生成一棵二叉树了。假设二叉树的结点均为一个字符,我们把刚才前序遍历序列AB#D##C##用键盘挨个输入。实现的算法如下:
其实建立二叉树,也是利用了递归的原理。只不过在原来应该是打印结点的地方,改成了生成结点、给结点赋值的操作而已。所以大家理解了前面的遍历的话,对于这段代码就不难理解了。
当然,你完全也可以用中序或后序遍历的方式实现二叉树的建立,只不过代码里生成结点和构造左右子树的代码顺序交换一下。另外,输入的字符也要做相应的更改。比如之前的扩展二叉树的中序遍历字符串就应该为#B#D#A#C#,而后序字符串应该为###DB##CA。
3. 线索二叉树
我们现在提倡节约型社会,一切都应该节约为本。对待我们的程序当然也不例外,能不浪费的时间或空间,都应该考虑节省。我们再来观察下图,会发现指针域并不是都充分的利用了,有许许多多的“∧”,也就是空指针域的存在,这实在不是好现象,应该要想办法利用起来。
首先我们要来看看这空指针有多少个呢?对于一个有n个结点的二叉链表,每个结点有指向左右孩子的两个指针域,所以一共是2n个指针域。而n个结点的二叉树一共有n-1条分支线数,也就是说,其实是存在2n-(n-1)=n+1个空指针域。比如上图有10个结点,而带有“∧”空指针域为11。这些空间不存储任何事物,白白的浪费着内存的资源。
另一方面,我们在做遍历时,比如对上图做中序遍历时,得到了HDIBJEAFCG这样的字符序列,遍历过后,我们可以知道,结点I的前驱是D,后继是B,结点F的前驱是A,后继是C。也就是说,我们可以很清楚的知道任意一个结点,它的前驱和后继是哪一个。
可是这是建立在已经遍历过的基础之上的。在二叉链表上,我们只能知道每个结点指向其左右孩子结点的地址,而不知道某个结点的前驱是谁,后继是谁。要想知道,必须遍历一次。以后每次需要知道时,都必须先遍历一次。为什么不考虑在创建时就记住这些前驱和后继呢,那将是多大的时间上的节省。
综合刚才两个角度的分析后,我们可以考虑利用那些空地址,存放指向结点在某种遍历次序下的前驱和后继结点的地址。就好像GPS导航仪一样,我们开车的时候,哪怕我们对具体目的地的位置一无所知,但它每次都可以告诉我从当前位置的下一步应该走向哪里。这就是我们现在要研究的问题。我们把这种指向前驱和后继的指针称为线索,加上线索的二叉链表称为线索链表,相应的二叉树就称为线索二叉树(Threaded Binary Tree)。
请看下图,我们把这棵二叉树进行中序遍历后,将所有的空指针域中的rchild,改为指向它的后继结点。于是我们就可以通过指针知道H的后继是D(图中①),I的后继是B(图中②),J的后继是E(图中③),E的后继是A(图中④),F的后继是C(图中⑤),G的后继因为不存在而指向NULL(图中⑥)。此时共有6个空指针域被利用。
再看下图,我们将这棵二叉树的所有空指针域中的lchild,改为指向当前结点的前驱。因此H的前驱是NULL(图中①),I的前驱是D(图中②),J的前驱是B(图中③),F的前驱是A(图中④),G的前驱是C(图中⑤)。一共5个空指针域被利用,正好和上面的后继加起来是11个。
通过下图(空心箭头实线为前驱,虚线黑箭头为后继),就更容易看出,其实线索二叉树,等于是把一棵二叉树转变成了一个双向链表,这样对我们的插入删除结点、查找某个结点都带来了方便。所以我们对二叉树以某种次序遍历使其变为线索二叉树的过程称做是线索化。
不过好事总是多磨的,问题并没有彻底解决。我们如何知道某一结点的lchild是指向它的左孩子还是指向前驱?rchild是指向右孩子还是指向后继?比如E结点的lchild是指向它的左孩子J,而rchild却是指向它的后继A。显然我们在决定lchild是指向左孩子还是前驱,rchild是指向右孩子还是后继上是需要一个区分标志的。因此,我们在每个结点再增设两个标志域ltag和rtag,注意ltag和rtag只是存放0或1数字的布尔型变量,其占用的内存空间要小于像lchild和rchild的指针变量。结点结构如下表所示。
其中:
■ ltag为0时指向该结点的左孩子,为1时指向该结点的前驱。
■ rtag为0时指向该结点的右孩子,为1时指向该结点的后继。
■ 因此对于之前的二叉链表图可以修改为下图的样子。
4. 线索二叉树结构实现
由此二叉树的线索存储结构定义代码如下:
线索化的实质就是将二叉链表中的空指针改为指向前驱或后继的线索。由于前驱和后继的信息只有在遍历该二叉树时才能得到,所以线索化的过程就是在遍历的过程中修改空指针的过程。
中序遍历线索化的递归函数代码如下:
你会发现,这代码除加粗代码以外,和二叉树中序遍历的递归代码几乎完全一样。只不过将本是打印结点的功能改成了线索化的功能。
中间加粗部分代码是做了这样的一些事。
if(!p->lchild)表示如果某结点的左指针域为空,因为其前驱结点刚刚访问过,赋值给了pre,所以可以将pre赋值给p->lchild,并修改p->LTag=Thread(也就是定义为1)以完成前驱结点的线索化。
后继就要稍稍麻烦一些。因为此时p结点的后继还没有访问到,因此只能对它的前驱结点pre的右指针rchild做判断,if(!pre->rchild)表示如果为空,则p就是pre的后继,于是pre->rchild=p,并且设置pre->RTag=Thread,完成后继结点的线索化。
完成前驱和后继的判断后,别忘记将当前的结点p赋值给pre,以便于下一次使用。
有了线索二叉树后,我们对它进行遍历时发现,其实就等于是操作一个双向链表结构。
和双向链表结构一样,在二叉树线索链表上添加一个头结点,如下图所示,并令其lchild域的指针指向二叉树的根结点(图中的①),其rchild域的指针指向中序遍历时访问的最后一个结点(图中的②)。反之,令二叉树的中序序列中的第一个结点中,lchild域指针和最后一个结点的rchild域指针均指向头结点(图中的③和④)。这样定义的好处就是我们既可以从第一个结点起顺后继进行遍历,也可以从最后一个结点起顺前驱进行遍历。
遍历的代码如下:
1.代码中,第4行,p=T->lchild;让p指向根结点开始遍历。
2.第5~16行,while(p!=T)其实意思就是循环直到图中的④的出现,此时意味着p指向了头结点,于是与T相等(T是指向头结点的指针),结束循环,否则一直循环下去进行遍历操作。
3.第7~8行,whild(p->LTag==Link)这个循环,就是由A→B→D→H,此时H结点的LTag不是Link(就是不等于0),所以结束此循环。
4.第9行,打印H。
5.第10~14行,while(p->RTag==Thread && p->rchild!=T),由于结点H的RTag==Thread(就是等于1),且不是指向头结点。因此打印H的后继D,之后因为D的RTag是Link,因此退出循环。
6.第15行,p=p->rchild;意味着p指向了结点D的右孩子I。
7.……,就这样不断循环遍历,直到打印出HDIBJEAFCG,结束遍历操作。
从这段代码也可以看出,它等于是一个链表的扫描,所以时间复杂度为O(n)。
由于它充分利用了空指针域的空间(这等于节省了空间),又保证了创建时的一次遍历就可以终生受用前驱后继的信息(这意味着节省了时间)。所以在实际问题中,如果所用的二叉树需经常遍历或查找结点时需要某种遍历序列中的前驱和后继,那么采用线索二叉链表的存储结构就是非常不错的选择。
5. 树、森林与二叉树的转换
我们前面已经讲过了树的定义和存储结构,对于树来说,在满足树的条件下可以是任意形状,一个结点可以有任意多个孩子,显然对树的处理要复杂得多,去研究关于树的性质和算法,真的不容易。有没有简单的办法解决对树处理的难题呢?
我们前面也讲了二叉树,尽管它也是树,但由于每个结点最多只能有左孩子和右孩子,面对的变化就少很多了。因此很多性质和算法都被研究了出来。如果所有的树都像二叉树一样方便就好了。你还别说,真是可以这样做。
在讲树的存储结构时,我们提到了树的孩子兄弟法可以将一棵树用二叉链表进行存储,所以借助二叉链表,树和二叉树可以相互进行转换。从物理结构来看,它们的二叉链表也是相同的,只是解释不太一样而已。因此,只要我们设定一定的规则,用二叉树来表示树,甚至表示森林都是可以的,森林与二叉树也可以互相进行转换。
我们分别来看看它们之间的转换如何进行。
5.1 树转换为二叉树
将树转换为二叉树的步骤如下
1.加线。在所有兄弟结点之间加一条连线。
2.去线。对树中每个结点,只保留它与第一个孩子结点的连线,删除它与其他孩子结点之间的连线。
3.层次调整。以树的根结点为轴心,将整棵树顺时针旋转一定的角度,使之结构层次分明。注意第一个孩子是二叉树结点的左孩子,兄弟转换过来的孩子是结点的右孩子。
例如下图,一棵树经过三个步骤转换为一棵二叉树。初学者容易犯的错误就是在层次调整时,弄错了左右孩子的关系。比如图中F、G本都是树结点B的孩子,是结点E的兄弟,因此转换后,F就是二叉树结点E的右孩子,G是二叉树结点F的右孩子。
5.2 森林转换为二叉树
森林是由若干棵树组成的,所以完全可以理解为,森林中的每一棵树都是兄弟,可以按照兄弟的处理办法来操作。步骤如下:
1.把每个树转换为二叉树。
2.第一棵二叉树不动,从第二棵二叉树开始,依次把后一棵二叉树的根结点作为前一棵二叉树的根结点的右孩子,用线连接起来。当所有的二叉树连接起来后就得到了由森林转换来的二叉树。
例如下图,将森林的三棵树转化为一棵二叉树。
5.3 二叉树转换为树
二叉树转换为树是树转换为二叉树的逆过程,也就是反过来做而已。如下图所示。步骤如下:
1.加线。若某结点的左孩子结点存在,则将这个左孩子的右孩子结点、右孩子的右孩子结点、右孩子的右孩子的右孩子结点……哈,反正就是左孩子的n个右孩子结点都作为此结点的孩子。将该结点与这些右孩子结点用线连接起来。
2.去线。删除原二叉树中所有结点与其右孩子结点的连线。
3.层次调整。使之结构层次分明。
5.4 二叉树转换为森林
判断一棵二叉树能够转换成一棵树还是森林,标准很简单,那就是只要看这棵二叉树的根结点有没有右孩子,有就是森林,没有就是一棵树。那么如果是转换成森林,步骤如下:
1.从根结点开始,若右孩子存在,则把与右孩子结点的连线删除,再查看分离后的二叉树,若右孩子存在,则连线删除……,直到所有右孩子连线都删除为止,得到分离的二叉树。
2.再将每棵分离后的二叉树转换为树即可。
6. 树与森林的遍历
最后我们再谈一谈关于树和森林的遍历问题。
树的遍历分为两种方式。
树的遍历分为两种方式。
1.一种是先根遍历树,即先访问树的根结点,然后依次先根遍历根的每棵子树。
2.另一种是后根遍历,即先依次后根遍历每棵子树,然后再访问根结点。比如下图中的树,它的先根遍历序列为ABEFCDG,后根遍历序列为EFBCGDA。
森林的遍历也分为两种方式:
1.前序遍历:先访问森林中第一棵树的根结点,然后再依次先根遍历根的每棵子树,再依次用同样方式遍历除去第一棵树的剩余树构成的森林。比如下图三棵树的森林,前序遍历序列的结果就是ABCDEFGHJI。
2.后序遍历:是先访问森林中第一棵树,后根遍历的方式遍历每棵子树,然后再访问根结点,再依次同样方式遍历除去第一棵树的剩余树构成的森林。比如下图三棵树的森林,后序遍历序列的结果就是BCDAFEJHIG。
可如果我们对二叉树进行分析就会发现,当以二叉链表作树的存储结构时,树的先根遍历和后根遍历完全可以借用二叉树的前序遍历和中序遍历的算法来实现。这其实也就证实,我们找到了对树和森林这种复杂问题的简单解决办法。
7. 赫夫曼树及其应用
现在我们都是讲究效率的社会,什么都要求速度,在不能出错的情况下,做任何事情都讲究越快越好。在计算机和互联网技术中,文本压缩就是一个非常重要的技术。玩电脑的人几乎都会应用压缩和解压缩软件来处理文档。因为它除了可以减少文档在磁盘上的空间外,还有重要的一点,就是我们可以在网络上以压缩的形式传输大量数据,使得保存和传递都更加高效。
在介绍赫夫曼编码前,我们必须得介绍赫夫曼树。
过去我们小学、中学一般考试都是用百分制来表示学科成绩的。这带来了一个弊端,就是很容易让学生、家长,甚至老师自己都以分取人,让分数代表了一切。有时想想也对,90分和95分也许就只是一道题目对错的差距,但却让两个孩子可能受到完全不同的待遇,这并不公平。于是在如今提倡素质教育的背景下,我们很多的学科,特别是小学的学科成绩都改作了优秀、良好、中等、及格和不及格这样模糊的词语,不再通报具体的分数。
不过对于老师来讲,他在对试卷评分的时候,显然不能凭感觉给优良或及格不及格等成绩,因此一般都还是按照百分制算出每个学生的成绩后,再根据统一的标准换算得出五级分制的成绩。比如下面的代码就实现了这样的转换。
上图粗略看没什么问题,可是通常都认为,一张好的考卷应该是让学生成绩大部分处于中等或良好的范围,优秀和不及格都应该较少才对。而上面这样的程序,就使得所有的成绩都需要先判断是否及格,再逐级而上得到结果。输入量很大的时候,其实算法是有效率问题的。
如果在实际的学习生活中,学生的成绩在5个等级上的分布规律如下表所示。
那么70分以上大约占总数80%的成绩都需要经过3次以上的判断才可以得到结果,这显然不合理。
有没有好一些的办法,仔细观察发现,中等成绩(70~79分之间)比例最高,其次是良好成绩,不及格的所占比例最少。我们把上图这棵二叉树重新进行分配。改成如下图的做法试试看。
从图中感觉,应该效率要高一些了,到底高多少呢。这样的二叉树又是如何设计出来的呢?我们来看看赫夫曼大叔是如何说的吧。
7.1 赫夫曼树定义与原理
我们先把这两棵二叉树简化成叶子结点带权的二叉树,如下图所示。其中A表示不及格、B表示及格、C表示中等、D表示良好、E表示优秀。每个叶子的分支线上的数字就是刚才我们提到的五级分制的成绩所占比例数。
赫夫曼大叔说,从树中一个结点到另一个结点之间的分支构成两个结点之间的路径,路径上的分支数目称做路径长度。上图二叉树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最小的二叉树称做赫夫曼树。也有不少书中也称为最优二叉树,我个人觉得为了纪念做出巨大贡献的科学家,既然用他们的名字命名,就应该要坚持用他们的名字称呼,哪怕“最优”更能体现这棵树的品质也应该只作为别名。
有了赫夫曼对带权路径长度的定义,我们来计算一下上图这两棵树的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次比较,差不多少了三分之一量,在性能上提高不是一点点。
那么现在的问题就是,上图的二叉树b这样的树是如何构造出来的,这样的二叉树是不是就是最优的赫夫曼树呢?别急,赫夫曼大叔给了我们解决的办法。
1.先把有权值的叶子结点按照从小到大的顺序排列成一个有序序列,即:A5,E10,B15,D30,C40。
2.取头两个最小权值的结点作为一个新节点N1的两个子结点,注意相对较小的是左孩子,这里就是A为N1的左孩子,E为N1的右孩子,如下图所示。新结点的权值为两个叶子权值的和5+10=15。
3.将N1替换A与E,插入有序序列中,保持从小到大排列。即:N115,B15,D30,C40。
4.重复步骤2。将N1与B作为一个新节点N2的两个子结点。如下图所示。N2的权值=15+15=30。
5.将N2替换N1与B,插入有序序列中,保持从小到大排列。即:N230,D30,C40。
6.重复步骤2。将N2与D作为一个新节点N3的两个子结点。如下图所示。N3的权值=30+30=60。
7.将N3替换N2与D,插入有序序列中,保持从小到大排列。即:C40,N360。
8.重复步骤2。将C与N3作为一个新节点T的两个子结点,如下图所示。
由于T即是根结点,完成赫夫曼树的构造。
此时的上图二叉树的带权路径长度WPL=40×1+30×2+15×3+10×4+5×4=205。与图6-12-4的二叉树b的WPL值220相比,还少了15。显然此时构造出来的二叉树才是最优的赫夫曼树。
不过现实总是比理想要复杂得多,图6-12-8虽然是赫夫曼树,但由于每次判断都要两次比较(如根结点就是a<80 && a>=70,两次比较才能得到y或n的结果),所以总体性能上,反而不如最开始的二叉树性能高。当然这并不是我们要讨论的重点了。
通过刚才的步骤,我们可以得出构造赫夫曼树的赫夫曼算法描述。
1.根据给定的n个权值{w1,w2,…,wn}构成n棵二叉树的集合F={T1,T2,…,Tn},其中每棵二叉树Ti中只有一个带权为wi根结点,其左右子树均为空。
2.在F中选取两棵根结点的权值最小的树作为左右子树构造一棵新的二叉树,且置新的二叉树的根结点的权值为其左右子树上根结点的权值之和。
3.在F中删除这两棵树,同时将新得到的二叉树加入F中。
4.重复2和3步骤,直到F只含一棵树为止。这棵树便是赫夫曼树。
7.2 赫夫曼编码
当然,赫夫曼研究这种最优树的目的不是为了我们可以转化一下成绩。他的更大目的是为了解决当年远距离通信(主要是电报)的数据传输的最优化问题。
比如我们有一段文字内容为“BADCADFEED”要网络传输给别人,显然用二进制的数字(0和1)来表示是很自然的想法。我们现在这段文字只有六个字母ABCDEF,那么我们可以用相应的二进制数据表示,如下表所示。
这样真正传输的数据就是编码后的“001000011010000011101100100011”,对方接收时可以按照3位一分来译码。如果一篇文章很长,这样的二进制串也将非常的可怕。而且事实上,不管是英文、中文或是其他语言,字母或汉字的出现频率是不相同的,比如英语中的几个元音字母“a e i o u”,中文中的“的了有在”等汉字都是频率极高。
假设六个字母的频率为A 27,B 8,C 15,D 15,E 30,F 5,合起来正好是100%。那就意味着,我们完全可以重新按照赫夫曼树来规划它们。
下图左图为构造赫夫曼树的过程的权值显示。右图为将权值左分支改为0,右分支改为1后的赫夫曼树。
此时,我们对这六个字母用其从树根到叶子所经过路径的0或1来编码,可以得到如下表所示这样的定义。
我们将文字内容为“BADCADFEED”再次编码,对比可以看到结果串变小了。
■ 原编码二进制串:001000011010000011101100100011 (共30个字符)
■ 新编码二进制串:1001010010101001000111100 (共25个字符)
也就是说,我们的数据被压缩了,节约了大约17%的存储或传输成本。随着字符的增加和多字符权重的不同,这种压缩会更加显出其优势。
当我们接收到1001010010101001000111100这样压缩过的新编码时,我们应该如何把它解码出来呢?
编码中非0即1,长短不等的话其实是很容易混淆的,所以若要设计长短不等的编码,则必须是任一字符的编码都不是另一个字符的编码的前缀,这种编码称做前缀编码。
你仔细观察就会发现,上表中的编码就不存在容易与1001、1000混淆的“10”和“100”编码。
可仅仅是这样不足以让我们去方便地解码的,因此在解码时,还是要用到赫夫曼树,即发送方和接收方必须要约定好同样的赫夫曼编码规则。
当我们接收到1001010010101001000111100时,由约定好的赫夫曼树可知,1001得到第一个字母是B,接下来01意味着第二个字符是A,如下图所示,其余的也相应的可以得到,从而成功解码。
一般地,设需要编码的字符集为{d1,d2,…,dn},各个字符在电文中出现的次数或频率集合为{w1,w2,…,wn},以d1,d2,…,dn作为叶子结点,以w1,w2,…,wn作为相应叶子结点的权值来构造一棵赫夫曼树。规定赫夫曼树的左分支代表0,右分支代表1,则从根结点到叶子结点所经过的路径分支组成的0和1的序列便为该结点对应字符的编码,这就是赫夫曼编码。