快速排序一步一步優化


一、快速排序介紹

  快速排序是C.R.A.Hoare於1962年提出的一種划分交換排序。它采用了一種分治的策略,通常稱其為分治法(Divide-and-ConquerMethod)。

  算法思想:1.先從數組中取出一個數組作為樞軸,一般情況下選取數組的第一個或者最后一個元素作為樞軸,當然可以選取其他的,在后面的優化措施里面,我會慢慢介紹。

       2.雙向遍歷,從左邊選取一個比樞軸大的數,從右邊選擇一個比樞軸小的數,然后交換這兩個數;

       3.重復步驟2,直到在樞軸的左邊都比樞軸小,樞軸右邊的數都比樞軸大。

  算法的時間復雜度:O(nlogn)

二、內容

  示例數組:arr = {1,4,2,5,6,7,9,3};

   

  我們選取第一個數作為樞軸。

  下面,我們來看看第一趟遍歷過程:

    

  我們從左循環了3次找到了比樞大的數5,從右循環找到了比樞軸小的數3,接下來,我們要交換這兩個數:

    

  至此,第一趟遍歷結束,但是這並沒有達到要求。我們來看看第二趟遍歷的結果:

    

  交換:

    

  由於,上述已經滿足了條件,因此不必進行再次交換。

    直到最后一趟,我們樞軸歸位:

    

  代碼實現:

int qsort(int *a,int left,int right){
    if (right <= left)
        return -1;
    int pivot = a[right];
    int i = left;
    int j = right - 1;

    //從前向后掃描,不需要判斷是否會出現越界問題
    while(true){
        while(a[i++] < pivot);

        //從后向前掃描,要防止越界
        while(a[j] > pivot && j >= left){
            j--;
        }
        if (i < j)
            swap(a[i++],a[j--]);
        else{
            break;
        }
    }
    swap(a[i],pivot); // 最后一趟是將a[i]與pivot交換
    qsort(a,left,i -1);
    qsort(a,i+1,right);
    return 0;
}

三、優化 

  我們都知道,快速排序的效率高低主要在於樞軸的選取,無論選取首個元素還是最后一個元素作為樞軸,我們都要對數組進一次遍歷。因此,要想優化快排,還得從樞軸的選取下手。

  1.隨機選取法

  引入原因:在待排序列是部分有序時,固定選取樞軸使快排效率底下,要緩解這種情況,就引入了隨機選取樞軸

  思路:使用隨機數生成函數生成一個隨機數rand,隨機數的范圍為[left, right],並用此隨機數為下標對應的元素a[rand]作為中軸,並與最后一個元素a[right]交換,然后進行

與選取最后一個元素作為中軸的快排一樣的算法即可。

  優點:這是一種相對安全的策略。由於樞軸的位置是隨機的,那么產生的分割也不會總是會出現劣質的分割。在整個數組數字全相等時,仍然是最壞情況,時間復雜度是O(n^2)。實際上,隨機化快速排序得到理論最壞情況的可能性僅為1/(2^n)。所以隨機化快速排序可以對於絕大多數輸入數據達到O(nlogn)的期望時間復雜度。

  代碼實現:

int random(int left,int right){
    return rand() % (right - left + 1) + left;
}

