大话数据结构之树(下)

156 阅读10分钟

一、线索二叉树原理

对于一个有n个结点,每个结点有指向左右孩子的两个指针域,所以一共是2n个指针域。而n个结点的二叉树一共有n-1条分支线数。 所以存在2n-(n-1)=n+1个空指针域

在这里插入图片描述 因此,我们可以把上图中存在的大量空指针域拿来存放结点的前驱和后继节点的地址。


我们把这种指向前驱和后继的指针称为线索,加上线索的二叉链表称为线索链表,相应的二叉树就称为线索二叉树

在这里插入图片描述

如上图所示,将所有空指针域的rchild改为指向它的后继结点。于是我们就可以知道H的后继结点是D,I的后继结点是B,J的后继结点是E,E的后继结点是A,F的后继结点是C,G由于不存在而指向NULL,此时有6个空指针域被利用。


至于为什么结点H的后继结点是D,I的后继结点是B.....? 其实是因为我们对其进行了一次中序遍历,即HDIBJEAFCG。

在这里插入图片描述

再看上图,此时把所有空指针域中的lchild改为指向当前结点的前驱。因此H的前驱是NULL,I的前驱是D,J的前驱是B,F的前驱是A,G的前驱是C。一共有5个空指针域被利用,加起来刚好利用了11个空指针域。

在这里插入图片描述

通过上图我们可以发现(空心箭头实线为前驱,虚线黑箭头为后继),其实线索二叉树,等于是把一棵二叉树转变成了一个双向链表,此时插入删除结点、查找结点都给我们带来了方便。所以我们对二叉树以某种次序遍历使其变为线索二叉树的过程称作是线索化。

不过问题还没有彻底解决。我们怎么知道某个结点的lchild是指向它的左孩子还是指向前驱?rchild是指向它的右孩子还是指向后继?

显然,我们在决定lchild是指向左孩子还是前驱,rchild是指向右孩子还是后继上是需要一个区分标志的。

因此我们在每个结点再增设两个标志域ltag和rtag,只存放0或1数字的布尔类型,结点结构如下:

在这里插入图片描述 其中: ltag为0时,指向该结点的左孩子,为1时指向该结点的前驱 rtag为0时,指向该结点的右孩子,为1时指向该结点的后继

在这里插入图片描述


二、线索二叉树结构实现

typedef enum {Link,Thread} PointerTag;	/* Link==0表示指向左右孩子指针, */
			        /* Thread==1表示指向前驱或后继的线索 */
typedef  struct BiThrNode	/* 二叉线索存储结点结构 */
{
	TElemType data;	/* 结点数据 */
	struct BiThrNode *lchild, *rchild;	/* 左右孩子指针 */
	PointerTag LTag;
	PointerTag RTag;		/* 左右标志 */
} BiThrNode, *BiThrTree;

线索化的实质就是将二叉链表中的空指针改为指向前驱或后继的线索。由于前驱和后继的信息只有在遍历该二叉树时才能得到,所以线索化的过程就是在遍历的过程中修改空指针的过程

中序遍历线索化的递归过程

BiThrTree pre; /* 全局变量,始终指向刚刚访问过的结点 */
/* 中序遍历进行中序线索化 */
void InThreading(BiThrTree p)
{ 
	if(p)
	{
		InThreading(p->lchild); /* 递归左子树线索化 */
		if(!p->lchild) /* 没有左孩子 */
		{
			p->LTag=Thread; /* 前驱线索 */
			p->lchild=pre; /* 左孩子指针指向前驱 */
		}
		if(!pre->rchild) /* 前驱没有右孩子 */
		{
			pre->RTag=Thread; /* 后继线索 */
			pre->rchild=p; /* 前驱右孩子指针指向后继(当前结点p) */
		}
		pre=p; /* 保持pre指向p的前驱 */
		InThreading(p->rchild); /* 递归右子树线索化 */
	}
}

将本是打印结点的功能改成了线索化的功能


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,完成后继结点的线索化



在这里插入图片描述

和双向链表结构一样,在二叉树线索链表上添加一个头结点,如图所示,并令其lchild域的指针指向二叉树的根结点(图中的1)其rchild域的指针指向中序遍历时访问的最后一个结点(图中的2)。
反之,令二叉树的中序序列中的第一个结点中,lchild域指针和最后一个结点的rchild域指针均指向头结点(图中的3和4)
这样定义的好处就是我们即可以从第一个结点起顺后继进行遍历,也可以从最后一个结点起顺前驱进行遍历

