快速排序(Quick Sort)

1. 簡介

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

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

  1. 在數(shù)組中選取一個元素作為主元
  2. 將數(shù)組切分成左右兩半,左邊一半的元素小于等于主元,右邊一半的元素大于等于主元
  3. 將左邊排序
  4. 將右邊排序
  5. 因為左邊已經(jīng)小于等于右邊了,所以當左右兩邊都排完序,整體也就有序了

2. 代碼實現(xiàn)

public class QuickSort {

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

    //切分數(shù)組的函數(shù)
    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);  //切分數(shù)組,返回切分的位置,也就是主元的位置
        sort(a, left, i - 1);   //對數(shù)組的左半邊排序
        sort(a, i + 1, right);  //對數(shù)組的右半邊排序
    }

    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);
    }
}

輔助函數(shù)
這一段是快速排序的簡單實現(xiàn),還有一些可以優(yōu)化的地方。先來介紹一下實現(xiàn)過程需要用的輔助函數(shù):

  • 因為排序過程中需要與主元進行比較且參與排序的元素是類變量,所以要求排序的元素需要實現(xiàn)Comparable接口重寫compareTo()函數(shù)。
  • 在與主元比較后可能需要交換位置所以用一個swap()函數(shù)交換兩個元素的位置。

3. 快速排序性能與復雜度分析

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

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

4. 算法優(yōu)化

1. 切換到插入排序

  • 對于小數(shù)組,快速排序比插入排序慢
  • 因為遞歸,快速排序的sort()方法在小數(shù)組中也會調(diào)用自己

所以可以當數(shù)組在大小在M以內(nèi)時調(diào)用插入排序,M的取值可以是5 ~ 15。

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

3. 重復的元素不必排序
當數(shù)組中存在大量的重復元素時,如果我們用上面所實現(xiàn)的快排,時間復雜度還是要O(nlgn),這開銷是在太大相對于插入排序來說。這時我們可以采用三向切分來實現(xiàn)快排。如下所示:

            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右邊接下來的第一個索引開始遍歷,每當遇到一個數(shù),就判斷它與主元之間的大小關系,有三種情況

  • 小于主元就把這個數(shù)與lt指向的數(shù)交換,然后lt,i都自增1,然后繼續(xù)遍歷
  • 大于主元就把這個數(shù)與gt指向的數(shù)交換,gt自減1,此時i還得不能自增,因為它不知道gt用一個什么樣的元素跟它交換,所以留到下一次循環(huán)判斷交換過來的這個元素的去留
  • 等于主元就不用跟誰進行交換,直接自增1就可以

三向切分快速排序如下:

public class Quick {

    //獲取中位數(shù)
    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. 注意:

目前所實現(xiàn)的三向切分并不完美,雖然它解決了大量重復元素的不必要排序,將排序時間從線性對數(shù)級別降到線性級別,但它在數(shù)組元素重復不多的情況下,它的交換次數(shù)比標準的二分法多很多。不過在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所要維護的元素,先退出循環(huán)等待與他們交換

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

快速的三向切分的實現(xiàn)

public class Quick3WayPartitionSort {
    //獲取中位數(shù)
    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;
        }
    }
    
    //調(diào)整數(shù)組
    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. 最后

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

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

推薦閱讀更多精彩內(nèi)容