数据结构-树与二叉树

简介: 数据结构-树与二叉树

树的基本概念

树(Tree)的定义

树是一种按层次关系组织起来的一对多分支结构。例如:一个学校由若干个学院组成,每个学院又由若干个专业组成。

:是$n(n \ge 0)$个结点的有限集合$T$,它满足如下两个条件:

  • 有且仅有一个特定的称为根(Root)的结点,它没有前驱。
  • 其余的结点可分为$m$个互不相交的有限集合$T_1, T_2, \cdots, T_m$,其中每个集合又是一颗树,并称为根的子树。

当$n = 0$时的空集合定义为空树。
由树的定义知,其时一个递归的定义,即树的定义中又用到了树的概念。树是一种递归的数据结构,树作为一种逻辑结构,同时也是一种分层结构,具有以下两个特点:

  • 树的根节点没有前驱,除根节点外的所有结点有且只有一个前驱。
  • 树中所有结点可以有零个或多个后继。
    树

    基本术语

  • 结点:指树中的一个元素,包含数据项及若干指向其子树的分支。
  • 结点的度:指结点拥有的子树的个数,如上图树的定义中学校结点的度为3,学院1的结点度为2。
  • 树的度:指树中最大结点度数。如上图树的度为3。
  • 叶子:指度为零的结点,又称为终端结点。例如图中的专业结点。
  • 结点的层次:从根节点开始,根节点为第一层,根的孩子结点为第二层,根的孩子的孩子为第三层,依此类推。
  • 树的深度(高度):树中结点的最大层次数。
  • 孩子:一个结点的子树的根称为该结点的孩子。
  • 双亲(父)结点:一个结点的直接上层结点称为该结点的双亲。
  • 兄弟:同一双亲的孩子互称为兄弟。
  • 路径:若存在一个结点序列$k_1, k_2, \cdots, k_j$,可使$k_1$到达$k_j$,则称这个结点序列是$k_1$到$k_j$的一条路径。
  • 森林:$m(m \ge 0)$棵互不相交的树的集合构成森林。当删除一棵树的根时,就得到子树构成森林;当在森林中加上一个根结点时,则森林就变为一颗树。
  • 有序树和无序树:若树中每个结点的各个子树都看成是从左到右有次序的(不能互换),则称该树为有序树。否则为无序树。

    树的性质

  • 树中的结点数等于所有结点的度数之和加1。
  • 度为$m$的树中第$i$层上至多有$m^{i - 1}(i \ge 1)$个结点。
  • 高度为$h$的$m$叉树至多有$\frac{m^h - 1}{m - 1}$个结点。
  • 具有$n$个结点的$m$叉树的最小高度为$\lceil log_m(n(m - 1) + 1) \rceil$

二叉树

二叉树的定义

二叉树:是$n(n \ge 0)$个结点的有限集,它或为空树$(n = 0)$,或由一个根节点及两棵互不相交,分别称为这个根的左子树和右子树的二叉树构成。

特殊二叉树

满二叉树

满二叉树:一棵高度为$h$的树,且含有$2^h -1$个结点的二叉树称为满二叉树,即树中每层都含有最多的结点。并且除叶子结点之外的每个结点度数均为2。
对满二叉树按照层序编号:从根结点(编号为1)开始,从上到下,从左到右。对于编号为$i$的结点,若有双亲,其双亲为$\lfloor \frac{i}{2} \rfloor$;若有左孩子,左孩子为$2i$;若有右孩子,右孩子为$2i + 1$。
满二叉树

完全二叉树

完全二叉树:高度为$h$,有$n$个结点的二叉树,当且仅当每个结点都与高度为$h$的满二叉树中编号为1 ~ $n$的结点一一对应时,称为完全二叉树。
完全二叉树
完全二叉树有特点如下:

  • 若$i \le \lfloor \frac{n}{2} \rfloor$,则结点$i$为分支结点,否则为叶子结点。
  • 叶子结点只可能在层次最大的两层上出现。
  • 若有度为1的结点,则只可能有一个,且该结点只有左孩子无右孩子。
  • 按层序编号,若某结点编号为$i$为叶子结点或只有左孩子,则编号大于$i$的结点均为叶子结点。
  • 若$n$为奇数,则每个分支结点都有左孩子和右孩子;若$n$为偶数,则编号最大的分支结点(编号为$\frac{n}{2}$)只有左孩子,没有右孩子,其余分支结点左右孩子都有。

    二叉排序树

    二叉排序树:左子树上所有结点的关键字均小于根结点的关键字;右子树上的所有结点的关键字均大于根节点的关键字。

    平衡二叉树

    平衡二叉树:树上任一结点的左子树和右子树的深度之差不超过1。

    二叉树的性质

  • 二叉树的第$i$层上至多有$2^{i - 1}(i \ge 1)$个结点。
  • 深度为$k$的二叉树至多有$2^k - 1(k \ge 1)$个结点。
  • 对任何一棵二叉树,如果其终端结点数为$n_0$,度为2的结点数为$n_2$,则$n_0 = n_2 + 1$。
  • 具有$n$个结点的完全二叉树的深度为$\lceil log_2(n + 1) \rceil$或$\lfloor log_2n \rfloor + 1$

    二叉树的存储结构

    顺序存储结构

    完全二叉树的顺序存储结构

    二叉树的顺序存储结构是将二叉树的所有结点,按照一定的顺序化方式,存储到一片连续的存储单元中。结点的顺序将反映出结点之间逻辑关系。
    在一棵完全二叉树中,从根结点开始,自上到下,从左到右的方式对结点进行顺序编号,便可得到一个反映结点之间关系的线性序列。这时只要按照结点的顺序依次将结点存储到一个具有$n + 1$个单元的向量中,就实现了完全二叉树的顺序存储。
    完全二叉树
    完全二叉树的顺序存储:
    | 编号 | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
    | --- | --- | --- | --- | --- | --- | --- | --- |
    | 结点值 | | 1 | 2 |3 | 4 |5 |6 |

