程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> C++ >> 關於C++ >> 快速排序(QuickSort)

快速排序(QuickSort)

編輯:關於C++

為什麼叫快速排序

 

這個標題是帶有歧義的,每一種排序都有自己的名字,有的是發明者+排序(Shell排序),有的是用的步驟名稱+排序(插入排序)... 而快速排序是以它的屬性+排序為名(這不是廢話嗎)。那麼我再換個意義明確的標題:

快速排序為什麼那麼快

要弄明白這一點首先需要了解基於比較的排序模型:決策樹

 

對大小為n的輸入,其位置關系有n!種可能。排序算法的工作就是在所有n!種可能中找出輸入究竟是哪一種。

而基於比較的排序算法手頭的工具就只有 比較 操作,通過不斷地比較一對對元素來排除不可能的排列(或者說留下可能的排列)。

而比較的結果只能是>和<(不考慮=)這兩種,所以每一次比較操作總有機會使得一半的可能留下來。所以基於比較的排序算法的下界為Ω(lgn!) =Ω(nlgn)。

也許你可能會問那為什麼插入排序在數據本來有序的情況下只需花費O(n)呢。

花費O(n)是沒錯,但這畢竟是決策樹中最短的一支啊,下界是針對最壞情況下至少得做多少次比較。

 

所以快速排序快的原因就是:其分區過程在很大程度上獲得的信息量很大,可以快速地排除掉不可能的排列可能。

比如拿最好的情況來說——主元為中位數(也就是均勻地把區間分為兩半),那麼得到有n/2個元素比主元小,n/2個元素比主元大。

這樣剩下來的排列可能為:(n/2)! * (n/2)! = n!/ 2^(n/2) 種,而這一過程只花費了O(n)。

當於在O(1)的比較下就只剩根號2分之1的可能,也是呈幾何下降。

 

當然快也是相對而言的:就拿插入排序來講,它為什麼那麼慢,就是因為每一次插入元素所獲得的信息很少,排除的排列可能也很少。

比如對第n/2元素進行插入操作,花費了O(n/2),剩下來的排列可能為 n!/ (n/2)! 而插入之前的排列可能為 n! / (n/2 - 1)! 種,相當於在O(1)的比較下 只排除了2/n的可能,這一效率是遠遠沒法跟快速排序比的!
所以快速排序高效的原因就在於利用主元劃分這一模型可以很快地確定元素之間的關系。

Hoare分區算法

Hoare分區算法的優點是:首先常數很低(對於本身就已經處於正確區間的元素是不會做多余移動的),並且會把和主元相等的元素均勻地分到兩邊。
/***************************************************************
    函數:隨機選取主元的霍爾分區算法
    參數:對[low, high)范圍內的數據分區,range為區間的元素個數
***************************************************************/
int* hoarePartition(int* low, int* high, int range)
{
    ///隨機選取主元
    int pivot = ((int)(((double)rand() / RAND_MAX) * range) + low);
    low--;
    while(true)
    {
        while(*(++low) < pivot);    ///掃描左區間
        while(*(--high) > pivot);   ///掃描右區間
        ///當兩邊都掃描到不屬於自己區間的元素時
        if(low < high)
        {
            ///當兩區間還沒有交集說明有兩個元素分別落在錯誤的區間,交換即可
            int tmp = *low;
            *low = *high;
            *high = tmp;
        }
        ///當區間相交時,Hoare分區只保證[0....pivot)中的元素 <= [pivot....n)中的元素
        ///並沒有確定主元的位置,所以只得到兩個確定大小關系的區間,中間沒有主元相隔
        else return low;
    }
}
這裡特別要注意的是Hoare分區算法並沒有讓主元隔在中間,只是得到兩個確定大小關系的區間。 所以在快速排序遞歸函數中對區間范圍的選擇會有所區別。

比較次數的期望

這裡的比較次數是指整個排序算法運行中,所有掃描區間的動作的次數。即下面cnt的大小的期望:
int cnt = 0;    ///比較次數

int* hoarePartition(int* low, int* high, int range)
{
    int pivot = *((int)(((double)rand() / RAND_MAX) * range) + low);
    low--;
    while(true)
    {
        while(*(++low) < pivot)
            cnt++;    ///發生比較
        cnt++;  ///測試失敗也是一次比較
        while(*(--high) > pivot)
            cnt++;   ///發生比較
        cnt++;  ///測試失敗也是一次比較
        if(low < high)
        {
            int tmp = *low;
            *low = *high;
            *high = tmp;
        }
        else return low;
    }
}