void Qsort(int *a,int left,int right){
    if (left >= right)
    {
        return;
    }
    //隨機選取一個元素作為樞軸,並與最后一個元素進行交換
    int ic = random(left,right);
    swap(a[ic],a[right]);

    int midIndex = data[right];
    int i = left;
    int j = right - 1;

    while(true){
        //找大於樞軸的數據
        while(a[i++] < midIndex);

        //找到小於樞軸的數據
        while(a[j] > midIndex && j >= left){
            j--;
        }
        //數據已經找到,准備交換
        if (i < j)
        {
            swap(a[i++],a[j--]);
        }
        else{
            break;
        }
    }
    swap(a[i],midIndex); //將樞軸放在正確的位置
    Qsort(a,left,i -1);
    Qsort(a,i+1,right);
}

  2.三數取中(median-of-three)

  引入的原因:雖然隨機選取樞軸時,減少出現不好分割的幾率,但是還是最壞情況下還是O(n^2),要緩解這種情況,就引入了三數取中選取樞軸

  思路:假設數組被排序的范圍為left和right,center=(left+right)/2,對a[left]、a[right]和a[center]進行適當排序,取中值為中軸,將最小者放a[left],最大者放在a[right],把中軸元與a[right-1]交換,並在分割階段將i和j初始化為left+1和right-2。然后使用雙向描述法,進行快排。

  分割好處:      

    1.將三元素中最小者被分到a[left]、最大者分到a[right]是正確的,因為當快排一趟后,比中軸小的放到左邊,而比中軸大的放到右邊,這樣就在分割的時候把它們分到了正確的位置,減少了一次比較和交換。

    2.在前面所說的所有算法中,都有雙向掃描時的越界問題,而使用這個分割策略則可以解決這個問題。因為i向右掃描時,必然會遇到不小於中軸的數a[right-1],而j在向左掃描時,必然會遇到不大於中軸的數a[left],這樣,a[right-1]和a[left]提供了一個警戒標記,所以不需要檢查下標越界的問題。

  分析:最佳的划分是將待排序的序列分成等長的子序列,最佳的狀態我們可以使用序列的中間的值,也就是第N/2個數。可是,這很難算出來,並且會明顯減慢快速排序的速度。這樣的中值的估計可以通過隨機選取三個元素並用它們的中值作為樞紐元而得到。事實上,隨機性並沒有多大的幫助,因此一般的做法是使用左端、右端和中心位置上的三個元素的中值作為樞紐元。顯然使用三數中值分割法消除了預排序輸入的不好情形,並且減少快排大約14%的比較次數

   例子:

    初始數組:6  1  8  9  4  3  5  2  7  0

    選取三個中間數:6  1  8  9  4  3  5  2  7  0

     對這三個數進行排序:0  1  8  9  4  3  5  2  7  6

    最后中軸與a[right-1]交換:0  1  8  9  7  3  5  2  4  6

  實例代碼:

int Median(int *a,int left,int right){
    int midIndex = (left + right)>>1;
    if (a[left] > a[midIndex])
    {
        swap(a[left],a[midIndex]);
    }
    if (a[left] > a[right])
    {
        swap(a[left],a[right]);
    }
    if (a[midIndex] > a[right])
    {
        swap(a[midIndex],a[right]);
    }
    swap(a[midIndex],a[right-1]);
    return a[right-1]; //返回中軸
}
void qSort(int *a,int left,int right){
        //如果需要排序的數據大於3個則使用快速排序
        if (right - left >=3)
        {
            int midIndex = Median(a,left,right);
            int begin = left;
            int end = right - 1;
            while (true){
                while(a[++begin] < midIndex);
                while(a[--end]<midIndex);
                if (begin < end)
                {
                    swap(a[begin],a[end]);
                }
                else{
                    swap(a[begin],a[right -1]);//將樞軸移動到何時位置
                    break;
                }
            }
            qSort(a,left,begin -1);
            qSort(a,begin + 1,right);
        }
        else{
            BubbleSort(a,left,right);//當數據小於3個,直接用冒泡排序
        }//當要排序的數據很少時(小於3個),則不能進行三數取中值,此時直接使用簡單的排序(例如冒泡)即可,而且從效率的角度來考慮這也是合理的,因為可以避免函數調用的開銷。
    }

四、進一步優化

  上述三種快排,在處理重復數的時候,效率並沒有很大提高,因此,我們可以想辦法優化。

  1.當待排序序列長度分割到一定大小后,使用插入排序。

   原因:對於很小和部分有序的數組,快排不如插排好。當待排序序列的長度分割到一定大小后,繼續分割的效率比插入排序要差,此時可以使用插排而不是快排

