資料結構之堆 → 不要侷限於堆排序

青石路發表於2022-04-12

開心一刻

  一天,一個演講老師正在演講正確的愛情觀

  情到深處,老師激動的說道:你一個月掙三千,憑什麼讓一個月掙三萬的人喜歡你?

  結果底下站起來一個女孩,說道:因為我騷呀

堆結構

  定義:堆就是用陣列實現的完全二叉樹,並且根據堆屬性來排序,決定節點在樹中的順序

  資訊量是不是有點大?

  欸,有這些疑問就對了,我們慢慢往下看

  堆屬性

  堆分為兩種:大頂堆和小頂堆,也稱最大堆和最小堆

  在大頂堆中,父節點的值大於等於左右孩子節點的值。在小頂堆中,父節點的值小於等於左右孩子的值。這就是所謂的 堆屬性 ,並且這個屬性對堆中的每一個節點都成立

  注意:堆屬性只限制了父節點與其左右孩子的大小關係,並沒有限制左右孩子之間的大小關係

  我們看個例子

  上圖中父節點有兩個:9 和 5,9 比 5 和 7 都大,5 比 3 和 2 都大,滿足大頂堆的屬性,所以它是一個大頂堆

  上圖中父節點有兩個:2 和 5,2 比 5 和 3 都小,5 比 7 和 9 都小,滿足小頂堆的屬性,所以它是一個小頂堆

  由此我們可以得出:大頂堆的根節點存放的肯定是最大值,小頂堆的根節點存放的肯定是最小值

  大頂堆能夠快速得到最大值、小頂堆能夠快速得到最小值,但也就僅此而已了。堆中其他節點的順序是未知的,大頂堆中不能確定最小值,小頂堆中不能確定最大值

  陣列如何實現完全二叉樹

  用陣列來實現完全二叉樹,是不是感覺很怪?常規的樹的節點由 資料+指向孩子節點的指標 組成,陣列如何表現 指向孩子節點的指標?

  怪不代表不能,不僅能實現,而且在時間和空間上還很高效

  我們以前面的大頂堆示例為例,通過陣列這樣儲存: [9, 5, 7, 3, 2] ,僅此而已,不需要任何額外的空間!

  那麼關鍵問題來了,既然沒有使用指標,那麼如何確定某個節點的父節點以及子節點了?答案就是: 索引對映 

  假設某個節點的索引是 i,那麼它的父節點和子節點在陣列中的位置可以通過如下公式獲取

  注意看左右孩子的公式,不難得出:某個節點的左右孩子處於相鄰位置

  我們將公式放到大頂堆示例中驗證一下

  完美契合,只是需要注意下索引的有效性

  堆與二叉搜尋樹的區別

  從定義上來講,堆和二叉搜尋樹還是有區別的,所以堆並不能取代二叉搜尋樹

  相似點就不梳理了,我們重點來看下它們的區別

  節點順序。二叉搜尋樹中,左孩子必須比父節點小,右孩子必須比父節點大。但是堆中並非如此,堆中只需要保證父節點比左右孩子都大(小)

  記憶體佔用。二叉搜尋樹除了需要儲存資料,還需要儲存指向左右孩子的的指標。但堆僅用一個陣列來儲存資料,而不使用指標

  平衡。二叉搜尋樹在平衡的情況下,其大部分操作的時間複雜度是 O(log N) ,非平衡的極端情況下,二叉搜尋樹退化成一個連結串列,大部分操作的時間複雜度是 O(N) 

    堆就是陣列實現的完全二叉樹,完全二叉樹就是平衡二叉樹,所以堆肯定是平衡的

  搜尋。二叉搜尋樹本身就是為搜尋而生,所以其搜尋很快。而堆的目的是快速找到最大(小)節點,所以其搜尋會很慢

