Tree 树


7.3.10 树(tree)

1.树的定义以及基本术语

树的定义

树是N(N >= 0 )个结点的有限集合,当N = 0 时,这棵树称为空树,这是一种特殊情况。在任意一棵非空树中,应满足:
1) 有且仅有一个特定的称为根的结点。
2)当N > 1时,其余结点可分为 m( m > 0)个互不相交的有限集合T1 , T2 , T3 , … Tm ,其中每一个集合本身又是一棵树,并且称为根结点的子树。

显然树的定义是递归的,是一种递归的数据结构。树作为一种逻辑结构,同时也是一种分层结构,具有以下两个特点:
1)树的根结点没有前驱结点,除根结点之外的所有结点有且只有一个前驱结点。
2)树中所有结点可以有零个或多个后继结点。

树适合于表示具有层次结构的数据。树中的某个结点(除根结点外)最多只和上一层的一个结点(即其父结点)有直接关系,根结点没有直接上层结点。因此在 n 个结点的树中有 n -1 条边 。而树中每个结点与其下一层的零个或多个结点(即其子女结点)有直接关系。

树的图形表示

树的基本术语

基本术语:

  • 结点的度:一个节点含有的子结点的个数称为该结点的度;
  • 终端结点(叶结点):度为0的结点称为叶结点;
  • 非终端结点:度不为0的结点;
  • 结点的层次:从根开始定义起,根为第1层,根的子结点为第2层,以此类推;
  • 树的度:一棵树中,所有的结点度的最大值;
  • 树的高度或深度:树中结点的最大层次;
  • 有序树:树中任意结点的子结点之间有顺序关系,这种树称为有序树;
  • 无序树:树中任意结点的子结点之间没有顺序关系,这种树称为无序树,也称为自由树;
  • 森林:由棵互不相交的树的集合称为森林。

结点之间的关系定义:

  • 孩子结点或子结点:一个节点含有的子树的根结点称为该结点的子结点;
  • 双亲结点或父结点:若一个节点含有子结点,则这个节点称为其子结点的父结点;
  • 子孙:以某结点为根的子树中任一节点都称为该结点的子孙;
  • 祖先:从根到该结点所经分支上的所有结点;
  • 兄弟结点:具有相同父节点的结点互称为兄弟结点;
  • 堂兄弟结点:双亲在同一层的结点互为堂兄弟;

2.二叉树

定义

每个结点最多含有两个子树的树称为二叉树,其中:
满二叉树:叶结点除外的所有节点均含有两个子树的树被称为满二叉树;
完全二叉树:除最后一层外,所有层都是满结点,且最后一层缺右边连续结点的二叉树称为完全二叉树;
二叉树具有如下五个性质:

  1. 在二叉树的第i层上最多有 2 i − 1 2^{i-1} 2i1个结点。
  2. 深度为K的二叉树最多有 2 K − 1 2^K-1 2K1个结点( K ≥ 1 K\geq1 K1
  3. 对于任意一颗二叉树BT,如果度为0的结点个数为n,度为2的结点个数为m,则 n = m + 1 n=m+1 n=m+1
  4. 具有n个结点的完全二叉树的深度为 ⌊ l o g 2 n ⌋ + 1 \lfloor log_2 n \rfloor+1 log2n+1。其中, ⌊ l o g 2 n ⌋ \lfloor log_2 n \rfloor log2n的结果是不大于 log ⁡ 2 n \log_2n log2n的最大整数。
  5. 对于有n个结点的完全二叉树中的所有结点按照从上到下,从左到右的顺序进行编号,则对于任意一个结点 i ( 1 ≤ i ≤ n ) i(1\leq i\leq n) i(1in),都有:
    1).如果i=1,则结点i是这个完全二叉树的根,没有双亲;否则其双亲的结点编号为 ⌊ i / 2 ⌋ \lfloor i/2\rfloor i/2
    2).如果2i>n,则结点i没有左孩子;否则其左孩子的编号为2i。

二叉树的存储结构

顺序存储结构

这种存储结构适用于完全二叉树。其存储方式为用一组连续的存储单元存放二又树中的结点元素,一般按照二叉树结点自上向下、自左向右的顺序存储。一棵满二叉树及其存储状态如图所示:
满二叉树存储状态
在C语言中,这种存储形式的定义如下所示:

