5.6 优先队列
许多应用程序都需要处理有序的元素,但不一定要求它们全部有序, 或是不一定要一次就将它们排序。很多情况下我们会收集一些元素, 处理当前键值最大的元素,然后再收集更多的元素,再处理当前键值最大的元素,如此这般。例如,你可能有一台能够同时运行多个应用程序的电脑(或者手机)。这是通过为每个应用程序的事件分配一个优先级, 并总是处理下一个优先级 最高的事件来实现的。例如,绝大多数手机分配给来电的优先级都会比游戏程序的高。
在这种情况下,类型叫做优先队列。优先队列的使用和队列(删除最老的元素)以及栈(删除最新的元素)类似,但高效地实现它则更有挑战性。
我们先简单地讨论优先队列的基本表现形式(都能在线性时间内完成)之后,我们会学习基于二叉堆数据结构的一种优先队列的经典实现方法定条件排序,以实现高效地(对数级别的)删除最大元素和插入元素操作。
优先队列的一些重要的应用场景包括模拟系统,其中事件的键即为发生的时间,而系统需要按照时间顺序处理所有事件:任务调度,其中键值对应的优先级决定了应该首先执行哪些任务:数值计算,键值代表计算错误,而我们需要按照键值指定的顺序来修正它们。个具体的例子,展示优先队列在粒子碰撞模拟中的应用。
通过插入一列元素然后个个地删掉其中最小的元素,我们可以用优先队列实现排序算法。一种名为堆排序的重要排序算法也来自于基于堆的优先队列的实现。
5.6.1 API
优先队列是种抽象数据类型 ,它表示了一组值和对这些值的操作,它的抽象层使我们能够方便地将应用程序(用例)和我们将在本节中学习的各种具体实现隔离开来。我们会详细定义一组应用程序编程接口 (API)来为数据结构的用例提供足够的信息。 优先队列最重要的操作就是删除最大元素和插入元素,所以我们会把精力集中在它们身上。删除最大元素的方法名为deMax(),插人元素的方法名为insert()。按照惯例,我们只会通过辅助函数less()来比较两个元素,和排序算法一样。 如果允许重复元素,最大表示的是所有最大元素之一。 为了将API定义完整,我们还需要加入构造函数(和我们在栈以及队列中使用的类似)和一个空队列测试方法。为了保证灵活性,我们在实现中使用了泛型,将实现了Comparable接口的数据的类型作为参数Key。这使得我们可以不必再区别元素和元素的键,对数据类型和算法的描述也将更加清断和简洁。例如,我们将用“最大元素"代替“最大键值”或是“键值最大的元素”
泛型优先队列的 API
public class MaxPQ<Key extends Comparable | |
---|---|
MaxPQ() | 创建一个优先队列 |
MaxPQ(int max) | 创建一个最大容量为max的优先队列 |
public class MaxPQ<key extends Comparable | |
---|---|
MaxPQ(Key[] a) | 用a[]中的元素创建个优先队列 |
void Insert(Key v) | 向优先队列中插入一个元素 |
Key max() | 返回最大元索 |
Key delMax() | 删除并返回最大元素 |
boolean isEmpty() | 返回队列是否为空 |
int size() | 返回优先队列中的元素个数 |
为了用例代码的方便,API包含的三个构造函数使得用例可以构造指定大小的优先队列(还可以用给定的一个数组将其初始化)。为了使用例代码更加清晰,我们会在适当的地方使用另一个类MinPQ。它和MaxPQ类似,只是含有一一个delMin()方法来删除并返回队列中键值最小的那个元素。MaxPQ的任意实现都能很容易地转化为MinPQ的实现,反之亦然,只需要改变一下less()比较的方向即可。
优先队列的调用示例
为了展示优先队列的抽象模型的价值,考虑以下问题:输入N个字符串,每个字符串都对映着一个整数,你的任务就是从中找出最大的(或是最小的) M个整数(及其关联的字符串)。这些输入可能是金融事务,你需要从中找出最大的那些;或是农产品中的杀虫剂含量,这时你需要从中找出最小的那些;或是服务请求、科学实验的结果,或是其他应用。在某些应用场景中,输入量可能非常巨大,甚至可以认为输人是无限的。解决这个问题的一种方法是将输入排序然后从中找M个最大的元素,但我们已经说明输人将会非常庞大。另一种方法是将每个新的输入和已知的M个最大元素比较,但除非M较小,否则这种比较的代价会非常高昂。只要我们能够高效地实现insert()和delMin(),下面的优先队列用例中调用了MinPQ的TopM就能使用优先队列解决这个问题,这就是本节中我们的目标。在现代基础性计算环境中超大的输入N非常常见,这些实现使我们能够解决以前缺乏足够资源去解决的问题,如下表所示。
从N个输入中找到最大的M个元素所需成本
示例 | 时间 | 空间 |
---|---|---|
排序算法的用例 | MogN | M |
调用初级实现的优先队列 | NM | M |
调用基于堆实现的优先队列 | NlogM | M |
一个优先队列的用例
public class TopM{public static void main(String[] args){//打印输入流最大的M行int M = Integer. parseInt(args[0]);MinPQ<Transaction> pq = new MinPQ<Transaction>(M+1);while (StdIn.hasNextLine(){//为下一行输入创建一个元素并放入优先队列中pq.insert(new Transaction(StdIn.readLine()));if (pq.size() > M){pq. delMin();//如果优先队列中存在M+1个元素则删除其中最小的元素}//最大的M个元素都在优先队列中Stack<Transaction> stack = new Stack<Transaction>();while(!pq.isEmpty()) stack.push(pq. delMin);for(Transaction t : stack ) System.out.println(t);}}}
从命令行输入一个整数M以及一系列字符申, 每一行表示一个事务。这段代码调用了MimPQ并会打印数字最大的M行。它用到了Transaction类,构造了一个用数字作为键的优先队列。当优先队列的大小超过M时就删掉其中最小的元素。所有事务输人完毕之后程序会从优先队列中按递减顺序打印出最大的M个事务。这段代码相当于将所有事务放入一个栈,遍历栈以颠倒它们的顺序并按照增序将它们打印出来。
5.6.2 初级实现
4种基础数据结构是实现优先队列的起点。我们可以使用有序或无序的数组或链表。在队列较小时,大量使用两种主要操作之一时, 或是所操作元素的顺序已知时,它们十分有用。
5.6.2.1 数组实现(无序)
或许实现优先队列的最简单方法就是基于2.1节中下压栈的代码。insert()方法的代码和栈的push()方法完全一样。要实现删除最大元素,我们可以添加一段类似于选择排序的内循环的代码,将最大元素和边界元素交换然后删除它,和我们对栈的pop()方法的实现一样。 和栈类似,我们也可以加人调整数组大小的代码来保证数据结构中至少含有四分之一的元素而又永远不会溢出。
5.6.2.2 数组实现 (有序)
另一种方法就是在insert()方法中添加代码,将所有较大的元素向右边移动-格以使数组保持有序(和插入排序一样)。这样,最大的元素总会在数组的一边,优先队列的删除最大元素操作就和栈的pop()操作一样了。
5.6.2.3 链表表示法
和刚才类似,我们可以用基于链表的下压栈的代码作为基础,而后可以选择修改pop()来找到并返回最大元素,或是修改push()来保证所有元素为逆序并用pop()来刪除并返回链表的首元素(也就是最大的元素)。
使用无序序列是解决这个问题的情性方法,我们仅在必要的时候才会采取行动(找出最大元素)使用有序序列则是解决问题的积极方法,因为我们会尽可能未雨绸缪(在插人元素时就保持列表有序),使后续操作更高效。
实现栈或是队列与实现优先队列的最大不同在于对性能的要求。对于栈和队列,我们的实现能够在常数时间内完成所有操作;而对于优先队列,我们刚刚讨论过的所有初级实现中,插入元素和删除最大元素这两个操作之一在最坏情况下需 要线性时间来完成(如下表所示)。我们接下来要讨论的基于数据结构堆的实现能够保证这两种操作都能更快地执行。
优先队列的各种实现在 最坏情况下运行时间的增长数量级
数据结构 | 插入元素 | 删除最大元素 |
---|---|---|
有序数组 | N | 1 |
无序数组 | 1 | N |
堆 | logN | logN |
理想情况 | 1 | 1 |