堆操作

  有兩個原始操作: shiftUp 和 shiftDown 用於保證插入或刪除節點後,堆仍然是一個有效的大頂堆或者小頂堆

  上移 → shiftUp

  在位置 k 處插入元素 x,將 x 逐層往根上移動,直至滿足堆屬性(仍是大頂堆或小頂堆)

  假設初始大頂堆如下:

  我們以它為例,來看兩種情況

  1、新插入元素:6,插入位置索引:5

    索引 5 的父位置索引是 2,那麼元素 6 的父元素是 7,7 比 6 大,仍是大頂堆,滿足堆屬性,操作完成

    此時大頂堆如下

  2、新插入元素:10,插入位置索引:5

    索引 5 的父位置索引是 2,那麼元素 10 的父元素是 7,7 比 10 小,不滿足堆屬性,元素 10 逐層往上移動,如下圖

  小頂堆是一樣的處理方式,只是比較方式不一樣而已,就不具體演示了

  我們再來看下具體的程式碼實現

  實現相容了 自然比較器 和 自定義比較器 兩種情況, 自然比較器 預設是升序排序

   比較器 升序對應的是小頂堆,降序對應的是大頂堆

  下移 → shiftDown

  在位置 k 處插入元素 x,將 x 逐層往葉子上移動(下移),直至滿足堆屬性(仍然是大頂堆或小頂堆)。整個操作也稱作 堆化(heapify) 

  假設大頂堆如下:

  我們以它為例,來看看一個例子

  假設我們需要將根節點 9 替換成 1,操作步驟是怎樣的?

  將 9 替換成 1 後,不滿足大頂堆屬性,需要調整,將節點 1 逐層向下移動,直至滿足堆屬性,如下所示

  1、節點 1 在根節點的時候,取它的孩子節點中的大者(7) 與自身交換

  2、節點 1 在索引為 1 的位置的時候,取它的孩子節點中的大者(3) 與自身交換

  3、節點 1 來到葉子節點,操作完成

  我們再來看看程式碼實現

  基於 shiftUp 和 shiftDown ,還有很多其他的操作,我們慢慢往下看

  insert

  在堆的末尾新增一個新的元素,然後用 shiftUp 修復堆;程式碼如下

  peek

  獲取根元素;如果是大頂堆則是獲取最大值,如果是小頂堆,則是獲取最小值

  indexOf

  查詢元素的位置索引

  因為堆不是為了快速查詢而建立的,所以其時間複雜度是 O(N) 

  remove & removeAt

   remove 是刪除元素。為了將這個節點刪除後的空位填補上,需要將最後一個元素移到根節點的位置,然後使用 shiftDown 方法來修復堆

   removeAt 是刪除指定位置的節點。將最後一個元素移到此位置,當它與子節點比較發現無序使用 shiftDown ,如果與父節點比較發現無序則使用 shiftUp 

  replace

  將指定位置的元素替換成目標元素;當它與子節點比較發現無序使用 shiftDown ,如果與父節點比較發現無序則使用 shiftUp  

  buildHeap

  構建初始堆,迴圈呼叫 insert 即可

使用場景

  堆排序

  這個可以說是大家最容易想到的堆的使用場景

  過程如下:

  1、以 0 ~ arr.length-1 元素進行堆化,那麼 arr[0] 就是最大值(大頂堆)或最小值(小頂堆),然後將 arr[length-1] 與 arr[0] 進行交換

  2、以 0 ~ arr.length-2 元素進行堆化,那麼 arr[0] 就是最大值(大頂堆)或最小值(小頂堆),然後將 arr[length-2] 與 arr[0] 進行交換

  3、以此類推,直至整個陣列有序

    如果是大頂堆,那麼則是升序;如果是小頂堆,則是降序

  以降序為例,我們來看下程式碼實現

  優先佇列

  優先佇列的底層實現就是:堆,有興趣的小夥伴可以去看看你們的開發語言中優先佇列的底層實現

   Java 中是 PriorityQueue ,只要你們去看它的原始碼,你們就會發現我上述 堆操作 的程式碼實現和 PriorityQueue 的基本一致,你們懂的: 拿來主義 

  獲取極值

  快速得到最大值或最小值;這是由堆屬性決定的,我們就不重複講了

  處理大資料量的 topN 問題,比如磁碟資料檔案 10G,記憶體卻只有 1G,如何統計出前 100 大的資料?

  可以利用小頂堆:每次讀取一個數與堆頂進行比較,若比堆頂大,則把堆頂彈出,把當前資料壓入堆頂,然後調整小頂堆( shiftDown ),最終得到的小頂堆即為最大的100條資料

  提升逼格

  雖然很虛,也很飄,但真的提升逼格,面試的時候還真有用!

總結

  堆屬性

  只強調了父節點與左右孩子節點的大小關係,並未要求左右孩子節點的大小關係

  所以堆不是有序的,查詢的時間複雜度 O(N) 

  堆操作

  重點是上移操作 shiftUp 與下移操作 shiftDown ,其他操作都是基於這兩個操作

  使用場景

  堆排序

  優先佇列

  獲取極值

參考

  Heap

相關文章