排序算法(6):快速排序

基本思想

  快速排序也是一種基於分治的排序算法,它的主要思想是將一個數組切分成兩部分,將這兩部分獨立的進行排序。和歸併排序不同的是:歸併排序首先對兩部分子數組進行排序,在子數組各自有序之後將他們合併爲一個完整的有序數組;而快速排序在兩個子數組均有序的時候整個數組也已經有序了。

  快速排序的關鍵在於對數組的切分,這個過程通過一個切分元素(或者叫基準)來實現的,切分將數組劃分爲兩部分,滿足前一部分的元素均不大於切分元素,後一部分元素均不小於切分元素,這樣在前後兩部分都有序時整個數組自然而然就是有序的。

算法流程

  1. 首先選取切分元素(隨計選取或者取數組第一個元素);
  2. 從前向後遍歷數組,找到一個大於切分元素的元素,接着從後向前遍歷元素找到一個小於切分元素的元素,然後交換這兩個元素的位置;
  3. 重複步驟2,直到遍歷數組的兩個指針相遇,交換此位置的元素和切分元素。這樣整個數組就被切分元素分割成了兩部分,滿足前面的元素均不大於它,後面的元素均不小於它;
  4. 對兩部分子數組遞歸地進行步驟1-3。

演示

代碼實現

  上面講到其實切分部分時算法的核心,因此首先給出切分數組的代碼:

private static int partition(int[] arr, int lo, int hi){
    int i = lo, j = hi + 1;
    int v = arr[lo];   //指定切分元素爲第一個元素
    while(true){
        while(arr[++i] < v){   // 從第二個元素開始向後遍歷,找到大於基準的元素
            if(i == hi)  break;
        }
        while(arr[--j] > v){
            if(j == lo)  break; // 從最後一個元素向前遍歷,找到小於基準的元素
        }
        if(i >= j) break;  // 前後便利的指針相遇,退出循環
        swap(arr, i, j);  // swap是交換數組元素的函數
    }
    swap(arr, lo, j);
    return j;
}

切分函數中使用了第一個元素作爲切分元素,因此從第二個元素向後遍歷,從最後一個元素向前遍歷,並交換滿足條件的元素。最差的情況就是其餘所有元素都小於切分元素 (i==hi),或者其餘所有元素都大於切分元素(j==lo),此時造成的結果是兩部分切分非常不均勻,因此隨機選取切分元素往往是更好的選擇, 或者在排序之前將數組隨機打亂。

  下面是排序函數,可以看到排序的最主要的操作就是切分,然後不斷地遞歸切分子數組

public static void quick_sort(int[] arr){
    // 數組爲空或者長度爲1不需要排序
    if(arr == null || arr.length < 2){
        return;
    }
    sort(arr, 0, arr.length-1);
}

private static void sort(int[] arr, int lo, int hi){
    if(lo >= hi)  return; // lo>=hi說明當前部分已經不需要排序了
    int j = partition(arr, lo, hi);  // 切分數組
    sort(arr, lo, j-1);  // 遞歸地對兩部分子數組進行排序
    sort(arr, j+1, hi);
}

分析

算法改進

1. 隨機選擇切分元素

  前面提到,在一些極端情況下(例如初始數組是倒序的),選取第一個元素進行切分造成的結果就是每次切分的兩部分非常不平衡。以倒序數組來說第一次切分後較長的一部分長度爲 n1n-1 (假設數組總長度爲n),這樣會造成沒有很好的利用到分治帶來的優勢,降低算法性能。 因此一個改進措施就是隨機選取切分元素,或者是選取切分元素前將數組隨機打亂。

2. 切換到插入排序

  對於小數組快速排序比插入排序慢,因此和歸併排序一樣,在子數組規模較小的時候切換爲插入排序而不是遞歸地使用快速排序能夠提高算法的效率。

3. 三路切分

  實際排序中如果數組中包含大量重複元素,此時對於所有元素均相等的子數組,快速排序仍舊會不斷地將其切分爲更小的數組,這時候可以通過將數組劃分爲三部分來改進快速排序。

① 思想

  三路劃分的思想是利用切分函數將待排序數組列劃分爲三部分:第一部分小於切分元素,第二部分等於切分元素,第三部分大於切分元素,接下來遞歸地對除了中間部分的其餘兩部份進行排序。這樣如果數組中包含了大量重複元素,就可以避免對於重複部分進行切分排序的時間消耗。

② 代碼

private static void quick3way_sort(int[] arr, int lo, int hi){
    if(hi <= lo) return;  // lo>=hi說明當前部分已經不需要切分排序了
    int lt = lo, i = lo+1, gt = hi;
    int v = arr[lo];   // 切分元素
    while(i <= gt){
        if(arr[i] < v)  swap(arr, i++, lt++);
        else if(arr[i] > v)  swap(arr, i, gt--);
        else i++;
    }
    // while循環執行完後,arr[lo...lt-1] < a[lt...gt] < a[gt+1...hi]
    quick3way_sort(arr, lo, lt-1);  // 然後對除了切分部分外的兩部分遞歸排序
    quick3way_sort(arr, gt+1, hi);
}

說明:這裏三路切分函數依舊使用第一個元素作爲切分的基準,標記爲v,lt用來存儲切分部分的左邊界,初始爲數組首位置,gt存放切分部分的右邊界,初始爲數組的末尾。從第二個元素開始向後遍歷並且和v比較:如果當前元素小於v,將其切分到左邊並且切分左邊界右移(lt++);如果當前元素大於v,將其切分到右邊並且切分部分右邊界左移(gt--),此時不執行i++的原因是不知道當前右邊界元素和切分元素的大小關係,需要下一次循環中進行比較;如果當前元素等於切分元素,繼續向後遍歷。

算法性能分析

  • 時間複雜度

    快速排序的平均時間複雜度爲 O(nlogn)O(nlogn)。最好情況下,如果每次劃分得當,遞歸樹的深度就是 lognlogn,時間複雜度爲 O(nlogn)O(nlogn);最差情況下,每次劃分都取到了數組中最大的(或最小的)元素作爲切分元素,此時快速排序就退化爲了冒泡排序,時間複雜度爲 O(n2)O(n^2)

  • 空間複雜度

    快速排序主要的空間消耗是遞歸調用的空間佔用。最好情況下,每次都能平均劃分數組,空間複雜度爲 O(logn)O(logn),最差情況下就是退化爲冒泡排序,此時時間複雜度爲 O(n)O(n)

  • 穩定性

    在交換切分元素和遍歷相遇點元素的時候,快速排序有可能打亂數組重複元素原有的順序,因此快速排序是不穩定的。

參考資料

  1. 一文搞定十大經典排序算法
  2. 《算法(第四版)》
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章