1. 程式人生 > 其它 >08_排序之歸併、快速

08_排序之歸併、快速

技術標籤:演算法

文章目錄

1.歸併排序

使用分治思想

public static void mergeSort(int[] A, int n){
    int[] B = new int[A.length];
    mergeSort_c(A, 0, n - 1, B);
}

public static void mergeSort_c(int[] A, int left, int right, int[] B){
    if (left < right) {
        int mid = (left + right) / 2;
        mergeSort_c(A, left, mid, B);
        mergeSort_c(A, mid + 1, right, B);
        merge(A, left, mid, right, B);
    }
}

public static void merge(int[] A, int left, int mid, int right, int[] B){
    int i = left, j = mid + 1, k = 0;
    while (i <= mid && j <= right)
        if (A[i] <= A[j])
            B[k++] = A[i++];
        else
            B[k++] = A[j++];
    while (i <= mid) B[k++] = A[i++];
    while (j <= right) B[k++] = A[j++];

    //拷貝
    k = 0;
    int t = left;
    while (t <= right) A[t++] = B[k++];
}

歸併排序是穩定的排序演算法

時間複雜度分析,如何分析遞迴的時間複雜度
假設a可以分解為多個子問題b,c,那麼b,c解決後,就可把b,c的結果合併成a的結果,遞推式如下

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

其中K表示將二者合併所需的時間
下面對n個元素進行分析,假設n個元素進行歸併排序需要的時間是T(n),那麼分解成兩個子陣列排序的時間都是T(n/2),我們知道,merge()函式合併兩個有序子陣列的時間複雜度是O(n),所以歸併排序的時間複雜度計算式為

T(1) = C;                       //C為常量級
T(n) = 2*T(n/2) + n;      // n>1
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
     = 2^k * T(n/2^k) + k*n

當 T(n/2^k)=T(1) 時,也就是 n/2^k=1,我們得到 k=log2n 。我們將 k 值代入上面的公式,得到 T(n)=Cn+nlog2n 。如果我們用大 O 標記法來表示的話,T(n) 就等於 O(nlogn)。所以歸併排序的時間複雜度是 O(nlogn)。
歸併排序的執行效率與要排序的原始陣列的有序程度無關,所以其時間複雜度是非常穩定的,不管是最好情況、最壞情況,還是平均情況,時間複雜度都是 O(nlogn)。

歸併排序的空間複雜度。使用了一個輔助陣列B,大小與A相同,故空間複雜度為O(n),這是歸併的一個缺點

2.快速排序

先思考遞推公式

遞推公式:
quick_sort(left…right) = quick_sort(left…mid-1) + quick_sort(mid+1… right)

終止條件:
left >= right

我們遍歷left到right之間的資料,把小於pivot的放到左邊,大於pivot的放到右邊,pivot放到中間。這樣,陣列就被分成了三部分,前面left到mid-1之間都是小於pivot的,中間是pivot,後面mid+1到right之間是大於pivot的。
接著,我們遞迴的處理left到mid-1和mid+1到right的資料,直到區間縮小為1。
(注:這裡的mid使用不太準確,因為pivot不一定每次都能完美中分)
在這裡插入圖片描述

public static void quickSort_c(int[] A, int left, int right){
    if (left >= right) return;
    int mid = partition(A, left, right);
    quickSort_c(A, left, mid - 1);
    quickSort_c(A, mid + 1, right);
}

關於piovt中分值的選擇,我們這裡選擇陣列末尾
partition分割槽函式是本排序公式的關鍵,我們可以用O(n)的空間複雜度完成(申請兩個臨時陣列),也可以優化到原地排序,下面講解原地排序的方法
我們用遊標i把A[left,right-1]分成兩部分,A[left,i-1]是小於pivot的,叫做“已處理區間”,A[i,right-1]是“未處理區間”。每次從未處理區間A[i,right-1]中取一個元素A[j],與pivot對比,如果小於pivot,則將其加入已處理區間的尾部,也就是A[i]的位置。
陣列的插入操作我們採用交換來省時, 只需要將 A[i]與 A[j]交換,就可以在 O(1) 時間複雜度內將 A[j]放到下標為 i 的位置。
如下圖所示
在這裡插入圖片描述

