6.6 二叉树的存储结构
6.6.1 二叉树顺序存储结构
顺序存储结构一般只用于完全二叉树。
将这棵二叉树存入数组中,相应的下标对应其同样的位置。
由于它定义的严格,用顺序结构也可以表现出二叉树的结构来。
而对于一般的二叉树,我们可以将其按完全二叉树编号,把不存在的结点设置为"^"。如图(浅色结点表示不存在)。
6.6.2 二叉链表
二叉树每个结点最多有两个孩子,所以为它设计一个数据域和两个指针域,我们称这样的链表叫做二叉链表。结点结构图如表所示。
二叉链表的结点结构定义代码:
6.7 遍历二叉树
6.7.1 二叉树遍历原理
二叉树的遍历 (traversing binary tree) 是指从根结点出发,按照某种次序依次访问二叉树中所有结点,使得每个结点被访问一次且仅被访问一次。
6.7.2 二叉树遍历方法
二叉树的遍历方式有很多,如果我们限制了从左到右的习惯方式,那么主要分为四种:
1.前序遍历
规则是若二叉树为空,则空操作返回,否则先访问根结点,然后前序遍历左子树,再前序遍历右子树。
(根在前,从左往右,一棵树的根永远在左子树前面,左子树永远在右子树前面 )
2 中序遍历
规则是若树为空,则空操作返回,否则中序遍历根结点的左子树,然后是访问根结点,最后中序遍历右子树。
(根在中,从左往右,一棵树的左子树永远在根前面,根永远在右子树前面)
3 后序遍历
规则是若树为空,则空操作返回,否则从左到右先叶子后结点的方式遍历访问左右子树,最后是访问根结点。
(根在后,从左往右,一棵树的左子树永远在右子树前面,右子树永远在根前面)
4 层序遍历
规则是若树为空,则空操作返回,否则从树的第一层,也就是根结点开始访问,从上而下逐层遍历,在同一层中,按从左到右的顺序对结点逐个访问。
6.7.3 前序遍历算法
二叉树的定义是用递归的方式,所以,实现遍历算法也可以采用递归,而且极其简洁明了。代码如下:
6.7.4 中序遍历算法
6.7.5 后序遍历算法
6.8 二叉树的建立
如果我们要在内存中建立一棵树,为了能让每个结点确认是否有左右孩子,我们对它进行扩展,如下图,将二叉树中每个结点的空指针引出一个虚结点,其值为一特定值,比如"#"。我们称这种处理后的二叉树为原二叉树的扩展二叉树。扩展二叉树就可以做到一个遍历序列确定一棵二叉树了。
假设二叉树的结点均为一个字符,我们把刚才前序遍历序列AB#D##C##用键盘挨个输入。实现算法如下:
6.9 线索二叉树
6.9.1 线索二叉树原理
对于如图所示的二叉树,指针域没有得到充分的利用,有许多"^",我们要想办法把它们利用起来。
在二叉链表上,我们只知道每个结点指向其左右孩子结点的地址,而不知道某个结点的前驱是谁,后继是谁。要想知道,必须遍历一次。所以我们可以考虑利用那些空地址存放指向结点在某种遍历次序下的前驱和后继结点的地址。我们把这种指向前驱和后继的指针称为线索,加上线索的二叉链表称为线索链表,相应的二叉树就称为线索二叉树(Threadeed Binary Tree)。
如下图,我们把这棵二叉树进行中序遍历后,将所有的空指针域中的rchild,改为指向它的后继结点。
再看下图,我们将这棵二叉树的所有空指针域中的lchild,改为指向当前结点的前驱。
由此得出下图(实线空心箭头为前驱,虚线黑箭头为后继),就更容易看出,其实线索二叉树,等于是把一棵二叉树转变成了双向链表,这样对我们的插入删除结点、查找某个结点都带来了方便。所以我们对二叉树以某种次序遍历使其变为线索二叉树的过程称作是线索化。
为了区分某个结点的lchild/rchild是指向它的左/右孩子还是指向前驱/后继,我们在每个结点再增设两个标志域ltag和rtag,主要它们只存放0或1数字的布尔型变量,其占的内存空间要小于像lchild和rchild的指针变量。
结点结构图如下表:
其中:
- ltag为0时指向该结点的左孩子,为1时指向该结点的前驱。
- rtag为0时指向该结点的右孩子,为1时指向该结点的后继。
因此之前的二叉链表图可以改为下图:
6.9.2 线索二叉树结构实现
线索化的实质就是将二叉链表中的空指针改为指向前驱或后继的线索。由于前驱和后继的信息只有在遍历该二叉树时才能得到,所以线索化的过程就是在遍历的过程中修改空指针的过程。
中序遍历线索化的递归函数代码如下:
我们发现,除了中间代码(两个递归之间的代码)之外,和二叉树中序遍历的递归代码几乎完全一样。只不过将本是打印结点的功能改成了线索化的功能。
下面来分析一下中间代码。
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,以便于下一次使用。
有了线索二叉树后,我们对它进行遍历时发现,其实就等于是操作一个双向链表结构。
和双向链表结构一样,在二叉树线索链表上添加一个头结点,如下图,头结点的前驱和后继添加如下①②③④关系。这样的好处就是我们既可以从第一个结点起顺着后继进行遍历,也可以从最后一个结点起顺着前驱进行遍历。
遍历代码如下:
时间复杂度O(n)
由于它充分利用了空指针域的空间(这等于节省了空间),又保证了创建时的一次遍历就可以终生受用前驱后继信息(这意味着节省了时间)。所以在实际问题中,如果所用的二叉树需经常遍历或查找结点时需要某种遍历序列中的前驱和后继,那么采用线索二叉链表的存储结构就是非常不错的选择。
6.10 树、森林与二叉树的转换
6.10.1 树转为二叉树
将树转换为二叉树的步骤如下:
加线。在所有兄弟结点之间加一条连线。
去线。对树中每个结点,只保留它与第一个孩子结点的连线,删除它与其他孩子结点之间的连线。
层次调整。以树的根结点为轴心,将整棵树顺时针旋转一定的角度,使其结构层次分明。注意第一个孩子是二叉树结点的左孩子,兄弟转过来的孩子是结点的右孩子。
6.10.2 森林转为二叉树
森林是由若干棵树组成的,所以完全可以理解为,森林中的每一棵树都是兄弟,可以按照兄弟的处理办法来操作。步骤如下:
把每个树转换为二叉树。
第一棵二叉树不动,从第二棵二叉树开始,依次把后一棵二叉树的根结点作为前一棵二叉树的根结点的右孩子,用线连接起来。当所有的二叉树连接起来后就得到了由森林转换来的二叉树。
6.10.3 二叉树转为树
二叉树转换为树是树转换为二叉树的逆过程,步骤如下:
加线。若某结点的左孩子结点存在,则将这个左孩子的右孩子结点、右孩子的右孩子结点、右孩子的右孩子的右孩子结点······作为此结点的孩子。将该结点与这些右孩子结点用线连接起来。
去线。删除原二叉树中所有结点与其右孩子的连线。
层次调整。使之结构层次分明。
6.10.4 二叉树转为森林
判断一棵二叉树能够转换成一棵树还是森林,只要看这棵二叉树的根结点有没有右孩子,有就是森林,没有就是一棵树。步骤如下:
从根结点开始,若右孩子存在,则把与右孩子结点的连线删除,再查看分离后的二叉树,若右孩子存在,则连线删除······,直到所有右孩子连线都删除为止,得到分离的二叉树。
再将每棵分离后的二叉树转换为树即可。
6.10.5 树与森林的遍历
树的遍历分为两种:
- 一种是先根遍历树,即先访问树的根结点,然后依次先根遍历根的每棵树。
- 另一种是后根遍历,即先依次后根遍历每棵子树,然后再访问根结点。
如下图这棵树,它的先根遍历序列为ABEFCDG,后根遍历序列为EFBCGDA。
6.11 赫夫曼树及其应用
6.11.1赫夫曼树的定义
首先给出两棵叶子结点带权的二叉树(注:树结点间的边相关的数叫做权 Weight),如下图。
从树中一个结点到另一个结点之间的分支构成两个结点之间的路径,路径上的分支数目称做路径长度。
二叉树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。
带权路径长度WPL最小的二叉树称做赫夫曼树。
二叉树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。
6.11.2 赫夫曼树的构造
-
先把有权值的叶子结点按照从小到大的顺序排列成一个有序序列,即:A5,E10,B15,D30,C40。
-
取头两个最小权值的结点作为一个新结点N1的两个子结点,注意相对较小的是左孩子,这里就是A为N1的左孩子,E为N1的右孩子,如下图。新结点的权值为两个叶子权值的和5+10=15。
-
将N1替换A与E,插入有序序列中,保持从小到大排列。即:N115,B15,D30,C40。
-
重复步骤2。将N1与B作为一个新结点N2的两个子节点。如下图。N2的权值=15+15=30。
-
将N2替换N1与B,插入有序序列中,保持从小到大排序。即:N230,D30,C40。
-
重复步骤2。将N2与D作为一个新结点N3的两个子结点。如下图。N3的权值=30+30=60。\
6.11.3 赫夫曼编码
如果我们有一段文字内容“BADCADFEED”要网络传输给别人,显然用二进制的数字(0和1)来表示是很自然的想法。这段文字里的6个字母可以用相应的二进制数据表示,如下图。
传输编码后“001000011010000011101110100011”,对方接收时就按照3位一分来译码。但如果文章特别长,这样的二进制串也是非常可怕的。而事实上,字母或汉字出现频率是不相同的,所以我们采用赫夫曼树的方法。
假设六个字母的频率为A27,B8,C15,D15,E30,F5,合起来正好是100%,因此我们可以重新按照赫夫曼树来规划它们。
下左图为构造赫夫曼树的过程的权值显示。右图为将权值左分支改为0,右分支改为1后的赫夫曼树。
此时,我们对这六个字母用其从树根到叶子所经过路径的0或1来编码,可以得到如下表这样的定义。
我们将文字内容为“BADCADFEED”再次编码,对比可以看到结果串变小了。
原编码二进制串:001000011010000011101110100011(共30个字符)
新编码二进制串:1001010010101001000111100 (共25个字符)
可以看出,数据被压缩了,节约了大约17%的存储或运输成本。随着字符的增加和多字符权重的不同,这种压缩会更加显出其优势。
关于解码,编码中非0即1,长短不等的话其实是很容易混淆的,所以若要设计长短不等的编码,则必须是任一字符的编码都不是另一个字符的编码的前缀,这种编码称做前缀编码。
但仅仅这样不足以让我们去方便地解码,因此在解码时,还要用到赫夫曼树,即发送方和接收方必须要约定好同样的赫夫曼编码规则。
一般地,设需要编码的字符集为{ d1,d2,···,dn },各个字符在电文中出现的次数或频率集合为{ w1,w2,···,wn },以d1,d2,···,dn作为叶子结点,以w1,w2,···,wn作为相应叶子结点的权值来构造一棵赫夫曼树。规定赫夫曼树的左分支代表0,右分支代表1,则从根结点到叶子结点所经过的路径分支组成的0和1的序列便为该结点对应字符的编码,这就是赫夫曼编码。
目录