许多应用程序都需要处理有序的元素,但不一定要求它们全部有序,或是不一定要一次就将它们排序。很多情况下我们会收集一些元素,处理当前键值最大的元素,然后再收集更多的元素,再处理当前键值最大的元素,如此这般。
在这种情况下,一个合适的数据结构应该支持两种操作:删除最大元素和插入元素。这种数据类型叫做优先队列。
优先队列的一些重要的应用场景包括模拟系统,其中事件的键即为发生的时间,而系统需要按照时间顺序处理所有事件;任务调度,其中键值对应的优先级决定了应该首先执行哪些任务;数值计算,键值代表计算错误,而我们需要按照键值指定的顺序来修正它们。
通过插入一列元素然后一个个地删掉其中最小的元素,我们可以用优先队列实现排序算法。
泛型优先队列的API
public class MaxPQ<Key extends Comparable<Key>>
API | 功能 |
---|---|
MaxPQ() | 创建一个优先队列 |
MaxPQ(int max) | 创建一个初始容量为max的优先队列 |
MaxPQ(Key[] a) | 用a[]中的元素创建一个优先队列 |
void insert(Key v) | 向优先队列中插入一个元素 |
Key max() | 返回最大元素 |
Key delMax() | 删除并返回最大元素 |
boolean isEmpty() | 返回队列是否为空 |
int size() | 返回优先队列中的元素数量 |
为了使用用例代码更加清晰,我们会在适当的地方使用另一个类MinPQ。它和MaxPQ类似,只是含有一个delMin()方法来删除并返回队列中键值最小的那个元素。只需改变一下less()比较的方法即可。
优先队列的调用实例
为了展示优先队列的抽象模型的价值,考虑以下问题:输入N个字符串,每个字符串都对应着一个整数,你的任务就是从中找出最大的(或是最小的)M个整数(及其关联的字符串)。
解决这个问题的一种方法是将输入排序然后从中找出M个最大的元素,但我们已经说明输入将会非常庞大。
另一种方法是将每个新的输入和已知的M个最大元素比较,但除非M较小,否则这种比较的代价会非常高昂。
从N个输入中找到最大的M个元素所需成本:
示例 | 时间 | 空间 |
---|---|---|
排序算法的用例 | NlogN | N |
调用初级实现的优先队列 | NM | M |
调用基于堆实现的优先队列 | NlogM | M |
初级实现
数组实现(无序)
要实现删除最大元素,我们可以添加一段类似于选择排序的内循环的代码,将最大元素和边界元素交换然后删除它。
Transaction类:
package section2_1.priorityqueue;
public class Transaction implements Comparable<Transaction> {
private String who;
private double amount;
public Transaction(String transaction) {
String[] s = transaction.split(" ");
who = s[0];
amount = Double.parseDouble(s[1]);
}
@Override
public int compareTo(Transaction o) {
if (this.amount < o.amount) return -1;
else if (this.amount == o.amount) return 0;
else return 1;
}
@Override
public String toString() {
return "Transaction{" +
"who='" + who + '\'' +
", amount=" + amount +
'}';
}
}
MinPQ类:
package section2_1.priorityqueue;
public class MinPQ<key extends Comparable<key>> {
private key[] array;
private int idx;
public MinPQ(int max) {
array = (key[]) new Comparable[max];
idx = 0;
}
public void insert(key v) {
array[idx++] = v;
}
public key delMin() {
int min = 0;
for (int i=1;i < idx;i++) {
if (array[i].compareTo(array[min]) < 0) min = i;
}
idx--;
key temp = array[min];
array[min] = array[idx];
array[idx] = temp;
return temp;
}
public boolean isEmpty() {
return idx == 0;
}
int size() {
return idx;
}
}
TopM类:
package section2_1.priorityqueue;
public class TopM {
public static void main(String[] args) {
int M = 5;
Transaction[] transactions = new Transaction[]{
new Transaction("Turing 644.08"),
new Transaction("vonNeumann 4121.85"),
new Transaction("Dijkstra 2678.40"),
new Transaction("vonNeumann 4409.74"),
new Transaction("Dijkstra 837.42"),
new Transaction("Hoare 3229.27"),
new Transaction("vonNeumann 4732.35"),
new Transaction("Hoare 4381.21"),
new Transaction("Turing 66.10"),
new Transaction("Thompson 4747.08"),
new Transaction("Turing 2156.86"),
new Transaction("Hoare 1025.70"),
new Transaction("vonNeumann 2520.97"),
new Transaction("Dijkstra 708.95"),
new Transaction("Turing 3532.36"),
new Transaction("Hoare 4050.20"),
};
MinPQ<Transaction> pq = new MinPQ<>(M+1);
int idx = 0;
while (idx < transactions.length) {
pq.insert(transactions[idx]);
if (pq.size() > M) {
pq.delMin();
}
idx++;
}
while (!pq.isEmpty()) {
System.out.println(pq.delMin());
}
}
}
另外,还有数组实现(有序) 和 链表表示法。
使用无序序列是解决这个问题的惰性方法,我们仅在必要的时候才会采取行动(找出最大的元素);使用有序序列则是解决问题的积极方法,因为我们会尽可能未雨绸缪(在插入元素时就保持列表有序),使后续操作更高效。
优先队列的各种实现在最坏情况下运行时间的增长数量级:
数据结构 | 插入元素 | 删除最大元素 |
---|---|---|
有序数组 | N | 1 |
无序数组 | 1 | N |
堆 | logN | logN |
理想情况 | 1 | 1 |
堆的定义
数据结构二叉堆能够很好地实现优先队列的基本操作。在二叉堆的数组中,每个元素都要保证大于等于另两个特定位置的元素。
当一棵二叉树的每个结点都大于等于它的两个子结点时,它被称为堆有序。
相应地,在堆有序的二叉树中,每个结点都小于等于它的父结点(如果有的话)。从任意结点向上,我们都能得到一列非递减的元素;从任意结点向下,我们都能得到一列非递增的元素。
根结点是堆有序的二叉树中的最大结点。
二叉堆表示法
完全二叉树只用数组而不需要指针就可以表示。具体方法就是将二叉树的结点按照层级顺序放入数组中,根结点在位置1,它的子结点在位置2和3,而子结点的子结点则分别在位置4,5,6,7,以此类推。
二叉堆是一组能够用堆有序的完全二叉树排序的元素,并在数组中按照层级存储(不使用数组的第一个位置)。
在一个堆中,位置k的结点的父结点的位置为k/2,而它的两个子结点的位置则分别为2k和2k+1。这样在不使用指针的情况下,我们也可以通过计算数组的索引在树中上下移动:a[k]向上一层就令k等于k/2,向下一层则令k等于2k或2k+1。
用数组(堆)实现的完全二叉树的结构是很严格的,但它的灵活性已经足以让我们高效地实现优先队列。用它们我们将能实现对数级别的插入元素和删除最大元素的操作。
一棵大小为N的完全二叉树的高度为lgN。
堆的算法
堆的操作会首先进行一些简单的改动,打破堆的状态,然后再遍历堆并按照要求将堆的状态恢复。我们称这个过程叫做堆的有序化。
在有序化的过程中我们会遇到两种情况。当某个结点的优先级上升(或是在堆底加入一个新的元素)时,我们需要由下至上恢复堆的顺序。当某个结点的优先级下降(例如,将根节点替换为一个较小的元素)时,我们需要由上至下恢复堆的顺序。
由下至上的堆有序化(上浮)
如果堆的有序状态因为某个结点变得比它的父结点更大而被打破,那么我们就需要通过交换它和它的父结点来修复堆。将这个结点不断向上移动直到我们遇到了一个更大的父结点。
由上至下的堆有序化(下沉)
如果堆的有序状态因为某个结点变得比它的两个子结点或是其中之一更小了而被打破了,那么我们可以通过将它和它的两个子结点中的较大者交换来恢复堆。交换可能会在子结点处继续打破堆的有序状态,因此我们需要不断地用相同的方式将其修复,将结点向下移动直到它的子结点都比它更小或是到达了堆的底部。
插入元素。我们将新元素加到数组末尾,增加堆的大小并让这个新元素上浮到合适的位置。
删除最大元素。我们从数组顶端删去最大的元素并将数组的最后一个元素放到顶端,减小堆的大小并让这个元素下沉到合适的位置。
基于堆的优先队列
package section2_1.priorityqueue;
public class MaxPQ<Key extends Comparable<Key>> {
private Key[] pq;
private int N = 0;
public MaxPQ(int max) {
pq = (Key[]) new Comparable[max+1];
}
private boolean less(int i, int j) {
return pq[i].compareTo(pq[j]) < 0;
}
private void exch(int i, int j) {
Key temp = pq[i];
pq[i] = pq[j];
pq[j] = temp;
}
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;
}
}
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 max = pq[1];
exch(1,N--);
pq[N+1] = null;
sink(1);
return max;
}
public void show() {
for (int i=1;i<=size();i++) {
System.out.print(pq[i]+" ");
}
System.out.println();
}
public static void main(String[] args) {
MaxPQ<String> mpq = new MaxPQ<>(10);
mpq.insert("P");
mpq.show();
mpq.insert("Q");
mpq.show();
mpq.insert("E");
mpq.show();
mpq.delMax();
mpq.show();
mpq.insert("X");
mpq.show();
mpq.insert("A");
mpq.show();
mpq.insert("M");
mpq.show();
mpq.delMax();
mpq.show();
mpq.insert("P");
mpq.show();
mpq.insert("L");
mpq.show();
mpq.insert("E");
mpq.show();
mpq.delMax();
mpq.show();
}
}