簡述二叉堆和優先級隊列

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),下面以一個最小堆爲例:

  1. 爲將一個元素X插入到堆中,我們在下一個可用位置(前序遍歷找下一個可用結點)創建一個空穴。
  2. 如果X可以放在該空穴中而不破壞堆的序(父結點小於等於子結點),那麼可以插入完成。否則,將空穴和父結點交換位置。這個操作叫做上濾。

  1. 以此類推,直到空穴無法再上濾爲止(此時的父結點已經小於等於插入的值),插入完成。

這個操作我們可以通過遞歸來實現,平均的時間複雜度是O(logn)

2.2 出隊操作(poll)

根據堆序特性,找到最先元素很簡單,就是堆的根節點,但是刪除它卻不容易,刪除根節點,必然會破壞樹的結構。所以在刪除時,我們也要特定操作,來保證堆序繼續正確。

這個在刪除時能保證堆繼續“平衡”的操作叫做下濾(Sift down),下面還是以一個最小堆爲例:

  1. 刪除一個元素,我們可以很快找到,根節點元素就是最小的元素。此時根節點變成了空穴。刪掉了一個結點,肯定要找一個結點補回來,爲了維持完全二叉的特性,我們找堆的最後一個結點(前序遍歷)賦值給空穴。也就是下圖中標紅的31。
  2. 此時,31成爲了根結點是不滿足堆序的,所以肯定需要在左右結點中找一個子結點來和根結點做交互,選擇哪個呢?根據堆序,肯定選擇子結點中較小的那個結點,和空穴交換位置。
  3. 重複上述操作,直到空穴下沉到最底層爲止。下濾操作完成。

這個操作我們可以通過遞歸來實現,平均的時間複雜度是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種情況:

  1. 刪除的是最後一個元素。直接刪除即可,不需要調整。
    1. 刪除的不是最後一個元素,從刪除點開始以最後一個元素爲參照調用一次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)。

4.2 待補充

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章