C++优先队列
下一站不是永远 人气:0前言
- 高级数据结构(Ⅱ)优先队列(Priority Queue)
- API
- 堆的定义
- 二叉堆表示法
- 堆的算法
- 基于堆的优先队列
- 堆排序
高级数据结构(Ⅱ)优先队列(Priority Queue)
许多应用程序都需要处理有序的元素,但不一定要求它们全部有序,或是不一定要一次就将它们排序。很多情况下我们会收集一些元素,处理当前键值最大的元素,然后再收集更多的元素,再处理当前键值最大的元素,如此这般。例如,你可能有一台能够同时运行多个应用程序的电脑(或者手机)。这是通过为每个应用程序事件分配一个优先级,并总是处理下一个优先级最高的事件来实现的。例如,绝大多数手机分配给来电的优先级都会被游戏程序的高。
在这种情况下,一个合适的数据结构应该支持两种操作:删除最大元素和插入元素。这种数据类型叫做优先队列。优先队列的使用和队列(删除最老的元素)以及栈(删除最新的元素)类似,但高效地实现它则更有挑战性。
在本篇中,我们会学习一种基于二叉堆数据结构的一种优先队列的经典实现方法,用数组保存元素并按照一定条件排序,以实现高效地(对数级别的)删除最大元素和插入元素操作。
API
优先队列是一种抽象数据类型,它表示了一组值和对这些值的操作,它的抽象层使我们能够方便地将应用程序和各种具体实现隔离开来。
实现
public class MaxPQ <Key extends Comparable<Key>> ----------------------------------------------------------------------- MaxPQ() //创建一个优先队列 MaxPQ(int max) //创建一个初始容量为max的优先队列 MaxPQ(Key[] a) //用a中的元素创建一个优先队列 void insert(Key v) //向优先队列中插入一个元素 Key max() //返回最大元素 Key delMax() //删除并返回最大元素 boolean isEmpty() //返回队列是否为空 int size() //返回优先队列中元素个数
堆的定义
数据结构二叉堆能够很好地实现优先队列的基本操作。在二叉堆的数组中,每个元素都要保证大于等于另外两个特定位置的元素。相应地,这些位置的元素又至少要大于等于数组中的另外两个元素,以此类推。如果我们将所有的元素画成一颗二叉树,将每个较大元素和较小的元素用边连接就可以很容易地看出这种结构。
命题:当一颗二叉树的每个结点都大于等于它的另外两个子节点时,它被称为堆有序。
相应地,在堆有序的二叉树中,每个结点都小于等于它的父节点(如果有的话)。从任意结点向上,我们都能得到一列非递减的元素;从任意结点向下,我们都能得到一列非递增的元素。
命题:根节点是堆有序的二叉树中最大的结点
二叉堆表示法
如果我们用指针来表示堆有序的二叉树,那么每个元素都需要三个指针来找到它的上下结点(父节点和两个子节点)。如下图所示,如果我们使用完全二叉树,表达就会变的特别方便
可以先定下根节点,然后一层一层地由上向下、从左至右,在每个结点的下方连接两个更小的结点,直至将N个结点全部连接完毕。完全二叉树只用数组而不需要指针就可以表示。具体方法就是将二叉树的结点按照层级顺序放入数组中,根结点在位置1,它的子节点在位置2和位置3,而子节点的子节点分别在位置4、5、6和7,以此类推。
定义:二叉堆是一组能够用堆有序的完全二叉树排序的元素,并在数组中按照层级存储(不使用数组的第一个位置)
在一个堆中,位置k的结点的父节点的位置为[k/2]向下取整,而它的两个子节点的位置则分别为2k和2k+1。用数组(堆)实现的完全二叉树的结构是很严格的,但它的灵活性已经足以让我们高效地实现优先队列。用它们我们能将实现对数级别的插入元素和删除最大元素的操作。利用数组中无需指针即可沿树上下移动的便利和以下性质,算法保证了对数复杂度的性能。
命题:一颗大小为N的完全二叉树的高度为lg[N]
堆的算法
我们用长度为N+1的私有数组pq[]来表示一个大小为N的堆。我们不会使用pq[0],堆元素放在pq[1]至pq[N]中。在排序算法中,我们只通过私有辅助函数less()和exch()来访问元素,但因为所有的元素都在数组pq[]中,我们在下面基于堆的优先队列中会用更紧凑的实现方式,不再将数组作为参数传递。堆的操作会首先进行一些简单的改动,打破堆的状态,然后再遍历堆并按照要求将堆的状态恢复。我们称这个过程为堆的有序化(reheapifying)。
实现:
private boolean less(int i, int j) { return pq[i].compareTo(pq[j]) < 0; } private void exch(int i, int j) { Key t = pq[i]; pq[i] = pq[j]; pq[j] = t; }
在堆有序化的过程中我们会遇到两种情况。当某个结点的优先级上升(或是在堆底加入一个新的元素)时,我们需要由下至上恢复堆的顺序。当某个结点的优先级下降(例如,将根节点替换为一个较小的元素)时,我们需要由下至上恢复堆的顺序。首先来学习如何实现这两种辅助操作,然后再用它们实现插入元素和删除最大元素的操作。
由下至上的堆的有序化(上浮)
如果堆的有序状态因为某个结点变得比它的父节点更大而被打破,那么我们就需要通过交换它和它的父节点来修复堆。交换后,这个结点比它的两个子结点都大(一个是曾经的父节点,另一个比它更小,因为它是它曾经父节点的子节点),但这个结点仍然可能比它现在的父节点更大。我们可以一遍遍地用同样的方法恢复秩序,将这个结点不断向上移动直到我们遇到了一个更大的父节点。位置k的结点的父结点为[k/2],swim()方法中的循环可以保证只有位置k上的结点大于它的父节点时堆的有序状态才会被打破。因此只要该结点不再大于父结点,堆的有序状态就恢复了。当一个结点优先级太大的时候它需要浮(swim)到堆的更高层,详细代码如下
private void swim(int k) { while(k > 1 && less(k / 2, k)) { exch(k / 2, k); k = k / 2; } }
由上至下的堆的有序化(下沉)
如果堆的有序状态因为某个结点变得比它的两个子结点或是其中之一更小了而被打破了,那么我们可以通过将它和它的两个子结点中的较大者交换来恢复堆。交换可能会在子结点处继续打破堆的有序状态,因此我们都需要用相同的方式来将其修复,将结点向下移动直到它的子结点都比它更小或者是达到了堆的底部。由位置为k的结点的子结点为2k和2k+1可以直接得到对应的代码。方法名为了形象表示这个过程称为sink()下沉,即当一个结点优先级太低时它需要沉(sink)到堆的更低层,
码实现如下:
private void sink(int k) { while(2 * k <= N) { int j = 2 * k; if(j < N && less(j, j + 1)) j++; if(!less(k, j)) break; exch(k, j); k = j; } }
插入元素
我们将新元素插入到数组末尾,增加堆的大小并让这个元素上浮到合适的位置(如下图左半部分所示)
删除最大元素
我们从数组顶端删去最大的元素并将数组的最后一个元素放到顶端,减小堆的大小并让这个元素下沉到合适的位置(如下图右半部分所示)
基于堆的优先队列
优先队列由一个基于堆的完全二叉树表示,存储于数组pq[1..N]中,pq[0]没有使用,pq[0]的值有时可以作为哨兵来用。在insert()中,我们将N加1并把新元素添加在数组最后,然后用swim()恢复堆的秩序。在delMax()中,我们从pq[1]中得到需要返回的元素,然后将pq[N]移动到pq[1],将N减1并用sink()来恢复堆的秩序。同时我们还将不再使用的pq[N+1]设为null,以便系统回收它所占用的空间。此处省略动态调整数组大小的代码,有兴趣的朋友可以自己写写。
命题:对于一个含有N个元素的基于堆的优先队列,插入元素操作只需不超过(lgN+1)次比较,删除最大元素的操作需要不超过2lgN次比较
基于堆的优先队列详细代码如下:
public class MaxPQ<Key extends Comparable<Key>> { private Key[] pq; //基于堆的完全二叉树 private int N = 0; //存储于pa[1..N]中,pq[0]没有使用 public MaxPQ(int maxN) { pq = (Key[]) new Comparable[maxN + 1]; this.N = maxN; } public boolean isEmpty() { return N == 0; } public int size() { return N; } public void insert(Key v) { pq[++N] = v; swim(N); } public Key delMax(Key v) { Key max = pq[1]; //根节点得到最大元素 exch(1, N--); //将其和最后一个结点交换 pq[N + 1] = null; //防止对象游离 sink(1); //恢复堆的有序性 return max; } private boolean less(int i, int j) { return pq[i].compareTo(pq[j]) < 0; } private void exch(int i, int j) { Key t = pq[i]; pq[i] = pq[j]; pq[j] = t; } private void swim(int k) { while(k > 1 && less(k / 2, k)) { exch(k / 2, k); k = k / 2; } } private void sink(int k) { while(2 * k <= N) { int j = 2 * k; if(j < N && less(j, j + 1)) j++; if(!less(k, j)) break; exch(k, j); k = j; } } }
过程:
堆排序
堆排序可以分为两个阶段。在堆的构造阶段中,我们将原始数组重新组织安排进一个堆中;然后在下沉排序阶段,我们从堆中按递减顺序取出所有元素并得到排序结果。为了和我们学过的代码保持一致,我们将使用一个面向最大元素的优先队列并重复删除最大元素。我们不再使将优先队列的具体表示隐藏,并将直接使用swim()和sink()操作。这样我们在排序时就可以将需要排序的数组本身作为堆,因此无需任何额外空间。
注意:将对应下标减1可以得到与其他排序算法一样的排序结果。否则将是忽略下标0的排序算法
加载全部内容