数据结构与算法分析-树

对于大量的输入数据,链表的线性访问时间太慢,不宜使用。

预备知识

*树(tree)可以用几种方式定义。定义树的一种自然的方式是递归的方法。一棵树是一些节点的集合。这个集合可以是空集;若非空,则一棵树由称做根(root)的节点 r r 以及0个或多个非空的(子)树 T 1 , T 2 , , T k T_1,T_2,\cdots,T_k 组成,这些子树中每一棵树的根都被来自根 r r 的一条有向的边(edge)*所连接。

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

从递归中我们发现,一棵树是 N N 个节点和 N 1 N-1 条边的集合,其中的一个节点叫做根。存在 N 1 N-1 条边的结论是由下面的事实得出的,每条边都将某个节点连接到它的父亲,而除去根节点外每一个节点都有一个父亲。

如上图所示,节点A是根。节点F有一个父亲并且儿子K、L和M。每一个节点可以有任意多个儿子,也可能是零个儿子。没有儿子的节点称为树叶(leaf);上图中的树叶是B、C、H、I、P、Q、K、L、M、和N。具有相同父亲的节点称为兄弟(sibling);用类似的方法可以定义祖父(grandfather)孙子(grandchild)

从节点 n 1 n_1 n k n_k 的*路径(path)定义为节点 n 1 , n 2 , , n k n_1,n_2,\cdots,n_k 的一个序列,使得对于 1 i < k 1\le i <k ,节点 n i n_i n i + 1 n_{i+1} 的父亲。这个路径的长(length)*为该路径上的边的条数,即 k 1 k-1 。从每一个节点到它自己有一条长为0的路径。注意,在一棵树中从根到每个节点恰好存在一条路径。

对任意节点 n i n_i n i n_i 的***深度(depth)***为从根到 n i n_i 的唯一路径的长。因此,根的深度为0。 n i n_i 的***高(height)***是从 n i n_i 到一片树叶的最长路径的长。因此,所有树叶的高都是0。一棵树的高等于它的根的高。对于上图,E的深度为1而高为2;F的深度为1高也是1;该树的高为3。一棵树的深度等于它的最深的树叶的深度;该深度总是等于这棵树的高。

如果存在从 n 1 n_1 n 2 n_2 的一条路径,那么 n 1 n_1 n 2 n_2 的一位祖先(ancestor) n 2 n_2 n 1 n_1 的一个后裔(descendant)。如果 n 1 n 2 n_1\neq n_2 ,那么 n 1 n_1 n 2 n_2 的一位真祖先(proper ancestor) n 2 n_2 n 1 n_1 的一个真后裔(proper descendant)

树的实现

实现树的一种方法可以是在每一个节点除数据外还要有一些指针,使得该节点的每一个儿子都有一个指针指向它。然而,由于每个节点的儿子数可以变化很大并且事先并不知道,因此在数据结构中建立到各儿子节点直接的链接是不可行的。实际上解法很简单:将每个节点的所有儿子都放在树节点的链表中。如下声明。

typedef struct TreeNode *PtrToNode;

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

如下图显示一棵树可以用这种实现方法表示出来。

第一儿子/下一兄弟表示法

图中向下的箭头是指向*FirstChild(第一儿子)的指针。从左到右的箭头是指向NextSibling(下一兄弟)*的指针。

如上图中,节点E有一个指针指向兄弟(F),另一指针指向儿子(I),而有的节点这两种指针都没有。

树的遍历及应用

树有很多应用。流行的用法之一是包括UNIX、VAX/VMS和DOS在内的许多常用操作系统中的目录结构。如下图是UNIX文件系统中一个典型的目录。

UNIX目录

这个目录的根是/usr。(名字后面的星号指出"/usr"本身就是一个目录。)“/usr”有三个儿子:mark、alex和bill,它们自己也都是目录。文件名“/usr/mark/book/ch1.r”先后三次通过最左边的儿子节点而得到。在第一个“/“后的每个”/”都表示一条边;结果为一全部路径名。在UNIX文件系统中的目录就是含有它的所有儿子的一个文件,因此,这些目录几乎是完全按照上述的类型声明构造的。

在UNIX文件系统中每个目录还有一项指向该目录本身以及另一项指向该目录的父目录。因此,严格来说,UNIX文件系统不是树,而是类树(treelike)。

事实上,如果将打印一个文件的标准命令应用到一个目录上,那么在该目录中的这些文件名能够在输出中被看到。

设我们想要列出目录中所有文件的名字。我们的输出格式将是:深度为 d i d_i 的文件的名字将被 d i d_i 次跳格(tab)缩进后打印出来。算法代码如下。

void ListDir(DirectorOrFile D, int Depth)
{
    if(D is a legitimate entry)
    {
        PrintName(D, Depth);
        if(D is a dorectory)
            for each child, C of D
                ListDir(C, Depth + 1);
    }
}
void ListDirectory(DirectorOrFile D)
{
    ListDir(D, 0);
}

目录(先序)列表

这种遍历的策略叫做先序遍历(preorder traversal)。在先序遍历中,对节点的处理工作是在它的诸儿子节点被处理之前(pre)进行的。

另一种遍历树的方法是后序遍历(postorder traversal)。在后序遍历中,在一个节点处的工作是在它的诸儿子节点被计算后(post)进行的。例如下图。

后序遍历得到的目录

上图表示的是与前面相同的目录结构,其中圆括号内的数代表每个文件占用的磁盘区块(disk block)的个数。

由于目录本身也是文件,因此它们也有大小。设我们想要计算被该树所有文件占用的磁盘块的总数。最自然的做法是找出含与子目录“/usr/mark(30)"、”/usr/alex(9)“和"/usr/bill(32)"的块的个数。于是磁盘块的总数就是子目录中的块的总数(71)加上”/usr"使用的一个块,共72个块。下图代码实现这种遍历的策略。

void SizeDirectory(DirectoryOrFile D)
{
    int TotalSize;
    
    TotalSize = 0;
    if(D is a legitimate entry)
    {
        TotalSize = FileSize(D);
        if(D is a directory)
            for each child, C, of D
                TotalSize += SizeDirectory(C);
    }
    return TotalSize;
}

如果D不是一个目录,那么SizeDirectory只返回D所占用的块数。否则,被D占用的块数将被加到在其所有子节点(递归地)发线的块数中去。下图显示每个目录或文件的大小是如何由该算法产生的。

轨迹

二叉树

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

如下图显示一颗由一个根和两颗子树组成的二叉树, T L T_L T R T_R 均可能为空。

一般二叉树

二叉树的一个性质是平均二叉树的深度要比 N N 小得多,这个性质有时很重要。分析表明,这个平均深度为 O ( N ) O(\sqrt N) ,而对于特殊类型的二叉树,即二叉查找树(binary search tree),其深度的平均值是 O ( log N ) O(\log N) 。不幸的是,正如下图所示,这个深度可以大到 N 1 N-1 的。

最坏情况的二叉树

实现

因为一颗二叉树最多有两个儿子,所以我们可以用指针直接指向它们。如下代码所示。

typedef struct TreeNode *PtrToNode;
typedef struct PtrToNode Tree;

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

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

表达式树

如下图是一个*表达式树(expression tree)*的例子。

表达式树

表达式树的树叶是操作数(operand),而其他的节点为操作符(operator)。由于这里的所有操作都是二元的,因此这颗特定的树正好是二叉树,虽然这是最简单的情况,但是节点还是有可能含有多于两个的儿子的。一个节点也有可能只有一个儿子,如具有一目减算符(unary minus operator)的情形。我们可以将通过递归计算左子树和右子树所得到的值应用在根处的算符操作中而算出表达式树T的值。

我们可以通过递归产生一个带括号的左表达式,然后打印处在根处的运算符,最好再递归地产生一个带括号的右表达式而得到一个(对两个括号整体进行运算的)中缀表达式(infix expression)。这种一般的方法(左、节、右)称为中序遍历(inorder traversal)

另一个遍历策略是递归打印出左子树、右子树,然后打印运算符。如果我们应用这种策略于上面的树,则输出将是“ a   b   c     +   d   e     f   +   g     + a\space b\space c\space *\space +\space d\space e\space *\space f\space +\space g\space *\space + ”,容易看出,这就是后缀表达式。这种遍历策略一般称为后序遍历(postorder traversal)

第三种遍历策略是先打印出运算符,然后递归地打印出右子树和左子树。其结果“ +   +   a     b   c     +     d   e   f   g +\space +\space a\space *\space b\space c\space *\space +\space *\space d\space e\space f\space g ”是不太常用的前缀(prefix)记法,这种遍历策略为先序遍历(preorder traversal)

构造一棵表达式树

现在给出一种算法来把后缀表达式转变成表达式树。一次一个符号地读入表达式。如果符号是操作数,我们就建立一个单节点树并将一个指向它的指针推入栈中。如果符号是操作符,那么我们就从栈中弹出指向两棵树 T 1 T_1 T 2 T_2 的那两个指针( T 1 T_1 的先弹出)并形成一颗新的树,该树的根就是操作符,它的左、右儿子分别指向 T 2 T_2 T 1 T_1 。然后将指向这颗树的指针压入栈中。

来看一个例子。设输入为:

a   b   +   c   d   e   +     a\space b\space +\space c\space d\space e\space +\space *\space *

前两个符号是操作数,因此我们两颗单节点树并将指向它们的指针压入栈中。

1

接着,“+”被读入,因此指向这两棵树的指针被弹出,一颗新的树形成,而指向该树的指针被压入栈中。

2

然后,c、d和e被读入,在每个单节点树创建后,指向对应的树的指针被压入栈中。

3

接下来读入“+”号,因此两棵树合并。

4

继续进行,读入“*”号,因此,我们弹出两个树指针并形成一个新的树,“*"号是它的根。

5

最好,读入最后一个符号,两棵树合并,而指向最后的树的指针留在栈中。

6

查找树ADT——二叉查找树

使二叉树称为二叉查找树的性质是,对于树中的每个节点X,它的左子树中所有关键字值小于X的关键字值,而它的右子树中所有关键字值大于X的关键字值。注意,这意味着,该树所有的元素可以用某种统一的方式排序。

现在给出通常对二叉查找树进行的操作的简要描述。注意,由于树的递归定义,通常递归地编写这些操作的例程。因为二叉查找树的平均深度是 O ( log N ) O(\log N) ,所以我们一般不必担心栈空间被用尽。下面给出定义和声明。

#ifndef _Tree_h
#define _Tree_h

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

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 /* _Tree_h */

/* Place in the implementation file */
struct TreeNode
{
    ElementType Element;
    SearchTree Left;
    SearchTree Right;
};

MakeEmpty

这个操作主要用于初始化。有些程序设计人员更愿意将第一个元素初始化为单节点树,但是,我们的实现方法更紧密地遵循树的递归定义。如下代码所示。

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

Find

这个操作一般需要返回指向树T中具有关键字X的节点的指针,如果这样的节点不存在则返回NULL。树的结构使得这种操作很简单。如果T是NULL,那么我们可以就返回NULL。否则,如果存储在T中的关键字是X,那么我们可以返回T。否则,我们对树T的左子树或右子树进行一次递归调用,这依赖于X与存储在T中的关键字的关系。如下代码所示。

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

还要注意,这里的两个递归调用事实上都是尾递归并且可以用一次复制和一个goto语句很容易地代替。尾递归的使用在这里是合理的,因为算法表达式的简明性是以速度的降低为代价的,而这里所使用的栈空间的量也只不过是 O ( log N ) O(\log N) 而已。

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);
}

我们使用递归编写FindMin,使用非递归编写FindMax。

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

Insert

为了将X插入到树T中,你可以像用Find那样沿着树查找。如果查找到X,则什么也不用做(或做一些“更新”)。否则,将X插入到遍历的路径上的最后一点上。如下图显示插入的实际情况。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0mlk3tBP-1579703325968)(C:\Users\FxPC\AppData\Roaming\Typora\typora-user-images\image-20200122152927129.png)]

下面给出Insert例程的代码。

SearchTree Insert(ElementType X, SearchTree T)
{
    if (T == NULL)
    {
        /* Create and return a one-node tree */
        T = (Position)malooc(sizeof(struct TreeNode));
        if (T == NULL)
            printf("Out of space!!!");
        else
        {
            T->Element = X;
            T->Left = 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);
    /* Else X is in the tree already; we'll do nothing */
    return T; /* Do not forget this line!!! */
}

由于T指向该树的根,而根又在第一次插入时变化,因此Insert被写成一个返回指向新树根的指针的函数。

Delete

正如许多数据结构一样,最困难的操作是删除。一旦发线要被删除的节点,我们就需要考虑几种可能的情况。

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

具有一个儿子的节点的删除

注意,所删除的节点现在已不在引用,而该节点只有在指向它的指针已被省去的情况下才能够被去掉。

复杂的情况是处理具有两个儿子的节点。一般的删除策略是用其右子树的最小的数据(很容易找到)代替该节点的数据并递归地删除那个节点(现在它是空的)。因为右子树中的最小的节点不可能有左儿子,所以第二次Delete要容易。如下图所示。

删除具有两个儿子

要被删除的节点是根节点的左儿子,其关键字是2。它被右子树中的最小数据(3)所代替,然后关键字是3的原节点如前例那样被删除。代码如下。

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

    if (T == NULL)
        printf("Element not found");
    else if (X < T->Element) /* Go left */
        T->Left = Delete(X, T->Left);
    else if (X > T->Element) /* Go right */
        T->Right = Delete(X, T->Right);
    else                         /* Found element to be deleted */
        if (T->Left && T->Right) /* Two children */
    {
        /* Replace with smallest in right subtree */
        TmpCell = FindMin(T->Right);
        T->Element = TmpCell->Element;
        T->Right = Delete(T->Element, T->Right);
    }
    else /* One or zero children */
    {
        TmpCell = T;
        if (T->Left == NULL) /* Also handles 0 children */
            T = T->Right;
        else if (T->Right == NULL)
            T = T->Left;
        free(TmpCell);
    }
    return T;
}

上面代码效率并不高,因为它沿该树进行两趟搜索以查找和删除右子树中最小的节点。写一个特殊的DeleteMin函数可以容易地改变效率不高的缺点。

如果删除的次数不多,则通常使用的策略是懒惰删除(lazy deletion):当一个元素要被删除时,它仍留在树中,而是只做了个被删除的记号。这种做法特别是在有重复关键字时很流行,因为此时记录出现频数的域可以减1。如果树中的实际节点数和“被删除”的节点数相同,那么树的深度预计只上升一个小的常数,因此,存在一个与懒惰删除相关的非常小的实际损耗。再有,如果被删除的关键字是重新插入的,那么分配一个新单元的开销就避免了。

平均情形分析

直观上,除MakeEmpty外,所以的操作都花费 O ( log N ) O(\log N) 时间,因为我们用常数时间在树种降低了一层,这样一来,对树的操作大致减少了一半左右。因此,除MakeEmpty外,所有的操作都是 O ( d ) O(d) ,其中 d d 是包含所访问的关键字的节点的深度。

我们在此要证明,所有的树出现的机会均等,则树的所有结点的平均深度为 O ( log N ) O(\log N)

一棵树的所有节点的深度的和称为内部路径长(internal path length)。我们现在要计算二叉查找树平均内部路径长,其中的平均是对向二叉查找树中所有可能的插入序列进行的。

D ( N ) D(N) 是具有 N N 个节点的某棵树T的内部路径长, D ( 1 ) = 0 D(1)=0 。一颗 N N 节点树是由一颗 i i 节点左子树和一颗( N i 1 N-i-1 )节点右子树以及深度为0的一个根节点组成,其中 0 i N 0\le i\leq N D ( i ) D(i) 为根的左子树的内部路径长。但是在原树中,所有这些节点都要加深一度。因此我们得到递归关系:

D ( N ) = D ( i ) + D ( N i 1 ) + N 1 D(N)=D(i)+D(N-i-1)+N-1

如果所有子树的大小都等可能地出现,这对于二叉查找树是成立的(因为子树的大小只依赖于第一个插入到树中的元素的相对的秩),但对于二叉树则不成立,那么 D ( i ) D(i) D ( N i 1 ) D(N-i-1) 的平均值都是( 1 N j = 0 N 1 D ( j ) \frac1N\sum^{N-1}_{j=0}D(j) )。于是

D ( N ) = 2 N [ j = 0 N 1 D ( j ) ] + N 1 D(N)=\frac2N[\sum^{N-1}_{j=0}D(j)]+N-1

得到的平均值为 D ( N ) = O ( N log N ) D(N)=O(N\log N) 。因此任意节点的期望深度为 O ( log N ) O(\log N)

但是,上来就断言这个结果意味着上一节讨论的所有操作的平均运行时间是 O ( log N ) O(\log N) 是并不完全正确的。原因在于删除操作,我们并不清楚是否所有的二叉查找树都是等可能出现的。特别的,上面描述的删除算法有助于使得左子树比右子树深度深,因为我们总是用右子树的一个节点来代替删除的节点。这种策略的准确效果仍然是未知的,但它似乎是理论上的谜团。已经证明,如果我们交替插入和删除 Θ ( N 2 ) \Theta(N^2) 次,那么树的期望深度将是 Θ ( N ) \Theta(\sqrt N) 。在25万次随机Insert/Delete后,如下图所示中右沉的树看起来明显地不平衡(平均深度=12.51)。

随机生成的二叉查找树

交替插入删除后的二叉查找树

在删除操作中,我们可以通过随机选取右子树的最小元素或左子树的最大元素来代替被删除的元素以消除这种不平衡问题。这种做法明显地消除了上述偏向并使树保持平衡,但是,没有人实际上证明过这一点。

如果向一棵树预先排序的树输入数据,那么一连串Insert操作将花费二次时间,而链表实现的代价会非常巨大,因为此时的树将只由那些没有左儿子的节点组成。一种解决办法就是要有一个称为平衡(balance)的附加的结构条件:任何节点的深度均不得过深。

有许多一般的算法实现平衡树。但是,大部分算法都要比标准的二叉查找树复杂得多,而且更新要平均花费更长的时间。不过,它们确实防止了处理起来非常麻烦的一些简单情形。下面将介绍最古老的一种平衡查找树,即AVL树。

另外,较新的方法是放弃平衡条件,允许树有任意的深度,但是在每次操作之后要使用一个调整规则进行调整,使得后面的操作效率更高。这种类型的数据结构一般属于自调整(self-adjusting)类结构。

发布了32 篇原创文章 · 获赞 18 · 访问量 3243

猜你喜欢

转载自blog.csdn.net/u011714517/article/details/104073192