演算法 —— 排序 —— 優先佇列
優先佇列
【Priority Queue】
首先宣告一下,優先佇列是基於堆的完全二叉樹,它和佇列的概念無關。(它並不是佇列,而是樹)
並且,優先佇列最重要的操作就是: 刪除最大元素和插入元素,所以我們把精力集中在這兩點上。(本文以最大堆為主講述)
二叉堆
定義 : 當一棵二叉樹的每個結點都大於等於它的兩個子結點時,它被稱為堆有序。
也可以說根結點(最頂部)是堆有序的二叉樹中的最大結點。(最大堆)
如上圖所示,一棵堆有序的完全二叉堆。
我們可以先定下根結點,然後一層一層向下,在每個結點的下方連線兩個更小的結點。完全二叉樹只用陣列就可以表示。
根結點的索引(index)為 1 ,它的子結點在位置 2 和 3 ,並以此類推。
二叉堆是一組能夠用堆有序的完全二叉堆排序的元素,並在陣列中按照層級儲存。(不使用陣列的第一個位置)。
在一個堆中,位置 k 的結點的父結點的位置為[ k / 2 ] ,而它的兩個子結點的位置則分別是 2k 或者 2k+1 。
我們用長度為 N+1 的私有陣列 pq[ ] 來表示一個大小為 N 的堆,我們不會使用pq[ 0 ],堆元素放在pq[ 1 ] 至 pq[ N ]中。
插入元素
- 由下至上的堆有序化(上浮)
如果堆的有序狀態因為某個結點變得比它的父結點更大而被打破,那麼我們需要通過交換它和它的父結點來修復堆。
也就是說通常我們把元素插入在堆(陣列)的末尾,然後不斷的比較或交換它與它的父結點,直至根結點。
//上浮,插入元素使用。k表示元素索引
private void swim(int[] a, int k) {
while (k > 1 && a[k / 2] < a[k]) {
exch(a, k, k / 2);
k = k / 2;
}
}
如果我們把堆想象成為一個嚴密的黑社會組織,每個每個子結點都表示一個下屬,父結點表示為它的直接上級。
swim()
表示一個很有能力的新人加入了組織並且逐級提升,(將能力不足的上級踩在腳下)直至它遇到了一個更強的領導。
刪除最大元素
- 由上至下的堆有序化(下沉)
與插入元素相同道理,如果新來的父結點(放置根結點)比子結點小,那麼就需要不斷的交換它和子結點的位置來修復堆。
所以我們刪除最大元素的操作是 :
- 從陣列頂端刪除最大的元素
- 將陣列的最後一個元素放到頂端,並讓這個元素下沉( sink )到合適位置。
/**
* @param k 元素索引,1開始
* @param LEN 需排序的元素大小
*/
private static void sink(int[] a, int k, int LEN) {
while (2 * k <= LEN) {
int j = 2 * k; //j , j + 1 為子結點索引(2個子元素)
if (j < LEN && a[j] < a[j + 1])
j++;
if (a[k] > a[j]) { //如果新加入的結點大於子結點,則終止
break;
}
exch(a, k, j); //否則交換父結點與子結點位置
k = j; //繼續比較子結點的子結點
}
}
private static int[] delMax(int[] a) {
exch(a, 1, a.length - 1);// 最大元素和最後一個結點交換
// a[LEN] = null;// 防止物件遊離,即回收物件
a = Arrays.copyOf(a, a.length - 1);
sink(a, 1, a.length - 1);
return a;
}
優先佇列由一個基於堆的完全二叉樹表示,儲存於陣列pq[ 1..N ]中,pq [0 ]沒有使用。
- 插入元素:我們將新元素新增在陣列最後,用
swim()
(index = LEN-1)恢復堆的秩序。 - 刪除最大元素 : 我們獲得最大元素(索引為1),並將最大元素和陣列的最後一個元素交換位置,用
sink()
(index = 1) 恢復堆的秩序。
堆排序
堆排序可以分為兩個階段: 堆構造 和 堆排序
首先我們要將原始陣列重新組織安排進入堆中,使陣列成為堆有序陣列。即堆有序的完全二叉堆。
然後我們再將該陣列進行排序,使陣列成為有序陣列。
1.堆的構造
由N個給定的元素構造一個堆,我們可以很容易的從左至右遍歷陣列,使用swim()
將元素一個個插入到堆中。
但是一個更高效的辦法是從右到左,使用sink()
函式構造堆,並且我們只需從 LEN / 2 的地方開始往左遍歷即可。
//堆構造
for (int k = LEN / 2; k >= 1; k--) {
sink(a, k, LEN);
}
2.堆的排序
我們可以再溫習一下刪除最大元素的思想:
- 我們將堆有序的 第一個元素 pq [ 1 ] 刪除
- 並且將陣列的最後一放置頂端,使用
sink()
修復堆。
也就是說我們每次刪除的元素都是最大的,那麼我們只要將依次刪除的這些最大元素收集起來,那麼也就是陣列有序了。
堆排序的主要工作都是在第二階段(第一階段是堆構造)完成的。
我們將堆的最大元素刪除,然後放入堆縮小後的陣列中空出位置。(這個過程和選擇排序有些類似,但所需的比較要少的多)
public class S_優先佇列 {
public static void main(String[] args) {
int[] a = { 0, 9, 6, 8, 10, 2, 11, 1, 5, 7, 4, 2 };// 11個元素,a[0] 不用
int LEN = a.length - 1;
for (int k = LEN / 2; k >= 1; k--) {
sink(a, k, LEN);
}
System.out.println("--構造堆--" + Arrays.toString(a));
while (LEN > 1) {
exch(a, 1, LEN--);
sink(a, 1, LEN);
}
System.out.println("--堆排序--" + Arrays.toString(a));
// a = delMax(a);
// System.out.println("--刪除最大元素--" + Arrays.toString(a));
}
/**
* @param k 元素(非陣列)下標,1開始
* @param LEN 需排序的元素大小
*/
private static void sink(int[] a, int k, int LEN) {
while (2 * k <= LEN) {
int j = 2 * k;
if (j < LEN && a[j] < a[j + 1])
j++;
if (a[k] > a[j]) {
break;
}
exch(a, k, j);
k = j;
}
}
/**
* 上浮,插入元素使用
*/
private void swim(int[] a, int k) {
while (k > 1 && a[k / 2] < a[k]) {
exch(a, k, k / 2);
k = k / 2;
}
}
private static int[] delMax(int[] a) {
exch(a, 1, a.length - 1);// 最大元素和最後一個結點交換
// a[LEN] = null;// 防止物件遊離,即回收物件
a = Arrays.copyOf(a, a.length - 1);
sink(a, 1, a.length - 1);
return a;
}
public static void exch(int[] arr, int a, int b) {
int temp = arr[a];
arr[a] = arr[b];
arr[b] = temp;
}
}
總結
堆排序在排序複雜度的研究中有著重要的地位。因為它在最壞的情況下也能保證使用 ~2NlgN 次比較和恆定的額外空間。
並且它能在 插入操作 和 刪除最大元素操作混合動態場景中保證對數級別的執行時間。
延伸 —– 最小堆
另外本文所述的為最大堆(大頂堆),即根結點最大。如果根結點為最小,它的子結點都比父結點大,那麼稱為最小堆(小頂堆)
從 n 個無序數中選出 m 個最大數
最小堆 : 頭 m 個數建立 size = m 的最小堆,(根結點最小),後續數小於根結點則拋棄;大於根結點則插入並替換。
快排 : 當大於分界的個數大於 m ,則拋棄小於分界的部分。當大於分界的個數小於 m ,則全部保留,並且在小於的部分找出剩餘的 n - m 個最大數。