树和二叉树

170 阅读18分钟

树和二叉树

基本概念

  • 结点: 数据元素以及指向子树的分支
  • 根结点: 非空树中无前驱结点的结点
  • 结点的度: 结点拥有的子树数
  • 数的度: 树里各结点的度的最大值
  • 叶子结点:(终端结点)度为 0 的结点
  • 分支结点: 非终端结点
  • 内部结点: 根结点以外的分支结点
  • 双亲|孩子: 结点的子树的根称为该结点的孩子,该结点称为孩子的双亲
  • 兄弟结点: 拥有共同双亲的结点
  • 堂兄弟结点: 双亲在同一层的结点
  • 祖先结点: 从根到该结点所经分支上的所有结点
  • 子孙结点: 以某结点为根的子树中的任一结点
  • 树的深度: 树中结点的最大层次
  • 有序树: 树种结点的各子树从左至右有次序(最左边的为第一个孩子)
  • 无序树: 树中结点的各子树无次序
  • 森林: 是 m(m≥0) 棵互不相交的树的集合 一棵树可以看成是一个特殊的森林 给森林中的各个子树加上一个双亲结点,森林就变成了树

应用

  • 数据压缩问题
  • 二叉树求解表达式的值

二叉树

普通树若不转化为二叉树,运算起来十分麻烦,很难实现

定义

二叉树是 n(n≥0) 个结点的有限集,它或者是空集 (n=0),或者是由一个根结点以及两个互不相交的分别称作这个根的左子树右子树的二叉树组成

特点

  1. 每个结点最多有两个孩子结点(不存在度大于 2 的结点)
  2. 子树有左右之分,其次序不能颠倒
  3. 二叉树可以是空集合,分可以有空的左子树或者空的右子树

**注:**二叉树 不是 树的特殊情况,它们是两个概念

  • 二叉树结点的子树要区分左子树和右子树,即便只有一棵子树也要进行区分
  • 树当结点只有一个孩子时,就无需区分它是左还是右的次序

抽象数据类型定义

二叉树抽象数据类型定义

重要基本操作

重要基本操作

性质与存储结构

**性质1:**在二叉树的第 i 层上至多有 2i-1 个结点(i≥1) 最少有 1 个结点

**性质2:**深度为 k 的二叉树至多有 2k-1 个结点(k≥1) 最少有 k 个结点 等比求和公式

**性质3:**对任何一颗二叉树 T,如果其叶子数为 n0,度为 2 的结点数为n2,则 n0=n2+1 二叉树度为2的结点与度为0的结点关系

**性质4:**具有 n 个结点的完全二叉树的深度为 ⌊log2n⌋+1 ⌊x⌋ 称为 x 的底,表示不大于 x 的最大整数 二叉树性质4证明

**性质5:**如果对一棵有 n 个结点的完全二叉树(深度为 ⌊log2n⌋+1 )的结点按层序编号,则对任一结点 i( 1≤i≤n ),有:

  1. 如果 i=1,则结点 i 时二叉树的根,无双亲;如果 i>1,则其双亲是结点 ⌊i/2⌋
  2. 如果 2i>n,则结点 i 为叶子结点,无左孩子;否则,其左孩子是结点 2i
  3. 如果 2i+1>n,则结点 i 无右孩子;否则,其右孩子为结点 2i+1

两种特殊形式的二叉树

  • 满二叉树
  • 完全二叉树

它们在顺序存储方式下可以复原

满二叉树

定义

深度为 k 时有 2k-1 个结点的二叉树称为满二叉树

特点
  • 它每一层上的结点数都是最大结点数(每层都满)
  • 叶子结点全部都在最底层

完全二叉树

定义

深度为 k 的具有 n 个结点的二叉树,当且仅当其每一个结点都与深度为 k 的满二叉树中编号为 1~n 的结点一一对应时,称之为完全二叉树

特点
  • 叶子结点只可能分布在层数最大的两层上
  • 对任一结点,如果其右子树的最大层次为 i,则其左子树的最大层次必为 i 或 i+1

