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

1、歸併排序 Merge Sort

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.二、性能分析

  • 歸併排序穩不穩定關鍵要看 merge() 函數,也就是兩個有序子數組合併成一個有序數組的那部分代碼。
  • 在合併的過程當中,若是 arr[p…q] 和 arr[q+1…r] 之間有值相同的元素,那咱們能夠像僞代碼中那樣,先把 arr[p…q] 中的元素放入 tmp 數組。
  • 這樣就保證了值相同的元素,在合併先後的前後順序不變。因此,歸併排序是一個穩定的排序算法
  • 其時間複雜度是很是穩定的,不論是最好狀況、最壞狀況,仍是平均狀況,時間複雜度都是 O(nlogn)
  • 歸併排序的合併函數,在合併兩個有序數組爲一個有序數組時,須要藉助額外的存儲空間。
  • 儘管每次合併操做都須要申請額外的內存空間,但在合併完成以後,臨時開闢的內存空間就被釋放掉了。在任意時刻,CPU 只會有一個函數在執行,也就只會有一個臨時的內存空間在使用。
  • 臨時內存空間最大也不會超過 n 個數據的大小,因此空間複雜度是 O(n),不是原地排序算法。

2、快速排序 Quicksort

2.一、實現原理

  • 快排的思想是:若是要排序數組中下標從 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() 分區函數:java

  • 是隨機選擇一個元素做爲 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.二、性能分析

  • 由於分區的過程涉及交換操做,若是數組中有兩個相同的元素,好比序列 6, 8, 7, 6, 3, 5, 9, 4,在通過第一次分區操做以後,兩個 6 的相對前後順序就會改變。因此,快速排序並不是穩定的排序算法
  • 按照上面的第二種分區方式,快速排序只涉及交換操做,因此空間複雜度爲 Q(1),是原地排序算法
  • 時間複雜度爲 Q(nlogn),最差爲Q(n²)

3、二者對比

歸併排序 快速排序
排序思想 處理過程由下到上,先處理子問題,而後在合併 由上到下,先分區,在處理子問題
穩定性
空間複雜度 Q(n) Q(1) 原地排序算法
時間複雜度 都爲 O(nlogn) 平均爲 O(nlogn),最差爲 O(n²)
  • 歸併之因此是非原地排序算法,主要緣由是合併函數沒法在原地執行。快速排序經過設計巧妙的原地分區函數,能夠實現原地排序,解決了歸併排序佔用太多內存的問題。
  • 歸併排序算法是一種在任何狀況下時間複雜度都比較穩定的排序算法,這也使它存在致命的缺點,即歸併排序不是原地排序算法,空間複雜度比較高,是 O(n)。正由於此,它也沒有快排應用普遍。
相關文章
相關標籤/搜索