数据结构11——二叉树的定义、性质及遍历算法

417 阅读12分钟

本文已参与「新人创作礼」活动, 一起开启掘金创作之路。

二叉树的定义、性质及遍历算法

1 二叉树(Binary Tree)的定义

二叉树n(n0)n(n\geq 0)个结点的有限集合,该集合或者为空集(称为空二叉树),或者由一个根节点和两棵互不相交的、分别称为根节点的左子树和右子树的二叉树组成,如下图所示。

image-20220908101859264.png

1.1 二叉树的特点

二叉树的特点有:

  • 每个结点最多有两棵子树,所以二叉树中不存在度大于2的结点,没有一棵子树或者由一棵子树都是可以的。

  • 左子树和右子树是有顺序的,次序不能颠倒。

  • 即使树中某结点只有一棵子树,也要区分它是左子树还是右子树。如图所示,树1和树2是同一棵树,但他们确实不同的二叉树。

    image-20220908102249777.png

二叉树具有五种基本形态:

  1. 空二叉树。
  2. 只有一个根结点。
  3. 根结点只有左子树。
  4. 根结点只有右子树。
  5. 根结点既有左子树又有右子树。

1.2 特殊二叉树

  1. 斜树

    所有的结点都只有左子树的二叉树叫左斜树。所有结点都只有右子树的二叉树叫右斜树这两者统称为斜树。斜树有很明显的特点,就是每一层都只有一个结点,结点的个数与二叉树的深度相同。

    image-20220908103250134.png

    image-20220908103304399.png

  2. 满二叉树

    **在一棵二叉树中,如果所有分支结点都存在左子树和右子树,并且所有叶子都在同一层上,这样的二叉树称为满二叉树,**如图所示。

    image-20220908111500427.png

    单是每个结点都存在左右子树,不能算是满二叉树,还必须要所有叶子都在同一层上,这就做到了整棵树的平衡。满二叉树的特点有:

    1. 叶子只能出现在下一层。出现在其他层就不可能达到平衡。
    2. 非叶子结点的度一定为2.
    3. 在同样深度的二叉树中,满二叉树的结点个数最多,叶子数最多。
  3. 完全二叉树

    对一棵具有nn个结点的二叉树按层序编号,如果编号为i(1in)i(1\leq i\leq n)的结点与同样深度的满二叉树中编号为ii的结点在二叉树中位置完全相同,则这棵二叉树称为完全二叉树。

    image-20220908105536124.png

    满二叉树一定是一棵完全二叉树,但完全二叉树不一定是满的。

    完全二叉树的特点:

    1. 叶子结点只能出现在最下两层。
    2. 最下层的叶子一定集中在左部连续位置。
    3. 倒数两层,若有叶子结点,一定都在右部连续位置。
    4. 如果结点度为1,则该结点只有左孩子,即不存在只有右子树的情况。
    5. 同样结点数的二叉树,完全二叉树的深度最小。

2 二叉树的性质

性质1:在二叉树的第ii层至多有2i12^i-1个结点(i1i\geq 1)

image-20220908111500427.png

​ 第一层是根结点,只有一个,所以211=20=12^{1-1}=2^0=1

​ 第二层有两个,221=21=22^{2-1}=2^1=2

​ 第三层有四个,231=22=42^{3-1}=2^2=4

​ 第四层有八个,241=23=82^{4-1}=2^3=8

性质2:深度为kk的二叉树至多有2k12^k-1个结点(k1k\geq 1),深度为kk意思是有kk层的二叉树。

​ 如果有一层,至多有211=12^1-1=1个结点。

​ 如果有二层,至多有221=32^2-1=3个结点。

​ 如果有三层,至多有231=72^3-1=7个结点。

​ 如果有四层,至多有241=152^4-1=15个结点。

性质3:对任何一棵二叉树T,如果其终端结点数为n0n_0,度为2的结点数为n2n_2,则n0=n2+1n_0=n_2+1

  • 结点拥有的子树数称为结点的度(Degree)。
  • 度为0的结点称为叶结点(Leaf)或终端结点;
  • 度不为0的结点称为非终端结点或分支结点。
  • 除根结点之外,分支结点也称为内部结点。

​ 如图所示,n0=5,n2=4n_0=5,n_2=4

image-20220908134149388.png

