数据结构-王道-树和二叉树

[top]

树和二叉树

       :是\(N(N\geq0)\)个结点的有限集合,\(N=0\)时,称为空树,这是一种特殊情况。在任意一棵非空树中应满足:

  • 有且仅有一个特定的称为的结点。
  • \(N>1\)时,其余结点可分为\(m(m>0)\)个互不相交的有限集合\(T_1,T_2,\ldots,T_m\),其中每一个集合本身又是一棵树,并且称为根结点的子树。

显然树的定义是递归的,是一种递归的数据结构。树作为一种逻辑结构,同时也是一种分层结构,具有以下两个特点:

  1. 树的根结点没有前驱结点,除根结点之外的所有结点有且仅有一个前驱结点。
  2. 树中所有结点可以有零个或者多个后继结点。

       树适合于表示具有层次结构的数据。树中的某个结点(除了根结点之外)最多之和上一层的一个结点(其父结点)有直接关系,根结点没有直接上层结点,因此在n个结点的树中最多只有n-1条边。而树中每个结点与其下一层的零个或者多个结点(即其子女结点)有直接关系。

![Alt text](http://pfap49gih.bkt.clouddn.com/1537133941165.png)
- 对K来说:根结点A到K的唯一路径上的任意结点,称为K的祖先结点。如结点B是K的祖先节点,K是B的子孙结点。路径上最接近K的结点E称为K的**双亲结点**,K是E的孩子结点。根A是树中唯一没有双亲的结点。有相同双亲的结点称为兄弟节点,如K和L有相同的双亲结点E,即K和L是兄弟结点。 - 树中一个结点的子结点个数称为该结点的度,树中结点最大度数称为树的度。如B的度为2,但是D的度为3,所以该树的度为3. - 度大于0的结点称为**分支结点**(又称为非终端结点);度为0(没有子女结点)的结点称为**叶子结点**(又称**终端结点**)。在分支结点中,每个结点的分支数就是该节点的度。 - 结点的高度,深度和层次。 1. 结点的**层次**从树根开始定义,根节点为第一层(有些教材将根节点定义为第0层),它的子结点为第2层,以此类推。 2. 结点的**深度**是从根节点开始自顶向下逐层累加的。 3. 结点的**高度**是从叶节点开始自底向上逐层累加的。 4. 树的**高度**(又称**深度**)是树中结点的最大层数。 5. **有序书和无序树**:树中结点的子树从左到右是有次序的,不能交换,这样的树称为**有序树**。有序树中,一个结点其子结点从左到右顺序出现是有关联的。反之称为**无序树**。在上图中,如果将子结点的位置互换,则变为一棵不同的树。 6. 路径和路径长度:树中两个结点之间的**路径**是由这两个节点之间所经过的结点序列构成的,而**路径长度**是路径上所经过的边的个数。A和K的路径长度为3.路径为B,E。 7. **森林**:森林是m棵互不相交的树的集合。森林的概念和树的概念十分相近,因为只要把树的根节点删掉之后就变成了森林。反之,只要给n棵独立的树加上一个结点,并且把这n棵树作为该结点的紫书,怎森林就变成了树。
>树具有如下最基本的性质。
1. 树中结点数等于所有节点的度数+1.
2. 度为m的树中第i层上之多有$m^{i-1}$个结点$(i\geq1)$。
3. 高度为h的m叉树至多有$\frac{m^h-1}{m-1}$个结点。
4. 具有n个结点的m叉树的最小高度为$\log_m(n(m-1)+1)$。

二叉树的概念

二叉树和度为2的有序树的区别:

  1. 度为2的树至少有3个结点,而二叉树则可以为空;
  2. 度为2的有序树的孩子结点的左右次序是相对于另一个孩子结点而言的,如果某个结点只有一个孩子结点,这个孩子结点就无需区别其左右次序,但是二叉树无论孩子数是否为2,均需要确定其左右次序,也就是说二叉树结点次数不是相对于另一个结点而言,而是确定的。
![Alt text](http://pfap49gih.bkt.clouddn.com/1537135888776.png)

       单解释一下完全二叉树:设一个高度为h,有n个结点的二叉树,当且仅当其每一个结点都与高度为h的满二叉树中编号一一对应是称为完全二叉树

二叉树的遍历

先序遍历

void PreOrder(BitTree T)
{
    if(T!=NULL)
    {
        printf("%d\n",T->data);
        PreOrder(T->lchild);
        PreOrder(T->rchild);
    }
}

中序遍历

void InOrder(BitTree T)
{
    if(T!=NULL)
    {
        InOrder(T->lchild);
        printf("%d\n",T->data);
        InOrder(T->rchild)
    }
}

后序遍历

void PostOrder(BitTree T)
{
    if(T!=NULL)
    {
        PostOrder(T->lchild);
        PostOrder(T->rchild);
        printf("%d\n",T->data);
    }
}

三种遍历算法中递归遍历左子树和右子树的顺序都是固定的,只是访问根节点的顺序不同。不管采用何种遍历方法,每个结点都是访问一次,所以时间复杂度就是\(O(n)\)
在递归遍历中,递归工作栈的深度恰巧是树的深度,所以在最坏的情况下,二叉树是有n个结点且深度为n的单支树,递归遍历算法的时间复杂度是\(O(n)\)

@[中序遍历的非递归算法如下]

typedef struct BiTNode
{
    int data;
    struct BiTNode *lchild,*rchild;
}*BitTree;
typedef struct
{
    char data[MaxSize];
    int top;
}SqStack;
void InitStack(SqStack &S)
{
    S.top=-1;
}
void InOrder2(BitTree T)
{
    InitStack(S);
    BitTree p=T;
    while(p||IsEmpty(s))
    {
        if(p)
        {
            Push(S,p);
            p=p->lchild;
        }
        else
        {
            Pop(s,p);
            printf("%d\n",p->data);
            p=p->rchild;
        }
    }
} 

线索二叉树

       遍历二叉树就是以一定的规则将二叉树中的结点排列为一个线性序列,从而得到二叉树中结点的各种遍历序列。其实质就是对一个非线性结构进行线性化操作,使在这个访问序列中的每一个结点(除了最后一个和第一个)都有一个直接前驱结点或者后继结点。
       传统的链式储存能够体现出一种父子关系,不能直接得到结点在遍历中的前驱或者后继。通过观察,我们发现在二叉链表表示的二叉树中存在大量的空指针,若是利用这些空链域存放指向其直接的前驱或者后继的指针,则可以更加方便的运用某些二叉树的操作算法。引入线索二叉树是为了加快查找节点的前驱和后继的速度。
       前面提到,在N个节点的二叉树中,有N+1个空指针。这是因为每个叶节点都有两个空指针,而每一个度为1的节点有一个空指针。总的空指针数目为\(2N_0+N_1\),又有\(N_0=N_2+1\)。意思是二倍的叶子节点加上1被的一个孩子的节点的数目。

线索二叉树的构造。
       线索二叉树的存储结构描述如下:

typedef struct ThreadNode
{
  int data;
  struct ThreadNode *lchild,*rchild;
  int ltag,rtag;
}ThreadNode,*ThreadTree;

\(ltag=0\)表示lchild指向的是结点的左孩子
\(ltag=1\)表示lchild指向的是结点的前驱
\(rtag=0\)表示rchild指向的是结点的右孩子
\(rtag=1\)表示rchild指向的是结点的后继

这种结点结构构成的二叉链表作为二叉树的存储结构,叫做线索链表,其中指向结点前驱和后继的指针,称为线索。加上线索的二叉树称为线索二叉树。对二叉树进行以某种次序遍历使其变为线索二叉树的过程叫做线索化。

@[线索化二叉树的构造]
       对二叉树的线索化,实质上就是遍历一次二叉树,只是在遍历的过程中检查当前节点的左右指针是否为空,若为空,将他们改为指向前驱节点或者后继节点的线索。


@[P109]
       度为2的有序树不是是二叉树:二叉树中如果某个节点只有一个孩子节点,那么这个孩子节点的左右次数是确定的,但是在有序树中如果某个节点只有一个孩子节点则这个节点无需区分其左右次序,所以度为2的树不是二叉树。
       完全二叉树的节点数目和高度的关系是\([\log_2N]+1\)
       完全二叉树的节点排列是从左到右从上到下,所以如果一个节点没有左孩子,则它必定是叶节点。
二叉排序树 后面补上。

设层次遍历的结果为A,B,C。
则先序遍历的结果为A,B,C。
则中序遍历的结果为B,A,C。
则后续遍历的结果为B,C,A。

二叉树的中序遍历的最后一个节点一定是从根开始沿右子女指针链走到最低的结点。

树的存储结构

> 双亲表示法

       这种存储方式采用一组连续空间来存储每个节点,同时在每个节点中增设一个尾指针指示双亲结点在数组中的位置。根节点下标为0,其伪指针域为-1.

![Alt text](http://pfap49gih.bkt.clouddn.com/1537251014840.png)
```cpp typedef struct // 数的结点定义 { int data; // 数据元素 int parent; // 双亲位置域 }PTNode; typedef struct // 树的类型定义 { PTNode nodes[Max_Tree_Size]; int n; // 双亲表示 }PTree; ``` 这种结构利用了每个节点(根结点除外)只有唯一双亲的性质,可以很快的得到每个节点的双亲节点,但是求节点的孩子时却要遍历整个结构。

并查集

       并查集是一种简单的集合表示,它支持一下三种操作:

  • $Union(S,root1,root2); $把集合S中的子集合Root2,并入Root1中。要求Root1和Root2互不相交,否则不执行合并。
  • \(Find(S,x);\)查找集合S中单元素x所在的子集合,并返回该子集合的名字。
  • \(Initial(S);\)将集合S中每一个元素都初始化为只有一个单元素的子集合。
           通常用树(森林)的双琴表示作为并查集的存储结构,每个子集合以一棵树表示。所有表示子集合的树,构成表示全集合的森林,存放在双亲表示数组中。

并查集的结构定义如下:

#define Size 100
int UFSets[Size];
void Initial(int S[])
{
    for(int i=0;i<Size;i++)
        S[i]=-1;
}
int Find(int S[],int x)
{
    while(S[x]>=0)
        x=S[x];
    return x;
}
void Union(int S[],int Root1,int Root1)
{
    S[Root1]=Root2;
}

森林

       由于二叉树和树都可以用二叉链表作为存储结构,则以二叉链表作为媒介可以导出树与二叉树的一个对应关系,即给定一棵树,可以找出唯一的一棵二叉树与之对应。从物理结构上看,树的孩子兄弟表示法和二叉树的二叉链表表示法相同,即每个节点共有两个指针,分别指向结点的第一个孩子节点和结点的下一个兄弟节点,而二叉链表可以使用双指针。因此,就可以用同意存储结构的不同解释将一棵树转换为二叉树。
       树转换为二叉树的规则:每个节点的左指针指向她的第一个孩子节点右指针指向它在书中的相邻兄弟结点,可表示为左孩子有兄弟。由于根节点没有兄弟,所以由树转换而得的二叉树没有右子树。

![Alt text](http://pfap49gih.bkt.clouddn.com/1537320915918.png)
       将森林转化为二叉树的规则和树类似。先将森林中的每一棵树转化为二叉树,再将第一棵树的根作为转换后的二叉树的根,第一棵树作为转化后的二叉树的根的左子树,第二颗数作为转换后二叉树的右子树,第三棵树作为转换后二叉树根右 子树的右子树,以此类推,就可将森林转换为二叉树。 ###
树和二叉树的应用
       **二叉排序树**:简称(BST),也称为二叉查找树。二叉排序树或者是一个空树,或者是一棵具有一下特性的非空二叉树: 1. 若左子树非空,则左子树上所有节点关键字值均小于根节点的关键字值。 2. 若右子树非空,则右子树上所有结点关键字值均大于根节点的关键字值。 3. 左,右子树本身也是一棵二叉排序树        由二叉排序树的定义,有$左子树根节点值<根结点值 <右子树结点值$,所以,对二叉树进行中序遍历,可以得到一个递增的有序序列。
![Alt text](http://pfap49gih.bkt.clouddn.com/1537322070733.png)
        二叉排序树的查找是从根节点开始的,沿某一分值逐层向下进行比较的过程。若二叉排序树非空,将给定值与根结点的关键字比较,若相等,则查找成功;若不等免责当根节点的关键字较大的时候,在根节点的左子树中继续查找,否则在右子树中查找。这显然是一个递归的过程。
二叉排序树的查找
BSTNode *BST_Search(BitTree T,int key,BSTNode *&p)
{   // 返回指向关键字为key的结点指针,若不存在则返回NULL
    p=NULL; // p指向被查找结点的双亲,用于插入和删除操作中。
    while(T!=NULL&&key!=T->data)
    {
        p=T;
        if(key<T->data)
            T=T->lchild;
        else
            T=T->rchild;
    }
    return T;
}
二叉排序树的插入
int BST_Insert(BitTree &T,int k)
{
    if(T==NULL)         // 原树为空,新插入的记录为根节点。
    {
        T=(BitTree)malloc(sizeof(BSTNode));
        T->data=k;
        T->lchild=T->rchild=NULL;
        return 1;
    }
    else if(k==T->data)  // 存在相同的结点。
        return 0;
    else if(k<T->data)   // 插入到T的左子树中
        return BST_Insert(T->lchild,k);
    else
        return BST_Insert(T->rchild,k);
}
由此可见,插入的新节点一定是某个叶节点。在一个二叉排序树先后依次插入结点28和58,虚线表示的边是其查找的路径。

二叉排序树的构造
void Create_BST(BitTree &T,int str[],int n)
{
    T=NULL;
    int i=0;
    while(i<n)
    {
        BST_Insert(T,str[i]);
        i++;
    }
}
二叉排序树的删除

       在二叉排序树中删除一个结点时,不能把以该结点为根的子树上的结点都删除,必须先把被删除结点从存储二叉排序树的链表上摘下来将因删除结点而断开的二叉链表重新链接起来同时确保二叉排序树的性质不会丢失
       删除操作的实现过程按照以下三种情况进行处理:

  1. 如果被删除结点z是叶节点,则直接删除,不会破坏二叉排序树的性质。
  2. 如果结点z只有一颗左子树后者右子树,则让z的子树称为z父结点的子树,替代z的位置。
  3. 若结点z有左右两棵子树,则令z的直接后继(或者直接前驱)替代z,然后从二叉排序树中删去这个直接后继(或者直接前驱),这样就变成了第一种或者第二种情况。
![Alt text](http://pfap49gih.bkt.clouddn.com/1537325195336.png)
       对于高度为H的二叉排序树,其插入和删除操作都是$O(H)$。但最坏的情况下,即构造二叉排序树的输入序列是有序的,则会形成一个倾斜的单支树,此时二叉排序树 的性能显著变坏,树的高度也增加为元素的个数N。        二叉排序树查找算法的平均查找长度取决于树的高度,即与二叉树的形态有关。如果二叉排序树是一个只有左(右)孩子的单支树(类似于有序的单链表),其平均长度和单链表相同为$O(n)$,如果二叉排序树的左右子树的高度之差的绝对值不超过1,这样的二叉排序树被称为平衡二叉树。她的平均查找长度达到$O(\log_2n)$。        从查找过程中看,二叉排序树和二分查找相似。就平均时间性能而言,二叉排序树上的查找和二分查找差不多,但二分查找的判定树唯一,而二叉排序树不唯一,相同的关键字其插入顺序不同可能会生成不同的二叉排序树。        就表的维护而言,二叉排序树无需移动节点,只需修改指针即可完成插入和删除操作,平均执行时间是$O(\log_2n)$。二分查找的对象是有序顺序表,若有插入和删除节点的操作,所花的时间代价是$O(n)$。当有序表是静态查找表时,宜用顺序表作为其存储结构,而采用二分查找实现其查找操作若有序表是动态查找表,则应该选择二叉排序树作为其逻辑结构。 ###
平衡二叉树
       为了避免树的高度增长过快,降低二叉排序树的性能,我们规定在插入和删除二叉树节点时,要保证任意节点的左右子树的高度差的绝对值不超过1,将这样的二叉树称为**平衡二叉树**,简称平衡树(AVL)。定义节点左子树和右子树的高度差为该节点的平衡因子,则平衡二叉树节点的平衡因子只能是-1,0,1。如图所示:
![Alt text](http://pfap49gih.bkt.clouddn.com/1537334623442.png)

       平衡二叉树的插入:二叉排序树保证平衡的基本思想:每当在二叉排序树中插入(或删除)一个节点时,首先要检查其在插入路径上的节点是否因此次操作导致了不平衡。如果导致了不平衡,则先找到插入路径上距离插入节点最近的平衡因子绝对值大于1的节点A,再对以A为根的子树,在保持二叉排序树特性的前提下调整各节点的位置关系,使之重新达到平衡。
       注意:每次调整的对象都是最小不平衡子树,即在插入路径上距离插入节点最近的平衡因子的绝对值大于1的结点作为根的子树。

![Alt text](http://pfap49gih.bkt.clouddn.com/1537335406895.png)
LL平衡旋转
       LL平衡旋转又称为**右单旋转**:由于结点A的左孩子L的左子树L上插入了新节点,A的平衡因子由1增至2,导致以A为根的子树失去平衡,需要一次向右的旋转操作。将A的左孩子B向右上旋转代替A称为新的根节点,将A结点向右下旋转称为B的右子树的根节点,而B的原右子树则称为A结点的左子树。
![Alt text](http://pfap49gih.bkt.clouddn.com/1537337461559.png)
RR平衡旋转
       RR平衡旋转又称为**左单旋转**。由于在结点A的右孩子R的右子树R上插入了新的结点,A的平衡因子由-1减至-2,导致以A为根的子树失去平衡,需要一次向左的旋转操作。将A的右孩子B向左上旋转替代A称为新的根节点,将A结点向左下旋转成为B的左子树的根节点,而B的原左子树则作为A结点的右子树。
![Alt text](http://pfap49gih.bkt.clouddn.com/1537338217054.png)
LR平衡旋转
       LR平衡旋转又称为**先左后右双旋转**:由于在A的左孩子L的右子树R上插入新节点,A的平衡因子由1增至2,导致以A为根的子树失去平衡,需要进行两次旋转操作,先左旋转然后右旋转。先将A结点的左孩子B的右子树的根节点C向上旋转提升至B结点的位置,然后再把该C结点向右上旋转提升至A结点的位置,如图所示。
![Alt text](http://pfap49gih.bkt.clouddn.com/1537338323932.png)
RL平衡旋转
       RL平衡旋转又称先右后左双旋转。由于在A的右孩子R的左子树L上插入新节点,A的平衡因子由-1减至-2,导致以A为根的子树失去平衡,需要进行两次旋转操作,先右旋转后左旋转。先将A结点的右孩子B的子树的根节点C向右上旋转提升至B结点的位置,然后再把C结点向左上旋转提升到A结点的位置,如图所示。
![Alt text](http://pfap49gih.bkt.clouddn.com/1537340095947.png)
####
平衡二叉树的查找
       在平衡二叉树上进行查找的过程和二叉排序树相同,因此,在查找过程中和给定值进行标胶的关键字个数不超过树的深度。假设以$N_h$表示深度为h的平衡树中含有最少结点数。显然,$N_0=0,N_1=1,N_2=2$,并且有$N_h=N_{h-1}+N_{h-2}+1$。易得含有n个结点的平衡二叉树的最大深度为$\log_2n$,因此,平衡二叉树的平均查找长度为$O(\log_2n)$。 ####
哈夫曼(Huffman)树和哈夫曼编码
       在许多实际应用中,树中结点常常被赋予一个表示某种意义的数值,成为该节点的权。从树根结点到任意结点的路径长度(经过的边数)与该节点上的权值的乘积称为该结点的带权路径长度。树中所有叶节点的带权路径长度之和称为该树的带权路径长度。记为:
$WPL=\sum_{i+1}^nw_i*l_i$
式中:$w_i$是第i个叶节点所带的权值;$l_i$是该叶节点到根节点的路径长度。        在$N$个含有带权叶子节点的二叉树中,其中带权路径长度$(WPL)$最小的二叉树称为哈夫曼树,也称为最优二叉树。 #####
哈夫曼树的构造
       给定N个权值分别为$w_1,w_2,w_3,\ldots,w_N$的结点。通过哈夫曼算法可以构造出最优二叉树,算法的描述如下: 1. 将这个N个结点分别作为N棵仅含有一个结点的二叉树,构成森林F。 2. 构造一个新节点,并从F中选取两颗根结点权值最小的树,作为新节点的左,右子树,并且将新节点的权值设置为左右子树上根节点的权值之和。 3. 从F中删除刚才选取的两棵树,同时将新得到的树加入F中。 4. 重复步骤2,3。森林中剩下唯一一棵树为止。
从上述步骤中可以看出哈夫曼树具有如下的特点:
1. 每个初始结点最终都会称为叶节点,并且权值越小的结点路径长度越大。
2. 构造过程中共新建了$N-1$个结点,因此哈夫曼树中结点的总数为$2N-1$。
3. 每次构造都选择两棵树作为新节点的孩子,因此哈夫曼树中不存在度为1的结点。
哈夫曼树编码

       对待处理一个字符串序列,如果每个字符用同样长度的二进制位来表示,则这种方式称为固定长度编码。若允许对不同字符用不等长的二进制位表示,则这种编码方式称为可变长度编码。可变长度编码比固定长度编码好得多,其特点是对频率高的字符赋予段编码,而对频率较低的字符则赋予一些较长的编码,从而可以是字符的平均编码长度被缩短,起到压缩数据的效果。哈夫曼编码是一种被广泛应用而且非常有效的数据压缩编码方法。
       如果没有一个编码是另一个编码的前缀,则称这样的编码为前缀编码。如0,101,100是前缀编码。对前缀编码的解码也是很简单的。因为没有一个码是其他码的前缀。所以可以识别出第一个编码,将他翻译为源码,在对雨下的编码文件重复同样的操作。如\('00101100'\)可被唯一的分析为0,0,101,100。
       由哈夫曼树得到哈夫曼编码是很自然的过程,首先将每个出现的字符当作一个独立的结点,其权值为它出现的频度(或者是次数),构造出对应的哈夫曼树。显然所有字符结点都出现在叶节点中。我们可以将字符的编码解释为从根至该字符的路径上边标记的序列,其中边标记为0表示“转向左孩子”,标记为1表示“转向右孩子”。

![Alt text](http://pfap49gih.bkt.clouddn.com/1537344715522.png)
因为0,1表示左子树还是右子树没有明确的规定。因此,左右结点的顺序是任意的,所以构造出哈夫曼编码并不唯一,但是各哈夫曼树的带权路径长度相同且为最优。
posted @ 2018-09-19 17:12  X-POWER  阅读(2274)  评论(3编辑  收藏  举报