对于一棵具有$n$个结点的完全二叉树,确定相应编号的规则如下:

  • 若$i = 1$,则$i$为根结点;若$i \ge 1$,则$i$结点的双亲结点编号为$\lfloor \frac{i}{2} \rfloor$
  • 若$2i > n$,则$i$结点无左孩子,$i$结点是终端结点;若$2i \le n$,则$2i$是结点$i$的左孩子。
  • 若$2i + 1 > n$,则$i$结点无右孩子;若$2i + 1 \le n$,则$2i + 1$是结点$i$的右孩子。
  • 若$i$为奇数且$i \ne 1$,则结点$I$的左兄弟是$i - 1$;否则结点$i$没有左兄弟。
  • 若$i$为偶数且$i < 1$,则结点$I$的右兄弟是$i + 1$;否则结点$i$没有右兄弟。

一般二叉树的顺序存储

对于完全二叉树的特殊情况,结点的层次顺序序列反映了整个二叉树的结构,完全二叉树采用顺序存储,即简单又节省存储空间。但对于一般二叉树,为了能用结点在向量中的相对位置来表示结点间的逻辑关系,往往需要添加一些结点来构成完全二叉树,在按照完全二叉树的顺序来存储。
一般二叉树
在一般二叉树构成完全二叉树示例中,其中方形结点表示虚结点,并用符合@表示结点值。
| 编号 | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 结点值 | | 1 | 2 |3 | @ |@ |6 |

通过虚结点构成完全二叉树虽然保持了结点间的逻辑结构,但也造成了空间的浪费。在最坏情况下,一个深度为$k$且只有$k$个结点的二叉树需要$2^k -1$个存储单元。

顺序存储的存储类型描述

#define MaxSize 512

typedef struct {
    // 数据域
    DataType data[MaxSize];
    // 最后一个结点在表中的位置
    int last;
} SequenList;

链式存储结构

因为二叉树的每个结点至多有两个孩子,所以采用链式存储来存储二叉树时,每个结点应至少包含三个域:数据域,左孩子指针域,右孩子指针域。
二叉树链式存储结点结构
因为二叉树的链式存储结构中包含两个指针域来分别指向相应的分支,因此二叉树的链式存储结构也称为二叉链表。

链式存储的结构类型描述

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

在二叉链表中,寻找结点的双亲结点需要从头遍历二叉树。为了方便寻找,在经常需要访问双亲结点的二叉树中可以在每个结点上增加一个指向其双亲的指针域parent,这种带三个结点的二叉树链式存储结构称为三叉链表。

二叉树的建立

建立二叉树常见的算法是添加虚结点,使之称为完全二叉树,按照完全二叉树的层次顺序,依次输入结点信息建立二叉链表。
建立二叉树链式存储结构的基本思想是:依次输入结点信息,若输入的结点不是虚结点,建立新结点,否则将新结点作为孩子结点链接在它的双亲结点上。这种方法可以建立辅助队列保存已输入结点,用队尾指针rear指向当前输入结点,队头指针front指向这个结点的双亲结点。

/**
 * 建立二叉链表
 * @param root 根结点
 */
void CreateTree(BiTree *root) {
    // 结点信息
    char ch;
    // 辅助队列
    BiTree *Queue[MaxSize];
    // 中间指针变量
    BiTree *s;
    // 队列指针变量初值
    int front = 1, rear = 0;

    while ((ch = (char) getchar()) != '#') {
        s = NULL;
        // 当不是虚结点时,添加新结点
        if (ch != '@') {
            s = (BiTree *) malloc(sizeof(BiTree));
            s->data = ch;
            s->lchild = NULL;
            s->rchild = NULL;
        }

        rear++;
        // 新结点入队
        Queue[rear] = s;

        if (rear == 1) {
            // 队列为1时,为根结点
            // 在荣政的教材中这里是root = s,但是因为本人在编写算法时,将root作为输入输出参数
            // 所以这里不能直接用root = s,否则会修改root指针的指向,导致结果无法输出到外部,原root指针悬空
            root->data = ch;
            root->lchild = NULL;
            root->rchild = NULL;
            Queue[rear] = root;
        } else {
            // 当前结点和双亲结点均不为空
            if (s && Queue[front]) {
                if (rear % 2 == 0) {
                    // rear为偶数(完全二叉树性质),结点为左孩子
                    Queue[front]->lchild = s;
                } else {
                    // rear为奇数(完全二叉树性质),结点为右孩子
                    Queue[front]->rchild = s;
                }
            }
            if (rear % 2 == 1) {
                front++;
            }
        }
    }
}

二叉树的遍历

二叉树的遍历是按照某种搜索路径来访问二叉树的每一个结点,使每个结点被且仅被访问一次。按照搜索路径的不同,二叉树的遍历可分为深度优先遍历和广度优先遍历两种方式。

深度优先遍历

根据二叉树的定义可知,二叉树由根结点、左子树和右子树三个基本部分构成。只要依次遍历这三个基本部分,便可遍历整个二叉树。设以L、D和R分别表示访问左子树、访问根结点和访问右子树,则有DLR、LDR、LRD、DRL、RDL和RLD六种不同的深度优先遍历方案,则只有DLR(先序(前根)遍历),LDR(中序(中根)遍历)和LRD(后序(后根)遍历)三种遍历方案。
因为遍历左、右子树的问题和遍历整个二叉树具有相同的特征属性,所以可以采用递归的方法来实现二叉树的深度优先遍历。

深度优先遍历的递归算法

先序遍历算法——递归

