快速排序及其優化

9龍發表於2019-05-05

一、引言

顧名思義,快速排序是實踐中的一種快速排序演算法,在C++或對Java基礎型別的排序中特別有用。它的平均執行時間是O(NlogN);但最壞情形效能為O(N2)。我會先介紹快速排序過程,再討論如何優化。

二、快速排序(quicksort)

  • 演算法思想

採用分治法,將陣列分為兩部分,並遞迴呼叫。將陣列S排序的快排過程

  1. 如果S中元素個數是0或1,則直接返回;
  2. 取S中任一元素v,稱之為樞紐元(pivot);【樞紐元的選取策略很重要,下面會詳述
  3. 將S-{v}(S中除了樞紐元中的其餘元素)劃分為兩個不相交的集合S1和S2,S1集合中的所有元素小於等於樞紐元v,S2中的所有元素大於等於樞紐元;
  4. 返回quicksort(S1),樞紐元v,quicksort(S1</sub2)。
  • 樞紐元的選取策略
  1. 取第一個或者最後一個:簡單但很傻的選擇(啊,9龍,上面這圖???)。當輸入序列是升序或者降序時,這時候就會導致S1集合為空,除樞紐元外所有元素在S2集合,這種做法,最壞時間複雜度為O(N2)。
  2. 隨機選擇:這是比較安全的做法。除非隨機數發生器出現錯誤,並且連續產生劣質分割的概率比較低。但隨機數生成開銷較大,這樣就增加了執行時間。
  3. 三數中值分割法:一組序列的中值(中位數)是樞紐元最好的選擇(因為可以將序列均分為兩個子序列,歸併排序告訴我們,這時候是O(NlogN);但要計算一組陣列的中位數就比較耗時,會減慢快排的效率。但可以通過計算陣列的第一個,中間位置,最後一個元素的中值來代替。比如序列:[8,1,4,9,6,3,5,2,7,0]。第一個元素是8,中間(left+right)/2(向下取整)元素為6,最後一個元素為0。所以中位數是6,即樞紐元是6。顯然使用三數分割法消除了預排序輸入的壞情形,並且實際減少了14%的比較。
  • 快排過程
  1. 將樞紐元與陣列最後一個元素調換,使樞紐元離開要被分割的資料段;

  2. 初始化兩個索引left和right,分別指向陣列第一個與倒數第二個元素;

  3. 如果left索引指向的元素小於樞紐元,則left++;否則,left停止。right索引指向的元素大於樞紐元,right--;否則,right停止。

  4. 如果left<right,則交換兩個元素,迴圈繼續3,4步驟;否則跳出迴圈,將left對應的元素與樞紐元交換(這時候完成了分割)。遞迴呼叫這兩個子序列。

    假設所有元素互異(即都不相等)。下面會說重複元素怎麼處理。

    接下來要做的就是將小於樞紐元的元素移到陣列左邊,大於樞紐元的元素移到陣列右邊。

    當left在right的左邊時,我們將left右移,移過那些小於樞紐元的元素,並將right左移,移過那些大於樞紐元的元素。當left和right停止時,left指向一個大於樞紐元的元素,right指向一個小於樞紐元的元素,如果left<right,則將這兩個元素交換。這樣是將一個大於樞紐元的元素推向右邊而把小於樞紐元的元素推向左邊。我們來圖示過程:left不動,而right左移一個位置,如下圖:

    我們交換left與right指向的元素,重複這個過程,直到left>right。

    至此,我們可以看到,left左邊的元素都小於樞紐元,右邊的元素都大於樞紐元。我們繼續遞迴左右序列,最終可完成排序。

    上面我們假設的是元素互異,下面我們討論重複元素的處理情況。

  • 重複元素的處理:簡單說是遇到與樞紐元相等的元素時,左右索引需要停止嗎?
  1. 如果只有其中一個停止:這包含兩種,如果只停止左、或者右索引,這將導致等於樞紐元的元素都移動到一個集合中。考慮序列所有元素都是重複元素,會是最壞情形O(N2)。
  2. 如果都不停止:這需要防範左右索引越界,並且不用交換元素。但正如上面圖示的正確過程是,樞紐元需要與left索引指向的元素進行交換。還是考慮所有元素相同的情況,這會導致序列全分到左邊,這樣還是最壞情形O(N2)
  3. 都停止:還是考慮元素全都相等的情況,這樣看似會進行很多次“無意義”的交換;但正面的效果卻是,left與right交錯是發生在中間位置,這時剛好將序列均分為兩個子序列,還是歸併排序的原理,這是O(NlogN)。我們分析指出,只有這種情況可以避免二次方。

在大規模輸入量中,重複元素還是挺多的。考慮能將這些重複元素進行有效排序,還是很重要。

快速排序真的快嗎?其實也不一定,對於小陣列(N<=20)的輸入序列,快速排序不如插入排序並且在我們上面的優化中,採用三數中值分割時,遞迴得到的結果可以是隻有一個,或者兩個元素,這時會有錯誤。所以,繼續優化是將小的序列用插入排序代替,這會減少大約15%的執行時間。較好的截止範圍是10(其實5-20產生的效果差不多)。

對於三數中值分割還可以進行優化:假設輸入序列為a,則選擇a[left],a[center],a[right],選擇出樞紐值,並將最小,與最大值分別放到a[left],a[right],將樞紐值放到a[right-1]處,這樣放置也是正確的位置,並且可以防止right向右進行比較時不會越界;這樣左右起始位置就是left+1,right-2。

三、優化彙總的java實現快速排序:

public class Quicksort {
    /**
     * 截止範圍
     */

    private static final int CUTOFF = 10;

    public static void main(String[] args) {
        Integer[] a = {8149635270};
        System.out.println("快速排序前:" + Arrays.toString(a));
        quicksort(a);
        System.out.println("快速排序後:" + Arrays.toString(a));
    }

    public static <T extends Comparable<? super T>> void quicksort(T[] a) {
        quicksort(a, 0, a.length - 1);
    }

    private static <T extends Comparable<? super T>> void quicksort(T[] a, int left, int right) {
        if (left + CUTOFF <= right) {
            //三數中值分割法獲取樞紐元
            T pivot = median3(a, left, right);

            // 開始分割序列
            int i = left, j = right - 1;
            for (; ; ) {
                while (a[++i].compareTo(pivot) < 0) {
                }
                while (a[--j].compareTo(pivot) > 0) {
                }
                if (i < j) {
                    swapReferences(a, i, j);
                } else {
                    break;
                }
            }
            //將樞紐元與位置i的元素交換位置
            swapReferences(a, i, right - 1);
            //排序小於樞紐元的序列
            quicksort(a, left, i - 1);
            //排序大於樞紐元的序列
            quicksort(a, i + 1, right);
        } else {
            //插入排序
            insertionSort(a, left, right);
        }
    }

    private static <T extends Comparable<? super T>> median3(T[] a, int left, int right) {
        int center = (left + right) / 2;
        if (a[center].compareTo(a[left]) < 0) {

            swapReferences(a, left, center);
        }
        if (a[right].compareTo(a[left]) < 0) {
            swapReferences(a, left, right);
        }
        if (a[right].compareTo(a[center]) < 0) {
            swapReferences(a, center, right);
        }
        // 將樞紐元放置到right-1位置
        swapReferences(a, center, right - 1);
        return a[right - 1];
    }

    public static <T> void swapReferences(T[] a, int index1, int index2) {
        T tmp = a[index1];
        a[index1] = a[index2];
        a[index2] = tmp;
    }

    private static <T extends Comparable<? super T>> void insertionSort(T[] a, int left, int right) {
        for (int p = left + 1; p <= right; p++) {
            T tmp = a[p];
            int j;

            for (j = p; j > left && tmp.compareTo(a[j - 1]) < 0; j--) {
                a[j] = a[j - 1];
            }

            a[j] = tmp;
        }
    }

}
//輸出結果
//快速排序前:[8, 1, 4, 9, 6, 3, 5, 2, 7, 0]
//快速排序後:[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
複製程式碼

四、快速排序分析

  1. 最壞時間複雜度:即元素都分到一個子序列,另一個子序列為空的情況,時間複雜度為O(N2)。
  2. 最好時間複雜度:即序列是均分為兩個子序列,時間複雜度是O(NlogN),分析與歸併排序差不多。
  3. 平均時間複雜度:O(NlogN)
  4. 空間複雜度:O(logN)

五、總結

本篇從如何較好選擇樞紐元,分析重複元素的處理及遞迴分成小陣列時更換為插入排序三個方面進行快速排序的優化,系統全面詳述了快速排序原理、過程及其優化。快速排序以平均時間O(NlogN)進行,是java中基礎型別使用的排序演算法。可以去看一下Arrays.sort方法。到這裡,我就要回過頭去完善求解topK問題了,可以利用快速排序的思想,達到平均O(N)求解topK。

覺得可以的小夥伴們點個推薦或小贊支援啊。

相關文章