演算法學習 - 歸併排序

吳與倫發表於2018-08-25

首先放上波波老師的《演算法與資料結構》這門課程地址:coding.imooc.com/class/71.ht… 誠心推薦。

當我們在解決一個問題的時候,通常分兩步:第一步是解決這個問題,第二步是如何更好的解決這個問題。第二步就是在第一步的基礎上看看原先使用的方法,有沒有改進或者優化的地方,或者有沒有更好的方法解決問題。對於解決排序問題,上一章主要介紹了時間複雜度為O(n^2)級別的基礎排序演算法,選擇排序,插入排序,以及氣泡排序,這是解決問題的第一步。這三種排序演算法都能解決排序的問題,但也存在不足,效率太低了,我們前面的資料量只有8個元素,在計算機上很快就會有結果,或許只有幾納秒,但是當資料量是百萬或者千萬級別的時候,因為時間複雜度為O(n^2),要經歷兩次遍歷,那麼百萬或者千萬級別的資料倍乘起來的時間的消耗將是巨大的。那麼在大的資料量上,使用這三種排序演算法的時間是多少呢?準備一個測試用例分別計算這三種排序演算法在1百萬資料量上的效能。

演算法效能測試

下面這個函式的作用是建立一個隨機的待排序的陣列,陣列內的元素型別為int,元素的取值範圍為[rangeL,rangeR],元素個數為n。

    int *generateRandomArray(int n, int rangeL, int rangeR){
        int *arr = new int[n];
        srand(time(NULL));
        for (int i = 0; i < n; i++) {
            arr[i] = rand() % (rangeR - rangeL + 1) + rangeL;
        }
        return arr;
    }
複製程式碼

下面這個函式是判斷陣列內的元素是否是有序,測試時設定的有序是從小到大的排列。基本邏輯迴圈遍歷這個陣列,如果遍歷到當前元素比後一個元素大的話,說明這個陣列不是有序的,返回false。

    template <typename S>
    bool isSorted(S arr[],int n){
        for (int i = 0; i < n - 1; i++) {
            if (arr[i] < arr[i+1]) {
                return false;
            }
        }
        return true;
    }
複製程式碼

下面這個函式為主要的測試函式,傳入的第一個引數為一個字串,使用時傳入排序演算法的名字,方便列印輸出時檢視。第二個引數是一個函式指標,這個函式的引數有兩個,第一個是待排序的陣列,第二個是陣列元素的個數。後面兩個引數為待排序陣列和陣列的個數,用於函式呼叫時傳參。

    template <typename T>
    void testSort(string sortName,void(*sort)(T arr[], int i),S arr[],int i){
        clock_t startTime = clock();
        sort(arr,i);
        clock_t endTime = clock();
        assert(isSorted(arr, i));
        cout<<sortName<<":"<< (double)(endTime - startTime) / CLOCKS_PER_SEC<<"s"<<endl;
    }
複製程式碼

整體邏輯在startTime 與 endTime中間呼叫排序函式,排序完成後呼叫isSorted()函式來測試陣列的有序性,如果返回的false,則丟擲異常。沒問題就列印endTime與startTime兩者時間的差值,單位是秒(s)。

接下來我們就來測試下這三個函式將100萬個數排序所需要花費的時間,將上一章中寫好的排序函式傳入到測試函式中。

演算法學習 - 歸併排序

這裡的傳入的陣列的個數n起初是傳入1百萬,但是執行後我等了好久好久還沒出結果,我不知道要等到什麼時候去。所以改為了10萬。可以看到氣泡排序花費了36秒,比選擇排序11.7秒和插入排序8秒要多的多。原因是因為排序時相鄰元素進行了大量的兩兩交換操作,交換是需要時間的,所以比前面兩種排序耗時。氣泡排序也有很多優化的地方,不過在這裡不是重點,這裡就不再贅述了。可以看到插入排序的時間最少,在實現插入排序時我們開闢了一個臨時空間來儲存將要排序的元素,避免了一些交換的操作。對這裡不太理解的可以看我的上一篇文章:juejin.im/post/5b7cd3… 在這篇文章中的關於插入排序的優化部分。當開闢更多的臨時空間來輔助排序,以空間來換取時間,就成為了另一種考慮排序的方法了,在這種思考方向上就出現了歸併排序快速排序這兩種時間複雜度為0(nlogn)的高階排序方法,本章主要介紹歸併排序,在介紹之前先簡單比較一下設計時間複雜度為O(nlogn)的演算法比0(n^2)在時間上的優勢。

