為什么要把快速排序和歸並排序放在一起寫?因為它們都涉及到一個通用的算法——分治法。
分治法
分治法顧名思義,分而治之,也即把一個較大的問題分解為若干個較小的問題解決,然后再把子問題的解合並為原來問題的解。
分治法一般分為三個步驟:
- 分
- 治
- 合
什么問題適合用分治法解決?一般有如下三個特征:
- 可分,問題可以分解為若干個規模較小的相同問題。
- 可治,問題規模更小更容易解決。
- 可合,該問題分解出的子問題的解可以合成原問題的解。
最好子問題之間相互獨立,不包含公共子問題,不然雖然可以求解,但是重復計算開銷較大,不如使用動態規划求解。
不同問題,分治法解決的難度也不一樣,有的容易分不容易合,有的容易合但是不容易分。另外,分治法一般和遞歸一起出現。
快速排序
快排的大名可謂是如雷貫耳,jdk源碼中用的就是快速排序。快速排序是典型的分治法應用,每趟排序將數組分為基准項、比基准項小的一組和大於等於其的另一組三個部分,然后對兩個子數組再遞歸的調用自己。結合分治法來看,一趟比較就是一趟切分,當元素個數小於3的時候就能保證,切分后為有序。另外,快排不需要刻意的去把子問題的解合成為原問題的解,子問題解決了,原問題自動得解。
說得好像比較抽象,快排的算法偽代碼如下:
quickSort(A, left, right) splitPoint ← split(A, left, right); quicksort(A, left, splitPoint - 1); quickSort(A, splitPoint + 1, right);
盜個圖,看下示例,其中綠色塊為比較基准元:

說白了,一趟遍歷,就是將原數組切分成兩個子數組,之前鄙人寫過一個python版本,是自己想出來的。簡單的說,設第一個為基准元,遍歷整個數組,發現比基准元小的元素就插入第二個位置,然后下個插入第三個位置,依次類推。整個過程維護兩個指針,一個指向當前遍歷的位置,另一個指向下一個小於基准元的元素應當插入的位置。代碼如下:
1 @staticmethod 2 def quick_sorter(data_list, start, end): 3 """ 4 :param data_list 5 :p_type: list 6 :return: sorted_list 7 : r_type: list 8 """ 9 if start == 0 and end == len(data_list) and not my_sorter.check_data(data_list): 10 11 return 12 13 flag = data_list[start] 14 index = start 15 16 for i in xrange(start + 1, end): 17 if data_list[i] < flag: # 將小於flag的元素依次插入下個位置 18 data_list[index] = data_list[i] 19 data_list[i] = data_list[index + 1] 20 index += 1 21 22 data_list[index] = flag 23 24 if index - start > 1: 25 my_sorter.quick_sorter(data_list, start, index) 26 if end - index > 1: 27 my_sorter.quick_sorter(data_list, index + 1, end) 28 29 return data_list
雖然思想上實現了快排,后來看了經典的快排之后,發現自己這個版本元素交換次數太多了。經典快排算法也是維護兩個指針,一個從前往后,一個從后往前,第一個指針一旦發現比基准元大的元素則停止,然后第二個指針一旦發現比基准元小的元素停止,然后兩個位置的元素相互交換,從而保證兩個指針所指過的元素,一個全部比基准元小,一個大於等於基准元。這種辦法比我的好理解,而且交換次數較少,因為第一個指針對比基准元小的元素不操作,同樣第二個指針對大於等於基准元的元素不操作。姜還是老的辣呀!貼上實例圖:

給出其實現的偽代碼:
split(A, lo, hi) flag ← A[lo]; left ← lo+1; right ← hi; while(left<=right) do while(left<=right and A[left]<flag) do left ← left+1; end while while(left<=right and A[right]>=flag) do right ← right-1; end while if(left<right) then swap A[left] with A[right]; left ← left+1; right ← right-1; end if end while swap A[lo] with A[right]; return right;
快排的復雜度和穩定性
快排是分治法的應用,快排之所以快是因為每次都將其分成了兩個子數組,使得下次比較需要的比較次數變少。什么是最好情況,什么又是最壞情況,舉個例子:
像上圖快排樹為平衡樹的時候是最好情況,完全偏斜樹(也即輸入數組為有序狀態)是最壞情況。最好情況下,樹高為 lg n, 樹的層數對應着遞歸的深度,每層比較的時間復雜度為O(n),整棵樹的時間復雜度對應快排的時間復雜度,也即O(n lg n)。最壞情況下,完全偏斜樹,樹高為 n,每層比較的時間復雜度為 O(n), 總的時間復雜度為O(n2)。
所以我們要盡量避免有序情況的發生,怎么做?隨機選擇基准元,選擇后將其與第一個元素對換,在運行之前的算法即可。還有一種辦法,選擇第一個、最后一個、中間那個,三個元素的中間值,這樣可以一定程度的緩解病態。
快排是基於遞歸來實現的,遞歸樹的樹高平均為lg n, 所以平均空間復雜度為O(lg n),最壞情況下空間復雜度為O(n)。
另外,在最后交換基准元的步驟中,可能會破壞相同元素之間的相對位置,所以為不穩定排序算法。
一個問題
在快排中,把一個子數組一分為二后,對兩個子數組遞歸調用,那么先排序哪個數組?
當然不管排序哪個數組,都能解決問題。但是不同的子數組排序順序帶來的空間消耗不同。要知道,每次遞歸調用,都會先把當前情況入棧,調用結束后再出棧。如果我們優先排序長度較大的子數組,那么較小的數組就需要入棧。由於較大的子數組的遞歸深度較較小的子數組更深,如此深入下去,小的子數組入棧越來越多,帶來的空間消耗就會越來越嚴重。所以好的辦法,應當是優先排序長度較小的子數組。
歸並排序
歸並排序也是分治法的應用,與快排不同,快排最難的是切分原問題的過程,而歸並排序切分簡單,難點在於將子問題的解合成為原問題的解。
簡單描述歸並排序的過程,歸並排序分為兩個階段:
- 向下切分階段。遍歷原數組,找到原數組中間元素的位置,以該元素為切分點,將原數組切分為兩部分。遞歸切分,直到子數組長度為1停止。
- 向上合並階段。從長度為1的子數組出發,兩兩合並子數組,每次合並,保證新數組為有序。直到整個數組的元素全部合並。
給個書上的實例圖,感受一下:

因為每次都是從中間位置切分數組,遞歸排序樹明顯是個二叉平衡樹。
歸並排序的復雜度和穩定性
請原諒我繞過代碼直接討論復雜度,因為我們需要先確定遞歸排序的數據結構。
什么鬼?不是對數組排序嗎?
是的,之前都是對數組排序,這里同樣可以使用數組。但如果采用數組的話,在向上合並的過程中,兩個有序子數組合成實質上是將值賦給新數組,最后再拷貝回去,這樣會有O(n)的空間消耗。當然,我們不能忽略數組在向下切分過程的好處,因為可以隨機訪問,所以只需要O(1)的時間。但向上合並呢,沒錯,每層都需要O(n)的時間復雜度,結合樹高 lg n,總的時間復雜度為 O(n lg n),空間復雜度為O(n)。
如果采用鏈表呢?鏈表的向上合成過程,時間復雜度同數組為 O(n lg n),但不需要額外空間,只需要改變節點指向就可以了。鏈表的弊端是向下切分的過程,每層的遍歷時間復雜度為O(n),結合樹高,向下切分過程的時間復雜度為O(n lg n)。算上合並的時間,總的時間復雜度依舊是O(n lg n),空間復雜度為O(lg n)。
元素位置變化只發生在向上合成階段,由於合並是依次比較,沒有破壞之前元素的相對位置,所以為穩定排序。
歸並排序的實現
以鏈表為數據結構實現遞歸排序,整個遞歸排序的偽代碼如下:
mergeSort(A, length) if length>1 then splitMap ← split(A, length) B ← splitMap.get("pointer"); Blength ← splitMap.get("secondLength"); mergeSort(A, length-Blength); mergeSort(B, Blength); merge(A, length-Blength, B, Blength); end if
向下切分的過程在split中實現,太過簡單就不寫了,寫下難點merge的偽代碼:
merge(A, lenA, B, lenB) if(lenA==0) then return B; else if(lenB==0) then return A; end if if(A.data <= B.data) then #設置初始節點 new ← A; A ← A.next; lenA ← lenA-1; else new ← B; B ← B.next; lenB ← lenB-1; end if pre ← new; while(lenA>0 and lenB>0) do #合並子數組直至一個數組全部合並完成 if(A.data<=B.data) then pre.next ← A; A ← A.next; lenA ← lenA-1; else pre.next A ← B; B ← B.next; lenB ← lenB-1; end if pre ← pre.next; if(lenA>0) then # 檢查是否有一個數組還有元素沒有合並 pre.next ← A; else if(lenB>0) then pre.next ← B; end if return new;
可能有輕微的小錯誤,還望大神輕噴。