#define MaxTreeNodeNum  100 
typedef struct{
	DataType data[MaxTreeNodeNum]; /*根存储在下表为1的数组单元中*/
	int n;						   /*当前完全二叉树的节点个数*/
}OBTree;

采用顺序存储能够最大地节省存储空间,可以利用数组元素下标值确定结点在二叉树中的位置以及结点之间的关系,即空间利用率高,寻找孩子和双亲容易。但是具有较多的空缺位置时,需要用特定的符号填补,会造成空间利用率的下降。

链式存储结构

链式存储结构是二叉树最常用的存储结构,是指用链表来表示一棵二叉树,即用链来指示元素的逻辑关系。其结构如图所示:
二叉链表结构
采用链式存储二叉树时,其节点结构由 3 部分构成:

  • 指向左孩子节点的指针(Lchild)
  • 节点存储的数据(data)
  • 指向右孩子节点的指针(Rchild);

一棵普通的二叉树,若将其采用链式存储,则只需从树的根节点开始,将各个节点及其左右孩子使用链表存储即可,其链式存储结构如下图所示:
普通二叉树链式存储结构

在C语言中,这种存储形式的定义如下所示:

typedef struct BiTNode{
    TElemType data;//数据域
    struct BiTNode *lchild,*rchild;//左右孩子指针
    struct BiTNode *parent;
}BiTNode,*BiTree;

3.二叉树的遍历

先序遍历

二叉树先序遍历的实现思想是:

  1. 访问根节点;
  2. 访问当前节点的左子树;
  3. 若当前节点无左子树,则访问当前节点的右子树;
    二叉树

以图1为例,采用先序遍历的思想遍历该二叉树的过程为:

  1. 访问该二叉树的根节点,找到 1;
  2. 访问节点 1 的左子树,找到节点 2;
  3. 访问节点 2 的左子树,找到节点 4;
  4. 由于访问节点 4 左子树失败,且也没有右子树,因此以节点 4 为根节点的子树遍历完成。但节点 2 还没有遍历其右子树,因此现在开始遍历,即访问节点 5;
  5. 由于节点 5 无左右子树,因此节点 5 遍历完成,并且由此以节点 2 为根节点的子树也遍历完成。现在回到节点 1 ,并开始遍历该节点的右子树,即访问节点 3;
  6. 访问节点 3 左子树,找到节点 6;
  7. 由于节点 6 无左右子树,因此节点 6 遍历完成,回到节点 3 并遍历其右子树,找到节点 7;
  8. 节点 7 无左右子树,因此以节点 3 为根节点的子树遍历完成,同时回归节点 1。由于节点 1 的左右子树全部遍历完成,因此整个二叉树遍历完成;

二叉树的先序遍历采用的是递归的思想,因此可以递归实现,其 C 语言实现代码为:

#include <stdio.h>
#include <string.h>
#define TElemType int
//构造结点的结构体
typedef struct BiTNode{
    TElemType data;//数据域
    struct BiTNode *lchild,*rchild;//左右孩子指针
}BiTNode,*BiTree;
//初始化树的函数
void CreateBiTree(BiTree *T){
    *T=(BiTNode*)malloc(sizeof(BiTNode));
    (*T)->data=1;
    (*T)->lchild=(BiTNode*)malloc(sizeof(BiTNode));
    (*T)->rchild=(BiTNode*)malloc(sizeof(BiTNode));
  
    (*T)->lchild->data=2;
    (*T)->lchild->lchild=(BiTNode*)malloc(sizeof(BiTNode));
    (*T)->lchild->rchild=(BiTNode*)malloc(sizeof(BiTNode));
    (*T)->lchild->rchild->data=5;
    (*T)->lchild->rchild->lchild=NULL;
    (*T)->lchild->rchild->rchild=NULL;
    (*T)->rchild->data=3;
    (*T)->rchild->lchild=(BiTNode*)malloc(sizeof(BiTNode));
    (*T)->rchild->lchild->data=6;
    (*T)->rchild->lchild->lchild=NULL;
    (*T)->rchild->lchild->rchild=NULL;
    (*T)->rchild->rchild=(BiTNode*)malloc(sizeof(BiTNode));
    (*T)->rchild->rchild->data=7;
    (*T)->rchild->rchild->lchild=NULL;
    (*T)->rchild->rchild->rchild=NULL;
    (*T)->lchild->lchild->data=4;
    (*T)->lchild->lchild->lchild=NULL;
    (*T)->lchild->lchild->rchild=NULL;
}

//模拟操作结点元素的函数,输出结点本身的数值
void displayElem(BiTNode* elem){
    printf("%d ",elem->data);
}
//先序遍历
void PreOrderTraverse(BiTree T){
    if (T) {
        displayElem(T);//调用操作结点数据的函数方法
        PreOrderTraverse(T->lchild);//访问该结点的左孩子
        PreOrderTraverse(T->rchild);//访问该结点的右孩子
    }
    //如果结点为空,返回上一层
    return;
}
int main() {
    BiTree Tree;
    CreateBiTree(&Tree);
    printf("先序遍历: \n");
    PreOrderTraverse(Tree);
}

运行结果:

先序遍历:
1 2 4 5 3 6 7

中序遍历

二叉树中序遍历的实现思想是:

  1. 访问当前节点的左子树;
  2. 访问根节点;
  3. 访问当前节点的右子树;

以图1为例,采用先序遍历的思想遍历该二叉树的过程为:

  1. 访问该二叉树的根节点,找到 1;
  2. 遍历节点 1 的左子树,找到节点 2;
  3. 遍历节点 2 的左子树,找到节点 4;
  4. 由于节点 4 无左孩子,因此找到节点 4,并遍历节点 4 的右子树;
  5. 由于节点 4 无右子树,因此节点 2 的左子树遍历完成,访问节点 2;
  6. 遍历节点 2 的右子树,找到节点 5;
  7. 由于节点 5 无左子树,因此访问节点 5 ,又因为节点 5 没有右子树,因此节点 1 的左子树遍历完成,访问节点 1 ,并遍历节点 1 的右子树,找到节点 3;
  8. 遍历节点 3 的左子树,找到节点 6;
  9. 由于节点 6 无左子树,因此访问节点 6,又因为该节点无右子树,因此节点 3 的左子树遍历完成,开始访问节点 3 ,并遍历节点 3 的右子树,找到节点 7;
  10. 由于节点 7 无左子树,因此访问节点 7,又因为该节点无右子树,因此节点 1 的右子树遍历完成,即整棵树遍历完成;

二叉树的先序遍历采用的是递归的思想,因此可以递归实现,其 C 语言实现代码为:

#include <stdio.h>
#include <string.h>
#define TElemType int
//构造结点的结构体
typedef struct BiTNode{
    TElemType data;//数据域
    struct BiTNode *lchild,*rchild;//左右孩子指针
}BiTNode,*BiTree;
//初始化树的函数
void CreateBiTree(BiTree *T){
    *T=(BiTNode*)malloc(sizeof(BiTNode));
    (*T)->data=1;
    (*T)->lchild=(BiTNode*)malloc(sizeof(BiTNode));
    (*T)->rchild=(BiTNode*)malloc(sizeof(BiTNode));
  
    (*T)->lchild->data=2;
    (*T)->lchild->lchild=(BiTNode*)malloc(sizeof(BiTNode));
    (*T)->lchild->rchild=(BiTNode*)malloc(sizeof(BiTNode));
    (*T)->lchild->rchild->data=5;
    (*T)->lchild->rchild->lchild=NULL;
    (*T)->lchild->rchild->rchild=NULL;
    (*T)->rchild->data=3;
    (*T)->rchild->lchild=(BiTNode*)malloc(sizeof(BiTNode));
    (*T)->rchild->lchild->data=6;
    (*T)->rchild->lchild->lchild=NULL;
    (*T)->rchild->lchild->rchild=NULL;
    (*T)->rchild->rchild=(BiTNode*)malloc(sizeof(BiTNode));
    (*T)->rchild->rchild->data=7;
    (*T)->rchild->rchild->lchild=NULL;
    (*T)->rchild->rchild->rchild=NULL;
    (*T)->lchild->lchild->data=4;
    (*T)->lchild->lchild->lchild=NULL;
    (*T)->lchild->lchild->rchild=NULL;
}

