排序3 - 快速排序和歸並排序


為什么要把快速排序和歸並排序放在一起寫?因為它們都涉及到一個通用的算法——分治法。

分治法

分治法顧名思義,分而治之,也即把一個較大的問題分解為若干個較小的問題解決,然后再把子問題的解合並為原來問題的解。

分治法一般分為三個步驟:

什么問題適合用分治法解決?一般有如下三個特征:

  1. 可分,問題可以分解為若干個規模較小的相同問題。
  2. 可治,問題規模更小更容易解決。
  3. 可合,該問題分解出的子問題的解可以合成原問題的解。

最好子問題之間相互獨立,不包含公共子問題,不然雖然可以求解,但是重復計算開銷較大,不如使用動態規划求解。

不同問題,分治法解決的難度也不一樣,有的容易分不容易合,有的容易合但是不容易分。另外,分治法一般和遞歸一起出現。

快速排序

快排的大名可謂是如雷貫耳,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停止。
  2. 向上合並階段。從長度為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;

 可能有輕微的小錯誤,還望大神輕噴。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM