树
图一
图二
相关术语
- 前驱:某结点上一层结点,图中H结点的前驱结点是F
- 后继:某结点紧跟的后面的结点,图中F结点的后继是G、H、I三个结点
- 根结点:非空树没有前驱结点的结点,图中的R结点
- 结点的度:结点拥有子树的个数,图中R结点度为3
- 树的度:所有结点度的最大值,图中为3
- 叶子结点(终端结点):子树(或者说度)数为0
- 孩子结点:后继的了一种表示方法,G、H、I是F的孩子结点
- 双亲结点:前驱结点的另一种表示方法,G、H、I的双亲结点是F
- 兄弟结点:有共同的双亲结点,如G、H、I三个结点
- 堂兄弟结点:同行(层)结点,如A、B、C三个结点
- 结点的祖先:从根结点到某结点途中分支经过的所有结点,比如说H结点的祖先结点是F、C、R
- 结点的子孙:从某结点往下所有分支的结点,比如说C结点的子孙结点是F、G、H、I
- 树的深度(高度):最大层次,这里面是4
- 有序树:树中结点的各子树从左至右是有序的,图二中三个子树,child1必须在左侧、child2必须在中间、child3必须在右侧
- 森林:m棵(m是非负整数)互不相交的树(独立的树)的集合
二叉树
- 最多两个分叉(即根结点下面只有左右子树)的树
- 每个结点最多有两个子树
- 有左右之分,不能颠倒
图三
性质
- 第i行最多有pow(2, i-1)个结点,其中i是不小于1的整数
- 深度为i的二叉树最多一共有pow(2, i)-1个结点
- 叶子结点个数为k,度为2的结点个数为x,则k=x+1
满二叉树
- 即满足二叉树性质二的二叉树
完全二叉树
- 按我的理解是 : 把一个n层的满二叉树,把1~(2n-1)按照每层从左到右排序给结点排序,从第(2n-1)个结点(也就是对应满二叉树最右下角那个一个结点)连续去掉任意结点的树,都是完全二叉树,而且有个特点——除了根结点,左值都是偶数,右值都是奇数
- 具有n个结点的完全二叉树的深度为int(log(2, n))+1
二叉树的存储结构
顺序存储结构:使用数组,按照满二叉树的结点层次编号,依次存放二叉树中的数据元素
- 特点:空间浪费,只适用于满二叉树和完全二叉树的存储
#define MAXSIZE 100
typedef int TElemType;
typedef TElemType SqBiTree[MAXSIZE];
链式存储结构
二叉链表:每个结点包含三个字段:数据域、左子结点指针和右子结点指针。
- 有n个结点二叉链表中,有n+1个空指针域
typedef struct BiNode {
TElemType data;
// 左右孩子指针
struct BiNode * lchild, * rchild;
} BiNode, *BiTree;
三叉链表:每个结点包含四个字段:数据域、左子结点指针、双亲结点指针和右子结点指针
typedef struct TriTNode {
TElemType data;
struct TriTNode *lchild, *parent, *rchild;
}TriTNode, *TriTree;
遍历二叉树的方法
- 若规定先左后右遍历,L是左子树、D是根、R是右子树
- 先(根)序遍历(DLR)
- 中(根)序遍历(LDR)
- 后(根)序遍历(LRD)
如图
- 如果采用先序遍历,得到:ABELDHMIJ
- 如果采用中序遍历,得到:ELBAMHIDJ
- 如果采用后序遍历,得到:LEBMIHJDA
使用三种遍历方式表示表达式
- 表达式的前缀表示,又称波兰式
- 表达式的中缀表示
- 表达式的后缀表示,又称逆波兰式
由三种遍历方式的两两组合是否可以倒推二叉树
- 先序遍历和中序遍历可以
- 中序遍历和后序遍历可以
- 先序遍历和后序遍历却不可以
三种递归遍历的复杂度
- 时间复杂度:都是O(n)
- 空间复杂度:最坏情况都是O(n)
二叉树的其他方法
深度
- 如果是空树,返回0
- 否则,分别递归计算左子树的深度为m,右子树的深度为n,返回二者最大的那个加上1
结点个数
- 如果是空树,返回0
- 否则,分别递归计算左子树的结点与右子树的结点m、n,求和加上1
叶子结点个数
- 如果是空树,返回0
- 否则,分别计算走左右子树的叶子结点个数,相加返回
- 另外,如果左右子树为空,那么此时的根结点属于叶子结点
线索二叉树
- 为了解决寻找特定遍历序列中二叉树结点的前驱和后继
- 解决方法1:通过遍历寻找,但是费时间
- 解决方法2:再添加前驱和后继指针域,但是增加了存储负担
- 解决方法3:利用二叉链表的空指针域,如果左孩子为空,就把空的左孩子指针域指向其前驱;如果右孩子为空,就把右孩子指针域指向后继。这种改变指向的指针就称为线索
构造思路
- 为了区分指针是指向孩子结点还是前驱后继,额外增添两个标志域来辨别,ltag和rtag
- ltag为0说明是左指针指向左子树,是1说明左指针指向前驱结点
- rtag为0说明是右指针指向右子树,是1说明右指针指向后继结点
相关代码
typedef struct ThreadedBiTreeNode{
TElemType data;
int ltag, rtag;
struct ThreadedBiTree *lchild, *rchild;
} ThreadedBiTreeNode, *ThreadedBiTree;
树的存储结构
双亲表示法
- 定义结构数组
- 存放树的结点
- 每个结点含有两个域:数据域和双亲域
- 数据域:存放结点本身信息
- 双亲域:指示本结点的双亲结点在数组中的位置
如图
特点
- 找双亲容易,找孩子麻烦
相关代码
// 树的双亲表示
typedef struct TreeNode {
// 当前索引对应结点的数据
TElemType data;
// 当前索引对应结点的双亲索引
int parent;
} TreeNode;
#define MAX_TREE_SIZE 100
typedef struct {
TreeNode treeNode[MAX_TREE_SIZE];
// 根结点的索引位置与实际结点个数(因为最大个数不一定存满)
int root, count;
} PTree;
孩子表示法
- 把每个结点的孩子排列起来,看做成一个单链表,则n个结点有n个孩子链表(叶子的孩子链表为空)。而且n个头指针又组成了一个顺序表(含有n个元素的数组)存储
如图
特点
- 找孩子容易,找双亲难
相关代码
// 树的孩子表示法
typedef struct CTNode {
TElemType data; // 这个地方写成结点的索引也可以 int index;
struct CTNode * child;
}CTNode;
#define MAX_NODE_SIZE 100
typedef struct {
CTNode ctNode[MAX_NODE_SIZE];
// 根结点的索引位置与实际结点个数(因为最大个数不一定存满)
int root, count;
} CTree;
孩子兄弟表示法
- 使用二叉链表作为树的存储结构,链表的每个结点的两个指针域分别指向其第一个孩子结点和下一个兄弟结点
- 又称二叉树表示法
如图
特点
相关代码
// 树的孩子兄弟表示法
typedef struct CBNode{
TElemType data;
struct CBNode *firstChild, *nextSibling;
} CBNode;
树与二叉树的转换
- 将树转化为二叉树进行处理,利用二叉树的算法进行对树的操作
- 给定的一棵树,可以找到唯一的二叉树与之对应
- 由于树与二叉树都可以用二叉链表(孩子兄弟存储结构)作为存储结构,则以二叉链表作为媒介可以实现树与二叉树的一一对应关系
- 实质就是实现二者的互相解释器
树转换为二叉树
- 兄弟直接加上连线
- 对每个结点,除了其左孩子以外,去除其与其余孩子之间的关系
- 以树的根结点为轴心,将整树顺时针旋转45°
二叉树转换为树
- 若p结点是双亲结点的左孩子,则将p的右孩子,右孩子的右孩子……沿着分支找到所有的右孩子,都与p的双亲用线连起来
- 抹掉原来二叉树中双亲与右孩子之间的连线
- 将结点按层次排列,形成树结构
森林转换为二叉树
- 将各棵树分别转换为二叉树
- 将每棵树的根结点用线相连
- 以第一颗树根结点作为二叉树的根,再以根结点为轴心,顺时针旋转,构成二叉树型结构
二叉树转换为森林
- 将二叉树中根结点与其右孩子相连,以及右孩子的右孩子……的所有右孩子之间的连线全部抹掉,使之变成独立的二叉树(所有根结点与其所有右孩子的连线全部抹掉)
- 然后将每个独立的二叉树还原成树
树的三种遍历方式
- 先根遍历:若树不为空,则先访问根结点,然后依次先根遍历(递归)各棵子树
- 后根遍历:若树不为空,则先依次后根遍历各棵子树,然后访问根结点
- 按层次遍历,若树不为空,则从上到下,从左到右访问树中的各个结点
- 先根遍历顺序:ABCDE
- 后根遍历顺序:BDCEA
- 层次遍历顺序:ABCED
森林的遍历
将森林看作三个部分组成:
- 森林中第一棵树的根结点(第一部分)
- 森林中第一棵树的子树森林(第二部分)
- 森林中其它树构成的森林(第三部分)
森林的遍历方式
先序遍历:实质就是对从左到右的每棵树进行树的先根遍历
- 访问森林的第一部分
- 先序遍历(递归)森林中的第第二部分
- 先序遍历森林中的第三部分
中序遍历:实质就是从左到右对每棵树进行树的后根遍历
-
中序遍历森林中的第二部分
-
访问森林中的第一部分
-
中序遍历森林中的第三部分
-
树的先序遍历顺序:ABCDEFGHIJ
-
树的中序遍历顺序:BCDAFEHJIG
哈夫曼树(最优二叉树)
- 给定N个权值作为N个叶子结点,构造一棵二叉树,若该树的带权路径长度达到最小,称这样的二叉树为最优二叉树,也称为哈夫曼树(Huffman Tree)。哈夫曼树是带权路径长度最短的树,权值较大的结点离根较近。(摘自知乎文章,我觉得这种说法最适合我理解)
- 老师给出的定义:树的带权路径长度最短的树
相关术语
- 路径:从树中一个结点到另一个结点之间分支构成这两个结点的路径
- 结点的路径长度:两个结点路径之间上的分支数
- 树的路径长度:从根结点到每一个结点的路径之和。结点数目相同的二叉树中,完全二叉树的路径长度是最短的(这只是充分条件)
- 权(权重):将树中结点赋值
- 结点的带权路径长度:从根结点到某结点之间的路径长度与该结点的权的乘机
- 树的带权路径长度:树当中所有叶子结点的带权路径长度之和
- 哈夫曼算法:构造哈夫曼树的算法
特点
- 满二叉树不一定是哈夫曼树
- 哈夫曼树中权越大的叶子离根越近
- 具有相同的带权结点的哈夫曼树不唯一
- 哈夫曼树中结点的度只有0或者2,没有度为1的结点
- 包含n个叶子结点的哈夫曼树中共有2n-1个结点
哈夫曼树的构造
- 根据n个给定的权值{W1, W2, W3, …, Wn},构成n棵二叉树组成的森林F,每个二叉树只有一个根结点,权重为Wi
- 在F中选取两个根结点权值最小的二叉树作为左右子树,构造一棵新的二叉树,且设置新的二叉树的根结点的权值为左右子树根结点的权值之和
- 在F中删除这两个树(就是上一步用于构成新的二叉树的左右子树),同时将新得到的二叉树添加到森林中
- 重复第二步与第三步,直到森林中只剩下一棵树,就是哈夫曼树
哈夫曼算法的实现
- 采用顺序存储——一维结构数组
- 结构如下
typedef struct {
// 假设权重取整数值
int weight;
// 分别是双亲结点、左孩子、右孩子的索引值,-1代表没有
int parent, lchild, rchild;
} HTNode, *HuffmanTree;
算法代码
// 初始化哈夫曼树,传入叶子结点的值组成的数组和叶子结点个数n(至少是2)
HuffmanTree createHT(const int weights[], int n){
HuffmanTree huffmanTree = (HTNode *) malloc((n * 2-1) * sizeof (HTNode));
for (int i=0;i<(2*n-1);i++){
huffmanTree[i].lchild = -1;
huffmanTree[i].rchild = -1;
huffmanTree[i].parent - -1;
}
for (int j=0;j<n;j++) huffmanTree[j].weight = weights[j];
return huffmanTree;
}
// 销毁哈夫曼树
void destroyHT(HuffmanTree huffmanTree){
free(huffmanTree);
}
// 根据哈夫曼算法实现哈夫曼树
HuffmanTree realizeHT(HuffmanTree huffmanTree, int n){
// 进行n-1次合并
// 每次在当前森林里面的二叉树中找到两个根结点权值最小的
// 在剩下的n-1个顺序表中的结点逐个补全,这n-1个结点组成的子顺序表就是哈夫曼树
for (int i=n;i<2 * n-1;i++){
// 在huffmanTree[k](0<= k <= i)中选择两个其双亲域为-1,而且权值最小的结点,并返回它们在huffmanTree中的序号s1和s2
Select(huffmanTree, i-1, s1, s2);
// 表示从森林中删除s1,s2
huffmanTree[s1].parent = i;
huffmanTree[s2].parent = i;
// s1,s2分别作为新二叉树的左右孩子
huffmanTree[i].lchild = s1;
huffmanTree[i].rchild = s2;
huffmanTree[i].weight = huffmanTree[s1].weight + huffmanTree[s2].weight;
}
}
哈夫曼编码
- 是一种无损压缩算法,通过对不同的符号赋予不同的变长编码,使得频率较高的符号具有较短的编码,而出现频率低的符号具有较长的编码,从而实现对数据进行高效压缩。
特点
- 统计每个字符在电文中的出现的平均概率,概率越大,要求编码越短
- 把每个字符的概率值作为权值,构造哈夫曼树,概率越大的结点,路径越短
- 在哈夫曼树的每个分支上标上0或者1,结点的左分支标0,右分支标1;把从根到每个叶子的路径上的标号连接起来,作为该叶子代表的字符的编码
- 哈夫曼编码可以保证是前缀编码(一种不会产生歧义的编码),因为没有一片树叶是另一树叶的祖先,所以每个叶结点的编码就不可能是其它叶结点编码的前缀(就是一个编码不可能是另一个编码的某片段截取)
- 能保证字符编码的总长度最短。哈夫曼树本身就是带权路径最短的树,所以编码的总长度最短
声明:
部分图片源于互联网、部分更通俗易懂的概念源于搜索
教学看的是青岛大学王卓老师的
源代码下载:https://gitee.com/PythonnotJava/Data-structure-and-algorithm-collection/tree/master/ReTree