//模拟操作结点元素的函数,输出结点本身的数值
void displayElem(BiTNode* elem){
    printf("%d ",elem->data);
}
//中序遍历
void INOrderTraverse(BiTree T){
    if (T) {
        INOrderTraverse(T->lchild);//遍历左孩子
        displayElem(T);//调用操作结点数据的函数方法
        INOrderTraverse(T->rchild);//遍历右孩子
    }
    //如果结点为空,返回上一层
    return;
}

int main() {
    BiTree Tree;
    CreateBiTree(&Tree);
    printf("中序遍历算法: \n");
    INOrderTraverse(Tree);
}

运行结果:

中序遍历算法:
4 2 5 1 6 3 7

后序遍历

二叉树后序遍历的实现思想是:

  1. 从根节点出发,依次遍历各节点的左右子树,直到当前节点左右子树遍历完成后,才访问该节点元素。

如图 1 中,对此二叉树进行后序遍历的操作过程为:

  1. 从根节点 1 开始,遍历该节点的左子树(以节点 2 为根节点);
  2. 遍历节点 2 的左子树(以节点 4 为根节点);
  3. 由于节点 4 既没有左子树,也没有右子树,此时访问该节点中的元素 4,并回退到节点 2 ,遍历节点 2 的右子树(以 5 为根节点);
  4. 由于节点 5 无左右子树,因此可以访问节点 5 ,并且此时节点 2 的左右子树也遍历完成,因此也可以访问节点 2;
  5. 此时回退到节点 1 ,开始遍历节点 1 的右子树(以节点 3 为根节点);
  6. 遍历节点 3 的左子树(以节点 6 为根节点);
  7. 由于节点 6 无左右子树,因此访问节点 6,并回退到节点 3,开始遍历节点 3 的右子树(以节点 7 为根节点);
  8. 由于节点 7 无左右子树,因此访问节点 7,并且节点 3 的左右子树也遍历完成,可以访问节点 3;节点 1 的左右子树也遍历完成,可以访问节点 1;
  9. 到此,整棵树的遍历结束。

后序遍历的递归实现代码为:

#include <stdio.h>
#include <string.h>
#define TElemType int
//构造结点的结构体
typedef struct BiTNode{
    TElemType data;//数据域
    struct BiTNode *lchild,*rchild;//左右孩子指针
}BiTNode,*BiTree;
//初始化树的函数
void CreateBiTree(BiTree *T){
    *T=(BiTNode*)malloc(sizeof(BiTNode));
    (*T)->data=1;
    (*T)->lchild=(BiTNode*)malloc(sizeof(BiTNode));
    (*T)->rchild=(BiTNode*)malloc(sizeof(BiTNode));
  
    (*T)->lchild->data=2;
    (*T)->lchild->lchild=(BiTNode*)malloc(sizeof(BiTNode));
    (*T)->lchild->rchild=(BiTNode*)malloc(sizeof(BiTNode));
    (*T)->lchild->rchild->data=5;
    (*T)->lchild->rchild->lchild=NULL;
    (*T)->lchild->rchild->rchild=NULL;
    (*T)->rchild->data=3;
    (*T)->rchild->lchild=(BiTNode*)malloc(sizeof(BiTNode));
    (*T)->rchild->lchild->data=6;
    (*T)->rchild->lchild->lchild=NULL;
    (*T)->rchild->lchild->rchild=NULL;
    (*T)->rchild->rchild=(BiTNode*)malloc(sizeof(BiTNode));
    (*T)->rchild->rchild->data=7;
    (*T)->rchild->rchild->lchild=NULL;
    (*T)->rchild->rchild->rchild=NULL;
    (*T)->lchild->lchild->data=4;
    (*T)->lchild->lchild->lchild=NULL;
    (*T)->lchild->lchild->rchild=NULL;
}

//模拟操作结点元素的函数,输出结点本身的数值
void displayElem(BiTNode* elem){
    printf("%d ",elem->data);
}
//后序遍历
void PostOrderTraverse(BiTree T){
    if (T) {
        PostOrderTraverse(T->lchild);//遍历左孩子
        PostOrderTraverse(T->rchild);//遍历右孩子
        displayElem(T);//调用操作结点数据的函数方法
    }
    //如果结点为空,返回上一层
    return;
}
int main() {
    BiTree Tree;
    CreateBiTree(&Tree);
    printf("后序遍历: \n");
    PostOrderTraverse(Tree);
}

运行结果为:

后序遍历:
4 5 2 6 7 3 1

递归的算法底层实现的是使用栈存储结构,也可以用栈直接写出相应的非递归算法。

中序遍历的非递归方式实现思想是:从根结点开始,遍历左孩子同时压栈,当遍历结束,说明当前遍历的结点没有左孩子,从栈中取出来调用操作函数,然后访问该结点的右孩子,继续以上重复性的操作。
除此之外,还有另一种实现思想:中序遍历过程中,只需将每个结点的左子树压栈即可,右子树不需要压栈。当结点的左子树遍历完成后,只需要以栈顶结点的右孩子为根结点,继续循环遍历即可。

后序遍历是在遍历完当前结点的左右孩子之后,才调用操作函数,所以需要在操作结点进栈时,为每个结点配备一个标志位。当遍历该结点的左孩子时,设置当前结点的标志位为 0,进栈;当要遍历该结点的右孩子时,设置当前结点的标志位为 1,进栈。
这样,当遍历完成,该结点弹栈时,查看该结点的标志位的值:如果是 0,表示该结点的右孩子还没有遍历;反之如果是 1,说明该结点的左右孩子都遍历完成,可以调用操作函数。

4.哈夫曼树

定义

给定N个权值作为N个叶子结点,构造一棵二叉树,若该树的带权路径长度达到最小,称这样的二叉树为最优二叉树,称为哈夫曼树(Huffman Tree),也称为“最优二叉树”。哈夫曼树是带权路径长度最短的树,权值较大的结点离根较近。

基本术语

  1. 路径:在一棵树中,一个结点到另一个结点之间的通路,称为路径。
  2. 路径长度:在一条路径中,每经过一个结点,路径长度都要加 1 。
  3. 结点的权:给每一个结点赋予一个新的数值,被称为这个结点的权。
  4. 结点的带权路径长度:指的是从根结点到该结点之间的路径长度与该结点的权的乘积。
  5. 树的带权路径长度为树中所有叶子结点的带权路径长度之和。通常记作 “WPL” 。

构建过程

对于给定的有各自权值的 n 个结点,构建哈夫曼树有一个行之有效的办法:

  1. 在 n 个权值中选出两个最小的权值,对应的两个结点组成一个新的二叉树,且新二叉树的根结点的权值为左右孩子权值的和;
  2. 在原有的 n 个权值中删除那两个最小的权值,同时将新的权值加入到 n–2 个权值的行列中,以此类推;
  3. 重复 1 和 2 ,直到所以的结点构建成了一棵二叉树为止,这棵树就是哈夫曼树。

哈夫曼树的构建过程
图 2 中,(A)给定了四个结点a,b,c,d,权值分别为7,5,2,4;第一步如(B)所示,找出现有权值中最小的两个,2 和 4 ,相应的结点 c 和 d 构建一个新的二叉树,树根的权值为 2 + 4 = 6,同时将原有权值中的 2 和 4 删掉,将新的权值 6 加入;进入(C),重复之前的步骤。直到(D)中,所有的结点构建成了一个全新的二叉树,这就是哈夫曼树。

结点结构

构建哈夫曼树时,首先需要确定树中结点的构成。由于哈夫曼树的构建是从叶子结点开始,不断地构建新的父结点,直至树根,所以结点中应包含指向父结点的指针。但是在使用哈夫曼树时是从树根开始,根据需求遍历树中的结点,因此每个结点需要有指向其左孩子和右孩子的指针。其结点结构的C语言表示为:

//哈夫曼树结点结构
typedef struct {
    int weight;//结点权重
    int parent, left, right;//父结点、左孩子、右孩子在数组中的位置下标
}HTNode, *HuffmanTree;

构建哈弗曼树的算法实现

