1 堆和樹的區別
堆是一類特殊的樹,就類似一堆東西一樣(金字塔結構),按照由大到小(或由小到大)“堆”起來。
其中容易混淆的是二叉堆和二叉樹。
二叉堆的特點是雙親結點的值必然小於等於(最小堆)或者大於等於(最大堆)子結點的值,而兩個子結點的關鍵字沒有次序規定。
而二叉樹中,每個雙親結點的值均大於左子樹結點的值,均小於右子樹結點的值,也就是說,每個雙親結點的左右子結點的值有次序關係。
從上面各自的結構上的分析可得:二叉樹是用來做查找的,而二叉堆是用來做排序的。
2 優先級隊列
普通的隊列是一種先進先出的數據結構,元素在隊列尾追加,而從隊列頭刪除。而在優先隊列中,元素被賦予優先級。當訪問元素時,具有最高優先級的元素最先刪除。
優先隊列具有最高級先出 (first in, largest out)的行爲特徵。通常採用堆數據結構來實現,因爲和其他線性結構相比,用堆實現優先級隊列的性能最優:
一般在優先隊列裏面說“堆”這個詞,指的都是二叉堆這種數據結構實現。
數據結構 | 入隊性能 | 出隊性能(取最大/最小元素) |
---|---|---|
普通線性結構 | O(1) | O(n) |
有序線性結構 | O(n) | O(1) |
堆 | O(logn) | O(logn) |
可以看到,使用堆來實現優先級隊列,它的入隊和出隊操作性能都比較優秀且平衡。
由於二叉堆具有很明顯的規律,所以我們可以用一個數組而不需要用鏈表來表示。我們設計一個數組來表示上圖的“二叉堆”。
根據二叉堆的性質,我們可以得到這樣的數組來表示一個堆:數組第0位放棄,從第一位開始放入堆的元素。我們看到,在堆中描述的父子結構(顏色標記),在數組中依然得以保留,不過保留的方式變成了:第i個位置上的元素,他的左子結點總是在第2i位置上,右子結點在2i+1的位置上。
2.1 入隊操作(add)
就像爲了保證二叉樹的查詢效率,我們要時刻維持二叉樹的平衡一樣。我們每次對堆進行插入操作,都需要保證滿足堆的堆序特性。所以很多時候我們在插入之後,不得不調整整個堆的順序來維持堆的數據結構。
這個在插入時能保證堆繼續“平衡”的操作叫做上濾(Sift up),下面以一個最小堆爲例:
- 爲將一個元素X插入到堆中,我們在下一個可用位置(前序遍歷找下一個可用結點)創建一個空穴。
- 如果X可以放在該空穴中而不破壞堆的序(父結點小於等於子結點),那麼可以插入完成。否則,將空穴和父結點交換位置。這個操作叫做上濾。
- 以此類推,直到空穴無法再上濾爲止(此時的父結點已經小於等於插入的值),插入完成。
這個操作我們可以通過遞歸來實現,平均的時間複雜度是O(logn)。
2.2 出隊操作(poll)
根據堆序特性,找到最先元素很簡單,就是堆的根節點,但是刪除它卻不容易,刪除根節點,必然會破壞樹的結構。所以在刪除時,我們也要特定操作,來保證堆序繼續正確。
這個在刪除時能保證堆繼續“平衡”的操作叫做下濾(Sift down),下面還是以一個最小堆爲例:
- 刪除一個元素,我們可以很快找到,根節點元素就是最小的元素。此時根節點變成了空穴。刪掉了一個結點,肯定要找一個結點補回來,爲了維持完全二叉的特性,我們找堆的最後一個結點(前序遍歷)賦值給空穴。也就是下圖中標紅的31。
- 此時,31成爲了根結點是不滿足堆序的,所以肯定需要在左右結點中找一個子結點來和根結點做交互,選擇哪個呢?根據堆序,肯定選擇子結點中較小的那個結點,和空穴交換位置。
- 重複上述操作,直到空穴下沉到最底層爲止。下濾操作完成。
這個操作我們可以通過遞歸來實現,平均的時間複雜度是O(logn)。
2.3 構建堆(heapify)
將一個任意數組整理成堆,這個操作叫做heapify,構建一個n個元素的最大/最小堆,我們直接執行n次insert方法即可, 因爲在設計insert方法時,就已將考慮到將插入到值放到堆的合適位置。輸入n項,即可自動生成n大小的堆。此時算法的時間複雜度是O(nlogn)。
2.4 替換堆中堆頂元素(replace)
用一個新的元素,替換堆中優先級最高的元素,可以直接用新元素覆蓋堆頂元素,然後執行一次sift down操作即可,時間複雜度爲O(logn)。
3 JAVA中PriorityQueue實現
PriorityQueue在jdk的java.util包下,其本質是一個Object數組,也就是我們前文提到的,使用數組來存放堆的模式:
/**
* Priority queue represented as a balanced binary heap: the two
* children of queue[n] are queue[2*n+1] and queue[2*(n+1)]. The
* priority queue is ordered by comparator, or by the elements'
* natural ordering, if comparator is null: For each node n in the
* heap and each descendant d of n, n <= d. The element with the
* lowest value is in queue[0], assuming the queue is nonempty.
*/
transient Object[] queue; // non-private to simplify nested class access
3.1 構造方法
優先級隊列,java的實現默認是小頂堆的實現,默認調用Object的compareTo方法。
PriorityQueue queue = new PriorityQueue();
我們可以使用它的另一個構造方法,手動傳入指定的comparator:
Comparator comparator = new Comparator() {
@Override
public int compare(Object o1, Object o2) {
...
}
};
PriorityQueue queue = new PriorityQueue(capacity,comparator);
3.2 添加元素
add()和offer()都是用來做入隊操作的方法,add(E e)和offer(E e)的語義相同,都是向優先隊列中插入元素,只是Queue接口規定二者對插入失敗時的處理不同。
add()在插入失敗時拋出異常,offer()則會返回false。對於PriorityQueue,這兩個方法其實沒什麼差別。新加入的元素可能會破壞堆的堆序性質,因此需要進行必要的調整。
public boolean offer(E var1) {
if (var1 == null) {//不允許放入null元素
throw new NullPointerException();
} else {
++this.modCount;
int var2 = this.size;
if (var2 >= this.queue.length) {
this.grow(var2 + 1);//自動擴容
}
this.size = var2 + 1;
if (var2 == 0) {//隊列原來爲空,這是插入的第一個元素
this.queue[0] = var1;
} else {
this.siftUp(var2, var1);//調整
}
return true;
}
}
上述代碼中,擴容函數grow()類似於ArrayList裏的grow()函數,就是再申請一個更大的數組,並將原數組的元素複製過去,這裏不再贅述。需要注意的是siftUp(int k, E x)方法,該方法用於插入元素x並維持堆的特性。
private void siftUp(int var1, E var2) {
if (this.comparator != null) {
this.siftUpUsingComparator(var1, var2);// 元素類型是無法直接比較,要借用比較器的場景
} else {
this.siftUpComparable(var1, var2);// 元素類型是可以直接比較,不需要借用比較器的場景
}
}
這兩者代碼大同小異,我們看其中一個:
private void siftUpUsingComparator(int var1, E var2) {// var1是size
while(true) {
if (var1 > 0) {
int var3 = var1 - 1 >>> 1;// val3是parent的下標,parentNo = (nodeNo-1)/2
Object var4 = this.queue[var3]; // val4是parent元素
if (this.comparator.compare(var2, var4) < 0) {
this.queue[var1] = var4;// 如果var2優先級較高,和parent交換下標
var1 = var3;
continue;
}
}
this.queue[var1] = var2;// 交換完下標,最後賦值,var2賦值到它應該去的地方。
return;
}
}
3.3 獲取隊首元素
element()和peek()的語義完全相同,都是獲取但不刪除隊首元素,也就是隊列中權值優先級最高的那個元素。
二者唯一的區別是當方法失敗時element()拋出異常,peek()返回null。
由於堆用數組表示,根據下標關係,0下標處的那個元素就是堆頂元素。所以直接返回數組0下標處的那個元素即可。
public E peek() {
return (size == 0) ? null : (E) queue[0];
}
3.4 刪除隊首元素
remove()和poll()的語義也完全相同,都是獲取並刪除隊首元素,區別是當方法失敗時remove()拋出異常,poll()返回null。由於刪除操作會改變隊列的結構,爲維護堆的堆序性質,需要進行必要的調整。
public E poll() {
if (size == 0)
return null;
int s = --size;//size減小
modCount++;
E result = (E) queue[0];//0下標處的那個元素就是堆頂元素
E x = (E) queue[s];
queue[s] = null;
if (s != 0)
siftDown(0, x);//調整
return result;
}
上述代碼首先記錄0下標處的元素,並用最後一個元素替換0下標位置的元素,之後調用siftDown()方法對堆進行調整,最後返回原來0下標處的那個元素(也就是最小的那個元素)
重點是siftDown(int k, E x)方法,該方法的作用是從k指定的位置開始,將x逐層向下與當前點的左右孩子中較小的那個交換,直到x小於或等於左右孩子中的任何一個爲止。
private void siftDown(int k, E x) {
if (comparator != null)
siftDownUsingComparator(k, x);// 元素類型是無法直接比較,要借用比較器的場景
else
siftDownComparable(k, x);// 元素類型是可以直接比較,不需要借用比較器的場景
}
這兩者代碼大同小異,我們看其中一個:
private void siftDownUsingComparator(int k, E x) {
int half = size >>> 1;
while (k < half) {
// 首先找到左右孩子中較小的那個,記錄到c裏,並用child記錄其下標
int child = (k << 1) + 1;//leftNo = parentNo*2+1
Object c = queue[child];
int right = child + 1;
if (right < size &&
comparator.compare((E) c, (E) queue[right]) > 0)
c = queue[child = right];
if (comparator.compare(x, (E) c) <= 0)
break;// 如果parent已經比子結點小了,跳出循環
queue[k] = c;// 否則,用較小的子結點替換parent,繼續循環
k = child;
}
queue[k] = x;最後將x賦值在他適合的位置
}
3.5 刪除特定元素
remove(Object o)方法用於刪除隊列中跟o相等的某一個元素(如果有多個相等,只刪除一個),該方法不是Queue接口內的方法,而是Collection接口的方法。由於刪除操作會改變隊列結構,所以要進行調整;又由於刪除元素的位置可能是任意的,所以調整過程比其它函數稍加繁瑣。
具體來說,remove(Object o)可以分爲2種情況:
- 刪除的是最後一個元素。直接刪除即可,不需要調整。
-
- 刪除的不是最後一個元素,從刪除點開始以最後一個元素爲參照調用一次siftDown()即可。此處不再贅述。
public boolean remove(Object o) {
int i = indexOf(o); //通過遍歷數組的方式找到第一個滿足o.equals(queue[i])元素的下標
if (i == -1)
return false;// 找不到下標,就返回
else {
removeAt(i);// 調用removeAt
return true;
}
}
private E removeAt(int i) {
// assert i >= 0 && i < size;
modCount++;
int s = --size;
if (s == i) // removed last element
queue[i] = null;// //情況1,也就是要刪的地方是堆的最後一個,則直接刪除
else {
E moved = (E) queue[s];// 將堆最後一個元素賦給moved
queue[s] = null;
siftDown(i, moved);// 情況2,則用moved來替換被刪除的位置,接着執行siftDown方法,
if (queue[i] == moved) {// 如果調用完siftDown,但moved還在原位沒有下沉,那麼可能說明moved應該上濾
siftUp(i, moved);// 調用siftUp,嘗試讓moved上濾
if (queue[i] != moved)// moved終於不在原地了
return moved;
}
}
return null;
}
4 應用
4.1 在n個元素中找到前k項
維護一個size=k的優先級隊列,優先級的邏輯爲題目中要求的邏輯,如題目要是找出最小的k項,那實現就是值越大的項優先級越高。
遍歷一次n個元素,每次遍歷中,都將當前元素入隊,同時出隊一個元素,保證隊列的長度爲k。
這樣一次完整的遍歷後,隊列中的元素即爲結果,時間複雜度爲n*(logk+logk),即O(nlogk)。
不過該題使用不完整快排(即快排到前k項即可,其他的操作都不用做)會更快,可以達到O(n)。