存储结构

顺序存储

按满二叉树的结点层次编号,依次存放二叉树中的数据元素

// 二叉树的顺序存储表示
#define MAXSIZE 100 
typedef TElemType SqBiTree[MAXSIZE];
SqBiTree bt;    

**缺点:**有时候非常浪费空间(右单支树)

链式存储

二叉链表
// 二叉链表存储结构
typedef struct BiNode{
    TElemType data;
    struct BiNode *lchild, *rchild;	// 左右孩子指针
}BiNode,*BiTree;

在 n 个结点的二叉链表中,有 n+1 个空指针域

三叉链表
// 三叉链表存储结构
typedef struct TriNode{
    TElemType data;
    struct BiNode *lchild, *parent, *rchild;	
}TriNode,*TriTree;

遍历二叉树和线索二叉树

遍历: 顺着某一条搜索路径巡防二叉树中的结点,使得每个结点均被访问依次,而且仅被访问一次

遍历目的: 得到树中所有结点的一个线性排列

遍历用途: 它是树结构插入、删除、修改、查找、排序运算的前提,是二叉树一切运算的基础与核心

遍历二叉树

遍历方法
  • L,遍历左子树
  • D,访问根结点
  • R,遍历右子树

重点研究

DLR,先序遍历

LDR, 中序遍历

LRD, 后序遍历

算法描述
先序遍历二叉树中序遍历二叉树后序遍历二叉树
若二叉树为空,则空操作;否则:
(1)访问根结点
(2)先序遍历左子树
(3)先序遍历右子树
若二叉树为空,则空操作;否则:
(1) 中序遍历左子树
(2)访问根结点
(3)中序遍历右子树
若二叉树为空,则空操作;否则:
(1)后序遍历左子树
(2)后序遍历右子树
(3)访问根结点

前缀表达式(波兰式)、中缀表达式、后缀表达式(逆波兰式)

二叉树表示算数表达式
根据遍历序列确定二叉树
  • 若二叉树中各个结点的值均不相同,则二叉树结点的先序序列、中序序列和后序序列都是唯一的
  • 由二叉树的先序序列和中序序列,或者由二叉树的后序序列和中序序列可以唯一确定一棵二叉树
先序遍历
Status PreOrderTraverse(BiTree T){
    if( T==NULL )
        return OK;	// 空二叉树
    else{
        visit(T);
        PreOrderTraverse(T->lchild);
        PreOrderTraverse(T->rchild);
    }
}
中序遍历
// 递归实现
Status InOrderTraverse(BiTree T){
    if( T==NULL )
        return OK;	// 空二叉树
    else{
        InOrderTraverse(T->lchild);
        visit(T);
        InOrderTraverse(T->rchild);
    }
}
// 非递归实现
Status InOrderTraverse(BiTree T){
    BiTree p;
    InitStack(S);
    p = T;
    while(p || !StackEmpty(S)){
        if(p){
            Push(S,p);
            p = P->lchild;
        }
        else{
            Pop(S,q);
            printf("%c", q->data);
            p = q->rchild;
        }// while
        return OK;
    }
}
后序遍历
Status PostOrderTraverse(BiTree T){
    if( T==NULL )
        return OK;	// 空二叉树
    else{
        PostOrderTraverse(T->lchild);
        PostOrderTraverse(T->rchild);
        visit(T);
    }
}
遍历算法分析
遍历算法分析
  • 第一次遍历时访问 = 先序遍历
  • 第二次遍历时访问 = 中序遍历
  • 第三次遍历时访问 = 后序遍历

**时间效率:**O(n) // 每个结点只访问一次

**空间效率:**O(n) // 栈占用的最大辅助空间

二叉树的层次遍历

对一棵二叉树,从根结点开始,按从上到下,从左到右的顺序访问每一个结点

算法思路

使用一个队列

  1. 将根结点入队;
  2. 队不空时循环:从队列中出列一个结点 *p ,访问它;
    • 若它有左孩子结点,将左孩子结点入队;
    • 若它有右孩子结点,将右孩子结点入队。