构建哈夫曼树时,需要每次根据各个结点的权重值,筛选出其中值最小的两个结点,然后构建二叉树。
查找权重值最小的两个结点的思想是:从树组起始位置开始,首先找到两个无父结点的结点(说明还未使用其构建成树),然后和后续无父结点的结点依次做比较,有两种情况需要考虑:

  • 如果比两个结点中较小的那个还小,就保留这个结点,删除原来较大的结点;
  • 如果介于两个结点权重值之间,替换原来较大的结点;

C语言代码实现

//HT数组中存放的哈夫曼树,end表示HT数组中存放结点的最终位置,s1和s2传递的是HT数组中权重值最小的两个结点在数组中的位置
void Select(HuffmanTree HT, int end, int *s1, int *s2)
{
    int min1, min2;
    //遍历数组初始下标为 1
    int i = 1;
    //找到还没构建树的结点
    while(HT[i].parent != 0 && i <= end){
        i++;
    }
    min1 = HT[i].weight;
    *s1 = i;
   
    i++;
    while(HT[i].parent != 0 && i <= end){
        i++;
    }
    //对找到的两个结点比较大小,min2为大的,min1为小的
    if(HT[i].weight < min1){
        min2 = min1;
        *s2 = *s1;
        min1 = HT[i].weight;
        *s1 = i;
    }else{
        min2 = HT[i].weight;
        *s2 = i;
    }
    //两个结点和后续的所有未构建成树的结点做比较
    for(int j=i+1; j <= end; j++)
    {
        //如果有父结点,直接跳过,进行下一个
        if(HT[j].parent != 0){
            continue;
        }
        //如果比最小的还小,将min2=min1,min1赋值新的结点的下标
        if(HT[j].weight < min1){
            min2 = min1;
            min1 = HT[j].weight;
            *s2 = *s1;
            *s1 = j;
        }
        //如果介于两者之间,min2赋值为新的结点的位置下标
        else if(HT[j].weight >= min1 && HT[j].weight < min2){
            min2 = HT[j].weight;
            *s2 = j;
        }
    }
}

哈夫曼树的应用举例

哈夫曼编码是哈夫曼树的一个应用。在数字通信中,经常需要将传送的文字转换成由二进制字符0、1组成的二进制串,这一过程被称为编码。在传送电文时,总是希望电文代码尽可能短,采用哈夫曼编码构造的电文的总长最短。
由常识可知,电文中每个字符出现的概率是不同的。假定在一份电文中,A,B,C,D四种字符出现的概率是4/10,1/10,3/10,2/10,若采用不等长编码,让出现频率低的字符具有较长的编码,这样就有可能缩短传送电文的总长度。

采用不等长编码时要避免译码的二义性和多义性。假设用0表示C,用01表示D,则当接收到编码串01,并译码到0时,是立即译出C,还是接着下一个字符1一起译为对应的字符D,这样就产生了二义性。 因此,若对某一字符集进行不等长编码,则要求字符集中任一字符的编码都不能是其他字符编码的前缀,符合此要求的编码叫做前缀编码。

为了使不等长编码也是前缀编码,可以用字符集中的每个字符作为叶子结点生成一棵编码二叉树,为了获得最短的电文长度,可将每个字符出现的频率作为字符的权值赋予对应的叶子结点,求出此树的最小带权路径长度就是电文的最短编码。可以根据哈夫曼算法构造哈夫曼树T。设需要编码的上述电文字符集d={A,B,C,D},在电文中出现的频率集合p={4/10,1/10,3/10,2/10}。我们以字符集中的字符作为叶子结点、频率作为权值,构造一棵哈夫曼树。
哈夫曼编码
其中,每个结点分别对应一个字符,对T中的边做标记,把左分支记为“0”,右分支标记为“1”。定义字符的编码是从根结点到该字符所对应的叶子结点的路径上,各条边上的标记所组成的序列就是哈夫曼编码。A的编码:0,C的编码:10,D的编码:110,B的编码:111。显然对于任意字符集,总能构造出这样的编码二叉树。由于在任何一条从根结点到一个叶子结点的路径上一定不会出现其他叶子结点,所以通过这种方法得到的编码一定是前缀编码,通过遍历二叉树,可以求出每个字符的编码。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值