目录
一、线索二叉树
1.1 基本概念
(1)概念引入
遍历二叉树是以一定规则将二叉树中的结点排列成一个线性序列,这实质上是对一个非线性结构进行线性化操作,使每个结点(除第一个和最后一个外)在这些线性序列中有且仅有一个直接前驱和直接后继。例如二叉树结点的中序序列a+b*c-d-e/f中,“c” 的前驱是“*”,后继是“-”。
但是,当以二叉链表作为存储结构时,只能找到结点的左、右孩子信息,而不能直接得到结点在任一序列中的前驱和后继信息,这种信息只有在遍历的动态过程中才能得到,为此引入线索二叉树来保存这些在动态过程中得到的有关前驱和后继的信息。
虽然可以在每个结点中增加两个指针域来存放在遍历时得到的有关前驱和后继信息,但这样做使得结构的存储密度大大降低。由于有n个结点的二叉链表中必定存在n+ 1个空链域,因此可以充分利用这些空链域来存放结点的前驱和后继信息。
(2)基本定义
试做如下规定:若结点有左子树,则其Ichild域指示其左孩子,否则令Ichild域指示其前驱; 若结点有右子树,则其rchild域指示其右孩子,否则令rchild域指示其后继。为了避免混淆,尚需改变结点结构,增加两个标志域。
(上图RTag有点问题,应该是rchild和右孩子)
图1.1-1 线索二叉树的结点形式
以这种结点结构构成的二叉链表作为二叉树的存储结构,叫做线索链表,其中指向结点前驱和后继的指针,叫做线索。加上线索的二叉树称之为线索二叉树。对二叉树以某种次序遍历使其变为线索二叉树的过程叫做线索化。
1.2 线索二叉树的构造
(1)存储结构描述
typedef struct BiThrNode {
TElemType data;
struct BiThrNode *lchild, *rchild; // 左右指针
PointerTag LTag, RTag; // 左右标志
} BiThrNode, *BiThrTree;
根据定义,可以简单得到如上所示的储存结构。
(2)中序序列构造线索二叉树
二叉树的线索化是将二叉链表中的空指针改为指向前驱或后继的线索。而前驱或后继的信息只有在遍历时才能得到,因此线索化的实质就是遍历一次二叉树。
以中序线索二叉树的建立为例。附设指针pre指向刚刚访问过的结点,指针p指向正在访问的结点,即pre指向p的前驱。在中序遍历的过程中,检查p的左指针是否为空,若为空就将它指向pre;检查pre的右指针是否为空,若为空就将它指向p。
图1.2-1 线索二叉树的构造
【算法步骤】
- 如果p非空,左子树递归线索化。
- 如果p的左孩子为空,则给p加上左线索,将其LTag置为1,让p的左孩子指针指向pre (前驱);否则将p的LTag置为0。
- 如果pre的右孩子为空,则给pre加上右线索,将其RTag置为1,让pre的右孩子指针指 向p (后继);否则将pre的RTag置为0。
- 将pre指向刚访问过的结点p,即pre = p。
- 右子树递归线索化。
void InThreading(BiThrTree p) {
if (p) { // 对以p为根的非空二叉树进行线索化
InThreading(p->lchild); // 左子树线索化
if (!p->lchild) // 建前驱线索
{ p->LTag = Thread; p->lchild = pre; }
if (!pre->rchild) // 建后继线索
{ pre->RTag = Thread; pre->rchild = p; }
pre = p; // 保持 pre 指向 p 的前驱
InThreading(p->rchild); // 右子树线索化
} // if
} // InThreading
(3)先序和后序序列构造线索二叉树
上面给出了建立中序线索二叉树的代码,建立先序线索二叉树和后序线索二叉树的代码类似,只需变动线索化改造的代码段与调用线索化左右子树递归函数的位置。
图1.2-2 先序线索二叉树和后序线索二叉树
以图1.2-2(a)的二叉树为例给出手动求先序线索二叉树的过程:
先序序列为ABCDF,然后依次判断每个结点的左右链域,如果为空则将其改造为线索。结点A、B均有左右孩子;结点C无左孩子,将左链域指向前驱B,无右孩子,将右链域指向后继结点D,结点D无左孩子,将左链域指向前驱C,无右孩子,将右链域指向后继F;结点F无左孩子,将左链域指向前驱D;无右孩子, 也无后继故置空,得到的先序线索二叉树如(b)所示。
求后序线索二叉树的过程:
后序序列为CDBFA,结点C无左孩子,也无前驱故置空,无右孩子,将右链域指向后继D;结点D无左孩子,将左链域指向前驱C,无右孩子,将右链域指向后继B;结点F无左孩子,将左链域指向前驱B,无右孩子,将右链域指向后继A得到的后序线索二叉树如(c)所示。
1.3 线索二叉树前驱后继查找与遍历
由于有了结点的前驱和后继信息,线索二叉树的遍历会变得简单。因此,若需经常査找结点在所遍历线性序列中的前驱和后继,则采用线索链表作为存储结构。
下面分3种情况讨论在线索二叉树中如何查找结点的前驱和后继。
(1)中序线索二叉树查找前驱后继
1.查找p指针所指结点的前驱:
若p- > LTag为1,则p的左链指示其前驱;
若p->LTag为0,则说明p有左子树,结点的前驱是遍历左子树时最后访问的一个结点(左子树中最右下的结点)。
2.查找p指针所指结点的后继:
若p->RTag为1,则p的右链指示其后继
若p->RTag为0,则说明p有右子树。根据中序遍历的规律可知,结点的后继应是遍历其右子树时访问的第一个结点,即右子树中最左下的结点。
(2)先序线索二叉树中查找前驱后继
1.查找p指针所指结点的前驱:
若p->LTag为1,则p的左链指示其前驱;
若p->LTag为0,则说明p有左子树。此时p的前驱有两种情况:若*p是其双亲的左孩子,则其前驱为其双亲结点;否则应是其双亲的左子树上先序遍历最后访问到的结点。
2.査找p指针所指结点的后继:
若p->RTag为1,则p的右链指示其后继;
若p->RTag为0,则说明p有右子树。按先序遍历的规则可知,*p的后继必为其左子树根(若存在)或右子树根。
(3)后序线索二叉树中査找前驱后继
1.查找p指针所指结点的前驱:
若p->LTag为1,则p的左链指示其前驱;
若p->LTag为0,当p->RTag也为0时,则p的右链指示其前驱;若p->LTag为0, 而p- > RTag为1时,则p的左链指示其前驱。
2.查找p指针所指结点的后继情况比较复杂,分以下情况讨论:
若*p是二叉树的根,则其后继为空;
若*p是其双亲的右孩子,则其后继为双亲结点;
若*p是其双亲的左孩子,且*p没有右兄弟,则其后继为双亲结点;
若*p是其双亲的左孩子,且*p有右兄弟,则其后继为双亲的右子树上按后序遍历列出的第一个结点(即右子树中最左下的叶结点)。
(4)遍历中序二叉树
由于有了结点的前驱和后继的信息,线索二叉树的遍历操作无需设栈,避免了频繁的进栈、出栈,因此在时间和空间上都较遍历二叉树节省。如果遍历某种次序的线索二叉树,则只要从该次序下的根结点出发,反复査找其在该次序下的后继,直到叶子结点。
下面以遍历中序线索二叉树为例介绍该算法。
[算法步骤】
1.指针p指向根结点。
2.P为非空树或遍历未结束时,循环执行以下操作:
沿左孩子向下,到达最左下结点*p,它是中序的第一个结点;
访问*p;
沿右线索反复查找当前结点*p的后继结点并访问后继结点,直至右线索为0或者遍历结束;
转向p的右子树。
void InOrderTraverse_Thr(BiThrTree T, void (*Visit)(TElemType e))
{
p = T->lchild; // p指向根结点
while (p != T) { // 空树或遍历结束时,p==T
while (p->LTag==Link) p=p->lchild; // 链,第一个结点
Visit(p->data);
while (p->RTag==Thread && p->rchild!=T)
{ p = p->rchild; Visit(p->data); } // 线索,后继结点
p = p->rchild; } // p进至其右子树根
} // InOrderTraverse_Thr
二、树和森林
2.1树的存储结构
(1)双亲表示法
这种存储方式釆用一组连续空间来存储每个结点,同时在每个结点中增设一个伪指针,指示其双亲结点在数组中的位置。根结点下标为0,其伪指针域为T。
图2.1-1 树的双亲表示法
该存储结构利用了每个结点(根结点除外)只有唯一双亲的性质,可以很快得到每个结点的 双亲结点,但求结点的孩子时需要遍历整个结构。
//结点结构
typedef struct PTNode
{ TElemType data; //数据域
int parent; // 双亲位置域
} PTNode;
//树结构
#define Max_Tree_Size 100 //树中结点最大数目
typedef struct
{ PTNode nodes [Max_Tree_Size]; //结点数组
int r, n; // 根结点的位置和结点个数
} PTree;
注意:区别树的顺序存储结构与二叉树的顺序存储结构。在树的顺序存储结构中,数组下标代表结点的编号,下标中所存的内容指示了结点之间的关系。而在二叉树的顺序存储结构中,数组下标既代表了结点的编号,又指示了二叉树中各结点之间的关系。当然,二叉树属于树,因此 二叉树都可以用树的存储结构来存储,但树却不都能用二叉树的存储结构来存储。
(2)孩子表示法
孩子表示法是将每个结点的孩子结点都用单链表链接起来形成一个线性结构,此时n个结点就有n个孩子链表(叶子结点的孩子链表为空表)。
这种存储方式寻找子女的操作非常直接,而寻找双亲的操作需要遍历n个结点中孩子链表指针域所指向的n个孩子链表。
但可以加上指向双亲的指针,但实现起来会相对繁琐。
图2.1-2 树的双亲表示法
图2.1-3 树的双亲表示法流程
//孩子结点
typedef struct CTNode
{ int child;
struct CTNode *next;
}*ChildPtr;
//孩子链表头结点
typedef struct
{ TElemType data;
ChildPtr firstchild;
}CTBox;
//树的结构
typedef struct
{ CTBox nodes[Max_Tree_Size];
int r, n; // 根结点的位置、结点个数
}CTree;
(3)孩子兄弟表示法
又称二叉树表示法,或二叉链表表示法,即以二叉链表做树的存储结构。链表中结点的两个链域分别指向该结点的第一个孩子结点和下一个兄弟结点。
图2.1-4 孩子兄弟表示法
这种储存方式比较灵活,可以方便地实现树转化为二叉树、找结点的孩子等,但缺点是从当前结点査找其双亲结点比较麻烦。若为每个结点增设一个parent 域指向其父结点,则査找结点的父结点也很方便。
//结点结构
typedef struct CSnode
{
ElemType data;//数据域
struct CSnode *firstchild, *nextsibling;//指向第一个孩子、下一个兄弟
} CSNode, *CSTree;
2.2 树、森林、二叉树的转换
由于二叉树和树都可以用二叉链表作为存储结构,因此以二叉链表作为媒介可以导出树与二 叉树的一个对应关系,即给定一棵树,可以找到唯一的一棵二叉树与之对应。从物理结构上看, 它们的二叉链表是相同的,只是解释不同而已。
树转换为二叉树的规则:每个结点左指针指向它的第一个孩子,右指针指向它在树中的相邻右兄弟,这个规则又称“左孩子右兄弟”。由于根结点没有兄弟,所以对应的二叉树没有右子树。
图2.2-1 树和二叉树的转换
将森林转换为二叉树的规则与树类似。先将森林中的每棵树转换为二叉树,由于任何一棵和树对应的二叉树的右子树必空,若把森林中第二棵树根视为第一棵树根的右兄弟,即将第二棵树对应的二叉树当作第一棵二叉树根的右子树,将第三棵树对应的二叉树当作第二棵二叉树根的右子树……以此类推,就可以将森林转换为二叉树。
二叉树转换为森林的规则:若二叉树非空,则二叉树的根及其左子树为第一棵树的二叉树形式,故将根的右链断开。二叉树根的右子树又可视为一个由除第一棵树外的森林转换后的二叉树, 应用同样的方法,直到最后只剩一棵没有右子树的二叉树为止,最后再将每棵二叉树依次转换成树,就得到了原森林。二叉树转换为树或森林是唯一的。
图2.2-2 森林和二叉树的转换
2.3 树和森林的遍历
树的遍历是指用某种方式访问树中的每个结点,且仅访问一次。主要有两种方式:
1)先根遍历。若树非空,先访问根结点,再依次遍历根结点的每棵子树,遍历子树时仍遵循先根后子树的规则。其遍历序列与这棵树相应二叉树的先序序列相同。
2)后根遍历。若树非空,先依次遍历根结点的每棵子树,再访问根结点,遍历子树时仍遵循先子树后根的规则。其遍历序列与这棵树相应二叉树的中序序列相同。
3) 层次遍历。与二叉树的层次遍历思想基本相同,即按层序依次访问各结点。
森林的遍历:
1)先序遍历森林。若森林为非空,则按如下规则进行遍历:
•访问森林中第一棵树的根结点。
•先序遍历第一棵树中根结点的子树森林。
•先序遍历除去第一棵树之后剩余的树构成的森林。
2)中序遍历森林。森林为非空时,按如下规则进行遍历:
•中序遍历森林中第一棵树的根结点的子树森林。
•访问第一棵树的根结点。
•中序遍历除去第一棵树之后剩余的树构成的森林。
图2.2-2的森林的先序遍历序列为ABCDEFGHI,中序遍历序列为BCDAFEHIG。
树 | 森林 | 二叉树 |
先根遍历 | 先序遍历 | 先序遍历 |
后根遍历 | 中序遍历 | 中序遍历 |
可知森林的先序和中序遍历即为其对应二叉树的先序和中序遍历。
三、哈夫曼树
3.1 哈夫曼树定义
哈夫曼树的定义:带权路径长度最短的树
路径:从树中一个结点到另一个结点之间的分支构成这两个结点间的~
路径长度:路径上的分支数
树的路径长度:从树根到每一个结点的路径长度之和
树的带权路径长度:树中所有叶子结点的带权路径长度之和
图3.1-1 哈夫曼树的相关定义
图3.1-2 具有不同带权长度的二叉树
- WPL = 7*2 + 5*2 + 2*2 + 4*2 = 36。
- WPL = 4*2 + 7*3 + 5*3 + 2*1 = 46。
- WPL = 7*1 + 5*2 + 2*3 + 4*3 = 35。
其中,(c)树的WPL最小。可以验证,它恰好为哈夫曼树。
3.2 哈夫曼树构造
3.2-1 构造哈夫曼树的步骤
哈夫曼树的特点:
- 每个初始结点最终都成为叶结点,且权值越小的结点到根结点的路径长度越大。
- 构造过程中共新建了n-1个结点(双分支结点),因此哈夫曼树的结点总数为2n -1。
- 每次构造都选择2棵树作为新结点的孩子,因此哈夫曼树中不存在度为1的结点。
图3.2-2 哈夫曼树构造案例1
图3.2-3 哈夫曼树构造案例2
3.3 哈夫曼编码
(1)基本概念与性质
图3.3-1 哈夫曼编码的概念与思想
哈夫曼编码的基本思想是:
为出现次数较多的字符编以较短的编码,利用哈夫曼树来设计二进制编码。
下面给出有关编码的两个概念。
(1) 前缀编码:如果在一个编码方案中,任一个编码都不是其他任何编码的前缀,则称编码是前缀编码。前缀编码可以保证对压缩文件进行解码时不产生二义性,确保正确解码。
(2) 哈夫曼编码:对一棵具有n个叶子的哈夫曼树,若对树中的每个左分支赋予0,右分支赋予1,则从根到每个叶子的路径上,各分支的赋值分别构成一个二进制串,该二进制串就称为哈夫曼编码。
哈夫曼编码满足下面的两个性质。
性质1哈夫曼编码是前缀编码。
性质2哈夫曼编码是最优前缀编码。
(2)哈夫曼编码的算法实现
在构造哈夫曼树之后,求哈夫曼编码的主要思想是:依次以叶子为出发点,向上回溯至根结点为止。回溯时走左分支则生成代码0,走右分支则生成代码1。
由于每个哈夫曼编码是变长编码,因此使用一个指针数组来存放每个字符编码串的首地址。
[算法步骤】
1.分配存储n个字符编码的编码表空间HC,长度为n+1;分配临时存储每个字符编码的动态数组空间cd, cd[n-1]置为‘/0’。
2.逐个求解n个字符的编码,循环n次,执行以下操作:
•设置变量start用于记录编码在cd中存放的位置,start初始时指向最后。
•设置变量c用于记录从叶子结点向上回溯至根结点所经过的结点下标,c初始时为当前待编码字符的下标i, f用于记录i的双亲结点的下标;
•从叶子结点向上回溯至根结点,求得字符i的编码,当f没有到达根结点时,循环执行以下操作:
- 回溯一次start向前指一个位置,-start;
- 若结点c是f的左孩子,则生成代码0,否则生成代码1,生成的代码0或1保存 在 cd [start]中;
- 继续向上回溯,改变c和f的值。
•根据数组cd的字符串长度为第i个字符编码分配空间HC[i],然后将数组cd中的编码复制到HC[i]中。
3.释放临时空间cd。
#include <stdio.h>
#include <stdlib.h>
#include<string.h>
#define maxnumber 65535
typedef struct//定义哈夫曼树的结构
{
int weight;
int parent,lchild,rchild;
}HTNode,*HuffmanTree;
typedef char* HuffmanCode;
HuffmanTree CreateHuffmanTree(HuffmanTree HT,int n )//构造哈夫曼树
{
int m;
int s,x;//用来获得最小值和次小值
if(n<=1) return;
m = 2*n-1;//获得要申请的空间大小,为什么是这个空间大小,因为有n个字符,要至少合并n-1次。因此,会产生n-1个结点。所以,总结点为2n-1;
HT = (HuffmanTree)malloc(sizeof(HTNode)*(m+1));//但是我们的下标从1开始,所以,要多申请一个空间
for(int i = 1; i<=m;++i)//初始化哈夫曼树
{
HT[i].parent = 0;
HT[i].lchild = 0;
HT[i].rchild = 0;
}
printf("请输入所有字符的权值:\n");
for(int j = 1;j<=n;j++)
{
int a;
scanf("%d",&HT[j].weight);
}
for(int i = n+1;i<=m;i++)
{
Select(HT,&s,&x,i-1);//得到最小值和次小值
HT[s].parent = i;
HT[x].parent = i;//将找到的两个结点的双亲结点改为i
HT[i].lchild = s;
HT[i].rchild = x;//将双亲结点的左孩子,右孩子改为找到的两结点。注意,左右孩子区分大小
HT[i].weight = HT[s].weight+HT[x].weight;//改双亲结点的权重
}
return HT;
}
void CreateHuffmanCode(HuffmanTree HT,int num,char* HC[])//构造哈夫曼编码,我们从孩子结点逆推到根结点
{
int start,parent;
char cd[num];//定义一个临时字符数组,用来获得编码
cd[num-1]='\0';//我们获得的字符串要逆推回去
for(int j =1;j<=num;++j)
{
start = num - 1;//到字符串终止符的前一个位置
int c = j;//c为孩子结点
parent = HT[j].parent;//parent 指向结点c的双亲结点
while(parent!=0)//如果,双亲没有到达根节点
{
--start;
if(HT[parent].lchild==c)//判断双亲结点的左孩子还是右孩子为当前的孩子结点,我们规定左孩子为0,右孩子为1
cd[start]='0';
else
cd[start]='1';
c = parent;//不断向上溯回
parent = HT[parent].parent;
}
HC[j]=(char *)malloc(sizeof(char)*(num-start));//申请一个已知大小的字符空间
strcpy(HC[j],&cd[start]);//把临时数组的字符串,拷入
}
}
int FindMin(HuffmanTree HT,int n)
{
int flag;//用来获得找到的最小值
int f = maxnumber;
for(int i = 1;i <= n ; i++ )
{
if(HT[i].weight<f&&HT[i].parent == 0)
{f = HT[i].weight;flag = i;}
}
HT[flag].parent = 1;//找到之后就标记,双亲结点为1.避免被重复查找
return flag;//返回下标
}
void Select(HuffmanTree HT,int *m,int* n,int num)//注意m为最小值,n为次小值
{
int x;
*m = FindMin(HT,num);
*n = FindMin(HT,num);
if(HT[*m].weight>HT[*n].weight)
{
x = *m;
*m = *n;
*n = x;
}
}
int main()
{
HuffmanTree HT;
printf("请输入有几个要被编码的字符:\n");
int num;
scanf("%d",&num);
HT = CreateHuffmanTree(HT,num);
printf("哈夫曼树已经建立完成:\n");
HuffmanCode HC[num+1];
CreateHuffmanCode(HT,num,HC);
printf("哈夫曼编码依次为:\n");
for(int i =1;i<=num;i++)
{
puts(HC[i]);
}
}
图3.3-2 哈夫曼编码的译码
四、本章小结
本章主要内容如下。
- 二叉树是一种最常用的树形结构,二叉树具有一些特殊的性质,而满二叉树和完全二叉树又是两种特殊形态的二叉树。
- 二叉树有两种存储表示:顺序存储和链式存储。顺序存储就是把二叉树的所有结点按照层次 顺序存储到连续的存储单元中,这种存储更适用于完全二叉树。链式存储又称二叉链表,每个结点包括两个指针,分别指向其左孩子和右孩子。链式存储是二叉树常用的存储结构。
- 树的存储结构有三种:双亲表示法、孩子表示法和孩子兄弟表示法,孩子兄弟表示法是常用的表示法,任意一棵树都能通过孩子兄弟表示法转换为二叉树进行存储。森林与二叉树之间也存在相应的转换方法,通过这些转换,可以利用二叉树的操作解决一般树的有关问题。
- 二叉树的遍历算法是其他运算的基础,通过遍历得到了二叉树中结点访问的线性序列, 实现了非线性结构的线性化。根据访问结点的次序不同可得三种遍历:先序遍历、中序遍历、后 序遍历,时间复杂度均为0(n)。
- 在线索二叉树中,利用二叉链表中的n+1个空指针域来存放指向某种遍历次序下的前驱结点和后继结点的指针,这些附加的指针就称为“线索”。引入二叉线索树的目的是加快查找结点 前驱或后继的速度。
- 哈夫曼树在通信编码技术上有广泛的应用,只要构造了哈夫曼树,按分支情况在左路径上写代码0,右路径上写代码1,然后从上到下叶结点相应路径上的代码序列就是该叶结点的最优前缀码,即哈夫曼编码。