面試必知必會|理解堆和堆排序

本文將闡述堆和堆排序的基本原理,經過本文將瞭解到如下內容:算法

  1. 堆數據結構的定義
  2. 堆的數組表示
  3. 堆的調整函數
  4. 堆排序實踐

1.堆的簡介編程

堆是計算機科學中的一種特別的樹狀數據結構。
如果知足如下特性,便可稱爲堆:給定堆中任意節點P和C,若P是C的母節點,那麼P的值會小於等於C的值。若母節點的值恆小於等於子節點的值,此堆稱爲最小堆;反之稱爲最大堆。
堆始於J. W. J. Williams在1964年發表的堆排序,當時他提出了二叉堆樹做爲此算法的數據結構,堆在戴克斯特拉算法和帶優先級隊列中亦爲重要的關鍵。

數據結構中的堆區別於內存分配的堆,咱們說的用於排序的堆是一種表示元素集合的結構,堆是一種二叉樹。數組

堆有兩個決定性特性:元素順序和樹的形狀數據結構

  • 元素順序
    在堆中任何結點與其子結點的大小都遵照數值大小關係。
    A. 若是結點大於等於其全部子結點,也就是堆的根是全部元素中最大的,這種堆稱爲大根堆(大頂堆、最大堆);
    B. 若是結點小於等於其全部子結點,也就是堆的根是全部元素中最小的,這種堆稱爲小根堆(小頂堆、最小堆);
    C. 大根堆/小根堆只是約定了父結點和子結點的大小關係,可是並不約束子結點的相對大小和順序;
    如圖爲小根堆結構:

  • 樹的形狀

堆這種二叉樹最多在兩層具備葉子結點,而且最底層的葉子結點靠左分佈,該樹種不存在空閒位置,也就是堆是個徹底二叉樹。上述的兩種性質能夠保證快捷找到最值,而且在插入和刪除新元素時能夠實現從新組織再次知足堆的性質。函數

2.堆的數組表示oop

堆中沒有空閒位置而且數組是連續的,可是數組的下標是從0開始,爲了統一,咱們統一從1開始,也就是root結點的數組index=1,那麼能夠經過數組的index能夠經過父結點找到左右子結點,也能夠經過子結點找到父結點。數組的元素遍歷就是堆的層次遍歷的結果,所以數組存儲的堆具有如下性質:spa

//數組下標範圍
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

堆和數組的對應關係如圖:3d

3.堆的調整函數code

堆調整的過程很是像數學概括法的遞推過程,看一下就知道。orm

敲黑板!如下兩個函數對於掌握堆很是重要。

  • 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.參考資料

  1. 《編程珠璣》 第14章 堆
相關文章
相關標籤/搜索