/***************************************
    函數:快速排序函數
    參數:對[low, high)范圍內的數據排序
    平均時間復雜度:O(nlgn)
***************************************/
void quickSort(int* low, int* high)
{
    int range = high - low; ///區間元素個數
    ///當區間有不止1個元素時才進行分區
    if(range > 1)
    {
        int* pivot = hoarePartition(low, high, range);  ///返回主元地址
        quickSort(low, pivot); ///對左區間進行快速排序
        quickSort(pivot, high);
    }
}

/***********************************
    函數:測試比較次數
***********************************/
void testQuickSort(int* a, int n)
{
    cnt = 0;    ///初始化次數
    quickSort(a, a + n);
    cout << endl << cnt << endl;
}
這裡得到的期望的常數和算法導論上是不一樣的,因為這是針對Hoare分區的期望分析,不過分析方法和算法導論是一樣的。 首先得假設下返回的主元就是用來劃分的主元。不然太難分析。 這裡用算法導論上面的推導方法:任意一對相距gap的元素(i,j),在排序過程中會比較k次的概率為1 / (gap + 1)^(k - 1) * 2 / (gap + 1), 而相距gap遠的元素對有(n - gap)對,所以得到下面的級數: \
cnt的實際值應該會比上面的計算結果大,因為Hoare分區算法中選取的主元值也會和自己比較(上面的級數沒有考慮這一點),所以還應該加上主元的個數, 這本來也是個概率問題(如果主元為最小值時可能使得區間不變),不過可以很快做出估計:O(n),因為這是一棵二叉樹,每個內部節點都會選取一次主元,而內部節點 = 葉子節點 - 1。 所以期望次數最終為:2nln(n) + n 下面是我的測試數據(輸入元素互異): \

三數取中的優勢

從區間隨機選取三個數(可以重復選取同一元素),直觀上很快可以知道越是大小接近中間的數越是容易成為主元,也就在很大程度上使得劃分更加均勻。
/***************************************************************
    函數:三數取中的霍爾分區算法
    參數:對[low, high)范圍內的數據分區,range為區間的元素個數
***************************************************************/
int* hoarePartition(int* low, int* high, int range)
{
    ///三數取中
    int a = *((int)(((double)rand() / RAND_MAX) * range) + low);
    int b = *((int)(((double)rand() / RAND_MAX) * range) + low);
    int c = *((int)(((double)rand() / RAND_MAX) * range) + low);
    int pivot = a > b ? (b > c ? b : (a > c ? c : a)) : (a > c ? a : (b > c ? c : b));
    low--;
    while(true)
    {
        while(*(++low) < pivot);    ///掃描左區間
        while(*(--high) > pivot);   ///掃描右區間
        ///當兩邊都掃描到不屬於自己區間的元素時
        if(low < high)
        {
            ///當兩區間還沒有交集說明有兩個元素分別落在錯誤的區間,交換即可
            int tmp = *low;
            *low = *high;
            *high = tmp;
        }
        ///當區間相交時,Hoare分區只保證[0....pivot)中的元素 <= [pivot....n)中的元素
        ///並沒有確定主元的位置,所以只得到兩個確定大小關系的區間,中間沒有主元相隔
        else return low;
    }
}
下面是具體的概率分布: 因為第k大的元素被選為主元的概率為(6(k - 1)(n - k) + 3n - 2) / n^3,對n取極限無窮得到概率分布函數 y = 6x(1 - x): 
如果像算法導論裡面那樣定義一個“好”的劃分為主元排在[n/3, 2n/3]之中,那麼通過積分我們可以知道這一概率為13/27(接近一半的概率),比不用三數取中的1/3大出不少。 完全不會分析這種概率分布不均的東西,但直覺上肯定是能理解的,下面我把測試數據發出來(和上面一樣元素互異) 
提升還是不錯的,但沒有什麼完美的東西,因為三數取中的“取”這一步又得花不少額外的時間。不過這一缺點可以被彌補一些,後面會講使用插入排序。

尾遞歸優化

在快速排序的遞歸樹中節點的信息是不需要被保存的,所以可以讓一個父節點來代替其中一個孩子節點繼續遞歸下去:
/***************************************
    函數:尾遞歸優化版快速排序
    功能:對[low, high)范圍內的數據排序
    期望運行時間:O(2nlgn)
***************************************/
void quickSortRoutine(int* low, int* high)
{
    int range;      ///區間元素個數
    ///當區間有不止1個元素時才進行分區
    while((range = high - low) > 1)    ///改為循環語句這樣父節點就可以在下一次運算中充當孩子節點
    {
        int* pivot = hoarePartition(low, high, range);  ///返回主元地址
        quickSortRoutine(low, pivot); ///對左區間進行快速排序
        low = pivot;    ///注意區間范圍
    }
}
可以通過選擇替代“較大”的孩子遞歸使得遞歸樹的深度控制在lgn之內,不過快速排序本來就是靠概率吃飯的,這樣會增加比較開銷,我就不寫了。 這裡還有一點點優化的空間,那就是維護一個保存區間信息的數組,裡面保存的數據為待分區的區間,這樣就完全不用遞歸了。不過會額外增加空間上的消耗。

使“葉子”變粗

就像前面說的三數取中的花銷很大,並且遞歸開銷也很大,所以我們可以把快速排序的“觸底”情況的限制放寬,最後對整個基本有序的數組應用插入排序。 於是得到完整快速排序:
#define FACTOR 37   ///葉子的寬度

/***************************************************************
    函數:三數取中的霍爾分區算法
    參數:對[low, high)范圍內的數據分區,range為區間的元素個數
***************************************************************/
int* hoarePartition(int* low, int* high, int range)
{
    ///三數取中
    int a = *((int)(((double)rand() / RAND_MAX) * range) + low);
    int b = *((int)(((double)rand() / RAND_MAX) * range) + low);
    int c = *((int)(((double)rand() / RAND_MAX) * range) + low);
    int pivot = a > b ? (b > c ? b : (a > c ? c : a)) : (a > c ? a : (b > c ? c : b));
    low--;
    while(true)
    {
        while(*(++low) < pivot);    ///掃描左區間
        while(*(--high) > pivot);   ///掃描右區間
        ///當兩邊都掃描到不屬於自己區間的元素時
        if(low < high)
        {
            ///當兩區間還沒有交集說明有兩個元素分別落在錯誤的區間,交換即可
            int tmp = *low;
            *low = *high;
            *high = tmp;
        }
        ///當區間相交時,Hoare分區只保證[0....pivot)中的元素 <= [pivot....n)中的元素
        ///並沒有確定主元的位置,所以只得到兩個確定大小關系的區間,中間沒有主元相隔
        else return low;
    }
}

/***************************************
    函數:尾遞歸優化版快速排序
    功能:對[low, high)范圍內的數據排序
    期望運行時間:O(nlgn)
***************************************/
void quickSortRoutine(int* low, int* high)
{
    int range;      ///區間元素個數
    ///當區間有不止1個元素時才進行分區
    while((range = high - low) > FACTOR)    ///改為循環語句這樣父節點就可以在下一次運算中充當孩子節點
    {
        int* pivot = hoarePartition(low, high, range);  ///返回主元地址
        quickSortRoutine(low, pivot); ///對左區間進行快速排序
        low = pivot;    ///注意區間范圍
    }
}

/********************************************************************
    函數:優化一點點的插入排序
    功能:對[low, high)內的數據進行排序
********************************************************************/
void improvedInsertionSort(int* low, int* high)
{
    ///因為最小值在第一位,所以直接從第三個元素開始插入
    ++low;
    while(++low < high)
    {
        int tmp = *low; ///把待插入元素保存到臨時變量中
        int* destPos = low;  ///計算插入位子
        ///把第一次測試單獨提出來
        if(*(--destPos) > tmp)
        {
            do
            {
                *(destPos + 1) = *destPos;
            }while(*(--destPos) > tmp);     ///測試上一個是否是目標位置
            *(destPos + 1) = tmp;     ///最後一次測試失敗使得destPos比實際小1
        }
    }
}

/**********************************
    函數:完整版快速排序
**********************************/
void quickSort(int* low, int* high)
{
    ///設置隨機數種子
    srand(time(nullptr));
    ///進行快速排序,葉子寬度為FACTOR
    quickSortRoutine(low, high);
    ///找出最小值放到最開始作為插入排序的哨兵節點
    int* minPos = low;   ///最小值的位置
    int* lastPos = low + FACTOR;
    for(int* i = low + 1; i < lastPos; i++)
        if(*i < *minPos) minPos = i;
    int tmp = *low;
    *low = *minPos;
    *minPos = tmp;
    ///最後進行插入排序
    improvedInsertionSort(low, high);
}
那麼葉子的寬度該如何去選擇呢。一般來說是8到20都不錯,不過這裡用了三數取中所以常數比普通隨機選取主元的快速排序大,所以這裡是個兩位數就可以了。 上面那個37也是我主觀選的,就像生活大爆炸裡sheldon說:73是最美妙的數字,因為73是第21個素數,反過來37正好又是第12個素數,並且21正好又等於7和3的積, 另外把73轉換為二進制後可以得到1001001,正讀倒讀都一樣。感覺這樣一寫就很高端啊。

 

 

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved