快速排序及其優化

算法簡介

是一種分治的排序算法,特點就是快,而且效率高。

基本思路

通過一趟排序將待排元素分隔成獨立的兩部分,其中一部分元素的關鍵字均比另一部分的關鍵字小,然后分別對這兩部分元素繼續進行排序,以達到整個序列有序。

Q:對比歸并排序,有何異同?
A:快速排序和歸并排序是互補的:歸并排序是將數組分成兩個子數組分別排序,并將有序的子數組歸并以整個數組排序;而快速排序是當兩個子數組都有序時,整個數組也就自然有序了。

遞歸調用先后順序不同歸并排序 ~ 遞歸調用發生在處理整個數組之前快速排序 ~ 遞歸調用發生在處理整個數組之后

切分數組的位置不同歸并 ~ 一個數組等分為兩半;快速 ~ 切分的位置取決于數組的內容

運行軌跡

快速排序遞歸的將子數組 arr[lo...hi] 排序,先用 partition() 方法將 arr[indexJ] 放到一個適合位置,然后再用遞歸調用將其他位置的元素排序

算法的遞歸調用過程

快速排序關鍵在于切分方法,我們就是通過遞歸地調用切分來排序的,因為切分過程總是能排定一個元素

實現的一般策略
①、隨意地取 arr[lo] 作為切分元素(將會排定的元素);
②、從數組的左端開始,向右端掃描,直到找到一個 >= 切分元素的元素;
③、從數組的右端開始,向左端掃描,直到找到一個 <= 切分元素的元素;
④、交換他倆的位置(因為顯然他倆沒有被排定);
⑤、如此繼續,可以保證左指針 indexI 的左側元素都 <= 切分元素、右側指針 indexJ 的右側元素都 >= 切分元素;
⑥、當兩個指針相遇時,交換切分元素 arr[lo]arr[indexJ] 并返回 indexJ 即可。

代碼實現

根據排序算法類的模板實現快速排序(提醒:點藍字查看詳情)

/**
 * 標準的快速排序
 *
 * @author TinyDolphin
 * 2017/11/13 14:20.
 */
public class Quick {

    public static void sort(Comparable[] arr) {
        shuffle(arr); // 打亂數組,避免切分不平衡,帶來的低效
        sort(arr, 0, arr.length - 1);
    }

    private static void sort(Comparable[] arr, int lo, int hi) {
        if (hi <= lo) return;
        int indexJ = partition(arr, lo, hi); // 切分方法
        sort(arr, lo, indexJ - 1);
        sort(arr, indexJ + 1, hi);
    }

    // 打亂數組的方法
    private static void shuffle(Comparable[] arr) {
        int length = arr.length;
        Random random = new Random(System.currentTimeMillis());
        for (int index = 0; index < length; index++) {
            int temp = random.nextInt(length);
            exch(arr,index,temp);
        }
    }

    /**
     * 關鍵:切分方法
     * 該過程使得數組滿足下面兩個條件:
     *    ①、對于某個 indexJ、arr[indexJ] 已經排定;
     *    ②、arr[lo...indexJ-1] <= arr[indexJ] <= arr[indexJ+1...hi]
     */
    private static int partition(Comparable[] arr, int lo, int hi) {
        int indexI = lo;            // 左右掃描指針
        int indexJ = hi + 1;        
        Comparable temp = arr[lo];    //切分元素
        while (true) {
            // 從數組的左端開始,向右端掃描,直到找到一個 >= 切分元素的元素;
            while (less(arr[++indexI], temp)) {
                if (indexI == hi) break;
            }
            // 從數組的右端開始,向左端掃描,直到找到一個 <= 切分元素的元素;
            while (less(temp, arr[--indexJ])) {
                if (indexJ == lo) break;
            }
            // 指針相遇,退出循環
            if (indexI >= indexJ) break;
            // 交換他倆的位置(因為顯然他倆沒有被排定)
            exch(arr, indexI, indexJ);
        }
        exch(arr, lo, indexJ);      // 將 temp = arr[indexJ] 放入正確的位置
        return indexJ;             // arr[lo...indexJ-1] <= arr[indexJ] <= arr[indexJ+1...hi] 達成
    }
    ...
}

Q:打亂數組的方法(shuffle())消耗了大量的時間,這么做值得么?
A:值得,這樣可以預防出現最壞情況并使時間可以預計。

實現細節必須注意

