《算法導論》——十分鐘瞭解快速排序!

注:本文爲《算法導論》中排序相關內容的筆記。對此感興趣的讀者還望支持原作者。

基本概念

快速排序最早由C. A. R. Hoare在1962年提出,一經面世,就備受矚目。不過,快速排序並不快!對於包含n個數的輸入數組來說,快速排序是一種最壞情況下時間複雜度爲Θ(n2)\Theta(n^2)的排序算法。然而,快速排序真的很快!因爲,它的平均性能非常好,期望時間複雜度爲Θ(nlgn)\Theta(n\lg n),而且Θ(nlgn)\Theta(n\lg n)中隱含的常數因子非常小。另外,它還能夠進行原址排序,甚至在虛存環境中也能很好地工作。因此,快速排序通常是實際排序應用中最好的選擇。

算法思想

與歸併排序一樣,快速排序也採用了分治思想。下面是對一個典型的子數組A[pr]A[p \dots r]進行快速排序的三步分治過程:

  • 分解:數組A[pr]A[p \dots r]被劃分爲兩個(可能爲空)子數組A[pq1]A[p \dots q-1]A[q+1r]A[q+1 \dots r],使得A[pq1]A[p \dots q-1]中的每一個元素都不大於A[q]A[q],而A[q]A[q]也不大於A[q+1r]A[q+1 \dots r]中的每個元素。其中,計算下標qq也是劃分過程的一部分
  • 解決:通過遞歸調用快速排序,對子數組A[pq1]A[p \dots q-1]A[pq1]A[p \dots q-1]進行排序。
  • 合併:因爲子數組都是原址排序的,所以不需要合併操作——數組A[pr]A[p \dots r]已經有序。

是不是感覺有點抽象?這樣就對一個無序數組完成排序操作了?沒錯,還真是!不信,咱們看看下面實現快速排序的僞代碼。

快速排序

從上圖中,我們不難看出,爲了排序一個數組AA的全部元素,我們初始調用QUICKSORT(A,1,A.lengthA, 1, A.length)。此外,我們可以得到算法的關鍵部分是PARTITION部分,它總是選擇一個x=A[r]x = A[r]作爲主元,並圍繞它來劃分子數組A[pr]A[p \dots r],實現了對子數組A[pr]A[p \dots r]的原址重排。因此,我們不妨詳細看看它的實現細節。

劃分

上圖給出了一個樣例數組的PARTITION過程,我們選擇4作爲主元來劃分子數組,而且淡灰色表示不大於主元的元素,深灰色表示不小於主元的元素。首先,2<42<4,根據PARTITION過程,與它自身進行交換,並被放入元素值較小的那部分;接着,8和7被添加到元素值較大的那個部分中;之後,因爲1<41<4,它將8進行交換,數值較小的部分規模增加;同樣的,在下一步中,3和7進行了交換;然後,5和6都被包含進較大部分,循環至此結束。最後,主元被交換,這樣主元就位於兩個部分之間,也完成了對原數組的劃分。

從上圖中,我們不難看出,ii始終指向不大於主元的元素的上界。因此,在PARTITION過程中,對於任意數組下標kk,有:

  1. pkip \le k \le i,則A[k]xA[k] \le x
  2. i+1kj1i + 1 \le k \le j-1,在A[k]>xA[k] > x
  3. k=rk = r, 則A[k]=xA[k] = x

通過PARTITION過程,我們將原數組劃分成不大於劃分元素和不小於劃分元素的兩部分,原數組變得相對有序。因此,不難想象,只要不斷地對劃分後的子數組遞歸調用PARTITION過程,我們就能完成對一個無序數組的排序。

算法分析

從快速排序的僞代碼實現中,我們不難看出,快速排序的時間複雜度主要取決於PARTITION過程。具體而言,快速排序的運行時間依賴於劃分是否平衡,而平衡又依賴於用於劃分的元素。如果劃分是平衡的,那麼快速排序算法性能與歸併排序一樣。如果劃分是不平衡的,那麼快速排序的性能就接近於插入排序了。因此,爲了更好地探究快速排序的時間複雜度,我們將分析快速排序在最壞情況劃分和最好情況劃分的分析。

最壞情況劃分下,劃分產生的兩個子數組分別包含n1n-1個元素和0個元素。不妨假設快速排序過程中,每一次遞歸調用中都出現了這種不平衡劃分。劃分操作爲Θ(n)\Theta(n)。由於對於一個大小爲0的數組進行遞歸調用會直接返回,因此T(0)=Θ(1)T(0) = \Theta(1)。於是,在最壞情況劃分下,快速排序的遞歸式可以表示爲:

T(n)=T(n1)+T(0)+Θ(n)=T(n1)+Θ(n)T(n) = T(n-1) + T(0) + \Theta(n) = T(n-1) + \Theta(n)