if (high - low + 1 < 10)  
{  
    InsertSort(arr,low,high);  
    return;  
}//else時,正常執行快排  

  2.在一次分割結束后,可以把與Key相等的元素聚在一起,繼續下次分割時,不用再對與key相等元素分割(處理重復效率極高) 

  舉例:

    待排序序列 1 4 6 7 6 6 7 6 8 6

    三數取中選取樞軸:下標為4的數6

    轉換后,待分割序列:6 4 6 7 1 6 7 6 8 6  樞軸key:6

    本次划分后,未對與key元素相等處理的結果:1 4 6 6 7 6 7 6 8 6

    下次的兩個子序列為:1 4 6 和 7 6 7 6 8 6

    本次划分后,對與key元素相等處理的結果:1 4 6 6 6 6 6 7 8 7

    下次的兩個子序列為:1 4 和 7 8 7

    經過對比,我們可以看出,在一次划分后,把與key相等的元素聚在一起,能減少迭代次數,效率會提高不少

  具體過程:在處理過程中,會有兩個步驟

    第一步,在划分過程中,把與key相等元素放入數組的兩端

    第二步,划分結束后,把與key相等的元素移到樞軸周圍

  舉例:

    待排序序列 1 4 6 7 6 6 7 6 8 6

    三數取中選取樞軸:下標為4的數6

    轉換后,待分割序列:6 4 6 7 1 6 7 6 8 6  樞軸key:6

    第一步,在划分過程中,把與key相等元素放入數組的兩端

    結果為:6 4 1 6(樞軸) 7 8 7 6 6 6

    此時,與6相等的元素全放入在兩端了

    第二步,划分結束后,把與key相等的元素移到樞軸周圍

    結果為:1 4 66(樞軸)  6 6 6 7 8 7

    此時,與6相等的元素全移到樞軸周圍了

    之后,在1 4 和 7 8 7兩個子序列進行快排

  代碼示例:

void QSort(int arr[],int low,int high)  //三數中值+聚集相等元素
{
    int first = low;
    int last = high;

    int left = low;
    int right = high;

    int leftLen = 0;
    int rightLen = 0;

    if (high - low + 1 < 10)
    {
        InsertSort(arr,low,high);
        return;
    }
    
    //一次分割
    int key = SelectPivotMedianOfThree(arr,low,high);//使用三數取中法選擇樞軸
        
    while(low < high)
    {
        while(high > low && arr[high] >= key)
        {
            if (arr[high] == key)//處理相等元素
            {
                swap(arr[right],arr[high]);
                right--;
                rightLen++;
            }
            high--;
        }
        arr[low] = arr[high];
        while(high > low && arr[low] <= key)
        {
            if (arr[low] == key)
            {
                swap(arr[left],arr[low]);
                left++;
                leftLen++;
            }
            low++;
        }
        arr[high] = arr[low];
    }
    arr[low] = key;

    //一次快排結束
    //把與樞軸key相同的元素移到樞軸最終位置周圍
    int i = low - 1;
    int j = first;
    while(j < left && arr[i] != key)
    {
        swap(arr[i],arr[j]);
        i--;
        j++;
    }
    i = low + 1;
    j = last;
    while(j > right && arr[i] != key)
    {
        swap(arr[i],arr[j]);
        i++;
        j--;
    }
    QSort(arr,first,low - 1 - leftLen);
    QSort(arr,low + 1 + rightLen,last);
}

  原因:在數組中,如果有相等的元素,那么就可以減少不少冗余的划分。這點在重復數組中體現特別明顯啊。

   3.優化遞歸操作 

  快排函數在函數尾部有兩次遞歸操作,我們可以對其使用尾遞歸優化

  優點:如果待排序的序列划分極端不平衡,遞歸的深度將趨近於n,而棧的大小是很有限的,每次遞歸調用都會耗費一定的棧空間,函數的參數越多,每次遞歸耗費的空間也越多。優化后,可以縮減堆棧深度,由原來的O(n)縮減為O(logn),將會提高性能。

void QSort(int arr[],int low,int high)  
{   
    int pivotPos = -1;  
    if (high - low + 1 < 10)  
    {  
        InsertSort(arr,low,high);  
        return;  
    }  
    while(low < high)  
    {  
        pivotPos = Partition(arr,low,high);  
        QSort(arr,low,pivot-1);  
        low = pivot + 1;  
    }  
} 

  

參考文獻

  http://blog.sina.com.cn/s/blog_5a3744350100jnec.html

  http://www.blogjava.net/killme2008/archive/2010/09/08/331404.html

  http://www.cnblogs.com/cj723/archive/2011/04/27/2029993.html

  http://blog.csdn.net/zuiaituantuan/article/details/5978009

  http://blog.csdn.net/ljianhui/article/details/16797431

  


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM