数据结构(3):树与二叉树(上)-二叉树

一.树的概念


(一)树的基本概念

  1. 树的定义

    • 树是n个节点的有限集合(n≥0),当n=0的时候称为空树,在任意一棵非空树中:
      ①有且仅有一个特定的称为根的节点
      ②当n>1的时候,其余节点可分为m个互不相交的有限集合T1,…,Tm,每个 集合本身也是一棵树,并且这些集合称为根节点的子树。

    • 特点:

      ①树的根节点没有前驱节点,除了根节点以外,所有节点有且只有一个前驱节点
      ②树中所有节点都可以有0个或多个后继节点
      ③在具有n个节点的树中具有n-1条边

  2. 基本术语

    • 族谱关系(可以在树中找到连接两个节点的一条路径来理解):祖先节点与子孙节点;孩子节点和双亲节点;兄弟节点
    • 节点的深度(层次)、高度、度;树的深度、高度、度(取树中节点的深度、高度、度的最大值);节点的
    • 结构性术语:分支/叶子节点;路径:两个节点之间所经过的节点序列;路径长度:路径上所经过的边个数
    • 有序树和无序树:子节点是否有序
  3. 树的性质

    • 树中的节点数等于所有节点的度数+1

    • 度为m的树中第i层上至多有m^(i-1)个节点(i≥1)

    • 高度为h的m叉树至多有(m^h-1)/(m-1)个节点

    • 具有n个节点的m叉树最小高度为
      l o g m ( n ( m 1 ) + 1 ) log_m(n(m-1)+1)

