一、佇列與優先佇列的區別
- 佇列是一種FIFO(First-In-First-Out)先進先出的資料結構,對應於生活中的排隊的場景,排在前面的人總是先通過,依次進行。
- 優先佇列是特殊的佇列,從“優先”一詞,可看出有“插隊現象”。比如在火車站排隊進站時,就會有些比較急的人來插隊,他們就在前面先通過驗票。優先佇列至少含有兩種操作的資料結構:insert(插入),即將元素插入到優先佇列中(入隊);以及deleteMin(刪除最小者),它的作用是找出、刪除優先佇列中的最小的元素(出隊)。
二、優先佇列(堆)的特性
優先佇列的實現常選用二叉堆,在資料結構中,優先佇列一般也是指堆。
堆的兩個性質:
結構性:堆是一顆除底層外被完全填滿的二叉樹,底層的節點從左到右填入,這樣的樹叫做完全二叉樹。
堆序性:由於我們想很快找出最小元,則最小元應該在根上,任意節點都小於它的後裔,這就是小頂堆(Min-Heap);如果是查詢最大元,則最大元應該在根上,任意節點都要大於它的後裔,這就是大頂堆(Max-heap)。
結構性:
通過觀察發現,完全二叉樹可以直接使用一個陣列表示而不需要使用其他資料結構。所以我們只需要傳入一個size就可以構建優先佇列的結構(元素之間使用compareTo方法進行比較)。
public class PriorityQueue<T extends Comparable<? super T>> {
public PriorityQueue(int capacity) {
currentSize = 0;
array = (T[]) new Comparable[capacity + 1];
}
}
複製程式碼
對於陣列中的任意位置 i 的元素,其左兒子在位置 2i 上,則右兒子在 2i+1 上,父節點在 在 i/2(向下取整)上。通常從陣列下標1開始儲存,這樣的好處在於很方便找到左右、及父節點。如果從0開始,左兒子在2i+1,右兒子在2i+2,父節點在(i-1)/2(向下取整)。
堆序性:
我們這建立最小堆,即對於每一個元素X,X的父親中的關鍵字小於(或等於)X中的關鍵字,根節點除外(它沒有父節點)。
如圖所示,只有左邊是堆,右邊紅色節點違反堆序性。根據堆序性,只需要常O(1)找到最小元。
三、基本的堆操作
- insert(插入)
- 上濾:為了插入元素X,我們在下一個可用的位置建立空穴(否則會破壞結構性,不是完全二叉樹)。如果此元素放入空穴不破壞堆序性,則插入完成;否則,將父節點下移到空穴,即空穴向根的方向上冒一步。繼續該過程,直到X插入空穴為止。這樣的過程稱為上濾。
圖中演示了18插入的過程,在下一個可用的位置建立空穴(滿足結構性),發現不能直接插入,將父節點移下來,空穴上冒。繼續這個過程,直到滿足堆序性。這樣就實現了元素插入到優先佇列(堆)中。
- java實現上濾
/**
* 插入到優先佇列,維護堆序性
*
* @param x :插入的元素
*/
public void insert(T x) {
if (null == x) {
return;
}
//擴容
if (currentSize == array.length - 1) {
enlargeArray(array.length * 2 + 1);
}
//上濾
int hole = ++currentSize;
for (array[0] = x; x.compareTo(array[hole / 2]) < 0; hole /= 2) {
array[hole] = array[hole / 2];
}
array[hole] = x;
}
/**
* 擴容方法
*
* @param newSize :擴容後的容量,為原來的2倍+1
*/
private void enlargeArray(int newSize) {
T[] old = array;
array = (T[]) new Comparable[newSize];
System.arraycopy(old, 0, array, 0, old.length);
}
複製程式碼
可以反覆使用交換操作來進行上濾過程,但如果插入X上濾d層,則需要3d次賦值;我們這種方式只需要d+1次賦值。
如果插入的元素是新的最小元從而一直上濾到根處,那麼這種插入的時間長達O(logN)。但平均來看,上濾終止得要早。業已證明,執行依次插入平均需要2.607次比較,因此平均insert操作上移元素1.607層。上濾次數只比插入次數少一次。
- deleteMin(刪除最小元)
- 下濾:類似於上濾操作。因為我們建立的是最小堆,所以刪除最小元,就是將根節點刪掉,這樣就破壞了結構性。所以我們在根節點處建立空穴,為了滿足結構性,堆中最後一個元素X必須移動到合適的位置,如果可以直接放到空穴,則刪除完成(一般不可能);否則,將空穴的左右兒子中較小者移到空穴,即空穴下移了一層。繼續這樣的操作,直到X可以放入到空穴中。這樣就可以滿足結構性與堆序性。這個過程稱為下濾。
如圖所示:在根處建立空穴,將最後一個元素放到空穴,已滿足結構性;為滿足堆序性,需要將空穴下移到合適的位置。
注意:堆的實現中,經常發生的錯誤是只有偶數個元素,即有一個節點只有一個兒子。所以需要測試右兒子的存在性。
/**
* 刪除最小元
* 若優先佇列為空,丟擲UnderflowException
*
* @return :返回最小元
*/
public T deleteMin() {
if (isEmpty()) {
throw new UnderflowException();
}
T minItem = findMin();
array[1] = array[currentSize--];
percolateDown(1);
return minItem;
}
/**
* 下濾方法
*
* @param hole :從陣列下標hole1開始下濾
*/
private void percolateDown(int hole) {
int child;
T tmp = array[hole];
for (; hole * 2 <= currentSize; hole = child) {
//左兒子
child = hole * 2;
//判斷右兒子是否存在
if (child != currentSize &&
array[child + 1].compareTo(array[child]) < 0) {
child++;
}
if (array[child].compareTo(tmp) < 0) {
array[hole] = array[child];
} else {
break;
}
}
array[hole] = tmp;
}
複製程式碼
這種操作最壞時間複雜度是O(logN)。平均而言,被放到根處的元素幾乎下濾到底層(即來自的那層),所以平均時間複雜度是O(logN)。
四、總結
優先佇列常使用二叉堆實現,本篇圖解了二叉堆最基本的兩個操作:插入及刪除最小元。insert以O(1)常數時間執行,deleteMin以O(logN)執行。相信大家看了之後就可以去看java的PriorityQueue原始碼了。今天只說了二叉堆最基本的操作,還有一些額外操作及分析下次再說。比如,如何證明buildHeap是線性的?以及優先佇列的應用等。
宣告:圖文皆原創,如有轉載,請註明出處。如有錯誤,請幫忙指出,歡迎討論;若覺得可以,微微一讚支援支援。