遍历的代码如下:

/*T指向头结点,头结点左链lchild指向根结点,头结点右链rchild指向中序遍历的*/
/* 中序遍历二叉线索树T(头结点)的非递归算法 */
Status InOrderTraverse_Thr(BiThrTree T)
{ 
	BiThrTree p;
	p=T->lchild; /* p指向根结点 */
	while(p!=T)
	{ /* 空树或遍历结束时,p==T */
		while(p->LTag==Link)
			p=p->lchild;
		if(!visit(p->data)) /* 访问其左子树为空的结点 */
			return ERROR;
		while(p->RTag==Thread&&p->rchild!=T)
		{
			p=p->rchild;
			visit(p->data); /* 访问后继结点 */
		}
		p=p->rchild;
	}
	return OK;
}
  1. 代码中,第4行, p=T->lchild;意思就是上图中的1,让p指向根结点开始遍历
  2. 第5~16行,while(p!=T)意思就是循环直到图中的4出现,此时意味着p指向了头结点,于是与T相等(T是指向头结点的指针),结束血循环,否则一直循环下去进行遍历操作
  3. 第7~8行,while(p->LTag==Link)这个循环,就是由A-->B-->D-->H,此时H结点的LTag不是Link,所以结束次循环
  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)

如果所用的二叉树需要经常遍历或者查找结点时需要某种遍历序列中的前驱和后继,那么采用线索二叉链表的存储结构是一种非常不错的选择。

三、哈夫曼树


在这里插入图片描述 在二叉树a中,根结点到结点D的路径长度就为4 在二叉树b中,根结点到结点D的路径长度就为2

路径长度

从树中一个结点到另外一个结点之间的分支构成两个结点之间的路径,路径上的分支数目称做路径长度

树的路径长度

就是从树根到每一个结点的路径长度之和

二叉树a的树路径长度就为1+1+2+2+3+3+4+4=20 二叉树a的树路径长度就为1+2+3+3+2+1+2+2=16


如果考虑到带权的结点,结点的带权的路径长度为从该结点到树根之间的路径长度与结点上权的乘积

树的带权路径长度为树中所有叶子结点的带权路径长度之和

假设有n个权值{w1,w2,.....wn},构造一颗有n个叶子结点的二叉树,每个叶子结点带权wk,每个叶子的路径长度为lk,带权路径长度WPL最小的二叉树称做赫夫曼树 例如二叉树a的WPL=315 二叉树b的WPL=220

问题是怎么构造出最优的赫夫曼树呢?

  1. 先把有权值的叶子结点按照从小到大的顺序排列成一个有序序列,即:A5,E10,B15,D30,C40
  2. 取头两个最小权值的结点作为一个新节点N1的两个子节点,注意相对较小的是左孩子,这里就是A为N1的左孩子,E为N1的右孩子。如下图
  3. 将N1替换A与E,插入有序序列中,保持从小到大排列。即:N115,B15,D30,C40
  4. 重复步骤2.将N1与B作为一个新节点N2的两个子节点。如图6-12-6
  5. 将N2替换N1与B,插入有序序列中,保持从小到大排列。即:N230,D30,C40
  6. 重复步骤2,将N2与D作为一个新节点N3的两个子节点。如图6-12-7
  7. 将N3替换N2与D,插入有序序列中,保持从小到大排列。即:C40,N360
  8. 重复步骤2。将C与N3作为一个新节点T的两个子节点,如图6-12-8,由于T是根结点,完成构造。

在这里插入图片描述 在这里插入图片描述

四、赫夫曼编码

如果我们有一段文字内容“BADCADFEED”要网络传输给别人,显然用二进制的数字(0和1)来表示是很自然的想法。这段文字里的6个字母可以用相应的二进制数据表示,如下图。

在这里插入图片描述 传输编码后“001000011010000011101110100011”,对方接收时就按照3位一分来译码。但如果文章特别长,这样的二进制串也是非常可怕的。而事实上,字母或汉字出现频率是不相同的,所以我们采用赫夫曼树的方法。

假设六个字母的频率为A 27,B 8,C 15,D 15,E 30,F 5,合起来正好是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的序列便为该结点对应字符的编码,这就是赫夫曼编码。