我們可以解得上式爲T(n)=Θ(n2)T(n)=\Theta(n^2)。因此,如果快速排序在每一層遞歸上,劃分都是最大程度的不平衡,那麼算法的時間複雜度爲Θ(n2)\Theta(n^2)。而輸入數組完全有序,或輸入數組元素相同都會導致最壞情況劃分的出現。

說完最壞情況劃分,再來說說最好情況劃分吧。其實,最好情況劃分也就是最平衡的劃分,即PARTITION得到的兩個子問題的規模都不大於n/2n/2。這是因爲,其中一個子問題的規模是n/2\lfloor n/2 \rfloor,另一個子問題的規模是n/21\lfloor n/2 \rfloor - 1。在這種情況下,快速排序的性能非常好,因爲,其算法運行時間的遞歸式爲:

T(n)=2T(n/2)+Θ(n)T(n)=2T(n/2) + \Theta(n)

我們可以求解上式得到T(n)=Θ(nlgn)T(n)=\Theta(n \lg n),十分令人滿意。

然而,在實際應用中,最壞情況劃分最好情況劃分畢竟都是少數,那麼快速排序算法的平均運行時間又是怎樣的呢?令人欣慰的是,快速排序的平均運行時間更接近於其最好情況劃分,而不是最壞情況劃分。沒錯,就是這麼神奇!可是,爲什麼呢?

我們不妨假設快速排序算法在每一層遞歸中,劃分比例爲1a:a(0<a1/2)1-a:a(0 < a \le 1/2),則此時快速排序的運行時間遞歸式爲:

T(n)=T[(1a)n]+T(an)+cnT(n)=T[(1-a)n]+T(an)+cn

則我們依然可以求解得到T(n)=Θ(nlgn)T(n)=\Theta(n\lg n)。此外,從直觀上看,即使快速排序算法有常數次遞歸,劃分比例不是1a:a1-a:a,甚至有常數次劃分比例爲n1:0n-1:0,其並不影響快速排序算法的時間複雜度的量級爲Θ(nlgn)\Theta(n\lg n)(當然,T(n)T(n)隱藏的常數因子不盡相同)。快速排序算法的平均運行時間更接近其最好情況劃分的特點也正是其無與倫比之處!

算法改進

前面說到,在最壞情況劃分下,快速排序的時間複雜度爲Θ(n2)\Theta(n^2),這是令人難以接受的。而輸入數組完全有序和輸入數組元素相同等情況都會導致最壞情況劃分的出現。因此,我們極力避免此類情況的發生,以下是幾種改進方案。

  1. 隨機化輸入數組。顯而易見,打亂輸入數組主要針對輸入數組完全有序的情形;
  2. 三路劃分。它主要針對輸入數組元素相同的情況;
  3. 三數取中劃分。它更細緻地選擇劃分過程中的主元元素以避免最壞情況劃分

接下來,我們將一一詳解上述三種改進方案。

首先是隨機化輸入數組。沒什麼好說的,就是打亂輸入數組以儘可能避免最壞情況劃分的出現,示例程序如下。

隨機化輸入數組

接下來是三路劃分。與原始的PARTITION過程不同,它修改了PARTITION過程——排列A[pr]A[p \dots r],返回值是兩個數組下標qqtt(其中,pqtrp \le q \le t \le r),且有:

  • A[qt]A[q \dots t]中的所有元素都相等;
  • A[pq1]A[p \dots q-1]中的所有元素都小於A[q]A[q]
  • A[t+1r]A[t+1 \dots r]中的所有元素都大於A[q]A[q]

根據三路劃分的要求,我們可以得到如下的示例程序:

三路劃分

不難看出,當輸入數組元素全部相同時,改進後的快速排序只需要調用一次修改後的PARTITION過程,即可完成排序,十分高效。

最後再來看看三數取中劃分。它從子數組中隨機選出三個元素,取其中位數作爲主元。它通過此方法以避免快速排序算法過程中發生最壞情況劃分,示例程序如下:

三數取中劃分

以上就是快速排序的三種改進方案,而且它們之間可以相互融合共同改進快速排序算法(下面的快速排序算髮的實現就是採取此種方案)。當然,快速排序的改進方案遠不止這三種,感興趣的讀者可以自行查閱相關文獻,在此不再贅述。

算法實現

好了,快速排序相關概念介紹了這麼多,是時候將其付諸實踐了!畢竟,“光說不練假把式”!下面給出了快速排序的Java版本。

import java.util.Random;

/**
 * 快速排序(對輸入隨機化處理,三路劃分)
 * @author 愛學習的程序員
 * @version V1.0
 */
public class QuickSort{

