【数据结构】第五章 树与二叉树_数

5.1 树的基本概念

树形结构是一类重要的非线性结构。树型结构是结点之间有分支,并且具有层次关系的结构,它非常类似于自然界中的树。

树结构在客观世界中是大量存在的,例如家谱、行政组织机构都可用树形象地表示。

树在计算机领域中也有着广泛的应用,例如在编译程序中,用树来表示源程序的语法结构;在数据库系统中,可用树来组织信息;在分析算法的行为时,可用树来描述其执行过程等等。

1.树的定义【递归是树的固有特性】

树是n(n >= 0)个结点的有限集T,满足:

  1. 当 n = 0 时,称为空树;
  2. 当 n > 0 时,有且仅有一个特定的称为根的结点;其余的结点可分为m(m >= 0)个互不相交的子集T1,T2,T3…Tm,其中每个子集Ti又是一颗树,并称其为子树。

2.树的逻辑表示

一般表示法(直观表示法)

【数据结构】第五章 树与二叉树_数_02

3.树的相关术语

名称

解释

结点

由一个数据元素及若干指向其他结点的分支所组成

结点的度:所拥有的子树的数目。 树的度:树中所有结点的度的最大值。

叶子(终端结点)

度为0的结点

非终端结点

度不为0的结点

孩子(子结点)

结点的子树的根称为该结点的孩子【这个概念非常绕,其实非常简单,就是分出来的就是子结点】

双亲(父结点)

一个结点称为该结点所有子树根的双亲

祖先

结点祖先指根到此结点的一条路径上的所有结点

子孙

从某结点到叶结点的分支上的所有结点称为该结点的子孙。

兄弟

同一双亲的孩子之间互称兄弟(父结点相同的结点)

结点的层次

从根开始算起,根的层次为1,其余结点的层次为其双亲的层次加1

堂兄弟

其双亲在同一层的结点

树的深度(高度)

一颗树中所有结点层次数的最大值

有序树

若树中各结点的子树从左到右是有次序的,不能互换,称为有序树

无序树

若树中各结点的子树是无次序的,可以互换,则称为无序树

森林

是m(m >= 0)颗树的集合

4.树的基本运算

求根Root(T): 求树T的根结点; 求双亲Parent(T, X): 求结点X在树T上的双亲;若X是树T的根或X不在T上,则结果为一特殊标志; 求孩子Child(T, X, i): 求树T上结点X的第i个孩子结点;若X不在T上或X没有第i个孩子,则结果为一特殊标志; 建树Create(X, T1, …, Tk),k > 1: 建立一颗以X为根,以T1,…Tk为第1,…k棵子树的树; 剪枝Delete(T, X, i): 删除树T上结点X的第i颗子树;若T无第i颗子树,则为空操作; 遍历TraverseTree(T): 遍历树,即访问树中每个结点,且每个结点仅被访问一次

5.2 二叉树

二叉树在树结构的应用中起着非常重要的作用,因为二叉树有许多良好的性质和简单的物理表示,而任何树都可以与二叉树相互转换,这样就解决了树的存储结构及其运算中存在的复杂性。

定义

二叉树是n(n>=0)个结点的有限集合,它或为空(n=0)或是由一个根及两棵互不相交的左子树和右子树组成,且中左子树和右子树也均为二叉树。(定义使用到递归)

【数据结构】第五章 树与二叉树_数_03

特点

  1. 二叉树可以是空的,称空二叉树;
  2. 每个结点最多只能有俩个孩子;
  3. 子树有左、右之分且次序不能颠倒。

二叉树与树的比较


结点

子树

结点顺序

n >= 0

不定(有限)

二叉树

n >= 0

<= 2

有(左、右)

二叉树结点的子树要区分左子树和右子树,即使只有一颗子树也要进行区分,说明它是左子树还是右子树。这是二叉树与树的最主要的差别。下图列出二叉树的5种基本形态【图C和图D是不同的俩颗二叉树】。

【数据结构】第五章 树与二叉树_数_04

二叉树的基本运算

初始化Initiate(BT): 建立一颗空二叉树,BT=∅。 说明:∅是表示空集的意思。 求双亲Parent(BT, X): 求出二叉树BT上结点X的双亲结点,若X是BT的根或X根本不是BT上的结点,运算结果为NULL。 求左孩子Lchild(BT, X)和求右孩子Rchild(BT, X): 分别求出二叉树BT上结点X的左、右孩子;若X为BT的叶子或X不在BT上,运算结果为NULL。 建二叉树Create(BT): 建立一颗二叉树BT。 先序遍历PreOrder(BT): 按先序对二叉树BT进行遍历,每个结点被访问一次且仅被访问一次,若BT为空,则运算为空操作。 中序遍历InOrder(BT): 按中序对二叉树BT进行遍历,每个结点被访问一次且仅被访问一次,若BT为空,则运算为空操作。 后序遍历PostOrder(BT): 按后序对二叉树BT进行遍历,每个结点被访问一次且仅被访问一次,若BT为空,则运算为空操作。 层次遍历LevelOrder(BT): 按层从上往下,同一层中结点按从左往右的顺序,对二叉树进行遍历,每个结点被访问一次且仅被访问一次,若BT为空,则运算为空操作。

