本文是對 Swift Algorithm Club 翻譯的一篇文章。
Swift Algorithm Club是 raywenderlich.com網站出品的用Swift實現演算法和資料結構的開源專案,目前在GitHub上有18000+⭐️,我初略統計了一下,大概有一百左右個的演算法和資料結構,基本上常見的都包含了,是iOSer學習演算法和資料結構不錯的資源。
?andyRon/swift-algorithm-club-cn是我對Swift Algorithm Club,邊學習邊翻譯的專案。由於能力有限,如發現錯誤或翻譯不妥,請指正,歡迎pull request。也歡迎有興趣、有時間的小夥伴一起參與翻譯和學習?。當然也歡迎加⭐️,?????。
本文的翻譯原文和程式碼可以檢視?swift-algorithm-club-cn/Heap
堆(Heap)
這個話題已經有個輔導文章
堆是陣列內的二叉樹,因此它不使用父/子指標。 堆基於“堆屬性”進行排序,“堆屬性”確定樹中節點的順序。
堆的一般用途:
堆屬性
有兩種堆:max-heap 和 min-heap,它們儲存樹節點的順序不同。
在max-heap中,每個父節點的值大於其子節點。 在min-heap中,每個父節點的值都小於其子節點。 這稱為“堆屬性”,對於樹中的每個節點都是如此。
一個例子:
這是一個max-heap,因為每個父節點都大於其子節點。 (10)
大於(7)
和(2)
。 (7)
大於(5)
和(1)
。
堆屬性的結果是,max-heap始終將其最大項儲存在樹的根節點中。 對於min-heap,根節點始終是樹中的最小項。 堆屬性很有用,因為堆通常用作優先佇列來快速訪問“最重要的”(**譯註:**最大或最小)元素。
注意: 堆的根節點是最大或最小元素,但其他元素的排序順序是不可預測的。例如,最大元素始終位於max-heap中的索引0處,但最小元素不一定是最後一個元素。 —— 唯一的保證是,最小元素是葉節點之一,但不知道是哪一個。
堆與常規樹對比
堆不是二叉搜尋樹的替代品,它們之間存在相似之處和不同之處。 以下是一些主要差異:
**節點的順序。**在二叉搜尋樹(BST)中,左子節點必須小於其父節點,右子節點必須更大。 堆不是這樣。 在max-heap中,兩個子節點必須小於父節點,而在min-heap中,子節點必須大於父節點。
**記憶體。**傳統的樹比它們儲存的資料佔用更多的記憶體。 需要為節點物件和指向左/右子節點的指標分配額外的儲存空間。 堆只使用普通陣列進行儲存,不使用指標。
平衡。 二叉搜尋樹(BST)必須“平衡”,以便大多數操作具有**O(log n)**效能。 您可以按隨機順序插入和刪除資料,也可以使用AVL樹或紅黑樹,但 我們實際上並不需要對整個樹進行排序。 我們只是希望實現堆屬性,因此平衡不是問題。 由於堆的結構方式,堆可以保證 O(log n) 的效能。
搜尋。 雖然在二叉樹中搜尋速度很快,但在堆中搜尋速度很慢。 搜尋不是堆中的最高優先順序,因為堆的目的是將最大(或最小)節點放在前面並允許相對快速的插入和刪除。
陣列中的樹
用陣列實現樹狀結構似乎比較奇怪,但它在時間和空間上都很高效的。
上面例子中的樹用陣列儲存為:
[ 10, 7, 2, 5, 1 ]
複製程式碼
這裡的所有了! 我們不需要比這個簡單陣列更多的儲存空間了。
那麼,如果不允許使用任何指標,我們如何知道哪些節點是父節點,哪些節點是子節點? 好問題!樹節點的陣列索引與其父節點和子節點的陣列索引之間存在明確定義的關係。
如果i
是節點的索引,則以下公式給出其父節點和子節點的陣列索引:
parent(i) = floor((i - 1)/2)
left(i) = 2i + 1
right(i) = 2i + 2
複製程式碼
注意right(i)
只是left(i)+ 1
。 左側和右側子節點始終緊挨著儲存。
對上面的例子使用這些公式。 填寫陣列索引,我們應該得到陣列中父節點和子節點的位置:
節點 | 陣列中的索引(i ) |
父節點索引 | 左子節點索引 | 右子節點索引 |
---|---|---|---|---|
10 | 0 | -1 | 1 | 2 |
7 | 1 | 0 | 3 | 4 |
2 | 2 | 0 | 5 | 6 |
5 | 3 | 1 | 7 | 8 |
1 | 4 | 1 | 9 | 10 |
驗證這些陣列索引確實對應於上面樹的圖片。
注意: 根節點
(10)
沒有父節點,因為-1
不是有效的陣列索引。 同樣,節點(2)
,(5)
和(1)
沒有子節點,因為那些索引大於陣列大小,所以用它們之前我們總是要確保我們計算的索引實際上是有效的。
回想一下,在max-heap中,父節點的值總是大於(或等於)其子節點的值。 這意味著對於所有陣列索引i
必須滿足以下條件:
array[parent(i)] >= array[i]
複製程式碼
驗證此堆屬性是否適用於示例堆中的陣列。
如您所見,這些等式允許我們在不需要指標的情況下找到任何節點的父索引或子索引。 這樣消除了使用指標的複雜,這是一種權衡:我們節省了記憶體空間,但需要額外的計算。 幸運的是,計算速度很快,只需要 O(1) 時間。
理解樹中陣列索引和位置之間的這種關係很重要。 下面?是一個更大的堆,有15個節點分為四個級別:
此圖片中的數字不是節點的值,而是儲存節點的陣列索引! 下面?是陣列索引對應樹的不同級別:
要使公式起作用,父節點必須出現在陣列中的子節點之前。 你可以在上面的圖片中看到。
請注意,此方案有侷限性。 您可以使用常規二叉樹執行以下操作,但不能使用堆執行以下操作:
除非當前最低階別已滿,否則無法開啟新級別,因此堆總是具有這種形狀:
注意: 您可以使用堆模擬常規二叉樹,但這會浪費空間,您需要將一些陣列索引標記為空。
突擊測驗! 假設我們有陣列:
[ 10, 14, 25, 33, 81, 82, 99 ]
複製程式碼
這是一個有效的堆嗎? 答案是肯定的! 從低到高的排序陣列是有效的min-heap。 我們可以按如下方式繪製這個堆:
堆屬性適用於每個節點,因為父節點始終小於其子節點。 (自己驗證從高到低排序的陣列始終是有效的max-heap。)
**注意:**但並非每個min-heap都必須是一個排序陣列! 排序陣列只是一種特殊情況。 要將堆重新轉換為已排序的陣列,需要使用堆排序。
更多數學!
如果你很好奇,這裡有一些描述堆的某些屬性的公式。 你不需要知道這些,但它們有時會派上用場。 可以跳過此部分!
樹的height定義為從根節點到最低葉節點所需的步數,或者更正式:height是節點之間的最大邊數。 高度h的堆具有h + 1級別。
這個堆的高度為3,所以它有4個級別:
具有n個節點的堆具有高度h = floor(log2(n))。 這是因為我們總是在新增新級別之前完全填滿最低階別。 該示例有15個節點,因此高度為 floor(log2(15)) = floor(3.91) = 3
。
如果最低階別已滿,則該級別包含 2^h 個節點。 它上面的樹的其餘部分包含 2^h - 1 個節點。 上面示例就是:最低階別有8個節點,實際上是 2^3 = 8
。 前三個級別包含總共7個節點,即2^3 - 1 = 8 - 1 = 7
。
因此,整個堆中的節點總數n 為 2^(h+1) - 1。 在示例中,2^4 - 1 = 16 - 1 = 15
。
在n個元素堆中,高度為h的最多有 ceil(n/2^(h+1)) 個的節點。(**譯註:**示例中h為0時,ceil(15/2^(0+1)) = 8
,h為1時,ceil(15/2^(1+1)) = 4
)
葉節點總是位於陣列索引 floor(n/2) 到 n-1。(譯註: 7 ~ 14
) 我們將利用這一事實從陣列中快速構建堆。 如果您不相信,請驗證此示例。;-)
只是一些數學就能照亮你的一天。☀️
你能用堆做什麼?
在插入或刪除元素之後,有兩個必要的原始操作來確保堆是有效的max-heap或min-heap:
-
shiftUp()
:如果元素比其父元素更大(max-heap)或更小(min-heap),則需要與父元素交換, 這使元素向上移動。 -
shiftDown()
。 如果元素比子元素小(max-heap)或更大(min-heap),這個操作使元素向下移動,也稱為“堆化(heapify)”。
向上或向下移動是一個遞迴過程,需要**O(log n)**時間。
以下是基於原始操作的其他操作:
-
insert(value)
:將新元素新增到堆的末尾,然後使用shiftUp()
來修復堆。 -
remove()
:刪除並返回最大值(max-heap)或最小值(min-heap)。為了填充元素刪除後留下的位置,讓最後一個元素移動到根位置,然後使用shiftDown()
修復堆。 (有時稱為“提取最小值”或“提取最大值”。) -
removeAtIndex(index)
:類似remove()
,不僅可以刪除根節點,也可以從堆中刪除任何節點。如果新元素與其子元素不規整,則呼叫shiftDown()
;如果元素與其父元素不規整,則呼叫shiftUp()
。 -
replace(index, value)
:為節點分配一個較小(min-heap)或較大(max-heap)的值。因為這會使堆屬性失效,所以它使用shiftUp()
來修復。 (也稱為“減少鍵”和“增加鍵”。)
以上所有操作都需要時間**O(log n)**因為向上或向下移動是昂貴的。還有一些操作需要更多時間:
-
search(value)
。堆不是為高效搜尋而構建的,但replace()
和removeAtIndex()
操作需要節點的陣列索引,因此您需要找到該索引。時間:O(n)。 -
buildHeap(array)
:通過重複呼叫insert()
將陣列(未排序的)轉換為堆。如果您對此很聰明,可以在**O(n)**時間內完成。 -
堆排序。由於堆是一個陣列,我們可以使用它的唯一屬性將陣列從低到高排序。時間:O(n lg n)。
堆還有一個peek()
函式,它返回最大(max-heap)或最小(min-heap)元素,而不從堆中刪除它。時間:O(1)。
注意: 到目前為止,您將使用堆執行的最常見操作是使用
insert()
插入新值,並使用remove()
刪除最大值或最小值。 兩者都需要**O(log n)**時間。 其他操作用來支援更高階的使用,例如構建優先佇列,其中專案的“重要性”在新增到佇列後可以改變。
向堆中插入元素
我們來看一個插入示例,詳細瞭解其工作原理。 我們將值16
插入此堆:
這個堆的陣列是[10, 7, 2, 5, 1]
。
插入新專案的第一步是將其附加到陣列的末尾。 該陣列變為:
[ 10, 7, 2, 5, 1, 16 ]
複製程式碼
樹結構如下:
(16)
被新增到最後一行的第一個可用空間。
不幸的是,堆屬性不再滿足,因為(2)
高於(16)
,我們希望更高的數字高於低的數字。 (這是max-heap。)
要恢復堆屬性,我們交換(16)
和(2)
。
我們還沒有完成,因為(10)
也小於(16)
。 我們繼續將其插入值與其父項交換,直到父項更大或到達樹的頂部。 這稱為shift-up 或 sifting ,並在每次插入後完成。 它會使一個太大或太小的數字“浮起”樹。
最後,我們得到:
現在每個父節點都比其子節點更大了。
上移所需的時間與樹的高度成正比,需要O(log n)時間。(將節點附加到陣列末尾所需的時間僅為O(1),因此不會降低它的速度。)
刪除根節點
從樹中移除(10)
:
頂部的空白怎麼辦?
插入時,我們將新值放在陣列的末尾。 在這裡,我們做相反的事情:我們採用我們擁有的最後一個物件,將其直接移動到樹的頂部,然後恢復堆屬性。
讓我們來看看如何shift-down(1)
。 要維護此max-heap的堆屬性,我們希望頂部為最大數。 我們有兩個交換位置的候選者:(7)
和(2)
。 選擇這三個節點之間的最高數字位於頂部,那是(7)
,所以交換(1)
和(7)
,得到下面?的樹:
繼續向下移動,直到節點沒有任何子節點,或者它比兩個子節點都大。 對於這個堆,只需要一個交換來恢復堆屬性:
完全向下移動所需的時間與樹的高度成正比,這需要**O(log n)**時間。
注意:
shiftUp()
和shiftDown()
一次只能修復一個異常元素。 如果錯誤的位置有多個元素,則需要為每個元素呼叫一次這些函式。
刪除任意節點
絕大多數情況下,將刪除的是堆根節點,因為這是堆設計的目的。
但是,刪除任意元素可能很有用。 這是remove()
的一般版本,可能涉及shiftDown()
或shiftUp()
。
讓我們再次採用前面的示例樹,刪除(7)
:
提醒一下,陣列是:
[ 10, 7, 2, 5, 1 ]
複製程式碼
如您所知,刪除元素可能會使max-heap或min-heap屬性失效。 要解決這個問題,我們將要移除的節點與最後一個元素交換:
[ 10, 1, 2, 5, 7 ]
複製程式碼
最後一個元素是我們將返回的元素; 我們將呼叫removeLast()
將其從堆中刪除。 (1)
現在是亂序的,因為它小於它的子節點,(5)
是在樹中應該更高。 我們呼叫shiftDown()
來修復它。
但是,向下移動並不是我們需要處理的唯一情況。 也可能發生新元素必須向上移動。 考慮如果從以下堆中刪除(5)
會發生什麼:
譯註:這個的樹對應的陣列是
[10, 7, 9, 5, 1, 2, 8]
。
現在(5)
與(8)
交換。 因為(8)
比它的父節點((7)
)大,我們需要呼叫shiftUp()
。
用陣列建立堆
將陣列轉換為堆可以很方便。 只是對陣列元素進行洗牌,直到滿足堆屬性。
在程式碼中它看起來像這樣:
private mutating func buildHeap(fromArray array: [T]) {
for value in array {
insert(value)
}
}
複製程式碼
我們只要為陣列中的每個值呼叫insert()
。 簡單但不是很高效。 這總共需要O(n log n)時間,因為有n個元素,每個插入需要log n時間。
如果你沒有跳過前面數學部分,你已經看到,對於任何堆,陣列索引n / 2到n-1的元素都是樹的葉節點。 我們可以簡單地跳過那些葉子。 我們只需要處理其他節點,因為它們是有一個或多個子節點的父節點,因此可能是錯誤的順序。
程式碼:
private mutating func buildHeap(fromArray array: [T]) {
elements = array
for i in stride(from: (nodes.count/2-1), through: 0, by: -1) {
shiftDown(index: i, heapSize: elements.count)
}
}
複製程式碼
這裡,elements
是堆自己的陣列。 我們從第一個非葉節點開始向後遍歷這個陣列,並呼叫shiftDown()
。 這個簡單的迴圈以正確的順序放置這些節點以及我們跳過的葉節點。 這被稱為Floyd演算法,只需要**O(n)**時間。 ✌️
搜尋堆
堆不能用於快速搜尋,但如果要使用removeAtIndex()
刪除任意元素或使用replace()
更改元素的值,則需要獲取該元素的索引。搜尋堆速度很慢。
在二叉搜尋樹中,根據節點的順序,可以保證快速搜尋。 由於堆以不同方式對其節點進行排序,因此二叉搜尋不起作用,您需要檢查樹中的每個節點。
再給出上面堆示例:
如果我們想要搜尋節點(1)
的索引,我們可以通過線性搜尋分步搜尋陣列[10, 7, 2, 5, 1]
。
即使堆屬性沒有考慮到搜尋,我們仍然可以利用它。 我們知道在max-heap中父節點總是比它的子節點大,所以如果父節點已經小於我們要查詢的值,我們可以忽略那些子節點(及其子節點等等)。
假設我們想要檢視堆是否包含值8
(沒有包含)。 我們從根(10)
開始。 這不是我們想要的,所以我們遞迴地看看它的左右子節點。 左邊的孩子是(7)
。 這也不是我們想要的,但由於這是一個max-heap,我們知道檢視(7)
的子節點是沒有意義的,它們總是小於7
,因此左側不會找到8
。 同樣,對於右節點,(2)
,也找不到。
儘管有一點優化,搜尋仍然是**O(n)**操作。
注意: 有一種方法可以通過保留一個將節點值對映到索引的附加字典來將查詢轉換為**O(1)**操作。 如果你經常需要呼叫
replace()
來改變構建在堆上的優先佇列中物件的“優先順序”,這可能是值得做的。
程式碼
有關用Swift程式碼實現,請參見Heap.swift。 大多數程式碼都很簡單。 唯一棘手的是shiftUp()
和shiftDown()
。
您已經知道有兩種型別的堆:max-heap和min-heap。 它們之間的唯一區別在於它們如何對節點進行排序:首先是最大值或最小值。
不是建立兩個不同的版本,MaxHeap
和MinHeap
,而只有一個Heap
物件,它需要一個isOrderedBefore
閉包。 此閉包包含確定兩個值的順序的邏輯。 你之前可能已經看過了,因為它也是Swift的sort()
的工作原理。
要建立一個max-heap整數堆:
var maxHeap = Heap<Int>(sort: >)
複製程式碼
要建立一個min-heap整數堆:
var minHeap = Heap<Int>(sort: <)
複製程式碼
I just wanted to point this out, because where most heap implementations use the <
and >
operators to compare values, this one uses the isOrderedBefore()
closure.
我只想指出這一點,因為大多數堆實現使用<
和>
運算子來比較值,這個使用isOrderedBefore()
閉包。
擴充套件閱讀
作者:Kevin Randrup, Matthijs Hollemans
翻譯:Andy Ron
校對:Andy Ron