齊姐漫畫:排序算法(二)之「 歸併排序」和「外排序」

那咱們借用 cs50 裏的例子,好比要把一摞卷子排好序,那用並歸排序的思想是怎麼作的呢?java

  1. 首先把一摞卷子分紅兩摞;
  2. 把每一摞排好序;
  3. 把排好序的兩摞再合併起來。

感受啥都沒說?
那是由於上面的過程裏省略了不少細節,咱們一個個來看。面試

  1. 首先分紅兩摞的過程,均分,奇偶數無所謂,也就是多一個少一個的問題;
  2. 那每一摞是怎麼排好序的?

答案是用一樣的方法排好序。算法

  1. 排好序的兩摞是怎麼合併起來的?

這裏須要藉助兩個指針和額外的空間,而後左邊畫一個彩虹🌈右邊畫個龍🐲,不是,是左邊拿一個數,右邊拿一個數,兩個比較大小以後排好序放回到數組裏(至於放回原數組仍是新數組稍後再說)。編程

這其實就是分治法 divide-and-conquer 的思想。
歸併排序是一個很是典型的例子。數組

分治法

顧名思義:分而治之。分佈式

就是把一個大問題分解成類似的小問題,經過解決這些小問題,再用小問題的解構造大問題的解。ide

聽起來是否是和以前講遞歸的時候很像?函數

沒錯,分治法基本都是能夠用遞歸來實現的動畫

在以前,咱們沒有加以區分,固然如今我也認爲不須要加以區分,但你若是非要問它們之間是什麼區別,個人理解是:spa

  • 遞歸是一種編程技巧,一個函數本身調用本身就是遞歸;
  • 分治法是一種解決問題的思想

    • 把大的問題分解成小問題的這個過程就叫「分」,
    • 解決小問題的過程就叫「治」,
    • 解決小問題的方法每每是遞歸。

因此分治法的三大步驟是:

「分」:大問題分解成小問題;

「治」:用一樣的方法解決小問題;

「合」:用小問題的解構造大問題的解。

那回到咱們的歸併排序上來:

「分」:把一個數組拆成兩個;

「治」:用歸併排序去排這兩個小數組;

「合」:把兩個排好序的小數組合併成大數組。

這裏還有個問題,就是何時可以解決小問題了?

答:當只剩一個元素的時候,直接返回就行了,分解不了了。
這就是遞歸的 base case,是要直接給出答案的。

老例子:{5, 2, 1, 0}

暗示着齊姐對大家的愛啊~❤️

Step1.

先拆成兩半,
分紅兩個數組:{5, 2} 和 {1, 0}

Step2.

沒到 base case,因此繼續把大問題分解成小問題:

固然了,雖然左右兩邊的拆分我都叫它 Step2,可是它們並非同時發生的,我在遞歸那篇文章裏有說緣由,本質上是由馮諾伊曼體系形成的,一個 CPU 在某一時間只能處理一件事,但我之因此都寫成 Step2,是由於它們發生在同一層 call stack,這裏就不在 IDE 裏演示了,不明白的同窗仍是去看遞歸那篇文章裏的演示吧。

Step3.

這一層都是一個元素了,是 base case,能夠返回併合並了。

Step4.

合併的過程就是按大小順序來排好,這裏藉助兩個指針來比較,以及一個額外的數組來輔助完成。

好比在最後一步時,數組已經變成了:
{2, 5, 0, 1},
那麼經過兩個指針 i 和 j,比較指針所指向元素的大小,把小的那個放到一個新的數組?裏,而後指針相應的向右移動。

其實這裏咱們有兩個選擇:

  • 一種是重新數組往原數組合並,
  • 另外一種就是從原數組往新數組裏合併。

這個取決於題目要求的返回值類型是什麼;以及在實際工做中,咱們每每是但願改變當前的這個數組,把當前的這個數組排好序,而不是返回一個新的數組,因此咱們採起重新數組往原數組合並的方式,而不是把結果存在一個新的數組裏。

那具體怎麼合併的,你們能夠看下15秒的小動畫:

擋板左右兩邊是分別排好序的,那麼合併的過程就是利用兩個指針,誰指的數字小,就把這個數放到結果裏,而後移動指針,直到一方到頭(出界)。

public class MergeSort {
public void mergeSort(int[] array) {
    if(array == null || array.length <= 1) {
        return;
    }
    int[] newArray = new int[array.length];
    mergeSort(array, 0, array.length-1, newArray);
}
private void mergeSort(int[] array, int left, int right, int[] newArray) {
  // base case
    if(left >= right) {
        return;
    }

  // 「分」
    int mid = left + (right - left)/2;
    
  // 「治」
  mergeSort(array, left, mid, newArray);
    mergeSort(array, mid + 1, right, newArray);
    
  // 輔助的 array
    for(int i = left; i <= right; i++) {
        newArray[i] = array[i];
    }
    
  // 「合」
    int i = left;
    int j = mid + 1;
    int k = left;
    while(i <= mid && j <= right) {
        if(newArray[i] <= newArray[j]) { // 等號會影響算法的穩定性
            array[k++] = newArray[i++];
        } else {
            array[k++] = newArray[j++];
        }
    }
    if(i <= mid) {
        array[k++] = newArray[i++];
    }
}
}

寫的不錯,我再來說一下:

首先定義 base case,不然就會成無限遞歸死循環,那麼這裏是當未排序區間裏只剩一個元素的時候返回,即左右擋板重合的時候,或者沒有元素的時候返回。

