数据结构与算法分析全流程笔记(二):树

数据结构与算法分析笔记(二):树

文章为《数据结构与算法分析:C语言描述》的学习笔记,手边有这本书籍看起来会比较轻松,因为在书中的一些图片示例并没有摘录。需要参照书籍的地方,笔记里均有标出,因此没有书籍也可以将此笔记作为学习参考。

4.1 预备知识

树可以由多种方法定义,其中一种是递归的方法:

一棵树是一些节点的集合。集合可以是空集;若集合非空,则一棵树由称作根(root)的节点r以及0个或多个非空的子树T1, T2, …, Tk组成,这些子树中每一棵的根,都被来自根r的一条有向边连接。

每一棵子树的根叫做根r的儿子(child),而r是每一棵子树的根的父亲(parent)。

没有儿子的节点称为树叶(leaf);具有相同父亲的节点为兄弟(sibling)。用类似的方法可以定义grandparent和grandchildren。

从节点n1到nk的路径(path)定义为节点n1,…,nk的一个序列。在一棵树中,从根到每个节点恰好存在一条路径。路径的长(length)为这条路径上的边(edge)的条数,每一个节点到自己有一条长为0的路径。

深度(depth)指的是根到节点的长度;高度(height)指的是根到树叶的最长长度。

4.1.1 树的实现

以下是一个典型的树节点声明,一个节点存放一个数据,并且指向自己的第一儿子与下一兄弟。

typedef struct TreeNode *PtrToNode;
struct TreeNode
{
	ElementType Element;
	PtrToNode FirstChild;
	PtrToNode NextSibling;
}

4.1.2 树的遍历及应用

类UNIX系统的目录和树非常类似。此处建议看一下书上的图。
以下是遍历树的伪代码:

static void ListDir(DirectoryOrFile D, int Depth)
{
	PrintName(D, Depth);
	if(D is a directory)
		for each child, C, of D
			ListDir(C, Depth + 1);
}

void ListDirectory(DirectoryOrFile D)
{
	ListDir(D, 0);
}

这属于前序遍历。

4.2 二叉树

二叉树(binary tree)是一棵树,其中每个节点的儿子都不能多于两个。

4.2.1 实现

树节点的声明有些类似于双链表,我们可以直接用指针指向某个节点的两个儿子。
增加和删除节点同样需要使用malloc和free,以下是二叉树的节点声明。

typedef struct TreeNode *PtrToNode;
typedef struct PtrToNode Tree;

struct TreeNode
{
	ElementType Element;
	Tree		Left;
	Tree		Right;
};

二叉树有许多与搜索无关的重要应用,其主要用处之一是在编译器的设计领域。

4.2.2 表达式树

表达式树的树叶是操作数(operand),其他的节点是操作符(operator)。

通过递归的方法,打印出左表达式,再打印根部的运算符,最后打印出右侧表达式的方法称为中序遍历(inorder traversal)。很容易看出来,打印的结果是中缀表达式(fix expression)。

通过递归的方法,递归打印出左子树、右子树,然后打印运算符的方法称为后序遍历(postorder traversal)。打印的结果是后缀表达式,在学习栈的时候,我们遇到过这一类表达式。

还有一种遍历策略,先打印出运算符,然后递归地打印出右子树和左子树,为先序遍历(preorder traversal)。打印结果是不常见的前缀计法。

构造表达式树,需要使用到栈,书中阐释非常详细易懂,此处不再赘述。

查找树ADT——二叉查找树

使二叉树成为二叉查找数的性质是,对于树中每个节点X,它的左子树中所有节点的元素均小于X的元素,它的右子树中所有节点的元素均大于X的元素。
现在给出简要描述。

#ifndef _Tree_H

struct TreeNode;
typedef struct TreeNode* Position;
typedef struct TreeNode* SearchTree;
typedef int ElementType;

SearchTree MakeEmpty(SearchTree T);
Position Find(ElementType X, SearchTree T);
Position FindMin(SearchTree T);
Position FindMax(SearchTree T);
SearchTree Insert(ElementType X, SearchTree T);
SearchTree Delete(ElementType X, SearchTree T);
ElementType Retrieve(Position P);

#endif

struct TreeNode
{
    ElementType Element;
    SearchTree Left;
    SearchTree Right;
};

以上是二叉查找树的声明。

4.3.1 MakeEmpty

这个操作主要用于初始化。我们的实现方法更紧密地遵循树的递归定义。

SearchTree MakeEmpty(SearchTree T)
{
    if(T != NULL)
    {
        MakeEmpty(T->Left);
        MakeEmpty(T->Right);
        free(T);
    }
    return NULL;
}

4.3.2 Find

一般需要返回指向树T中具有关键字X的节点的指针,如果节点不存在则返回NULL。
关键的问题在于首先要对是否为空树进行测试;其次,尾递归在这里的运用是合理的,我们使用的栈空间只不过是O(logN)。

Position Find(ElementType X, SearchTree T)
{
    if(T == NULL)
        return NULL;
    if(X < T->Element)
        return Find(X, T->Left);
    else
    if(X > T->Element)
        return Find(X, T->Right);
    else
        return T;
}

4.3.3 FindMin和FindMax

分别返回树中最小元和最大元的位置,这两个函数的操作是极其相似的,因此,此处使用递归编写FindMin,使用非递归编写FindMax。

Position FindMin(SearchTree T)
{
    if(T == NULL)
        return NULL;
    else
    if(T->Left == NULL)
        return T;
    else
        return FindMin(T->Left);
    
}

Position FindMax(SearchTree T)
{
    if(T != NULL)
        while (T->Right != NULL)
            T = T->Right;

    return T;
}

4.3.4 Insert

插入例程需要符合二叉查找树的定义,其设计思想有些类似于Find。注意代码里面递归的运用,一层层深入,再一层层返回。

SearchTree Insert(ElementType X, SearchTree T)
{
    if(T == NULL)
    {
       T = malloc( sizeof( struct TreeNode ) );
       if(T == NULL)
       {
            printf("Out of Space!!!!");
            exit;
       }
       else
       {
            T->Element = X;
            T->Left = NULL;
            T->Right = NULL;
       }
    }
    else
    if (X < T->Element)
        T->Left = Insert(X, T->Left);
    else
    if (X > T->Element)
        T->Right = Insert(X, T->Right);
    
    return T;
}

4.3.5 Delete

接下来是最困难的操作,一旦发现需要删除的节点,我们就需要考虑几种可能的情况。

如果节点是一片树叶,那么可以立即删除。如果节点有一个儿子,则该节点可以在其父节点调整指针,绕过该节点,然后删除。

更复杂的情况是处理具有两个儿子的节点。一般的删除策略是,用需要删除的节点的右子树中的最小的节点,代替该节点的数据。接下来,我们只需要删除那个右子树中最小的节点即可,不需要删掉有两个儿子的节点。

SearchTree Delete(ElementType X, SearchTree T)
{
    Position TmpCell;

    if(T == NULL)
    {
        printf("ELement Not Found");
        exit;
    }
    else if(X < T->Element)
        T->Left = Delete(X, T->Left);
    else if(X > T->Element)
        T->Right = Delete(X, T->Right);

    //至此已找到节点,这之后就开始删除(两个情况,即节点有 2个孩子 和 0或1个孩子 )

    else if(T->Left && T->Right)
    {
        TmpCell = FindMin(T->Right);
        T->Element = TmpCell->Element;
        T->Right = Delete(T->Element, T->Right);
    }
    else
    {
        TmpCell = T;
        if(T->Left == NULL)
            T = T->Right;
        else if(T->Right == NULL)
            T = T->Left;
        free(TmpCell);
    }

    return T;
}

删除的次数不多时,常采用懒惰删除(lazy deletion):当一个元素被删除时,它仍然留在树中,只是做了个被删除的记号。

4.3.6 平均情形分析

除了MakeEmpty函数,我们期望之前提到的所有操作都花费O(logN)的时间。我们又易知,那些操作是O(d)的,d是节点深度。在本节要证明,如果所有的树出现的机会均等,则所有节点的平均深度为O(logN)。

一棵树所有节点深度的和称为 内部路径长(internal path length) 。我们找的是它的平均。

令 D(N) 是具有 N 个节点的某棵树 T 的内部路径长,D(1) = 0。一棵N节点数是由一棵 i 节点左子树和一棵( N - i - 1)个节点的右子树以及深度为0的根节点组成。但是在原树中,所有这些节点都要加深一度。同样的结论对于右子树也是成立的,因此我们得到递归关系。
D ( N ) = D ( i ) + D ( N − i − 1 ) + N − 1 D(N) = D(i)+D(N-i-1)+N-1 D(N)=D(i)+D(Ni1)+N1
设所有子树的大小都等可能的出现,这对于二叉查找树是成立的,但对于二叉树则不成立。易得:
D ( N ) = 2 N [ ∑ j = 0 n − 1 D ( j ) ] + N − 1 D(N)= \dfrac{2}{N} [\sum^{n-1}_{j=0}D(j)]+N-1 D(N)=N2[j=0n1D(j)]+N1
事实上,明确“平均”意味着声明一般是极其困难的,可能需要一些假设。没有经过删除操作的所有查找二叉树是等可能出现的,如果经过了删除,就不一定了。
如果向一棵树输入预先排序的数据,那么一连串insert操作将花费二次时间,用链表实现insert的代价也十分巨大,因为这时候树根本不会存在左儿子。其中一种解决办法是有一个称为平衡(balance)的附加的结构条件:任何节点的深度均不能过深。
下面介绍一种平衡查找树:AVL(Adelson-Velskii and Landis)树。

4.4 AVL树

我们希望AVL树是带有平衡条件的二叉查找树,要保证树的深度是O(logN)。
因此我们给出如下条件:一棵AVL树是其每个节点的左子树和右子树的高度最多差1的二叉查找树。空树的高度定义为-1。
此处建议去看一下AVL树大致的图例。

由此总结得到,一棵AVL树的高度最多为:
1.44   l o g ( N + 2 ) − 1.328 1.44\,log(N+2)-1.328 1.44log(N+2)1.328
在高度为h的AVL树中,最少节点数S(h)满足的关系式为:
S ( h ) = S ( h − 1 ) + S ( h − 2 ) + 1 S(h) = S(h-1)+S(h-2)+1 S(h)=S(h1)+S(h2)+1
h = 0, S(h) = 1; h = 1, S(h) = 2
可知最少节点数与斐波那契数列密切相关,由这个递推式可以推出上面提到的AVL的高度的界。

当进行插入操作时,插入的节点可能会破坏AVL树的特性,如果发生这种情况,我们需要把性质恢复才认为这一步插入完成。事实上这总可以通过对树进行简单的修正来完成,我们称其为旋转(rotation)。
我们将在最深的节点(第一个被破坏平衡的节点)去重新平衡这棵树。
我们的不平衡会有四种情况:

  1. 对节点左儿子的左子树进行一次插入
  2. 对节点左儿子的右子树进行一次插入
  3. 对节点右儿子的左子树进行一次插入
  4. 对节点右儿子的右子树进行一次插入
    根据情形不同,采用的旋转方式也不同。

4.4.1 单旋转

强烈建议参照图片或动图理解。此处不进行笔记记录。

4.4.2 双旋转

强烈建议参照图片或动图理解。此处不进行笔记记录。
以下是代码实现:

#include <stdio.h>
#include <stdlib.h>
#ifndef _AVLTREE_H

struct AvlNode;
typedef struct AvlNode* Position;
typedef struct AvlNode* AvlTree;
typedef double ElementType;

AvlTree MakeEmpty(AvlTree T);
Position Find(ElementType X, AvlTree T);
Position FindMin(AvlTree T);
Position FindMax(AvlTree T);
AvlTree Insert(ElementType X, AvlTree T);
AvlTree Delete(ElementType X, AvlTree T);
ElementType Retrieve(Position P);

#endif

struct AvlNode
{
    ElementType Element;
    AvlTree Left;
    AvlTree Right;
    int Height;
};

static int Height(Position P)
{
    if(P == NULL)
        return -1;
    else
        return P->Height;
}

int Max(int a, int b)
{
    return a > b ? a : b;
}

static Position SingleRotateWithLeft(Position K2)
{
    //该函数只有当K2有左孩子的时候才能用
    //对K2和它的左孩子进行一次旋转
    //更新树的高度,然后返回一个新的根

    Position K1;
    K1 = K2->Left;
    K2->Left = K1->Right;
    K1->Right = K2;
    
    K2->Height = Max(Height(K2->Left), Height(K2->Right)) + 1;
    K1->Height = Max(Height(K1->Left), Height(K1->Right)) + 1;

    return K1;

}

static Position SingleRotateWithRight( Position K2 )
{
    Position K1;
    K1 = K2->Right;
    K2->Right = K1->Left;
    K1->Left = K2;

    K2->Height = Max(Height(K2->Left), Height(K2->Right)) + 1;
    K1->Height = Max(Height(K1->Left), Height(K1->Right)) + 1;

    return K1;
    
}

static Position DoubleRotateWithLeft(Position K3)
{
    //先对K1和K2进行一次右旋转
    K3->Left = SingleRotateWithRight( K3->Left );

    //再对K2和K3进行一次左旋转
    return SingleRotateWithLeft( K3 );

}

static Position DoubleRotateWithRight( Position K3 )
{
    K3->Right = SingleRotateWithLeft( K3->Right );

    return SingleRotateWithRight(K3);
}

