zoukankan      html  css  js  c++  java
  • 二叉树结构详解

    导言

    我们先来看个例子,假设我连续抛一毛、五毛、一块钱的硬币各一个,那么这 3 枚硬币呈现出的状态有多少种可能呢?我们知道抛一枚硬币只有两种可能——证明或反面,也就是说抛硬币这个事件可能会产生两种可能性,所以我们来看:

    如果我们把这个过程模拟成一个树,整个树有 8 个叶结点,那么这个事件的 8 种可能性我们就能说明白了。

    二叉树的定义

    二叉树 (Binary Tree) 是 n(n ≥ 0) 个结点的有限集合,该集合为空集时称为空二叉树,由一个根结点和两棵互不相交的、分别称为根结点的左子树和右子树的二叉树组成。例如上文作为例子的树结构,由于出现了一个结点有 3 个子树的情况,所以不属于二叉树,而如图所示结构就是二叉树。

    对于二叉树来说有以下特点:

    1. 二叉树的每个结点至多有两个子树,也就是说二叉树不允许存在度大于 2 的结点;
    2. 二叉树有左右子树之分,次序不允许颠倒,即使是只有一棵子树也要有左右之分。

    因此对于一棵有 3 个结点的二叉树来说,由于需要区分左右,会有以下五种情况。

    特殊的二叉树

    斜树

    所有结点都只有左(右)子树的二叉树被称为左(右)斜树,同时这个树结构就是一个线性表,如图所示。

    满二叉树

    满二叉树要求所有的分支结点都存在左右子树,并且所有的叶结点都在同一层上,若满二叉树的层数为 n,则结点数量为 2n-1 个结点,子叶只能出现在最后一层,内部结点的度都为 2,如图所示。

    完全二叉树

    从定义上来说,完全二叉树是满足若对一棵具有 n 个结点的二叉树按层序编号,如果编号为 i 的结点 (1 ≤ i ≤ n)于同样深度的满二叉树中编号为 i 的结点在二叉树的位置相同的二叉树。这样讲有些繁琐,可以理解为完全二叉树生成结点的顺序必须严格按照从上到下,从左往右的顺序来生成结点,如图所示。

    因此我们就不难观察出完全二叉树的特点,完全二叉树的叶结点只能存在于最下两层,其中最下层的叶结点只集中在树结构的左侧,而倒数第二层的叶结点集中于树结构的右侧。当结点的度为 1 时,该结点只能拥有左子树。

    二叉树的性质

    性质 内容
    性质一 在二叉树的 i 层上至多有 2i-1 个结点(i>=1)
    性质二 深度为 k 的二叉树至多有 2k-1 个结点(i>=1)
    性质三 对任何一棵二叉树 T,如果其终端结点树为 n0,度为 2 的结点为 n2,则 n0 = n2 + n1
    性质四 具有 n 个结点的完全二叉树的深度为 [log2n] + 1 向下取整
    性质五 如果有一棵有 n 个结点的完全二叉树(其深度为 [log2n] + 1,向下取整)的结点按层次序编号(从第 1 层到第 [log2n] + 1,向下取整层,每层从左到右),则对任一结点 i(1 <= i <= n)有
    1.如果 i = 1,则结点 i 是二叉树的根,无双亲;如果 i > 1,则其双亲是结点 [i / 2],向下取整
    2.如果 2i > n 则结点 i 无左孩子,否则其左孩子是结点 2i
    3.如果 2i + 1 > n 则结点无右孩子,否则其右孩子是结点 2i + 1

    二叉树的存储结构

    顺序存储

    由于二叉树的结点至多为 2,因此这种性质使得二叉树可以使用顺序存储结构来描述,在使用顺序存储结构时我们需要令数组的下标体现结点之间的逻辑关系。我们先来看完全二叉树,如果我们按照从上到下,从左到右的顺序遍历完全二叉树时,顺序是这样的:

    那么我们就会发现,设父结点的序号为 k,则子结点的序号会分别为 2k 和 2k + 1,子结点的序号和父结点都是相互对应的,因此我们可以用顺序存储结构来描述,例如如图大顶堆:

    用顺序存储结构描述如图所示:

    那么对于一般的二叉树呢?我们可以利用完全二叉树的编号来实现,如果在完全二叉树对应的结点是空结点,修改其值为 NULL 即可,例如:

    再看个例子,左斜树:

    但是我们可以很明显地看到,对于一个斜树,我开辟的空间数远超过实际使用的空间,这样空间就被浪费了,因此顺序存储结构虽然可行,但不合适。

    链式存储

    由于二叉树的每个结点最多只能有 2 个子树,因此我们就不需要使用上述的 3 种表示法来做,可以直接设置一个结点具有两个指针域和一个数据域,那么这样建好的链表成为二叉链表。例如:


    再看个例子,上述我描述孩子兄弟表示法的树结构,稍加改动就可以把图示改成二叉树:

    结构体定义

    typedef struct BiTNode
    {
        ElemType data;    //数据域
        ChildPtr *lchild,*rchild;    //左右孩子的指针域
        //可以开个指针域指向双亲,变为三叉链表
    }BiTNode, *BiTree;
    

    二叉树的遍历

    递归遍历法

    从斐波那契数列说起

    我们先不急着开始谈二叉树的遍历,而是先回忆一下我们是怎么利用斐波那契数列实现递归的:

    代码实现:

    int f(int n)
    {
        if (n == 0)
    	return 0;
        else
            if (n == 1)
    	    return 1;
    	else
    	    return f(n - 2) + f(n - 1);
    }
    

    代码很好读,已经不是什么难题了,但是我们并不是一开始就懂得递归是个什么玩意,我们也是通过模拟来深刻理解的。因此下面我们用图示法进行模拟,假设我需要获取第 4 个斐波那契数:

    仔细看,我们模拟递归函数调用的过程,和二叉树长得是一模一样啊,那么对于二叉树的操作,我们能否用递归来作些文章?

    遍历算法

    由于二叉树的结点使用了递归定义,也就是结点的拥有自己本身作为成员的成员,这就使得遍历算法可以使用递归实现,而且思路很清晰。

    void PreOrderTraverse (BiTree T)
    {
        if(T == NULL)
            return;
        //cout << T->data << " " ;    //前序遍历
        PreOrderTraverse (T->lchild);
        //cout << T->data << " " ;    //中序遍历
        PreOrderTraverse (T->rchild);
        //cout << T->data << " " ;    //后序遍历
    }
    

    可以看到,根据输出语句的位置不同,输出的数据顺序是不一样的,例如如图所示二叉树,3 种顺序的输出顺序为:
    前序:先访问根结点,然后先进入左子树前序遍历,再进入右子树前序遍历。

    中序:从根结点出发,先进入根结点的左子树中序遍历,然后访问根结点,最后进入右子树中序遍历。

    后序:从左到右先叶子后结点的方式进入左、右子树遍历,最后访问根结点。

    · 需要注意的是,无论是什么样的遍历顺序,访问结点都是从根结点开始访问,按照从上到下,从左到右的顺序向下挖掘,分为 3 中顺序主要因为我们需要有一些方式来描述递归遍历的结果,让我们可以抽象二叉树的结构,因此我们就按照输出语句放的位置不同而决定是什么序遍历,所以我这边就将 3 中遍历顺序放在一起谈。

    层序遍历法

    顾名思义,层序遍历法就是从第一层(根结点)开始,按照从上到下,从左到右的顺序进行遍历,如图所示。

    层序遍历法不仅直观,而且好理解,但是我们要思考,处于同一层的结点存在于不同子树,按照刚才的递归遍历法我们无法和其他子树产生沟通,那该怎么实现?仔细观察,层序遍历就好像从根结点开始,一层一层向下扩散搜索,这就跟我们队列实现迷宫算法非常类似,因为迷宫算法的不同路径也是无关联的,但是我们是用广度优先搜索的思想可以找到最短路径。

    算法实现

    void levelOrder(BiTree t)
    {
        BiNnode ptr;
        queue<BiTree> que_level;    //层序结点队列
    
        if (t == NULL)    //空树处理
        {
            cout << "NULL";
        }
        que_level.push(t);    //根结点入队列
        while (!que_level.empty() && que_level.front())    //直至空队列,结束循环
        {
            cout << que_level.front()->data << ' ';
            if (que_level.front()->left != NULL)    //队列头结点是否有左结点
            {
                que_level.push(que_level.front()->left);    //左结点入队列
            }
            if (que_level.front()->right != NULL)    //队列头结点是否有左结点
            {
                que_level.push(que_level.front()->right);    //右结点入队列
            }
            que_level.pop();    //队列头出队列
        }
    }
    

    加深印象

    某二叉树的前序和后序遍历序列正好相反,则该二叉树一定是(B)
    A、空或只有一个结点
    B、高度等于其结点数
    C、任一结点无左孩子
    D、任一结点无右孩子

    建立二叉树

    拓展二叉树

    例如要确定一个二叉树,我们肯定不能只是把结点说明白,还需要把每个结点是否有左右孩子说明白。例如如图所示树结构,我们可以向其中填充结点,使其的所有结点填充完后均具有左右结点,为了表示该结点其实是不存在的,我们需要设置一个标志来表示,例如是“#”,那么这种描述就是拓展二叉树如图所示。

    按照前序遍历,输出的结果为“ABD#GE##C#F##”。

    建树算法

    对于树来说,遍历是各种操作的基础,我们刚刚是通过递归的方式实现了二叉树的遍历读取,现在我们可以再次搬出递归,使其按照前序遍历的顺序建立二叉树。假设树结构的每一个结点的数据域都是一个字符,先序遍历的顺序已知,算法要求将一个字符序列的元素依次读入建立二叉树。
    由于对一个树结构来说,每个结点的左右分支都可以被理解为是一个树结构,例如根结点就拥有左右子树,叶结点可以理解为左右子树都是空树的根结点。因此我们可以通过分治思想,每一次只构建一棵子树的根结点,然后递归建立左右子树,直至读取到“#”终止递归。

    void CreatBiTree(BiTree &T)
    {
        char ch;
    
        cin >> ch;
        if(ch == '#')    //读取到 NULL 结点
            T = NULL;    //建立空树,结束递归
        else
        {
            T = new BiTNode;    //生成树的根结点
            T->data = ch;
            CreatBiTree(T->lchild);    //创建根结点的左子树
            CreatBiTree(T->rchild);    //创建根结点的右子树
        }
    }
    

    已知前序、中序遍历建树法

    样例模拟

    假设我有如下遍历序列:

    ABDFGHIEC    //前序遍历
    FDHGIBEAC    //中序遍历
    

    我们来尝试一下用这两个遍历结果反向建立一棵二叉树。首先根据前序遍历的特点,对于一棵树来说,在前序遍历时根结点会被先输出,在中序遍历时根结点会在左子树结点输出完毕之后输出,因此我们可以知道这棵二叉树的根结点的值为 “A”,而在中序遍历中“A”结点又把序列分为了左右子树,分别是“FDHGIBE”和“C”,如图所示。

    我的根结点安排明白了,这个时候在我眼里,前序遍历只剩下了“BDFGHIEC”,而对于左子树的中序遍历是“FDHGIBE”,右子树的中序遍历是“C”。
    对于二叉树来说,可以看做由两颗子树构成的森林重新组合的树结构,因此在我眼里根据前序遍历的结构,左子树的根结点是“B”,该结点把二叉树分成了左右子树分别是“FDHGI”和“E”,如图所示。

    重复上述切片操作,就能够建立一棵二叉树。


    我们发现了,反向建树的方式还是渗透了分治法的思想,通过分治把一个序列不断分支成左右子树,知道分治到叶结点。因此我们可以总结出建树的算法思路:在递归过程中,如果当前先序序列的区间为 [idx_f1,idx_f2],中序序列的区间为 [idx_m1,idx_m2],设前序序列的第一个元素在中序序列中的下标为 k,那么左子树的结点个数为 num = (k − idx_m1) 。这样左子树的先序序列区间就是 [idx_f1 + 1, idx_f1 + num],左子树的中序序列区间是 [idx_m1,k − 1];右子树的先序序列区间是 [idx_f1 + num + 1,idx_f1],右子树的中序序列区间是 [k + 1,idx_m2],由于我按照先序序列的顺序安排结点,因此当先序序列的 idx_f1 > idx_f2 时,就是递归的结束条件。

    代码实现

    void createTree(tree& t, int idx_f1, int idx_f2, int idx_m1,int idx_m2)
    {                 //front 和 middle 是存储输入的前序、中序序列的数组,为全局变量
        int i;
        
        t = new treenode;
        if (idx_f1 > idx_f2)    //前序序列已经安排完毕,结束
        {
            t = NULL;
            return;
        }
        t->data = front[idx_f1];    //构建子树的根结点
        for (i = idx_m1; i <= idx_m2; i++)
        {
            if (middle[i] == front[idx_f1])    //查找到在中序序列中的对应位置
            {
                break;
            }
        }                        //递归分治,将子树对应的前序、中序序列传入递归函数
        createTree(t->left, idx_f1 + 1, idx_f1 + i - idx_m1, idx_m1, i - 1);
        createTree(t->right, idx_f1 + i - idx_m1 + 1, idx_f2, i + 1, idx_m2);
    }
    

    已知后序、中序遍历建树法

    样例模拟

    假设我有如下遍历序列:

    2 3 1 5 7 6 4    //后序遍历
    1 2 3 4 5 6 7    //中序遍历
    

    后续遍历的最后一个元素是根结点,因此通过根结点“4”在中序序列分成了左、右子树。对于后续遍历也被分为两个序列“2 3 1”和“5 7 6”。

    对于左子树来说,根据后序遍历“2 3 1”,他的根结点是“1”,这个结点将中序序列分成了左右子树,分别为右子树“2 3”和一个空左树。

    重复上述操作即可还原出二叉树。


    过程和前、中序序列建树是很相似的,虽然后序遍历理解起来比前序要复杂一些,因为前序序列你只需要一个一个向下读取。不过我们发现当我们找到根结点在中序序列中的位置之后,后序遍历中左子树与中序遍历左子树位置是对应的,后序遍历中右子树与中序遍历中右子树位置相差一个元素,也就是中序遍历根节点的位置。因此我们可以利用这个特性使用指针来描述数组,就不需要传递那么多描述下标的参数了。

    代码实现

    void createBiTree(BiTree& t, int* back, int* middle, int n)
    {                       //back 和 middle 分别是指向后续、中序序列的数组的指针
        int num;
        int* ptr;
    
        if (n <= 0)    //序列长度小于 0 时,递归结束
        {
            t = NULL;
            return;
        }
        t = new BiNode;
        ptr = middle;      //ptr 指向 middle 的第一个元素
        while (*ptr != back[n - 1])
        {
            ptr++;    //查找中序序列的根结点
        }
        t->data = *ptr;
        num = ptr - middle;    //左子树的结点数,可以通过这个变量退出右子树结点数
        createBiTree(t->left, back, middle, num);    //通过指针运算限制传入的数组
        createBiTree(t->right, back + num, middle + num + 1, n - num - 1);
    }
    

    二叉树的其他基操

    复制二叉树

    还是用递归,与创建二叉树类似,先申请一个新结点用于拷贝根结点,然后通过递归依次复制每一个子树的根结点即可实现。

    void CopyBiTree(BiTree &T,BiTree &NewT)
    {
        if(T == NULL)    //根结点是空树,结束复制
            NewT = NULL; 
            return;
        else
        {
            NewT = new BiTNode;
            NewT->data = T->data;    //拷贝根结点
            CopyBiTree(T->lchild,NewT->lchild);    //拷贝左子树根结点
            CopyBiTree(T->rchild,NewT->rchild);    //拷贝右子树根结点
        }
    }
    

    获取二叉树的深度

    还是用递归,与创建二叉树类似,利用分治的思想,对于倒数第二层的子树来说深度为 1 或 2,即左右子树是否存在的问题,那么当我从最底层分治回根结点时,二叉树的深度即为左右子树深度较大的数值加 1,最后函数需要返回树的深度。

    int DepthBiTree(BiTree T)
    {
        int l_depth,r_depth;
    
        if(T == NULL)    //若树为空树则表示子树的深度为 0
            return 0;
        else
        {
            l_depth = DepthBiTree(T->lchild);    //向左子树挖掘深度
            r_depth = DepthBiTree(T->rchild);    //向右子树挖掘深度
            if(l_depth > r_depth)    //返回左右子树中的较大层数
                return l_depth + 1;
            else
                return r_depth + 1;
        }
    }
    

    统计二叉树的结点数

    还是用递归,每一个子树的结点数为其左子树和右子树的结点树之和再加上它本身,也就是加 1。

    int NodeCount(BiTree T)
    {
        if(T == Tree)
            return 0;    //若为空树,则结点数为 0
        else
            return NodeCount(T->lchild) + NodeCount(T->rchild) + 1;    //挖掘左右结点的节点个数
    }
    

    线索二叉树

    描述前驱与后继

    回顾一下双向链表,为了能够准确描述某个结点的前驱,我们给结点结构体引入了前驱指针域,通过前驱指针域我们就能够清楚地知道一个结点的前驱,而不需要再次遍历。那么再看一下二叉树,虽然在树结构中,结点间的关系是一对多的关系,但是当我们遍历二叉树时,无论是前序遍历、中序遍历还是后序遍历,我们使用了某些规则使得我们能够按照一定的顺序来描述二叉树的结点。也就是说,例如我们使用中序遍历的时候,我们是可以按照中序遍历的规则明白一个结点的前驱和后继的,但是如果我们需要知晓这一点,就不得不进行一次遍历操作。那么我们能不能像双向链表那样开辟一些指针域来描述前驱与后继的关系呢?

    别急,我们先来观察一下,如图所示的二叉树使用二叉链表来组织结点,中序遍历的结点顺序为 GDBEACF,但是我们发现并不是所有的结点的指针域都得到了充分的应用。该二叉树有 7 个结点,也就是说有 14 个指针域,可是我们只使用了 6 个指针域来描述逻辑关系。再接着看,当我们需要描述后继关系时,也就是 G->D、D->B、E->A、F->NULL 这四个关系,描述清楚之后就能够吧中序遍历所得的后继关系说明白;描述前驱关系时,需要把 G->NULL、E->B、C->A、F->C 这四个关系说明白。观察一下,如图二叉树有 6 个分支,这些分支分别需要有 1 个指针域来存储信息,总共有 14 个指针域,那也就是还有 8 个指针域是空闲的,然后我们就能发现,这个数字与我们要描述清前驱后继所需要的指针域是一样的,也就是说我们无需对结构体的定义进行操作,只需要将这些空闲的空间充分利用即可。
    如图所示,描述后继关系:

    描述前驱关系:

    对于这类用于描述前驱和后继的指针,我们称之为线索,而将空闲的指针域利用起来的二叉链表,也就是引入线索的二叉链表成为线索链表,描述的二叉树成为线索二叉树。通过对线索的使用,我们把一棵二叉树描述为一个双向链表,我们很清楚双线链表的插入、删除和查找结点的操作都是很方便的,而我们以某种遍历顺序设置线索的过程成称为线索化。线索二叉树的结构即充分利用了二叉树的空指针域,又使得一次遍历就能获取结点的前驱和后继信息,既节省了空间也节省了时间。

    线索二叉树结点结构体定义

    我们明白了可以利用空闲的指针域来描述前驱后继,但是我们要如何确定这些指针域是用来描述左右子结点还是前驱后继的关系的呢?也就是说,我们不仅需要一些机制来进行判断,更要留下一些标志来为我们后续的访问提供便利。我们的做法是,引入两个 bool 性成员变量 ltag、rtag,当 ltag 的值为 0 时表示指针域指向该结点的左结点,值为 1 时指向该结点的前驱,rtag 的用法同理。

    typedef enum {Link,Thread} PointerTag;    //Link 表示指向子结点,Thread 表示指向前驱或后继
    typedef struct BiThrNode
    {
        ElemType data;    //数据域
        BiThrNode *lchild,*rchild;    //左右孩子的指针域
        PointerTag LTag;    //判断左指针域作用的 flag
        PointerTag RTag;    //判断右指针域作用的 flag
    }BiThrNode, *BiThrTree;
    

    线索化

    所谓线索化就是将二叉树中没有使用的空闲指针域进行修改,使其能够描述前驱和后继的过程,而前驱和后继的信息我们在遍历的时候比较关心,因此线索化本质上就是在中序遍历的时候添加描述的过程,算法的实现也是基于遍历算法的实现。

    BiThrTree pre;    //当前访问结点的前驱指针
    void InThreading(BiThrTree ptr)
    {
        if(ptr != NULL)
        {
            InThreading(ptr->lchild);    //左子树线索化
            if(!ptr->lchild)    //结点无左子树
            {
                ptr->LTag = Thread;    //修改 flag
                ptr->lchild = pre;    //左指针域指向前驱
            }
            if(!pre->rchild)    //结点的前驱无右子树
            {
                pre->RTag = Thread;    //修改 flag
                pre->rchild = ptr;    //右指针域指向后继
            }
            pre = ptr;    //移动 pre,使其始终指向当前操作结点的前驱
            InThreading(ptr->rchild);    //左子树线索化
        }
    }
    

    遍历线索二叉树

    由于线索二叉树实现了近似于双向链表的结构,因此我们可以添加一个头结点,使其左指针域指向线索二叉树的根结点,右指针域指向中序遍历访问的最后一个结点。同时我们可以运用一下循环链表的思想,使中序遍历的第一个结点的左指针域和最后一个结点的右指针域指向头结点,就能够实现从任何结点出发都能够完整遍历线索二叉树的功能了。该算法时间复杂度 O(n)。

    bool InOederTraverse_Thr(BiThrTree T)    //指针 T 指向头结点
    {
        BiThrTree ptr = T->lchild;    //ptr 初始化为根结点
        
        while(ptr != T)    //遇到空树或遍历结束时,ptr 会指向头结点
        {
            while(ptr->LTag == Link)    //结点指向左子树时循环到中序序列的第一个结点
                ptr = ptr->lchild;
            cout << ptr->data;
            while(ptr->RTag == Thread && ptr->rchild != T)    //中序遍历,并找到下一个右子树
            {
                ptr = ptr->rchild;
                cout << ptr->data;
            }
            ptr = ptr->rchild;    //ptr 进入右子树
        }
        return true;
    }
    

    哈夫曼树

    左转我的另一篇博客——哈夫曼树与哈夫曼编码

    参考资料

    《大话数据结构》—— 程杰 著,清华大学出版社
    《数据结构教程》—— 李春葆 主编,清华大学出版社
    《数据结构(C语言版|第二版)》—— 严蔚敏 李冬梅 吴伟民 编著,人民邮电出版社

  • 相关阅读:
    SpringCloud之Eureka注册中心
    微服务入门概念
    迁移桌面程序到MS Store(3)——开机自启动
    迁移桌面程序到MS Store(2)——Desktop App Converter
    迁移桌面程序到MS Store(1)——通过Visual Studio创建Packaging工程
    [UWP]涨姿势UWP源码——适配电脑和手机
    [UWP]涨姿势UWP源码——UI布局
    微软2017MVP大礼包拆箱攻略
    [UWP]涨姿势UWP源码——Unit Test
    [UWP]涨姿势UWP源码——IsolatedStorage
  • 原文地址:https://www.cnblogs.com/linfangnan/p/12603112.html
Copyright © 2011-2022 走看看