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

五 优先队列(堆)

  成员拥有优先权的一种特殊队列称之为优先队列(priority queue)。本章中我们将讨论:

  • 优先队列ADT的高效实现。

  • 优先队列的使用。

  • 优先队列的高级实现。

  我们将看到的这类数据结构属于计算机科学中最雅致的一种。

 5.1 模型

  优先队列是至少允许下列两种操作的数据结构:insert(插入);deleteMin(删除最小项),它的工作是找出、返回和删除优先队列中最小的元素。insert操作等价于enqueue(入队),而deleteMin则是队列操作dequeue(出队)在优先队列中的等价操作。

  除了操作系统外,优先队列还有许多应用。优先队列如何用于外部排序。在贪心算法的实现方面优先队列也很重要,该算法通过反复求出最小元来进行计算。

164653_qsZX_2537915.jpg

图5-1 优先队列的基本模型

 

 5.2 一些简单的实现

  有几种明显的方法实现优先队列。我们可以使用一个简单链表在表头以Ο(1)执行插入操作,并遍历该链表以删除最小元,这又需要Ο(N)时间。另一种方法是,始终让表保持排序状态:这使得插入代价昂贵(Ο(N))而deleteMin花费低廉(Ο(1))。基于deleteMin的操作从不多于插入操作这一事实,因此前者恐怕是更好的想法。

  再一种实现优先队列的方法是使用二叉查找树,它对这两种操作的平均运行时间都使Ο(logN)。

 

 5.3 二叉堆

  我们项将要使用的这种工具叫做二叉堆(binary heap)。堆有两个性质,即结构性质和堆序性质。类似于AVL树,对堆的操作可能破坏其中一个性质。因此,堆的操作必须到堆的所有性质都被满足时才能终止。

 

  5.3.1 结构性质

  堆是一棵被完全填满的二叉树,可能的例外是在底层,底层上的元素从左到右填入。这样的树称为完全二叉树(complete binary tree)。图5-2是一个例子。

  一棵高为h的完全二叉树有2^k到2^(k+1)-1个节点。这意味着,完全二叉树的高是[logN],显然它是Ο(logN)。完全二叉树是很有规律的,所以可以用一个数组表示而不需要使用链。图5-3中的数组对应5-2中的堆。

092610_BOVc_2537915.jpg

图5-2 一棵完全二叉树

092658_VKkn_2537915.jpg

图5-3 完全二叉树的数组实现

  对于数组中任一位置i上的元素,其左儿子在位置2上,右儿子在左儿子后的单元(2i+1)中,它的父亲则在位置[i+2]上。这种实现的唯一问题是,最大的堆大小需要事先估计,但一般情况下这并不成问题(而且如果需要我们可以重新调整)。

  因此,一个堆数据结构将由一个(Comparable对象的)数组和一个代表当前堆大小的整数组成。下面是堆接口代码。

 template<typename Comparable>
 class BinaryHeaps
 {
   public:
     explicit BinaryHeap( int capacity = 100 );
     explicit BinartHeap( const vector<Comparable> & items );
     
     bool isEmpty() const;
     const Comparable & findMin() const;
     
     void insert( const Comparable & x );
     void deleteMin();
     void deleteMin( comparable & minItem );
     void makeEmpty();
     
   private:
   int currentSize; //Number of elements in heap
   vector<Comparable> array; //The heap array
   
   void buildHeap();
   void percolateDown( int hole );
 }

  本章始终把堆画成树,这意味着,具体的实现将使用简单的数组。

 

  5.3.2 堆序性质

  使操作可以快速执行的性质是堆序性质(heap-order property)。由于想要快速地找出最小元,因此最小元应该在根上。如果考虑任意子树页应该是堆,那么任意节点就应该小于它的所有后裔。

  应用这个逻辑,可以得到堆序性质。在堆中,对于每一个节点X,X的父亲中的键小于(或等于)X中的键,根节点除外(它没有父亲)。图5-4中左边的树是一个堆,而右边的树却不是(虚线表示堆序性被破坏)。

094659_ZNhU_2537915.jpg