算法流程:

  • 访问根结点
  • 先序遍历左子树
  • 先序遍历右子树
    /**
    * 先序遍历深度优先算法(根左右)
    * @param root 
    */
    void PreOrder(BiTree *root) {
      if (root != NULL) {
          printf("%c ", root->data);
          PreOrder(root->lchild);
          PreOrder(root->rchild);
      }
    }
    
    中序遍历算法——递归
    算法流程:
  • 中序遍历左子树
  • 访问根结点
  • 中序遍历右子树
    /**
    * 中序遍历深度优先算法(左根右)
    * @param root
    */
    void InOrder(BiTree *root) {
      if (root != NULL) {
          InOrder(root->lchild);
          printf("%c ", root->data);
          InOrder(root->rchild);
      }
    }
    
    后序遍历算法——递归
    算法流程:
  • 后序遍历左子树
  • 后序遍历右子树
  • 访问根结点
    /**
    * 后序遍历深度优先算法(左右根)
    * @param root
    */
    void PostOrder(BiTree *root) {
      if (root != NULL) {
          PostOrder(root->lchild);
          PostOrder(root->rchild);
          printf("%c ", root->data);
      }
    }
    

    深度优先遍历的非递归算法

    前面三种深度优先算法都是用递归实现的。虽然递归算法简单紧凑,结构清晰,但是其运行效率低,可读性较差,同时并非所有语言都支持允许递归,所有需要将递归算法转化为等价的非递归算法。
    将递归算法转变为非递归算法时使用一个堆栈来保存每次调用的参数。
    前序遍历算法——非递归
    /**
    * 前序深度优先遍历算法非递归版本(根左右)
    * @param root
    */
    void NpreOrder(BiTree *root) {
      // 辅助栈
      BiTree *stack[MaxSize];
      BiTree *s = root;
      int top = -1;
      while (top != -1 || s != NULL) {
          while (s != NULL) {
              // 一直向左遍历,直到遍历到最左的根结点
              if (MaxSize - 1 == top) {
                  printf("overflow");
                  return;
              } else {
                  // 入栈
                  top++;
                  stack[top] = s;
                  // 访问根结点,因为是向左搜索,左子树即是左结点也是根结点,所以直接访问
                  printf("%c ", s->data);
                  // 令下一个活动结点为根节点的左子树
                  s = s->lchild;
              }
          }
          // 向左遍历完毕,向右子树遍历
          s = stack[top];
          top--;
          s = s->rchild;
      }
    }
    
    中序遍历算法——非递归
    /**
    * 中序深度优先遍历算法非递归版本(左根右)
    * @param root
    */
    void NinOrder(BiTree *root) {
      // 辅助栈
      BiTree *stack[MaxSize];
      BiTree *s = root;
      int top = -1;
      while (top != -1 || s != NULL) {
          while (s != NULL) {
              // 一直向左遍历,直到遍历到最左的根结点
              if (MaxSize - 1 == top) {
                  printf("overflow");
                  return;
              } else {
                  top++;
                  stack[top] = s;
                  s = s->lchild;
              }
          }
          s = stack[top];
          top--;
          // 访问左子树
          printf("%c ", s->data);
          s = s->rchild;
      }
    }
    
    后序遍历算法——非递归
    /**
    * 后序深度优先遍历算法非递归版本(左右根)
    * @param root
    */
    void NpostOrder(BiTree *root) {
      BiTree *stack[MaxSize];
      BiTree *s = root;
      int top = -1;
      while (top != -1 || s != NULL) {
          while (s != NULL) {
              if (MaxSize - 1 == top) {
                  printf("overflow");
                  return;
              } else {
                  top++;
                  s->flag = 0;
                  stack[top] = s;
                  s = s->lchild;
              }
          }
          s = stack[top];
          top--;
          if (s->flag == 0) {
              // flag == 0,说明还没有遍历它的右孩子
              s->flag = 1;
              stack[++top] = s;
              s = s->rchild;
          } else {
              printf("%c ", s->data);
              s = NULL;
          }
      }
    }
    

广度优先遍历(层次遍历)

二叉树的广度优先遍历又称层次遍历。
这种遍历方式是从左到右,从上到下先遍历二叉树的第一层结点,然后遍历第二层结点,依此类推,直到遍历最下一层结点。
在广度优先遍历算法实现中,需要使用一个队列,把二叉树的根结点存储地址入队,然后依次从队列中取出出队结点的存储地址,每出队一个结点的存储地址则对该结点进行访问,再依次将该结点的左右孩子入队,直到队空。

/**
 * 广度优先遍历(层次遍历)
 * @param root 
 */
void Layer(BiTree *root) {
    // 辅助队列
    BiTree *Queue[MaxSize];
    // 辅助结点
    BiTree *s;
    int front = 0, rear = 1;
    Queue[rear] = root;
    // 队列为空时,即队头和队尾指针重叠遍历完毕
    while (front < rear) {
        front++;
        // 访问队头结点
        s = Queue[front];
        printf("%c ", s->data);
        if (s->lchild != NULL) {
            rear++;
            // 左孩子入队
            Queue[rear] = s->lchild;
        }
        if (s->rchild != NULL) {
            rear++;
            // 右孩子入队
            Queue[rear] = s->rchild;
        }
    }
}

根据遍历序列恢复二叉树

前面我们讨论了二叉树的遍历问题,下面讨论遍历的逆问题,根据遍历算法恢复二叉树。
需要注意的是,已知二叉树,可以确定其唯一的遍历序列,但是只知道一个序列,不能唯一确定对应二叉树,唯一确定二叉树需要中序遍历序列和其他遍历序列一起才可以确定对应二叉树。
由二叉树的定义知,先序遍历是先访问根结点,所以先序序列的第一个序列值必是二叉树的根结点。在中序序列中,根据先序序列确定的根结点位置,可以将中序序列分割为两边,其中左边对应二叉树的左子树,右边对应二叉树的右子树,依次类型,就可以确定二叉树对应图像。同理可得中序序列和后序序列、中序队列和层次序列恢复二叉树图像方法。
例如:一棵二叉树的先序序列为ABDGCEFH,中序序列为DGBAECHF,其构造方式如下:

  • 从先序序列中确定A是二叉树根节点,根据A在中序序列中的位置,确定DGB在左子树上,ECHF在右子树上
  • 根据先序序列确定B和C分别是A的左子树和右子树的根,根据B和C在DGB和ECHF中的位置可知DG在B的左子树上,B的右子树为空;E在C的左子树上,HF在右子树上
  • 根据中序序列可知G在D后,为D的右子树的根,H在F前,可知H为F的左子树。
    /**
    * 采用递归方法根据前序和中序序列求二叉树
    * @param preod 前序序列数组
    * @param inod 中序序列数组
    * @param ps 前序序列首元素索引
    * @param pe 前序序列尾元素索引
    * @param is 中序序列首元素索引
    * @param ie 中序序列尾元素索引
    * @return 
    */
    BiTree *BPI(DataType preod[], DataType inod[], int ps, int pe, int is, int ie) {
      int m;
      BiTree *p;
      if (pe < ps) {
          return NULL;
      }
      p = (BiTree *) malloc(sizeof(BiTree));
      // 前序序列首元素为根结点,添加到结点的数据域中
      p->data = preod[ps];
      // 在中序序列中遍历寻找前序结点对应的根结点值
      m = is;
      while (inod[m] != preod[ps]) {
          m++;
      }
      // 中序序列左边的为左子树,m为中序的索引号
      p->lchild = BPI(preod, inod, ps + 1, ps + m - is, is, m - 1);
      // 中序序列右边的为右子树
      p->rchild = BPI(preod, inod, ps + m - is + 1, pe, m + 1, ie);
      return p;
    }
    

    树和森林

    树

