of {$slidecount} ½ {$title} ATZJG.NET {$author}

首页






优先队列(堆)
  • 二叉堆
  • 优先队列的应用
  • d 堆
  • 左式堆
  • 斜堆
  • 二项队列
  • 标准库中的优先队列


Haifeng Xu


(hfxu@yzu.edu.cn)

This slide is based on the book of Mark Allen Weiss
Data Structures and Algorithm Analysis in C++
张怀勇等译.

目录

现实需求

现实需求

打印机的作业一般都放在队列中.

在多用户环境中, 操作系统调度程序必须决定在若干进程中运行那个进程.

为实现这类特殊的应用, 我们需要一种称之为 优先队列(priority quene) 的数据结构.

优先队列还被用于排序中. 在贪心算法(greedy algorithm)的实现方面优先队列也很重要.

模型

模型

优先队列是至少允许下列两种操作的数据结构:

deleteMin 的工作是找出、返回和删除优先队列中最小的元素.

C++ 提供两种版本的 deleteMin. 一个是删除最小项, 另一个在删除最小项的同时在通过引用传递的对象中存储所删掉的值.

其他操作是扩展的操作.

一些简单的实现

一些简单的实现

  1. 使用一个简单链表在表头以 O(1) 执行插入操作, 并遍历该链表以删除最小元, 这需要 O(N) 的时间.
  2. 始终让表保持排序状态, 这使得插入代价高昂(O(N)), 而 deleteMin 花费低廉(O(1)).
  3. 使用二叉查找树, 它对这两种操作(插入、删除)的平均运行时间都是 $O(\log N)$. (尽管插入是随机的, 删除却不是. 但这个结论是成立的.)

基于 deleteMin 的操作从不多于插入操作 这一事实, 因此第一种实现要比第二种好.

对于第三种实现, 由于删除操作删除的是最小元, 反复除去左子树中的结点似乎损害了树的平衡, 使得右子树加重.

然而右子树是随机的. 在最坏情形, 即 deleteMin 将左子树删空的情形下, 右子树拥有的元素最多也就是它应具有的两倍. 这只是在其期望的深度上加了一个小常数.

注意, 通过使用平衡树, 可以把界变成最坏情形的界; 这将防止出现不好的插入序列.

使用查找树也有缺点, 查找树有大量并不需要的操作.

我们将要使用的数据结构

我们将要使用的基本数据结构不需要链, 它以最坏情形时间 $O(\log N)$ 支持上述两种操作.

插入实际上将花费常数平均时间, 若无删除干扰, 该结构的实现将以线性时间建立一个具有 $N$ 项的优先队列.

我们还将讨论如何实现优先队列以支持有效的合并, 这个附加的操作需要使用链接的结构.

二叉堆

二叉堆(binary heap)

这里我们将二叉堆简称堆(heap).

与二叉查找树一样, 堆也有两个性质, 即 结构性质堆序性质.

类似于 AVL 树, 对堆的操作可能破坏其中一个性质, 因此, 堆的操作必须到堆的所有性质都被满足时才能终止.

结构性质

堆是一棵完全填满的二叉树, 可能的例外是在底层, 底层上的元素从左到右填入. 这样的二叉树我们称为完全二叉树(complete binary tree).

Claim. 一棵高为 $h$ 的完全二叉树有 $2^h$$2^{h+1}-1$ 个结点.

作为 Exercise, 可使用归纳法证明.

通过观察, 发现完全二叉树可以使用数组实现而不必使用链表, 原因在于

Claim. (1) 对于数组中任一位置 $i$ 上的元素, 其左儿子在位置 $2i$ 上, 右儿子在位置 $2i+1$ 上.
(2) 结点 $i$ 的父亲位于 $[i/2]$.

因此, 这里不需要链, 而且遍历该树所需要的操作也极简单.

这种实现方法的唯一问题在于, 堆的大小事先需要估计. 但一般情况下这并不成问题. 而且如果需要, 我们可以重新调整.

因此, 一个堆数据结构由一个(Comparable 对象的)数组和一个代表当前堆大小的整数组成.

优先队列的类接口

template <typename Comparable>
class BinaryHeap
{
  public:
    explicit BinaryHeap( int capacity = 100 );
    explicit BinaryHeap( const vector & 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 );
};

堆序性质

定义. 使操作可以快速执行的性质叫做 堆序性质(heap-order property).

由于想要快速找出最小元, 因此最小元应该在根上. 因此 findMin 操作是常数时间 O(1).

如果考虑任意子树也应该是堆, 那么任意结点就应该小于它的所有后裔.

基本的堆操作

二叉堆的插入

    /**
     * Insert item x, allowing duplicates.
     */
    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;
    }

二叉堆的 deleteMin 操作

二叉堆的 deleteMin 操作

    /**
     * Remove the minimum item.
     * Throws UnderflowException if empty.
     */
    void deleteMin( )
    {
        if( isEmpty( ) )
            throw UnderflowException( );
    
        array[ 1 ] = array[ currentSize-- ];
        percolateDown( 1 );
    }
    
    /**
     * Remove the minimum 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;
    }

堆的其他操作

堆的其他操作

buildHeap

    explicit BinaryHeap( const vector & 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
     * arrangement of items. Runs in linear time.
     */
    void buildHeap( )
    {
        for( int i = currentSize / 2; i > 0; i-- )
            percolateDown( i );
    }

注意这里的 buildHeap() 对于 percolateDown 是递减做的. 请实验一下, 如果 i 递增会怎样? (经过循环后可能仍然不是一个堆.)

虽然求最小值操作可以在常数时间完成, 但是, 按照求最小元设计的堆(也称最小堆)在求最大元方面却无任何作用.

事实上, 一个堆所蕴涵的关于序的信息很少, 因此, 若不对整个堆进行线性搜索, 是没有办法找出任何特定的元素的.

对于最小堆, 最大元必定出现在叶子上, 不过叶子的数目是总的结点数的一半以上.

Exercise, 计算 buildHeap() 的时间复杂度.


decreaseKey

increaseKey

remove


一些结论

定义. 包含 $2^{h+1}-1$ 个结点的二叉树称为理想二叉树(perfect binary tree) , 显然高为 $h$.

定理. 高为 $h$ 的理想二叉树(perfect binary tree), 其结点的高度之和为 $2^{h+1}-1-(h+1)$.

证明. 该树由高度 $h$ 上的 1 个结点、高度 $h-1$ 上的 2 个结点、高度 $h-2$ 上的 $2^2$ 个结点以及一般的, 高度 $h-i$ 上的 $2^i$ 个结点组成. 因此, 所有结点的高度和为 \[ \begin{split} S&=\sum_{i=0}^{h}2^i(h-i)\\ &=h+2(h-1)+2^2(h-2)+2^3(h-3)+\cdots+2^{h-1}\cdot 1. \end{split} \] 两边乘以 2 得 \[ 2S=\quad+2h+2^2(h-1)+2^3(h-2)+2^4(h-3)+\cdots+2^{h}\cdot 1. \] 将第二个方程减去第一个方程, 得 \[ \begin{split} S&=-h+2+2^2+2^3+2^4+\cdots 2^{h-1}+2^h\\ &=(2^{h+1}-1)-(h+1). \end{split} \]

优先队列的应用

优先队列的应用

选择问题

设有 $N$ 个数 $\{a_1,a_2,\ldots, a_N\}$, 确定其中第 $k$ 大的数.

注意第 1 大的数也就是最大元.

算法 1.A

算法 1.B

算法 6.A

构造堆的最坏情形是 $O(N)$ 时间, 每次执行 deleteMin 是 $O(\log N)$ 时间. 由于执行了 $k$ 次 deleteMin 操作, 因此我们得到总的运行时间为 $O(N+k\log N)$. 如果 $k=O(\frac{N}{\log N})$, 那么运行时间为 $O(N)$, 相当于取决于 buildHeap 操作. 对于大的 $k$, 运行时间为 $O(k\log N)$. 如果 $k=[N/2]$, 则运行时间为 $\Theta(N\log N)$.

特别的, 令 $k=N$, 运行该程序并在元素离开堆时记录下这些元素的值, 那么实际上就是对输入文件以时间 $O(N\log N)$ 进行了排序. 在第七章, 我们将细化该想法, 并得到一种快速的排序算法, 称为 堆排序(heapsort).

算法 6.B

我们使用算法 1.B 的思路. 在任一时刻都将维持 $k$ 个元素的集合 $S$. 不过这里使用一个堆来实现 $S$.

因此, 总的运行时间是 $O(k+(N-k)\log k)=O(N\log k)$. 该算法对于找出中位数, 也给出了时间界 $O(N\log N)$.

事件模拟

d 堆

d 堆

$d$ 堆是二叉堆的推广, 它的所有结点都有 $d$ 个儿子.

$d$ 堆要比二叉堆浅得多, 它将 insert 操作的运行时间改进为 $O(\log_d N)$.

但是对于大的 $d$, deleteMin 操作就费时得多. 虽然树浅了, 但是必须要找出 $d$ 个儿子中最小的一个.

现在找出儿子和父亲的乘法和除法都有个因子 $d$, 因此除非 $d$ 是 $2$ 的幂, 否则将会由于不能通过二进制移位来实现除法而导致运行时间的急剧增加.

$d$ 堆在理论上很有趣, 因为存在许多算法, 其插入次数比 deleteMin 的次数多很多, 因此理论上的加速是可能的.

当优先队列太大不能完全装入主存时, $d$ 堆也是很有用的. 在这种情况下, $d$ 堆可以与 $B$ 树大致相同的方式发挥作用.

有证据显示, 在实践中, $4$ 堆可以胜过二叉堆.

堆的合并

堆的合并

除了不能执行 find 操作外, 堆的最明显的缺点是:将两个堆合而为一是很困难的操作.

我们将这个操作称为 合并(merge).

有许多实现堆的方法可以是一次 merge 操作的运行时间是 $O(\log N)$.

下面讨论三种复杂程度不一的数据结构, 它们都有效地支持 merge 操作.

所有支持有效合并的高级数据结构都需要使用链式数据结构. 实践中, 这可能使得其他操作变慢.

左式堆(Leftist Heap)

左式堆(Leftist Heap)

左式堆(也叫左式树 leftist tree, 左偏树)首先是一个二叉树, 它与二叉堆的惟一区别是: 左式堆不是理想平衡的(perfectly balanced), 而且事实上是趋于非常不平衡的.

左式堆与二叉堆一样既有结构性质, 又有堆序性质.

Def: 任一结点 X零路径长(Null Path Length) npl(X)定义为它到后继结点(当该结点只有一个儿子或没有儿子时需要包括自身)中不具有两个儿子的结点的路径长的最小值.

Exer: 给出一个二叉树, 请填写每个结点的零路径长.

左式堆的性质

对于堆中每一个结点 X, 其左儿子的零路径长至少与右儿子的零路径长一样大. \[ \text{npl}(leftchild)\geq\text{npl}(rightchild) \]

Claim: 沿左式堆右侧的右路径是该堆中最短的路径.

否则, 就会存在一条更短的路径通过某个结点 $X$ 并取得左儿子, 此时 $X$ 就破坏了左式堆的性质.

Thm: 在右路径上有 $r$ 个结点的左式树(leftist tree)必然至少有 $2^r-1$ 个结点.

证明: (使用归纳法证明) 若 $r=1$, 则必然至少有一个树结点. 结论成立.

若 $r=2$, 即右路径上有两个结点, 根结点和它的右儿子. 由于是左式堆, 根结点左儿子的零路径长要大于等于右儿子的零路径长, 故必有左儿子. 从而总的结点数至少为 $3=2^2-1$.

设定理对 $k=1,2,\ldots,r$ 个结点成立. 考虑在右路径上有 $k+1$ 个结点的左式树. 于是这棵树的根结点的右子树的右侧有 $k$ 个结点. 根据假设此子树至少有 $2^k-1$ 个结点.

记根结点的右儿子为 $N_{R1}$, 根结点的左儿子为 $N_{L1}$. 则 $\text{npl}(N_{R1})=k$. 因此 $N_{L1}$ 的零路径长也至少为 $k$. 由于是左式树, 因此 $N_{L1}$ 的右路径至少也有 $k$ 个结点(包括 $N_{L1}$). 根据归纳假设, 根结点的左子树也至少有 $2^k-1$ 个结点. 从而总的结点数至少有 \[ 1+(2^k-1)+(2^k-1)=2^{k+1}-1. \]

Q.E.D.

从这个定理可以得到, $N$ 个结点的左式树有一条右路径最多含有 $\lfloor\log(N+1)\rfloor$ 个结点.

对左式堆操作的思路

将所有的工作放到右路径上进行, 它保证路径不会太深.

惟一要注意的是, 对右路径的 insertmerge 可能会破坏左式堆的性质.

左式堆的操作

左式堆的操作

左式堆的基本操作是合并.

插入只是合并的特殊情形, 因为可以把插入看成是单结点堆与一个大的堆的 merge.

首先我们给出一个简单的递归解法, 然后介绍如何非递归地施行该解法.

左式堆的类型声明

左式堆的类型声明

template <typename Comparable>
class LeftistHeap
{
  public:
    LeftistHeap( );
    LeftistHeap( const LeftistHeap & rhs );
    ~LeftistHeap( );

    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 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;
    }
    
    /**
     * Internal method to merge two roots.
     * Deals with deviant cases and calls recursive merge1.
     */
    LeftistNode * merge( LeftistNode *h1, LeftistNode *h2 )
    {
        if( h1 == NULL )
            return h2;
        if( h2 == NULL )
            return h1;
        if( h1->element < h2->element )
            return merge1( h1, h2 );
        else
            return merge1( h2, h1 );
    }

合并左式堆的实际例程

    /**
     * Internal method to merge two roots.
     * Assumes trees are not empty, and h1's root contains smallest item.
     */
    LeftistNode * merge1( LeftistNode *h1, LeftistNode *h2 )
    {
        if( h1->left == NULL )   // Single node
            h1->left = h2;       // Other fields in h1 already accurate
        else
        {
            h1->right = merge( h1->right, h2 );
            if( h1->left->npl < h1->right->npl )
                swapChildren( h1 );
            h1->npl = h1->right->npl + 1;
        }
        return h1;
    }

左式堆的插入例程

左式堆的插入例程

    /**
     * Inserts x; duplicates allowed.
     */
    void insert( const Comparable & x )
    {
        root = merge( new LeftistNode( x ), root ); 
    }

左式堆的 deleteMin 例程

左式堆的 deleteMin 例程

    /**
     * Remove the minimum item.
     * Throws UnderflowException if empty.
     */
    void deleteMin( )
    {
        if( isEmpty( ) )
            throw UnderflowException( );
    
        LeftistNode *oldRoot = root;
        root = merge( root->left, root->right );
        delete oldRoot;
    }
    /**
     * Remove the minimum item and place it in minItem.
     * Throws UnderflowException if empty.
     */
    void deleteMin( Comparable & minItem )
    {
        minItem = findMin( );
        deleteMin( );
    }

左式堆的其他例程

左式堆的其他例程

/**
     * Find the smallest item in the priority queue.
     * Return the smallest item, or throw Underflow if empty.
     */
    const Comparable & findMin( ) const
    {
        if( isEmpty( ) )
            throw UnderflowException( );
        return root->element;
    }
    /**
     * Returns true if empty, false otherwise.
     */
    bool isEmpty( ) const
      { return root == NULL; }
    /**
     * Make the priority queue logically empty.
     */
    void makeEmpty( )
    {
        reclaimMemory( root );
        root = NULL;
    }
    /**
     * Internal method to make the tree empty.
     * WARNING: This is prone to running out of stack space;
     *          exercises suggest a solution.
     */
    void reclaimMemory( LeftistNode *t )
    {
        if( t != NULL )
        {
            reclaimMemory( t->left );
            reclaimMemory( t->right );
            delete t;
        }
    }
    /**
     * Swaps t's two children.
     */
    void swapChildren( LeftistNode *t )
    {
        LeftistNode *tmp = t->left;
        t->left = t->right;
        t->right = tmp;
    }
    const LeftistHeap & operator=( const LeftistHeap & rhs )
    {
        if( this != &rhs )
        {
            makeEmpty( );
            root = clone( rhs.root );
        }
        return *this;
    }
    /**
     * Internal method to clone subtree.
     * WARNING: This is prone to running out of stack space.
     *          exercises suggest a solution.
     */
    LeftistNode * clone( LeftistNode *t ) const
    {
        if( t == NULL )
            return NULL;
        else
            return new LeftistNode( t->element, clone( t->left ), clone( t->right ), t->npl );
    }

斜堆

斜堆(skew heap)

斜堆是是左式堆的自调节形式. 斜堆和左式堆的关系类似于伸展树与 AVL 树之间的关系.

斜堆是具有堆序的二叉树, 但是不存在对树的结构限制.

与左式堆不同, 斜堆不保存任意结点的零路径长.

斜堆的右路径在任何时刻都可以任意长. 因此, 所有操作的最坏情形运行时间均为 $O(N)$.

与左式堆相同, 斜堆的基本操作也是合并操作. merge 例程也是递归的. 我们执行与以前完全相同的操作, 但有一个例外, 即: 对于左式堆, 我们查看是否左儿子和右儿子满足左式堆的结构性质, 并在不满足该性质时将它们交换. 但对于斜堆, 交换是无条件的, 除右路径上所有结点的最大者不交换它的左右儿子外, 我们都要进行这种交换.

这是因为不断递归调用, 为保证上层结点(每一次的 root 结点)的值最小, 所以右路径中的最大结点必定位于右路径的最后一个. 它一定没有右儿子, 所以不必交换.

二项队列

二项队列

虽然左式堆和斜堆都以每次操作花费 $O(\log N)$ 时间有效地支持合并、插入和 deleteMin, 但是还是有改进的余地.

因为, 二叉堆以每次操作花费常数时间支持插入.

如果要插入的元素是新的最小元从而一直上滤到根处, 那么这种插入的时间为 $O(\log N)$. 平均来看, 这种上滤终止得要早.

已经证明: 执行一次插入平均需要 2.607 次比较. 因此 insert 将元素平均上移 1.607 层. 所以差不多是 $O(1)$ 时间.

二项队列支持所有这三种操作(合并、插入、deleteMin), 每次操作的最坏情形运行时间为 $O(\log N)$, 而插入操作花费常数时间.

二项队列的结构

二项队列的结构

二项队列(binomial queue) 是指具有堆序的若干树的集合. 树的集合称为森林(forest).

这里每一棵树都要求是二项树(binomial tree)

这里的二项树森林, 要求同一高度的二项树只能有一棵.

因此, 二项树 $B_k$ 由一个带有儿子 $B_0, B_1,\ldots,B_{k-1}$ 的根组成.

性质(Exercise)

二项树的堆序

二项树的堆序

如果我们把堆序施加到二项树上并允许任意高度上最多一棵二项树, 那么我们能够用二项树的集合唯一地表示任意大小的优先队列.

例如, 大小为 13 的优先队列可以用森林 $B_3,B_2,B_0$ 表示. 我们将这种表示写成 $1101$, 它不仅以二进制表示了 13, 而且也表示这样的事实: 在上述表示中, $B_3,B_2,B_0$ 出现, 而 $B_1$ 没有出现.

作为例子, 6 个元素的优先队列可以表示为如下形状.

二项队列的操作

二项队列的操作

找到最小元

最小元可以通过搜索所有树的树根来找出. 由于最多有 $\log N$ 棵不同的树, 因此最小元可以以 $O(\log N)$ 时间找到.

另外, 如果我们记住当最小元在其他操作期间变化时更新它, 那么也可保留最小元的信息并以 $O(1)$ 时间执行该操作.

二项队列的合并

二项队列的合并

我们通过例子来说明如何合并.



合并的原理

时间分析

由于几乎使用任意合理的实现方法合并两棵二项树均花费常数时间, 而总共存在 $O(\log N)$ 棵二项树, 因此合并在最坏情形下花费时间为 $O(\log N)$.

为使该操作更有效, 我们需要将这些树放到按照高度排序的二项队列中.

插入实际上是特殊情形的合并, 只要创建一棵单结点树并执行一次合并即可. 这种操作的最坏情形运行时间也是 $O(\log N)$.

更准确地说, 如果元素将要插入的那个优先队列中不存在的最小的二项树是 $B_i$, 那么运行时间与 $i+1$ 成正比.

例子

例子

我们依次插入 $1$ 到 $7$ 来构成一个二项队列.








deleteMin

deleteMin

deleteMin 可以通过首先找出一棵具有最小根的二项树来完成.

算法

令该树为 $B_k$, 并令原始的优先队列为 $H$.

例子


注意 $B_3$ 中含有最小元, 因此删除 $B_3$, 得到新的二项队列 $H'=\{B_0,B_2\}$.


将 $B_3$ 的根删除, 得到二项队列 $H''$.


将两个二项队列 $H'$ 和 $H''$ 合并.


时间分析

deleteMin 操作将原二项队列一分为二. 找出含有最小元的二项树并建立队列 $H'$ 和 $H''$ 花费 $O(\log N)$ 时间. 合并这两个队列又花费 $O(\log N)$ 时间. 因此, 整个 deleteMin 操作花费 $O(\log N)$ 时间.

二项队列的实现

二项队列的实现

deleteMin 操作需要快速找出根的所有子树的能力, 因此, 需要一般树的标准表示方法: 每个结点的儿子都在一个链表中, 而且每个结点都有一个指向它的第一个儿子(如果有的话)的指针.

该操作还要求诸儿子按照它们的子树的大小排序.

我们还需要保证很容易合并两棵树. 当两棵树被合并时, 其中一棵树作为儿子加到另一棵树上. 由于这棵新树将是最大的子树, 因此, 以大小递减的方式保持这些子树是有意义的. 只有这时我们才能够有效地合并两棵二项树从而合并两个二项队列. 二项队列是二项树的数组.

总之, 二项树的每一个结点将包含数据、第一个儿子以及右兄弟.

二项树中的儿子以递减次序排列.

二项队列的实现

二项队列的实现

template <typename Comparable>
class BinomialQueue
{
  public:
    BinomialQueue( );
    BinomialQueue( const Comparable & item );
    BinomialQueue( const BinomialQueue & rhs );
    ~BinomialQueue( );

    bool isEmpty( ) const;
    const Comparable & findMin( ) const;

    void insert( const Comparable & x );
    void deleteMin( );
    void deleteMin( Comparable & minItem );

    void makeEmpty( );
    void merge( BinomialQueue & rhs );

    const BinomialQueue & operator= ( const BinomialQueue & rhs );

  private:
    struct BinomialNode
    {
        Comparable    element;
        BinomialNode *leftChild;
        BinomialNode *nextSibling;

        BinomialNode( const Comparable & theElement, 
                        BinomialNode *lt, BinomialNode *rt )
          : element( theElement ), leftChild( lt ), nextSibling( rt ) { }
    };

    enum { DEFAULT_TREES = 1 };

    int currentSize;                  // Number of items in priority queue
    vector<BinomialNode *> theTrees;  // An array of tree roots

    int findMinIndex( ) const;
    int capacity( ) const;
    BinomialNode * combineTrees( BinomialNode *t1, BinomialNode *t2 );
    void makeEmpty( BinomialNode * & t );
    BinomialNode * clone( BinomialNode *t ) const;
};

合并两棵同样大小的二项树的例程

合并两棵同样大小的二项树的例程

    /**
     * Return the result of merging equal-sized t1 and t2.
     */
    BinomialNode * combineTrees( BinomialNode *t1, BinomialNode *t2 )
    {
        if( t2->element < t1->element )
		{
            return combineTrees( t2, t1 );//在真正执行合并两棵二项树时, 总是令第一个参数 t1 指向的 element 之小于等于第二个参数 t2 所指向的 element 值.
		}
		//于是按合并的规则, t2 这棵树将接到 t1, 即 t2 指向的结点作为 t1 指向结点的儿子.
		//具体的,
        t2->nextSibling = t1->leftChild;// t1的leftChild(脱离t1)成为t2的nextSibling.
        t1->leftChild = t2;//t2成为t1的leftChild.
        return t1;
    }

注意这里树结构的存储与往常不同, 每个结点将其最右边的儿子当作第一个儿子, 然后从右向左找 nextSibling.

合并两个优先队列的例程

合并两个优先队列的例程

    /**
     * Merge rhs into the priority queue.
     * rhs becomes empty. rhs must be different from this.
     */
    void merge( BinomialQueue & rhs )
    {
        if( this == &rhs )    // Avoid aliasing problems
            return;
    
        currentSize += rhs.currentSize;
    
        if( currentSize > capacity( ) )
        {
            int oldNumTrees = theTrees.size( );
            int newNumTrees = max( theTrees.size( ), rhs.theTrees.size( ) ) + 1;
            theTrees.resize( newNumTrees );
            for( int i = oldNumTrees; i < newNumTrees; i++ )
                theTrees[ i ] = NULL;
        }
    
        BinomialNode *carry = NULL;
        for( int i = 0, j = 1; j <= currentSize; i++, j *= 2 )
        {
            //思考 j *= 2 的作用是什么?(Exercise)
            
            BinomialNode *t1 = theTrees[ i ];
            
            //判断 i 是否超过了将要合并的二项队列rhs中二项树的个数, 如果是, 则令结点为 NULL.
            BinomialNode *t2 = i < rhs.theTrees.size( ) ? rhs.theTrees[ i ] 
                                                        : NULL;
            //事实上, t1 指向当前二项队列中的二项树, t2 指向 rhs 中的二项树.
            
            int whichCase = t1 == NULL ? 0 : 1;
            whichCase += t2 == NULL ? 0 : 2;
            whichCase += carry == NULL ? 0 : 4;
            /*
            carry  t2   t1
              x_1  x_2  x_3
            -----------------
            where x_i=0 or 1, there are 8 cases.
            */
    
            switch( whichCase )
            {
              case 0: /* No trees */
              case 1: /* Only this */
                break;
              case 2: /* Only rhs */
                theTrees[ i ] = t2;
                rhs.theTrees[ i ] = NULL;
                break;
              case 4: /* Only carry */
                theTrees[ i ] = carry;
                carry = NULL;
                break;
              case 3: /* this and rhs */
              //carry 是由高度相同的两棵树进行合并而来.
                carry = combineTrees( t1, t2 );
                theTrees[ i ] = rhs.theTrees[ i ] = NULL;//注意此步清空 this 和 rhs 这两棵树,因为已经合并.
                break;
              case 5: /* this and carry */
                carry = combineTrees( t1, carry );
                theTrees[ i ] = NULL;
                break;
              case 6: /* rhs and carry */
                carry = combineTrees( t2, carry );
                rhs.theTrees[ i ] = NULL;
                break;
              case 7: /* All three */
                theTrees[ i ] = carry;
                carry = combineTrees( t1, t2 );
                rhs.theTrees[ i ] = NULL;
                break;
            }
        }
        //将 rhs 这个二项队列清空.
        for( int k = 0; k < rhs.theTrees.size( ); k++ )
            rhs.theTrees[ k ] = NULL;
        rhs.currentSize = 0;
    }

这里循环中每次执行 j *= 2, 是控制循环的次数. 例如, 若 this 所指的二项队列的结点数是 $M_1$, 要合并的二项队列 rhs 中的结点数为 $M_2$. 则程序一开始将它们相加存储到 currentSize 中. 即 $\mathrm{currentSize}=M_1+M_2$. 于是循环的次数为 $[\log(M_1+M_2)]$.

j *= 2 可以改为 j<<1.

二项队列的 deleteMin