图5-4 两棵完全树(只有左边的树是堆)

 

  5.3.3 基本的堆操作

   5.3.3.1 insert

  为将一个元素X插入堆中,我们在下一个空闲位置创建一个空穴,因为否则该堆将不是完全树。如果X可以放在空穴中而不破坏堆序,那么插入完成。否则,把空穴的父节点上的元素移入空穴,这样,空穴就朝着根的方向上行一步。继续该过程直到X能被放入空穴中为止。图5-5和图5-6,为了插入14的操作。

095533_w1DZ_2537915.jpg

图5-5 尝试插入14:创建一个空穴,再将空穴上冒

095628_XqMV_2537915.jpg

图5-6 将14插入到前面的堆中的最后两步

  这种一般的策略叫做上滤(percolate up):新元素在堆中上滤直到找出正确的位置。下面的代码很容易实现。

/**
 * Insert item x, allowing duplicate
 */
 void insert( const Comparable & x )
 {
   if( currentSize == array.size() - 1 )
     array.resize( array.size() * 2 );
     
   // Percolate up
   int hole = ++currentSize;
   for( ; hole > 1 && x < array[ hole / 2  ]; hole / 2  )
     array[ hole ] = array[ hole/ 2 ];
   array[ hole ] = x;
 }

  如果插入的元素是新的最小元从而一直上滤到根处,那么这种插入的时间为Ο(logN)

 

 

   5.3.3.2 deleteMin

  deleteMin以类似于插入的方式处理。找出最小元是容易的:困难的是删除它。当删除最小元时,要在根节点建立一个空穴。由于现在堆少了一个元素,因此堆中最后一个元素X必须移动到该堆的某个地方。如果X可以被放到空穴中,那么deleteMin完成。图5-7中左边的图是deleteMin前的堆。这种一般的策略叫做下滤(percolate down)。

101927_YJMq_2537915.jpg

图5-7 在根处建立空穴

102023_weu0_2537915.jpg

图5-8 在deleteMin中的接下来的两步

102131_M3vU_2537915.jpg

图5-9 在deleteMin中的最后两步

/**
 * Remove the minimun item.
 * Throws underflowException if empty
 */
 void deleteMin()
 {
   if( isEmpty() )
     throw UnderflowException();
     
   array[ 1 ] = array[ currentSize-- ];
   percolateDown( 1 );
 }
 
 /**
  * Remove the minimun item and place it in minItem.
  * Throws underflowException if empty
  */
 void deleteMin( Comparable & minItem )
 {
   if( isEmpty() )
     throw UnderflowException();
     
   minItem = array[ 1 ];
   array[ 1 ] = array[ currentSize--];
   percolateDown( 1 );
 }
 
 /**
  * Internal method to percolate down in the heap.
  * hole is the index at which the percolate begins.
  */
  void percolateDown( int hole )
  {
    int child;
    Comparable tmp = array[ hole ];
    
    for( ; hole * 2 <= currentSize; hole = child )
    {
      child = hole * 2;
      if( child != currentSize && array[ child + 1 ] < array[ child ] )
        child++;
      if( array[ child ] < tmp )
        array[ hole ] = array[ child ];
      else
        break;
    }
    array[ hole ] = tmp;
  }

  这种操作的最坏情形运行时间是Ο(logN)。平均而言,被放到根处的元素几乎下滤到堆的底层(即它来自的那层)。

  5.3.4 堆的其他操作

  虽然求最小值操作可以在常数时间完成,但是,按照求最小元设计的堆(也称做最小堆(min heap))在求最大元方面却毫无作用。一个堆所蕴涵的关于序的信息很少,因此,若不对整个堆进行线性搜索,是没有办法找出任何特定的元素的。如下图5-10所示的大型堆结构,关于最大元所知的唯一信息是:该元素在其中一片树叶上。

110226_NZEd_2537915.jpg

图5-10 一棵巨大的完全二叉树

  如果假设通过某种其他方法得知每一个元素的位置,那么其他几种操作的开销就很小。下述的前三种操作均以对数最坏情形时间运行。

 

   5.3.4.1 decreaseKey

  decreaseKey(p,△)操作减小在位置p处的元素的值,减小的幅度为正的量△。由于这可能破坏堆序性质,因此必须通过上滤操作对堆进行调整。该操作对系统管理程序是有用的:系统管理程序能够使它们的程序以最高的优先级来运行。

 

   5.3.4.2 increaseKey

  increaseKey(p,△)操作增加在位置p处的元素的值,增加的幅度为正的量,这可以用下滤来完成。许多调度程序自动地降低过多消耗CPU时间的进程的优先级。

 

   5.3.4.3 remove

  remove(p)操作删除堆中位置p上的节点。这通过首先执行decreaseKey(p,∞),然后再执行deleteMin()来完成。当一个进程由用户中止(而不是正常终止)时,必须将其从优先队列中除去。

 

   5.3.4.4 buildHeap

  有时候二叉堆通过项的原始集合来构造。这个构造函数将N项作为输入并把它们放入一个堆中。很明显,这可以通过N次连续的insert来完成。由于insert操作操作都花费Ο(1)的平均时间,以及Ο(logN)的最坏情形时间,这个算法的总的运行时间就是Ο(N)平均时间,其最坏情形时间为Ο(NlogN)。

  通常的算法是将N项以任意顺序放入树中,并保持结构特性。

explicit  BinaryTree( const vector<Comparable> & items )
 : array( items.size() + 10 ), currentSize( items.size() )
{
  for( int i = 0; i < items.size(); i++ )
    array[ i + 1 ] = items[ i ];
  buildHeap();
}

/**
 * Establish heap order property from an arbitrary
 */
 void buildHeap()
 {
    for( int i = currentSize / 2; i > 0; i-- )
      percolateDown( i );
 }

  图5-11中的一棵树是无序树。从图5-12到5-15中其余7棵树表示出7个percolateDown中每一个的执行结果。每条虚线对应两次比较:一次是找出较小的儿子节点,另一次是较小的儿子与该节点比较。

113431_pqeb_2537915.jpg

图5-11 左——初始堆; 右——在percolateDown(7)之后

113550_faFQ_2537915.jpg

图5-12 左——percolateDown(6)之后; 右——在percolateDown(5)之后

113722_rxEQ_2537915.jpg

图5-13 左——percolateDown(4)之后; 右——在percolateDown(3)之后

113810_0z72_2537915.jpg

图5-14 左——percolateDown(2)之后; 右——在percolateDown(1)之后

  定理5.1 包含2^(h+1)-1个节点且高为h理想二叉树(perferct binary tree)的节点的高度和为2^(k+1)-1-(h+1)。

 

 5.4 优先队列的应用

  5.4.1 选择问题

  要考察的第一个问题是选择问题(selection problem)。输入是N个元素以及一个整数k,这N个元素的集可以是全续集。该选择问题是要找出第k个最大的元素。

   5.4.1.1 算法6A

  为了简单起见,假设我们只考虑找出第k个最小元素。先将N个元素读入数组,然后对该数组应用buildHeap算法。最后,执行k次deleteMin操作。最后从该堆中提取的元素就是正确解。只要改变堆序的性质,就可以求解原始的问题:找出第k个最大的元素。

  这个算法的正确性是明显的。如果使用buildHeap,构造堆的最坏情形是Ο(N)时间,而每次执行deleteMin是Ο(logN)时间。由于有k次deleteMin,因此我们得到总的运行时间为Ο(N+klogN)。如果k=Ο(N/logN),那么运行时间取决于buildHeap操作,即Ο(N)。对于大的k值,运行时间为Ο(klogN)。如果k=[N/2],那么运行时间则为Θ(NlogN)。

 

   5.4.1.2 算法6B

  第二种方法在任意时刻都将维持k个最大元素的集合S。设S中最小元素为Sk,每次读入一个元素都将与Sk比较。如果新元素大于Sk,那么就用新元素代替S中的Sk。这里我们用一个堆来实现S。通过调用buildHeap将前k个元素以总时间Ο(k)放入堆中。处理其余每个元素的时间为Ο(1),还要加上Ο(logk)时间,用来检测元素是否放入S,并在需要时删除Sk并插入新元素。因此总的时间是Ο(k+(N-k)logk)=Ο(Nlogk)。该算法也可以给出中位数时间界Θ(NlogN)

 

  5.4.2 事件模拟

  设有一个系统,比如银行,顾客们到达并排队等待,直到在总计k个出纳员中有一个出纳员有空。顾客的到达情况由概率分布函数控制,服务时间(当出纳员有空时,用于服务的时间量)也是如此。我们关心的是平均一位顾客要等多久或所排的队伍可能有多长这类统计问题。

  模拟由处理中的时间组成。这里的两个事件是:(a)一位顾客的到达;(b)一位顾客的离去,从而腾出一名出纳员。

  我们可以使用概率函数来生成一个输出流,它由每位顾客的到达时间和服务时间的序偶组成,并以到达时间排序。我们使用一份单位时间量,称为一个滴答(tick)。

  进行这种模拟的一个方法是在0滴答处启动一台模拟钟表。让钟表一次走一个滴答,同时查看是否有事件发生。如果有,那么处理这个(或这些)事件,搜集统计资料。当没有顾客留在输入流中且所有的出纳员都闲着的时候,模拟结束。

  这种模拟策略的问题是,其运行时间不依赖于顾客数或事件数(每位顾客有两个事件),却依赖于滴答数。而后者实际并不是输入的一部分。下面来看为什么这很重要:假设将钟表的单位改成毫滴答(millitick)并将输入中的所有时间乘以1000,则结果将是:模拟时间长1000倍!

  避免这种问题的关键是在每个阶段让钟表直接走到下一个事件时间。从概念上看这个容易做到。在任意时刻,可能出现的下一事件是:(a)输入文件中的下一位顾客到达; (b)在一名出纳员处一位顾客离开。由于可以得知事件发生的所有时间,因此只需找出最近要发生的事情并处理这个事件即可。

  如果事件是离开,那么处理过程包括搜集离开的顾客的统计资料以及检验队伍(队列),看是否还有其他顾客在等待。如果有,那么加上这位顾客,处理所需的统计资料。计算顾客将要离开的时间,并将离开事件加到等待发生的事件集中去。

  如果事件是到达,那么检查是否有空闲的出纳员。若没有,就把这一到达事件放到队伍(队列)中去;否则,分配给顾客一个出纳员,计算顾客的离开时间,并将离开事件加到等待发生的事件集中去。

  在等待的顾客队伍可以实现为一个队列。由于需要找到最近将要发生的事件,因此合适的办法是将等待发生的离开事件的集合编入一个优先队列中。下一事件是下一个到达或下一个离开(哪个先发生那么就是下一个事件)。

  虽然可能很耗时,但编写这样一个模拟方法还是很简单的。如果有C个顾客(因此有2C个事件)和k个出纳员,那么模拟的运行时间将是Ο(Clog(k+1)),因为计算和处理每个事件花费Ο(logH),其中H=k+1,为堆的大小。

 5.5 d堆

  d堆是二叉堆的简单推广,它与二叉堆很像,但其所有的节点都有d个儿子(因此,二叉堆是2堆)。

  图5-15所示为一个3堆。d堆要比二叉堆浅得多,它将insert操作的运行时间改进为Ο(logdN)。然而对于大的d值,deleteMin操作就费时得多。因为虽然树浅了,但是必须要找出d个儿子中最小的一个。在优先队列太大不能完全装入内存的时候,d堆也是很有用的。这种情况下,d堆可以与B树大致相同的方式发挥作用。最后,有证据显示,在实践中4堆可以胜任二叉堆。

165313_rqax_2537915.jpg

图5-15 一个d堆

  除了不能执行find操作外,堆的实现的最明显的缺点是:将两个堆合二为一是很困难的操作。这个附加操作称为合并(merge)。有许多实现堆的方法可以使一次merge操作的运行时间是Ο(logN)。

 

 5.6 左式堆

  左式堆(leftist heap)像二叉树那样既有结构性质,又有堆序性质。左式堆也是二叉树,它和二叉堆唯一的区别是:左式堆不是理想平衡的(perfectly balanced),而事实上是趋于非常不平衡的。

 

  5.6.1 左式堆性质

  我们把任一节点X的零路径长(null path length)npl(X)定义为从X到一个不具有两个儿子的节点的最短路径的长。因此,具有0个或1个儿子的节点的npl为0,而npl(NULL)=-1。在图5-16的树中,零路径常标记在树的节点内。

232713_sbBo_2537915.jpg

图5-16 两棵树的零路径长;只有左边的树是左式的

  任一节点的零路径比它的诸儿子节点的零路径长的最小值多1。这个结论页使用于两个儿子的节点,因为null的零路径长是-1。

  左式堆性质是:对于堆中的每一个节点X,左儿子的零路径长至少与右儿子的零路径长一样大。这个性质实际上超出了它确保树不平衡的要求,因为它显然偏重于树向增加深度。确定有可能存在由左节点形成的长路径路径构成的树(而且实际上更便于合并操作)——因此,称为左式堆(leftist heap)。

  定理5.2 左右路径上有r个节点的左式树必然至少有2^r-1个节点。

 

  5.6.2 左式堆操作

  对左式堆的基本操作是合并。插入只是合并的特殊情况,因为可以把插入看成是单节点堆与一个大的堆的merge。首先,给出一个简单的递归解法。输入两个左式堆H1和H2,见图5-17。最小的元素在根处。除数据、左指针和右指针所用空间外,每个单元还要有一个指示零路径长的项。

091211_aXs3_2537915.jpg

图5-17 两个左式堆H1和H2

  如果两个堆中有一个是空的,那么可以返回另一个堆。否则为何并这两个堆。需要比较它们的根。首先递归地将具有大的根值与具有小的根值的堆的右子堆合并。本例中,递归地将H2和H1中根在8处的右子堆合并。

091741_eL4A_2537915.jpg

图5-18 将将H2和H1的右子堆合并的结果

  现在我们让这个新的堆称为H1的根的右儿子。

092237_cEKj_2537915.jpg

图5-19 将前面图中的左式堆作为H1的右儿子接上后的结果

  虽然最后得到的堆满足堆序性质,但不是左式堆。调整做法是:只要交换根的左儿子和右儿子并更新零路径长,就完成merge,新的零路径长是新的右儿子的零路径长加1。

094136_S375_2537915.jpg

图5-20 交换H1的根的儿子得到的结果

  该算法描述的代码实现。

template<typename Comparable>
class LeftistHeap
{
  public:
    LeftistHeap();
    LeftistHeap( const LeftistHeap & rhs );
    ~LeatistHeap();
    bool isEmpty() const;
    const Comparable & findMin() const;
    
    void insert( const Comparable & x );
    void deleteMin();
    void deleteMin( Comparable & minItem );
    void makeEmpty();
    void merge( LeftistHeap & rhs );
    
    const LeftistHeap & operator=( const LeftistHeap & rhs );
    
  private:
    struct LeftistNode
    {
      Comparable element;
      LeftistNode *left;
      LeftistNode *right;
      int npl;
      
      LeftistNode( const Comparable & theElement, LeftistNode *lt = NULL, LeftistNode *rt = NULL, 
      int np = 0 )
        : element( theElement ), left( lt ), right( rt ), npl( np ) { }
    };
    
    LeftistNode * root;
    
    LeftistNode * merge( LeftistNode *h1, LeftistNode *h2 );
    LeftistNode * merge1( LeftistNode *h1, LeftistNode *h2 );
    
    void swapChildren( LeftistNode *t );
    void reclaimMemory( LeftistNode *t );
    LeftistNode *clone( LeftistNode *t ) const;
};

  两个merge方法设计成消除一些特殊情形并保证H1有较小根的驱动程序。实际的合并操作在merge1中进行。公有的merge方法将rhs合并到控制堆中,rhs变成了空的。

/*
 * Merge rhs into the priority queue
 * rhs becomes empty.rhs must be different from this.
 */
 void merge( LeftistHeap & rhs )
 {
   if( this = &rhs ) // Avoid aliasing problems
     return;
     
   root = merge( root, rhs.root );
   rhs.root = NULL;
 }


 

 

 

 

 

 

 
 

 

 

 

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值