二叉树

来源:互联网 发布:php下载系统源码 编辑:程序博客网 时间:2024/05/20 17:09

  • 二叉树的存储
    • 1 顺序存储
    • 2 二叉树的链接存储
      • 21 线索二叉树
  • 二叉树的遍历
    • 1 二叉树遍历的实现方式
  • 二叉树的实现
  • 哈夫曼树
    • 1 哈夫曼算法
  • 总结

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

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

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

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数组,孩子链表,左孩子右兄弟的链表结构(其实是转化为二叉树进行存储的)。

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

原创粉丝点击