二项队列的 deleteMin

    /**
     * Remove the minimum item and place it in minItem.
     * Throws UnderflowException if empty.
     */
    void deleteMin( Comparable & minItem )
    {
        if( isEmpty( ) )
            throw UnderflowException( );
    
        int minIndex = findMinIndex( );
        minItem = theTrees[ minIndex ]->element;
    
        BinomialNode *oldRoot = theTrees[ minIndex ];
        BinomialNode *deletedTree = oldRoot->leftChild;
        delete oldRoot;//删除最小元
    
        //找到最小元后, 将这个最小元所在的二项树的根结点删除, 得到新的二项队列 H''
        // Construct H''
        BinomialQueue deletedQueue;
        deletedQueue.theTrees.resize( minIndex + 1 );// 若最小元在 B_k, 则其儿子数为 k 个
        deletedQueue.currentSize = ( 1 << minIndex ) - 1;// 若 minIndex=k, 则 (1 << minIndex) 返回 2^k
        //构造二项队列, 将 B_k 的各个儿子作为二项树保存到向量(队列)中.
        for( int j = minIndex - 1; j >= 0; j-- )
        {
            deletedQueue.theTrees[ j ] = deletedTree;//先保存第一个左儿子
            deletedTree = deletedTree->nextSibling;// 左儿子下一个兄弟作为左儿子的角色
            deletedQueue.theTrees[ j ]->nextSibling = NULL;// 割断原左儿子与它下一个兄弟之间的联系.
        }
    
        // Construct H'
        // 构造 H' 的过程很简单, 即删除最小元所在的二项树 B_k
        theTrees[ minIndex ] = NULL;//删除 B_k
        currentSize -= deletedQueue.currentSize + 1;// 不要忘了改变 currentSize 的大小.
    
        merge( deletedQueue );// 最后将 H' 和 H'' 合并. this 指向 H', 而 deletedQueue 指向 H''
    }
    
    /**
     * Find index of tree containing the smallest item in the priority queue.
     * The priority queue must not be empty.
     * Return the index of tree containing the smallest item.
     */
    int findMinIndex( ) const
    {
        int i;
        int minIndex;
        
        // 跳过前面的各个空树. 
        for( i = 0; theTrees[ i ] == NULL; i++ )
            ;
    
        for( minIndex = i; i < theTrees.size( ); i++ )
            if( theTrees[ i ] != NULL &&
                theTrees[ i ]->element < theTrees[ minIndex ]->element )
                minIndex = i;
    
        return minIndex;
    }

标准库中的优先队列

标准库中的优先队列

在 STL 中, 二叉堆是通过称为 priority_queue 的类模板实现的. 该类模板可以在标准头文件 queue 中找到.

STL 实现的是最大堆(max-heap)而不是最小堆(min-heap), 于是所访问的项都是最大项而非最小项.

主要成员函数有

void push( const Object & x );// 将 x 添加到优先队列中
const Object & top( ) const; // 返回优先队列中的最大项
void pop( ); // 删除优先队列中的最大项. (由于允许重复, 所以如果有若干最大项, 则只删除其中一个.)
bool empty( );
void clear( );

STL 的 priority_queue 例程

#include <iostream>
#include <vector>
#include <queue>
#include <functional>
#include <string>
using namespace std;

// Empty the priority queue and print its contents.
template <typename PriorityQueue>
void dumpContents( const string & msg, PriorityQueue & pq )
{
    cout << msg << ":" << endl;
    while( !pq.empty( ) )
    {
        cout << pq.top( ) << endl;
        pq.pop( );
    }
}

// Do some inserts and removes (done in dumpContents).
int main( )
{
    //默认得到最大堆
    priority_queue<int>                           maxPQ;

    //使用 greater 函数对象作为比较器可以得到最小堆.
    priority_queue<int,vector<int>,greater<int> > minPQ;

    minPQ.push( 4 ); minPQ.push( 3 ); minPQ.push( 5 );
    maxPQ.push( 4 ); maxPQ.push( 3 ); maxPQ.push( 5 );
	
    dumpContents( "minPQ", minPQ );     // 3 4 5
    dumpContents( "maxPQ", maxPQ );     // 5 4 3

    return 0;
}

小结

小结

我们已经看到优先队列的各种实现方法和用途.

标准的二叉堆实现简单、速度快,很雅致. 并且不需要链表, 只需要常量的附加空间, 且有效地支持优先队列的操作.

我们考虑了附加的 merge 操作, 开发了三种实现方法, 每种都有其独到之处.

左式堆是展现递归之功能的完美实例.

斜堆则代表缺少平衡原则的一种重要的数据结构.

二项队列表现出, 一个简单的想法如何用来达到好的时间界.

End






Thanks very much!

This slide is based on the book: Data Structures and Algorithm Analysis in C++.