12.分而治之-歸併排序

分而治之歸併排序

關注「碼哥字節」設置星標,接收最新技術乾貨提高自我。本文完整源碼詳見 Github:https://github.com/UniqueDong/algorithms.gitjava

前面咱們學習了時間複雜度 O(n²) 的經典排序算法:冒泡排序、插入排序、選擇排序,今天咱們來學習時間複雜度爲 O(nlogn) 的歸併排序,這種排序思想也更加經常使用。git

歸併排序和快速排序都用到了 分治思想github

做爲一種典型的分而治之思想的算法應用,歸併排序的實現由兩種方法:算法

  • 自上而下的遞歸(全部遞歸的方法均可以用迭代重寫,因此就有了第 2 種方法);
  • 自下而上的迭代;

原理

把數組從中間分紅左右兩部分,而後對左右兩部分分別排序,再將排序號的兩部分合並在一塊兒,最後整個序列有序。其實就是運用了分治思想,顧名思義,就是分而治之,將一個大問題分解成小的子問題。編程

是否是跟遞歸很像,分治通常都是用遞歸來實現。分治是一種解決問題的處理思想,遞歸是一種編程技巧。數組

遞推公式

以前咱們在 遞歸 篇說過,遞歸代碼的技巧就是分析遞推公式,找出終止條件,而後把遞推公式翻譯代碼。函數

遞推公式:
merge_sort(p…r) = merge(merge_sort(p…q), merge_sort(q+1…r))

終止條件:
p >= r 不用再繼續分解

我來解釋一下這個遞推公式。性能

merge_sort(p…r) 表示,給下標從 p 到 r 之間的數組排序。咱們將這個排序問題轉化爲了兩個子問題,merge_sort(p…q) 和 merge_sort(q+1…r),其中下標 q 等於 p 和 r 的中間位置,也就是 (p+r)/2。當下標從 p 到 q 和從 q+1 到 r 這兩個子數組都排好序以後,咱們再將兩個有序的子數組合並在一塊兒,這樣下標從 p 到 r 之間的數據就也排好序了。學習

public static void mergeSort(int[] arr) {
    sort(arr, 0, arr.length - 1);
}

public static void sort(int[] arr, int left, int right) {
    // 遞歸終止條件
    if(left >= right) {
        return;
    }
    // 獲取 left right 之間的中間位置
    int mid = left + ((right - left) >> 1);
    // 分治遞歸
    sort(arr, left, mid);
    sort(arr, mid + 1, right);
    merge(arr, left, mid, right);
}

// 合併數據
public static void merge(int[] arr, int left, int mid, int right) {
    int[] temp = new int[right - left + 1];
    int i = 0;
    int p1 = left;
    int p2 = mid + 1;
    // 比較左右兩部分的元素,哪一個小,把那個元素填入temp中
    while(p1 <= mid && p2 <= right) {
        temp[i++] = arr[p1] < arr[p2] ? arr[p1++] : arr[p2++];
    }
    // 上面的循環退出後,把剩餘的元素依次填入到temp中
    // 如下兩個while只有一個會執行
    while(p1 <= mid) {
        temp[i++] = arr[p1++];
    }
    while(p2 <= right) {
        temp[i++] = arr[p2++];
    }
    // 把最終的排序的結果複製給原數組
    for(i = 0; i < temp.length; i++) {
        arr[left + i] = temp[i];
    }
}

性能分析

第一,歸併排序是穩定的排序算法嗎?翻譯

你應該能發現,歸併排序穩不穩定關鍵要看 merge() 函數,也就是兩個有序子數組合併成一個有序數組的那部分代碼。

在合併的過程當中,若是 A[left…mid] 和 A[mid+1…right] 之間有值相同的元素,那咱們能夠像僞代碼中那樣,先把 A[left…mid] 中的元素放入 tmp 數組。這樣就保證了值相同的元素,在合併先後的前後順序不變。因此,歸併排序是一個穩定的排序算法。

第二,歸併排序的時間複雜度是多少?

歸併排序涉及遞歸,時間複雜度的分析稍微有點複雜。咱們正好藉此機會來學習一下,如何分析遞歸代碼的時間複雜度。