性质4:具有nn个结点的完全二叉树深度为log2n+1(x|log_2n+1|(|x|表示不大于x的最大整数)。

​ 由满二叉树的定义可知,深度为kk的满二叉树的结点数nn一定是2k12^k-1。因为这是最多的结点个数。那么对于n=2k1n=2^k-1倒推得到满二叉树的深度为k=log2(n+1)k=log_2(n+1),比如结点数为15的满二叉树,深度为4。

性质5:如果对一棵有nn个结点的完全二叉树(其深度为log2n+1|log_2n+1|)的结点按层序编号(从第1层到第log2n+1|log_2n+1|层,每层从左到右),对任一结点i(iin)i(i\leq i \leq n)有:

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

image-20220908120049143.png

​ 对于第一条来说,i=1i=1时是根结点。i>1i>1时,结点7,它的双亲是[7/2]=3[7/2]=3,结点9,它的双亲是[9/2]=4[9/2]=4

​ 对于第二条来说,比如结点6,因为2×6=122\times 6=12超过了结点总数12,所以结点6无左孩子,是叶子结点。对于结点5,因为2×5=102\times 5=10正好是结点总数10,所以它的左孩子是结点10。

​ 对于第三条,比如结点5,因为2×5+1=112\times5+1=11,大于结点总数10,所以无右孩子。而结点3,因为2×3+1=72\times3+1=7小于10,所以它的右孩子是结点7。

3 二叉树的存储结构

3.1 二叉树的顺序存储结构

二叉树的顺序存储结构就是用一维数组存储二叉树的结点存储二叉树中的结点并且结点的存储位置,也就是数组的下标要体现结点之间的逻辑关系,比如双亲与孩子的关系,左右兄弟的关系等。

​ 一棵完全二叉树如图所示:

image-20220908134149388.png

​ 将这棵二叉树存入数组中,相应的下标对应其同样的位置,如图所示。

image-20220908134343567.png

​ 完全二叉树定义的严格用顺序结构也可以体现出二叉树的结构,对于一般的二叉树,虽然层序编号不能反映逻辑关系,但完全可以按完全二叉树的编号,把不存在的结点设置为“^”即可,如图所示。

image-20220908135003567.png

​ 但是对于一种极端情况,一棵深度为k的右斜树,只有k个结点,却需要分配2k12^k-1个存储单元,对存储空间有着极大的浪费,如图所示。所以,顺序存储结构一般只用于完全二叉树。

image-20220908135158489.png

3.2 二叉树的链式存储结构

​ **二叉树每个结点最多有两个孩子,所以为它涉及一个数据域和两个指针域,称这样的链表为二叉链表。**结点结构涉及如图所示。

image-20220908135526675.png

​ 其中,data是数据域;lchild和rchild都是指针域,分别存放指向左孩子和右孩子的指针。

​ 二叉链表的结点结构定义代码如下:

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

​ 结构示意图如图所示:

image-20220908135932566.png

我们可以在二叉链表的基础上,再增加一个指向其双亲的指针域,这样就可以称之为三叉链表。

4 遍历二叉树

二叉树的遍历(traversing binary tree)是指从根结点出发,按照某种次序一次访问二叉树中的所有结点,使得每个结点被访问一次且仅被访问一次。

二叉树的遍历次序不同于线性结构,最多也就是从头到尾、循环、双向等简单的遍历方式。树的结点之间不存在唯一的前驱和后继关系。

4.1 二叉树的遍历方法

  1. 前序遍历

    规则是若二叉树为空,则空操作返回,否则先访问根结点,然后前序遍历左子树,再前序遍历右子树。如图所示,遍历的顺序为ABDGHCEIF。

    image-20220908142032899.png

  2. 中序遍历

    规则是若树为空,则空操作返回,否则从根结点开始(注意不是先访问根结点),中序遍历根结点的左子树,然后是访问根结点,最后中序遍历右子树。如图所示,遍历的顺序为GDHBAEICF。

    image-20220908142203367

  3. 后序遍历

    规则是若树为空,则空操作返回,否则从左到右先叶子后结点的方式遍历访问左右子树,最后是访问根结点。如图所示,遍历的顺序为GHDBIEFCA。

    image-20220908142432915

  4. 层序遍历

    规则是若树为空,则空操作返回,否则从树的第一层,也就是根结点开始访问,从上而下逐层遍历,在同一层中,按从左到右的顺序对结点逐个访问。如图所示,遍历的顺序为ABCDEFGHI。

    image-20220908142559926.png

4.2 前序遍历算法的实现

​ 二叉树的定义是使用递归的方式,实现遍历算法也可以采用递归,二叉树的前序遍历算法如下:

void PreOrderTraverse(BiTree T){
    if(T==NULL){  // 树为空
        return;
    }
    printf("%c",T-data);  // 对数据进行输出操作
    PreOrderTraverse(T->lchild);  // 先序遍历左子树
    PreOrderTraverse(T->rchild);  // 先序遍历右子树
}

​ 假设我们需要遍历的树如图所示,算法执行流程如下:

image-20220908143118043.png

  1. 调用PreOrderTraverse(T),T根结点不为null,所以执行printf,打印字母A,如下图所示。

    image-20220908143138469.png

  2. 调用PreOrderTraverse(T->lchild),访问了A结点的左孩子,不为null,执行printf,打印字母B,如下图所示。

    image-20220908154224809.png

  3. 再次递归调用PreOrderTraverse(T->lchild),访问了B结点的左孩子,不为null,执行printf,打印字母D,如下图所示。

    image-20220908154237189.png

  4. 再次递归调用PreOrderTraverse(T->lchild),访问了D结点的左孩子,不为null,执行printf,打印字母H,如下图所示。

    image-20220908154247601.png

  5. 再次递归调用PreOrderTraverse(T->lchild),访问了H结点的左孩子,因为H结点无左孩子,所以T==null,返回此函数,此时递归调用PreOrderTraverse(T->rchild),访问了H结点的右孩子,printf显示字母K,如下图所示。

    image-20220908154303763.png

  6. 再次递归调用PreOrderTraverse(T->lchild),访问了K结点的左孩子,因为K结点无左孩子,所以T==null,返回此函数,此时递归调用PreOrderTraverse(T->rchild),访问了K结点的右孩子,也是null,返回。返回到打印结点D的函数,调用PreOrderTraverse(T->rchild)。访问了D结点的右孩子,不存在,返回B结点,调用PreOrderTraverse(T->rchild),找到了结点E,打印字母E,如图所示。

    image-20220908154327362.png

  7. 结点E没有左右孩子,返回打印结点B时的递归函数,递归执行完毕,返回到最初的PreOrderTraverse,调用PreOrderTraverse(T->rchild),访问结点A的右孩子,打印字母C,如图所示。

    image-20220908154404636.png

  8. 之后类似前面的递归调用,依次继续打印F、I、G、J。

4.3 中序遍历算法

​ 二叉树的中序遍历算法和前序遍历算法仅仅是代码顺序上的差,实现代码如下:

// 初始条件:二叉树T存在
// 操作结果:中序递归遍历T,依次输出值
void InOrderTraverse(BiTree T){
    if(T==null){
        return;
    }
    InOrderTraverse(T->lchild);  // 中序遍历左子树
    printf("%c",T->data);  // 进行输出结点数据的操作
    InOrderTraverse(T->rchild);  // 中序遍历右子树
}

​ 换句话说,中序遍历算法相当于把调用左孩子的递归函数提前,假设我们需要遍历的树如图所示,算法执行流程如下:

image-20220908143118043

  1. 调用InOrderTraverse(T),T的根结点为null,于是调用InOrderTraverse(T->lchild),访问结点B。当前指针不为null,继续调用InOrderTraverse(T->lchild),访问结点D。不为null,继续调用InOrderTraverse(T->lchild),访问结点H。继续调用InOrderTraverse(T->lchild),访问结点H的左孩子,发现当前指针为null,于是返回。打印当前结点H,如图所示。

    image-20220908155713458.png

  2. 调用InOrderTraverse(T->rchild),访问结点H的右孩子,因该结点无做孩子,打印K,如图所示。

    image-20220908155729441.png

  3. 结点K没有右孩子,所以返回,打印结点H,函数执行完毕,返回。打印字母D,如图所示。

    image-20220908155741394.png

  4. 结点D无右孩子,此函数执行完毕,返回,打印字母B,如图所示。

    image-20220908160104659.png

  5. 调用InOrderTraverse(T->rchild),访问结点B的右孩子E,因结点E无左孩子,所以打印E,如图所示。

    image-20220908160116012

  6. 结点E无右孩子,返回,结点B的递归函数执行完毕,返回到最初调用InOrderTraverse的地方,打印字母A,如图所示。

    image-20220908154327362.png

  7. 调用InOrderTraverse(T->rchild),访问结点A的右孩子C,再递归访问结点C的左孩子F,结点F的左孩子I,因为I无左孩子,打印I,之后分别打印F、C、G、J。

4.4 后序遍历算法

​ 后序遍历算法与前序、中序遍历算法类似,只是执行步骤有些差异,算法代码如下:

// 初始条件:二叉树T存在
// 操作结果:后续递归遍历T
void PostOrderTraverse(BiTree T){
    if(T==null){
        return;
    }
    PostOrderTraverse(T->lchild);  // 后序遍历左子树
    PostOrderTraverse(T->rchild);  // 后序遍历右子树
    print("%c",T->data);  // 进行结点数据输出操作
}

​ 如图所示,后序遍历是先递归左子树,由根结点A->B->D->H,结点H无左孩子,再查看结点H的右孩子K,因为K无左右孩子,所以打印K,返回。

image-20220908160607310.png

​ 后序遍历的结点顺序为KHDEBIFJGCA。

4.5 推导遍历结果

两个二叉树遍历的性质:

  • 已知前序遍历序列和中序遍历序列,可以唯一确定一棵二叉树。
  • 已知后序遍历序列和中序遍历序列,可以唯一确定一棵二叉树。
  • 已知前序和后序遍历,是不能确定一棵二叉树的。