C++編寫經典算法之一:歸併排序MergeSort(通俗易懂)

歸併排序

  • "歸併排序"是數列排序的算法之一。
  • 其思路引點來自於著名的“分治”思想和“遞歸思想”。

“分治,字面上的解釋是“分而治之”,就是把一個複雜的問題分成兩個或更多的相同或相似的子問題,再把子問題分成更小的子問題……直到最後子問題可以簡單的直接求解,原問題的解即子問題的解的合併。在計算機科學中,分治法就是運用分治思想的一種很重要的算法。”

在這裏插入圖片描述

而遞歸的思想,做爲一種算法在程序設計語言中廣泛應用。 一個過程或函數在其定義或說明中有直接或間接調用自身的一種方法,它通常把一個大型複雜的問題層層轉化爲一個與原問題相似的規模較小的問題來求解,遞歸策略只需少量的程序就可描述出解題過程所需要的多次重複計算,大大地減少了程序的代碼量。遞歸的能力在於用有限的語句來定義對象的無限集合。一般來說,遞歸需要有邊界條件、遞歸前進段和遞歸返回段。當邊界條件不滿足時,遞歸前進;當邊界條件滿足時,遞歸返回。這種特性與“分治”不謀而合,故大部分的分治算法都是通過遞歸實現的。

一、算法思路

假設有如下數列:
在這裏插入圖片描述
首先,我們將數列分成兩半。
在這裏插入圖片描述

在這裏插入圖片描述在這裏插入圖片描述

直至數列的最小單位。

接下來,將結合我們分割的每個組。這個過程稱爲 “合併”。

合併時,按照數字的升序移動(如果你是升序排序的話),使得合併後的數字在組內按升序排列。

在這裏插入圖片描述

在這裏插入圖片描述

當合幷包含多個數字的組時,我們只比較開頭的數字,移動較小的數字。

在這裏插入圖片描述

在圖中,比較左組開頭的“4”和右組開頭的“3”。

4>3,所以移動“3”。

在這裏插入圖片描述

同樣,我們記住,只比較餘列的開頭數字。

在這裏插入圖片描述

4<7,所以我們移動“4”。

在這裏插入圖片描述

6<7,所以移動“6”。

在這裏插入圖片描述

最後只剩下“7”了,直接移動上去。

因爲這個過程是完全相同的,所以我們可以使用遞歸實現。遞歸地重複合併操作,直到所有的數字都在一個組中。

在這裏插入圖片描述

在這裏插入圖片描述

在這裏插入圖片描述

等整個過程完成後,就實現了歸併排序。

二、動畫演示

在這裏插入圖片描述

三、道理都懂了,可是怎麼就實現了排序?

其實,剛接觸歸併排序的人,會困於兩個點:分治和合並。

分治,其實思想很簡單,不斷的劃分,但很多人卡在了實現的過程中,用什麼實現?如何實現?

上文,我們講到,遞歸與分治的思想部分吻合,因此用遞歸實現分治的劃分是一個不錯的選擇。

/*
 * Method mergeSort has three parameters,the first of them is initial array,then,second is the initiative index of current array,finally third one is the last index of current array.
 * 歸併排序方法有三個參數,第一個是初始的數組,第二個是該數組的起始索引,第三是該數組的尾巴索引。
 */
void mergeSort(int *ms,int startIndex,int endIndex){
    //如果數列劃分至最小單位(一個數)則停止分割
    if(endIndex-startIndex>0){
        //將數列分爲左右部分進行分治
        mergeSort(ms,startIndex,(startIndex+endIndex)/2);//左分治
        mergeSort(ms,((startIndex+endIndex)/2)+1,endIndex);//右分治
        merge(ms,startIndex,endIndex);//歸併
    }
}

因爲這個劃分的過程就是不斷的劃分劃分…通過遞歸,不斷的調用自身,從而實現參數的暫存,數據的緩存,利用一個函數,將整個大問題,劃分至單位問題。

第二個點就是合併。其實分治與合併相比,是很簡單的。合併的難點在於,我們到底怎麼合併?用什麼合併?合併後會發生什麼?

這裏作者建議使用數組的特性,因爲我們利用函數傳遞的是數組元素頭的地址,而不是整個數組,或一個新的數組,所以我們可以通過指針實現將所有的操作在初始待排序的數組上實現。

void merge(int *ms,int startIndex,int endIndex){
......
}

那麼只剩下解決最後一個問題:如何合併。

這裏試想一個問題:將兩個數組,排序後,放置到一個新數組中,該怎麼做?

這個問題咋一看似乎很簡單?實際上存在着也許技巧。

這裏作者提供兩個方法供大家思考。

  1. 利用C++的結束符
    我們都知道C++中,字符串,數組等都會默認的在生成時在結尾添加一個結束符,方便我們輸出,那麼我們就可以利用這個結束符。
    首先,用上文算法思路里提到的,我們只比較兩個數組的最左端(其實是指針所指向的最左),將較小的存入新數組中(其實我們會使用初始待排序的那個數組來存,節省空間),不斷重複這個過程,當不論是左數組到頭了,還是右數組到頭了,我們利用if判斷是否到了結束符即可,再將還有剩餘的數組剩下的全部存入新數組即可。但,我不推薦這個方法,太直接,太笨了。
  2. 利用數組的length
    這個方法我很推薦,在於,我們利用了現有的資源。同上,我們我們只比較兩個數組的最左端(其實是指針所指向的最左),將較小的存入新數組中(其實我們會使用初始待排序的那個數組來存,節省空間),不斷重複這個過程,直到,我們的if判斷,判定其中的一個數組到達了它的長度,那麼我們就將另一個有剩餘的數組剩下的全部存入新數組即可。

