数据结构与算法 —— 05 树

1.树(Tree):

树是 n(n>=0) 个结点的有限集。
当 n=0 时称为空树。在任意一颗非空树中:有且仅有一个特定的称为根(Root)的结点
当 n>1 时,其余结点可以分为 m(m>0) 个互不相交的有限集 T1,T2,..,Tm, 其中每一个集合本身又是一颗树,并且称为根的子树(SubTree)

树的分类结构图


            ┌ 普通树             ┌ 斜树(左斜树、右斜树)
            │                     │ 
      ┌ 树1 ┤ 二叉树(BinaryTree)  ┤ 满二叉树:树深 log(n+1)
      │     │                     │
      │     │                     └ 完全二叉树(重要): 树深 [logn]+1         
      │     └ ...
      │
森林 ─┤ 树2
      │ ...
      │
      └

本质:是一对多的数据结构

(1)结点分类

结点的度:结点拥有的子树的数称为该结点的度(Degree)。
叶结点:度为零的结点称为叶结点
分支结点(非终端结点,内部结点): 度不为 0 的结点
树的度:指该树中各结点的度的最大值称为该树的度。

(2)结点的关系

孩子:结点的子树的根称为该结点的孩子(Child)
双亲:该结点称为其孩子的双亲(Parent)

(3)结点的层次(这个层次的概念是针对结点而言的)

从根开始定义起,根为第一层,根的孩子为第二层。

(4)树的深度(高度)

树中结点的最大层次称为树的深度(Deep),很显然,该结点肯定是树的根结点了。

注意树的深度树的度是不同的概念

(5)有序树和无序树

树中个结点的子树从左至右(或从右至左)是有序的,不能互换,则称该树为有序树,否则为无序树

(6)森林

是 m(m>=0)棵互不相交的树的集合

2.树的存储结构

这里已经不能单纯的采用前面的顺序存储、链式存储,介绍三种存储方式:双亲表示法、孩子表示法、孩子兄弟表示法

(1)双亲表示法(以父结点的角度)

用一组连续的空间存储树的结点,同时在每一个结点中,附设一个指示器指示双亲结点在数组中的位置。

┌────────┬────────┐
│ data   │ parent │
└────────┴────────┘

'结点描述'

public class PNode { 
    public int parent; //父结点的位置
    public T data; //数据域
}
(2)孩子表示法(以孩子结点的角度)

将每个结点的孩子结点排列起来,以单链表的形式作为存储结构,则n个结点有n个孩子链表,如果是叶子结点则此单链表为空,然后n个头指针又组成一个线性表,采用顺序存储结构,存放入一个一维数组中。

1  A -> 1 -> 2
2  B -> 3
3  C -> 4 -> 5
4  D -> 6 -> 7 -> 8
5  E -> 9
6  F

为此要使用两种结点结构:
(1)表头结点

┌────┬──────────┐
│data│firstchild│
└────┴──────────┘

(2)孩子结点

┌─────┬────┐
│child│next│
└─────┴────┘

优点:方便查找某个结点的兄弟,只需要遍历相关结点的孩子链表即可。遍历整颗树也是很方便,循环输出整个头结点数组即可

#######(3)孩子兄弟表示法(以结点的兄弟为角度)
优点是将一颗复杂的树变成了一颗二叉树

┌────┬──────────┬──────────┐
│data│firstchild│rightchild│
└────┴──────────┴──────────┘

'代码描述'
(1)树的结点描述

public class TreeNode {
    public TreeNode lChild; //左孩子
    public TreeNode rChild; //右孩子
    private T data; //数据域
    //结点初始化
    public TreeNode() {
        data = null;
        lChild = null;
        rChild = null;          
    }
    public TreeNode(T x) {
        data = x;
        lChild = null;
        rChild = null;          
    }       
}

'树的数据类型定义'

public class Tree {
    //其他的一些操作
    ...
}
3.二叉树(Binary Tree)

是一种特殊的树。(普通树是可以和二叉树相互转换)
定义:是 n(n>=0) 个结点的有限集合,该集合或者为空集(称为空二叉树),或者有一个根结点和两棵互不相交的、分别为根结点的左子树和右子树的二叉树组成。

(1)二叉树的特点

1)由于每个结点最多有两棵子树,所以二叉树中不存在度大于2的结点;侧面也证明了,二叉树的度 <= 2
2)左子树和右子树是有顺序的,不可以颠倒
3)即使二叉树中某个结点只有一棵子树,也要区分是左子树还是右子树。

(2)特殊的二叉树

斜树:所有结点都只有左子树或都只有右子树。分别称为左斜树、右斜树
满二叉树(有2个条件):所有的分支结点都有左子树和右子树,且所有的叶子结点在同一层上。

**满二叉树的特点: **

  1. 叶子结点只能出现在最下层
  2. 非叶子结点的度均为 2
  3. 在同样深度的二叉树中,满二叉树的结点个数最多,叶子结点最多

③ 完全二叉树:如果编号为 i(1<= i <= n) 的结点与同样深度的满二叉树中编号为 i 的结点在二叉树中的位置完全相同,则称为完全二叉树。

完全二叉树的特点:
1)树的编号是连续
2)叶子结点只能出现在最下两层
3)最下层的叶子结点一定集中在左部连续的位置

(3)二叉树的性质(理解记忆)

1)在二叉树的第 i 层上至多有 2^(i-1)个结点
2)深度为k的二叉树至多有 2^k-1 个结点(k >= 1)
注:当为满二叉树的时候,结点个数为:2^k-1

3)对任何一颗二叉树T, 如果其终端结点(即叶子结点)数为n0,度为2的结点数为 n2,则有 n0=n2+1;
4)具有 n 个结点的满二叉树的深度为: ** log(n+1)**
5)具有 n 个结点的完全二叉树的深度为: ** [logn]+1**

注意:这是针对于完全二叉树,不是满二叉树
推导过程:

由于深度为 k 的满二叉树的结点个数:n = 2^k-1
所以,深度为 k 的完全二叉树的结点个数:n, 满足:
      2^(k-1)-1 < n <= 2^k-1
由于n是整数,因此
——> 2^(k-1) - 1 < n < 2^k
   ——> 2^(k-1) <= n < 2^k
        —— k-1 < logn <= k
由于k取整数  ——> k = [logn]+1 

6)如果对一棵有 n 个结点的完全二叉树(易知其深度[logn]+1),将其结点按层编号(从第1层到[logn]+1)层,从左到右),对任意结点 i (i<= i <= n)有:
ⅰ) i = 1, 则i为根结点,无双亲,如果i>1, 则其双亲结点编号:[i/2]
ⅱ) 如果 2i>n, 则结点 i 无左孩子(且i为叶子结点);否则其左孩子编号:2i
ⅲ) 如果 2i+1>n, 则无右孩子;否则右孩子的编号:2i+1

(4)二叉树的存储

存储方式和普通树的存储方式还是有很大的差别,尤其是它可以实现顺序存储

1)顺序存储结构

用一维数组存储二叉树中的结点,并且结点的存储位置是可以反映出各个结点之间的逻辑关系。(这点是不同于普通树)

对于"完全二叉树"而言:

┌───┬───┬───┬───┬───┬───┬───┐
│ A │ B │ C │ D │ E │ F │ G │
└───┴───┴───┴───┴───┴───┴───┘

对于"普通的二叉树"可以当成完全二叉树来存储,只是把没有结点的地方设为"^"

┌───┬───┬───┬───┬───┬───┬───┐
│ A │ B │ C │ ^ │ E │ ^ │ G │
└───┴───┴───┴───┴───┴───┴───┘

对于"斜二叉树"而言就有些浪费了空间,因此,这种顺序存储结构比较适合"完全二叉树"

2)链式存储结构

由于二叉树的每个结点最多有2个孩子,因此,其结点可以设计成如下形式:

┌────────┬─────┬────────┐
│ lChild │ data│ rChild │
└────────┴─────┴────────┘

'代码描述'

public class BinTreNode {
    T data; //数据域
    BinTreNode lChild; //左孩子结点指针
    BinTreNode rChild; //右孩子节点指针
    public BinTreNode() {
        this.data = null;
        this.lChild = null;
        this.rChild = null;                 
    }
    public BinTreNode(T x) {
        this.data = x;
        this.lChild = null;
        this.rChild = null;                 
    }
}

注意:如果为了方便找某个结点的双亲结点,就同普通树中处理方式一样,增加一个指向其双亲结点 parent 的指针域即可:

┌──────┬────┬──────┬──────┐
│lChild│data│rChild│parent│
└──────┴────┴──────┴──────┘
(5)遍历二叉树(Traversing binary Tree)

含义:是指从根结点出发,按照某种"次序"依次"访问"二叉树的所有结点并且只被访问一次。

二叉树的遍历不同于线性数据结构:
因为线性数据结构中结点都是有唯一的前驱或后继结点,这使得遍历结果是唯一确定;
然而,在二叉树(普通树也是如此)中每个结点的后继结点不唯一,可以有多种选择,因此选择
不同,遍历结果也就不同了。

二叉树:

        A
      ╱ ╲
     B      C
   ╱    ╱  ╲
  D     E      F
╱  ╲   ╲
G      H    I
二叉树常见的遍历方式:

1)前序遍历: ABDGHCEIF
规则:若二叉树为空,则遍历结果返回空。否则先访问根结点、左子树、右子树

2)中序遍历: GDHBAEICF
规则:若二叉树为空,则遍历结果返回空。否则先从根结点开始(不是访问根结点),中序遍历根结点的左子树、根结点、右子树

3)后序遍历: GHDBIEFCA
规则:若二叉树为空,则遍历结果返回空。否则先从根结点开始(不是访问根结点),后序遍历根结点的左子树、右子树、根结点

4)层序遍历(层次遍历):ABCDEFGHI
规则:若二叉树为空,则遍历结果返回空。否则从树的根结点开始遍历,从上至下,逐层遍历访问

**注意: **
1.前、中、后遍历方式,是针对"根结点"来说的
2.为什么要研究遍历?
因为计算机只会处理线性序列,因此,我们需要研究如何把树这种非线性序列转变为线性序列。
3.已知前序遍历序列和中序遍历序列,可以唯一的确定一颗二叉树
已知后序遍历序列和中序遍历序列,可以唯一的确定一颗二叉树
但是,已知前序和后序遍历序列,无法唯一的确定一颗二叉树

'遍历代码描述':采用递归的方式很容易的完成

/**
 * 二叉树的遍历方式
 */
//前序遍历
public void preOrder(BinaTreNode node) {
    if (node == null) {
        return ;
    }
    //打印根结点
    System.out.print(node.data);
    preOrder(node.lChild);
    preOrder(node.rChild);      
}

//中序遍历
public void inOrder(BinaTreNode node) {
    if (node == null) {
        return ;
    }
    inOrder(node.lChild);
    //打印根结点
    System.out.print(node.data);
    inOrder(node.rChild);       
}

//后序遍历
public void postOrder(BinaTreNode node) {
    if (node == null) {
        return ;
    }
    postOrder(node.lChild);
    postOrder(node.rChild);
    //打印根结点
    System.out.print(node.data);
}

//层序遍历:使用队列来实现层序遍历
public void levelOrder() {
    BinaTreNode[] queue = new BinaTreNode[this.maxNodes];
    int front = -1; //队首指针
    int rear = 0; //队尾指针        
    
    if (this.root == null) {
        return;
    }
    queue[rear] = this.root;//二叉树的根结点进队
    //若队不为空,则继续遍历
    while(rear != front) {
        front ++;
        //打印根结点
        System.out.print(queue[front].data);
        //将队首结点的左孩子进队
        if (queue[front].lChild != null) {
            rear ++;
            queue[rear] = queue[front].lChild;
        }
        //将队首的右孩子也进队
        if (queue[front].rChild != null) {
            rear ++;
            queue[rear] = queue[front].rChild;
        }
    }           
}
4.线索二叉树(Thread BinaryTree)

要是能知道二叉树每一个结点的直接前驱结点或后驱结点是谁,将会为二叉树的其他操作带来方便。但是,二叉树在存储结点的时候,并没有反映出来每一个结点的直接前驱结点或后驱结点是谁。只能在二叉树的某种遍历过程中动态的得到这些信息。

一个具有 n 个结点的二叉树,对于其二叉链表存储,一共有 2n 个指针域(每个结点有左右两个孩子指针域),n-1 个分支线(即两个结点之间连接线),因此,还有 2n-(n-1)=n+1 个指针域是空的,白白的浪费,没有利用。因此,可以考虑使用这些空闲的指针域:
将某个结点空闲的左指针域(lChild) 用来存储该结点在某种遍历下的直接前驱结点
将某个结点空闲的右指针域(rChild) 用来存储该结点在某种遍历下的直接后继结点

我们将这种指向前驱和后继的指针称为线索(Thread),加了线索的二叉树称为线索二叉树

线索二叉树的结点结构:

┌────────┬────────┬──────┬────────┬────────┐
│  ltag  │ lChild │ data │ rChild │ rtag   │
└────────┴────────┴──────┴────────┴────────┘

ltag, rtal 是两个标志位(各只占了 1bit 空间),分别用来表 lChild 和 rChild 是表示左(右)孩子结点还是前驱(后继)结点。

    ┌ 0, 表示左孩子指针

ltag = │
└ 1, 表示前驱结点指针

    ┌ 0, 表示右孩子指针

rtag = │
└ 1, 表示后继结点指针

线索二叉树因遍历顺序不同,获得的线索二叉树也不同:
前序线索二叉树
中序线索二叉树
后序线索二叉树

'结点代码描述'

/**
 * 线索二叉树的结点
 * @author Administrator
 *
 */
public class ThreadedTreNode {
    public T data; //数据域
    public ThreadedTreNode lChild; //左指针域
    public ThreadedTreNode rChild; //右指针域
    //左标志位, 这是为了后面代码方便才写成boolean类型的
    public boolean ltag; //true表示为前驱结点指针
    //右标志位, 这是为了后面代码方便才写成boolean类型的
    public boolean rtag; //true表示后继结点指针
    
    public ThreadedTreNode() {
        data = null;
        lChild = null;
        rChild = null;
        ltag = false; //默认表示左右孩子
        rtag = false;       
    }
    
    public ThreadedTreNode(T x) {
        data = x;
        lChild = null;
        rChild = null;
        ltag = false;
        rtag = false;       
    }
}

'线索二叉树代码描述'

/**
 * 线索二叉树
 * @author Administrator
 *
 */
public class ThreadedTree {
    /**
    * 头结点,只是为了方便操作而增设的.
    * 其结构与其他线索二叉树的结点结构一样,只是数据域不存放信息,其
    * 左指针指向二叉树的根结点,右指针指向自己。
    * 而原二叉树在某种遍历下的第一个结点的前驱线索和最后一个结点的后继线索
    * 都指向该头结点
    */
    private ThreadedTreNode head; //
    private ThreadedTreNode pre; //表示刚刚访问过的结点
    
    //创建一棵包含头结点的线索二叉树
    public ThreadedTree() {
        this.head = new ThreadedTreNode();       
    }
    
    /**
     * 通过中序遍历的序列对二叉树进行线索化
     * @return
     */
    public boolean startInThreading() {
        if(head == null) {
            return false;           
        }
        //设置head结点为头结点,其左子结点指向根结点
        head.ltag = false; 
        head.rtag = true;
        head.rChild = head; //头结点的右指针指向自身。
        if(head.lChild == null) {
            //若二叉树为空,则左指针指向自身
            head.lChild = head;
        } else {
            //pre始终指向刚刚访问过的结点。
            pre = head; //设置默认的前驱结点
            inThreading(head); //按中序遍历进行中序线索化
            //对最后一个结点线索化
            pre.rChild = head;
            pre.rtag = true;
        }
        
        return true;        
    }
    
    //中序完成二叉树线索化
    private void inThreading(ThreadedTreNode p) {
        //p表示指向当前结点
        if(p == null) {
            return;         
        }
        inThreading(p.lChild); //左子树线索化
        
        if(p.lChild == null) {
            //表明当前结点的没有左孩子(左指针域为空),因此,该结点是有前驱结点的。
            // 此时,其前驱结点 pre 刚刚被访问过
            //线索化
            p.ltag = true; //表明左指针是前驱结点指针
            p.lChild = pre;         
        }
        
        // 由于此时p结点的后继还没有被访问到,只能对他的前驱结点pre的右指针进行判断
        if(pre.rChild == null) {
            //表明 p 是 pre 的后继
            pre.rtag = true;
            pre.rChild = p;         
        }
        
        pre = p; //保持 pre 指向 p 的前驱      
        inThreading(p.rChild); //右子树线索化     
    }
    
    //遍历二叉线索树
    public void traversing() {
        ThreadedTreNode node = head.lChild;
        if(node == null) {
            return;         
        }
        while(!node.ltag) {
            //寻找中序序列的首结点
            node = node.lChild;
            do {
                if (node != null) {
                    System.out.println(node.data);
                    node = searchPostNode(node);
                }
            } while (node.rChild != head);
        }
    }
    /**
     * 寻找中序的后继结点
     * @param node
     * @return
     */
    public ThreadedTreNode searchPostNode(ThreadedTreNode node) {
        ThreadedTreNode q = node.rChild;
        if (!node.rtag) {
            while(!q.rtag) {
                q = q.lChild;               
            }
        }
        return q;       
    }
    
    /**
     * 寻找中序的前继结点
     * @param node
     * @return
     */
    public ThreadedTreNode searchPreNode(ThreadedTreNode node) {
        ThreadedTreNode q = node.lChild;
        if (!node.ltag) {
            while(!q.ltag) {
                q = q.rChild;               
            }
        }
        return q;       
    }
}
5.普通树、森林、二叉树之间的转换

(1)转换
1)树 ——> 二叉树
步骤:1) 在所有的兄弟之间加一条连线
2) 对树中每一个结点,只保留它与第一个孩子的连线,删除与其他孩子的连线
3) 层次调整。简单的理解:想像用手捏住根结点往起来一提溜,靠重力下垂,
便可得到调整后的层次

2)森林 ——> 二叉树
步骤:1) 把每个树转换为二叉树
2) 第一个二叉树不动,从第二棵开始,依次把后一棵二叉树的根结点作为前一棵根结点的右孩子

3)二叉树 ——> 树
是上面树到二叉树的逆过程
4)二叉树 ——> 森林
如果这棵二叉树有右孩子,那么该二叉树就能转换为森林是上面森林到二叉树的逆过程

(2)树与森林的遍历
1)树的遍历
先根遍历 (类似先序遍历)
后跟遍历 (类似后跟遍历)
2)森林遍历
前序遍历(先访问第一棵树,每棵树内用先根遍历)
后序遍历(先访问第一棵树,每棵树内用后跟遍历)

注意:森林的前序遍历和二叉树的前序遍历结果相同
森林的后序遍历和二叉树的中序遍历结果相同

因此,当以二叉链表来存储树时,其先根遍历和后根遍历算法完全同二叉树的前序遍历和后序遍历

这样就可以将树和森林这种复杂问题进行简单处理

6.二叉树的应用:Huffman树与Huffman编码

(1)几个概念:
1)路径长度:从树中一个结点到另一个结点之间的分支(其实就是结点之间的连线)构成两个结点之间的路径,而把这条路径上的的分支(即连线)数目(之和)称做路径长度。
注意:"路径长度" 是针对任意两个结点间而言的

2)树的路径长度:指从树根到每一个结点的路径长度之和(对就是字面意思_)
3)结点的带权路径长度:该结点到树根结点之间的路径长度与该结点上权值的乘积
4)树的带权路径(WPL):树中所有叶子结点的带权路径之和

WPL = ∑ W(k)*L(K)

其中,W(k)为叶子结点的权值,L(k)为叶子结点的路径长度

5)Huffman树:把WPL最小的二叉树称为Huffman树

(2)如何构造Huffman树 ?

根据Huffman树的定义知:要想使WPL最小,必须是权值越大的叶子结点越靠近根结点,而权值越小的叶子结点越远离根结点。

基本思想如下:
ⅰ)把所有包含权值的数据元素(w1, w2, ..., wn)看成离散的叶子结点,并组成"结点集合": F={w1, w2, ..., wn}

ⅱ)从集合中选取权值最小的和次小的两个叶子结点作为左右子树构造成一棵新的二叉树,则该二叉树的根结点(记为,R(i),i表示第i个合成的根结点 )的权值为其左右子树根结点的权值之和

ⅲ)从结点集合中剔除刚选取过的作为左右子树的那两个叶子结点,并将新构建的二叉树的根结点(为R(i) )加入到结点集合中。

ⅳ)重复(ⅱ)(ⅲ)两步,当集合中只剩下一个结点时,该结点就是所建立的Huffman树的根结点,该二叉树便为Huffman树

注意:对于一组给定的叶子结点所组成的Huffman树,其树形可能不相同,但其WPL一定是相等的,且为最小

(3)Huffman编码

Huffman树最早是用于优化电文编码的。减小电文编码长度,节约存储或传输成本。

如:A B   C   D   E   F (字符,即叶子结点)
    27  8   15  15  30  5 (字符出现的频率或权值)

构造Huffman树
将Huffman树的左分支代表0,右分支代表1
    则,相应的Huffman编码:

    A     B      C      D   E     F
    01  1001    101     00  11  1000
    
                ○
            ╱     ╲
          ╱         ╲
        (42)           (58)
      ╱   ╲       ╱  ╲
    D(15)   A(27)  (28)   E(30)
                  ╱  ╲
                (13)  C(15)
               ╱  ╲
             F(5)  B(8)

你可能感兴趣的:(数据结构与算法 —— 05 树)