二叉树的性质(1)

二叉树具有下列重要性质:


性质1:(根据二叉树所在层数计算该层的结点最大数)

在二叉树的第i(i >= 1)层上至多有2^(i-1)个结点。

【数据结构】第五章 树与二叉树_数_05


性质2:(根据二叉树的深度计算节点数最大值)

深度为k(k >= 1)的二叉树至多有2^k-1个结点 比如满二叉树的结点个数

【数据结构】第五章 树与二叉树_数据结构_06



满二叉树

【数据结构】第五章 树与二叉树_夏明亮_07

满二叉树中结点顺序编号:即从第一层结点开始自上而下,从左到右进行连续编号。

完全二叉树与非完全二叉树

完全二叉树从上到下从左到右逐层为结点编号;其编号不应出现空缺的情况。出现空缺就是非完全二叉树。

【数据结构】第五章 树与二叉树_数据结构_08

二叉树的性质(2)

性质3:(根据也叶子结点计算度为2的结点树 或 根据度为2的节点数计算叶子结点树)

对于任何一颗二叉树T,如果其终端结点(叶子结点)数为n0,度为2的结点数为n2,则n0=n2+1。 终端结点数就是叶子结点数,除了叶子结点外,剩下的就是度为1或2的结点数了,我们设n1为度是1的结点数。则树T的结点总数n=n0+n1+n2。 如下图例子,结点数为10,他是由A,B,C,D等度为2结点,F,G,H,I,J等度为0的叶子结点和E这个度为1的结点组成。总和为4+1+5=10。

【数据结构】第五章 树与二叉树_运算_09

换个角度,在数一数它的连接线数,由于根结点只有分出去,没有分支进入,所以分支线总数为结点总数在减去1。上图就是有9个分支。对于A,B,C,D结点来说,他们都有两个分支线出去,而E结点只有一个分支线出去,所以总分支线为4 * 2 + 1*1=9。 用代数表达就是分支总数=n-1=n1+2n2。因为刚才我们有等式n=n 0+n 1+n 2,所以可以推导出n 0+n1+n2-1=n1+2n2。

【数据结构】第五章 树与二叉树_数据结构_10

性质4:(由结点总数计算二叉树深度)

具有n个节点的完全二叉树深k为⌊log_2{x}⌋+1(其中x表示不大于n的最大整数)。(⌊ ⌋是向下取整符号) 由满二叉树的定义我们可以知道,深度为k的满二叉树的结点数n一定是2^k-1。因此这是最多的结点个数。那么对于n=2^k-1推导得出满二叉树的度为k=log_2{(n+1)},比如结点数为15的满二叉树,度为4。 完全二叉树,是一颗具有n个结点的二叉树,按层序编号后其编号与同样深度的满二叉树中编号结点在二叉树中位置完全相同,那它就是完全二叉树,也就是说,它的叶子结点只会出现在最下面的两层(如上一性质中的示例图)。 它的结点数一定少于等于同样度数的满二叉树的结点数(2^k)-1,但是一定多于[2^(k-1)-1]。即满足[2^(k-1)-1]<n<=(2^k)-1。由于结点数n是正整数,n<=(2^k)-1意味着n<2^k;n>2^(k-1)-1,意味着n>=2^(k-1)。所以2^(k-1)<=n<2^k,不等式两边取对数,得到k-1=<log_2{n}<k,而k作为度数也是整数,因此k=⌊ log_2{n}⌋+1。

【数据结构】第五章 树与二叉树_数_11

【数据结构】第五章 树与二叉树_运算_12


性质5:(根据完全二叉树的总节点数判断第i个结点的双亲或孩子结点信息)

如果对一颗有n个结点的完全二叉树(其深度为[log_2{n}]+1)的结点按层序编号(从第一层到[log_2{n}]+1层,每层从左到右),对任一结点i(1 <= i <= n): (1)如果i=1,则结点i是二叉树的根,无双亲;

(2)如果i>1,则其双亲结点是结点⌊ i/2 ⌋((⌊ ⌋是向下取整符号));

(3)如果2i>n,则结点i无左孩子(结点i为叶子结点);否则(2i = n)左孩子是结点2i;

(4)如果2i+1>n,则结点i无右孩子,否则(2i+1 =n)其右孩子是结点2i+1.

【数据结构】第五章 树与二叉树_数据结构_13

二叉树的存储结构

顺序存储

用一组连续的存储单元存储二叉树的数据元素。因此,必须把二叉树的所有结点安排成为一个恰当的序列,结点在这个序列中的相互位置能反映出结点之间的逻辑关系,可用编号的方法。

