那咱們借用 cs50 裏的例子,好比要把一摞卷子排好序,那用並歸排序的思想是怎麼作的呢?java
感受啥都沒說?
那是由於上面的過程裏省略了不少細節,咱們一個個來看。面試
答案是用一樣的方法排好序。算法
這裏須要藉助兩個指針和額外的空間,而後左邊畫一個彩虹🌈右邊畫個龍🐲,不是,是左邊拿一個數,右邊拿一個數,兩個比較大小以後排好序放回到數組裏(至於放回原數組仍是新數組稍後再說)。編程
這其實就是分治法 divide-and-conquer 的思想。
歸併排序是一個很是典型的例子。數組
顧名思義:分而治之。分佈式
就是把一個大問題分解成類似的小問題,經過解決這些小問題,再用小問題的解構造大問題的解。ide
聽起來是否是和以前講遞歸的時候很像?函數
沒錯,分治法基本都是能夠用遞歸來實現的。動畫
在以前,咱們沒有加以區分,固然如今我也認爲不須要加以區分,但你若是非要問它們之間是什麼區別,個人理解是:spa
分治法是一種解決問題的思想:
因此分治法的三大步驟是:
「分」:大問題分解成小問題;
「治」:用一樣的方法解決小問題;
「合」:用小問題的解構造大問題的解。
那回到咱們的歸併排序上來:
「分」:把一個數組拆成兩個;
「治」:用歸併排序去排這兩個小數組;
「合」:把兩個排好序的小數組合併成大數組。
這裏還有個問題,就是何時可以解決小問題了?
答:當只剩一個元素的時候,直接返回就行了,分解不了了。
這就是遞歸的 base case,是要直接給出答案的。
暗示着齊姐對大家的愛啊~❤️
先拆成兩半,
分紅兩個數組:{5, 2} 和 {1, 0}
沒到 base case,因此繼續把大問題分解成小問題:
固然了,雖然左右兩邊的拆分我都叫它 Step2,可是它們並非同時發生的,我在遞歸那篇文章裏有說緣由,本質上是由馮諾伊曼體系形成的,一個 CPU 在某一時間只能處理一件事,但我之因此都寫成 Step2,是由於它們發生在同一層 call stack,這裏就不在 IDE 裏演示了,不明白的同窗仍是去看遞歸那篇文章裏的演示吧。
這一層都是一個元素了,是 base case,能夠返回併合並了。
合併的過程就是按大小順序來排好,這裏藉助兩個指針來比較,以及一個額外的數組來輔助完成。
好比在最後一步時,數組已經變成了:
{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,不然就會成無限遞歸死循環,那麼這裏是當未排序區間裏只剩一個元素的時候返回,即左右擋板重合的時候,或者沒有元素的時候返回。
而後定義小問題,先找到中點,
雖然數學上是同樣的,
可是這樣寫,
有可能出現 integer overflow.
這樣咱們拆好了左右兩個小問題,而後用「一樣的方法」解決這兩個自問題,這樣左右兩邊就都排好序了~
那在這裏,能不能把它寫成:
mergeSort(array, left, mid-1, newArray); mergeSort(array, mid, right, newArray);
也就是說,
這樣對不對呢?
答案是否認的。
由於會形成無限遞歸。
最簡單的,舉個兩個數的例子,好比數組爲{1, 2}.
那麼 left = 0, right = 1, mid = 0.
用這個方法拆分的數組就是:
因此這樣來分並沒有縮小問題,沒有把大問題拆解成小問題,這樣的「分」是錯誤的,會出現 stack overflow.
再深一層,究其根本緣由,是由於 Java 中的小數是「向零取整」
。
因此這裏必需要寫成:
接下來就是合併的過程了。
在這裏咱們剛纔說過了,要新開一個數組用來幫助合併,那麼最好是在上面的函數裏開,而後把引用往下傳。開一個,反覆用,這樣節省空間。
咱們用兩個指針: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,那麼怎麼排呢?
那這是在一臺機器上的,若是數據量再大,好比在一個分佈式系統,那就須要用到 Map-Reduced 去作歸併排序,感興趣的同窗就繼續關注我吧~