《大话数据结构》笔记——第6章 树(二)

文章目录

  • 6.8 遍历二叉树
    • 6.8.1 二叉树遍历原理
    • 6.8.2 二叉树遍历方法
    • 6.8.3 前序遍历算法
    • 6.8.4 中序遍历算法
    • 6.8.5 后序遍历算法
    • 6.8.6 推导遍历结果
  • 6.9 二叉树的建立
  • 6.10 线索二叉树
    • 6.10.1 线索二叉树原理
    • 6.10.2 线索二叉树结构实现
  • 6.11 树、森林与二叉树的转换
    • 6.11.1 树转换为二叉树
    • 6.11.2 森林转换为二叉树
    • 6.11.3 二叉树转换为树
    • 6.11.4 二叉树转换为森林
    • 6.11.5 树与森林的遍历
  • 6.12 赫夫曼树及其应用
    • 6.12.1 赫夫曼树
    • 6.12.2 赫夫曼树定义与原理
    • 6.12.3 赫夫曼编码
  • 6.13 总结回顾
  • 6.14 结尾语

6.8 遍历二叉树

6.8.1 二叉树遍历原理

二叉树的遍历(traversing binary tree)是指从根结点出发,按照某种次序依次访问二叉树中所有结点,使得每个结点被访问一次且仅被访问一次

这里有两个关键词:访问次序

访问其实是要根据实际的需要来确定具体做什么,比如对每个结点进行相关计算,输出打印等,它算作一个抽象操作。在这里我们可以简单地假定就是输出结点的数据信息。

二叉树的遍历次序不同于线性结构,最多也就是从头到尾、循环、双向等简单的遍历方式。树的结点之间不存在唯一的前驱和后继关系,在访问一个结点后,下一个被访问的结点面临着不同的选择

6.8.2 二叉树遍历方法

前序遍历

规则是若二叉树为空,则空操作返回,否则先访问根结点,然后前序遍历左子树,再前序遍历右子树。如图 6-8-2 所示,遍历的顺序为:ABDGHCEIF。

《大话数据结构》笔记——第6章 树(二)_第1张图片

中序遍历

规则是若树为空,则空操作返回,否则从根结点开始(注意并不是先访问根结点),中序遍历根结点的左子树,然后是访问根结点,最后中序遍历右子树。如图 6-8-3 所示,遍历的顺序为:GDHBAEICF。

《大话数据结构》笔记——第6章 树(二)_第2张图片

后序遍历

规则是若树为空,则空操作返回,否则从左到右先叶子后结点的方式遍历访问左右子树,最后是访问根结点。如图 6-8-4 所示,遍历的顺序为:GHDBIEFCA。

《大话数据结构》笔记——第6章 树(二)_第3张图片

层序遍历

规则是若树为空,则空操作返回,否则从树的第一层,也就是根结点开始访问,从上而下逐层遍历,在同一层中,按从左到右的顺序对结点逐个访问。如图 6-8-5 所示,遍历的顺序为:ABCDEFGHI。

《大话数据结构》笔记——第6章 树(二)_第4张图片

6.8.3 前序遍历算法

二叉树的定义是用递归的方式,所以,实现遍历算法也可以采用递归,而且极其简洁明了。先来看看二叉树的前序遍历算法。代码如下:

/* 二叉树的前序遍历递归算法 */
void PreOrderTraverse(BiTree T)
{
	if (T == NULL) //判断树是否为空
	{
		return; //如果树为空,直接返回,不执行后续操作
	}
	printf("%c", T->data); //显示结点数据,可以更改为其他对结点的操作
	PreOrderTraverse(T->lchild); //再先序遍历左子树
	PreOrderTraverse(T->rchild); //最后先序遍历右子树
}

6.8.4 中序遍历算法

那么二叉树的中序遍历算法是如何呢?它和前序遍历算法仅仅只是代码的顺序上的差异。