二叉树的顺序存储结构:对二叉树按完全二叉树进行编号,然后用一维数组存储,其中编号为i的结点存储在数组中下标为i的分量中。 – 该方法称为『以编号为地址』策略。

对于非完全二叉树,则用某种方法将其转化为完全二叉树,为此可增设若干个虚拟结点。

如果用于一般二叉树(则存储空间浪费极大,因为会有很多虚拟结点):

【数据结构】第五章 树与二叉树_数_14

缺点:

1)有可能对存储空间造成极大的浪费,在最坏的情况下,一个深度为H且只有H个结点的右单支树却需要2 h − 1 2^{h-1}个结点存储空间。

2)若经常需要插入与删除树中结点时,顺序存储方式不是很好。

链式存储

二叉链表

【数据结构】第五章 树与二叉树_数_15

二叉链表表示法

【数据结构】第五章 树与二叉树_二叉树_16

实际上,二叉树的链式存储结构远不止上图所示的这一种。某些实际场景中,可能会在树中做类似 "查找某节点的父节点" 的操作,可以在节点结构中再添加一个指针域,用于各个节点指向它的父亲节点。(三叉)

二叉链表类型定义
#include <stdio.h>

typedef int DataType;
typedef struct{
    DataType data;
    Node *lchild, *rchild;
}Node, *TwoLinkBinTree;
三叉链表表示法

【数据结构】第五章 树与二叉树_二叉树_17

三叉链表类型定义
#include <stdio.h>

typedef int DataType;
typedef struct{
    DataType data;
    Node *lchild, *rchild, *parent;
}Node, *ThreeLinkBinTree;

二叉树的运算

创建一棵二叉树需要什么 就是从根节点开始,逐步实现子节点的创建,从而实现树的整体框架。 对于生成后的树,我们应该可以对它进行查找,修改,插入,删除等功能。 最后,我们将进行对树的遍历。

一般来说,二叉树使用链表来定义

生成

Node* root = NULL

插入结点

  1. 首先将待插入结点的值与根结点的值作比较,若val == root->data,此时我们直接返回当前root指针,因为我们规定,二叉排序树中不存在值相同的结点。
  2. 若待插入结点的值小于根结点的值,此时我们应该进入根结点的左子树
  3. 若待插入结点的值大于根结点的值,此时我们应该进入根结点的右子树
  4. 若指针为空,此时我们根据传入的值创建结点,并返回创建的结点指针

删除结点

二叉树的删除操作比较复杂,因为一个结点可能没有孩子结点,也可能有一个孩子结点,也可能有两个孩子结点,所以删除二叉树的结点需要分情况讨论:

  1. 删除结点没有孩子结点是叶子结点;
  2. 删除结点只有一个孩子结点; 2.1 删除结点没有右孩子结点; 2.2.删除结点没有左孩子结点;
  3. 删除结点左右孩子结点都存在;
    有两种办法:
    a.让删除结点的左子树最大值(也可以理解为中序遍历的直接前继)接替删除的位置;
    b.让删除结点的右子树最小值(也可以理解为中序遍历的直接后继)接替删除的位置;

遍历(可应用于查找)

遍历含义

在二叉树的一些应用中,常常要求在树中查找具有某种特征的结点,或者对树中全部结点逐一进行某种处理。这就引入了遍历二叉树的问题。

遍历二叉树:是指按某种次序访问二叉树上的所有结点,使每个结点被访问一次且仅被访问一次。

遍历规则

由二叉树的递归定义得知:二叉树的三个基本组成单元是:根节点、左子树和右子树。

  1. 先(根)序遍历DLR:首先访问根节点,其次遍历根的左子树,最后遍历根右子树,对每颗子树同样按这三步(先根、后左、再右)进行。
  2. 中(根)序遍历LDR:首先遍历根的左子树,其次访问根结点,最后遍历根右子树,对每颗子树同样按这三步(先左、后根、再右)进行。
  3. 后(根)序遍历LRD:首先遍历根的左子树,其次遍历根的右子树,最后访问根结点,对每颗子树同样按这三步(先左、后右、最后根)进行。
  4. 二叉树的层次遍历:从二叉树的根结点的这一层开始,逐层向下遍历,在每一层上按从左到右的顺序对结点逐个访问。
1.先(根)序遍历DLR(递归算法)

若二叉树为空,执行空操作; 否则:

  1. 访问根结点;(输出当前的根结点)
  2. 先序遍历左子树;(递归,将当前左子树作为根,再执行DLR)
  3. 先序遍历右子树。(递归,将当前右子树作为根,再执行DLR)

算法:

//先序遍历DLR
void preOrder(TwoLinkBinTree bt){
    if(bt != NULL){
        visit(bt);             //输出当前子树的根
        preOrder(bt->lchild);  //递归
        preOrder(bt->rchild);  //递归
    }
}

