二叉树

作者: 贪婪的君子 | 来源:发表于2017-07-16 08:00 被阅读132次

数据结构中,树只是一种逻辑结构,在最底层并没有这种存储结构的存在,只是我们因为需要一种比较好的结构来描述我们的问题,所以树结构就应运而生。

在树的结构中,二叉树是一种较为常用的树结构,并且还很重要,几乎我们平常编程时所说的树,都是指的二叉树。

其实家谱就是一种树结构,他清晰的向我们传达了家人之间的信息。

1. 二叉树的存储


二叉树的存储也可以像矩阵一般,存储在一维数组中,或者是通过链表进行存储。存储于数组中,需要相应的映射关系,并且还存在很多问题,如果存储的是完全二叉树,那么从树结构到数组的存储映射关系就非常简单了,而且浪费空间会少很多(原因后文会进行解释)。

以链表形式存储二叉树,可以说是普遍的实现方式了。

1.1 顺序存储

顺序存储就是将一个二叉树存储在一个一维数组中,这种形式非常简单,而且是最节省空间的存储方式,多数完全二叉树的存储会采用这种形式实现。

这个原理非常简单,就是对二叉树上的结点按顺序(从上到下,自左向右)进行编号,然后按照编号的顺序将其按顺序存储在一维数组中,其映射关系为:

A[0]存储二叉树的根结点,A[i]存储二叉树中编号为i的结点,编号为i的结点,其左孩子如果存在,则存储在A[2i+1]处,其右孩子如果存在,则存储在A[2i+2]处。

完全二叉树及其顺序存储结构

如果要存储一个非完全二叉树,顺序存储也是可以的,但是却并不像完全二叉树存储时那么方便。

非完全二叉树的顺序存储

可以看到,在非完全二叉树的顺序存储中,我们要先将非完全二叉树补成完全二叉树(方块仅为了方便对其结点进行编号所假设补全的结点,实际并不存在),然后对二叉树的结点进行编号,然后存储到数组中,但是我们可以看到,数组中可能会空出多个位置,增大存储开销,带来不必要的损失。

1.2 二叉树的链接存储

考虑到数组对于非完全二叉树的存储不是那么友好,所以我们通常会选用链接存储结构来进行二叉树的存储。

与矩阵类似,我们需要针对某个实际问题设计对应的存储结点。

二叉树的链接存储表示

图中∧符号表示空指针,结点中Left表示左孩子指针,Right表示右孩子指针,Data表示数据域,数据域可根据具体问题而不同。

struct Node
{
    int data;
    Node *left, *right;
    Node(int data = 0, Node *left = null, *right = null);
};

1.2.1 线索二叉树

二叉树的链接存储模式下,衍生出了不少的二叉树种类,线索二叉树就是其中的一种。
线索二叉树的树结点在原有结点内容下,增加了前驱和后继结点的指针。

中序线索二叉树的链接表示

这里只给出中序线索二叉树的链接表示,先序与后序暂不给出(什么是先序,中序,后序在后文会逐渐讲解)。

我们从图中可以看出,就只是五个结点的存储,线索二叉树的空指针就占了很大比重,这表示存储空间的浪费问题并没有得到解决。

为了改进这种情况,有人提出另一种方案:

  • 将原指针域Pred和Succ替换成存储一个二进制位的域LThread和RThread

  • 若结点t有左孩子,则left指向t的左孩子结点,LThread值为0;若无,则left指向t的前驱结点,LThread值为1,此时我们称该left指针为线索。

  • 若结点t有右孩子,则right指向t的右孩子结点,RThread值为0;若无,则right指向t的后继结点,RThread值为1,此时我们称right指针为线索。
    根据这种方案进行改进后,不仅释放了指针域Pred和Succ所占的空间,还充分的利用了left和right指针域,减少了空指针的存在。

2. 二叉树的遍历


二叉树有三种遍历的形式,分别为先根序,中根序,后根序(亦称先根遍历,中根遍历,后根遍历),其主要区别是对根的访问时机。

二叉树遍历方式

我们以先根序为例,进行访问根,遍历左右子树操作的解析。

一个二叉树

这个二叉树遍历的结果为ABDFCE

  • 先根遍历二叉树Tree_0
  1. 访问Tree_0的根结点A

  2. 先根遍历A的左子树(即以B为根的子树T1)

    • 访问T1的根结点B
    • 先根遍历B的左子树(左子树为空,返回B结点)
    • 先根遍历B的右子树(右子树不为空,则访问以D为根的子树T2)
      • 访问T2的根结点D
        • 先根遍历D的左子树(不为空,则访问以D为根的子树T3)
          • 访问T3的根结点F
          • 先根遍历F的左子树(为空,返回F)
          • 先根遍历F的右子树(为空,返回F)
        • T3已遍历完成,返回T2子树的根结点D
      • T2子树已遍历完成,返回T1子树的根结点B
    • T1子树已遍历完成,返回根结点A
  3. 先根遍历A的右子树(即以C为结点的子树T4)

    • 访问T4的跟结点C
    • 先根遍历C的左子树(不为空,即访问以E为根结点的子树T5)
      • 访问T5的根结点E
      • 先根遍历E的左子树(为空,返回E)
      • 先根遍历E的右子树(为空,返回E)
    • 返回T4的根结点C
    • 先根遍历C的右子树(为空,返回结点A)
  4. 根结点A已遍历完成,算法结束。

中根遍历,后根遍历与先根遍历操作类似,仅对根的访问时机不同。

2.1 二叉树遍历的实现方式

我们现在知道对二叉树的遍历有先根序,中根序以及后根序三种,而在对先根遍历方式的算法描述,我们用到的是递归的思想,实现起来也比较简单,只需不断的调用函数自身,等到满足递归出口条件时,就可以结束算法。

void PreOrder(Node *t)const
{
    if (t != NULL)     //递归出口
    {
        cout<<t->GetData()<<endl;    //打印根结点值
        PreOrder(t->GetLeft());     //先根遍历左子树
        PreOrder(t->GetRight());    //先根遍历右子树
    }
}

这只是递归的方式遍历,我们还有其他的非递归方式,如堆栈,队列等。

在设计堆栈方式时,我们需要考虑到出栈和弹栈的恢复性,所以在堆栈上,我们为每个结点增加了一项信息——访问计数(设为i)。表示为(结点,i)

这里我们给出一个后根遍历的算法步骤:

  • i=0,将(结点P,1)压栈,若该结点左子树存在,则将(左孩子(P),1)压栈,准备遍历其左子树。

  • i=1,此时宜遍历完结点P的左子树,将(结点P,2)压栈,即将i设置为2,若其右子树不为空,则将(右孩子(P),1)压栈,准备遍历其右子树。

  • 若i=2,此时已遍历完成结点P的右子树,访问结点P的数据域。

这段算法也是按顺序重复进行的,直到满足出口条件。

上述的是堆栈法,接下来的层次遍历,我们需要用到队列,这个遍历方式不同于先根,中根,后根遍历的方法。

层次遍历是将树分层,自根结点所在层开始,由0开始编号,逐层增大。

某个二叉树的层次图

层次遍历的方法为:

  • 根结点入队

  • 若队列非空,取队首元素访问,若其坐指针域不为空,则将其左孩子入队;若其右指针域非空,则将其右孩子入队;重复本步骤直到队列空。

void LevelOrder(Node *t)const
{
    if (t == NULL) return;
    Node *p;
    AQueue q;        //假设实现了一个名为AQueue的队列
    if (t != NULL)
        q.QInsert(t);   //根结点入队
    while (!q.IsEmpty())
    {
        q.QDelete(p);    //QDelete中的p用来存放被删除的结点
        cout<<p->GetData()<<endl;
        if (p->GetLeft()) != NULL)
            q.QInsert(p->GetLeft());
        if (p->GetRight() != NULL)
            q.QInsert(p->GetRight());
    }
}

3. 二叉树的实现


关于存储方式和二叉树的遍历方式就已经算是讲完了,接下来要进行二叉树的具体实现。

首先,针对实际的问题,我们需要选择二叉树的存储方式,是顺序存储,还是链接存储(但大多数情况我们都选用链接结构,故而该步骤可以省略)。

其次,针对我们选择的存储方式,我们进行相应的结构设计,假设选择链接存储。

做完这些,我们需要考虑二叉树都需要哪些具体的操作,比如,创建,复制,增加,删除,修改,查找,遍历这些操作,都是二叉树的基本操作。我们必须要实现。

在这里设计一个通用化的二叉树:

class Node
{
    private:
        char data;
        Node *left;
        Node *right;
    public:
        //构造器
        //get(),set()等函数
};
class BinTree
{
    private:
        Node *root;   //根结点
        int stop;      //构造二叉树时的结束符
    public:
        BinTree(Node *t = NULL):root(t) { };
        virtual ~BinTree() { Del(root); }
        Node *Father(Node *t, Node *p);   //搜索父结点
        Node *Find(Node *t, const char &item);    //查找二叉树中数据域为item的值
        void DelSubTree(Node *t);   //删除结点t的子树(左右子树) 

        //基本操作
        void CreateBinTree(int stop = 0);   //默认遇到0结束创建
        Node *Create();
        Node *CopyTree(Node *t); 
        Node GetRoot() { return root; }
        void SetRoot(Node *t) { root = t; }
        int GetStop() { return stop; }
        void SetStop(int s) { stop = s; }
        int IsEmpty() { return root == NULL; }

        //遍历方式,均以参数中的结点为根
        void PreOrder(Node *t)const;    //先序遍历
        void InOrder(Node *t)const;    //中序遍历
        void PostOrder(Node *t)const;    //后根遍历
        void LevelOrder(Node *t)const;   //层次遍历
        void NorecPreOrder(Node *t)const;    //非递归先根遍历
        void NorecInOrder(Node *t)const;    //非递归中根遍历
        void NorecPostOrder(Node *t)const;  //非递归后根遍历
};

4. 哈夫曼树


哈夫曼树的提出是为了解决数据压缩问题的。

为什么要进行数据压缩?要知道计算机诞生的年代,乃至上世纪八十年代,计算机的存储都是以kb为单位的,哪会像现在这样动辄以TB计算,像C语言这种面向底层的高级语言都不是很流行,汇编语言才是最流行的。所以为了避免空间的浪费,我们提出了数据压缩。

其实放到现在也一样,压缩文件可以节省不少的空间。

解决数据压缩问题,一般都是采用不等长的二进制编码,因为这种编码形式,灵活可变,相比如固定长度的编码存储不会浪费太多空间,保证每一个字符都能以最小的二级制数来存储。

我们知道,在一个文件中,如txt文件,其中存储的字符并不是全部都不同的,至少我们可以在平时写的文档中找到一个出现过两次的字符,而且常用的字符占所有字符的少数,你说6763个汉字,常用的也不过一半而已,如果使用定长的编码格式,那么考虑到非常用字,那么一个文件就会出现空间浪费,如果只对常用字进行编码,那么其编码长度就变小了,而且节省了存储空间。

但是为了防止不定长的编码出现相同的情况(这种情况我们称为编码的多义性),我们增加了前缀码来进行区分。

4.1 哈夫曼算法

哈夫曼算法其实就是求一个最优二叉树的算法。

扩展

最优二叉树是以扩充二叉树为基础的,而扩充二叉树简单来说,就是为一个普通二叉树所有结点(内结点)出现的空子树(如果该结点的左右子树都存在,则跳过)补充成一个叶结点(外结点)。如图:

扩充二叉树

其中的方块表示扩充的叶结点(即外结点)。

我们定义:外通路长度为,从根结点到每个外结点的路径长度之和,内通路长度为,从根结点到每个内结点的路径长度之和。
根据上图,我们可以计算出外通路长度为2+2+3+3+2=12,内通路长度为1+0+1+2=4。

最优二叉树则是为扩充二叉树中扩充出来的叶结点赋值(为权值),然后找到加权外通路长度最小的扩充二叉树。

如图则是一种最优二叉树:

最优二叉树

只要找到每一个外结点对应的外通路,这两者之和最小即可。

关于哈夫曼树,我们针对一个具体问题来进行分析:

假设现在有7个节点,我们将每一个结点视为一颗二叉树的根结点,由此组成一个森林,把结点在文件中出现的次数定义为该结点的权值。
如此,我们可以进行合并,具体如下:

  1. 在森林中取权值最小的两个根结点s和n,合并成一棵二叉树,并生成一个新结点T1,作为其二者的父结点,T1的权值是其两个子结点的权值之和,森林中减少一棵树。

  2. 重复上述操作,直到森林中只剩一棵树,算法结束。

哈夫曼树

上图就是问题所对应的哈夫曼树了。

5. 总结


二叉树只是树的一种特殊结构,且其应用是非常广泛的,树可以和二叉树进行转换,二叉树也可以转换成对应的树,树的存储和二叉树的存储方式相同,只是由于其结点可能不止左右两个孩子,所以其存储结构又不同,但是我们也有对应的方法去解决,如Father数组,孩子链表,左孩子右兄弟的链表结构(其实是转化为二叉树进行存储的)。

至于红黑树,二叉查找树这些,以后再慢慢讲吧。

相关文章

  • 数据结构与算法-二叉树02

    二叉树的定义 二叉树的特点 二叉树的五中基本形态 其他二叉树 斜二叉树 满二叉树 完全二叉树图片.png满二叉树一...

  • 二叉树

    二叉树 高度 深度真二叉树 满二叉树 完全二叉树 二叉树遍历前序 中序 后序层序遍历 翻转二叉树 递归法...

  • 二叉树 基础操作

    二叉树的使用 二叉树结构 先序创建二叉树 DFS 先序遍历二叉树 中序遍历二叉树 后序遍历二叉树 BFS 层次遍历...

  • 树与二叉树

    **树 ** 二叉树 满二叉树 完全二叉树 三种遍历方法 树与二叉树的区别 二叉查找树 平衡二叉树 红黑二叉树

  • 二叉树的宽度优先搜索(层次遍历,BFS)

    二叉树结构: 二叉树宽度优先搜索: 按照二叉树的层数依次从左到右访问二叉树的节点;例如:给定一个二叉树: 按照宽度...

  • 剑指 offer:39、平衡二叉树

    39. 平衡二叉树 题目描述 输入一棵二叉树,判断该二叉树是否是平衡二叉树。 解题思路: 平衡二叉树:Wiki:在...

  • Algorithm小白入门 -- 二叉树

    二叉树二叉树构造二叉树寻找重复子树 1. 二叉树 基本二叉树节点如下: 很多经典算法,比如回溯、动态规划、分治算法...

  • 14-树&二叉树&真二叉树&满二叉树

    一、树 二、二叉树 三、真二叉树 四、满二叉树

  • 二叉树的应用

    完美二叉树(满二叉树) 除了最下一层的节点外,每层节点都有两个子节点的二叉树为满二叉树 完全二叉树 除二叉树最后一...

  • 12.树Tree(2)

    目录:1.二叉树的基本概念2.二叉树的性质3.二叉树的创建4.二叉树的遍历 1.二叉树的基本概念 2.二叉树的性质...

网友评论

    本文标题:二叉树

    本文链接:https://www.haomeiwen.com/subject/aiwzhxtx.html