AvlTree Insert( ElementType X, AvlTree T )
{
    if( T == NULL )
    {
        //create and return a one-node Tree.
        T = malloc( sizeof( struct AvlNode) );
        if( T == NULL )
             exit;//out of space
        else
        {
            T->Element = X;
            T->Height = 0;
            T->Left = T->Right = NULL;
        }
    }
    else if( X < T->Element )
    {
        T->Left = Insert( X, T->Left );
        if( Height(T->Left) - Height(T->Right) == 2 )
            if( X < T->Left->Element)
                T = SingleRotateWithLeft( T );
            else
                T = DoubleRotateWithLeft( T );
    }
    else if( X > T->Element )
    {
        T->Right = Insert( X, T->Right);
        if( Height(T->Right) - Height(T->Left) == 2 )
            if( X < T->Right->Element )
                T = SingleRotateWithRight( T );
            else
                T = DoubleRotateWithRight( T );
    }
    //还有一种剩余的情况是X已经在树中了,我们就不做任何处理

    T->Height = Max( Height(T->Left), Height(T->Right) ) + 1;
    return T;
}

对于AVL树的理解,建议画图和看代码结合,光看文字描述很难有直观形象的感受。

4.5 伸展树

伸展树(splay tree),保证从空树开始任意连续M次对树的操作最多花费O(M log N)时间。
一般来说,当M次操作的序列总的最坏时间是O(M F(N) )时,我们说它摊还(amortized)运行时间为O( F(N) )。

伸展树的基本想法是,当一个节点被访问后,它就要经过一系列AVL树的旋转后放到根上。这一种结构往往有实际的效用,因为在许多应用中,当一个节点被访问时,它就很可能不久之后再次被访问,我们会把访问该节点的时间通过重新构造而变少。

4.5.1 一个简单的想法

此处建议参照书籍图片。
如果我们单纯地对某个在深处的节点不断施加旋转,直到这个节点成为树的根,但是这种操作没有改变访问其它节点的情况,会把其他的某个节点推向我们施加操作的节点原先在的深度。

所以这个想法还不够好。

4.5.2 展开

建议配合图片形象理解
如果是之字形,使用标准的双旋转;
如果是一字形,使用一字形旋转。

展开操作不仅将访问的节点移动到根处,而且还有把访问路径上的大部分节点的深度大致减少一半的效果。

当访问路径太长而导致超出正常查找时间的时候,旋转操作对于未来的操作有益,但当访问耗时很少的时候,这些旋转是有害的。

我们还可以通过访问要删除的节点实现删除操作,这种操作将节点上推到根处,一旦删除则得到两棵子树TL和TR,找到TL的最大元素,将这个元素旋转到TL的根下,此时TL将有一个没有右儿子的根,将TR作为右儿子而结束删除。

4.6 树的遍历

void PrintTree( SearchTree T )
{
    if( T != NULL )
    {
        PrintTree(T->Left);
        printf("%d",T->Element);
        PrintTree(T->Left);
    }
}

这是中序遍历,首先遍历左子树,之后是当前的节点,最后遍历右子树。

int Height( SearchTree T )
{  
    if( T == NULL )
        return -1;
    else
        return 1 + Max(Height(T->Left), Height(T->Right));
}

这是后序遍历。计算树的高度。

所有这些例程有一个共有的想法,就是先处理NULL的情况。

4.7 B树

还有一种常用的查找树,它不是二叉树,叫做B树(B-Tree)。

阶为M的B树具有以下结构特性:

  • 树的根要么是一片树叶,要么其儿子数在2到M之间。
  • 除根以外,所有非树叶节点的儿子数在 M/2(向上取整) 和 M 之间。
  • 所有的树叶都在相同深度上。

所有的数据都储存在树叶上,在每一个内部的节点上皆有指向该节点各儿子的指针P1,P2,……,PM和分别代表在子树P2,……,PM中发现的最小关键字k1,k2,…, km-1的值。

对于一般的M阶B树,当插入一个关键字是,唯一的困难在于接收该关键字的节点已经有M个关键字了。插入这个关键字使得该节点具有M+1个关键字,我们可以把它分裂成两个节点,它们分别具有 ⌈ ( M + 1 ) / 2 ⌉ \lceil (M+1)/2 \rceil ⌈(M+1)/2个和 ⌊ ( M + 1 ) / 2 ⌋ \lfloor (M+1)/2 \rfloor ⌊(M+1)/2个关键字。
又由于多出一个儿子,因此我们必须检查这个节点是否可被父节点接受,如果父节点已经具有M个儿子,那么这个父节点就要被分裂成两个节点。我们重复这个过程直到找到一个具有少于M个儿子的父节点。如果分裂根节点,则需要创建一个新的根,这个根有两个儿子。

B树的深度最多是 ⌈ l o g ⌈ M / 2 ⌉ N ⌉ \lceil log_{\lceil M/2 \rceil}N \rceil logM/2N

B树实际用于数据库系统,在那里树被储存在物理的磁盘上而不是主存中。

  • 12
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值