O(nlogn)與0(n^2)在時間上的比較

演算法學習 - 歸併排序

可以看出來,當n的取值不斷增大,也就是數量級不斷增大的時候,nlogn比n^2的執行速度的倍數,也就是圖中的時間對比下的數字,變得越來越大,當n = 100000時,nlogn比n^2快了6020倍。打個比方,nlogn執行需要一天時間的話,那麼n^2級別的演算法就需要6020天,一年365天,17年後才能知道結果。如果數量級更大,時間上的差異會更明顯。所以設計效率更高的排序演算法就很有必要了。下面開始介紹效率更高的歸併排序。

歸併排序的實現思想

同樣給定一組待排序的陣列,8個元素。

演算法學習 - 歸併排序

歸併排序的思想是先將這個陣列分成2個部分,讓這兩個部分先單獨排序。然後再將這兩個部分歸併起來。整個陣列被分成了2部分,一部分為3,6,4,1,另一部分為 8,5,7,2。

演算法學習 - 歸併排序

為了對這兩個部分進行排序,又分別對這兩個部分進行切割。

演算法學習 - 歸併排序

然後繼續先切分,再進行歸併。我們看到這個陣列就被切分成一個一個單獨的元素。每一個元素不用進行排序就是有序的了。切割好後,下一步就是從下往上進行歸併。

演算法學習 - 歸併排序

歸併開始。 3 和 6 這兩組資料歸併成一組有序的資料,4 和 1 這兩組資料歸併成一組有序的資料,8 和 5 這兩組資料歸併成一組有序的資料,7 和 2 歸併成一組有序的資料,圖片紅色部分代表每一個小部分歸併完成。可以看到紅色部分為下面藍色中兩組資料歸併後變得有序了。

演算法學習 - 歸併排序

然後繼續 3,6 與 1,4進行歸併,5,8 與2,7進行歸併,那麼此時1,3,4,6歸併完成,2,5,7,8歸併完成

演算法學習 - 歸併排序

繼續將這兩個部分歸併成一個整體。

演算法學習 - 歸併排序

那麼紅色區域整個陣列就排好序了。以上就是歸併排序的思想,先平均切分成兩個部分,對每一個部分分別排好序後,再歸併成一個有序的整體。 重點來了,這個歸併過程是怎樣的?如果可以的話,那麼我們就可以使用遞迴的過程先切割在逐層歸併來完成整個排序。

選取最後一步的歸併過程進行講解。

歸併過程

看下圖,左右兩部分都已排好了序,歸併的過程就是將這兩部分合併成一個有序的整體。我們們一步步講解如何進行歸併。

演算法學習 - 歸併排序

首先開闢一塊與這個陣列同樣大小的臨時的空間來輔助我們完成這個操作。

演算法學習 - 歸併排序

那麼現在要使用三個索引對陣列內的元素進行追蹤。將開闢的臨時空間中的兩部分的黃色箭頭所指向的首元素進行比較。

演算法學習 - 歸併排序

左邊部分的1與右邊部分的2我稱之為待排序的元素,然後將這兩個元素進行比較,1比2小,所以將1放到原陣列綠色箭頭的首元素上,此時1變成紅色,表示已排好序。

演算法學習 - 歸併排序

1已經排好序了,那麼就要將黃色箭頭向後挪一位,此時指著3,同時上面陣列中的第一個元素1,也就是紅色部分已經排好序了,所以將綠色箭頭後移。如圖示。

演算法學習 - 歸併排序

同樣將臨時空間中左右兩部分的黃色箭頭所指向的3與2進行比較,2比3小,所以將2放到上面陣列所指向的位置上,此時紅色區域的1與2就排序完成了。如圖示。

演算法學習 - 歸併排序

繼續,2排好序後,將臨時空間右邊的黃色箭頭往後移一位,指向待排序的元素5。因為2已排序完成,將原陣列的綠色箭頭指向下一個位置。

演算法學習 - 歸併排序

將兩個黃色箭頭所指向的3與5進行比較,3比5小,所以將3放到綠色箭頭所指向的位置上。

演算法學習 - 歸併排序

繼續,綠色箭頭像後移一位,左邊黃色箭頭也需要往後移一位,指向下一個待排序的元素4。

演算法學習 - 歸併排序

繼續進行比較。4比5要小,將4放到綠色箭頭下的位置。

演算法學習 - 歸併排序

4排好序後,對應的黃色箭頭指向下一個待排序的元素6。綠色箭頭往後挪,準備承接下一個將要放置的元素。

演算法學習 - 歸併排序

繼續進行比較。5比6小,將5放到綠色箭頭下的位置。

演算法學習 - 歸併排序

5排好序後,黃色箭頭後移,指向下一個待排序的元素7,綠色箭頭後移,準備承接下一個將要放置的元素。

演算法學習 - 歸併排序

繼續進行比較。6比7要小,所以將6放到綠色箭頭下的位置。

演算法學習 - 歸併排序

此時,左邊的元素1,3,4,6已全部排序完成了。所以右邊的7與8兩個元素直接放置到元素的剩下位置上就好了。排序完成。

演算法學習 - 歸併排序

排序過程總結:先開闢一塊與待排序陣列同樣大小的臨時空間,將這塊空間中的兩個已排好序的陣列內的元素一一比較,將比較較小的元素放置到原陣列對應的位置上。

程式碼實現

從前面的圖片演示中,我們設立了三個索引的位置,在程式碼實現中,我們必須把他們定義清楚,尤其要注意,對於邊界條件的處理。這樣在編碼過程中才不會出問題。

如圖所示,將這三個索引的位置分別定義為i,j,k。 i,j表示當前正在進行比較的兩個元素,k表示這兩個元素比較後得到的結果最終將要放置的位置。這裡需要注意的是,k不表示歸併結束後放置的元素的位置,而是表示下一個需要放置的位置。我們在寫演算法過程中,就需要時刻維護這些變數,使他們在演算法執行過程中,始終滿足他們所代表的定義。

演算法學習 - 歸併排序

接下來我們要對一些邊界情況進行處理,我們設定這個陣列是在一個前閉後閉的區間中,在陣列最左邊的位置為l(left),最右邊的元素為r(right)。所以這個陣列的區間為[l,r];

演算法學習 - 歸併排序

接下來再定義一個變數m(middle),用來區分左邊已排好序的部分和右邊排好序的部分,給他的定義是:左邊排好序的最後一個元素的位置,如圖所示。

演算法學習 - 歸併排序

所以左邊已排好序的區間為[l,m],右邊已排好序的區間為[m + 1,r], 所以i的取值範圍是 0 <= i <= m; m + 1 <= j <= r;

定義好這些變數後,下面開始寫實現程式碼:

依舊寫成一個函式,傳入的引數為待排序的陣列和陣列的元素個數。在上面的實現中也可以看到,歸併排序的本質其實是一次逐層進行遞迴的過程。歸併排序的實現中,呼叫的 __mergeSort就為遞迴函式

template <typename T>
void mergeSort(T arr[], int n){
    __mergeSort(arr,0,n - 1);
}
複製程式碼

下面繼續看遞迴函式的實現。傳入的引數為待排序的陣列,l ,r 分別為陣列最左邊與最右邊元素的索引,所以是對陣列的範圍arr[l,r]內的元素進行排序。

