堆排序你真的學會了嗎?

公眾號程式設計師學長發表於2021-07-25

堆這種資料結構應用場景很多,最經典的莫過於堆排序。堆排序是一種原地的、時間複雜度為O(nlogn)的排序演算法。我們今天就來分析一下堆這種資料結構。

一、什麼是堆

堆是一種特殊的樹。只要滿足以下兩點,就稱為堆。

  • 堆是一個完全二叉樹。
  • 堆的每一個節點的值都必須大於等於(或小於等於)其子樹中每個節點的值。

       對於每個節點的值都大於等於其子樹中每個節點的值的堆,我們叫做“大頂堆”。對於每個節點的值都小於等於其子樹中每個節點的值的堆,我們叫做“小頂堆”。

二、如何實現一個堆

      首先我們需要知道堆都支援哪些操作以及如何儲存一個堆。

      對於一個完全二叉樹來說,用陣列來儲存是非常節省儲存空間的。因為我們不需要儲存指向左右子節點的指標,單純的通過陣列下標就可以找到一個節點的左右子節點和父節點。如下圖所示。

如圖所示,陣列中下標為i的節點,它的左子節點的下標為2i,它的右子節點下標為2i+1,它的父節點的下標為i/2。下面我們再來看一下堆上有哪些常用的操作。我們以大頂堆為例,來看一下堆的插入操作和刪除操作。

1.往堆中插入元素

      往堆中插入一個新的元素後,我們需要繼續滿足堆的兩個特性。

      如果我們把新插入的元素放到堆的最後,是不符合堆的特性的,所以我們需要調整,以保證其滿足堆的特性。調整分為向上調整和向下調整。我們先以向上調整為例。如下圖所示。

     這個過程很簡單,我們讓新插入的節點和其父節點對比大小。如果不      滿足子節點小於等於父節點,我們就互換兩個節點。一直重複這個過程,直到滿足要求。

2.刪除堆頂元素

  接下來我們來看一下刪除操作。我們首先把堆的最後一個元素和堆頂元素互換位置,然後刪除最後一個元素。剩下的堆元素是不滿足堆的要求的,我們需要從堆頂開始從上往下調整,直到父子節點滿足大小關係為止。如下圖所示。

      我們知道一個包含n個節點的完全二叉樹,樹的高度不會超過log2n,堆調整的過程是順著節點所在的路徑進行比較交換,所以時間複雜度是和堆的高度成正比的,也就是O(logN)。插入資料和刪除堆頂資料的主要邏輯就是堆的調整,所以往堆裡插入一個元素和刪除堆頂元素的時間複雜度是O(logN)。

三、堆排序

       我們上次講了很多的排序方法,可以點選排序演算法去檢視。今天我們繼續講一種新的排序演算法-堆排序,它的時間複雜度是O(nlogN),並且是原地排序演算法。我們可以把堆排序的過程大致分為兩大步驟,分別是建堆和排序。

1.建堆

        我們首先將陣列原地建一個堆。“原地”的含義就是不借助另一個陣列,就在原陣列上操作。我們的實現思路是從後往前處理資料,並且每個資料都是從上向下調整。

        我們看一下下面的建堆分解步驟圖。由於葉子節點向下調整隻能自己跟自己比較,所以我們直接從最後一個非葉子節點開始,依次向下調整就好了。

      如圖所示,我們對下標從n/2開始一直到1的資料進行向下調整,下標是n/2+1到n的節點是葉子節點,所以我們不需要調整。\

2.排序

       建堆結束後,陣列中的資料已經按照大頂堆的特性進行組織了。陣列中的第一個元素就是堆頂,也就是最大的元素。我們把它和最後一個元素交換,那最大的元素就放到了下標為n的位置。

       這個過程類似於刪除堆頂操作,當堆頂元素移除以後,我們把下標為n的元素放到堆頂,然後再進行向下調整,將剩下的n-1個元素重新構建成堆。調整完成之後,我們再取堆頂元素,放到下標為n-1的位置,一直重複這個過程,直到堆中最後只剩下下標為1的一個元素,排序工作就完成了。

      現在我們來分析一下堆排序的時間複雜度、空間複雜度以及穩定性。整個堆排序的過程中,只需要個別的臨時儲存空間,所以堆排序是原地排序演算法。堆排序包括建堆和排序兩個操作,建堆的時間複雜度是O(n),排序過程時間複雜度是O(nlogN)。所以,堆排序的整個時間複雜度是O(nlogN)。因為在排序的過程中,存在將堆的最後一個節點跟堆頂互換的操作,所以有可能會改變值相同資料的原始相對順序,所以堆排序不是穩定的排序演算法。

四、堆的應用

下面我們來說一下堆的幾個非常重要的應用。

1.優先順序佇列

 優先順序佇列,顧名思義,它首先是一個佇列。佇列的最大特性就是先進先出。但是,在優先順序佇列中,出隊的順序不是按照先進先出,而是按照優先順序來,優先順序高的先出隊。     

如何實現一個優先順序佇列呢?其實有很多方法,不過使用堆來實現是最直接、最高效的。因為堆和優先順序佇列非常相似。一個堆就可以看做是一個優先順序佇列。往優先順序佇列中插入一個元素,就相當於往堆中插入一個元素;從優先順序佇列中取出最高優先順序的元素,就相當於取出堆頂元素。我們來看一下下面這樣一個應用場景。

假如我們有100個小檔案,每個檔案的大小是100MB。每個檔案中儲存的都是有序的字串。我們希望將這些小檔案合併成一個有序大檔案。這裡就會用到優先順序佇列。      我們將從100個小檔案中,各取出一個字串,然後我們建立小頂堆,那堆頂的元素,也就是優先順序佇列的隊首元素,也就是最小的字串。我們將這個字串放到大檔案中,並將其從堆中刪除。然後再從小檔案中取出下一個字串放入堆中。迴圈此過程,就可以將100個小檔案的資料依次放入到大檔案中。

2.利用堆求topK

我們可以把求topk的問題抽象成2類。一類是針對靜態資料集合,也就是說資料集合事先確定,不會再變。另一類是針對動態資料集合,也就是說資料集合事先不確定,有資料動態地加入到集合中。    針對靜態資料集合,如何在包含n個資料的陣列中,查詢前K大資料呢?我們可以維護一個大小為k的小頂堆,順序遍歷陣列,從陣列中取出資料和堆頂元素比較。如果比堆頂元素大,我們就把堆頂元素刪除,並且將這個元素插入到堆中;如果比堆頂元素小,我們就不做處理,繼續遍歷陣列。這樣等陣列中的資料都遍歷完成之後,堆中的資料就是前K大資料了。    針對動態資料求得topK,也就是實時topK。怎麼理解呢?我舉個例子。一個資料集合中有兩個操作,一個是新增資料,另一個就是詢問當前的前K大資料。     如果每次詢問前k大資料時,我們都基於當前的資料重新計算的話,那時間複雜度就是O(nlogN),n表示當前資料的大小。實際上我們可以一直維護一個k大小的小頂堆,當有資料要新增到集合中時,我們就拿它與堆頂元素做對比。如果比堆頂元素大,我們把堆頂元素刪除,並將這個元素插入到堆中;如果比堆頂元素小,我們則不做處理。這樣,不論何時需要查詢前K大資料,我們都可以立刻返回給它。

      更多硬核知識,請關注公眾號“程式設計師學長”。

 

 

 

相關文章