//實現原地排序
public static int partition(int[] A, int left, int right){
    int pivot = A[right];
    int i = left; //把陣列分成“已處理”和“未處理”兩個部分
    for (int j = left;j < right;j++){
        if (A[j] < pivot){
            int tmp = A[i];
            A[i] = A[j];
            A[j] = tmp;
            i++;
        }
    }
    int temp = A[i];
    A[i] = A[right];
    A[right] = temp;


    return i;
}

提問:快速排序是不是一個穩定的演算法?
如下圖所示
在這裡插入圖片描述
兩個6的位置發生了改變,故並不是一個穩定的演算法

3.歸併與快排的區別

在這裡插入圖片描述
(1)歸併排序的處理過程是由下到上的,先處理子問題,然後再合併。而快排正好相反,它的處理過程是由上到下的,先分割槽,然後再處理子問題。
(2)歸併排序雖然是穩定的、時間複雜度為 O(nlogn) 的排序演算法,但是它是非原地排序演算法。

歸併排序演算法是一種在任何情況下時間複雜度都比較穩定的排序演算法,這也使它存在致命的缺點,即歸併排序不是原地排序演算法,空間複雜度比較高,是 O(n)。正因為此,它也沒有快排應用廣泛。
快速排序演算法雖然最壞情況下的時間複雜度是 O(n2),但是平均情況下時間複雜度都是 O(nlogn)。不僅如此,快速排序演算法時間複雜度退化到 O(n2) 的概率非常小,我們可以通過合理地選擇 pivot 來避免這種情況。

4.快排的時間複雜度分析

如果每次分割槽操作,都能正好把陣列分成大小接近相等的兩個小區間,那快排的時間複雜度遞迴求解公式和歸併相同,為O(nlogn)

T(1) = C;   n=1時,只需要常量級的執行時間,所以表示為C。
T(n) = 2*T(n/2) + n; n>1

但想要正好一分為二,是很難實現的
如果陣列已經有序,如1,2,3,4,5,6,每次選擇最後一個元素作為pivot,那每次得到的兩個區間都是不均等的。大約需要n次分割槽操作,才能完成快排的整個過程。每次分割槽我們平均要掃描大約n/2個元素,這種情況下,快排的時間複雜度就從O(nlogn)退化成了O(n2)

那麼,平均情況呢?
需要使用遞迴樹,暫時不講,平均情況為O(nlogn)

5.如何在O(n)時間複雜度內求無序陣列的第K大元素

我們選擇陣列區間 A[0…n-1]的最後一個元素 A[n-1]作為 pivot,對陣列 A[0…n-1]原地分割槽,這樣陣列就分成了三部分,A[0…p-1]、A[p]、A[p+1…n-1]。
如果 p+1=K,那 A[p]就是要求解的元素;如果 K>p+1, 說明第 K 大元素出現在 A[p+1…n-1]區間,我們再按照上面的思路遞迴地在 A[p+1…n-1]這個區間內查詢。 同理,如果 K < p+1,那我們就在A[0…p-1]區間查詢。

時間複雜度分析
第一次分割槽查詢,對大小為n的陣列執行操作,需要遍歷n個元素,第二次分割槽查詢,只需要對大小n/2的陣列執行操作,需要遍歷n/2個元素…依此類推,分割槽遍歷元素的個數分別為n/4,n/8,n/16…直到區間縮小為1.
每次分割槽遍歷的元素個數加起來,就是n+n/2+n/4+…+1,等比數列求和,等於2n-1,所以,上述解決思路的時間複雜度就為 O(n)。

參考課程:極客時間王爭老師的《資料結構與演算法之美》