面試算法理解篇——堆排序的應用

前言

這篇仍然是面試筆試中的一篇小總結。再次申明:不足之處請各位不吝賜教。

堆排序算法

首先說說什麼叫堆,以及和BST(二叉查找樹,二叉排序樹),AVL(平衡二叉樹)的區別。
堆在內存中很常見,一般內存中分配都是以堆棧分配的(這裏原諒渣渣我對jvm虛擬機還沒有足夠深的認識,這裏不敢瞎說)。堆考的最多的有兩種,最大堆和最小堆。簡單來說:

  • 最大堆:樹的父親節點比它的子結點大,即Tree[i]>=Tree[i2]&&Tree[i]>=Tree[i2+1]
  • 最小堆:樹的父節點比它的子結點小,即Tree[i]>=Tree[i2]&&Tree[i]>=Tree[i2+1]

二叉查找樹:二叉查找樹定義及算法戳這裏
平衡二叉樹: 戳這裏

算法講解依舊引用前輩們的博客,貼出幾個把堆排序講解的很好很透徹很容易理解的博客
博客1
博客2
博客3(這個博客之前看過一個版本,代碼是錯誤的,應該是轉載的這位大神的,後來刪除了,特別恨轉載不標明出處的,更可恨的是,你還轉載了一個錯誤的代碼給別人,錯誤還在不太容易發現的地方)
博客4
博客5
好的博客是在太多了,這裏記錄下來是爲了自己以後方便,這篇博文也是加深理解篇,以後忘了堆排序的時候,可以回過頭來看看,看着這些性質,可以快速的回憶和總結出來。
首先,拿出堆排序的算法,堆有兩種寫法,一種是遞歸的,一中是非遞歸的。我個人認爲遞歸的好理解一些,所以先拿出遞歸的算法(這裏直接應用博客5中的算法)。

聲明:注意根節點是0還是1,如果根節點是1,那麼i結點的左結點是2*i,右結點是2*i+1,父節點是i/2。如果根結點是0,那麼i結點的左結點是2*i+1,右結點是2*i+2,父結點是(i-1)/2;

//遞歸解法(最大堆)
void adjust_max_heap_recursive(int *datas,int length,int i)
{
    int left,right,largest;
    int temp;
    left = LEFT(i);   //left child
    right = RIGHT(i); //right child
    //find the largest value among left and rihgt and i.
    if(left<=length && datas[left] > datas[i])
        largest = left;
    else
        largest = i;
    if(right <= length && datas[right] > datas[largest])
        largest = right;
    //exchange i and largest
    if(largest != i)
    {
        temp = datas[i];
        datas[i] = datas[largest];
        datas[largest] = temp;
        //recursive call the function,adjust from largest
        adjust_max_heap(datas,length,largest);
    }
}

用語言描繪下思路:用root表示父結點,left表示左結點,right表示右結點。
建立堆時,要從下往上調整,從右往左遍歷所有的非葉子結點。先把left和right調整好,確保left和right已經是最大或者最小堆了,再來調整root。
每次調整某棵樹時,從上往下調整,選取父節點,左右子結點中相對較大的與父節點交換。如果父結點就是最大的結點,那麼不用交換,該樹已經滿足最大堆的性質了,如果左結點最大,那麼將父結點和子結點交換,交換後,父結點作爲left結點的左子樹不一定滿足最大堆的性質,所以需要同樣的思想遞歸到下一層,而右子樹肯定依然是滿足最大堆性質的。

//非遞歸調整最大堆代碼
void adjust_max_heap(int *datas,int length,int i)
{
    int left,right,largest;
    int temp;
    while(1)
    {
        left = LEFT(i);   //left child
        right = RIGHT(i); //right child
        //find the largest value among left and rihgt and i.
        if(left <= length && datas[left] > datas[i])
            largest = left;
        else
            largest = i;
        if(right <= length && datas[right] > datas[largest])
            largest = right;
        //exchange i and largest
        if(largest != i)
        {
            temp = datas[i];
            datas[i] = datas[largest];
            datas[largest] = temp;
            i = largest;
            continue;
        }
        else
            break;
    }
}
//建立堆的代碼
void build_max_heap(int *datas,int length)
{
    int i;
    //build max heap from the last parent node
    for(i=length/2;i>0;i--)
        adjust_max_heap(datas,length,i);
}

堆排序的思想就是在已經建立好最大堆的基礎上,把root結點a[0]和最後一個結點a[n-1]交換,把a[0]到a[n-2]重新調整爲最大堆,再把a[0]和a[n-2]交換,再調整堆,再把a[0]和a[n-3]交換…以此類推,最終的出一個有序的數組。
然後是堆的插入,刪除,以及穩定性。
這裏引用MoreWindows 白話經典算法系列之七 堆與堆排序

/*
每次插入都是將新數據放在數組最後。可以發現從這個新數據的父結點到根結點必然爲一個有序的數列,現在的任務是將這個新數據插入到這個有序數據中——這就類似於直接插入排序中將一個數據併入到有序區間中,對照《白話經典算法系列之二 直接插入排序的三種實現》不難寫出插入一個新數據時堆的調整代碼:
*/
//  新加入i結點  其父結點爲(i - 1) / 2
void MinHeapFixup(int a[], int i)
{
    int j, temp;
      
       temp = a[i];
       j = (i - 1) / 2;      //父結點
       while (j >= 0)
       {
              if (a[j] <= temp)
                     break;
             
              a[i] = a[j];     //把較大的子結點往下移動,替換它的子結點
              i = j;
              j = (i - 1) / 2;
       }
       a[i] = temp;
}

更簡短的表達爲:


void MinHeapFixup(int a[], int i)
{
       for (int j = (i - 1) / 2; j >= 0 && a[i] > a[j]; i = j, j = (i - 1) / 2)
              Swap(a[i], a[j]);
}
插入時:
//在最小堆中加入新的數據nNum
void MinHeapAddNumber(int a[], int n, int nNum)
{
       a[n] = nNum;
       MinHeapFixup(a, n);
}

然後是刪除,同樣應用這篇博文,因爲這篇博文實在是太經典了。

按定義,堆中每次都只能刪除第0個數據。爲了便於重建堆,實際的操作是將最後一個數據的值賦給根結點,然後再從根結點開始進行一次從上向下的調整。調整時先在左右兒子結點中找最小的,如果父結點比這個最小的子結點還小說明不需要調整了,反之將父結點和它交換後再考慮後面的結點。相當於從根結點將一個數據的“下沉”過程。下面給出代碼:
//  從i節點開始調整,n爲節點總數 從0開始計算 i節點的子節點爲 2*i+1, 2*i+2
void MinHeapFixdown(int a[], int i, int n)
{
    int j, temp;
 
       temp = a[i];
       j = 2 * i + 1;
       while (j < n)
       {
              if (j + 1 < n && a[j + 1] < a[j]) //在左右孩子中找最小的
                     j++;
 
              if (a[j] >= temp)
                     break;
 
              a[i] = a[j];     //把較小的子結點往上移動,替換它的父結點
              i = j;
              j = 2 * i + 1;
       }
       a[i] = temp;
}
//在最小堆中刪除數
void MinHeapDeleteNumber(int a[], int n)
{
       Swap(a[0], a[n - 1]);
       MinHeapFixdown(a, 0, n - 1);
}

提一點,這裏的swap,不能是自定義的函數來交換,因爲這樣只是在函數裏面交換,函數調用完回來,依然沒用交換,具體原因可以參考c語言指針。
下面說一下堆排序的運用。堆的應用實在太多了,排序只是一種,最重要的,是堆調整隻有log(n)的這種思想。這個後面有時間再好好總結一下。

應用1:

2個有序數組中,前k個小的數。
這裏想法有好幾個

思路一

用歸併排序的思想,把兩個數組合並,然後找出前k個,這裏不需要合併兩個數組,只需要在比較時計數,找出前k個值就行,複雜度爲O(k)。

思路二

二分,先找到第一個數組的a[n/2],然後在第二個數組中找到第一個比a[n/2]小的位置,如果兩個和加起來大於k,縮小n/2,小於k,放大n/2。這裏複雜度是log(n)*log(n);很明顯,當K的值很大的時候,適合這種方法。

思路三

用堆。這個題目用堆其實意義不大,因爲只有兩個數組,很明顯看出選取哪個數組中的最小值,但是下面這個擴展的題目,就不得不使用堆了。

擴展:m個有序數組,前k個小的數。

2個好做,m個呢?

  • 用思路一,把m個數組合並?這裏就不好合並了,2個數組直接就可以知道選取哪個值。所以,需要先兩兩合併,最後再回歸到兩個來解決,這裏的複雜度就不好計算了,因爲每次合併,數組的個數都增加了,下一次再次合併就不是這個複雜度了,但是一共需要合併(log m)次是確認的。
  • 思路二?第一次二分,二分複雜度是logn,複雜度是 (m-1)次二分(log n)(m-1),需要二分log(n)次,所以總複雜度(log(n))(m-1)次方,m過大顯然受不了。
    最快的方法就是思路三,維護一個k堆,先把每個數組的第一個值放在堆裏面,每次取出堆頂,放進與堆頂相鄰的元素,下面是例題。

相關題目

這裏給出leetcode的題目連接地址K-th Smallest Prime Fraction
AC代碼如下


    Comparator<Node> comparator = new Comparator<Node>() {
        @Override
        public int compare(Node o1, Node o2) {
            if (o1.node > o2.node)
                return 1;
            else {
                return -1;
            }
        }
    };
    class Node {
        int x;
        int y;
        double node;

        public Node(int x, int y, double node) {
            this.x = x;
            this.y = y;
            this.node = node;
        }
    }

    public int[] kthSmallestPrimeFraction(int[] A, int K) {
        Queue<Node> queue = new PriorityQueue<>(comparator);
        int len = A.length;
        int[] b = new int[A.length];

        for (int i = 0; i < A.length; i++) {
            b[i] = A[A.length - i - 1];
        }
        for (int i = 0; i < A.length; i++) {
            queue.add(new Node(i, 0, 1.0 * A[0] / b[i]));
        }
        Node ans = null;
        while (K-- != 0) {
            Node node = queue.poll();
            ans = node;
            if (node.y + 1 < len) {
                Node tN = new Node(node.x, node.y + 1, 1.0 * A[node.y+1] / b[node.x]);
                queue.add(tN);
            }
        }
        return new int[]{A[ans.y],b[ans.x]};
    }
額外思考

這個題目和2個有序數組a[m],b[n],求前k個a[i]+b[j]的最小值。這個題目也是運用堆的思想,維持大小爲n的堆不變,把a[0]+b[j](j從0到n-1)的n個值放入堆中,每次取出堆頂,放入堆頂元素右邊的位置進去,比如,堆頂元素是a[1]+b[j],那麼放進堆的值就是a[2]+b[j],再調整堆。

總結:

堆的特點:

運用的最多的特點,就是可以直接從堆頂取到最大值或最小值,再次維護只需要logn的複雜度。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章