(二)二叉树的概念

  1. 结构特点:二叉树是另一种树的结构,每个节点至多只有两棵子树(二叉树中不存在度大于2的节点),且二叉树中的子树有左右之分,次序不能颠倒。

  2. 二叉树的定义(同样采取递归)
    二叉树是具有n个节点的有限集合(n≥0):
    ①要么n=0,说明是一棵空二叉树
    ②要么该二叉树由一个根节点和两个互不相交的称为根的左子树和根的右子树组成,其中左右子树分别是一棵二叉树。

  3. 特点:
    ①每个节点的度不会超过2
    ②二叉树是一棵有序树,且即使只有一棵子树也要区分左右

    二叉树 v.s. 度为2的树
    ①度为2的树只要要有3个节点(一个根节点加两个子节点),二叉树可以是一棵空树
    ②度为2的有序树的孩子节点的左右次序是相对孩子节点而言的,意味着如果只有一个孩子节点则不需要区分左右;但是二叉树即使只有一个孩子节点也是需要区分左右孩子的。

  4. 特殊的二叉树

    名称 定义 特点 给定一个编号为i的节点
    满二叉树 若该二叉树高度为h,则含有2^h-1个节点,树中每一层都含有最多的节点 叶子节点均在最下一层;除了叶子节点每个节点的度均为2 其双亲节点:∟i/2」
    左子树2i,右子树2i+1
    根节点的编号为1
    除了根节点,编号为偶数的节点为左子树,奇数则为右子树
    完全二叉树 一个高度为h,节点数为n的二叉树,当且仅当其中每一个结点都和高度为h的满二叉树的编号1-n的节点相对应 叶子节点只可能在最底下两层出现,最下一层的叶子节点依次排列在改层的最左侧
    只可能存在一个度为1的节点,该节点有左子树
    编号为i的节点若只有左子树,那么大于i的节点均为叶子节点
    若节点总数n为奇数,每个分支均有左右子树;若n为偶数,编号最大的分支节点(对n/2取下整)只有左子树
    如果i≤(n//2),则节点i为分支节点,否则i为叶子节点
    完全二叉树也符合满二叉树关于节点编号与双亲、孩子节点的关系
    总节点数//2得到的是最大分支节点【这一点在所有二叉树上均成立】
    二叉排序树 一棵空的二叉树或者是具有如下性质的一棵二叉树:①左子树上所有节点的关键字均小于根节点的关键字②右子树上所有节点的关键字均大于根节点的关键字
    左右子树各是一棵二叉排序树
    / /
    平衡二叉树 树上任意一个节点的左子树和右子树的深度不会超过1
  5. 二叉树的性质

    性质 证明
    非空二叉树上叶子节点数=度为2的节点数+1
    N0 = N2+1
    设B为二叉树的分支总数,B = N-1,B = N1+2N2,N = N1+N2+N3,联立则可解
    非空二叉树第k层上至多有2^(k-1)个节点(K≥1) 二叉树各层上的节点数目是以2为公比的一个等比数列(指数型增长)
    高度为H的二叉树至多有2^H-1个节点(H≥1) 高度为H的二叉树节点数最多的情况就是满二叉树,前面说过了各层节点数为等比数列,则等比数列求和即可
    编号为i的节点所在的层次为以2位底的对数,求下整+1 证明同上,核心把握住二叉树节点的数目和节点编号的增长是呈指数型的,故指数运算和对数运算是最常用到的
    具有N个节点(N>0)的完全二叉树的高度为2为底,N+1的对数,取上整 同上,注意求层次的时候,只要该层有节点层次就会往下加一,故结果要求上整

二.二叉树及其上的操作


(一)二叉树的存储结构

1.顺序存储结构

  1. 顺序存储结构的描述:【顺序存储结构多适合于满二叉树和完全二叉树】用一组地址连续的存储单元依次自上而下、自左至右存储完全二叉树上的节点元素,将完全二叉树上编号为i的节点元素存储在某个数组下标为i的分量中,然后通过一些方法确定节点在逻辑上的父子和兄弟关系。

  2. 分析:

    ①优点:对于满二叉树和完全二叉树而言,树中节点的编号可以唯一地反映出节点之间的逻辑关系,这样可以节省存储空间,也可利用数组元素的下标值确定节点在二叉树中的位置,以及节点之间的关系。

    ②缺点:但是对于一般的二叉树,想要让数组下标能反映节点之间的逻辑关系,只能添加一些并不存在的空节点让其每个节点与完全二叉树上的节点相对应,会造成很多空间的浪费。

2.链式存储结构

  1. 树的链式结构:为了提高存储时的空间利用率,一般都采用链式存储。链式结构就是用一个链表来存储树,树中每一个结点用链表的一个节点来存储,节点包含若干个数据域和若干个指针域。
  • 二叉树的链式结构:一个数据域和两个指针域,分别指示左右孩子。
  1. 二叉树的链式存储表示

    • 节点结构

      lchild data rchild
    • 结构体定义

      typedef struct BiTNode{
          ElemType data;
          struct BiTNode *lchild,*rchild;
      }BiTNode,*BiTree;
      

      说明:①不同的存储结构所对应的二叉树上的操作也会相应不同,因此要根据实际的应用场合对存储结构进行选择
      在含有n个节点的二叉链表中含有n+1个空链域【重要结论!】

(二)二叉树的遍历

二叉树的遍历:按照某条搜索路径访问树中的每个节点,使每个节点都被访问一次,而且仅被访问一次。

  • 根据二叉树(树)的递归定义,可知在对树进行遍历的时候需要决定对根节点N,左子树L和右子树R的访问顺序。
  • 一般来说默认左子树的访问先于右子树,则常见的遍历次序有NLR(先序),LNR(中序),LRN(后序)。其中,序指的是根节点在什么时候被访问。

1.先序遍历

  1. 操作过程:如果二叉树为空,则不进行任何处理,否则:

    • 访问根节点
    • 先序遍历左子树
    • 先序遍历右子树
  2. 算法表示:

    void PreOrder(BiTree T){
        if(T!=NULL){
            visit(T);
            PreOrder(T->lchild);
            PreOrder(T->rchild);
        }
    }
    

2.中序遍历

  1. 操作过程:如果二叉树为空,则不进行任何处理,否则:

    • 中序遍历访问左子树
    • 访问根节点
    • 中序遍历访问右子树
  2. 算法表示:

    void InOrder(BiTree T){
        if(T!=NULL){
            InOrder(T->lchild);
            visit(T);
            InOrder(T->rchild);
        }
    }
    

3.后序遍历

  1. 操作过程:如果二叉树为空,则不进行任何处理,否则:

    • 后序遍历访问左子树
    • 后序遍历访问右子树
    • 访问根节点
  2. 算法表示:

    void PostOrder(BiTree T){
        if(T!=NULL){
            PostOrder(T->lchild);
            PostOrder(T->rchild);
            visit(T);
        }
    }
    

①三种访问顺序只是对于根节点的访问顺序的不同,但是每个节点访问有且仅有一次,故时间复杂度为O(N)

②在递归遍历中,递归工作栈的栈深恰巧为树的深度,在最坏的情况下,二叉树是有n个节点且深度为n的单支树,遍历算法的空间复杂度为O(n)

4.递归算法与非递归算法

借助栈就可以把二叉树的递归算法转化为非递归算法,但是这往往需要读者能够把握住三种遍历的路径以及实质特点。

非递归算法的执行效率要高于递归算法。

  1. 中序遍历的非递归算法

    • 节点遍历路径描述:访问根节点的所有左节点并将其一一进栈,当遍历到最左下节点后,将栈顶元素出栈(能够出栈的元素要么左子节点已经遍历过;要么没有左子树),扫描当前出栈节点的右节点,并将右节点入栈,之后再扫描这个右节点的所有左节点,重复上述过程,直到栈空为止。

    • 代码描述

      void InOrder2(BiTree T){
          InitStack(S);BiTree p = T;
          while(p||!IsEmpty(S)){
              if(p){
                  Push(S,p);
                  p = p->lchild;
              }
              else{
                  Pop(S,p);
                  visit(p);
                  p = p->rchild;
              }
          }
      }
      
  2. 先序遍历的非递归算法

    • 节点遍历路径描述:先访问当前树的根节点,再将该节点的右子节点和左子节点依次入栈,只要栈非空,就重复如下过程:弹出当前栈顶元素并访问,如果该栈顶右子树非空,则将右子节点放进栈中;如果栈顶左子树非空,则将左子节点放进栈中。

    • 代码描述

      void PreOrder2(BiTree T){
          InitStack(S);BiTree p = T;
          Push(S,T);
          while(!IsEmpty(S)){
              Pop(S,p);
              visit(p);
              if(p->rchild!=NULL)
                  Push(S,p->rchild);
              if(p->lchild!=NULL)
                  Push(S,p->lchild);
      }
      
  3. 后序遍历的非递归算法

    1. 方案一:
      • 特点在后序遍历中,对根节点的访问一定要保证在其左右子节点都已经访问了才行**,且对于任意一个有左右子节点的根节点,访问它时上一个访问节点应该是其左子节点或右子节点**
      • 对于任意一个节点,先入栈,如果它左右孩子均不存在,那么直接访问它;如果它存在左孩子或右孩子,但是孩子节点都已经被访问过了,就可以直接访问这个节点;否则,就应该把它的右孩子和做孩子依次入栈。
      • 代码描述
    void InOrder2(BiTree T){
        InitStack(S);
        Push(S,T);
        BiTree pre = NULL,cur;
        while(!IsEmpty(S)){
            cur = S.top();
            if(cur-lchild==NULL&&cur-rchild==NULL||pre!=NULL&&(pre==cur-lchild||pre==cur-rchild)){
                visit(cur);
                Pop(S,pre);//弹出当前遍历节点,并让pre指向该节点
            }
            else{
                if(cur-rchild!=NULL)
                    Push(S,cur-rchild);
                if(cur-lchild!=NULL)
                    Push(S,cur-lchild);
            }
        }
    }
    
    1. 方案二:

      先来谈一下我个人对中序和后序的理解(先序应该是非递归实现中最简单的了,逻辑很顺,且根节点优先访问,而对于每一个子树其本身也可以看做是一个根节点,则只要按序放入栈中再弹出访问即可)。但是先序和后序就需要对于跟、左右子树有明确的区分。
      ①对于中序遍历:为了区分当前访问的节点到底是根节点还是左子树节点,只需要将访问操作“延后”即可,就是在发现当前元素已经为空了,再回溯到栈顶的元素,此时再进行输出。

      ②对于后序遍历:在遍历的过程中对每一棵树或子树的根节点都会经过两次(分别寻找左节点和右节点)。

      • 对于任意一个节点,将它入栈,再沿着它的左子树不断往下遍历和入栈,直到没有左孩子的时候(这一段步骤与中序遍历是相同的),但是此时还不能节点出栈访问,因为右孩子部分呢还没有访问,所以接下来再按照上述相同的规则将节点的右孩子部分依次入栈,访问到右孩子的边界的时候,前面说的栈顶节点又会再一次出现在栈顶,这个时候就可以将其出栈并访问了。注意:这里与中序遍历的区别就是,对左孩子和右孩子的遍历是伴随在入栈的过程中得到

5.层次遍历

  • 说明:层次遍历是按照树的层次结构,依次对节点进行遍历,遍历的顺序和完全二叉树中的树的下标变化顺序时一致的。

  • 遍历机制:借助一个队列,先将二叉树的根节点入队,再出队依次判断,如果当前节点的左孩子存在,则将左孩子入队;如果当前节点的右孩子存在,则将右孩子入队,重复以上过程直到队列为空。

  • 算法代码

    void LevelOrder(BiTree T){
        InitQueue(Q);
        BiTree p;
        EnQueue(Q,T);
        while(!IsEmpty(Q)){
            DeQueue(Q,p);
            visit(p);
            if(p->lchild != NULL){
                EnQueue(Q,p->lchild);
            }
            if(p->rchild!=NULL)
                EnQueue(Q,p->rchild);
        }
    }
    

上述关于树的遍历的算法要当做模板进行记忆。

遍历是二叉树的操作的基础,可以在遍历的过程中对节点进行各种操作(也就是visit的具象版本),例如对已知树求双亲,求孩子节点,求树的深度,求叶子节点个数判断二叉树是否相等etc.

6.由遍历序列构造二叉树

  1. 由二叉树的先序遍历和中序遍历可以唯一地确定一棵二叉树:先序遍历中第一个节点一定是二叉树的根节点;中序遍历中根节点一定将中序序列划分成两个子序列,前一个子序列就是根节点的左子树的中序序列,后一个子序列就是根节点的右子树的中序序列。同理,层序+中序也可以唯一地确定一棵二叉树。

在这里插入图片描述

(三)线索二叉树

证明:在由N个节点的二叉树中,有N+1个空指针

解答:二叉树中各个节点度的可能性只有0,1,2三种可能,故N = N0+N1+N2;每个叶节点有2个空指针,每个度为1的节点有一个空指针,总的空指针数为2N0+N1,且前文证明过:N0 = N2+1,所以,总的空指针为N0+N1+N2+1 = N+1

1.线索二叉树的基本概念

  1. 背景:二叉树的传统链式存储中,节点之间的指向只能体现出父子关系,不能直接得到某一个节点在遍历中(注意:前驱和后继的关系都是放在特定的遍历情景下的,如果遍历的方法不同,前驱和后继自然也不同)。因为二叉链表的表示方法中存在大量的空指针,利用空链域存放直接前驱和后继的指针,则可以更方便地进行相关操作。

  2. 二叉树线索化的定义:若某一个节点没有左子树,则令lchild指向其前驱节点;若没有右子树,令rchild指向后继节点。此外还需要增加两个标志域指示当前指针域存放的是子节点还是前驱或后继。

    ltag lchild data rchild rtag
  3. 存储结构描述

    typedef struct ThreadNode{
        ElemType data;
        struct ThreadNode *lchild,*rchild;
        int ltag,rtag;
    }ThreadNode,*ThreadTree;
    
    • 线索链表:以上述节点构成的二叉链表作为二叉树的存储结构
    • 线索二叉树:加上线索的二叉树
    • 线索化:对二叉树以某种次序遍历使其变成线索二叉树的过程

2.线索二叉树的构造

注:以下二叉树的构造以及遍历都是基于中序遍历而言的。

  1. 核心思想:对二叉树的线索化,实质就是遍历一次二叉树,在遍历的过程中,检查当前节点左、右指针域是否为空,若为空,将它们改成指向前驱节点或者后继节点的线索。

  2. 递归算法表示:

    void InThread(ThreadTree &p,ThreadTree &pre){
        //中序遍历对二叉树线索化的递归算法
        if(p!=NULL){
            InThread(p->lchild,pre);
            if(p->lchild == NULL){
                p->lchild = pre;
                p->ltag = 1;
            }
            pre = p;
            InThread(p->rchild,pre);
        }
    }
    
    void CreateInThread(ThreadTree T){
        ThreadTree pre = NULL;
        if(T!=NULL){
            InThread(T,pre);
            pre->rchild = NULL;
            pre->rtag = 1;
        }
    }
    
  3. 二叉线索链表的头结点

    为了一些操作的方便,仿照线性表的存储结构,在二叉树的线索链表上也添加了一个头结点,并且令lchild域的指针指向二叉线索树的根节点,使其rchild域指向中序遍历时的最后一个节点。

    则在这样的构造下,相当于为二叉树建立了一个双向线索链表,可以从第一个节点其顺后继进行遍历;也可以从最后一个节点其顺前驱进行遍历。

3. 线索二叉树的遍历

利用中序线索化的二叉树,可以实现二叉树遍历的非递归算法。

//求不含头结点的线索二叉树的遍历算法
//求中序线索二叉树的中序序列的第一个节点
ThreadNode *Firstnode(ThreadNode *p){
    while(p->ltag == 0) p = p->lchild;
    return p;
}
//求中序线索二叉树中节点p在中序序列下的后继节点
TreadNode *Nextnode(ThreadNode *p){
    if(p->rtag == 0) return Firstnode(p->rchild);
    //核心!!如果某个节点后面接的就是后继节点,很容易转到后继;如果节点右子节点接的是其右孩子,那么下一个遍历的节点就是以右孩子为根节点的首个节点
    else return r->child;
}
//主函数
void InOrder(ThreadNode *T){
    for(ThreadNode *p = Firstnode(T);p!=NULL;p = Nextnode(p))
        visit(p);
}

三.二叉树相关算法设计题


算法设计题多从以下几个方面考察:

  • 二叉树的四种遍历方式及其变形
  • 线索二叉树的相关操作
  • 基于二叉树的基本遍历对树进行一些基本求解:如树高,某类节点个数,判断某棵树是否为完全二叉树等等。

对于二叉树,因为它的定义是递归的,所以在进行算法设计的时候也往往考虑递归,通常使用的方法有:对节点类型进行讨论(按照度),将树分成三部分(根结点,左子节点,右子节点)

  1. 求解二叉树的树高

    • 非递归算法:
      总体是采用层次遍历的算法设置变量level记录当前节点所在的层数,last指向当前层最右的节点(如何保证last能够一直指向最右呢?因为采取的是层序遍历,所以当队列指针已经访问到上一层最右的时候,上一层的所有子节点都已经入队,当前队列的队尾所指就是下一层最右的节点)

      int Btdepth(BiTree T){
          if(!T)
              return 0;
          int front = -1,rear = -1;
          int last = 0,level = 0;//last指向下一层第一个节点的位置
          BiTree Q[MaxSize];
          Q[++rear] = T;
          BiTree p;
          while(front<rear){
              p = Q[++front];
              if(p->lchild)
                  Q[++rear] = p->lchild;
              if(p->rchild)
                  Q[++rear] = p->rchild;
              if(front==last){
                  level++;
                  last = rear;
              }
          }
          return level;
      }
      
    • 递归算法:

      int Btdepth(BiTree T){
          if(T==NULL) return 0;
          ldepth = Btdepth(T->lchild);
          rdepth = Btdepth(T->rchild);
          return max(ldepth,rdepth)+1;
      }
      

      求某一层的节点个数、每一层节点的个数、树的最大宽度都可以采取上述类似思想

  2. 判断某棵树是否为完全二叉树
    采用层次遍历,将节点依次入队,并出队判断节点是否为空,若空节点之后队列中还存储有非空节点,那么非完全二叉树。

    bool IsComplete(BiTree T){
        InitQueue(Q);
        if(!T)
            return 1;
        EnQueue(Q,T);
        while(!IsEmpty(Q)){
            DeQueue(Q,p);
            if(p){
                EnQueue(Q,p->lchild);
                EnQueue(Q,p->rchild);
            }
            else{
                while(!IsEmpty(Q)){
                    DeQueue(Q,p);
                    if(p)
                        return 0;
                }
            }
        }
        return 1;
    }
    
  3. 将表达树转化成中缀表达式,用括号表达计算的次序

    • 问题描述

在这里插入图片描述

  • 问题求解
    因为要输出的是中缀表达式,且运算符都存储在分支节点上,所以对二叉树进行基于中序遍历的一些操作即可。

    除了根节点还有叶节点,遍历到其他节点时,在遍历其左子树之前加上左括号,遍历完右子树后加上右括号

    void BtreeToE(BTree *root){
        BtreeToExp(root,1);
    }
    void BtreeToExp(BTree *root,int deep){
        if(root == NULL)
            return;
        else if(root->left == NULL&&root->right ==NULL)
            printf("%s");
        else{
            if(deep>1) printf("(");
            BtreeToExp(root->left,deep+1);
            printf("%s",root->data);
            BtreeToExp(root->right,deep+1);
            if(deep>1)
                printf(")");
        }
    }
    

参考文档:

https://www.cnblogs.com/SHERO-Vae/p/5800363.html

《王道2019年数据结构考研复习指导》

猜你喜欢

转载自blog.csdn.net/kodoshinichi/article/details/106969894