天天看點

快速排序及優化(三路劃分等)時間複雜度分析優化

快速排序, 是最經典的排序算法之一。快速排序擁有良好的時間複雜度,平均為 O(nlog2n) ,最差為 O(n2) 。在這裡,我們不妨略略深入讨論一下快速排序:

時間複雜度分析

首先說平均時間複雜度。以比較常用的從兩頭進行掃描的算法為例,算法主要分兩步:

1. 是快排的核心:“分趟”。就是“每一趟”下來,找到某一個元素應該待的位置,這個元素一般被稱為pivot;

2.再分别對pivot前後兩部分進行遞歸排序。

#include <iostream>
using namespace std;
int partition(int *a, int left, int right)
{
    int key = a[left];
    while(left < right){
        while(left < right && a[right] >= key) right--;     //從右找到第一個比key小的
        a[left] = a[right];
        while(left < right && a[left] <= key) left++;       //從左找到第一個比key大的
        a[right] = a[left];   
    }
    a[left] = key;                                          //基準歸位
    return left;    
}
void Qsort(int *a, int left, int right)
{
    if(left < right){                                       //元素長度>1時
        int pos = partition(a, left, right);
        Qsort(a, left, pos - );                            //pos本身不需要再動了
        Qsort(a, pos + , right);
    }
}
int main()
{
    int a[] = {, , , , , , , , };

    Qsort(a, , sizeof(a) / sizeof(a[]) - );

    for(int i = ; i < sizeof(a) / sizeof(a[]); i++)
    {
        cout << a[i] << " ";
    }

    return ;
}
           

顯然,一趟下來,pivot被固定的位置越趨于中間,前後兩部分子序列的遞歸調用就越均衡,這時候時間複雜度是最小的。

T(n) <= n + 2T(n/)
         <= 2n + 4T(n/)
         <= 3n + 8T(n/)
         ...
         <= (log n)n + nT() = O(nlog n)
           

是以,為 O(nlog2n) 。

最差的情況下,也就是pivot被固定後的位置總是在最前面或最後面,導緻前後兩部分子序列實際隻是一個子序列。這也就意味着,原代排序列本身就是有序的,要麼從小到大,要麼從大到小。比如從小到大:此時,第一趟經過n-1次比較,将第一個元素固定在首位;第二趟經過n-2次比較,将第二個元素固定在第二位,以此類推,n個元素總共要比較 1+2+3+...+(n−1)=n(n−1)/2 次,是以複雜度為 O(n2) 。當然,如果從簡單形象的角度去了解,一般的快排執行過程大概是二叉樹形結構,而最差情況則是退化成了連結清單。

優化

優化大緻有三種比較有效的方法。

使用插入排序

在子序列比較小的時候,其實插排是比較快的,因為對于有序的序列,插排可以達到 O(n) 的複雜度,如果序列比較小,則和大序列比起來會更加有序,這時候使用插排效率要比快排高。其實作方法也很簡單:快排是在子序列元素個數變成1是,才停止遞歸,我們可以設定一個門檻值n,假設為5,則大于5個元素,子序列繼續遞歸,否則選用插排。(其實在C++的STL中,歸并算法就是采用了這個思路,當子序列小到一定程度的時候,直接選用插排對子序列進行排序)

快排是在待排數列越趨近于有序時變得越慢,複雜度越高,調用插排可以很好的解決這個問題。

pivot選用中位數

對于一般的快排,我們直接簡單的就取最左或最右的資料作為pivot,這樣的話很可能遇到比較極端的pivot,使得劃分出來的左右子序列變得不均衡。如果選取最左、中間、最右這三個值的中位數的話,顯然會使得pivot更加“不偏激”,這樣劃分出來的左右子序列也會更加均衡。

選用中位數和調用插排一樣,都能避免數列比較有序時複雜度變高的問題。

三路劃分

快排是二路劃分的算法。如果待排序列中重複元素過多,也會大大影響排序的性能。這時候,如果采用三路劃分,則會很好的避免這個問題。

如果一個帶排序列重複元素過多,我們先随機選取一個pivot,設為T,那麼數列可以分為三部分:小于T,等于T,大于T:

快速排序及優化(三路劃分等)時間複雜度分析優化

等于T的部分就無需再參與後續的遞歸調用了,速度自然就大大提升了。

但是問題在于怎麼高效地将序列劃分為三部分!

如下圖,我們可以設定四個遊标,左端a、b,右端c、d。b、c的作用跟之前兩路劃分時候的左右遊标相同,就是從兩端向中間周遊序列,并将周遊到的元素與pivot比較,如果等于pivot,則移到兩端(b對應的元素移到左端,c對應的元素移到右端。移動的方式就是拿此元素和a或d對應的元素進行交換,是以a和d的作用就是記錄等于pivot的元素移動過後的邊界),反之,如果大于或小于pivot,還按照之前兩路劃分的方式進行移動。這樣一來,中間部分就和兩路劃分相同,兩頭是等于pivot的部分,我們隻需要将這兩部分移動到中間即可。

快速排序及優化(三路劃分等)時間複雜度分析優化
快速排序及優化(三路劃分等)時間複雜度分析優化

參考算法如下,摘自http://blog.csdn.net/jlqCloud/article/details/46939703:

快速排序及優化(三路劃分等)時間複雜度分析優化
private void quickSort(int[] a, int left, int right) {
    if (right <= left)
        return;

    /* 
     * 工作指針
     * p指向序列左邊等于pivot元素的位置
     * q指向序列右邊等于Pivot元素的位置
     * i指向從左向右掃面時的元素
     * j指向從右向左掃描時的元素
     */
    int p, q, i, j;
    int pivot;// 錨點
    i = p = left;
    j = q = right - ;
    /*
     * 每次總是取序列最右邊的元素為錨點
     */
    pivot = a[right];
    while (true) {
        /*
         * 工作指針i從右向左不斷掃描,找小于或者等于錨點元素的元素
         */
        while (i < right && a[i] <= pivot) {
            /*
             * 找到與錨點元素相等的元素将其交換到p所訓示的位置
             */
            if (a[i] == pivot) {
                swap(a, i, p);
                p++;
            }
            i++;
        }
        /*
         * 工作指針j從左向右不斷掃描,找大于或者等于錨點元素的元素
         */
        while (left <= j && a[j] >= pivot) {
            /*
             * 找到與錨點元素相等的元素将其交換到q所訓示的位置
             */
            if (a[j] == pivot) {
                swap(a, j, q);
                q--;
            }
            j--;
        }
        /*
         * 如果兩個工作指針i j相遇則一趟周遊結束
         */
        if (i >= j)
            break;

        /*
         * 将左邊大于pivot的元素與右邊小于pivot元素進行交換
         */
        swap(a, i, j);
        i++;
        j--;
    }
    /*
     * 因為工作指針i指向的是目前需要處理元素的下一個元素
     * 故而需要退回到目前元素的實際位置,然後将等于pivot元素交換到序列中間
     */
    i--;
    p--;
    while (p >= left) {
        swap(a, i, p);
        i--;
        p--;
    }
    /*
     * 因為工作指針j指向的是目前需要處理元素的上一個元素
     * 故而需要退回到目前元素的實際位置,然後将等于pivot元素交換到序列中間
     */
    j++;
    q++;
    while (q <= right) {
        swap(a, j, q);
        j++;
        q++;
    }

    /*
     * 遞歸周遊左右子序列
     */
    quickSort(a, left, i);
    quickSort(a, j, right);
}

private void quick(int[] a) {
    if (a.length > ) {
        quickSort(a, , a.length - );
    }
}

private void swap(int[] arr, int a, int b) {
    int temp = arr[a];
    arr[a] = arr[b];
    arr[b] = temp;
}
           

三路劃分可以避免很多重複元素再次參與遞歸,對于有大量重複元素的待排序列,效率提高了不少。

以上隻是理論上的總結,當然實踐起來代碼也不難寫。在這裡推薦一篇有碼有實驗資料的文章,看後也是更加直覺形象,受益匪淺。

快排的優化其實對于一個計算機科學與技術的入門者來講,是一個不錯的思維上的砥砺,這種類型的東西多多探索,計算機科學“素養”自然慢慢就上去了。

繼續閱讀