算法实现
// 队列类型定义
typedef struct{
    BTNode data[MAXSIZE];	// 存放队中元素
    int front, rear;		// 队头与队尾指针
}SqQueue;

// 层次遍历算法,队列实现
void LevelOrder(BTNode *b){
    BTNode *p;
    SqQueue *qu;
    EnQueue(qu, b);			// 根结点指针进入队列
    while(!QueueEmpty(qu)){
        DeQueue(qu, p);		// 出队结点p
        printf("%c", p->data);
        if(p->lchild != NULL)
            EnQueue(qu, p->lchild);
        if(p->rchild != NULL)
            EnQueue(qu, p->rchild);
    }
}
二叉树的建立
  • 先序遍历序列建立二叉树的二叉链表
    1. 从键盘输入二叉树的结点信息,建立二叉树的存储结构;
    2. 在建立二叉树的过程中按照二叉树先序方式建立;
    3. 需要对空结点补充代表 “空” 的字符,这里采用 ”#“
Status CreateBiTree(BiTree &T){
    scanf(&ch);	
    if(ch == "#")
        T = NULL;
    else{
        if(!(T = (BiTNode*)malloc(sizeof(BiTNode))))
            exit(OVERFLOW);
        T->data = ch;				// 生成根结点
        CreateBiTree(T->lchild);	// 构建左子树
        CreateBiTree(T->rchild);	// 构建右子树
    }
    return OK;
}
复制二叉树
  • 如果是空树,递归结束;
  • 否则,申请新结点空间,复制根结点
    • 递归复制左子树
    • 递归复制右子树
int Copy(BiTree T, BiTree &NewT){
    if(T == NULL){
        NewT = NULL;
        return 0;
    }else{
        NewT =(BiTNode*)malloc(sizeof(BiTNOde));
        NewT->data = T->data;
        Copy(T->lchild, NewT->lchild);
        Copy(T->rchild, NewT->rchild);
    }
}
求二叉树深度
  • 如果是空树,则深度为0
  • 否则,递归计算左子树的深度为 m,递归计算右子树的深度为 n,二叉树的深度为 m 与 n 的较大者加 1
int Depth(BiTree T){
    if(T == NULL)
        return 0;
    else{
        m = Depth(T->lChild);
        n = Depth(T->rchild);
        if(m>n)
            return (m+1);
        else
            return (n+1);
    }
}
求二叉树结点总数
  • 如果是空树,则结点个数为0
  • 否则,结点个数为左子树的结点个数 + 右子树的结点个数 + 1
int NodeCount(BiTree T){
    if(T == NULL)
        return 0;
    else
        return NodeCount(T->lchild)+NodeCount(T->rchild)+1;
}
求二叉树叶子结点的个数
  • 如果是空树,则叶子结点个数为0;
  • 否则,为左子树的叶子结点个数 + 右子树的叶子结点个数
int LeadCount(BiTree T){
    if(T == NULL)
        return 0;
    if(T->lchild==NULL && T->rchild==NULL)
        return 1;
    else
        return LeafCount(T->lchild)+LeafCount(T->rchild);
}

线索二叉树

利用二叉链表中的空指针域: 如果某个结点的左孩子为空,则将空的左孩子指针域改为指向其前驱;如果某结点的右孩子为空,则将空的右孩子指针域改为指向其后继——这种 改变指向的指针 称为 线索

加上了线索的二叉树称为 线索二叉树 (Theraded Binary Tree)

线索化 :对二叉树按某种遍历次序使其变为线索二叉树

为区分 lchild 和 rchild 指针到滴是指向孩子的指针,还是指向其前驱/后继的指针,对二叉链表中每个结点增设两个标志域 ltagrtag ,并约定:

  • ltag = 0,lchild 指向该结点的左孩子
  • ltag = 1,lchild 指向该结点的前驱
  • rtag = 0,rchild 指向该结点的右孩子
  • rtag = 1,rchild 指向该结点的后继

线索二叉树的结点结构为:

lchildltagdatartagrchild
// 线索二叉树存储结构
typedef struct BiThrNode{
    int data;
    int ltag, rtag;
    struct BiThrNode *lchild, *rchild;
}BiThrNode, *BiThrTree;

先序线索二叉树  先序线索二叉树

增设一个头结点

ltag = 0, lchild 指向根结点

rtag = 1,rchild 指向遍历序列中最后一个结点

遍历序列中第一个结点的 lchild 域和最后一个结点的 rchild 域都指向头结点(避免了结点悬空)

定义

树(Tree)是 n(n≥0) 个结点的有限集 若 n=0,称为空树 若 n>0,则它满足以下两个条件

  • 有且仅有一个特定的根结点(Root);
  • 其余结点可分为 m(m≥0) 个互不相交的有限集,其中每一个集合本身又是一棵树,并称为根的子树(SubTree)

树的定义是一个递归定义

树(Tree)是 n(n≥0) 个结点的有限集 若 n=0,称为空树 若 n>0,则它满足以下两个条件

  • 有且仅有一个特定的根结点(Root);
  • 其余结点可分为 m(m≥0) 个互不相交的有限集,其中每一个集合本身又是一棵树,并称为根的子树(SubTree)

树的定义是一个递归定义

存储结构

双亲表示法

  • 定义结构数组存放树的结点,每个结点包含两个域:
    • 数据域:存放结点本身信息
    • 双亲域:指示本结点的双亲结点在数组中的位置

​ **特点:**找双亲容易,找孩子难

结点结构:

dataparent
树的双亲表示法
// 双亲表示法的存储结构
typedef struct PTNode{
    TElemType data;
    int parent;
}PTNode;
// 树结构
#define MAX_TREE_SIZE 100
typedef struct{
    PTNode nodes[MAX_TREE_SIZE];
    int r, n;	// 分别存储根结点的位置和结点个数
}PTree;

孩子链表

把每个结点的孩子结点排列起来,看成是一个线性表,用单链表存储。n 个结点有 n 个孩子链表(叶子的孩子链表为空表)。而 n 个头指针又组成一个线性表,用顺序表(含 n 个元素的结构数组)存储。

// 孩子结点结构
typedef struct CTNode {
    int child;
    struct CTNode *next;
}*ChildPtr;
// 双亲结点结构
typedef struct {
	TElemType data;
    ChildPtr firstChild;
}CTBox;
// 树结构
typedef struct {
    CTBox nodes[MAX_TREE_SIZE];
    int n, r;	//结点数和根结点的位置
}CTree;

**特点:**找孩子容易,找双亲难

孩子兄弟表示法

又叫 二叉树表示法 | 二叉链表表示法

用二叉链表作树的存储结构,链表中每个结点的两个指针域分别指向其第一个孩子结点下一个兄弟结点

typedef struct CSNode{
    ElemType data;
    struct CSNode *firstchild, *nextsibling;
}CSNode, *CSTree;
孩子兄弟表示法

树与二叉树的转换

将树转化为二叉树进行处理,利用二叉树的算法来实现对树的操作

给定一棵树,可以找到唯一的一颗二叉树与之对应

树→二叉树

  1. 加线:在兄弟之间加一条线
  2. 抹线:对每个结点,除了其左孩子外,去除其与其余孩子之间的关系
  3. 旋转:以树的根结点为轴心,将整树顺时针转45°

树变二叉树,兄弟相连留长子

二叉树→树

  1. 加线:若 p 结点时双亲结点的左孩子,则将 p 的右孩子、右孩子的右孩子……,沿分支找到的所有右孩子,都与 p 的双亲用线连起来
  2. 抹线:抹掉原二叉树中双亲与右孩子之间的连线
  3. 调整:将结点按层次排列,形成树结构

二叉树变树:左孩右右连双亲,去掉原来右孩线

树的遍历

  • 先序遍历 若树不空,先访问根结点,然后依次先根遍历各棵子树
  • 后序遍历 若树不空,则先依次后根遍历各棵子树,然后访问根结点
  • 层次遍历 若树不空,则自上而下自左至右访问树中每个结点