①、原地切分:如果使用一個輔助數組,我們可以很容易實現切分,但將切分后的數組復制回去的開銷也許會使我們得不償失。
②、別越界:如果切分元素是數組中最小或最大的那個元素,我們就要小心別讓掃描指針跑出數組的邊界。parttion() 實現可以進行明確的檢測來預防。測試條件 ( indexJ==lo ) 是冗余的,因為切分元素就是 arr[lo],它不可能比自己小。數組右端也有相同的情況,他們都是可以去掉的。(練習 2.3.17)
③、保持隨機性:數組元素的順序是被打亂過的。保存隨機性的另一種方法:在切分方法隨機選擇一個切分元素
④、終止循環:一個最常見的錯誤是沒有考慮到數組中可能包含和切分元素的值相同的其他元素。
⑤、處理切分元素值有重復的情況:左側掃描最好遇到 >= 切分元素值 的元素時停下,右側掃描則是遇到 <= 切分元素值的元素時停下。(盡管存在一些等值交換,但可以避免算法的運行時間變為平方級別
⑥、終止遞歸:一定要保證將切分元素放入正確的位置,不然容易導致無限遞歸。

性能分析

最佳情況:T(n) = O(nlogn)
最差情況:T(n) = O(n2)
平均情況:T(n) = O(nlogn)

將長度為 N 的無重復數組排序,快排平均需要 ~2NlnN 次比較以及 1/6 的交換

快排最多需要約 N2/2 次比較,但隨機打亂數組能夠預防這種情況。

優化方案

①、切換到插入排序
對于小數組,快排比插入慢;因為遞歸,快排的 sort() 方法在小數組中也會調用自己。
②、三取樣切分(用來選擇切分元素)
使用子數組的一小部分元素的中位數來切分數組(取樣大小為 3 并用大小居中的元素效果最好) || 將取樣元素放在數組末尾作為去掉 partition() 中的數組邊界測試。

優化代碼 NO.1:插入排序+三取樣切分
/**
 * 快速排序優化
 *
 * 插入排序+三取樣切分
 *
 * @author TinyDolphin
 * 2017/11/14 13:41.
 */
public class QuickBar {

    private static final int CUTOFF = 8;

    public static void sort(Comparable[] arr) {
        //shuffle(arr); // 讓數組成為隨機數組,避免切分不平衡,帶來的低效
        sort(arr, 0, arr.length - 1);
    }

    private static void sort(Comparable[] arr, int lo, int hi) {
        int n = hi - lo + 1;
        // 當子數組的長度為 8 時,調用插入排序
        if (n <= CUTOFF) {
            insertionSort(arr, lo, hi);
            return;
        }
        // 調用三取樣切分
        int m = median3(arr, lo, lo + n / 2, hi);
        exch(arr, m, lo);
        int indexJ = partition(arr, lo, hi); // 切分方法
        sort(arr, lo, indexJ - 1);
        sort(arr, indexJ + 1, hi);
    }

    // 插入排序
    private static void insertionSort(Comparable[] arr, int lo, int hi) {
        for (int indexI = lo; indexI <= hi; indexI++) {
            for (int indexJ = indexI; indexJ > lo && less(arr[indexJ], arr[indexJ - 1]); indexJ--) {
                exch(arr, indexJ, indexJ - 1);
            }
        }
    }

    // 選擇切分元素:取 arr[i]  arr[j]   arr[k]  三個元素值的中間元素的下標
    private static int median3(Comparable[] arr, int i, int j, int k) {
        return (less(arr[i], arr[j]) ?
                (less(arr[j], arr[k]) ? j : less(arr[i], arr[k]) ? k : i) :
                (less(arr[k], arr[j]) ? j : less(arr[k], arr[i]) ? k : i));
    }

    // 切分方法
    private static int partition(Comparable[] arr, int lo, int hi) {
        int indexI = lo;            // 左右掃描指針
        int indexJ = hi + 1;        // 切分元素
        Comparable temp = arr[lo];
        while (true) {
            // 從數組的左端開始,向右端掃描,直到找到一個 >= 切分元素的元素;
            while (less(arr[++indexI], temp)) {
                if (indexI == hi) break;
            }
            // 從數組的右端開始,向左端掃描,直到找到一個 <= 切分元素的元素;
            while (less(temp, arr[--indexJ])) {
                if (indexJ == lo) break;
            }
            // 指針相遇,退出循環
            if (indexI >= indexJ) break;
            // 交換他倆的位置(因為顯然他倆沒有被排定)
            exch(arr, indexI, indexJ);
        }
        exch(arr, lo, indexJ);      // 將 temp = arr[indexJ] 放入正確的位置
        return indexJ;             // arr[lo...indexJ-1] <= arr[indexJ] <= arr[indexJ+1...hi] 達成
    }
    ...
}

③、熵最優的排序
在應對大量重復元素的情況下,我們可以將數組切分為三部分,分別對應 小于、等于和大于 切分元素的數組元素。

三向切分的快速排序

Dijkstra 的解法如“三向切分的快速排序”中極為簡潔的切分代碼:它從左到右遍歷數組一次,維護
一個指針 lt 使得 a[lo...lt-1] 中的元素都 < v;
一個指針 gt 使得 a[gt+1...hi] 中的元素都 > v ;
一個指針 i 使得 a[lt...i-1] 中的元素都 == v,a[i...gt] 中的元素都還未確定。


優化代碼 NO.2:三向切分

對于存在大量重復元素的數組,這種方法比標準的快速排序的效率高得多。其他情況,可能不及優化方案 NO.1。

/**
 * 快速排序優化:三向切分(用于解決大量重復元素)
 *
 * @author TinyDolphin
 * 2017/11/14 14:12.
 */
public class Quick3way {

    public static void sort(Comparable[] arr) {
        shuffle(arr);
        sort(arr, 0, arr.length - 1);
    }

    private static void sort(Comparable[] arr, int lo, int hi) {
        if (hi <= lo) {
            return;
        }
        int lt = lo;
        int gt = hi;
        Comparable v = arr[lo];
        int i = lo;
        while (i <= gt) {
            int cmp = arr[i].compareTo(v);
            // arr[i] < v,交換 arr[lt] & arr[i],將 lt & i 加一
            if (cmp < 0) {
                exch(arr, lt++, i++);
            }
            // arr[i] > v,交換 arr[gt] & arr[i],將 gt 減一
            else if (cmp > 0) {
                exch(arr, i, gt--);
            }
            // arr[i] == v,將 i 加一
            else {
                i++;
            }
        }
        // arr[lo...lt-1] < v = arr[lt...gt] < arr[gt+1...hi]
        sort(arr, lo, lt - 1);
        sort(arr, gt + 1, hi);
    }

    // 打亂數組的方法
    private static void shuffle(Comparable[] arr) {
        int length = arr.length;
        Random random = new Random(System.currentTimeMillis());
        for (int index = 0; index < length; index++) {
            int temp = random.nextInt(length);
            exch(arr, index, temp);
        }
    }
    ...
}
優化代碼 NO.3:插入排序+三取樣切分+三向切分

優化了三向切分代碼,加快了處理大量重復元素的速度,但是其他情況下,速度還是不及NO.1

/**
 * 快速排序優化
 *
 * 插入排序+三取樣切分+三向切分
 *
 * @author TinyDolphin
 * 2017/11/14 17:11.
 */
public class Quick3wayBar {

    private static final int CUTOFF = 8;

    public static void sort(Comparable[] arr) {
        //shuffle(arr);
        sort(arr, 0, arr.length - 1);
    }

    private static void sort(Comparable[] arr, int lo, int hi) {
        int n = hi - lo + 1;
        // 當子數組的長度為 8 時,調用插入排序
        if (n <= CUTOFF) {
            insertionSort(arr, lo, hi);
            return;
        }
        // 調用三取樣切分
        int m = median3(arr, lo, lo + n / 2, hi);
        exch(arr, m, lo);

        int lt = lo;
        int gt = hi;
        Comparable v = arr[lo];
        int i = lo;
        while (i <= gt) {
            int cmp = arr[i].compareTo(v);
            // arr[i] < v,交換 arr[lt] & arr[i],將 lt & i 加一
            if (cmp < 0) {
                exch(arr, lt++, i++);
            }
            // arr[i] > v,交換 arr[gt] & arr[i],將 gt 減一
            else if (cmp > 0) {
                exch(arr, i, gt--);
            }
            // arr[i] == v,將 i 加一
            else {
                i++;
            }
        }
        // arr[lo...lt-1] < v = arr[lt...gt] < arr[gt+1...hi]
        sort(arr, lo, lt - 1);
        sort(arr, gt + 1, hi);
    }

    // 插入排序
    private static void insertionSort(Comparable[] arr, int lo, int hi) {
        for (int indexI = lo; indexI <= hi; indexI++) {
            for (int indexJ = indexI; indexJ > lo && less(arr[indexJ], arr[indexJ - 1]); indexJ--) {
                exch(arr, indexJ, indexJ - 1);
            }
        }
    }

    // 取 arr[i]  arr[j]   arr[k]  三個元素值的中間元素的下標
    private static int median3(Comparable[] arr, int i, int j, int k) {
        return (less(arr[i], arr[j]) ?
                (less(arr[j], arr[k]) ? j : less(arr[i], arr[k]) ? k : i) :
                (less(arr[k], arr[j]) ? j : less(arr[k], arr[i]) ? k : i));
    }
優化代碼 NO.4:快速三向切分

優化:插入排序 + 三取樣切分 + Tukey's ninther + Bentley-McIlroy 三向切分

Tukey's ninther 方法選擇切分元素:選擇三組,每組三個元素,分別取三組元素的中位數,然后去三個中位數的中位數作為切分元素。

原理:將重復元素放置于子數組兩端的方式實現一個信息量最優的排序算法。


/**
 * 三向切分-快速排序優化
 *
 * 插入排序 + 三取樣切分 + Tukey's ninther + Bentley-McIlroy 三向切分
 *
 * @author TinyDolphin
 * 2017/11/15 15:16.
 */
public class QuickX {

    private static final int INSERTION_SORT_CUTOFF = 8;

    private static final int MEDIAN_OF_3_CUTOFF = 40;

    public static void sort(Comparable[] arr) {
        sort(arr, 0, arr.length - 1);
    }

    private static void sort(Comparable[] a, int lo, int hi) {
        int n = hi - lo + 1;

        // 當子數組大小 <= 8 時,切換到插入排序
        if (n <= INSERTION_SORT_CUTOFF) {
            insertionSort(a, lo, hi);
            return;
        }

        // 當子數組大小 <= 40 時,使用三取樣切分(median-of-3)選擇切分元素
        else if (n <= MEDIAN_OF_3_CUTOFF) {
            int m = median3(a, lo, lo + n / 2, hi);
            exch(a, m, lo);
        }

        // 當子數組大小 > 40 時,使用 Tukey's ninther 方法選擇切分元素
        else {
            int eps = n / 8;
            int mid = lo + n / 2;
            int m1 = median3(a, lo, lo + eps, lo + eps + eps);
            int m2 = median3(a, mid - eps, mid, mid + eps);
            int m3 = median3(a, hi - eps - eps, hi - eps, hi);
            int ninther = median3(a, m1, m2, m3);
            exch(a, ninther, lo);
        }

        // 使用 Bentley-McIlroy 三向切分
        // 使數組 a[lo...p-1] & a[q+1...hi] == v ; a[p...i-1] < a[lo] < a[j+1...q]
        int i = lo, j = hi + 1;
        int p = lo, q = hi + 1;
        Comparable v = a[lo];
        while (true) {
            // 移動指針,使得 a[p..i-1] < a[lo] == v,直到一個 >= v 的元素a[i]
            while (less(a[++i], v))
                if (i == hi) break;
            // 移動指針,使得 a[lo] == v > a[j+1...q],直到一個 <= v 的元素a[j]
            while (less(v, a[--j]))
                if (j == lo) break;

            // 指針交叉時,剛好 a[i] == v 的情況下,交換以將 a[i] 歸位
            if (i == j && eq(a[i], v))
                exch(a, ++p, i);
            // 排序完成,退出循環
            if (i >= j) break;

            // 交換 a[i] & a[j] 的值,使其歸位
            exch(a, i, j);
            // 如果 a[i] == v,交換 a[p] & a[i],使其歸位
            if (eq(a[i], v)) exch(a, ++p, i);
            // 如果 a[j] == v,交換 a[q] & a[i],使其歸位
            if (eq(a[j], v)) exch(a, --q, j);
        }


        // 在切分循環結束后,將和 v 相等的元素交換到正確位置
        // 即使數組 a[lo...j-1] < v == a[j...i] < a[i+1...hi]
        i = j + 1;
        // 把 v == a[lo...p-1] 元素歸位到 a[j...i] 中
        for (int k = lo; k <= p; k++)
            exch(a, k, j--);
        // 把 v == a[q+1...hi] 元素歸位到 a[j...i] 中
        for (int k = hi; k >= q; k--)
            exch(a, k, i++);

        // 遞歸調用
        sort(a, lo, j);
        sort(a, i, hi);
    }


    // 插入排序
    private static void insertionSort(Comparable[] arr, int lo, int hi) {
        for (int indexI = lo; indexI <= hi; indexI++) {
            for (int indexJ = indexI; indexJ > lo && less(arr[indexJ], arr[indexJ - 1]); indexJ--) {
                exch(arr, indexJ, indexJ - 1);
            }
        }
    }

    // 取 arr[i]  arr[j]   arr[k]  三個元素值的中間元素的下標
    private static int median3(Comparable[] arr, int i, int j, int k) {
        return (less(arr[i], arr[j]) ?
                (less(arr[j], arr[k]) ? j : less(arr[i], arr[k]) ? k : i) :
                (less(arr[k], arr[j]) ? j : less(arr[k], arr[i]) ? k : i));
    }

    // 判斷兩個元素是否相等
    private static boolean eq(Comparable v, Comparable w) {
        return v.compareTo(w) == 0;
    }
    ...
}
優化代碼 NO.5:最簡單的實現之一

但是碰到大量重復元素的話,可能會變成 O(n2)

/**
 * 快速排序最簡單的實現方式之一
 *
 * @author TinyDolphin
 * 2017/11/15 16:38.
 */
public class QuickKR {

    public static void sort(Comparable[] a) {
        sort(a, 0, a.length - 1);
    }

    private static void sort(Comparable[] a, int lo, int hi) {
        if (hi <= lo) return;
        exch(a, lo, (lo + hi) / 2);  // use middle element as partition
        int last = lo;
        for (int i = lo + 1; i <= hi; i++)
            if (less(a[i], a[lo])) exch(a, ++last, i);
        exch(a, lo, last);
        sort(a, lo, last-1);
        sort(a, last+1, hi);
    }
    ...
}
測試代碼
    public static void main(String[] args) {
        int length = 1000000;  // 百萬數據量級別
        Integer[] arr = new Integer[length];
        for (int index = 0; index < length; index++) {
            // 隨機數組
            arr[index] = new Random().nextInt(length) + 1;
            // 大量重復元素的數組
            // arr[index] = new Random().nextInt(100) + 1;
        }

        long start = System.currentTimeMillis();
        sort(arr);
        long end = System.currentTimeMillis();
        System.out.println("耗費時間:" + (end - start) + "ms");
    }
測試結果
隨機數組 重復數組 升序數組 降序數組
MergePlus.sort() 1127ms 827ms 60ms 401ms
Array.sort() 1706ms 1096ms 30ms 94ms
Quick 1573ms 900ms 1343ms 1242ms
QuickBar 846ms 659ms 251ms 溢出
Quick3way 2191ms 543ms 1635ms 1606ms
Quick3wayBar 1513ms 343ms 11146ms 11588ms
QuickX 1331ms 553ms 228ms 528ms
QuickKR 1325m 21270ms 347ms 423ms

Merge:歸并排序
MergePlus:表示快排的優化實現
QuickBar:插入排序+三取樣切分
Quick3way:三向切分
Quick3wayBar:插入排序+三取樣切分+三向切分
QuickX:快速三向切分(插入排序 + 三取樣切分 + Tukey's ninther + Bentley-McIlroy 三向切分)
QuickKR:快速排序的最簡單實現方式之一

Q:QuickBar 排序降序數組時,為什么報
java.lang.StackOverflowError 異常?
*
A:因為調用層次過多
解決方案:排序之前調用 shuffle() 方法打亂數組即可,但是對效率有所影響。

總結

由上表可知:
①、在處理隨機數組的時候,QuickBar(插入排序+三取樣切分)速度較快
②、在處理大量重復元素數組的時候,Quick3wayBar(插入排序+三取樣切分+三向切分)速度最快。
③、綜合表現最好的是:QuickX(快速三向切分(插入排序 + 三取樣切分 + Tukey's ninther + Bentley-McIlroy 三向切分)

注意:編譯器默認不適用 assert 檢測(但是junit測試中適用),所以要使用時要添加參數虛擬機啟動參數-ea 具體添加過程,請參照eclipse 和 IDEA 設置虛擬機啟動參數

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容