四、歸併排序 && 快速排序

abc_十號發表於2020-06-05

一、歸併排序 Merge Sort

1.1、實現原理

  • 如果要排序一個陣列,我們先把陣列從中間分成前後兩部分,然後對前後兩部分分別排序,再將排好序的兩部分合並在一起,這樣整個陣列就都有序了。
  • 歸併排序使用的就是分治思想。分治,顧名思義,就是分而治之,將一個大問題分解成小的子問題來解決。小的子問題解決了,大問題也就解決了。
  • 分治思想跟遞迴思想很像。分治演算法一般都是用遞迴來實現的。 分治是一種解決問題的處理思想,遞迴是一種程式設計技巧,這兩者並不衝突。
歸併排序
  • 寫遞迴程式碼的技巧就是,分析得出遞推公式,然後找到終止條件,最後將遞推公式翻譯成遞迴程式碼。所以,要想寫出歸併排序的程式碼,我們先寫出歸併排序的遞推公式。
  • 遞推公式:erge_sort(p…r) = merge(merge_sort(p…q), merge_sort(q+1…r))
  • 終止條件:p >= r 不用再繼續分解
  • merge_sort(p…r)表示,給下標從 p 到 r 之間的陣列排序。
  • 我們將這個排序問題轉化為了兩個子問題, merge_sort(p…q) 和 merge_sort(q+1…r),其中下標 q 等於 p 和 r 的中間位置,也就是 (p+r)/2。
  • 當下標從 p 到 q 和從 q+1 到 r 這兩個子陣列都排好序之後,我們再將兩個有序的子陣列合並在一起,這樣下標從 p 到 r 之間的資料就也排好序了。
  • 實現思路如下:
/**
 * 歸併排序
 * @param arr 排序資料
 * @param n   陣列大小
 */
public static void merge_sort(int[] arr, int n) {
    merge_sort_c(arr, 0, n - 1);
}

// 遞迴呼叫函式
public static void merge_sort_c(int[] arr, int p, int r) {
    // 遞迴終止條件
    if (p >= r) {
        return;
    }
    // 取p到r之間的中間位置q
    int q = (p + r) / 2;

    // 分治遞迴
    merge_sort_c(arr, p, q);
    merge_sort_c(arr, q + 1, r);
    // 將 arr[p...q] 和 arr[q+1...r] 合併為 arr[p...r]
    merge(arr[p...r],arr[p...q],arr[q + 1...r]);
}
  • merge(arr[p...r], arr[p...q], arr[q + 1...r]) 這個函式的作用就是,將已經有序的 arr[p…q] 和 arr[q+1…r] 合併成一個有序的陣列,並且放入 arr[p…r]。
  • 如下圖所示,我們申請一個臨時陣列 tmp,大小與 arr[p…r] 相同。
  • 我們用兩個遊標 i 和 j,分別指向 arr[p…q] 和 arr[q+1…r] 的第一個元素。
  • 比較這兩個元素 arr[i] 和 arr[j],如果 arr[i] <= arr[j],我們就把 arr[i] 放入到臨時陣列 tmp,並且 i 後移一位,否則將 arr[j] 放入到陣列 tmp,j 後移一位。
  • 繼續上述比較過程,直到其中一個子陣列中的所有資料都放入臨時陣列中,再把另一個陣列中的資料依次加入到臨時陣列的末尾,這個時候,臨時陣列中儲存的就是兩個子陣列合並之後的結果了。
  • 最後再把臨時陣列 tmp 中的資料拷貝到原陣列 arr[p…r] 中。
/**
 * merge 合併函式
 * @param arr 陣列
 * @param p   陣列頭
 * @param q   陣列中間位置
 * @param r   陣列尾
 */
