1. 概念
与线性结构的“一对一”不同,树是“一对多”的数据结构。
树是有限个结点n(n >= 0)的集合。
- n为0时称为空树;
- 不为0时,有且只有一个结点作为树的根结点。
- n大于1时,除根结点外的其他结点可以分为m(m > 0)个互不相交的有限集合T1...Tm,每个子集合又是一棵树,称为子树。
下图即为一棵树:
1.1 树的“度”
每个结点包含的子结点的个数称为结点的“度”(degree)。
度为0的结点为“叶子结点”或“终端结点”;度不为0的结点称为“分支结点”或“非终端结点”。
我们将一棵树中所有子结点的“度”中的最大值称作树的度。
示意图中树的度为3(结点D的度最大,是3)。
1.2 结点间的关系
结点的子树中的根结点叫做该结点的孩子(Child)结点;该结点称为孩子的双亲(Parent)结点;同一双亲结点的子结点互为兄弟(Sibling)结点。
例如,在示意图中,结点D是B的孩子结点,C是B的兄弟结点,A是B的双亲结点。
注:以下将双亲结点简称为“父结点”,孩子结点称为“子结点”。
1.3 树的“深度”
从根结点开始,作为树的第一层(Level);其子结点作为第二层,以此类推。
树的最大层数称为该树的“深度”(Depth)。
由于示意图中的树分为四层,故其深度为4。其中,同一层的结点互为堂兄弟结点。如第三层的D、E和F结点。
1.4 其他概念
- 若树从左至右为有序,且各子结点的顺序不可调换,则此树可以称为“有序树”。
- 森林(Forest)是m(m >= 0)棵互不相交的树的集合。
1.5 结构对比
与线性结构的对比如下:
线性结构:
- 头元素:无前驱结点
- 尾元素:无后继结点
- 中间元素:一个前驱结点,一个后继结点
树结构:
- 根结点:无父结点,唯一
- 叶子结点:无子结点,自身可以有多个
- 分支结点:有父结点,存在1个或多个子结点
2. 存储结构
利用顺序和链式存储方式,我们可以将树的存储方式简单介绍三种:
- 双亲表示法
- 孩子表示法
- 孩子兄弟表示法(二叉树)
2.1 双亲表示法
双亲表示法使用顺序存储结构,将所有结点依次存储在连续的空间中。
最简单的就是使用一个parent域来标明自身的父结点。其结构如下:
data | parent |
---|---|
数据域 | 父结点索引(数组下标) |
以示意图为例,使用双亲表示法可以表示为:
下标 | data | parent |
---|---|---|
0 | A | -1 |
1 | B | 0 |
2 | C | 0 |
3 | D | 1 |
4 | E | 2 |
5 | F | 2 |
6 | G | 3 |
7 | H | 3 |
8 | I | 3 |
9 | J | 4 |
根据此存储方式,可以直接查询出每个子结点的父结点,其时间复杂度固定为O(1)。
可以根据需要,对结点的数据结构进行扩展:如添加指向第一个孩子结点的索引域firstChild;添加指向兄弟结点的索引域rightSib等。这种方式可以适时通过扩展并添加存储空间来提高访问效率。
2.2 孩子表示法
由于每个结点的子结点个数不确定,可以通过链表来表示每个结点下的子树。且由于我们在需要时可以方便地遍历树中的所有结点,可以考虑将所有结点保存在顺序存储结构中。故孩子表示法的具体方法为:把每个结点的孩子结点排列起来,以单链表为存储结构,则n个结点有n个孩子链表,如果该结点是叶子结点,则此单链表为空表。然后,将n个头指针又组成一个线性表,以顺序存储结构存储在一个一维数组中。
故示意图中的树,使用孩子表示法可以表示为:
其中结点分为两种:
- 孩子链表中的孩子结点:
child | next |
---|---|
结点索引(顺序数组的下标) | 兄弟结点索引 |
- 表头数组的表头结点
data | firstChild |
---|---|
数据域 | 头指针域(存储对应孩子链表的头指针) |
对于此存储结构,要查找某结点的孩子结点,或查找某结点的兄弟结点,只要查找对应的孩子结点单链表即可。
2.3 孩子兄弟表示法
任意一棵树,它的结点的第一个孩子如果存在就是唯一的,它的右兄弟如果存在也是唯一的。因此此节点包括两个指针域,分别指向该结点的第一个孩子结点和此结点的右兄弟结点。
data | firstChild | rightSib |
---|---|---|
数据域 | 第一个孩子结点的存储地址 | 右兄弟结点的存储地址 |
故示意图中的树,使用孩子兄弟表示法可以表示为:
使用此方式,可以很方便地查找某结点的第几个子结点:只要找到该结点的第一个孩子结点后,依次查找其兄弟结点即可。
通过此种表示方法,我们可以发现,标准的树被转换成了二叉树表示(每个结点最多只有两个子结点)。
3. 二叉树
3.1 定义
- 每个结点最多有两棵子树(子结点),故二叉树的度不超过2。
- 左右子树有序,不能颠倒。
- 结点若只有一棵子树,也要区分是左还是右子树。
如下图,二者是两棵不同的二叉树:
3.2 存储结构
3.2.1 顺序存储结构
二叉树的顺序存储结构一般只适用于完全二叉树**。
完全二叉树:
- 叶子结点只出现在二叉树的最后两层
- 最后一层的叶子结点都是左结点;倒数第二层的叶子结点都是右结点
- 满二叉树(只有最后一层存在,且全都是叶子结点的二叉树)属于完全二叉树
如图所示,用一维数组存储二叉树的所有结点,通过数组下标来提现二叉树中结点间的关系。
但是,当二叉树中有过多空余结点(如图中的黄色不存在结点)时会导致空间浪费,故一般只使用顺序结构存储完全二叉树。
3.2.2 链式存储结构--二叉链表
二叉链表:每个数据节点包含本身的数据域和两个指针域,分别指向两个可能的孩子结点,这种结点组成的链表称为二叉链表。
结点结构如下:
lchild | data | rchild |
---|---|---|
左孩子结点的存储地址 | 数据域 | 右孩子结点的存储地址 |
/** 二叉链表的结点结构体 */
typedef struct BiTNode {
TElemType data; // 数据域
struct BiTNode *lchild; // 左孩子结点指针
struct BiTNode *rchild; // 右孩子结点指针
} BiTNode, *BiTree;
下图为二叉链表的表述示意图:
3.3 二叉树的遍历
二叉树的遍历(traversing binary tree)是指从根结点出发,按照某种次序依次访问二叉树中的所有结点,使得每个结点被访问一次且仅被访问一次。
若以从左到右方向限定,主要的遍历方式分为以下四种:
- 前序遍历
- 中序遍历
- 后序遍历
- 层序遍历
与定义二叉树的方式一样,其遍历也是以递归的方式进行。
3.3.1 前序遍历
遍历方式:先访问根节点,然后前序遍历左子树,再前序遍历右子树。
void PreOrderTraverse(BiTree T) {
if (T == NULL) {
return;
}
// 访问根节点
printf("%c", T->data);
// 遍历左子树
PreOrderTraverse(T->lchild);
// 遍历右子树
PreOrderTraverse(T->rchild);
}
3.3.2 中序遍历
遍历方式:先中序遍历根节点的左子树,然后是根节点,再中序遍历右子树。
void InOrderTraverse(BiTree T) {
if (T == NULL) {
return;
}
// 遍历左子树
InOrderTraverse(T->lchild);
// 访问根节点
printf("%c", T->data);
// 遍历右子树
InOrderTraverse(T->rchild);
}
3.3.3 后续遍历
遍历方式:从左到右,先叶子后结点,全部访问完左右子树后,最后访问根结点。
void PostOrderTraverse(BiTree T) {
if (T == NULL) {
return;
}
// 遍历左子树
PostOrderTraverse(T->lchild);
// 遍历右子树
PostOrderTraverse(T->rchild);
// 访问根节点
printf("%c", T->data);
}
3.4 二叉树的创建
这里以前序方式创建。与前序遍历相同,创建时按照前序遍历的方式依次递归输入结点数据即可:
/** 创建二叉树【前序遍历法创建:根--左--右】 */
void CreateBiTree(BiTree *T) {
TElemType ch;
scanf("%c", &ch);
if (ch == '#') {
*T = NULL; // 代表此结点位置无数据(数据为空)
return;
} else {
// 分配空间,创建新结点
*T = (BiTree)malloc(sizeof(BiTNode));
// 内存分配失败,退出
if (!*T) {
exit(OVERFLOW);
}
// 前序遍历方式赋值
// 1. 赋值根节点
(*T)->data = ch;
// 2. 左孩子结点递归
CreateBiTree(&((*T)->lchild));
// 3. 右孩子结点递归
CreateBiTree(&((*T)->rchild));
}
}
如下图所示,依照代码,先将给定的二叉树转化为扩展二叉树**。
扩展二叉树:
将给定的二叉树的每个结点的空指针设置一个虚拟结点,并指定一个特殊值(这里定义为“#”)。
二叉树的创建与三种遍历方式,可查看此示例:项目地址
3.5 线索二叉树
3.5.1 线索化过程
此二叉链表虽然功能强大,但弱点显而易见:就是每次只能通过指定方式的遍历,才可以确定每个结点的前趋结点和后缀结点。而且我们可以发现,在下面的二叉链表示意图中,有n+1(即2n个总lchild和rchild指针个数 与 n-1个连线 的差)个空余指针域(存储的是“^”),随着二叉树的增长,浪费的空间会越来越多。
因此,我们可以使用这些空余空间来保存每个结点的前趋和后继结点的信息,以解决上述问题。而解决此问题的过程,叫做线索化,线索化后的二叉链表叫做线索链表,对应的树叫做线索二叉树。
具体做法是:在使用某种顺序遍历每个结点时,若没有左孩子结点,其lchild保存的是前趋结点的指针;若没有右孩子结点,其rchild保存的是后继结点的指针。
如上图中的二叉树,其中序遍历后的结果为HDIBJEAFCG,根据遍历结果对该二叉链表进行线索化的结果如下:
可以看到,通过线索化,不仅解决了空间浪费问题,还解决了前趋和后继结点的记录问题,提高了访问效率。
3.5.2 区分结点类型
此时线索二叉树还存在一个问题:我们无法区分lchild指向的结点是前趋结点还是左孩子节点,对于rchild也是如此。
此时,需要针对每个指针域分别设置一个布尔类型的数据域,在线索化的过程中,根据实际情况进行区分(是前趋或后继结点时为1,是孩子结点时为0)。
结点结构如下:
左孩子结点地址 | 左标识符 | 数据域 | 右标识符 | 右孩子结点地址 |
---|---|---|---|---|
lchild | ltag | data | rtag | rchild |
对应实现如下:
/** 枚举变量,用于标识符赋值 */
typedef enum {
/** 是孩子结点 */
Link,
/** 是前趋或后缀结点 */
Thread
} PointerTag;
/** 线索二叉树结点结构 */
typedef struct BiThrNode {
/** 数据域 */
TElemType data;
/** 左结点指针 */
struct BiThrNode *lchild;
/** 左结点标识符 */
PointerTag LTag;
/** 右结点指针 */
struct BiThrNode *rchild;
/** 右结点标识符 */
PointerTag RTag;
} BiThrNode, *BiThrTree; // 定义为线索二叉树结点及线索二叉树指针
添加结点类型区分后的线索二叉树如下:
这样在访问指定节点时,根据ltag和rtag的值即可区分相邻结点是前趋后继结点或是孩子结点了。
线索化过程的代码如下(这里使用中序遍历方式):
/** 当前访问节点 */
BiThrTree currentP = NULL;
/** 中序遍历线索化 */
void InThreading(BiThrTree p) {
if (!p) {
// 结点不存在,返回
return;
}
if (!p->lchild && !p->rchild) {
// 左右子结点均不存在,即是叶子结点【防止最后一个结点无修改RTag】
p->LTag = Thread;
p->RTag = Thread;
}
// 递归线索化左结点
InThreading(p->lchild);
// 自身数据线索化(由于是中序遍历【左--中--右】,此时右结点还没有访问到,故只处理当前和前一个结点即可)
if (!(p->lchild)) {
// 左结点为空,即没有左孩子,故当前指向前趋结点
p->LTag = Thread;
p->lchild = currentP;
} else {
// 包含左孩子
p->LTag = Link;
}
if (currentP) {
if (!(currentP->rchild)) {
// 前一个结点的右结点指针为空,即没有右孩子,故指向后继结点(当前结点)
currentP->RTag = Thread;
currentP->rchild = p;
} else {
// 包含右孩子
currentP->RTag = Link;
}
}
// 记录当前结点
currentP = p;
// 递归线索化右结点
InThreading(p->rchild);
}
由上图即可看出,如果在二叉树的根结点之前再插入一个头结点,此时的线索二叉树实际上就是一个双向链表结构。其结构示意图如下:
插入头结点的简单方法如下:
/** 向线索二叉树中插入头结点 */
BiThrTree insertHeadNodeToBiThrTree(BiThrTree biTree) {
if (biTree == NULL) {
return NULL;
}
// 创建头节点
BiThrTree headNode = (BiThrTree)malloc(sizeof(BiThrNode));
// 左孩子指向线索二叉树的根结点
headNode->lchild = biTree;
headNode->LTag = Link;
// 右孩子指向中序遍历的最后一个结点
BiThrTree inOrderTailNode = biTree;
while (inOrderTailNode->RTag == Link) {
inOrderTailNode = inOrderTailNode->rchild;
}
// 此时inOrderTailNode即为最后一个结点
headNode->rchild = inOrderTailNode;
headNode->RTag = Thread; // 并不是右孩子
// targetNode的后继结点指向头结点
inOrderTailNode->rchild = headNode;
// 中序遍历的第一个结点的前趋结点指向头结点
BiThrTree inOrderFirstNode = biTree;
while (inOrderFirstNode->LTag == Link) {
inOrderFirstNode = inOrderFirstNode->lchild;
}
// 此时inOrderFirstNode即为第一个结点
inOrderFirstNode->lchild = headNode;
return headNode;
}
最终,通过访问双向链表的方式,中序遍历输出此二叉树的方式如下:
/** 中序遍历【扫描二叉链表方式】 */
void InOrderTraverse_Thr(BiThrTree T) {
// 中序遍历,即从头结点开始扫描,
// 1. 首先找到最左边的结点(没有左孩子的),
// 2. 然后找其后继结点(子树的根),
// 3. 最后是其右结点(右孩子或后继)
// 直到扫描到的结点是头结点,结束
// 指向根结点(从根结点开始)
BiThrTree currentNode = T->lchild;
// 遍历结束时,指向头结点
while (currentNode != T) {
// 1. 找到当前子树最左边的结点(没有左孩子的)
while (currentNode->LTag == Link) {
// 有左孩子,继续查找
currentNode = currentNode->lchild;
}
// 找到了最左边的结点,输出
printf("%hhd ", currentNode->data);
// 2. 一直向后找到所有后继结点(即对应子树的根)
while (currentNode->RTag == Thread && currentNode->rchild != T) {
currentNode = currentNode->rchild;
// 找到了后继结点,输出
printf("%hhd ", currentNode->data);
}
// 3. 指向右结点(下一个结点,不管结点类型,准备下次循环)
currentNode = currentNode->rchild;
}
}
线索化及遍历的完整过程,可查看此示例:项目地址
3.6 树、森林转换为二叉树
由于二叉树的结构稳定(每个结点只有左右两个孩子),其性质也容易研究,故在某些情况下,将普通的树甚至森林转换为二叉树即可对它们进行问题的研究(如遍历等)。
3.6.1 树 => 二叉树
转换规则:
- 在兄弟结点与其左方的结点之间添加连接线。
- 在结点的所有子孩子中,除了左边第一个孩子结点外,其他所有兄弟孩子结点与父结点间的连线去除。
- 调整层次,结点的第一个子孩子作为二叉的左孩子,兄弟孩子作为“前一个”结点的右孩子。
示例:
转换过程:
3.6.2 森林 => 二叉树
可以将森林中的每一棵树看做是兄弟,利用兄弟结点的合并方式(作为前一个结点的右孩子)进行合并,最终合成一棵二叉树:
- 把每一棵树转换为二叉树。
- 依次将后一棵树的根结点作为前一棵树的根结点的右孩子。连线完成后,即可得到合成的二叉树。
示例:
转换过程:
3.7 赫夫曼树及霍夫曼编码
3.7.1 概念介绍
赫夫曼树是在二叉树的基础上设计而来。赫夫曼树的每个叶子结点都包含对应的权值(Weight)。两个结点之间经过的所有分支叫做路径,路径上分支的个数叫做路径长度。树的路径长度就是从根结点到每个叶子结点的路径长度的总和**。
图中的二叉树的路径长度为(以A-E的顺序):3 + 3 + 2 + 2 + 2 = 12。
霍夫曼树需要引入权的概念,故我们得到了带权路径的算法:
结点的带权路径 = 结点从根到自身的路径长度 * 自身权重
因此树的带权路径长度即为所有叶子结点带权路径的总和,称作WPL。
图中二叉树的WPL为:3 * 5 + 3 * 15 + 2 * 40 + 2 * 30 + 2 * 10 = 220。
在带有权重的一组叶子结点所组成的二叉树中,WPL最小的可称作霍夫曼树。
3.7.2 生成方法
- 将所有结点按照权值升序排列,生成有序树集合(每棵树即为单独的叶子结点)。
- 取前两棵树(权值最小)作为左右子树,生成新二叉树,其根结点的权值为原两树权值之和。
- 在集合中使用新的二叉树替换原始的两棵树并重新排序。
- 重复步骤2和3,直到序列只剩下一棵树为止,此树即为霍夫曼树。
示例:
结点 | A | B | C | D | E |
---|---|---|---|---|---|
权值 | 5 | 15 | 40 | 30 | 10 |
3.7.3 霍夫曼编码
将霍夫曼树中所有结点(包括合成结点)的权值改为0和1(左分支权值为0,右分支权值为1),这样从根结点到指定叶子结点所生成的0和1序列即为霍夫曼编码。
上面的霍夫曼树转换后的结果为:
因此,图中每个字符所生成的霍夫曼编码如下:
字符 | A | B | C | D | E |
---|---|---|---|---|---|
霍夫曼编码 | 1000 | 101 | 0 | 11 | 1001 |
霍夫曼编码可以用来压缩数据,进而提高了传输效率。
假设传输”BADBEC“这个字符串,使用传统的等长编码,可能会使用如下方式:
字符 | A | B | C | D | E |
---|---|---|---|---|---|
等长编码 | 100 | 101 | 010 | 011 | 110 |
故在此方式下,编码后的数据为”101100011101110010“,长度为18位;而使用霍夫曼编码,编码后的数据为”1011000101101110“,长度仅为16位,故数据得到了压缩。且随着编码字符的增多,霍夫曼编码的数据优势会越来越大。
像霍夫曼编码这样长短不一的编码方式,由于容易混淆,故必须设计成任一字符的编码都不是其他字符编码的前缀(否则解码时无法区分),这种编码方式叫做前缀编码。