树的存储结构

双亲表示法

在树中,每个结点的双亲是唯一的,利用这个性质,可以在存储结点信息的同时,为每个结点存储其双亲结点的地址信息。

#define MaxSize 50

typedef struct {
    DataType data;
    int parent;
} ParentTree;

ParentTree tree[MaxSize];

上图所示的树的双亲表示法如下表所示:
| 结点 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| data | | A | B | C | D | E | F | G | H | I | J |
| parent | -1 | 0 | 1 | 1 | 1 | 2 | 2 | 3 | 4 | 4 | 4 |

孩子表示法

因为树中每个子树的数目不相同,因此采用链式存储结构表示树时,每个结点要设置的孩子指针是难以确定的。若以整个树的度k来设置指针,则会造成存储空间的浪费,若按每个结点的实际孩子数来设置指针,虽然存储空间利用率高,但是运算不便。所以上述两种方法都不可取。
孩子表示法,为每个结点建立一个孩子链表,孩子链表通过指针连接起来。

// 孩子链表结点
typedef struct ChildNode {
    // 孩子结点序号
    int child;
    struct ChildNode *next;
} Link;

typedef struct {
    DataType data;
    int parent;
    // 孩子链表头指针
    Link *headptr;
} ChildTree;

ChildTree tree{MaxSize];

孩子兄弟表示法

在存储结点信息时,附加两个分别指向该结点的最左孩子和右邻兄弟指针域first和next,即可得到孩子兄弟表示法。其中first指向数的最左边的孩子,next指向其兄弟结点。这种存储的最大优点是,它和二叉树的二叉链表表示法完全一样,可以利用二叉树的算法来实现。

typedef struct ChildSiblingNode {
    DataType data;
    struct ChildSiblingNode *firstChild, *nextSibling;
} ChildSiblingTree;

数、森林和二叉树的转换

任何一棵森林或一棵树都可以唯一转换成一棵二叉树;任意一棵二叉树也能唯一对应于一个森林或一棵树,这种转换是具有唯一性。

将一棵树,森林转换为二叉树

  • 在兄弟之间增加一条连线
  • 对每个结点,除了保留与其左孩子的连线,除去与其他孩子之间的连线
  • 以树的根结点为轴心,将整个树顺时针旋转$45 \degree$

    将一棵二叉树转换为树、森林

  • 若结点X是双亲Y的左孩子,则把X的右孩子,右孩子的右孩子$\cdots$都与Y用连线相连
  • 去掉原有的双亲到右孩子的连线

    树和森林的遍历

    树的遍历

    树的遍历主要有两种方式:
  • 先根遍历:若树非空,先访问其根结点,再依次遍历根结点的每棵子树,遍历子树时仍遵循先根后子树的规则。其遍历序列与这棵树对应的二叉树先序序列相同。
  • 后根遍历:若树非空,先依次遍历根结点的每棵子树,再访问根结点,遍历子树时仍遵循先子树后根的规则。其遍历序列与这棵树对应的二叉树中序序列相同。

    森林的遍历

    按照森林和树的递归定义,可得到森林的两种遍历方法:
  • 先序遍历森林:
    • 访问森林中第一棵树的根结点
    • 先序遍历第一棵树中根结点的子树森林
    • 先序遍历除去第一棵树之后剩余的树构成的森林
  • 中序遍历森林:
    • 中序遍历森林中第一棵树的根结点的子树森林
    • 访问第一棵树的根结点
    • 中序遍历除去第一棵树之后剩余的树构成的森林

      树、森林遍历与二叉树遍历对应关系

      | 树 | 森林 | 二叉树 |
      | --- | --- | --- |
      | 先根遍历 | 先序遍历 | 先序遍历 |
      | 后根遍历 | 中序遍历 | 中序遍历 |

      线索二叉树

      依照前面介绍的几种遍历方式对二叉树遍历后,将按一定规则得到一个线性序列,使每个结点(除第一个和最后一个)再这些线性序列中有且仅有一个直接前驱和直接后继。
      如果希望快速得到一个结点的前驱和后继,却不希望每次都对二叉树遍历一遍,这就需要把每个结点的前驱和后继结点记录下来,为此我们引入线索二叉树

      线索二叉树的建立

      为了记录结点的前驱和后继,可在原来的二叉链表中增加一个前驱指针pred和后继指针succ,分别指向该结点的前驱结点和后继结点。
      线索二叉树指针结构
      这样做会浪费存储空间,在$n$个结点的二叉链表中含有$n + 1$个空指针域,可以利用这些空指针来存放结点的前驱和后继指针,这些附加的指针称为线索,加上线索的二叉链表称为线索链表,相应的二叉树称为线索二叉树。为了区分一个结点的指针是指向其孩子的还是指向线索的,可以在每个结点中增加两个线索标志域。
      线索二叉树的标志结构
      将二叉树变为线索二叉树的过程称为线索化,线索化的实质是将二叉链表中的空指针改为指向前驱和后继线索,而前驱和后继的信息只有在遍历时才能得到,因此线索化的过程即为在遍历的过程修改空指针的过程。
      为了记录遍历时的先后关系,附设一个指针pre指向刚刚访问过的结点,指针p指向当前正在访问的结点。显然pre是p的前驱,p是pre的后继。
      在中序线索化算法中,对当前根结点p所做的处理如下:
  • 若结点p有空指针域,将相应的标志设为1
  • 若结点有中序前驱结点pre(即pre != NULL),则:
    • 若结点pre的右线索标志已建立(pre.rtag == 1),则令pre.rchild指向其中序后继结点p的右线索
    • 若结点p的左线索标志已建立(p.tag == 1),则令p.lchild为指向其中序结点前驱结点pre的左线索
  • 将pre指向刚刚访问过的结点p

    结点类型描述

    typedef struct Node {
      // 线索标志
      int ltag, rtag;
      // 数据域
      DataType data;
      // 指针域
      struct Node *lchild, *rchild;
    } ClueTree;
    

    线索二叉树建立算法

    ```C
    ClueTree *pre;

/**

  • 线索二叉树中序线索化函数(左根右)
  • @param root
    /
    void _InThread(ClueTree
    root) {
    if (root != NULL) {
     // 左子树线索化
     _InThread(root->lchild);
     // 建立左线索标志
     if (root->lchild == NULL) {
         root->ltag = 1;
     }
     // 建立右线索标志
     if (root->rchild == NULL) {
         root->rtag = 1;
     }
     if (pre != NULL) {
         if (1 == pre->rtag) {
             // pre无右子树,令当前结点作为前驱结点的后继
             pre->rchild = root;
         }
         if (1 == root->ltag) {
             // 当前结点无左子树,令pre称为当前结点的前驱
             root->lchild = pre;
         }
     }
     pre = root;
     // 右子树线索化
     _InThread(root->rchild);
    
    }
    }

/**

  • 线索二叉树建立算法包装函数
  • @param root
    /
    void InThread(ClueTree
    root) {
    // 重置pre前置变量
    pre = NULL;
    _InThread(root);
    }
    ```
    在荣政的教材中采用全局变量方式递归线索化二叉树,但是那种方法在调用一次后,pre不在指向NULL,而是上一次线索化二叉树的最后一个序列,造成对其他二叉树线索化中会导致线索化出错。

    访问线索二叉树

    查找某结点的前驱结点和后继结点

    查找结点p的中序后继结点分两种情况:
    • 若结点的右子树为空,则p.rchild为右线索,指向p的后继结点
    • 若结点的右子树非空,则p的中序后继必是右子树第一个中序遍历到的结点。
      • 即从右孩子开始沿左指针链向下找,直至一个没有左孩子的结点为止,这个结点是p的右子树中最左下的结点,就是p的中序后继结点。
        ```C
        /**
  • 中序线索二叉树查找后继结点
  • @param root
  • @return
    /
    ClueTree
    InOrderNext(ClueTree root) {
    ClueTree
    q = NULL;
    if (root->rtag == 1) {
     // 右子树为空,这里rtag == 1,表示root结点为右子树因为为空,才设置为1
     // 其右孩子指针指向是NULL或者后继线索
     return root->rchild;
    
    } else {
     // 右子树非空
     q = root->rchild;
     // 查找右子树的最左下结点
     while (q->ltag == 0) {
         q = q->lchild;
     }
    
    }
    return q;
    }
    ```

    遍历线索二叉树

    遍历线索二叉树,只要从该结点开始,查找该次序下的后继,直至终端结点为止。
    中序线索二叉树遍历算法:
    ```C
    /**
  • 中序二叉树遍历算法
  • @param root
    /
    void TravInThread(ClueTree
    root) {
    if (root != NULL) {
     // 寻找中序序列的开始结点
     while (root->ltag == 0) {
         root = root->lchild;
     }
     do {
         printf("%c", root->data);
         // 找当前结点的直接后继
         root = InOrderNext(root);
     } while (root != NULL);
    
    }
    }
    ```

    二叉树的应用

    哈夫曼树(最优二叉树)

    哈夫曼树:又称最优二叉树,是一类带权路径长度最短的树。

    哈夫曼树的定义

    在许多应用中,树中结点常常被赋予了一个表示某种意义的数值,称为该结点的权。从树的根到任意结点的路径长度与该结点的权的乘积,称为该结点的带权路径长度,树中所有叶结点的带权路径长度之和称为该树的带权路径长度,记作:
    $$ WPL = \sum_{i=1}^n w_il_i $$
    式中,$w_i$是第$i$个叶结点所带权值,$l_i$是叶结点到根结点的路径长度。
    在含有$n$个结点个带权叶结点的二叉树中,其中带权路径长度最小的二叉树称为哈夫曼树,也称最优二叉树

    哈夫曼树的构造

    给定$n$个权值分别为$w_1, w_2, \cdots, w_n$的结点,构造哈夫曼树的算法描述如下:
    • 将这$n$个结点分别作为$n$棵仅含一个结点的二叉树,构成森林$F$
    • 构造一个新结点,从$F$中选取两棵根结点权值最小的树作为新结点的左、右子树,并且将新结点的权值置为左、右子树上根结点的权值之和
    • 从$F$中删除刚才选出的两棵树,同时将新得到的树加入$F$中
    • 重复(2)和(3)步骤,直至$F$中只剩下一棵树为止

上述构造的哈夫曼树有以下特点:

  • 每个初始结点始终都成为叶结点,且权值越小的结点到根结点的路径长度越大
  • 构造过程中共新建了$n - 1$个结点,因此哈夫曼树的结点总数为$2n - 1$
  • 每次构造都选择2棵树作为新结点的孩子,因此哈夫曼树中不存在度为1的结点

    哈夫曼树结点存储结构

    哈夫曼结点存储结构
    typedef struct {
      // 权值
      float weight;
      DataType data;
      int lchild, rchild, parent;
    } HuffmanTree;
    

    哈夫曼树构造算法

    ```C
    // 叶子结点个数

    define LEAVES 6

    // 哈夫曼树结点个数,根据哈夫曼树结点的性质 结点数 = 2 * 叶子数 - 1

    define NODE_NUM 2 * LEAVES - 1

    // 辅导书比较精度

    define EPS 1e-5

/**

  • 构造哈夫曼树
  • @param tree
    */
    void CreatHuffman(HuffmanTree tree[]) {
    char ch;
    float weight;
    // pointSmall:权值最小结点(指针),pointBig:权值第二小结点(指针)
    int pointSmall, pointBig;
    // smallWeightSmall:对应最小权值,对应第二小权值
    float smallWeightSmall, smallWeightLarge;
    // 初始化结点数组
    for (int i = 0; i < NODE_NUM; ++i) {
     tree[i].parent = -1;
     tree[i].lchild = -1;
     tree[i].rchild = -1;
     tree[i].weight = 0.0;
     tree[i].data = '0';
    
    }
    // 叶子结点赋值
    for (int i = 0; i < LEAVES; ++i) {
     scanf("%f", &weight);
     tree[i].weight = weight;
     scanf("%c", &ch);
     tree[i].data = ch;
    
    }
    // 确定从叶子结点后的的结点具体值
    for (int i = LEAVES; i < NODE_NUM; ++i) {
     pointSmall = pointBig = 0;
     // FLT_MAX:float.h定义宏变量,float最大值
     // 令smallWeight的值都为float最大值(相当于INF),避免对数组种权值的比较产生影响
     smallWeightSmall = smallWeightLarge = FLT_MAX;
     for (int j = 0; j < i; ++j) {
         if (tree[j].parent == -1) {
             // 判断搜索的结点的父结点是否为-1,即当前处理的结点是未处理过的结点
             // 处理过的结点指向父结点,除根结点外必为非负值,在这里根结点是最后处理
             if (tree[j].weight - smallWeightSmall < EPS) {
                 // 当前搜索的结点小于最小的smallWeightSmall结点
                 smallWeightLarge = smallWeightSmall;
                 smallWeightSmall = tree[j].weight;
                 // 令pointSmall指向当前搜索结点即最小权值对应结点,pointBig原倒一小权值对应结点
                 pointBig = pointSmall;
                 pointSmall = j;
             } else if (tree[2].weight - smallWeightLarge < EPS) {
                 // 当前搜索的结点大于最小的smallWeightSmall结点,小于第二小的smallWeightLarge结点
                 smallWeightLarge = tree[j].weight;
                 // pointBig指向当前搜索结点
                 pointBig = j;
             }
         }
     }
     // 令最小和第二小结点的父结点指向当前结点
     tree[pointSmall].parent = i;
     tree[pointBig].parent = i;
     // 当前结点赋值
     tree[i].lchild = pointSmall;
     tree[i].rchild = pointBig;
     tree[i].parent = -1;
     tree[i].weight = tree[pointSmall].weight + tree[pointBig].weight;
    
    }
    }
    ```

    哈夫曼编码

    在数据通信中,总是将字符转换位二进制数序列进行传送,并在接收端将二进制编码转换为原字符,这个过程就是编码和译码。
    编码分为等长编码和不等长编码,等长编码十分方便,但是在实际使用中,有些字符频繁使用,有些字符极少使用,都采用等长编码,会造成存储空间浪费。因此可以采用不等长编码,让使用频率高的字符尽可能短,是传送报文总长减少。
    若设计不等长编码,则必须是任意一个字符的编码都不是其他字符的前缀,这种编码称为前缀编码。

由哈夫曼树得到哈夫曼编码,将每个字符当作一个独立结点,其权值为其出现的频度或次数,构造出哈夫曼树。显然,所有字符结点都出现在叶子结点,保证每个结点不是其他结点的前缀。然后将字符的编码解释为从根到字符路径上边标记的序列,其中边标记为0表示转向左孩子,标记为1表示转向后孩子。

编码数组结构

因为每个叶子结点对应一个编码数组,每个结点都有其双亲指针域,所以可以从叶子结点出发回溯到根结点来确定叶子结点对应的编码。
因为哈夫曼树每一次合并都对应一次代码分配,因此$n$个叶子结点的最大编码长度不会超过$n - 1$,故可为每个叶子结点分配一个长度为$n$的编码数组。从叶子结点向上回溯生成的代码序列与实际编码的代码序列相反,所以需要一个变量来记录代码序列在编码数组中的起始位置。

typedef struct {
    // 编码数组串
    char bits[LEAVES];
    // 编码在串中的起始位置
    int start;
    // 结点值
    DataType data;
} CodeType;

哈夫曼编码算法

哈夫曼编码

哈夫曼编码的基本思想是:从叶子结点出发,利用双亲地址寻找双亲结点,再利用双亲结点的指针域判断当前结点是左孩子还是右孩子,绝对分配编码'0'还是编码'1',继续回溯,直到根结点为止。

/**
 * 根据哈夫曼树构造哈夫曼编码
 * @param code
 * @param tree
 */
void HuffmanEnCode(CodeType code[], HuffmanTree tree[]) {
    int temp_i;
    int parent;
    CodeType temp_code;
    for (int i = 0; i < LEAVES; ++i) {
        temp_code.start = LEAVES;
        // 通过temp_i对当前循环变量i进行控制,避免在最后影响当前叶子结点的赋值
        temp_i = i;
        parent = tree[temp_i].parent;
        while (parent != -1) {
            temp_code.start--;
            // 判断当前结点是父结点的左子树还是右子树
            if (tree[parent].lchild == temp_i) {
                temp_code.bits[temp_code.start] = '0';
            } else {
                temp_code.bits[temp_code.start] = '1';
            }
            temp_i = parent;
            parent = tree[temp_i].parent;
        }
        code[i] = temp_code;
    }
}
哈夫曼译码

哈夫曼译码是指给定代码求出代码所表示的结点值,是哈夫曼编码的逆过程。
哈夫曼译码的过程是:从根结点出发,逐个读入二进制代码,若代码为0则走左孩子,反之走右孩子,直到根结点。

/**
 * 哈夫曼译码
 * @param code 
 * @param tree 
 */
void HuffmanDeCode(CodeType code[], HuffmanTree tree[]) {
    // 当前结点(指针)
    int point;
    // 结束标志
    int endFlag = -1;
    // 读入编码
    int inputCode;
    // 令当前结点指针指向根结点,根据哈夫曼树的性质,根结点必为哈夫曼数组中最后一个数,即最后一个结点
    point = NODE_NUM - 1;
    // 读入二进制代码
    scanf("%d", &inputCode);
    while (inputCode != endFlag) {
        // 根据当前编码走向左右孩子
        if (0 == inputCode) {
            point = tree[point].lchild;
        } else {
            point = tree[point].rchild;
        }
        // 哈夫曼树结点无左孩子或无右孩子即为叶子结点
        if (tree[point].lchild == -1) {
            printf("%c", code[point].data);
            // 令point重新指向根结点,开始新一轮译码
            point = NODE_NUM - 1;
        }
        scanf("%d", &inputCode);
    }
    // 电文不在起点根结点和终点叶子结点,说明电文未读完,有错
    if ((-1 != tree[point].lchild) && (NODE_NUM - 1 != point)) {
        printf("error");
    }
}

在荣政的教材书中,前面采用tree[point].lchild \=\= -1判断是不是叶子结点,后面采用tree[point].lchild \=\= 0判断是不是叶子结点,其前后不一致。在其下面的解释为tree[point].lchild = 0判断哈夫曼树是根据树的度来判断的,这里是有误的。tree[point].lchild表示的是结点左孩子在哈夫曼数组中的索引,没有左孩子表示为-1,因为哈夫曼树是严格二叉树,树中要么只要一个结点为叶子结点,要么左右孩子都有,没有度数为1的结点,所以只要左右孩子种有一个没有即表示都没有左右孩子,是叶子结点。
tree[point].lchild \=\= 0真正的意义应该是其左孩子是数组的第一个结点(树中从数组0到Leaves - 1都是叶子结点),说明tree[point]是有左孩子的,不是叶子结点。

二叉排序树

二叉排序树的定义

二叉排序树:如果一棵二叉树的每个结点对应一个关键码,并且每个结点的左子树中所有结点的码值小于该结点的关键码值,右子树的所有结点的关键码值都大于该结点的关键码值,则称这个二叉树为二叉排序树。
在二叉排序树中,采用中序遍历得到的序列是一个递增的有序序列,这是二叉树的重要性质。

二叉排序树的存储结构

在二叉排序树中使用二叉链表作为存储结构,其结点类型描述如下:

typedef struct Node {
    // 关键字项
    KeyType key;
    DataType data;
    struct Node *lchild, *rchild;
} BinarySortTree;

二叉排序树的算法描述

二叉排序树的构造是将给定数据元素序列构造成相应的二叉排序树。
对于给定的任意一组数据元素:$\{R_1, R_2, \cdots, R_n\}$,可按以下方法构造二叉排序树:

  • 令$R_1$为二叉排序树的根
  • 若$R_2 < R_1$,令$R_2$为$R_1$的左子树根结点;否则$R_2$为$R_1$的右子树根结点
  • 对于$R_3, \cdots, R_n$结点,依次与前面生成的结点比较以确定输入结点的位置。

    二叉排序树插入结点算法

    /**
    * 二叉排序树插入结点
    * @param root 二叉排序树
    * @param insertNode 需插入的结点
    */
    void InsertBSTree(BinarySortTree *root, BinarySortTree insertNode) {
      // point:当前结点,prePoint:当前结点的上一个结点
      BinarySortTree *point, *prePoint;
      point = root;
      while (NULL != point) {
          prePoint = point;
          // 树中已有该结点,无需插入
          if (insertNode.key == point->key) {
              return;
          }
          // 向下查找
          if (insertNode.key < point->key) {
              point = point->lchild;
          } else {
              point = point->rchild;
          }
      }
      // 原树为空,使先插入的结点作为根结点
      if (NULL == root) {
          *root = insertNode;
      }
      // 比较待插入结点和待插入结点的上一个结构,插入到对应指针域中
      if (insertNode.key < prePoint->key) {
          prePoint->lchild = &insertNode;
      } else {
          prePoint->rchild = &insertNode;
      }
    }
    
    在插入过程中,每一次插入的新结点都是二叉排序树的叶子结点,并且不需要移动其他结点,所以插入操作方便。
    对二叉排序树进行中序遍历时,会得到一个按关键字大小排序的有序序列,因此对于无序序列可通过构造二叉排序树和对这个二叉排序树进行中序遍历得到有序序列。

    二叉排序树的建立算法

    /**
    * 生成新的二叉排序树
    * @param root
    */
    void CreateBSTree(BinarySortTree *root) {
      // node:待插入结点
      BinarySortTree *node;
      // endFlag:结束标志
      KeyType key, endFlag = 0;
      DataType data;
      printf("Please insert node key(Knock - 1 to exit):");
      scanf("%d", &key);
      while (key != endFlag) {
          node = (BinarySortTree *) malloc(sizeof(BinarySortTree));
          node->lchild = node->rchild = NULL;
          node->key = key;
          printf("\nPlease insert node data:");
          scanf("%c", &data);
          node->data = data;
          InsertBSTree(root, *node);
          printf("\nPlease insert node key(Knock - 1 to exit):");
          scanf("%d", &key);
      }
    }
    

    二叉排序树的结点删除

    在二叉排序树的删除结点之和,剩下的结点仍需保存二叉排序树的特点。
    若要删除的结点由point给出,双亲结点由parent给出,则二叉排序树中结点删除分三种情况:
  • 若point指向叶子结点,直接将该结点删除
  • 若point所指向结点只要左子树pointLchild或右子树pointRchild,此时只要是pointLchild或pointRchild成为parent所指向的左子树或右子树即可
  • 若point指向结点的左子树pointLchild和右子树pointRchild均非空,则需要将pointLchild和pointRchild链接到合适的位置上:

    • 令pointLchild直接链接到parent的左(或右)孩子链域上,而pointRchild下接到point结点的前驱结点中序前驱结点s上(s是pointLchild最右下的结点)。(该方法会增加二叉树的深度,不建议使用)
    • 令point结点的直接中序前驱或后继替代point所指结点,再从原二叉排序树中删除该直接前驱或后继。

      /**
      * 删除二叉排序树结点
      * @param root 
      * @param key 
      */
      void DeleteBSTree(BinarySortTree *root, KeyType key) {
      // prePoint:上一个活动结点,point:当前活动结点
      // precursorPoint:point的前驱指针,prePrecursorPoint:precursorPoint的前驱指针
      BinarySortTree *prePoint, *point, *precursorPoint, *prePrecursorPoint;
      point = root;
      prePoint = NULL;
      // 寻找待删除结点
      while (NULL != point) {
        if (point->key == key) {
            break;
        }
        prePoint = point;
        if (point->key > key) {
            point = point->lchild;
        } else {
            point = point->rchild;
        }
      }
      // 未找到待删除结点
      if (NULL == point) {
        return;
      }
      
      if (NULL == point->lchild) {
        // 待删除结点的左子树为空
        if (NULL == prePoint) {
            // prePoint为NULL,表示待删除结点为原树的根
            // 令新根为就根的右子树(左子树为空)
            *root = *point->rchild;
        } else if (prePoint->lchild == point) {
            // 情况2:待删除结点左孩子为空,当前结点为上一个结点的左孩子
            prePoint->lchild = point->rchild;
        } else {
            // 情况2:待删除结点右孩子为空,当前结点为上一个结点的右孩子
            prePoint->rchild = point->rchild;
        }
        free(prePoint);
      } else {
        // 待删除结点有左子树,用point的直接前驱替代point
        // 直接前驱的父结点
        prePrecursorPoint = point;
        // 直接前驱
        precursorPoint = point->lchild;
        // 在pointLchild中查找最右下结点,即查找point的直接前驱
        while (precursorPoint->rchild != NULL) {
            prePrecursorPoint = precursorPoint;
            precursorPoint = precursorPoint->rchild;
        }
      
        // point的直接前驱是最右下的结点,所以其直接前驱无右子树
        if (prePrecursorPoint == point) {
            // point = prePrecursorPoint,说明point的直接前驱就是其左子树
            // 用直接前驱替代point后,前驱的左子树链接到point的左子树上
            prePrecursorPoint->lchild = precursorPoint->lchild;
        } else {
            // point的直接前驱为其左子树上的f结点的右子树
            // 将直接前驱的指针指向其前序的右子树上(一直)
            prePrecursorPoint->rchild = precursorPoint->lchild;
        }
        // 令当前结点等于其直接前驱,然后删除待删除结点
        point->key = precursorPoint->key;
        point->data = precursorPoint->data;
        free(precursorPoint);
      }
      }
      

      在二叉排序树的删除算法中,需要仔细体会其用直接前驱替代当前结点的过程。

相关文章
|
18天前
|
存储 搜索推荐 算法
【数据结构】树型结构详解 + 堆的实现(c语言)(附源码)
本文介绍了树和二叉树的基本概念及结构,重点讲解了堆这一重要的数据结构。堆是一种特殊的完全二叉树,常用于实现优先队列和高效的排序算法(如堆排序)。文章详细描述了堆的性质、存储方式及其实现方法,包括插入、删除和取堆顶数据等操作的具体实现。通过这些内容,读者可以全面了解堆的原理和应用。
60 16
|
18天前
|
C语言
【数据结构】二叉树(c语言)(附源码)
本文介绍了如何使用链式结构实现二叉树的基本功能,包括前序、中序、后序和层序遍历,统计节点个数和树的高度,查找节点,判断是否为完全二叉树,以及销毁二叉树。通过手动创建一棵二叉树,详细讲解了每个功能的实现方法和代码示例,帮助读者深入理解递归和数据结构的应用。
66 8
|
1月前
|
存储 算法 关系型数据库
数据结构与算法学习二一:多路查找树、二叉树与B树、2-3树、B+树、B*树。(本章为了解基本知识即可,不做代码学习)
这篇文章主要介绍了多路查找树的基本概念,包括二叉树的局限性、多叉树的优化、B树及其变体(如2-3树、B+树、B*树)的特点和应用,旨在帮助读者理解这些数据结构在文件系统和数据库系统中的重要性和效率。
22 0
数据结构与算法学习二一:多路查找树、二叉树与B树、2-3树、B+树、B*树。(本章为了解基本知识即可,不做代码学习)
|
1月前
|
存储 算法 搜索推荐
数据结构与算法学习十七:顺序储存二叉树、线索化二叉树
这篇文章主要介绍了顺序存储二叉树和线索化二叉树的概念、特点、实现方式以及应用场景。
25 0
数据结构与算法学习十七:顺序储存二叉树、线索化二叉树
|
1月前
|
Java
【用Java学习数据结构系列】震惊,二叉树原来是要这么学习的(二)
【用Java学习数据结构系列】震惊,二叉树原来是要这么学习的(二)
27 1
|
1月前
|
算法 Java C语言
【用Java学习数据结构系列】震惊,二叉树原来是要这么学习的(一)
【用Java学习数据结构系列】震惊,二叉树原来是要这么学习的(一)
24 1
|
1月前
|
Java C++
【数据结构】探索红黑树的奥秘:自平衡原理图解及与二叉查找树的比较
本文深入解析红黑树的自平衡原理,介绍其五大原则,并通过图解和代码示例展示其内部机制。同时,对比红黑树与二叉查找树的性能差异,帮助读者更好地理解这两种数据结构的特点和应用场景。
30 0
|
1月前
|
存储 算法
探索数据结构:分支的世界之二叉树与堆
探索数据结构:分支的世界之二叉树与堆
|
1月前
|
存储 算法
数据结构与算法学习十六:树的知识、二叉树、二叉树的遍历(前序、中序、后序、层次)、二叉树的查找(前序、中序、后序、层次)、二叉树的删除
这篇文章主要介绍了树和二叉树的基础知识,包括树的存储方式、二叉树的定义、遍历方法(前序、中序、后序、层次遍历),以及二叉树的查找和删除操作。
25 0

热门文章

最新文章

下一篇
无影云桌面