在遞歸那一節咱們講過,遞歸的適用場景是,一個問題 a 能夠分解爲多個子問題 b、c,那求解問題 a 就能夠分解爲求解問題 b、c。問題 b、c 解決以後,咱們再把 b、c 的結果合併成 a 的結果。

若是咱們定義求解問題 a 的時間是 T(a),求解問題 b、c 的時間分別是 T(b) 和 T( c),那咱們就能夠獲得這樣的遞推關係式:其中 K 等於將兩個子問題 b、c 的結果合併成問題 a 的結果所消耗的時間。

T(a) = T(b) + T(c) + K

咱們能夠獲得一個重要的結論:不只遞歸求解的問題能夠寫成遞推公式,遞歸代碼的時間複雜度也能夠寫成遞推公式。

咱們假設對 n 個元素進行歸併排序須要的時間是 T(n),那分解成兩個子數組排序的時間都是 T(n/2)。咱們知道,merge() 函數合併兩個有序子數組的時間複雜度是 O(n)。因此,套用前面的公式,歸併排序的時間複雜度的計算公式就是:

T(1) = C;   n=1 時,只須要常量級的執行時間,因此表示爲 C。
T(n) = 2*T(n/2) + n; n>1

經過這個公式,如何來求解 T(n) 呢?還不夠直觀?那咱們再進一步分解一下計算過程。

T(n) = 2*T(n/2) + n
     = 2*(2*T(n/4) + n/2) + n = 4*T(n/4) + 2*n
     = 4*(2*T(n/8) + n/4) + 2*n = 8*T(n/8) + 3*n
     = 8*(2*T(n/16) + n/8) + 3*n = 16*T(n/16) + 4*n
     ......
     = 2^k * T(n/2^k) + k * n
     ......

經過這樣一步一步分解推導,咱們能夠獲得 T(n) = 2k*T(n/2k)+kn。當 T(n/2^k)=T(1) 時,也就是 n/2^k=1,咱們獲得 k=log2n 。咱們將 k 值代入上面的公式,獲得 T(n)=Cn+n*log2n 。若是咱們用大 O 標記法來表示的話,T(n) 就等於 O(nlogn)。因此歸併排序的時間複雜度是 O(nlogn), 不論是最好狀況、最壞狀況,仍是平均狀況,時間複雜度都是 O(nlogn)。

第三,歸併排序的空間複雜度是多少?

歸併排序的時間複雜度任何狀況下都是 O(nlogn),看起來很是優秀。(待會兒你會發現,即使是快速排序,最壞狀況下,時間複雜度也是 O(n²)。)可是,歸併排序並無像快排那樣,應用普遍,這是爲何呢?由於它有一個致命的「弱點」,那就是歸併排序不是原地排序算法。

這是由於歸併排序的合併函數,在合併兩個有序數組爲一個有序數組時,須要藉助額外的存儲空間。

實際上,遞歸代碼的空間複雜度並不能像時間複雜度那樣累加。剛剛咱們忘記了最重要的一點,那就是,儘管每次合併操做都須要申請額外的內存空間,但在合併完成以後,臨時開闢的內存空間就被釋放掉了。在任意時刻,CPU 只會有一個函數在執行,也就只會有一個臨時的內存空間在使用。臨時內存空間最大也不會超過 n 個數據的大小,因此空間複雜度是 O(n)。

課後思考

如今你有 10 個接口訪問日誌文件,每一個日誌文件大小約 300MB,每一個文件裏的日誌都是按照時間戳從小到大排序的。你但願將這 10 個較小的日誌文件,合併爲 1 個日誌文件,合併以後的日誌仍然按照時間戳從小到大排列。若是處理上述排序任務的機器內存只有 1GB,你有什麼好的解決思路,能「快速」地將這 10 個日誌文件合併嗎?

回臺回覆:日誌排序,便可獲取答案哦。

後臺回覆「加羣」加入技術羣獲取更多成長,最新內容一手掌握

碼哥字節

相關文章
相關標籤/搜索