    /**
     * 交換兩個數(藉助數組,無法直接交換)
     * @param arr 原數組
     * @param m 待交換數的下標
     * @param n 另一個待交換數的下標
     * @return 無
     */
    public static void exchange(int[] arr, int m ,int n){
        int temp = arr[m];
        arr[m] = arr[n];
        arr[n] = temp;
        return;
    }

    /**
     * 打亂數組
     * @param arr 待打亂數組
     * @return 無
     */
    public static void shuffle(int[] arr){
        Random rand = new Random();
        int index = rand.nextInt(arr.length);
        exchange(arr, 0, index);
        for(int i = 1; i < arr.length; i++){
            index = rand.nextInt(arr.length-i) + i;
            exchange(arr, i, index);
        }
    }

    /**
     * 三數取中法選取劃分元素
     * @param arr 原數組
     * @param low 數組下界的下標
     * @param high 數組上界的下標
     * @return 無 
     */
    public static int median(int[] arr, int low, int high){  
            Random rand = new Random();
            int[] index = new int[3];
            for(int i = 0; i < 3; i++)
                index[i] = rand.nextInt(high-low+1) + low;
            // 可利用快排過程選出第k大的數的特點選擇中位數,但是既然確定是三數取中法,就不用快排了
            if(arr[index[0]] >= arr[index[1]]){
                if(arr[index[0]] <= arr[index[2]])
                    return index[0];
                else if(arr[index[1]] >= arr[index[2]])
                    return index[1];
                else
                    return index[2];
            }
            else{
                if(arr[index[0]] >= arr[index[2]])
                    return index[0];
                else if(arr[index[1]] >= arr[index[2]])
                    return index[2];
                else
                    return index[1];
            }
        
    }

    /**
     * 快速排序中的三路劃分
     * @param arr 原數組
     * @param low 數組的下界
     * @param high 數組的上界
     * @return 三路劃分的結果的下標(小於劃分元素的部分的上界的下標,大於劃分元素的部分的下界的下標)
     */
    public static int[] partition(int[] arr, int low, int high){
        if(low >= high)
            return null;
        else{
            // 用於劃分的元素
            int pivot = 0;
            // 劃分元素取中位數
            if((high - low) >= 2){
                int index = median(arr, low, high);
                pivot = arr[index];
                exchange(arr, low, index);
            }                        
            else{
                // 交換數組的下界與上界
                pivot = arr[high];
                exchange(arr, low, high);
            }               
            // i表示數組中小於劃分元素部分的上界,j表示數組中等於劃分元素部分的上界
            int i = low - 1, j = low + 1;
            for(int k = low + 1; k < high+1; k++){
                if(arr[k] < pivot){
                    i++;
                    exchange(arr, i, k);
                    exchange(arr, j, k);
                    j++;
                    continue;
                }
                if(arr[k] == pivot){
                    exchange(arr, j, k);  
                    j++;
                    continue;
                }                         
            }
            int[] result = {i, j};
            return result;
        }
    }

    /**
     * 快速排序
     * @param arr 原數組
     * @param low 數組的下界
     * @param high 數組的上界
     * @return 無
     */
    public static void quickSort(int[] arr, int low, int high){
        int[] result = new int[2];
        while(low < high){
            result = partition(arr, low, high);
            if((high - result[1]) > (result[0] - low)){
                quickSort(arr, low, result[0]);
                low = result[1];
            }
            else{
                quickSort(arr, result[1], high);
                high = result[0];
            }
        }
    }

    public static void main(String[] args){
        // 測試數組
        Random rand = new Random();
        int[] arr = new int[10];
        int i = 0;
        System.out.println("測試數組:");
        for(i = 0; i < arr.length ;i++){
            arr[i] = rand.nextInt(15) + 1;
            System.out.print(arr[i]+"\t");
        }
        // 打亂數組以儘可能避免最壞情況
        shuffle(arr);
        System.out.println("\n"+"打亂結果:");
        for(i = 0; i < arr.length; i++)
            System.out.print(arr[i]+"\t");
        System.out.println("\n"+"排序結果:");
        //快速排序
        quickSort(arr, 0, arr.length - 1);
        for(i = 0; i < arr.length; i++)
            System.out.print(arr[i]+"\t");
    }
}

算法總結

最後再簡單總結一下快速排序算法的優缺點吧。

  • 優點

    快速排序平均性能非常好,它的期望運行時間是Θ(nlgn)\Theta(n\lg n),而且隱藏的常數因子非常小。另外,它還能夠進行原址排序,甚至在虛存環境中也能很好地工作,是實際排序應用中最好的選擇!

  • 缺點

    快速排序是不穩定的,它最壞劃分情況下的時間複雜度爲Θ(n2)\Theta(n^2)。雖然,快速排序發展至今,已經有不少學者提出了改進方案,但仍未徹底解決此問題。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章