首先先處理遞迴到底的情況,也就是當l >= r,時,l > r不可能發生,也就是當l = r時,此時由於區間是前閉後閉,區間[l,r]中就只有一個元素,此時我們的遞迴函式就直接返回return回去就好了,否則的話,定義一箇中間變數m,根據前面談過的m表示左邊的已排好序的最後一個元素,將這個區間平分成左右兩個部分,那麼兩部分的區間範圍分別為[l,m],與[m+1,r],然後再呼叫該函式,進行遞迴,對著兩部分的範圍內的元素進行排序,然後這兩個部分依次進行遞迴,當這兩部分排好序之後,再呼叫__merge(),對這兩個部分進行歸併。


// 遞迴使用歸併排序,對arr[l,r]範圍內的元素進行排序
template <typename T>
void __mergeSort(T arr[], int l,int r){
    if (l >= r) {
        return;,
    }
    
    int m = (l + r) / 2;
    
    __mergeSort(arr, l, m);
    __mergeSort(arr, m + 1, r);
    __merge(arr,l,mid,r);
}
複製程式碼

下面,我們來看 __merge這個函式的具體實現, __merge這個函式的功能是將arr[l...m],以及 arr[m+1...r],這兩個陣列內的元素進行歸併。我們一步步看這個函式的實現。

  • 先建立一個臨時的輔助陣列temArr,因為定義陣列邊界是前閉後閉的,所以陣列的大小為 r-l後需要再加上1;
  • 然後進行for迴圈,對這個臨時陣列內的元素進行賦值,注意新建立的元素索引是從0開始的,而傳入的陣列是從l開始的,所以賦值時有l的偏移量,。
  • 定義兩個變數i,j。表示當前左右兩部分正在進行比較的兩個元素。
  • 在for迴圈中進行比較排序,k表示這i,j兩個元素比較後得到的結果最終將要放置的位置,k的範圍也就是要要歸併的兩個陣列 的整體範圍,也就是arr[l,r]。
  • 首先先維護i,與j的範圍,i最多小於等於m,當i大於m時,就說明左邊部分已排序完成,那麼右邊剩餘的為排序的部分直接賦值給arr剩下未排序的部分就好了。同理,j最多小於等於r,當j大於r時,就說明右邊部分已排序完成,那麼左邊剩餘的為排序的部分直接賦值給arr剩下未排序的部分就好了。
  • 然後比較兩邊元素的大小,小的賦值給arr[k],再進行i,j在定義上的維護。至此歸併過程完成。
//arr[l...m],以及 arr[m+1...r],這兩個陣列內的元素進行歸併。
template <typename T>
void __merge(T arr[],int l,int mid,int r){
    // 建立一個臨時空間
    T temArr[r-l+1];
    for (int i = l; i<=r; i++) {
        temArr[i-l] = arr[i];
    }
    
    int i = l,j = mid + 1;
    for (int k = l; k <= r; k++) {
        
        if (i>mid) {
            arr[k] = temArr[j-l];
            j++;
        }else if (j>r) {
            arr[k] = temArr[i-l];
            i++;
        } else if (temArr[i-l] < temArr[j-l]) {
            arr[k] = temArr[i-l];
            i++;
        } else {
            arr[k] = temArr[j-l];
            j++;
        }
    }
}
複製程式碼

歸併演算法效能測試

利用前面寫好的測試用例,對寫好的歸併排序的演算法進行效能測試,測試資料量和前面的10萬保持一致,來比較與基礎演算法的差異。

可以看到,歸併排序只用了0.02秒。效能比選擇排序快了580倍,比插入排序快了396倍,比氣泡排序快了1796倍。差異明顯。

演算法學習 - 歸併排序

好了,歸併排序就介紹到這裡,暫時還沒講歸併排序的優化,以後再做補充吧。

感謝您能看到最後,篇幅略長,我不清楚我講述的您是否理解,如果有不理解的內容,煩請提出,我一定做詳細的解釋。如果文章內容有錯誤,煩請指正。如果您喜歡我的文章,請關注我。

相關文章