数据结构之二叉树(BinaryTree)

导读

  二叉树是一种很常见的数据结构,但要注意的是,二叉树并不是树的特殊情况,二叉树与树是两种不一样的数据结构。

目录

   一、 二叉树的定义

  二、二叉树为何不是特殊的树

  三、二叉树的五种基本形态

  四、二叉树相关术语

  五、二叉树的主要性质(6个)

  六、二叉树的存储结构(2种)

  七、二叉树的遍历算法(4种)

  八、二叉树的基本应用:二叉排序树、平衡二叉树、赫夫曼树及赫夫曼编码

一、二叉树的定义

  如果你知道树的定义(有限个结点组成的具有层次关系的集合),那么就很好理解二叉树了。定义:二叉树是n(n≥0)个结点的有限集,二叉树是每个结点最多有两个子树的树结构,它由一个根结点及左子树和右子树组成。(这里的左子树和右子树也是二叉树)。

  值得注意的是,二叉树和“度至多为2的有序树”几乎一样,但,二叉树不是树的特殊情形。具体分析如下

二、二叉树为何不是特殊的树

  1、二叉树与无序树不同

  二叉树的子树有左右之分,不能颠倒。无序树的子树无左右之分。

  2、二叉树与有序树也不同(关键)

  当有序树有两个子树时,确实可以看做一颗二叉树,但当只有一个子树时,就没有了左右之分,如图所示:

  

三、二叉树的五种基本状态

  

四、二叉树相关术语

  满二叉树:所有叶子结点全部集中在最后一层,这样的二叉树称为满二叉树。(注意:国内的定义是每一层的结点都达到最大值时才算是满二叉树;而国际定义为,不存在度为1的结点,即结点的度要么为2要么为0,这样的二叉树就称为满二叉树。这两种概念完全不同,既然在国内,我们就默认第一种定义就好)。

  完全二叉树:如果将一颗深度为K的二叉树按从上到下、从左到右的顺序进行编号,如果各结点的编号与深度为K的满二叉树相同位置的编号完全对应,那么这就是一颗完全二叉树。如图所示:

  

五、二叉树的主要性质

  二叉树的性质是基于它的结构而得来的,这些性质不必死记,使用到再查询或者自己根据二叉树结构进行推理即可。

  性质1:非空二叉树的叶子结点数等于双分支结点数加1。

  证明:设二叉树的叶子结点数为X,单分支结点数为Y,双分支结点数为Z。则总结点数=X+Y+Z总分支数=Y+2Z

     由于二叉树除了根结点外其他结点都有唯一的分支指向它,所以总分支数=总结点数-1.。

     结合三个方程:总分支数=总结点数-1,即Y+2Z = X+Y+Z-1。化简得到X = Z + 1。即叶子结点数等于双分支结点数加1。

  性质2:在二叉树的第i层上最多有2 i-1 个结点 (i>=1)。

  证明:二叉树结点最多的情况即为满二叉树的情况,由于是满二叉树,每个结点都有两个孩子,所以下一层是上一层的2倍,构成了公比为2的等比数列,而第一层只有根结点,所以首项是1。所以二叉树的第i层上最多有2 i-1 个结点。

  性质3:高度(或深度)为K的二叉树最多有2k - 1个结点(K>=1)。

  证明:本性质其实就是性质2中描述的等比数列的前项和的问题。

  性质4:若对含 n 个结点的完全二叉树从上到下且从左至右进行 1 至 n 的编号,则对完全二叉树中任意一个编号为 i 的结点:

  (1) 若 i=1,则该结点是二叉树的根,无双亲, 否则,编号为 [i/2] 的结点为其双亲结点;  

  (2) 若 2i>n,则该结点无左孩子,  否则,编号为 2i 的结点为其左孩子结点;

  (3) 若 2i+1>n,则该结点无右孩子结点,  否则,编号为2i+1 的结点为其右孩子结点。

          

  性质5:具有n个结点的完全二叉树的深度为[log2n]+1或者[log2(n+1)],其中[log2n]+1是向下取整,[log2(n+1)]是向上取整。

    性质6:Catalan函数性质:给定n个结点,能构成H(n)种结构不同的树。H(n) = c(2n,n) / (n+1)。

六、二叉树的存储结构

  为了方便说明,我们使用下图树1作为案例树。

  

1、顺序存储

  顺序存储是使用一个数组来存储二叉树,我们一般将二叉树按照性质4的做法,即从上到下且从左至右进行 1 至 n 的编号,然后编号与数组下标对应,按照编号依次将对应的结点信息存储数组中即可。

  第一步:给二叉树编号

  

   注意,编号5的位置是没有结点的,但是我们这样编号的目的是为了更好的应用性质4,而性质4描述的是完全二叉树,所以我们编号时要将普通二叉树看做完全二叉树来进行编号,所以即使编号5即使没有结点也需要进行编号。

  第二步:按编号存储到数组BTree[]中

 

  第三步:按照性质4的规律取元素

  性质4主要是描述结点的编号和双亲编号或孩子编号之间的数学关系,即我们知道了一个结点的编号,那么它的双亲编号和孩子孩子我们都能计算得到并从数组中取出来。

  例如,结合性质4我们来计算编号3的双亲和孩子:选取出编号3即BTree[3],就可以知道编号3的元素为C;双亲结点编号 = 3/2 = 1 ≥ 1,所以C结点的双亲为BTree[1]即A;左孩子编号 = 3*2 = 6 ≤ 7,所以C的右孩子为BTree[6] = E;右孩子编号 = 3*2+1 = 7 ≤ 7,所以C的右孩子为Btree[7] = F。

  结论:像编号5这种情况会占用存储空间,所以这种存储方式最适合用于存储完全二叉树,而存储一般的二叉树则会浪费大量空间。

2、链式存储

  根据二叉树的结构,我们使用下面的链式结点来存储一个二叉树结点。

  

  所以树1对应的链式存储结构为:

  

七、二叉树的遍历算法

  根据二叉树的结构特点:一般二叉树由左子树、根结点和右子树组成。这三个元素:左子树(L)、根结点(N)、右子树(R)有6种中排列组合,即NLR、LNR、LRN、NRL、RNL、RLN。而从左往右和从右往左这种遍历顺序是对称结构的,采用一种顺序即可,所以二叉树按照三个元素的排列顺序遍历就形成了:NLR(先序遍历)、LNR(中序遍历)和LRN(后序遍历)。

  ps:二叉树的这三种遍历要用递归的思想去理解。

  先序遍历(NLR):根左右

  1)访问根结点

  2)先序遍历左子树

  3)先序遍历右子树

  中序遍历(LNR):左根右

  1)中序遍历左子树

  2)访问根结点

  3)中序遍历右子树

  后序遍历(LRN):左右根

  1)后序遍历左子树

  2)后序遍历右子树

  3)访问根结点

  java实现(遍历树1):

 1 package test;
 2 
 3 import org.junit.Test;
 4 
 5 /**
 6  * 二叉树的遍历
 7  * @author Fzz
 8  * @date 2018年1月17日
 9  * @Description TODO:
10  */
11 public class BinaryTreeTraversal {
12     private StringBuffer sb = new StringBuffer();
13     //先序遍历(数组)
14     public String first(Object[] o,int i){
15         //访问根结点
16         sb.append(o[i]);
17         //遍历左子树
18         int left = i*2;
19         if(left<o.length&&o[left]!=null)
20             first(o,left);
21         //遍历右子树
22         int right = i*2+1;
23         if(right<o.length&&o[right]!=null)
24             first(o,right);
25         return sb.toString();
26     }
27     
28     //中序遍历
29     public String mid(Object[] o,int i){
30         //遍历左子树
31         int left = i*2;
32         if(left<o.length&&o[left]!=null)
33             mid(o,left);
34         //访问根结点
35         sb.append(o[i]);
36         //遍历右子树
37         int right = i*2+1;
38         if(right<o.length&&o[right]!=null)
39             mid(o,right);
40         return sb.toString();
41     }
42     
43     //后序遍历
44     public String last(Object[] o,int i){
45         //遍历左子树
46         int left = i*2;
47         if(left<o.length&&o[left]!=null)
48             last(o,left);
49         //遍历右子树
50         int right = i*2+1;
51         if(right<o.length&&o[right]!=null)
52             last(o,right);
53         //访问根结点
54         sb.append(o[i]);
55         return sb.toString();
56     }
57     
58     //将缓存区设为空
59     public void setBufferNull(){
60         this.sb = this.sb.delete(0, sb.length());
61     }
62     
63     @Test
64     public void test(){
65         Character[] o = {null,'A','B','C','D',null,'E','F'};
66         //遍历前先清空缓存区
67         this.setBufferNull();
68         String s = first(o,1);
69         System.out.println("先序遍历结果:"+s);
70         this.setBufferNull();
71         s = mid(o,1);
72         System.out.println("中序遍历结果:"+s);
73         this.setBufferNull();
74         s = last(o,1);
75         System.out.println("后序遍历结果:"+s);
76     }
77 }

  测试结果:

  

  层次遍历

  层次遍历比较简单,即按照从上往下、从左往右一层一层遍历即可。层次遍历是现实,如果遍历的是顺序存储(数组存储)的二叉树,由于存储的时候就是按照从上往下从左往右的顺序存储的,直接按顺序取出即可;如果是链式存储的二叉树,需要使用一个循环队列进行操作:先将根结点入队,当前结点是队头结点,将其出队并访问,如果当前结点的左结点不为空将左结点入队,如果当前结点的右结点不为空将其入队。所以出队顺序也是从左到右依次出队。

八、二叉树的基本应用

1、二叉排序树(Binary Sort Tree)

  二叉排序树,又称二叉查找树(Binary Search Tree),亦称二叉搜索树。

  1、定义

  二叉排序树或者是一棵空树,或者是具有下列性质的二叉树:

  (1)若左子树不空,则左子树上所有结点的值均小于或等于它的根结点的值;
  (2)若右子树不空,则右子树上所有结点的值均大于或等于它的根结点的值;
  (3)左、右子树也分别为二叉排序树;
  ps:根据二叉排序树的定义,如果对二叉排序树进行中序遍历,那么遍历的结果就是一个递增的序列。
  2、查找关键字
  二叉排序树的主要功能就是查找。首先将需要查找的序列排序后存储到二叉排序树中,那么要查找的关键字要么在左子树,要么在根结点,要么在右子树,所以我们首先将要查找的关键字与根结点做比较,相等则查找成功。小于根结点则递归查找左子树,大于根结点则递归查找右子树,直到出现相等情况则查找成功,否则查找失败。(该查找过程与折半查找类似)
  3、插入关键字
  插入操作主要是对查找不成功的排序二叉树,即如果关键字查找不成功,那么我们就需要将查找不成功的关键字插入查找不成功的位置。所以我们只需要将查找算法进行修改就能实现插入操作(ps:若二叉树为空,则首先单独生成根结点):
 1 BiTree* InsertBST(BiTree *t,int key)
 2 {
 3     if (t == NULL)
 4     {
 5         t = new BiTree();
 6         t->lchild = t->rchild = NULL;
 7         t->data = key;
 8         return t;
 9     }
10  
11     if (key < t->data) 
12         t->lchild = InsertBST(t->lchild, key);
13     else
14         t->rchild = InsertBST(t->rchild, key);
15  
16     return t;
17 }
  4、删除关键字
  在删除关键字结点时,需要注意的是,在删除后我们需要保持二叉排序树的特性。
  在二叉排序树删去一个结点,分三种情况讨论:(设被删除结点为p,p的双亲结点为f)
  1. 若p结点为叶子结点,即PL(左子树)和PR(右子树)均为空树。由于删去叶子结点不破坏整棵树的结构,则可以直接删除此子结点。
  2. 若p结点只有左子树PL或右子树PR,此时只要令PL或PR直接成为其双亲结点f的左子树(当p是左子树)或右子树(当p是右子树)即可,作此修改也不破坏二叉排序树的特性。
  3. 若p结点的左子树和右子树均不空。这种情况可以转换为情况1或2然后再按照1或2的方法来解决。有两种转换方法:
    其一是找到p结点的左子树的最右边的结点r(沿着p的左子树的根结点的右指针一直走,其实就是找到左子树的最大值),用r结点替换p结点,然后再删除r结点即可。
    其二是找到p结点的右子树的最左边的结点r(沿着p的右子树的根结点的左指针一直走,其实就是找到左子树的最小值),用r结点替换p结点,然后再删除r结点即可。
    java实现:
private void deleteNode(BinarySortTree p)
{
    //TODOAuto-generatedmethodstub
    if(p!=null)
    {
        //如果结点有左子树
        /*1。若p有左子树,找到其左子树的最右边的叶子结点r,用该叶子结点r来替代p,把r的左孩子
        作为r的父亲的右孩子。
        2。若p没有左子树,直接用p的右孩子取代它。
        */
        if(p.lChild!=null)
        {
            BinarySortTree r=p.lChild;
            BinarySortTree prev=p.lChild;
                while(r.rChild!=null)
               {
                    prev=r;
                    r=r.rChild;
                }
                p.data=r.data;
            //若r不是p的左子树,p的左子树不变,r的左子树作为r的父结点的右孩子结点
            if(prev!=r)
            {
                prev.rChild=r.lChild;
             }
            else
            {
             //若r是p的左子树,则p的左子树指向r的左子树
            p.lChild=r.lChild;
            }
        }
        else
        {
            p=p.rChild;
        }
    }
}

2、平衡二叉树

  平衡二叉树又称为AVL树,是一种特殊的二叉排序树,即左右两个子树高度之差不超过1,并且左右两个子树都是平衡二叉树的二叉排序树称为平衡二叉树。

  为什么要构造平衡二叉树呢?对于一般的二叉排序树,其期望高度(即为一棵平衡树时)为log2n,其各操作的时间复杂度(O(log2n))同时也由此而决定。由于AVL树的左右子树高度之差不超过1,其高度一般都良好地维持在O(log(n)),大大降低了操作的时间复杂度

  平衡二叉树的实现算法:关键在于左右子树的平衡。具体的算法有:红黑树、AVL算法、Treap、伸展树、SBT来实现。有兴趣的可以自行搜索,这里就不再描述。

3、赫夫曼树及赫夫曼编码

  赫夫曼树又叫做最优二叉树,特点是带权路径长度最短

  1、相关术语

  路径和路径长度:在一棵树中,从一个结点往下可以达到的孩子或孙子结点之间的通路,称为路径。通路中分支的数目称为路径长度
  结点的权及带权路径长度:若将树中结点赋给一个有着某种含义的数值,则这个数值称为该结点的权。结点的带权路径长度为:从根结点到该结点之间的路径长度与该结点的权的乘积
  树的带权路径长度:树的带权路径长度规定为所有叶子结点的带权路径长度之和,记为WPL。
  如下图有4个叶子结点的二叉树:
  
  则这棵树的WPL = 2*7+2*5+3*2+2*4 = 38。
  2、赫夫曼树的构造
  假设有n个权值,则构造出的哈夫曼树有n个叶子结点。 n个权值分别设为 w1、w2、…、wn,则哈夫曼树的构造规则为:
  (1) 将w1、w2、…,wn看成是有n 棵树的森林(每棵树仅有一个结点);
  (2) 在森林中选出两个根结点的权值最小的树合并,作为一棵新树的左、右子树,且新树的根结点权值为其左、右子树根结点权值之和;
  (3)从森林中删除选取的两棵树,并将新树加入森林;
  (4)重复(2)、(3)步,直到森林中只剩一棵树为止,该树即为所求得的哈夫曼树。
  以上面的二叉树为例:首先有4个叶子结点a、b、c、d,权值分别为7、5、2、4。则:
  普构造得到的赫夫曼树的带权路径长度是最小的,WPL = 1*7+2*5+3*2+4*4 = 35。
  3、赫夫曼树的特点
  1)权值越大的结点离根结点越近。
  2)树中没有度为1的结点。这类树又称为正则(严格)二叉树。
  4、赫夫曼树的应用:赫夫曼编码
  赫夫曼编码是一种编码方式。例如我们需要发送右A、B、C、D这4个字符组成的一些文字,如果分别用00,01,10,11来代表要传送的A、B、C、D。则需要发送"ADA"就编码为:001100。
  我们希望编码的长度能够越短越好,上面的编码方式长度显然是与字符个数成正比的,不能满足需求,所以赫夫曼想到了设置不同的编码长度来表达不同字符,那么让出现概率越高的字符设置编码长度越短即可。假设A、B、C、D出现的概率分别为0.4、0.3、0.2、0.1。那么A、B、C、D的编码就可以分别用0、00、01、1来表示。那么ADA的编码就是“010”,但"CA"的编码也是“010”,所以我们在设置不同长度的编码时需要使用前缀编码(即任一编码都不是其他编码的前缀,这样就不会产生歧义了)。所以A、B、C、D的编码修改为:0、10、110、111即可。
  赫夫曼编码就是长度可变(编码长度最短)的前缀编码。其实,赫夫曼编码的构造就是赫夫曼树的构造过程:即字符相当于叶子结点;字符出现的概率相当于结点的权值;由于构造的赫夫曼树权值越大的结点离根结点越近,所以字符出现概率越大的字符离根结点越近,路径也就越短。
  所以,A、B、C、D(0.4、0.3、0.2、0.1)构造的赫夫曼树过程如下:
  1)构造赫夫曼树。
  2)约定左分支表示0,右分支表示1。
  3)从根结点到字符结点的路径组成的编码就是该字符的赫夫曼编码。
  
posted @ 2018-01-17 18:50  风之之  阅读(1398)  评论(0编辑  收藏  举报