快速排序(Quick Sort)

NAOKO發表於2017-12-18

1. 簡介

快速排序是由C.A.R.Hoare在1960年發明的。快速排序可能是應用最廣泛的排序演算法了,快速排序的實現簡單,平均時間複雜度是O(NlgN),而且它是原地排序。其實在快排的實現有一些坑,如果不仔細一點,快排也許就變成慢排了。 接下來所講的排序都是從小到大排序的,程式碼也是java描述的:

與歸併排序一樣,快速排序也採用了分而治之的思想。

  1. 在陣列中選取一個元素作為主元
  2. 將陣列切分成左右兩半,左邊一半的元素小於等於主元,右邊一半的元素大於等於主元
  3. 將左邊排序
  4. 將右邊排序
  5. 因為左邊已經小於等於右邊了,所以當左右兩邊都排完序,整體也就有序了

2. 程式碼實現

public class QuickSort {

    //交換陣列中兩個元素的位置
    private static void swap(Comparable[] a, int i, int j){
        Comparable t = a[i];
        a[i] = a[j];
        a[j] = t;
    }

    //切分陣列的函式
    private static int partition(Comparable[] a, int left, int right){
        swap(a, (left + right) / 2, left);
        Comparable v = a[left];  //v是主元
        int i = left, j = right + 1;
        
        while (true) {
            while (a[++i].compareTo(v) < 0)
                if (i == right)
                    break;

            while (a[--j].compareTo(v) > 0)
                if (j == left)
                    break;

            if (i < j)
                swap(a, i, j);
            else
                break;
        }
        swap(a, j, left);
        return j;
    }

    private static void sort(Comparable[] a, int left, int right){
        if (left >= right) 
            return;
        int i = partition(a, left, right);  //切分陣列,返回切分的位置,也就是主元的位置
        sort(a, left, i - 1);   //對陣列的左半邊排序
        sort(a, i + 1, right);  //對陣列的右半邊排序
    }

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


    //測試
    public static void main(String[] args) {
        Integer[] a = new Integer[]{1, 3, 4, 7, 9, 2};
        sort(a);
    }
}
複製程式碼

輔助函式: 這一段是快速排序的簡單實現,還有一些可以優化的地方。先來介紹一下實現過程需要用的輔助函式:

  • 因為排序過程中需要與主元進行比較且參與排序的元素是類變數,所以要求排序的元素需要實現Comparable介面重寫compareTo()函式。
  • 在與主元比較後可能需要交換位置所以用一個swap()函式交換兩個元素的位置。

3. 快速排序效能與複雜度分析

快速排序的執行時間取決於切分是否平衡,而是否平衡又依賴於切分的元素,也就是主元的選擇。

  • 最壞情況 假設我們每次選擇的主元恰好是待排陣列中的極值且元素都不重複時,例如最小值:根據切分函式,指標i在遇到第一個元素就停下來,而j卻一直向左遍歷直到遇到主元才停下來。最終切分的位置變成了left,切分出一個大小為0的陣列和一個大小為n - 1的陣列,不煩假設每次都出現這種不平等的切分,切分的操作時間複雜度為O(n),對一個大小為0的陣列遞迴呼叫排序會直接返回,因此T(0) = O(1)。於是演算法的執行時間的遞迴式可表達為:T(n) = T(0) + T(n - 1) + O(n) = T(n - 1) + O(n),T(n)的解是O(n^2)
  • 最好情況 最好的情況是每次切分後的兩個陣列大小都不大於n / 2時,這時一個的陣列的大小為[n / 2 - 1],另一個為[n / 2],此時演算法執行時間的遞迴式為:T(n) = 2T(n / 2) + O(n),T(n)的解是O(nlgn)
  • 平均情況 快速排序的平均執行時間其實更接近與最好情況,而非最壞情況。

4. 演算法優化

1. 切換到插入排序

  • 對於小陣列,快速排序比插入排序慢
  • 因為遞迴,快速排序的sort()方法在小陣列中也會呼叫自己

所以可以當陣列在大小在M以內時呼叫插入排序,M的取值可以是5 ~ 15。

2. 選擇合適的主元 如我上面所說,假設我們每次選擇的主元恰好是待排陣列中的極值時,那就是最壞的情況,如果要避免這種情況的發生,那就是要選擇合適的主元。我們可以在待排陣列取左,中,右3個數,取其中位數作為主元。這樣就可以在一定程度上避免最壞情況。

3. 重複的元素不必排序 當陣列中存在大量的重複元素時,如果我們用上面所實現的快排,時間複雜度還是要O(nlgn),這開銷是在太大相對於插入排序來說。這時我們可以採用三向切分來實現快排。如下所示:

            left part           center part                   right part
        * +--------------------------------------------------------------+
        * |  < pivot   |          ==pivot         |    ?    |  > pivot  |
        * +--------------------------------------------------------------+
        *              ^                          ^         ^
        *              |                          |         |
        *              lt                         i        gt
複製程式碼

通過維持三個指標來控制[left, lt )小於主元(pivot),[lt, i)等於主元,[i, gt]未知,(gt, right]大於主元。 一開始,lt指向主元的位置leftgt指向right,而ileft右邊接下來的第一個索引開始遍歷,每當遇到一個數,就判斷它與主元之間的大小關係,有三種情況

  • 小於主元就把這個數與lt指向的數交換,然後lt,i都自增1,然後繼續遍歷
  • 大於主元就把這個數與gt指向的數交換,gt自減1,此時i還得不能自增,因為它不知道gt用一個什麼樣的元素跟它交換,所以留到下一次迴圈判斷交換過來的這個元素的去留
  • 等於主元就不用跟誰進行交換,直接自增1就可以

三向切分快速排序如下:

public class Quick {

    //獲取中位數
    private static int getMedian(Comparable[] a, int i, int j, int k){
        return a[i].compareTo(a[j]) > 0
                ? (a[i].compareTo(a[k]) < 0 ? i : a[j].compareTo(a[k]) > 0 ? j : k)
                : (a[i].compareTo(a[k]) > 0 ? i : a[j].compareTo(a[k]) < 0 ? j : k);
    }

    private static void swap(Comparable[] a, int i, int j){
        Comparable t = a[i];
        a[i] = a[j];
        a[j] = t;
    }

  //插入排序
    private static void insertSort(Comparable[] a, int left, int right) {
        for (int i = left; i <= right; ++i) {
            int j;
            Comparable value = a[i];
            for (j = i - 1; j >= left && value.compareTo(a[j]) < 0; --j)
                a[j + 1] = a[j];
            a[j + 1] = value;
        }
    }

    private static void sort(Comparable[] a, int left, int right){
        if (right - left < 15) {
            insertSort(a, left, right);
            return;
        }
        swap(a, getMedian(a, left, (left + right) / 2, right), left);
        Comparable v = a[left];
        int lt = left, i = left + 1, gt = right;

        while (i <= gt){
            int cmp = a[i].compareTo(v);
            if (cmp < 0)
                swap(a, lt++, i++);
            else if (cmp > 0)
                swap(a, i, gt--);
            else
                i++;
        }

        sort(a, left, lt - 1);
        sort(a, gt + 1, right);
    }
  
   public static void sort(Comparable[] a){
        sort(a, 0, a.length - 1);
    }

//測試
    public static void main(String[] args) {
        int size = 10000000;
        Integer[] a = new Integer[size];
        for (int i = 0; i < 10000000; ++i)
            a[i] = 88;
        sort(a);  
    }
}
複製程式碼

5. 注意:

目前所實現的三向切分並不完美,雖然它解決了大量重複元素的不必要排序,將排序時間從線性對數級別降到線性級別,但它在陣列元素重複不多的情況下,它的交換次數比標準的二分法多很多。不過在90年代J.BentlyD.Mcllroy找到一個聰明的辦法解決了這個問題。接下來的快速三向切分就是解決辦法。

快速的三向切分

            *   left part         center part                  right part
            * +----------------------------------------------------------+
            * | == pivot |  < pivot  |    ?    |  > pivot    | == pivot |
            * +----------------------------------------------------------+
            *            ^           ^         ^             ^
            *            |           |         |             |  
            *            p           i         j             q
複製程式碼

在這個演算法中,[p, i)裡面的元素小於主元,(j, q]裡面的元素大於主元,而左右兩端[left, p)(q, right]等於主元。在演算法一開始,pi都指向left後面的第一個元素, jq都指向right,先把i從左到右遍歷時每遇到一個元素都會有三種情況:

  • 等於主元,這時只要與p指向的元素交換然後各自自增1即可
  • 小於主元,這就是指標pi所要維護的元素,直接把i自增1跳過就可以
  • 大於主元,這時就是jq所要維護的元素,先退出迴圈等待與他們交換

同理,對於jright向左遍歷也是一樣。當 i > j 時,切分也就結束,最後還要把陣列調整為左邊小右邊大,中間等於主元的形式,再依次排序左邊和右邊。在這個演算法中,既解決了重複元素排序的問題,又解決了少量元素重複時,交換次數過多的問題。接下來是我的實現,不過我覺得我有些地方實現的不太好,湊合著用吧。

快速的三向切分的實現

public class Quick3WayPartitionSort {
    //獲取中位數
    private static int getMedian(Comparable[] a, int i, int j, int k){
        return a[i].compareTo(a[j]) > 0
                ? (a[i].compareTo(a[k]) < 0 ? i : a[j].compareTo(a[k]) > 0 ? j : k)
                : (a[i].compareTo(a[k]) > 0 ? i : a[j].compareTo(a[k]) < 0 ? j : k);
    }

    private static void swap(Comparable[] a, int i, int j){
        Comparable t = a[i];
        a[i] = a[j];
        a[j] = t;
    }

    public static void insertSort(Comparable[] a, int left, int right) {
        for (int i = left; i <= right; ++i) {
            int j;
            Comparable value = a[i];
            for (j = i - 1; j >= left && value.compareTo(a[j]) < 0; --j)
                a[j + 1] = a[j];
            a[j + 1] = value;
        }
    }
    
    //調整陣列
    private static void adjust(Comparable[] a, int start, int end, int toStart){
        for (int i = start; i <= end; ++i)
            swap(a, i, toStart++);
    }

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

    private static void sort(Comparable[] a, int left, int right){
        if (right - left < 10) {
            insertSort(a, left, right);
            return;
        }
        swap(a, getMedian(a, left, (left + right) / 2, right), left);
        Comparable v = a[left];
        int p = left + 1, i = p, j = right, q = j;

        while (true){
            while (i <= j){
                int cmp = a[i].compareTo(v);
                if (cmp == 0)
                    swap(a, i++, p++);
                else if (cmp < 0)
                    i++;
                else
                    break;
            }

            while (i <= j){
                int cmp = a[j].compareTo(v);
                if (cmp == 0)
                    swap(a, j--, q--);
                else if (cmp > 0)
                    j--;
                else
                    break;
            }

            if (i < j)
                swap(a, i++, j--);
            else
                break;
        }

        if (p - left > i - p)
            adjust(a, p, i - 1, left);
        else
            adjust(a, left, p - 1, left + i - p);

        if (right - q > q - j)
            adjust(a, j + 1, q, right - q + j);
        else
            adjust(a, q + 1, right, j + 1);

        sort(a, left, left + i - p - 1);
        sort(a, right + j - q - 1, right);
        }


    public static void main(String[] args) {
        
    }
}
複製程式碼

6. 最後

快速排序不是穩定的排序演算法,所謂穩定就是當待排陣列中存在重複元素的時候,排序後重復元素的相對順序不會改變。在多關鍵字排序時,穩定的排序演算法就很有用處。比如當一個學生按照學號先排序,然後再根據成績進行排序,因為成績存在重複的值,此時穩定的排序演算法就會導致排序後具有相同成績的學生按照學號排序,不會混亂。

相關文章