数据结构与算法学习(三)

三 树

  对于大量的输入数据,链表的线性访问时间太长,不宜使用。我们现在使用的数据结构叫做二叉查找数(binary search tree)。二叉查找数是两个流行库集合类map和set的实现基础。本章讨论的主题是:

  •  了解树是如何用于实现几个流行的操作系统中的文件系统的。

  •  了解树如何用来计算算术表达式的值。

  •  指出如何利用树支持以Ο(logN)平均时间进行的各种搜索操作,以及如何细化以得到最坏情况时间界Ο(logN)。我们还讨论数据被存储在磁盘上时如何实现这些操作。

  •  讨论并使用set和map类。

 

 3.1 预备知识

  数(tree)可以有几种方式定义。其中一种自然方式是递归的定义。一棵树是一些节点的集合。这个集合可以是空集;若不是空集,则树由称作跟(root)的节点r以及零个或者多个非空的(子)树T1、T2、…、Tk组成。这些子树中的每一颗的根都被来自跟r的一条有向的边(edge)所连接。每棵树的根叫做根r的儿子(child),而r是每棵树的根的父亲(parent)。图3-1显示了用递归定义的典型的树。

223232_Uk0K_2537915.jpg

图3-1  一般的树

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

223712_4M8G_2537915.jpg

图3-2 一棵具体的树

  节点A是根。没有儿子的节点称为叶(leaf)节点;具有相同父亲的节点称为兄弟(siblings)节点。

  从节点n1到nk的路径(path)定义为节点n1,n2,…,nk的一个序列。使得对于1<=i<k,节点ni是ni+1的父亲。路径的长(length)为路径上的边的条数,即k-1。从每个节点到它自己有一条长为0的路径。注意,在一棵树中从根到每个节点恰好存在一条路径。

  对任意节点n1,ni的深度(depth)为从根到ni的唯一路径的长。因此,根的深度为0,ni的高(height)是从ni到一片树叶的最长路径的长。

  如果存在从n1到n2的一条路径,那么n1是n2的一位祖先(ancestor)而n2是n1的一个后裔(descendant)。如果n1≠n2,那么n1是n2的一位真祖先(proper ancestor)而n2是n1的一个真后裔(proper decendant)。

 

  3.1.1 树的实现

  实现树的一种方法是在每一个节点除数据外还要有一些链,来指向该节点的每一个儿子。下面是典型的节点声明:

struct TreeNode
{
  Object element;
  TreeNode *fileChild;
  TreeNode *nextSibling;
}

  图3-3显示了一棵树如何用这种方法表示出来的。图中向下的箭头是指向firstChild的链。从左到右的箭头是指向nextSibling的链。

230825_k9JA_2537915.jpg

图3-3 在图3-2中所示的树的第一个儿子/下一个兄弟的表示法

 

  3.1.2 树的遍历及应用

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

231656_Gsrr_2537915.jpg

图3-4 UNIX目录

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

void FileSystem::listAll( int depth = 0 ) const
{
  printName( depth ); //Print the name of object
  if( isDirectory() )
    for each file c in this directory( for each child )
      c.listAll( depth + 1 );
}

  整个输入结果如图3-5。

091255_iC1P_2537915.jpg

图3-5 (前序)目录列表

  这个遍历策略称为前序遍历(preorder traversal)。前序遍历中,对节点的处理工作是在它的诸儿子节点被处理之前进行的。如果有N个文件名需要输出,则运行时间就是Ο(N)。

  另一种遍历树的常用方法是后序遍历(postorder traversal)。在后序遍历中,在一个节点的工作是在它的诸儿子节点被计算后进行的。例如,图3-6中圆括号内的数代表每个文件占用的磁盘块的个数。

092843_dz1A_2537915.jpg

图3-6 经由后序遍历得到的带有文件大小的UNIX目录

  下面是伪代码方法size实现了这种遍历策略:

int FileSystem::size( ) const
{
  int totalSize = sizeOfThisFile();
  
  if( isDirectory() )
    for each file c in this directory ( for each child )
      totalSize += c.size();
      
   return totalSize;
}

  图3-7显示了每个目录或文件的大小如何由该算法产生的。

 

093607_ajvD_2537915.jpg

图3-7 函数size的跟踪

 3.2 二叉树

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

  图3-8显示了一棵由一个根和两棵子树组成的二叉树,子树TL和TR均可能为空。

094050_B8z0_2537915.jpg

图3-8 一般二叉树

  二叉树的一个性质是平均二叉树的深度要比节点个数N小得多。分析表明这个平均深度为Ο(√N),而对特殊类型的二叉树,即二叉查找树(binary search tree),其深度的平均值为Ο(logN)。遗憾的是,正如下图所示的例子,这个深度也可以大到N-1。

094555_bEFn_2537915.jpg

图3-9 最坏情况的二叉树

  3.2.1 实现

  在声明中,一个节点就是由element(元素)的信息加上两个到其他节点的引用(left和right)组成的结构。下面是二叉树节点的伪代码。

struct BinaryNode
{
  Object element; //The data in the node
  BinaryNode *left; //Left child
  BinaryNode *right; //Right child
}

  3.2.2 一个例子——表达式树

  图3-10是一个表达式树(expression tree)的例子。表达式树的树叶是操作数(operand),如常数或者变量名字,其他的节点为操作符(operator)。下面这个例子中,左子树的值是a+b*c,右子树的值是(d*e+f)*g。

095734_vhME_2537915.jpg

图3-10 (a+b*c)+((d*e+f)*g)

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

  另一个策略是递归地打印出左子树、右子树,然后打印操作符。如果应用这种策略于上面的树,则输出将是a b c * + d e * f + g * +。这一种是后序遍历(postorder traversal)。

  还有一种遍历策略是先打印出操作符,然后递归地打印出左子树和右子树。其结果是:++a*bc*+*defg,这是不太常用的前缀(prefix)记法,这种遍历策略称为前序遍历(preorder traversal)。

   构造一棵表达式树

  下面给出一种算法把后缀表达式转变成表达式树。我们一次一个符号地读入表达式,如果符号是操作数,那么就建立一个单节点树并将它推入栈中。如果符号是操作树,它的左、右儿子分别是T2和T1。然后将指向这棵树的指针压入栈中。

  设输入为:

                a b + c d e + * *

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

103024_WtcS_2537915.jpg

  接着,“-”被读入,因此指向两棵数的指针被弹出,形成一棵新的树,并将指向它的指针压入栈中。

103302_pk35_2537915.jpg

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

103453_M83u_2537915.jpg

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

103543_teBu_2537915.jpg

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

 

103804_ko37_2537915.jpg

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

103956_bOnd_2537915.jpg

 3.3 查找树ADT——二叉查找树

  二叉树的一个重要的应用是它们在查找中的应用。这里我们假设树中的每一个节点存储一项数据,都是互异的整数。

  使二叉树称为二叉查找树的性质是,对于树中的每个节点X,它的左子树所有项的值小于X中的项,而它的右子树中的所有项的值大于X中的项。例如,在下图3-11中,左边的树是二叉查找树,但右边的树却不是。

105035_kadA_2537915.jpg

图3-11 两棵二叉树(只有左边的树是查找树)

  下面给出二叉树的操作简要描述。由于树的递归定义,通常是递归地编写这些操作的例程。因为二叉查找树的平均深度是Ο(logN),所以不必担心栈空间被用完。下面是 BinarySearchTree类模板的接口(框架)。

template<typename Comparable>
class BinarySearchTree
{
  public:
    BinarySearchTree();
    BinarySearchTree( const BinarySearchTree & rhs );
    ~BinarySearchTree();
    
    const Comparable & findMin() const;
    const Comparable & findMax() const;
    bool contain( const Comparable & x ) const;
    bool isEmpty() const;
    void printTree() const;
    
    void makeEmpty();
    void insert( const Comparable & x );
    void remove( const Comparable & x );
    
    const BinarySearchTree & operator= ( const BinarySearchTree & rhs );
    
  private:
    struct BinaryNode
    {
      Comparable element;
      BinaryNode *left;
      BinaryNode *right;
      
      BinaryNode( const Comparable & theElement, BinaryNode *lt, BinaryNode *rt )
        : element( theElement ), left( lt ), right( rt )
    };
    
     BinaryNode *root;
     
     void insert( const Comparable & x, BinaryNode * & t  ) const;
     void remove( const Comparable & x, BinaryNode * & t  ) const;
     BinaryNode * findMin( BinaryNode *t ) const;
     BinaryNode * findMax( BinaryNode *t ) const;
     bool contains( const Comparable & x, BinaryNode *t ) const;
     void makeEmpty( BinaryNode * & t );
     void printTree( BinaryNode *t ) const;
     BinaryNode * clone( BinaryNode *t ) const; 
}

 

  3.3.1 contains

  如果在树T中有项为X的节点,那么contains就返回true,否则,若没有这样的节点,就返回false。若树为空就返回false。下面是公有成员函数调用私有递归成员函数的示例,

/**
 * Returns true if x is found in the tree
 */
 bool contains( const Comparable & x ) const
 {
   return contains( x, root );
 }
 
 /**
 * Returns true if x is found in the tree
 */
 void insert( const Comparable & * )
 {
   insert( x, root );
 }
 
 /**
 * Remove x from the tree.Nothing is done if x is not found
 */
 void remove( const Comparable & x )
 {
   remove( x, root );
 }

  下面是二叉查找树的contains递归操作。

 /**
 * Internal method to test if an item is in a subtree.
 * x is item to search for.
 * f is the node that roots the subtree.
 */
 bool contains( const Comparable & x, BinaryNode *t ) const
 {
   if( t==NULL )
     return false;
   else if( x < t->element )
     return contains( x, t->left );
   else if( t->element < x )
     return contains( x, t->right );
   else
     return true;  //Match
 }

 

  3.3.2 findMin和findMax

  这个方法分别返回指向树中包含最小元和最大元的节点的指针。为执行findMin,从根开始并只要有左儿子就向左进行,终止点就是最小元素。findMax方法除分支朝右儿子其余过程相同。下面是递归实现的findMin:

/**
 * Internal method to find the smallest item in a subtree t.
 * Return node containing the smallest item.
 */
 BinaryNode *findMin( BinaryNode *t ) const
 {
   if( t == NULL )
     return NULL;
   if( t->left == NULL )
     return t;
   return findMin( t->left );
 }

  下面是对二叉查找树findMax的非递归实现。

/**
 * Internal method to find the largest item in a subtree t.
 * Return node containing the largest item.
 */
  BinaryNode *findMax( BinaryNode *t ) const
  {
    if( t != NULL )
      while( t -> right != NULL )
        t = t->right;
    return t;
  }

  3.3.3 insert

  进行插入操作的方法在概念上很简单。为了将X插入到树T中,可以像使用contains那样沿着树查找。如果找到X,则什么也不做(或做一些“更新”)。否则将X插入到遍历的路径上的最后一点上。如图3-12在插入5以前和以后的二叉树。

151218_r3se_2537915.jpg

图3-12 在插入5以前和以后的二叉查找树

  下面是插入方法的实现代码。

/**
 * Internal method to insert into a subtree.
 * x is the item to insert.
 * t is the node that roots the subtree.
 * Set the new root of the subtree.
 */
  void insert( const Comparable & x, BinaryNode * & t )
  {
    if( t ==NULL )
      t =new BinaryNode( x, NULL, NULL );
    else if( x < t->element )
      insert( x, t->left );
    else if( t->element < x )
      insert( x, t->right );
    else
      ; //Dupilicate; do nothing 
  }

 

  3.3.4 remove

   同许多数据结构一样,最困难的操作是删除。一旦发现要被删除的节点,就需要考虑几种可能的情况。如果一个节点是一片树叶,那么它可以被立即删除。如果节点有一个儿子,则该节点可以在其父节点调整它的链以绕过该节点后被删除(为了清楚起见,我们将明确画出链的指向),见图3-13。

154601_Ljct_2537915.jpg

图3-13 具有一个儿子的节点4删除前后的情况

  复杂的情况是处理具有两个儿子的节点。一般的删除策略是用其右子树的最小的数据(很容易找到)代替该节点的数据并递归地删除那个节点(现在它是空的)。因为右子树中的最小的节点不可能有左儿子,所以第二次remove就很容易。图3-14显示了一棵初始的树及其中一个节点被删除后的结果。

155258_8d2L_2537915.jpg

图3-14 删除有两个儿子的节点2前后情况

  下面是一个删除的代码实现,但其效率不高,因为它沿该树进行两次搜索以查找和删除右子树中最小节点。通过编写一个特殊的removeMin方法可改善。

/**
 * Internal method to remove from a subtree.
 * x is the item to remove.
 * t is the node that roots the subtree.
 * Set the new root of the subtree.
 */
  void remove( const Comparable & x, BinaryNode * & t )
  {
    if( t == NULL )
      return; //Item not found;do nothing
    if( x < t->element )
      remove( x, t->left );
    else if( t->element < x )
      remove( x, t->right );
    else if( t->left != NULL && t->right != NULL ) //Two children
    {
      t->element = findMin( t->right )->element;
      remove( t->element, t->right );
    }
    else
    {
      BinaryNode *oldNode = t;
      t = ( t->left != NULL ) ? t->left : t->right;
      delete oldNode;
    }
  }

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

  3.3.5 析构函数和复制赋值操作符

  与往常一样,析构函数调用makeEmpty。公有的makeEmpty则简单的调用私有的递归版的makeEmpty。下面是析构函数和递归makeEmpty成员函数。

/**
 * Destructor for the tree
 */
 ~BinarySearchTree()
 {
   makeEmpty();
 }
 /**
 * Internal method to make subtree empty.
 */
 void makeEmpty( BinaryNode * & t )
 {
   if( t != NULL )
   {
     makeEmpty( t->left );
     makeEmpty( t->right );
     delete t;
   }
   t = NULL;
 }

  下面是operator和递归的clone成员函数。

 /**
 * Deep copy.
 */
 const BinarySearchTree & operator= ( const BinarySearchTree & rhs )
 {
   if( this != &rhs )
   {
     makeEmpty();
     root = clone( rhs.root );
   }
   return *this;
 }
 
 /**
 * Internal method to clone subtree .
 */
 BinaryNode * clone( BinaryNode *t ) const
 {
   if( t == NULL )
     return NULL;
     
   return new BinaryNode( t->element, clone( t->left ), clone( t->right ) );
 }

 

  3.3.6 平均情况分析

  如果所有的插入序列都使等可能的,那么,树的所有节点的平均深度为Ο(logN)。

  如果向一棵预先排序的树输入数据,那么,一连串insert操作将花费二次的时间。而链表实现的代价会非常巨大,因此此时的树将只由那些没有左儿子的节点组成。

  一种解决办法是要有一个称为平衡(balance)的附加结构条件:任何节点的深度均不得过深。

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

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

转载于:https://my.oschina.net/u/2537915/blog/638836

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值