/* 二叉树的中序遍历递归算法 */
void InOrderTraverse(BiTree T)
{
	if (T == NULL) //判断树是否为空
	{
		return; //如果树为空,直接返回,不执行后续操作
	}
	InOrderTraverse(T->lchild); //中序遍历左子树
	printf("%c", T->data); //显示结点数据,可以更改为其他对结点的操作
	InOrderTraverse(T->rchild); //最后中序遍历右子树
}

6.8.5 后序遍历算法

那么同样的,后序遍历也就很容易想到应该如何写代码了。

/* 二叉树的后序遍历递归算法 */
void PostOrderTraverse(BiTree T)
{
	if (T == NULL) //判断树是否为空
	{
		return; //如果树为空,直接返回,不执行后续操作
	}
	PostOrderTraverse(T->lchild); //先后序遍历左子树
	PostOrderTraverse(T->rchild); //再后序遍历右子树
	printf("%c", T->data); //显示结点数据,也可以更改为其他对结点操作
}

6.8.6 推导遍历结果

有一种题目为了考查你对二叉树遍历的掌握程度,是这样出题的。已知一棵二叉树的前序遍历序列为:ABCDEF,中序遍历序列为:CBAEDF,请问这棵二叉树的后序遍历结果是多少

三种遍历都是从根结点开始,前序遍历是先打印再递归左和右。所以前序遍历序列为 ABCDEF。第一个字母是 A 被打印出来,就说明 A 是根结点的数据。再由中序遍历序列是 CBAEDF,可以知道 C 和 B 是 A 的左子树的结点,E、D、F 是 A 的右子树的结点,如图 6-8-21 所示。

《大话数据结构》笔记——第6章 树(二)_第5张图片

然后我们看前序中的 C 和 B,它的顺序是 ABCDEF,是先打印 B 后打印 C,所以 B 应该是 A 的左孩子,而 C 就只能是 B 的孩子,此时是左还是右孩子还不确定。再看中序序列是 CBAEDF,C 是在 B 的前面打印,这就说明 C 是 B 的左孩子,否则就是右孩子了,如图 6-8-22 所示。

《大话数据结构》笔记——第6章 树(二)_第6张图片

再看前序中的 E、D、F,它的顺序是 ABCDEF,那就意味着 D 是 A 结点的右孩子,E 和 F 是 D 的子孙,注意,他们中有一个不一定是孩子,还有可能是孙子的。再来看中序序列是 CBAEDF,由于 E 在 D 的左侧,而 F 在右侧,所以可以确定 E 是 D 的左孩子,F 是 D 的右孩子。因此最终得到的二叉树是图 6-8-23 所示。

《大话数据结构》笔记——第6章 树(二)_第7张图片

反过来,如果我们的题目是这样:二叉树的中序序列是 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 的孩子。如果你是在考试时做这道题目,时间就是分数、名次、学历,那么你根本不需关心 F 是 G 的左还是右孩子,前序遍历序列的最终结果就是 EACBDGF。

不过细细分析,根据中序序列 ABCDEFG,是可以得出 F 是 G 的左孩子。

《大话数据结构》笔记——第6章 树(二)_第8张图片

从这里我们也得到二叉树遍历的性质:

  • 已知前序遍历序列和中序遍历序列,可以唯一确定一棵二叉树。

  • 已知后序遍历序列和中序遍历序列,可以唯一确定一棵二叉树。

  • 但要注意了,已知前序和后序遍历,是不能确定一棵二叉树的。

6.9 二叉树的建立

如果我们要在内存中建立一个如图 6-9-1 左图这样的树,为了能让每个结点确认是否有左右孩子,我们对它进行了扩展,变成图 6-9-1 右图的样子,也就是将二叉树中每个结点的空指针引出一个虚结点,其值为一特定值,比如 “#”。我们称这种处理后的二叉树为原二叉树的扩展二叉树。扩展二叉树就可以做到一个遍历序列确定一棵二叉树了。比如图 6-9-1 的前序遍历序列就为 AB#D##C##。

《大话数据结构》笔记——第6章 树(二)_第9张图片

实现的算法如下:

/* 按前序输入二叉树中结点的值(一个字符),# 表示空树,构造二叉链表表示二叉树 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); //构造右子树
	}
}

其实建立二叉树,也是利用了递归的原理。只不过在原来应该是打印结点的地方,改成了生成结点,给结点赋值的操作而已。所以大家理解了前面的遍历的话,对于这段代码就不难理解了。

6.10 线索二叉树

6.10.1 线索二叉树原理

我们再来观察图 6-10-1,会发现指针域并不是都充分的利用了,有许许多多的 “^”,也就是空指针域的存在,这实在不算好现象,应该要想办法利用起来。

《大话数据结构》笔记——第6章 树(二)_第10张图片

首先我们要来看看这空指针又多少个呢?对于一个有 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导航仪一样。我们开车的时候,哪怕我们对具体目的地的位置一无所知,但它每次都可以告诉我从当前位置的下一步应该走向哪里。这就是我们现在要研究的问题。我们把这种指向前驱和后继的指针称为线索,加上线索的二叉链表称为线索链表,相应的二叉树就称为线索二叉树(Threaded Binary Tree)

请看图 6-10-2,我们把这棵二叉树进行中序遍历后,将所有的空指针域中的 rchild,改为指向它的后继结点。于是我们就可以通过指针知道 H 的后继是 D(图中1),I 的后继是 B(图中2),J 的后继是 E(图中3),E 的后继是 A(图中4),F 的后继是 C(图中5),G 的后继因为不存在而指向 NULL(图中6)。此时共有 6 个空指针域被利用。

《大话数据结构》笔记——第6章 树(二)_第11张图片

再看图 6-10-3,我们将这棵二叉树的所有空指针域中的 lchild,改为指向当前结点的前驱。因此 H 的前驱是 NULL(图中1),I 的前驱是 D(图中2),J 的前驱是 B(图中3),F 的前驱是 A(图中4),G 的前驱是 C(图中5)。一共 5 个空指针域被利用,正好和上面的后继加起来是 11 个。

《大话数据结构》笔记——第6章 树(二)_第12张图片

通过图 6-10-4(空心箭头实线为前驱,虚线黑箭头为后继),就更容易看出,其实线索二叉树,等于是把一棵二叉树转变成了一个双向链表,这样对我们的插入删除结点、查找某个结点都带来了方便。所以我们对二叉树以某种次序遍历使其变为线索二叉树的过程称做是线索化

《大话数据结构》笔记——第6章 树(二)_第13张图片

不过好事总是多磨的,问题并没有彻底解决。我们如何知道某一结点的 lchild 是指向它的左孩子还是指向前驱?rchild 是指向右孩子还是指向前驱?比如 E 结点的 lchild 是指向它的左孩子 J,而 rchild 却是指向它的后继 A。显然我们在决定 lchild 是指向左孩子还是前驱,rchild 是指向右孩子还是后继上是需要一个区分标志的。因此,我们在每个结点再增设两个标志域 ltag 和 rtag,注意 ltag 和 rtag 只是存放 0 或 1 数字的布尔型变量,其占用的内存空间要小于像 lchild 和 rchild 的指针变量,结点结构如表 6-10-1 所示。

《大话数据结构》笔记——第6章 树(二)_第14张图片

其中:

  • ltag 为 0 时指向该节点的左孩子,为 1 时指向该结点的前驱。
  • rtag 为 0 时指向该结点的右孩子,为 1 时指向该结点的后继。
  • 因此对于图 6-10-1 的二叉链表图可以修改为图 6-10-5 的样子。

《大话数据结构》笔记——第6章 树(二)_第15张图片

6.10.2 线索二叉树结构实现

由此二叉树的线索存储结构定义代码如下:

//二叉树的二叉线索存储结构定义
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,完成后继结点的线索化。

完成前驱和后继的判断后,别忘了将当前的结点 p 赋值给 pre,以便于下一次使用。

有了线索二叉树后,我们对它进行遍历时发现,其实就等于是操作一个双向链表结构

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

《大话数据结构》笔记——第6章 树(二)_第16张图片

遍历的代码如下:

#define OK 1
#define ERROR 0
typedef int ElemType; //也就相当于给int起别名  
typedef int Status; //创建子函数返回类型 
 
/*
 T 指向头结点,头结点左链 lchild 指向根结点,头结点右键 rchild 指向
中序遍历的最后一个结点。中序遍历二叉树线索链表表示的二叉树 T 
*/
Status InOrderTraverse_Thr(BiThrTree T)
{
	BiThrTree p;
	p = T->lchild; // p 指向根结点
	while (p != T)
	{
		while (p->LTag == Link) //当 LTag==0 时循环到中序序列第一个结点
		{
			p = p->lchild;
		}
		printf("%c", p->data); //显示结点数据,可以更改为其他对结点操作
		while (p->RTag == Thread&&p->rchild != T)
		{
			p = p->rchild;
			printf("%c", p->data);
		}
		p = p->rchild; // p 进至其右子树根
	}
	return OK;
}