【数据结构】第五章 树与二叉树_数据结构_18


2.中(根)序遍历LDR(递归算法)

步骤: 若二叉树为空,执行空操作; 否则:

  1. 中序遍历左子树;(递归,将当前左子树作为根,再执行LDR)
  2. 访问根结点;(输出当前的根结点)
  3. 中序遍历右子树。(递归,将当前右子树作为根,再执行LDR)

算法:

//中序遍历LDR
void midOrder(TwoLinkBinTree bt){
    if(bt != NULL){
        midOrder(bt->lchild);  //递归
        visit(bt);             //输出当前子树的根
        midOrder(bt->rchild);  //递归
    }
}

【数据结构】第五章 树与二叉树_二叉树_19


3.后(根)序遍历LRD(递归算法)

步骤: 若二叉树为空,执行空操作; 否则:

  1. 后序遍历根的左子树;(递归,将当前左子树作为根,再执行LRD)
  2. 后序遍历根的右子树;(递归,将当前右子树作为根,再执行LRD)
  3. 访问根结点;(输出当前的根结点)

算法:

//后序遍历LRD
void postOrder(TwoLinkBinTree bt){
    if(bt != NULL){
        postOrder(bt->lchild);  //递归
        postOrder(bt->rchild);  //递归
        visit(bt);              //输出当前子树的根
    }
}

【数据结构】第五章 树与二叉树_夏明亮_20


4.二叉树的层次遍历

从二叉树的根结点的这一层开始,逐层向下遍历,在每一层上按从左到右的顺序对结点逐个访问。

设立一个队列Q,用于存放结点,以保证二叉树结点按照层次顺序从左往右进入队列。

算法:

void levelOrder(BinTree bt) {
    LkQue Q;
    InitQueue(&Q);
    if (bt != NULL) {
        EnQueue(&Q, bt);
        while(!EmptyQueue(Q)) {
            p = GetHead(&Q);
            outQueue(&Q);
            visit(p);
            if (p -> Lchild != NULL) {
                EnQueue(&Q, p -> Lchild);
            }
            if (p -> Rchild != NULL) {
                EnQueue(&Q, p -> Rchild);
            }
        }
    }
}

【数据结构】第五章 树与二叉树_二叉树_21


应用举例(主要是遍历的应用)

二叉树中重复值的问题解释

依序列(40,72,38,35,67,51,90,8,55,21),建立二叉排序树。

可以很容易的得到一棵二叉排序树如下:

【数据结构】第五章 树与二叉树_运算_22

但在某些问题中,例如将序列替换为

(54,25,36,47,36,88,11,86,60)

其中出现了36,47,36这一片段,出现了两个相同的值该如何处理呢? 在查找资料后得到:

“二叉树是一种动态查找表。特点是,树的结构不是一次生成的,而是在查找过程中,当树中不存在关键字等于给定值的结点时再进行插入。新插入的结点一定是一个新添加的叶子结点,并且是查找不成功时查找路径上访问的最后一个结点的左孩子或右孩子结点。”

所以根据上面所得,第二个36在树中存在,所以在建立时不需要再次插入。 所以需要去掉一个值为36的结点,得到的二叉排序树如下(蓝色为建立顺序)

【数据结构】第五章 树与二叉树_二叉树_23

求二叉树中结点的个数
求终端结点(叶子结点)总数
度为1的结点个数
度为2的结点个数
非终端(叶子)结点的个数
按值查找;返回树中包含该值的结点个数
判定结点所在层

算法代码

//应用
//--求结点总数
int getAllNodeCount(TwoLinkBinTree bt){
    if(bt == NULL){ return 0; }
    int l = getAllNodeCount(bt->lchild);
    int r = getAllNodeCount(bt->rchild);
    return l+r+1;
}
//--求终端结点(叶子结点)总数
int getLeafNodeCount(TwoLinkBinTree bt){
    if(bt == NULL){ return 0; }
    if(bt->lchild == NULL && bt->rchild == NULL){ return 1; }
    int l = getLeafNodeCount(bt->lchild);
    int r = getLeafNodeCount(bt->rchild);
    return l+r;
}
//--度为1的结点个数
int getOneSonNodeCount(TwoLinkBinTree bt){
    //空树
    if(bt == NULL){ return 0; }
    //度为1
    if((bt->lchild == NULL && bt->rchild != NULL) || (bt->lchild != NULL && bt->rchild == NULL)){
        return getOneSonNodeCount(bt->lchild) + getOneSonNodeCount(bt->rchild) + 1;    //+1加的是自己
    }
    //度为2的结点继续递归
    return getOneSonNodeCount(bt->lchild) + getOneSonNodeCount(bt->rchild);
}
//--度为2的结点个数
int getTwoSonNodeCount(TwoLinkBinTree bt){
    //空树
    if(bt == NULL){ return 0; }
    if(bt->lchild != NULL && bt->rchild != NULL){ 
        return getTwoSonNodeCount(bt->lchild) + getTwoSonNodeCount(bt->rchild) + 1;    //+1加的是自己
    }
    //度为1的结点继续递归
    return getTwoSonNodeCount(bt->lchild) + getTwoSonNodeCount(bt->rchild);
}

//--非终端(叶子)结点的个数
int getNonLeafNodeCount(TwoLinkBinTree bt){
    //空树
    if(bt == NULL){ return 0; }
    //叶子结点
    if(bt->lchild == NULL && bt->rchild == NULL){ return 0; }
    int l = getNonLeafNodeCount(bt->lchild);
    int r = getNonLeafNodeCount(bt->rchild);
    return l+r+1;
}

//--按值查找;返回树中包含该值的结点个数
int findSpecialDataCount(TwoLinkBinTree bt, DataType x){
    if(bt == NULL){ return 0; }
    if(bt->data == x){
        return findSpecialDataCount(bt->lchild, x) + findSpecialDataCount(bt->rchild, x) + 1;
    }else{
        return findSpecialDataCount(bt->lchild, x) + findSpecialDataCount(bt->rchild, x);
    }
}

//--判定结点所在层
int getWhichLevelNodeIn(TwoLinkBinTree bt, DataType x){
    int defaultlevel = 1;
    if (bt == NULL) {
        return 0;
    }
    if (bt->data == x) {
        return defaultlevel ;
    }
    int leftLevel = getNodeLevel(bt->lchild, x, defaultlevel + 1);
    if (leftLevel != 0) {
        return leftLevel;
    }
    int rightLevel = getNodeLevel(bt->rchild, x, defaultlevel  + 1);
    return rightLevel;
}

课外知识

二叉树 结点数据是否可以相同?

二叉树的结点数据可以相同。在二叉树中,每个结点都包含一个数据项,这个数据项可以是任何类型的数据。结点的数据项可以相同,也可以不同。每个结点还包含左子结点和右子结点的引用,用于构建树形结构。因此,即使结点的数据项相同,它们在树中的位置仍然是唯一的。

二叉树在插入新结点时如何确定新结点的插入位置?

在二叉树中插入新结点时,需要通过比较新结点的值与当前结点的值来确定插入位置。具体的插入过程如下:

  1. 如果树为空,将新结点作为根结点。
  2. 如果树不为空,将新结点的值与当前结点的值进行比较。
  • 如果新结点的值小于当前结点的值,则将新结点插入到当前结点的左子树中。
  • 如果新结点的值大于当前结点的值,则将新结点插入到当前结点的右子树中。
  • 如果新结点的值与当前结点的值相等,可以根据具体需求来处理,例如可以将重复值插入到左子树或右子树中。
  1. 重复上述步骤,直到找到新结点的插入位置,即找到一个空的位置,将新结点插入其中。

在确定插入位置时,可以采用递归或迭代的方式来实现。递归的方式比较简洁,利用递归函数的调用栈来保存当前结点的信息;迭代的方式则需要使用循环和栈等数据结构来实现。无论采用哪种方式,都需要保证二叉树的性质,即左子树的值小于当前结点的值,右子树的值大于当前结点的值。

结点的值如果不是数字而是其他数据类型该如何比较大小呢?

当结点的值不是数字而是其他数据类型时,比较大小的方式取决于该数据类型是否已经定义了大小比较的方法。如果该数据类型已经定义了大小比较的方法,可以直接使用该方法来比较结点的值。

例如,对于字符串类型,可以使用字符串的比较方法来比较结点的值。对于自定义的类,可以根据类的设计,在类中定义一个比较方法或者重载比较运算符,然后使用该方法或运算符来比较结点的值。

如果该数据类型没有定义大小比较的方法,那么需要根据实际需求来确定比较的规则。可以根据数据类型的特定属性或者自定义的比较函数来比较结点的值。

无论是使用已定义的比较方法,还是根据实际需求自定义比较规则,都需要保证在二叉树中插入新结点时,能够根据比较结果确定新结点的插入位置,使得二叉树的性质得到保持。

线索二叉树

什么是线索?

对一棵二叉树中所有节点的空指针域按照某种遍历方式加线索的过程叫作线索化。

加线索的方式有哪些?

不同的加线索的方式形成的线索二叉树类型也是不同的;

根据二叉树的三种遍历方式构成了三种不同的线索二叉树;

二叉树的遍历只能从根结点开始依次遍历,而构建了线索二叉树后,就可以从二叉树中任何一个结点进行遍历,这就是线索化最大的意义了;

【数据结构】第五章 树与二叉树_二叉树_24

实际上线索二叉树的应用面是很窄的,但是学习它最重要的意义还是理解它的这种思想; 就是将闲置的空间充分利用起来,这应该是最重要的意义了;

什么是线索二叉树?

被线索化了的二叉树称为线索二叉树。

为什么需要线索二叉树?

二叉树中查找节点的前驱和后继节点非常困难。

加入我们知道了二叉树中结点的“前驱”和“后继”信息,就可以把二叉树看作一个链表结构,从而可以像遍历链表那样来遍历二叉树,进而提高效率

线索二叉树的结点构成?

线索二叉树因为要提供额外的线索信息;因此,它的结点构成和普通的二叉树结点构成是由区别的:

【数据结构】第五章 树与二叉树_夏明亮_25

  1. 如果ltag=0,表示lchild指向节点的左孩子。如果ltag=1,则表示lchild为线索,指向节点的直接前驱
  2. 如果rtag=0,表示rchild指向节点的右孩子。如果rtag=1,则表示rchild为线索,指向节点的直接后继

但是这样就要求为结点分配更多的存储空间来存放标识信息,这种方式会降低树存储结构的存储密度。;因此为了避免空间的浪费将tag和结点原来的左右孩子指针结合起来即实现二叉树的线索化目标同时尽最多可能节省空间资源。

注意:对于满二叉树来说,每个节点要么没有子节点,要么有两个子节点,不存在空指针。因此,在满二叉树中,所有的空指针都已经被填满了,不需要进行线索化。

为什么能使用左右孩子指针?

在N个节点的二叉树中,每个节点有2个指针,所以一共有2N个指针,除了根节点以外,每一个节点都有一个指针从它的父节点指向它,所以一共使用了N-1个指针,所以剩下2N-(N-1)也就是N+1个空指针;

如果能利用这些空指针域来存放指向该节点的直接前驱或是直接后继的指针,则可由此信息直接找到在该遍历次序下的前驱节点或后继节点,从而比递归遍历提高了遍历速度,节省了建立系统递归栈所使用的存储空间;

这些被重新利用起来的空指针就被称为线索(Thread),加上了线索的二叉树就是线索二叉树;

二叉线索树的前驱与后继

根据不同的遍历方式得到的结点序列得到结点的前驱和后继是不同的:

【数据结构】第五章 树与二叉树_数据结构_26

如何线索化二叉树

将二叉树转化为线索二叉树,实质上是在遍历二叉树的过程中,将二叉链表中的空指针改为指向直接前趋或者直接后继的线索。

二叉树的线索化

在遍历过程中

如果当前结点没有左孩子,需要将该结点的 lchild 指针指向遍历过程中的前一个结点,所以在遍历过程中,设置一个指针(名为 pre ),时刻指向当前访问结点的前一个结点。

如果当前结点没有右孩子,需要将该结点的 rchild 指针指向遍历过程中的下一个结点,所以在遍历过程中,设置一个指针(名为post),时刻指向当前访问几点的下一个结点。

过程的图示如下:

【数据结构】第五章 树与二叉树_二叉树_27

以中序遍历为例:

【数据结构】第五章 树与二叉树_二叉树_28

二叉线索链表上的运算

结点类型构成

typedef int DataType;
typedef struct{
   DataType data;//我们需要存的值在data里面
   struct Node *lchild,*rchild;//两个指针表示指向左右子树,如果为空改变标志值指向遍历前后结点
   int ltag,rtag;//用来标记前驱和后继结点的状态,0表示有子树,1表示指向遍历前后结点
} Node, *tbTree;

中序遍历线索化

void createInTree(ThrBiTree root) {
  ThrBiTreeNode *pre = NULL;
  if (NULL == root) {
    return;
  }
  //通过递归的方式进行中序线索化
  inThread(root, pre);
  //如果递归完成最后一个结点的后继结点因为空
  if (NULL == pre -> rchild) {
    pre -> rtag = 1;
  }
}
void inThread (ThrBiTree &cur, ThrBiTreeNode &pre) {
  //递归出口
  if (NULL == cur) {
    return;
  }
  //左孩子的线索化
  inThread (cur -> lchild, pre);
  //如果当前结点没有左孩子结点就将该指针线索化
  if (NULL == cur -> lchild) {
    cur -> lchild = pre;
    cur -> ltag = 1;
  }
  //如果当前结点没有右孩子结点就线索化
  if (NULL != pre && NULL != pre -> rchild) {
    pre -> rchild = cur;
    pre -> rtag = 1;
  }
  //记录当前结点的前驱结点,方便线索化处理
  pre = p;
  //右孩子的线索化
  inThread(p -> rchild, pre);
}

正向遍历

1)找到当前线索二叉树序遍历的第一个被遍历的结点

2)找到当前结点的后继结点;如果当前结点的右指针未被线索化,就调第一个方法寻找。

3)已被线索化就直接返回