森林

它是 m(m≥0) 棵互不相交的树的集合

二叉树与森林的转换

森林转换成二叉树

二叉树与多棵树之间的关系

  1. 将各棵树分别转换成二叉树
  2. 将每棵树的根结点用线相连
  3. 以第一棵树根结点为二叉树的根,再以根结点为轴心,顺时针旋转,构成二叉树型结构

森林变成二叉树:树变二叉根相连

二叉树转换成森林

  1. 抹线:将二叉树中根结点与其右孩子连线,以及沿右分支搜索到的所有右孩子间的连线全部抹掉,是指变成孤立的二叉树
  2. 还原:将孤立的二叉树还原成树

二叉树变森林:去掉全部右孩线,孤立二叉再还原

森林的遍历

将森林看作由三部分组成:

  1. 森林中第一棵树的根结点;
  2. 森林中第一棵树的子树森林;
  3. 森林中其它树构成的森林。

先序遍历

若森林不空,则:

  1. 访问森林中第一棵树的根结点;
  2. 先序遍历森林中第一棵树的子树森林;
  3. 先序遍历森林中(除第一棵树外)其余树构成的森林

即:依次从左至右对森林中的每一棵树进行先根遍历

中序遍历

若森林不空,则:

  1. 中序遍历森林中第一棵树的子树森林;
  2. 访问森林中第一棵树的根结点;
  3. 中序遍历森林中(除第一棵树之外)其余树构成的森林。

即:依次从左至右对森林中的每一棵树进行后根遍历

哈夫曼树

哈夫曼,David Albert Huffman

基本概念

  • 路径: 从树中一个结点到另一个结点之间的分支构成这两个结点间的路径

  • 结点的路径长度: 两结点间路径上的分支数

  • 树的路径长度: 从根结点到每一个结点的路径长度之和。记作TL 结点数目相同的二叉树中,完全二叉树时路径长度最短的二叉树

  • 权(weight): 将树中结点赋给一个有着某种含义的数值,则这个数值称为该结点的权

  • 结点的带权路径长度: 结点到该结点之间的 路径长度 与该结点的 权的乘积

  • 树的带权路径长度: 树中所有叶子结点的带权路径长度之和,记作:(Weighted Path Length)

    WPL=k=1nwklkWPL=\sum_{k=1}^n w_kl_k
  • 哈夫曼树: 最优树 带权路径长度最短的树

注: “带权路径长度最短”是在“度相同”的树中比较而得到的结果,因此有最优二叉树、最优三叉树等

满二叉树不一定是哈夫曼树

具有相同带权结点的哈夫曼树不唯一

构造哈夫曼树

构造哈夫曼树

哈夫曼树只有度为 0 或 2 的结点,没有度为 1 的结点

包含 n 个叶子结点的哈夫曼树中共有 2n-1 个结点

算法实现

采用顺序存储结构——一维结构数组

// 结点类型定义
typedef struct{
    int weight;
    int parent, lch, rch;
}HTNode, *HuffmanTree;
// 1.初始化 HT[1,...,2n-1]:lch=rch=parent=0
// 2.输入初始n个叶子节点:置 HT[1,...,n] 的权值
// 3.进行以下 n-1 次合并,依次产生 n-1 个结点 HT[i],i=n+1,...,2n-1
// 在HT[1,...,i-1] 中选中两个未被选过(从 parent==0 的结点中选)的权值最小的两个结点 HT[s1] 和 HT[s2],s1 和 s2 未最小两个结点下标
// 修改HT[s1] 和 HT[s2] 的 parent 值:HT[s1].parent=i;HT[s2].parent=i; 
// 修改新产生的 HT[i]:
//		HT[i].weight=HT[s1].weight+HT[s2].weight;
//  	HT[i].lch=s1;HT[i].rch=s2;