从这段代码也可以看出,它等于是一个链表的扫描,所以时间复杂度为 O(n)。

由于它充分利用了空指针域的空间(这等于节省了空间),又保证了创建时的一次遍历就可以终生受用前驱后继的信息(这意味着节省了时间)。所以在实践问题中,如果所用的二叉树需经常遍历或查找结点时需要某种遍历序列中的前驱和后继,那么采用线索二叉链表的存储结构就是非常不错的选择

6.11 树、森林与二叉树的转换

在讲述的存储结构时,我们提到了树的孩子兄弟法可以将一棵树用二叉链表进行存储,所以借助二叉链表,树和二叉树可以相互进行转换。从物理结构来看,他们的二叉链表也是相同的,只是解释不太一样而已。因此,只要我们设定一定的规则,用二叉树来表示树,甚至表示森林都是可以的,森林与二叉树也可以互相进行转换

我们分别来看看他们之间的转换如何进行。

6.11.1 树转换为二叉树

将树转换为二叉树的步骤如下:

  1. 加线。在所有兄弟结点之间加一条连线。
  2. 去线。对树中每个结点,只保留它与第一个孩子结点的连线,删除它与其他孩子结点之间的连线。
  3. 层次调整。以树的根结点为轴心,将整棵树顺时针旋转一定的角度,使之结构层次分明。注意第一个孩子是二叉树结点的左孩子,兄弟转换过来的孩子是结点的右孩子。

例如图 6-11-2,一棵树经过三个步骤转换为一棵二叉树。初学者容易犯的错误就是在层次调整时,弄错了左右孩子的关系。比如图中 F、G 本都是树结点 B 的孩子,是结点 E 的兄弟,因此转换后,F 就是二叉树结点 E 的右孩子,G 是二叉树结点 F 的右孩子。

《大话数据结构》笔记——第6章 树(二)_第17张图片

6.11.2 森林转换为二叉树

森林是由若干棵树组成的,所以完全可以理解为,森林中的每一棵树都是兄弟,可以按照兄弟的处理办法来操作。步骤如下:

  1. 把每个树转换为二叉树
  2. 第一棵二叉树不动,从第二棵二叉树开始,依次把后一棵二叉树的根结点作为前一棵二叉树的根结点的右孩子,用线连接起来。当所有的二叉树连接起来后就得到了由森林转换来的二叉树。

例如图 6-11-3,将森林的三棵树转化为一棵二叉树。

《大话数据结构》笔记——第6章 树(二)_第18张图片

6.11.3 二叉树转换为树

二叉树转换为树是树转换为二叉树的逆过程,也就是反过来做而已。如图 6-11-4 所示。步骤如下:

  1. 加线。若某结点的左孩子结点存在,则将这个左孩子的右孩子结点、右孩子的右孩子结点、右孩子的右孩子的右孩子结点…哈,反正就是左孩子的 n 个右孩子结点都作为此结点的孩子。将该结点与这些右孩子结点用线连接起来。
  2. 去线。删除原二叉树中所有结点与其右孩子结点的连线。
  3. 层次调整。使之结构层次分明。

《大话数据结构》笔记——第6章 树(二)_第19张图片

6.11.4 二叉树转换为森林

判断一棵二叉树能够转换成一棵树还是森林,标准很简单,那就是只要看这棵二叉树的根结点有没有孩子,有就是森林,没有就是一棵树。那么如果是转换成森林,步骤如下:

  1. 从根结点开始,若右孩子存在,则把与右孩子结点的连线删除,再查看分离后的二叉树,若右孩子存在,则连线删除…,直到所有右孩子连线都删除为止,得到分离的二叉树。
  2. 再将每棵分离后的二叉树转换为树即可。

《大话数据结构》笔记——第6章 树(二)_第20张图片

6.11.5 树与森林的遍历

最后我们再谈一谈关于树和森林的遍历问题。

树的遍历分为两种方式

  1. 一种是先根遍历树,即先访问树的根结点,然后依次先根遍历根的每棵子树。
  2. 另一种是后根遍历,即先依次后根遍历每棵子树,然后再访问根结点。比如图 6-11-4 中最右侧的树,它的先根遍历序列为 ABEFCDG,后根遍历序列为 EFBCGDA。

森林的遍历也分为两种方式

  1. 前序遍历:先访问森林中第一棵树的根结点,然后再依次先根遍历根的每棵子树,再依次用同样方式遍历除去第一棵树的剩余树构成的森林。比如图 6-11-5 右侧三棵树的森林,前序遍历序列的结果就是 ABCDEFGHJI。
  2. 后序遍历:是先访问森林中第一棵树,后根遍历的方式遍历每棵子树,然后再访问根结点,再依次同样方式遍历除去第一棵树的剩余树构成的森林。比如图 6-11-5 右侧三棵树的森林,后序遍历序列的结果就是 BCDAFEJHIG。

可如果我们对图 6-11-4 的左侧二叉树进行分析就会发现,森林的前序遍历和二叉树的前序遍历结果相同,森林的后序遍历和二叉树的中序遍历结果相同。

这也就告诉我们,当以二叉链表作树的存储结构时,树的先根遍历和后根遍历完全可以借助二叉树的前序遍历和中序遍历的算法来实现。这其实也就证实,我们找到了对树和森林这种复杂问题的简单解决办法。

6.12 赫夫曼树及其应用

6.12.1 赫夫曼树

在计算机和互联网技术中,文本压缩就是一个非常重要的技术。玩电脑的人几乎都会应用压缩和解压缩软件来处理文档。因为它除了可以减少文档在磁盘上的空间外,还有重要的一点,就是我们可以在网络上以压缩的形式传输大量数据,使得保存和传递都更加高效。

那么压缩而不出错是如何做到的呢?简单说,就是把我们要压缩的文本进行重新编码,以减少不必要的空间。尽管现在最新技术在编码上已经很好很强大,但这一切都来自于曾经的技术积累,我们今天就来介绍一下最基本的压缩编码方法——赫夫曼编码

在介绍赫夫曼编码前,我们必须得介绍赫夫曼树,而介绍赫夫曼树,我们不得不提这样一个人,美国数学家赫夫曼(David Huffman),也有的翻译为哈夫曼。他在 1952 年发明了赫夫曼编码,为了纪念他的成就,于是就把他在编码中用到的特殊的二叉树称之为赫夫曼树,他的编码方法称为赫夫曼编码。也就是说,我们现在介绍的知识全都来自于近 60 年前这位伟大科学家的研究成果,而我们平时所用的压缩和解压缩技术也都是基于赫夫曼的研究之上发展而来,我们应该要记住他。

什么叫做赫夫曼树呢?我们先来看一个例子。

过去我们小学、中学一般考试都是用百分制来表示学科成绩的。这带来了一个弊端,就是很容易让学生、家长,甚至老师都以分取人,让分数代表了一切。又是想想也对,90 分和 95 分也许就只是一道题目对错的差距,但却让两个孩子可能受到完全不同的际遇,这并不公平。于是再如今提倡素质教育的背景下,我们很多的学科,特别是小学的学科成绩都该作了优秀、良好、中等、及格和不及格这样模糊的词语,不再通报具体的分数。

不过对于老师来讲,他在对试卷评分的时候,显然不能凭感觉给优良或及格不及格等成绩,因此一般都还是按照百分制算出每个学生的成绩后,再根据统一的标准换算得出五级分制的成绩。比如下面的代码就实现了这样的转换。

《大话数据结构》笔记——第6章 树(二)_第21张图片

图 6-12-2 粗略看没什么问题,可是通常都认为,一张好的考卷应该是让学生成绩大部分处于中等或良好的范围,优秀和不及格都应该较少才对。而上面这样的程序,就使得所有的成绩都需要先判断是否及格,再逐级而上得到结果。输入量很大的时候,其实算法是有效率问题的

《大话数据结构》笔记——第6章 树(二)_第22张图片

如果在实际的学习生活中,学生的成绩在 5 个等级上的分布规律如表 6-12-1 所示。

《大话数据结构》笔记——第6章 树(二)_第23张图片

那么 70 分以上大约占总数 80% 的成绩都需要经过 3 次以上的判断才可以得出结果,这显然不合理。

有没有好一些的办法,仔细观察发现,中等成绩(70-79 分之间)比例最高,其次是良好成绩,不及格的所占比例最少。我们把图 6-12-2 这棵二叉树重新进行分配。改成如图 6-12-3 的做法试试看

《大话数据结构》笔记——第6章 树(二)_第24张图片

从图中感觉,应该效率要高一些了,到底高多少呢。这样的二叉树又是如何设计出来的呢?我们来看看赫夫曼大叔是如何说的吧。

6.12.2 赫夫曼树定义与原理

我们先把这两棵二叉树简化成叶子结点带权的二叉树,如图 6-12-4 所示。其中 A 表示不及格、B 表示及格、C 表示中等、D 表示良好、E 表示优秀。每个叶子的分支线上的数字就是刚才我们提到的五级分制的成绩所占比例数。

《大话数据结构》笔记——第6章 树(二)_第25张图片

赫夫曼大叔说,从树中一个结点到另一个结点之间分分支构成两个结点之间的路径,路径上的分支数目称做路径长度。图 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 个叶子结点的二叉树,每个叶子结点带权 w(k),每个叶子的路径长度为 1k,我们通常记作。则其中带权路径长度 WPL 最小的二叉树称做赫夫曼树。也有不少书中也称为最优二叉树,我个人觉得胃里纪念做出巨大贡献的科学家,既然用他们的名字命名,就应该要坚持用他们的名字称呼,哪怕"最优"更能体现这棵树的品质也应该只作为别名。

有了赫夫曼对带权路径长度的定义,我们来计算一下图 6-12-4 这两棵树的 WPL 值。

二叉树 a 的 WPL=5x1+15x2+40x3+30x4+10x4=315。

注意:这里 5 是 A 结点的权,1 是 A 结点的路径长度,其他同理。

二叉树 b 的 WPL=5x3+15x3+40x2+30x2+10x2=220。

这样的结果意味着什么呢?如果我们现在有 10000 个学生的百分制成绩需要计算五级分制成绩,用二叉树 a 的判断方法,需要做 31500 次比较,而二叉树 b 的判断方法,只需要 22000 次比较,差不多少了三分之一量,在性能上提高不是一点点

那么现在的问题就是,图 6-12-4 的二叉树 b 这样的树是如何构造出来的,这样的二叉树是不是就是最优的赫夫曼树呢?别急,赫夫曼大叔给了我们解决的办法。

  1. 先把有权值的叶子结点按照从小到大的顺序排列成一个有序序列,即:A5,E10,B15,D30,C40。

  2. 取头两个最小权值的结点作为一个新节点 N1 的两个子结点,注意相对较小的是左孩子,这里就是 A 为 N1 的左孩子,E 为 N1 的右孩子,如图 6-12-5 所示。新结点的权值为两个叶子权值的和 5+10=15。

    《大话数据结构》笔记——第6章 树(二)_第26张图片

  3. 将 N1 替换 A 与 E,插入有序序列中,保持从小到大排列。即:N(1)15,B15,D30,C40。

  4. 重复步骤 2。将 N1 与 B 作为一个新节点 N2 的两个子节点。如图 6-12-6 所示。N2 的权值=15+15=30。

    《大话数据结构》笔记——第6章 树(二)_第27张图片

  5. 将 N2 替换 N1 与 B,插入有序序列中,保持从小到大排列。即:N(2)30,D30,C40。

  6. 重复步骤 2。将 N2 与 D 作为一个新节点 N3 的两个子结点。如图 6-12-7 所示。N3 的权值=30+30=60。

    《大话数据结构》笔记——第6章 树(二)_第28张图片

  7. 将 N3 替换 N2 与 D,插入有序序列中,保持从小到大排列。即:C40,N(3)60。

  8. 重复步骤 2。将 C 与 N3 作为一个新节点T的两个子结点,如图 6-12-8 所示。由于 T 即是根结点,完成赫夫曼树的构造。

    《大话数据结构》笔记——第6章 树(二)_第29张图片

此时的图 6-12-8 二叉树的带权路径长度 WPL=40x1+30x2+15x3+10x4+5x4=205。与图 6-12-4 的二叉树 b 的 WPL 值 220 相比,还少了 15。显然此时构造出来的二叉树才是最优的赫夫曼树

不过现实总是比理想要复杂得多,图 6-12-8 虽然是赫夫曼树,但由于每次判断都要两次比较(如根结点就是a<80 && a>=70,两次比较才能得到y或n的结果),所以在总体性能是哪个,反而不如图 6-12-3 的二叉树性能高。当然这并不是我们要讨论的重点了。

通过刚才的步骤,我们可以得出构造赫夫曼树的赫夫曼算法描述

  1. 根据给定的 n 个权值 {w1,w2,…,wn} 构成 n 棵二叉树的集合 F={T1,T2,…,Tn},其中每棵二叉树 Ti 中只有一个带权为 Wi 根结点,其左右子树均为空。
  2. 在 F 中选取两棵根结点的权值最小的树作为左右子树构造一棵新的二叉树,且置新的二叉树的根结点的权值为其左右子树上根结点的权值之和。
  3. 在 F 中删除这两棵树,同时将新得到的二叉树加入 F 中。
  4. 重复 2 和 3 步骤,直到 F 只含一棵树为止。这棵树便是赫夫曼树。

6.12.3 赫夫曼编码

当然,赫夫曼研究这种最优树的目的不是为了我们可以转化一下成绩。他的更大目的是为了解决当年远距离通信(主要是电报)的数据传输的最优化问题

比如我们有一段文字内容为 “BADCADFEED” 要网络传输给别人,显然用二进制的数字(0和1)来表示是很自然的想法,我们现在这段文字只有六个字母 ABCDEF,那么我们可以用相应的二进制数据表示,如表 6-12-2 所示。

《大话数据结构》笔记——第6章 树(二)_第30张图片

这样真正传输的数据就是编码后的 “001000011010000011101100100011”,对方接收时可以按照 3 位一分来译码。如果一篇文章很长,这样的二进制串也将非常的可怕。而且事实上,不管是英文、中文或是其他语言,字母或汉字的出现频率是不相同的,比如英语中的几个元音字母 “a e i o u”,中文中的 “的 了 有 在” 等汉字都是频率极高。

假设六个字母的频率为 A 27,B 8,C 15,D 15,E 30,F 5,合起来正好是100%。那就意味着,我们完全可以重新按照赫夫曼树来规划它们

图 6-12-9 左图为构造赫夫曼树的过程的权值显示。右图为将权值左分支改为 0,右分支改为 1 后的赫夫曼树。

《大话数据结构》笔记——第6章 树(二)_第31张图片

此时,我们对这六个字母用其从树根到叶子所经过路径的 0 或 1 来编码,可以得到如表 6-12-3 所示这样的定义。

《大话数据结构》笔记——第6章 树(二)_第32张图片

我们将文字内容为 “BADCADFEED” 再次编码,对比可以看到结果串变小了

  • 原编码二进制串:001000011010000011101100100011(共30个字符)
  • 新编码二进制串:1001010010101001000111100(共25个字符)

也就是说,我们的数据被压缩了,节约了大约 17% 的存储或传输成本。随着字符的增加和多字符权重的不同,这种压缩会更加显出其优势。

当我们接收到 1001010010101001000111100 这样压缩过的新编码时,我们应该如何把它解码出来呢

编码中非 0 即 1,长短不等的话其实是很容易混淆的,所以若要设计长短不等的编码,则必须是任一字符的编码都不是另一个字符的编码的前缀,这种编码称做前缀编码。

你仔细观察就会发现,表 6-12-3 中的编码就不存在容易与 1001,1000 混淆的 “10” 和 “100” 编码。

可仅仅是这样不足以让我们去方便地解码的,因此在解码时,还是要用赫夫曼树,即发送方和接收方必须要约定好同样的赫夫曼编码规则

当我们接收到 1001010010101001000111100 时,由约定好的赫夫曼树可知,1001 得到第一个字母是 B,接下来 01 意味着第二个字符是 A,如图 6-12-10 所示,其余的也相应的可以得到,从而成功解码。

《大话数据结构》笔记——第6章 树(二)_第33张图片

一般地,设需要编码的字符集为 {d1,d2,…,dn},各个字符在电文中出现的次数或频率集合为 {W1,W2,…,Wn},以 d1,d2,…,dn 作为叶子结点,以 W1,W2,…,Wn 作为相应叶子结点的权值来构造一棵赫夫曼树。规定赫夫曼树的左分支代表 0,右分支代表 1,则从根结点到叶子结点所经过的路径分支组成的 0 和 1 的序列便为该结点对于字符的编码,这就是赫夫曼编码。

6.13 总结回顾

终于到了总结的时间,这一章与前面章节相比,显得过于庞大了些,原因也就在于树的复杂性和变化丰富度是前面的线性表所不可比拟的。即使在本章之后,我们还要讲解关于树这一数据结构的相关知识,可见它的重要性。

开头我们提到了树的定义,讲到了递归在树定义中的应用。提到了如子树、结点、度、叶子、分支结点、双亲、孩子、层次、深度、森林等诸多概念,这些都是需要在理解的基础上去记忆的。

我们谈到了树的存储结构时,讲了双亲表示法、孩子表示法、孩子兄弟表示法等不同的存储结构。

并由孩子兄弟表示法引出了我们这章中最重要一种树,二叉树。

二叉树每个结点最多两棵子树,有左右之分。提到了斜树,满二叉树、完全二叉树等特殊二叉树的概念。

我们接着谈到它的各种性质,这些性质给我们研究二叉树带来了方便。

二叉树的存储结构由于其特殊性使得既可以用顺序存储结构又可以用链式存储结构表示。

遍历是二叉树最重要的一门学问,前序,中序,后序以及层序遍历都是需要熟练掌握的知识。要让自己学会计算机的运行思维去模拟递归的实现,可以加深我们对递归的理解。不过,并非二叉树遍历就一定要用到递归,只不过递归的实现比较优雅而已。这点需要明确。

二叉树的建立自然也是可以通过递归来实现。

研究中也发现,二叉链表有很多浪费的空指针可以利用,查找某个结点的前驱和后继为什么非要每次遍历才可以得到,这就引出了如何构造一棵线索二叉树的问题。线索二叉树给二叉树的结点查找和遍历带来了高效率。

树、森林看似复杂,其实它们都可以转化为简单的二叉树来处理,我们提供了树、森林与二叉树的互相转换的办法,这样就使得面对树和森林的数据结构时,编码实现成为了可能。

最后,我们提到了关于二叉树的一个应用,赫夫曼树和赫夫曼编码,对于带权路径的二叉树做了详尽地讲述,让你初步理解数据压缩的原理,并明白其是如何做到无损编码和无错解码的。

6.14 结尾语

你可能感兴趣的:(#,《大话数据结构》笔记)