//找到当前线索二叉树中序遍历的第一个被遍历的结点
ThrBiTreeNode * findFirstNode(ThrBiTreeNode *cur) {
  //中序遍历(左根右),因此要找到子树中最后一个左孩子结点,即为第一个要遍历的结点
  while(p -> ltag == 0) {
    p = p -> lchild;
  }
  return p;
}
//找到当前结点的后继结点
ThrBiTreeNode * findNextNode(ThrBiTreeNode *cur) {
  //如果当前结点的右指针未被线索化,就调第一个方法寻找
  if (cur -> rtag == 0) {
    return findFirstNode(cur -> rchild);
  } else {//被线索化就直接返回
    return cur -> rchild;
   }
}
void inorder(ThrBiTreeNode *p) {
  if (NULL == p) {
    return;
  }
  for (ThrBiTreeNode * cur = findFirstNode(p); cur != NULL; cur = findNextNode(cur)) {
    visit(cur);
  }
}

逆向遍历

1)找到当前线索二叉树中序遍历的最后一个被遍历的结点

2)如果当前结点的左指针未被线索化,就调线索化算法查找寻找

3)被线索化就直接返回

//找到当前线索二叉树中序遍历的最后一个被遍历的结点
ThrBiTreeNode * findLastNode(ThrBiTreeNode *cur) {
  //中序遍历(左根右),因此要找到子树中最后一个右孩子结点结点,即为最后要遍历的结点
  while(p -> rtag == 0) {
    p = p -> rchild;
  }
  return p;
}
//找到当前结点的前继结点
ThrBiTreeNode * findPreNode(ThrBiTreeNode *cur) {
  //如果当前结点的左指针未被线索化,就调第一个方法寻找
  if (cur -> ltag == 0) {
    return findFirstNode(cur -> lchild);
  } else {//被线索化就直接返回
    return cur -> lchild;
   }
}
void inorder(ThrBiTreeNode *p) {
  if (NULL == p) {
    return;
  }
  for (ThrBiTreeNode * cur = findLastNode(p); cur != NULL; cur = findPreNode(cur)) {
    visit(cur);
  }
}

树和森林

基本概念

树(Tree)是一种非线性的数据结构。

树是n(n≥0)个节点的有限集。n=0时,称为空树。

树由唯一的根和若干棵互不相交的子树组成。

每一棵子树又是一棵树,也是由唯一的根节点和若干棵不相交的子树组成的。(天然的递归构成)

下面的图就是一颗树:

【数据结构】第五章 树与二叉树_数_29

什么是森林?

若干棵互不相交的树的集合。

【数据结构】第五章 树与二叉树_二叉树_30

树的表示方法(存储结构)

双亲表示法

树中每个结点都有唯一一个双亲结点,根据这一特性,可以用一组连续的存储空间(一维数组)存储树中的各个结点,数组中每个元素都表示树中的一个结点,数组元素为结构体类型,这个结构体类型由结点本身的数据和结点的双亲在数组中的序号组成。

【数据结构】第五章 树与二叉树_数_31

该存储结构利用了每个结点(根结点除外)只有唯一双亲的性质,可以很快得到每个结点的双亲结点,但求结点的孩子时需要遍历整个结构。

孩子表示法

孩子表示法是将每个结点的孩子结点都用单链表链接起来形成一个线性结构,此时n个结点就有n个孩子链表(叶子结点的孩子链表尾空表)。

【数据结构】第五章 树与二叉树_运算_32

这种存储方式寻找子女的操作非常直接,而寻找双亲的操作需要遍历n个结点中孩子链表指针域所指向的n个孩子链表。

双亲孩子表示法

树中每个结点的孩子串成一单链表 – 孩子链表;n个结点 对应n个孩子链表 同时用一维数组顺序存储树中的各结点,数组元素除了包括结点本身的信息和该结点的孩子链表的头指针之外,还增设一个域,用来存储结点双亲结点在数组中的序号。

这种表示法时结合了上面两者的优点形成的。

【数据结构】第五章 树与二叉树_数据结构_33

孩子兄弟表示法(二叉链表表示)

孩子兄弟表示法又称二叉树表示法,即以二叉链表作为树的存储结构。

这种方法的结构体包含:每个结点的数据,指向该结点的第一个孩子结点的指针和指向下一个兄弟结点的指针。

【数据结构】第五章 树与二叉树_数据结构_34

这种存储表示法比较灵活,其最大的优点是可以方便地实现树转换为二叉树的操作,易于查找结点的孩子等,但是缺点是从当前结点查找双亲结点比较麻烦。同样地若为每个结点增设一个parent域指向其父结点,则查找结点的父结点也很方便。

树、森林和二叉树的转换

这位大佬的动图制作的很好实在没必要在重新作图了:

https://zhuanlan.zhihu.com/p/134251528

我这里直接截图汇总:

树->二叉树

【数据结构】第五章 树与二叉树_数据结构_35

二叉树->树

【数据结构】第五章 树与二叉树_运算_36

二叉树->森林