void CreatHuffmanTree(HuffmanTree HT, int n){
    if(n<=1)
        return ;
    m = 2*n-1;	// 数组共 2n-1 个元素
    HT = (HuffmanTree)malloc(sizeof(HTNode)*(m+1));
    // C++ 语法: HT=new HTNode[m+1];
    // 0号单元未用,HT[m]表示根结点
    for(i=1;i<=m;++i){
        HT[i].lch=0;
        HT[i].rch=0;
        HT[i].parent=0;
    }
    for(i=1;i<=n;++i){
        scanf("%d",&HT[i].wheight);
        // cin>>HT[i].weight;
        // 输入前 n 个元素的权值
    }
// 初始化结束,下面开始建立哈夫曼树
// 合并产生 n-1 个结点
	for(i=n+1; i<=m; i++){
        Select(HT, i-1, s1, s2);
        // 在 HT[k](1≤k≤i-1)中选择连个其双亲域为0,且权值最小的结点,并返回它们在 HT 中的序号 s1 和 s2
        HT[s1].parent = i;
        HT[s2].parent = i;
        // 表示从 F 中删除 s1, s2
        HT[i].lch = s1;
        HT[i].rch = s2;
        HT[i].weight = HT[s1].weight + HT[s2].weight;
        // i 的权值为左右孩子权值之和
    }
}

应用——哈夫曼编码

等长编码(比较浪费空间)→不等长编码

注: 要设计不等长的编码,则必须使任一字符的编码都不是另一字符的编码的 前缀,这种编码称作 前缀编码

  1. 统计字符集中每个字符在电文中出现的平均概率(概率越大,要求编码越短)
  2. 利用哈夫曼树的特点:权越大的叶子离根越近;将每个字符的概率值作为权值,构造哈夫曼树。则概率越大的结点,路径越短
  3. 在哈夫曼树的每个分支上标上 0 或 1
    • 结点的左分支标 0,右分支标 1
    • 把从根到每个叶子的路径上的标号连接起来,作为该叶子代表的字符的编码

提问:

  1. 为什么哈夫曼编码能够保证是前缀编码? 因为没有一片树叶是另一片树叶的祖先,所以每个叶子结点的编码就不可能是其它叶子结点编码的前缀
  2. 为什么哈夫曼编码能够保证字符编码总长最短? 因为哈夫曼树的带权路径长度最短,所以字符编码的总长最短

哈夫曼编码性质

  • 哈夫曼编码是前缀码
  • 哈夫曼编码是最优前缀码

算法实现

void CreatHuffmanCode(HuffmanTree HT, HuffmanCode &HC, int n){
    // 从叶子结点到根逆向求每个字符的哈夫曼编码,存储在编码表 HC 中
    HC = (char*)malloc(sizeof(char)*(n+1));
    // HC = new char *[n+1];
    // 分配 n 个字符编码的头指针矢量
    cd[n-1] = '\0'; // 字符串结束标志,编码结束符
    for(i=1; i<=n; i++){	// 逐个字符求哈夫曼编码
        start = n-1;
        c = i;
        f = HT[i].parent;
        while(f != 0){		// 从叶子结点开始向上回溯,直到根结点	
            --start;		// 回溯一次 start 向前指一个位置
            if(HF[f].lchild == c)
                cd[start] = '0';
            else
                cd[start] = '1';
            c = f;			// 继续向上回溯
            f = HT[f].parent;
        }
        HC[i] = (char)malloc(sizeof(char)*(n-start));	// 为第 i 个字符串编码分配空间
        strcpy(HC[i], &cd[start]);
    }	   // 将得到的编码从临时空间 cd 复制到 HC 的当前行中
    free(cd);
}// CrteatHuffmanCode

文件编码与解码

编码

  • 输入各字符及其权值
  • 构造哈夫曼树 HT[i]
  • 进行哈夫曼编码 HC[i]
  • 查 HC[i],得到各字符的哈夫曼编码

解码

  • 构造哈夫曼树
  • 依次读入二进制码
  • 读入 0,则走向左孩子;读入 1,则走向右孩子
  • 一旦到达叶子结点,即可译出字符
  • 然后再从根出发继续译码,直到结束