方法2看似很容易,但實現起來,需要很細心的去利用index,下面只貼法2的代碼,請讀者細細體會,跟着代碼走一走。

void merge(int *ms,int startIndex,int endIndex){

    //進入歸併步驟時,數組將由兩個數組合並,升序排序劃分,左邊的稱之爲左數組,同理,右邊的稱之爲右數組。

    int left_mid = (startIndex+endIndex)/2;//待定左數組的右邊界
    int mid_right = ((startIndex+endIndex)/2)+1;//待定右數組的左邊界

    int left_length = (left_mid-startIndex)+1;//待定左數組長度
    int right_length = (endIndex-mid_right)+1;//待定右數組長度

    int left_array[left_length];//初始化左數組
    int right_array[right_length];//初始化右數組

    for(int i=left_mid;i>=startIndex;i--){//將左數組掛起
        left_array[i-startIndex] = ms[i];
    }

    for(int i=endIndex;i>=mid_right;i--){//將右數組掛起
        right_array[i-mid_right] = ms[i];
    }

    int l_index=0,r_index=0;//將兩個指針指向左、右數組的頭元素

    //雙數組循環排序,複雜度O(n),排序後的結果直接賦回原數組ms上
    for(int i=startIndex;i<=endIndex;i++){
        if(l_index!=left_length && r_index!=right_length){
            if(left_array[l_index]<right_array[r_index]){
                ms[i] = left_array[l_index++];
            }
            else{
                ms[i] = right_array[r_index++];
            }
        }
        else if(l_index==left_length){
            ms[i] = right_array[r_index++];
        }
        else{
            ms[i] = left_array[l_index++];
        }
    }

}

四、代碼清單及其測試結果

#include <iostream>
#include <ctime>
template <class T>

int getSizeOfArray(T& bs){
    return sizeof(bs)/ sizeof(bs[0]);
}


/*
 * Method merge has three parameters,the first of them is initial array,then,second is the initiative index of current array,finally third one is the last index of current array.
 * 歸併方法有三個參數,第一個是初始的數組,第二個是該數組的起始索引,第三是該數組的尾巴索引。
 */
void merge(int *ms,int startIndex,int endIndex){

    //進入歸併步驟時,數組將由兩個數組合並,升序排序劃分,左邊的稱之爲左數組,同理,右邊的稱之爲右數組。

    int left_mid = (startIndex+endIndex)/2;//待定左數組的右邊界
    int mid_right = ((startIndex+endIndex)/2)+1;//待定右數組的左邊界

    int left_length = (left_mid-startIndex)+1;//待定左數組長度
    int right_length = (endIndex-mid_right)+1;//待定右數組長度

    int left_array[left_length];//初始化左數組
    int right_array[right_length];//初始化右數組

    for(int i=left_mid;i>=startIndex;i--){//將左數組掛起
        left_array[i-startIndex] = ms[i];
    }

    for(int i=endIndex;i>=mid_right;i--){//將右數組掛起
        right_array[i-mid_right] = ms[i];
    }

    int l_index=0,r_index=0;//將兩個指針指向左、右數組的頭元素

    //雙數組循環排序,複雜度O(n),排序後的結果直接賦回原數組ms上
    for(int i=startIndex;i<=endIndex;i++){
        if(l_index!=left_length && r_index!=right_length){
            if(left_array[l_index]<right_array[r_index]){
                ms[i] = left_array[l_index++];
            }
            else{
                ms[i] = right_array[r_index++];
            }
        }
        else if(l_index==left_length){
            ms[i] = right_array[r_index++];
        }
        else{
            ms[i] = left_array[l_index++];
        }
    }

}

/*
 * Method mergeSort has three parameters,the first of them is initial array,then,second is the initiative index of current array,finally third one is the last index of current array.
 * 歸併排序方法有三個參數,第一個是初始的數組,第二個是該數組的起始索引,第三是該數組的尾巴索引。
 */
void mergeSort(int *ms,int startIndex,int endIndex){
    //如果數列劃分至最小單位(一個數)則停止分割
    if(endIndex-startIndex>0){
        //將數列分爲左右部分進行分治
        mergeSort(ms,startIndex,(startIndex+endIndex)/2);//左分治
        mergeSort(ms,((startIndex+endIndex)/2)+1,endIndex);//右分治
        merge(ms,startIndex,endIndex);//歸併
    }
}

int main() {
    using namespace std;
    int ms[] = {2,3,5,1,0,8,6,9,7};
    int size = getSizeOfArray(ms);

    cout<< "原數列:";

    for(int i = 0;i<size;i++)
    {
        cout<< ms[i] << " ";
    }

    cout<< "\n" << "歸併排序後:";

    mergeSort(ms,0,size-1);

    for(int i = 0;i<size;i++)
    {
        cout<< ms[i] << " ";
    }
    return 0;
}

在這裏插入圖片描述

五、算法分析

隨機數範圍:r屬於[0,100]的整數


樣本數(單位:個) 10 100 1,000 10,000 100,000 1,000,000
運行時間(單位:秒) 3*10-6 1.5*10-5 0.000201 0.002218 0.01838 0.2

歸併排序比較佔用內存,但卻是一種效率高且穩定的算法。
改進歸併排序在歸併時先判斷前段序列的最大值與後段序列最小值的關係再確定是否進行復制比較。如果前段序列的最大值小於等於後段序列最小值,則說明序列可以直接形成一段有序序列不需要再歸併,反之則需要。所以在序列本身有序的情況下時間複雜度可以降至O(n)
TimSort可以說是歸併排序的終極優化版本,主要思想就是檢測序列中的天然有序子段(若檢測到嚴格降序子段則翻轉序列爲升序子段)。在最好情況下無論升序還是降序都可以使時間複雜度降至爲O(n),具有很強的自適應性。
在這裏插入圖片描述

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