面試必知必會|理解堆和堆排序
本文將闡述堆和堆排序的基本原理,通過本文將瞭解到以下內容:
- 堆資料結構的定義
- 堆的陣列表示
- 堆的調整函式
- 堆排序實踐
1.堆的簡介
堆是電腦科學中的一種特別的樹狀資料結構。
若是滿足以下特性,即可稱為堆:給定堆中任意節點P和C,若P是C的母節點,那麼P的值會小於等於C的值。若母節點的值恆小於等於子節點的值,此堆稱為最小堆;反之稱為最大堆。
堆始於J. W. J. Williams在1964年發表的堆排序,當時他提出了二叉堆樹作為此演算法的資料結構,堆在戴克斯特拉演算法和帶優先順序佇列中亦為重要的關鍵。
資料結構中的堆區別於記憶體分配的堆,我們說的用於排序的堆是一種表示元素集合的結構,堆是一種二叉樹。
堆有兩個決定性特性:元素順序和樹的形狀
- 元素順序:
在堆中任何結點與其子結點的大小都遵守數值大小關係。
A. 如果結點大於等於其所有子結點,也就是堆的根是所有元素中最大的,這種堆稱為大根堆(大頂堆、最大堆);
B. 如果結點小於等於其所有子結點,也就是堆的根是所有元素中最小的,這種堆稱為小根堆(小頂堆、最小堆);
C. 大根堆/小根堆只是約定了父結點和子結點的大小關係,但是並不約束子結點的相對大小和順序;
如圖為小根堆結構:
- 樹的形狀:
堆這種二叉樹最多在兩層具有葉子結點,並且最底層的葉子結點靠左分佈,該樹種不存在空閒位置,也就是堆是個完全二叉樹。上述的兩種性質可以保證快捷找到最值,並且在插入和刪除新元素時可以實現重新組織再次滿足堆的性質。
2.堆的陣列表示
堆中沒有空閒位置並且陣列是連續的,但是陣列的下標是從0開始,為了統一,我們統一從1開始,也就是root結點的陣列index=1,那麼可以通過陣列的index可以通過父結點找到左右子結點,也可以通過子結點找到父結點。陣列的元素遍歷就是堆的層次遍歷的結果,因此陣列儲存的堆具備以下性質:
//陣列下標範圍 i<=n && i>=1 //根結點下標為1 root_index = 1 //層次遍歷第i個結點的值等於陣列第i個元素 value(i) = array[i] //堆中第i個元素的左孩子下標i*2 left_child_index(i) = i*2 //堆中第i個元素的右孩子下標i*2+1 right_child_index(i) = i*2+1 //堆中第i個元素的父結點下標i/2 parent(i) = i/2
堆和陣列的對應關係如圖:
3.堆的調整函式
堆調整的過程非常像數學歸納法的遞推過程,看一下就知道。
敲黑板!以下兩個函式對於掌握堆非常重要。
- siftup函式的原理
以小根堆為例,之前a[1...n-1]滿足堆的特性,在陣列a[n]插入新元素之後,就產生了兩種情況:
A. 如果a[n]大於父結點那麼a[1...n]仍然滿足堆的特性,不需要調整;
B. 如果a[n]比它的父結點要小無法保證堆的特性,就需要進行調整;
迴圈過程:自底向上的調整過程就是新加入元素不斷向上比較置換的過程,直到新結點的值大於其父結點,或者新結點成為根結點為止。
停止條件:siftup是一個不斷向上迴圈比較置換的過程,理解迴圈的關鍵是迴圈停止的條件,從偽碼中可以清晰地看到,siftup的偽碼:
//siftup執行的前置條件 heap(1,n-1) == True void siftup(n) i = n loop: // 迴圈停止條件一 // 已經是根結點 if i == 1: break; p = i/2 // 迴圈停止條件二 // 調整結點大於等於在此位置的父結點 if a[p] <= a[i] break; swap(a[p],a[i]) // 繼續向上迴圈 i = p
siftup調整過程演示
在尾部插入元素16的調整過程如圖:
- siftdn函式的原理
以小根堆為例,之前a[1...n]滿足堆的特性,在陣列a[1]更新元素之後,就產生了兩種情況:
A. 如果a[1]小於等於子結點仍然滿足堆的特性,不需要調整;
B. 如果a[1]大於子結點無法保證堆的特性,就需要進行調整;
迴圈過程:自頂向下的調整過程就是新加入元素不斷向下比較置換的過程,直到新結點的值小於等於其子結點,或者新結點成為葉結點為止。
停止條件:siftdn是一個不斷向下迴圈比較置換的過程,理解迴圈的關鍵是迴圈停止的條件,從偽碼中可以清晰地看到siftdn的偽碼:
heap(2,n) == True void siftdn(n) i = 1 loop: // 獲取理論上的左孩子下標 c = 2*i // 如果左孩子下標已經越界 // 說明當前已經是葉子結點 if c > n: break; //如果存在右孩子 // 則獲取左右孩子中更小的一個 // 和父結點比較 if c+1 <= n: if a[c] > a[c+1] c++ // 父結點小於等於左右孩子結點則停止 if a[i] <= a[c] break; // 父結點比左右孩子結點大 // 則與其中較小的孩子結點交換 // 也就是讓原來的孩子結點成為父結點 swap(a[i],a[c]) // 繼續向下迴圈 i = c
siftdn調整過程演示
在頭部元素更新為21的調整過程如圖:
4.堆排序
堆排序的場景:
假如有200w資料,要找最大的前10個數,那麼就需要先建立大小為10個元素的小頂堆,然後再逐漸把其他所有元素依次滲透進來比較或入堆淘汰老資料或跳過,直至所有資料滲透完成,最後小根堆的10個元素就是最大的10個數了。
最大TopN使用小根堆的原因:選擇最大的TopN個數據使用小根堆,因為堆頂就是最小的資料,每次進來的新資料只需要和堆頂比較即可,如果小於堆頂則跳過,如果大於堆頂則替換掉堆頂進行siftdn調整,來找到新進元素的正確位置,以及產生新的堆頂。
建堆過程:可以自頂向下自底向上均可,以下采用自底向上思路分析。可以將陣列的葉子節點,是單個結點滿足二叉堆的定義,於是從底層葉子結點的父結點從左到右,逐個向上構建二叉堆,直到第一個節點時整個陣列就是一個二叉堆,這個過程是siftup和siftdn的混合,巨集觀上來看是自底向上,微觀上每個父結點是自頂向下。
滲透排序過程:完成堆化之後,開處理N之後的元素,從N+1~200w,遇到比當前堆頂大的則與堆頂元素交換,進入堆觸發siftdn調整,直至生產新的小根堆。
例項程式碼(驗證AC):
題目:leetCode 第215題 陣列中的第K個最大元素,這道題可以用堆排序來完成,建立小根堆取堆頂元素即可。
//leetcode 215th the Kth Num //Source Code:C++ class Solution { public: //調整以當前節點為根的子樹為小頂堆 int heapadjust(vector<int> &nums,int curindex,int len){ int curvalue = nums[curindex]; int child = curindex*2+1; while(child<len){ //左右孩子中較小的那個 if(child+1<len && nums[child] > nums[child+1]){ child++; } //當前父節點比左右孩子其中一個大 if(curvalue > nums[child]){ nums[curindex]=nums[child]; curindex = child; child = curindex*2+1; }else{ break; } } nums[curindex]=curvalue; return 0; } int findKthLargest(vector<int>& nums, int k) { //邊界條件 if(nums.size()<k) return -1; //建立元素只有K個的小頂堆 //擷取陣列的前k個元素 vector<int> subnums(nums.begin(),nums.begin()+k); int len = nums.size(); int sublen = subnums.size(); //將陣列的前k個元素建立小頂堆 for(int i=sublen/2-1;i>=0;i--){ heapadjust(subnums,i,sublen); } //建立好小頂堆之後 開始逐漸吸收剩餘的陣列元素 //動態與堆頂元素比較 替換 for(int j=k;j<len;j++){ if(nums[j]<=subnums[0]) continue; subnums[0] = nums[j]; heapadjust(subnums,0,sublen); } return subnums[0]; } };
5.總結
網上有很多堆排序過程的圖解,本文因此並沒有過多重複這個過程,從實踐來看,重點是初始化堆和調整堆兩個過程,然而這兩個過程都離不開siftup和siftdn兩個函式,因此掌握這兩個函式,基本上就掌握了堆。
由於堆是二叉樹,因此在實際使用中需要結合樹的遍歷和迴圈來實現堆調整。掌握堆調整過程和二叉樹遍歷過程,拿下堆,指日可待。
6.參考資料
- 《程式設計珠璣》 第14章 堆