【数据结构】第五章 树与二叉树_二叉树_37


【数据结构】第五章 树与二叉树_夏明亮_38


树和森林的遍历

普通树的遍历(注意没有中序遍历)

1.先序遍历:先访问根节点,然后依次先序遍历根的每颗子树;

2.后序遍历:先依次后序遍历每颗子树,最后访问根结点。

3.层次遍历:按层次逐层访问每个结点。

【数据结构】第五章 树与二叉树_数_39

森林的遍历(注意只有2种)

1.先序遍历森林: 访问森林中每棵树的根结点;先序遍历森林中第一颗树的根节点的子树组成的森林;先序遍历除去第一颗树之外其余的树组成的森林。

2.中序遍历森林: 中序访问森林中第一颗树的根节点的子树组成的森林;访问第一棵树的根结点;中序遍历除去第一棵树之外其余的树组成的森林。(像是对森林进行类似二叉树的后序遍历步骤)

森林的中序遍历顺序与该森林对应的二叉树的中序遍历顺序相同。

这里的中序遍历容易让人产生误解。

哈夫曼树及其应用

最优二叉树(哈夫曼树)

哈夫曼树又名最优树,就是指带权路径最小的数,也是二叉树的一种,仅包含度为0或者2的节点。

在哪些情况下使用二叉树比较合理?

问题:n个学生成绩a1,a2,…,an(百分制),现要转换成五类。 第一类: < 60 不及格 第二类:[60, 70] 及格 第三类:[70, 80] 中等 第四类:[80, 90] 良好 第五类:>= 90 优秀

每类出现的概率:

分数

0~59

60~69

70~79

80~89

90以上

概率

5%

15%

40%

30%

10%

我们最容易想到的是对对分数大小逐级判断后分级:

【数据结构】第五章 树与二叉树_数_40

以上判断存在的问题: 对n个数分类花费时间较多。 因为大多数元素属于中和良,这样大多数数据都得通过3至4次判断,这样总的判断次数就多。

判定树:用于描述分类过程的二叉树,其中:每个非终端结点包含一个条件,对应一次比较;每个终端结点包含一个种类标记,对应于一种分类结果。

基于如上问题,我们进行改进:

【数据结构】第五章 树与二叉树_数据结构_41

由于属于中、良的数最多(数据特性),而检验它们的判断变少少,会导致总的判断次数减少。

哈夫曼树与哈夫曼算法

哈夫曼树是为了构造时间性能最高的判定树。

路径:在一棵树中,一个结点到另一个结点之间的通路,称为路径。

路径长度:在一条路径中,每经过一个结点,路径长度都要加 1 。

结点的权:给每一个结点赋予一个新的数值,被称为这个结点的权。

结点的带权路径长度:指的是从根结点到该结点之间的路径长度与该结点的权的乘积。

树的带权路径长度为树中所有叶子结点的带权路径长度之和。通常记作 “WPL” 。

【数据结构】第五章 树与二叉树_夏明亮_42

【数据结构】第五章 树与二叉树_夏明亮_43

在构建哈弗曼树时,要使树的带权路径长度最小,只需要遵循一个原则,那就是:权重越大的结点离树根越近。在上图中,因为结点 a 的权值最大,所以理应直接作为根结点的孩子结点。

构建哈夫曼树

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

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

【数据结构】第五章 树与二叉树_运算_44

上图中,(A)给定了四个结点a,b,c,d,权值分别为7,5,2,4;

第一步 如(B)所示,找出现有权值中最小的两个,2 和 4 ,相应的结点 c 和 d 构建一个新的二叉树,树根的权值为 2 + 4 = 6,同时将原有权值中的 2 和 4 删掉,将新的权值 6 加入;

第二步 如(C)所示,选择剩下结点中权值最小的1个;重复之前的步骤。

第三步 如(D)所示,所有的结点构建成了一个全新的二叉树,这就是哈夫曼树。

哈夫曼编码

可利用哈夫曼树构造用于通信的二进制编码称为哈夫曼编码。

树中从根到每个叶子都有一条路径,对路径上的各分支约定指向左子树根的分支表示’0’码,指向右子树的分支表示’1’码,取每条路径上的’0’或’1’的序列作为和各个叶子对应的字符的编码,这就是哈夫曼编码。

编码的应用

【数据结构】第五章 树与二叉树_运算_45

本质就是画出哈夫曼树:

【数据结构】第五章 树与二叉树_数据结构_46

【数据结构】第五章 树与二叉树_夏明亮_47


【数据结构】第五章 树与二叉树_运算_48

【数据结构】第五章 树与二叉树_数据结构_49



本人能力有限,文中内容难免有纰漏,真诚欢迎大家斧正~

喜欢本文的朋友请三连哦!!!

另外本文也参考了网络上其他优秀博主的观点和实例,这里虽不能一一列举但内心属实感谢无私分享知识的每一位原创作者。