public static void merge(int[] arr, int p, int q, int r) {
    if (r <= p) return;

    // 初始化變數i j k
    int i = p;
    int j = q + 1;
    int k = 0;

    // 申請一個大小跟A[p...r]一樣的臨時陣列
    int[] tmp = new int[r - p + 1];

    // 比較排序移動到臨時陣列
    while ((i <= q) && (j <= r)) {
        if (arr[i] <= arr[j]) {
            tmp[k++] = arr[i++];
        } else {
            tmp[k++] = arr[j++];
        }
    }

    // 判斷哪個子陣列中有剩餘的資料
    int start = i, end = q;
    if (j <= r) {
        start = j;
        end = r;
    }

    // 將剩餘的資料拷貝到臨時陣列tmp
    while (start <= end) {
        tmp[k++] = arr[start++];
    }

    // 將tmp中的陣列拷貝回 arr[p...r]
    for (int a = 0; a <= r - p; a++) {
        arr[p + a] = tmp[a];
    }
}

1.2、效能分析

  • 歸併排序穩不穩定關鍵要看 merge() 函式,也就是兩個有序子陣列合併成一個有序陣列的那部分程式碼。
  • 在合併的過程中,如果 arr[p…q] 和 arr[q+1…r] 之間有值相同的元素,那我們可以像虛擬碼中那樣,先把 arr[p…q] 中的元素放入 tmp 陣列。
  • 這樣就保證了值相同的元素,在合併前後的先後順序不變。所以,歸併排序是一個穩定的排序演算法
  • 其時間複雜度是非常穩定的,不管是最好情況、最壞情況,還是平均情況,時間複雜度都是 O(nlogn)
  • 歸併排序的合併函式,在合併兩個有序陣列為一個有序陣列時,需要藉助額外的儲存空間。
  • 儘管每次合併操作都需要申請額外的記憶體空間,但在合併完成之後,臨時開闢的記憶體空間就被釋放掉了。在任意時刻,CPU 只會有一個函式在執行,也就只會有一個臨時的記憶體空間在使用。
  • 臨時記憶體空間最大也不會超過 n 個資料的大小,所以空間複雜度是 O(n),不是原地排序演算法。

二、快速排序 Quicksort

2.1、實現原理

  • 快排的思想是:如果要排序陣列中下標從 p 到 r 之間的一組資料,可以選擇 p 到 r 之間的任意一個資料作為 pivot(分割槽點)。
  • 遍歷 p 到 r 之間的資料,將小於 pivot 的放到左邊,將大於 pivot 的放到右邊,將 pivot 放到中間。
  • 經過這一步驟之後,陣列 p 到 r 之間的資料就被分成了三個部分,前面 p 到 q-1 之間都是小於 pivot 的,中間是 pivot,後面的 q+1 到 r 之間是大於 pivot 的。
  • 根據分治、遞迴的處理思想,可以用遞迴排序下標從 p 到 q-1 之間的資料和下標從 q+1 到 r 之間的資料,直到區間縮小為 1,就說明所有的資料都有序了。
  • 用遞推公式來將上面的過程寫出來的話,就是這樣:quick_sort(p…r) = quick_sort(p…q-1) + quick_sort(q+1, r)。
  • 終止條件:p >= r
/**
 * 快速排序
 * @param arr 排序陣列
 * @param p 陣列頭
 * @param r 陣列尾
 */
public static void quickSort(int[] arr, int p, int r) {
    if (p >= r) 
        return;
    // 獲取分割槽點 並移動資料
    int q = partition(arr, p, r);
    quickSort(arr, p, q - 1);
    quickSort(arr, q + 1, r);
}

partition() 分割槽函式:

  • 是隨機選擇一個元素作為 pivot(一般情況下,可以選擇 p 到 r 區間的最後一個元素),然後對 arr[p…r] 分割槽,並將小於 pivot 的放右邊,大於的放左邊,函式返回 pivot 的下標。

partition() 的實現有兩種方式:

  • 一種是不考慮空間消耗,此時非常簡單。

    • 申請兩個臨時陣列 X 和 Y,遍歷 arr[p…r],將小於 pivot 的元素都拷貝到臨時陣列 X,將大於 pivot 的元素都拷貝到臨時陣列 Y,最後再將陣列 X 和陣列 Y 中資料順序拷貝到arr[p…r]。
    /**
     * 分割槽函式方式一
     *
     * @param arr 陣列
     * @param p   上標
     * @param r   下標
     * @return 函式返回 pivot 的下標
     */
    public static int partition1(int[] arr, int p, int r) {
        int[] xArr = new int[r - p + 1];
        int x = 0;
    
        int[] yArr = new int[r - p + 1];
        int y = 0;
    
        int pivot = arr[r];
    
        // 將小於 pivot 的元素都拷貝到臨時陣列 X,將大於 pivot 的元素都拷貝到臨時陣列 Y
        for (int i = p; i < r; i++) {
            // 小於 pivot 的存入 xArr 陣列
            if (arr[i] < pivot) {
                xArr[x++] = arr[i];
            }
            // 大於 pivot 的存入 yArr 陣列
            if (arr[i] > pivot) {
                yArr[y++] = arr[i];
            }
        }
    
        int q = x + p;
        // 再將陣列 X 和陣列 Y 中資料順序拷貝到 arr[p…r]
        for (int i = 0; i < x; i++) {
            arr[p + i] = xArr[i];
        }
        arr[q] = pivot;
        for (int i = 0; i < y; i++) {
            arr[q + 1 + i] = yArr[i];
        }
    
        return q;
    }
    
  • 另外一種有點類似選擇排序。

    • 我們通過遊標 i 把 arr[p…r-1] 分成兩部分。arr[p…i-1] 的元素都是小於 pivot 的,我們暫且叫它“已處理區間”,arr[i…r-1] 是“未處理區間”。
    • 我們每次都從未處理的區間 arr[i…r-1] 中取一個元素 arr[j],與 pivot 對比,如果小於 pivot,則將其加入到已處理區間的尾部,也就是 arr[i]的位置。
    • 在陣列某個位置插入元素,需要搬移資料,非常耗時。此時可以採用交換,在 O(1) 的時間複雜度內完成插入操作。需要將 arr[i] 與 arr[j] 交換,就可以在 O(1)時間複雜度內將 arr[j] 放到下標為 i 的位置。
    /**
     * 分割槽函式方式二
     * @param arr 陣列
     * @param p   上標
     * @param r   下標
     * @return 函式返回pivot的下標
     */
    public static int partition2(int[] arr, int p, int r) {
        int pivot = arr[r];
        int i = p;
        for (int j = p; j < r; j++) {
            if (arr[j] < pivot) {
                if (i == j) {
                    ++i;
                } else {
                    int tmp = arr[i];
                    arr[i++] = arr[j];
                    arr[j] = tmp;
                }
            }
        }
        int tmp = arr[i];
        arr[i] = arr[r];
        arr[r] = tmp;
        return i;
    }
    

2.2、效能分析

  • 因為分割槽的過程涉及交換操作,如果陣列中有兩個相同的元素,比如序列 6, 8, 7, 6, 3, 5, 9, 4,在經過第一次分割槽操作之後,兩個 6 的相對先後順序就會改變。所以,快速排序並不是穩定的排序演算法
  • 按照上面的第二種分割槽方式,快速排序只涉及交換操作,所以空間複雜度為 Q(1),是原地排序演算法
  • 時間複雜度為 Q(nlogn),最差為Q(n²)

三、兩者對比

歸併排序 快速排序
排序思想 處理過程由下到上,先處理子問題,然後在合併 由上到下,先分割槽,在處理子問題
穩定性
空間複雜度 Q(n) Q(1) 原地排序演算法
時間複雜度 都為 O(nlogn) 平均為 O(nlogn),最差為 O(n²)
  • 歸併之所以是非原地排序演算法,主要原因是合併函式無法在原地執行。快速排序通過設計巧妙的原地分割槽函式,可以實現原地排序,解決了歸併排序佔用太多記憶體的問題。
  • 歸併排序演算法是一種在任何情況下時間複雜度都比較穩定的排序演算法,這也使它存在致命的缺點,即歸併排序不是原地排序演算法,空間複雜度比較高,是 O(n)。正因為此,它也沒有快排應用廣泛。

相關文章