問:談談你對二叉堆數據結構的理解及在 PriorityQueue 中的實現?
答:這算是一道比較有深度的問題了,要回答好首先得解釋什么是二叉堆數據結構,接著解釋其優點,然后解釋在 JDK 1.5 的 PriorityQueue 中是怎么使用的,只有這幾個方面都點到才算比較滿意的答案。
首先堆的特點總是一棵完全二叉樹且某個節點值總是不大于或不小于其父節點值,PriorityQueue 使用的是堆中比較特殊的二叉堆,二叉堆是完全二叉樹或者是近似完全二叉樹,二叉堆分為最大堆和最小堆,最大堆的父結點值總是大于或等于任何一個子節點值,最小堆的父結點值總是小于或等于任何一個子節點值。如下圖就是一個最小二叉堆結構圖:
可以看見二叉堆(完全二叉樹)在第 N 層深度被填滿之前是不會開始填第 N+1 層的,且元素插入也是從左往右順序。此外我們通過上面的樹狀圖和數組連續內存分布可以看到父子節點的索引順序存在如下關系:
parentNodeIndex = (currentNodeIndex-1)/2;
leftNodeIndex = parentNoIndex*2+1;
rightNodeIndex = parentNodeIndex*2+2;
可以看見,通過公式能直接計算出某個節點的父節點以及子節點的下標,所以這也就是為什么可以直接用數組來存儲二叉堆而不用鏈表的原因之一,故 PriorityQueue 的 peek()/element() 操作時間復雜度是 O(1),而 add()/offer()/poll()/remove() 操作的時間復雜度是 O(log(N))。
了解了二叉堆的原理和特點之后我們就來看看 PriorityQueue 中是怎么使用二叉堆實現操作的,我們主要要看的方法為add()/offer()/peek()/element()/poll()/remove(),下面會對這些方法進行分組實現解說。
1. add()/offer()
PriorityQueue 的 add()/offer() 操作都是向優先隊列中插入元素,add() 的實現就是直接調用 offer() 方法返回,所以我們直接看下 offer() 方法的實現:
public boolean offer(E e) {
//PriorityQueue元素不允許為null
if (e == null) throw new NullPointerException();
modCount++;
int i = size;
//數組需要擴容,arraycopy操作
if (i >= queue.length) grow(i + 1);
size = i + 1;
if (i == 0)
queue[0] = e;
//隊列為空時第一個元素插在數組開頭
else
siftUp(i, e);
//隊列不為空時堆結構調整數組元素位置
return true;
}
// 使用不同的比較器進行比較
private void siftUp(int k, E x) {
if (comparator != null) siftUpUsingComparator(k, x);
else siftUpComparable(k, x);
}
//k為currentNodeIndex,x為要插入的元素
private void siftUpComparable(int k, E x) {
Comparable<? super E> key = (Comparable<? super E>) x;
while (k > 0) {
int parent = (k - 1) >>> 1;
//等價于parentNodeIndex=(currentNodeIndex-1)/2;
Object e = queue[parent];
//將x逐層與parent元素比較交換,只到x>=queue[parent]結束
if (key.compareTo((E) e) >= 0) break;
queue[k] = e;
k = parent;
}
queue[k] = key;
}
上面的代碼用圖示流程演示如下(9個元素的優先級列表插入一個調整后變為10個元素):
2. peek()/element()
PriorityQueue 的 peek()/element() 操作,都是取出最小堆頂元素但不刪除隊列堆頂元素,區別就是 element() 的實現是 peek() 且 element() 取出元素為 null 會拋出異常而 peek() 不會,所以我們直接看下 peek() 方法的實現:
public E peek () {
return (size == 0) ? null : (E) queue[0];
}
演示流程圖如下:
3. poll()
PriorityQueue 的 poll() 操作,其目的就是取出最小堆頂部元素并從隊列刪除,當失敗時返回 null,所以該方法的實現如下:
public E poll() {
if (size == 0) return null;
int s = --size;
modCount++;
//最小二叉堆的最小元素自然在數組的index為0處
E result = (E) queue[0];
// 取出數組最后一個元素,即二叉堆樹最深層最右側的元素
E x = (E) queue[s];
// 最后一個元素位置置空
queue[s] = null;
if (s != 0) siftDown(0, x);
// 調整二叉堆數組元素位置
return result;
}
// 直接看siftDown中的Comparable情況,k索引0開始,x為二叉堆最后一個元素
private void siftDownComparable(int k, E x) {
Comparable<? super E> key = (Comparable<? super E>) x;
int half = size >>> 1; // loop while a non-leaf
while (k < half) {
// 找到當前元素的左子節點索引
int child = (k << 1) + 1; // assume left child is least
Object c = queue[child];
// 找到當前元素的右子節點索引
int right = child + 1;
if (right < size && ((Comparable<? super E>) c).compareTo((E) queue[right]) > 0) c = queue[child = right];
if (key.compareTo((E) c) <= 0) break;
queue[k] = c;
k = child;
}
queue[k] = key;
}
上面的代碼用圖示流程演示如下(10個元素的優先級列表 poll 刪除一個調整后變為9個元素):
4. remove()
PriorityQueue 的 remove(E e) 操作,其目的就是將指定的元素從隊列刪除,當失敗時返回 false,該方法的實質是先遍歷獲取 e 在數組的 index 索引,然后調用 removeAt(index) 方法,所以我們看下 removeAt(index) 方法源碼如下:
//i為要刪除的元素在數組的索引
E removeAt ( int i )
{
// assert i >= 0 && i < size;
modCount++;
int s = --size;
if (s == i) // removed last element
queue[i] = null;
//如果要刪除的元素恰巧在最后一個則直接刪除不用調整
else {
//取出二叉堆樹的最后一個節點元素
E moved = (E) queue[s];
//最后一個節點置為空
queue[s] = null;
//然后類似poll進行siftDown向下子節點比較交換(從i位置當做頂層父節點)
siftDown(i, moved);
// 向下沉淀完發現沒變化則需要向上浮動,說明最后一個元素換到 i 位置后是最小元素
if (queue[i] == moved) {
siftUp(i, moved);
if (queue[i] != moved) return moved;
}
}
return null;
}
上面的代碼用圖示流程演示如下(10個元素的優先級列表 remove 刪除一個調整后變為9個元素):
在作答這個題時你可以選擇畫圖也可以選擇直接寫父子節點關系公式和 siftUp、siftDown 的機制即可,核心答出來就行,當然不要忘記最小二叉堆是數組實現且 PriorityQueue 元素不允許為空的特性。