「分」

而後定義小問題,先找到中點,

  • 那這裏能不能寫成 (left+right)/2 呢?
  • 注意⚠️,是不能夠的哦。

雖然數學上是同樣的,
可是這樣寫,
有可能出現 integer overflow.

「治」

這樣咱們拆好了左右兩個小問題,而後用「一樣的方法」解決這兩個自問題,這樣左右兩邊就都排好序了~

  • 爲何敢說這兩邊都排好序了呢?
  • 由於有數學概括法在後面撐着~

那在這裏,能不能把它寫成:

mergeSort(array, left, mid-1, newArray);
mergeSort(array, mid, right, newArray);

也就是說,

  • 左邊是 [left, mid-1],
  • 右邊是 [mid, right],

這樣對不對呢?

答案是否認的

由於會形成無限遞歸

最簡單的,舉個兩個數的例子,好比數組爲{1, 2}.

那麼 left = 0, right = 1, mid = 0.

用這個方法拆分的數組就是:

  • [0, -1], [0, 1] 即:
  • 空集,{1, 2}

因此這樣來分並沒有縮小問題,沒有把大問題拆解成小問題,這樣的「分」是錯誤的,會出現 stack overflow.

再深一層,究其根本緣由,是由於 Java 中的小數是「向零取整」

因此這裏必需要寫成:

  • 左邊是 [left, mid],
  • 右邊是 [mid + 1, right]。

「合」

接下來就是合併的過程了。

在這裏咱們剛纔說過了,要新開一個數組用來幫助合併,那麼最好是在上面的函數裏開,而後把引用往下傳。開一個,反覆用,這樣節省空間。

咱們用兩個指針:i 和 j 指向新數組,指針 k 指向原數組,開始剛纔動畫裏的移動過程。

要注意,這裏的等於號跟哪邊,會影響這個排序算法的穩定性。不清楚穩定性的同窗快去翻一下上一篇文章啦~

那像我代碼中這種寫法,指針 i 指的是左邊的元素,遇到相等的元素也會先拷貝下來,因此左邊的元素一直在左邊,維持了相對順序,因此就是穩定的。

最後咱們來分析下時空複雜度:

時間複雜度

歸併排序的過程涉及到遞歸,因此時空複雜度的分析稍微有點複雜,在以前「遞歸」的那篇文章裏我有提到,求解大問題的時間就是把全部求解子問題的時間加起來,再加上合併的時間。

咱們在遞歸樹中具體來看:

這裏我右邊已經寫出來了:

「分」的過程,每次的時間取決於有多少個小問題,能夠看出來是
1,2,4,8...這樣遞增的,
那麼加起來就是O(n).

「合」的過程,每次都要用兩個指針走徹底程,每一層的 call stack 加起來用時是 O(n),總共有 logn 層,因此是 O(nlogn).

那麼總的時間,就是 O(nlogn).

空間複雜度

其實歸併排序的空間複雜度和代碼怎麼寫的有很大的關係,因此我這裏分析的空間複雜度是針對我上面這種寫法的。

要注意的是,遞歸的空間複雜度的分析並不能像時間複雜度那樣直接累加,由於空間複雜度的定義是在程序運行過程當中的使用空間的峯值,自己就是一個峯值而非累加值的概念。

那也就是 call stack 中,所使用空間最高的時刻,其實就是遞歸樹中最右邊的這條路線:它既要存着左邊排好序的那半邊結果,還要把右邊這半邊繼續排,總共是 O(n).

那有同窗說 call stack 有 logn 層,爲何不是 O(logn),由於每層的使用的空間不是 O(1) 呀。

擴展:外排序

這兩節介紹的排序算法都屬於內部排序算法,也就是排序的過程都是在內存中完成。

但在實際工做中,當數據量特別大時,或者說比內存容量還要大時,數據就沒法一次性放入內存中,只能放在硬盤等外存儲器上,這就須要用到外部排序算法算法來完成。一個典型的外排序算法就是外歸併排序(External Merge Sort)。

這纔是一道有意思的面試題,在經典算法的基礎上,加上實際工做中的限制條件,和麪試官探討的過程當中,就能看出 candidate 的功力。

要解決這個問題,實際上是要明確這裏的限制條件是什麼

首先是內存不夠。那除此以外,咱們還想儘可能少的進行硬盤的讀寫,由於很慢啊。

好比就拿wiki上的例子,要對 900MB 的數據進行排序,可是內存只有 100MB,那麼怎麼排呢?

  1. wiki 中給出的是讀 100MB 數據至內存中,我並不贊同,由於不管是歸併排序仍是快排都是要費空間的,剛說的空間複雜度 O(n) 不是,那數據把內存都佔滿了,還怎麼運行程序?那我建議好比就讀取 10MB 的數據,那就至關於把 900MB 的數據分紅了 90 份;
  2. 在內存中排序完成後寫入磁盤;
  3. 把這 90 份數據都排好序,那就會產生 90 個臨時文件;
  4. 用 k-way merge 對着 90 個文件進行合併,好比每次讀取每一個文件中的 1MB 拿到內存裏來 merge,保證加起來是小於內存容量且能保證程序可以運行的。

那這是在一臺機器上的,若是數據量再大,好比在一個分佈式系統,那就須要用到 Map-Reduced 去作歸併排序,感興趣的同窗就繼續關注我吧~

相關文章
相關標籤/搜索