优先队列
一、优先队列
许多应用程序都需要处理有序的元素,但又不一定要求全部有序,或不要求一次就排序完成。
在这种情况下,一个合适的数据结构应该支持两种操作:删除最大元素和插入元素。这就是优先队列。
API:
优先队列最重要的操作就是删除最大元素和插入元素。
二、基础实现
1、数组实现(有序)
public class OrderedArrayMaxPQ<Key extends Comparable<Key>> { private Key[] pq; // elements private int n; // number of elements // set inititial size of heap to hold size elements public OrderedArrayMaxPQ(int capacity) { pq = (Key[]) (new Comparable[capacity]); n = 0; } public boolean isEmpty() { return n == 0; } public int size() { return n; } public Key delMax() { return pq[--n]; } public void insert(Key key) { int i = n-1; while (i >= 0 && less(key, pq[i])) { pq[i+1] = pq[i]; i--; } pq[i+1] = key; n++; } /*************************************************************************** * Helper functions. ***************************************************************************/ private boolean less(Key v, Key w) { return v.compareTo(w) < 0; } /*************************************************************************** * Test routine. ***************************************************************************/ public static void main(String[] args) { OrderedArrayMaxPQ<String> pq = new OrderedArrayMaxPQ<String>(10); pq.insert("this"); pq.insert("is"); pq.insert("a"); pq.insert("test"); while (!pq.isEmpty()) StdOut.println(pq.delMax()); } }
在insert方法中,将所有较大的元素向右边移动一格以使数组保持有序(和插入排序一样),这样最大的元素总会在数组的右边。
优先队列的删除最大元素就和栈的pop操作一样了。
2、数组实现(无序)
public class UnorderedArrayMaxPQ<Key extends Comparable<Key>> { private Key[] pq; // elements private int n; // number of elements // set inititial size of heap to hold size elements public UnorderedArrayMaxPQ(int capacity) { pq = (Key[]) new Comparable[capacity]; n = 0; } public boolean isEmpty() { return n == 0; } public int size() { return n; } public void insert(Key x) { pq[n++] = x; } public Key delMax() { int max = 0; for (int i = 1; i < n; i++) if (less(max, i)) max = i; exch(max, n-1); return pq[--n]; } /*************************************************************************** * Helper functions. ***************************************************************************/ private boolean less(int i, int j) { return pq[i].compareTo(pq[j]) < 0; } private void exch(int i, int j) { Key swap = pq[i]; pq[i] = pq[j]; pq[j] = swap; } /*************************************************************************** * Test routine. ***************************************************************************/ public static void main(String[] args) { UnorderedArrayMaxPQ<String> pq = new UnorderedArrayMaxPQ<String>(10); pq.insert("this"); pq.insert("is"); pq.insert("a"); pq.insert("test"); while (!pq.isEmpty()) StdOut.println(pq.delMax()); } }
insert方法和栈的push方法一样。
要实现删除最大元素,我们在内部循环添加类似于选择排序的代码,将最大元素和最右边的元素交换,然后删除(和pop方法一样)。
注:和栈类似,我们可以添加调整数组大小的代码来保证数据结构中至少有四分之一的元素而又永远不会溢出。
3、链表表示
和数组实现方法类似,以栈代码为基础。
要么修改pop方法来查找并返回最大元素,也就是无序链表表示。
要么修改push方法,来保证最大元素在开头,并用pop方法来删除并返回最大元素(也就是首元素),这是有序链表表示。
总结:
使用无序序列是懒惰方法,仅在必要的时候采取行动(找最大值)。
使用有序序列是积极方法,会提前做尽可能的工作,使后续操作更高效。
栈、队列和优先队列的性能比较:
栈和队列都能够在常数时间内完成所有操作。
优先队列则至少有一个关键操作(插入元素和删除最大元素)在最坏情况需要线性时间完成。
三、堆定义
1、二叉堆(binary heap)
二叉堆是完全二叉树或者近似完全二叉树。
二叉堆满足两个特性:
(1)父节点键值总是大于或等于(小于或等于)任一个子节点的键值。(堆有序)
(2)每个节点的左子树和右子树都是一个二叉堆(都是最大堆或者最小堆)。
推理1:如果一个二叉树的每个节点的键值大于或等于(小于或等于)子节点的键值,那么这个二叉树就是堆有序的。
推理2:在堆有序的二叉树中,根节点的键值是最大的(最小的)。
2、二叉堆实现
如果使用链表来表示二叉堆,那么每个节点都需要三个指针,分别来指向父节点和两个子节点。
不过,完全二叉树可以只用数组不用指针就可以表示,具体方法就是将二叉树的节点按照层级顺序放入数组中。
根节点在位置1,子节点在位置2和3,而子节点的子节点在位置4,5,6,和7,以此类推。即在一个二叉堆中,位置为k的节点,其父节点在k/2,其子节点在2 * k和2 * k + 1(根节点在1处)。
用数组实现的完全二叉树(堆),可以高效的实现优先队列。可以实现对数级别的插入元素和删除最大元素操作。
性质:大小为N的完全二叉树的高度为floor(lgN)。
完全二叉树定义:若设二叉树的深度为h,除第 h 层外,其它各层 (1~h-1) 的结点数都达到最大个数,第 h 层所有的结点都连续集中在最左边,这就是完全二叉树。
四、堆实现优先队列
1、由下至上的堆有序化(上浮)
如果堆的有序状态因为某个节点比父节点更大被打破,那么就需要交换这个节点和它的父节点来修复堆。
交换后,这个节点比两个子节点都大,但这个子节点有可能比父节点还要大,因此需要重复这个过程直到遇到更大的父节点或者到达根节点。
private void swim(int k) { while (k > 1 && less(k/2, k)) { exch(k, k/2); k = k/2; } }
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; } }
3、实现优先队列操作
(1)插入元素
添加新的元素到数组的末尾,相应增加堆的大小,然后将这个元素上浮到合适的位置。
public void insert(Key x) {// add x, and percolate it up to maintain heap invariant pq[++n] = x; swim(n); }
(2)删除最大元素
从数组的顶端取走最大元素,并将数组末尾的元素放到顶端,相应减小堆的大小,然后将顶端元素下沉到合适的位置。
public Key delMax() { Key max = pq[1]; exch(1, n--); sink(1); pq[n+1] = null; // to avoid loiterig and help with garbage collection
return max; }
注:上述优先队列的插入元素和删除最大元素这两个操作,时间复杂度是对数级别(lgN)的。
(3)最精简的实现
package com.qiusongde; import edu.princeton.cs.algs4.StdIn; import edu.princeton.cs.algs4.StdOut; public class MaxPQ<Key extends Comparable<Key>> { private Key[] pq; private int N = 0; public MaxPQ(int maxN) { pq = (Key[]) new Comparable[maxN+1]; } public int size() { return N; } public boolean isEmpty() { return N == 0; } 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; } 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++;//larger children if(!less(k, j)) break; exch(k, j); k = j; } } public static void main(String[] args) { MaxPQ<String> pq = new MaxPQ<String>(5); while (!StdIn.isEmpty()) { String item = StdIn.readString(); if (!item.equals("-")) pq.insert(item); else if (!pq.isEmpty()) StdOut.print(pq.delMax() + " "); } StdOut.println("(" + pq.size() + " left on pq)"); } }
输出:
qiu chen dong zi wu - zi - wu - qiu - dong - chen
五、性能分析
结论:对于一个有N个元素,堆实现的优先队列,插入元素操作需要不超过1+lgN次比较次数。删除最大元素操作需要不超过2lgN次比较次数。
证明:两种操作都在根节点和堆底之间移动元素,而路径长度不超过lgN。删除最大元素,对于每个节点(堆底除外)都需要两次比较,一次比较用于找出较大子节点,一次比较用于确认父节点是否下沉。
这个性质对于需要大量混杂的插入和删除最大元素操作的应用来说,是一个重大的性能突破。
不管是无序数组、有序数组、无序链表或有序链表,都有至少一个操作需要线性时间来完成。
但堆实现的优先队列,能够保证在对数时间内完成这两个重要操作。
六、性能提升
1、多叉堆
用数组实现三叉堆,可以将上述代码做相应的改变即可。
即在一个三叉堆中,位置为k的节点,其父节点在(k+1)/3,其子节点在3 * k - 1,3 * k和3 * k + 1(根节点在1处)。
对于给定的d,将上述代码修改为d叉树也不难。
一般需要在树高logdN和在每d个子节点中找到最大节点的代价之间折中。
2、调整数组大小
在insert方法中添加将数组长度加倍的代码,在delMax方法中添加将数组长度减半的代码。这样用户就不用关心优先队列大小限制的问题了。
当数组长度是动态的时候,上述对数时间复杂度上限是平摊后的。
代码实现:
package com.qiusongde; import java.util.NoSuchElementException; import edu.princeton.cs.algs4.StdIn; import edu.princeton.cs.algs4.StdOut; public class MaxPQ<Key extends Comparable<Key>> { private Key[] pq; private int n; public MaxPQ(int maxN) { pq = (Key[]) new Comparable[maxN+1]; n = 0; } public MaxPQ() { this(1); } public MaxPQ(Key[] keys) { n = keys.length; pq = (Key[]) new Comparable[keys.length + 1]; for (int i = 0; i < n; i++) pq[i+1] = keys[i]; for (int k = n/2; k >= 1; k--) sink(k); } public int size() { return n; } public boolean isEmpty() { return n == 0; } public void insert(Key v) { int truesize = pq.length - 1; if(n >= truesize) resizeArray(2 * (truesize));//doubling pq[++n] = v; swim(n); } public Key delMax() { if(isEmpty()) throw new NoSuchElementException("Priority Queue underflow"); Key max = pq[1]; exch(1, n--); pq[n+1] = null; sink(1); int truesize = pq.length - 1; if ((n > 0) && (n == truesize / 4)) resizeArray(truesize / 2);//halving return max; } private void resizeArray(int max) { if(max < n) throw new IllegalArgumentException("The new size must not smaller than the size of Priority Queue"); Key[] temp = (Key[]) new Comparable[max + 1]; for (int i = 1; i <= n; i++) { temp[i] = pq[i]; } pq = temp; } 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++;//larger children if(!less(k, j)) break; exch(k, j); k = j; } } @Override public String toString() { String result = ""; result += " n:" + n; result += " size:" + pq.length; for(int i = 0; i < pq.length; i++) { result += " " + pq[i]; } result += "\n"; return result; } public static void main(String[] args) { MaxPQ<String> pq = new MaxPQ<String>(); StdOut.print(pq); while (!StdIn.isEmpty()) { String item = StdIn.readString(); if (!item.equals("-")) { pq.insert(item); StdOut.print(pq); } else if (!pq.isEmpty()) { StdOut.println("delete " + pq.delMax()); StdOut.print(pq); } } } }
n:0 size:2 null null a n:1 size:2 null a b n:2 size:3 null b a c n:3 size:5 null c a b null d n:4 size:5 null d c b a e n:5 size:9 null e d b a c null null null f n:6 size:9 null f d e a c b null null g n:7 size:9 null g d f a c b e null h n:8 size:9 null h g f d c b e a i n:9 size:17 null i h f g c b e a d null null null null null null null - delete i n:8 size:17 null h g f d c b e a null null null null null null null null - delete h n:7 size:17 null g d f a c b e null null null null null null null null null - delete g n:6 size:17 null f d e a c b null null null null null null null null null null - delete f n:5 size:17 null e d b a c null null null null null null null null null null null - delete e n:4 size:9 null d c b a null null null null - delete d n:3 size:9 null c a b null null null null null - delete c n:2 size:5 null b a null null - delete b n:1 size:3 null a null - delete a n:0 size:3 null null null
3、元素的不可变性
优先队列存储了用户创建的对象,但同时假设用例代码不会改变这些对象。用例代码改变了这些对象就会打破堆的有序性。
可以考虑将这个假设转化为强制条件。
七、应用
问题:输入N个对象,每个对象都对应着一个整数,任务就是找出M个最大的或最小的对象。
条件:输入量非常巨大,或者可以认为是无限的。
解决:可以用优先队列来解决。
public class TopM { // This class should not be instantiated. private TopM() { } /** * Reads a sequence of transactions from standard input; takes a * command-line integer m; prints to standard output the m largest * transactions in descending order. * * @param args the command-line arguments */ public static void main(String[] args) { int m = Integer.parseInt(args[0]); MinPQ<Transaction> pq = new MinPQ<Transaction>(m+1); while (StdIn.hasNextLine()) { // Create an entry from the next line and put on the PQ. String line = StdIn.readLine(); Transaction transaction = new Transaction(line); pq.insert(transaction); // remove minimum if m+1 entries on the PQ if (pq.size() > m) pq.delMin(); } // top m entries are on the PQ // print entries on PQ in reverse order Stack<Transaction> stack = new Stack<Transaction>(); for (Transaction transaction : pq) stack.push(transaction); for (Transaction transaction : stack) StdOut.println(transaction); } }