快速排序

一、選取最後一個元素

在我們的課本中,看到最多的就是選擇第一個元素作爲中軸,但是在很多書上卻選擇最後一個元素作爲中軸。下面就讓我們來一睹選取最後一個元素作爲中軸的快排。


注:本文中的所有算法都採用雙向掃描法,即,設兩個下標i和j,i和右掃描,j向左掃描,直到i不小於j。而當下標爲i的數小於中軸時,跳過並繼續向右掃描,否則停止掃描,並開始j的向左掃描,相對地,當下標爲j的數大於中軸時,跳過並繼續向右掃描,否則停止掃描,然後交換下標爲i和j的兩個數,並從下一個位置繼續兩個方向的掃描,直到i不小於j。最後把中軸與下標爲i的元素交換即完成一趟的快排。


下面就以最後一個元素作爲中軸來說明。


初始數據爲: 8  1  4  9  0  3  5  2  7 6
中軸爲:6,第一趟快排的情景如下:

8  1  4  9  0  3  5  2  7  6

↑                               ↑

i→                         ←j
第一次交換後:
2  1  4  9  0  3  5 8  7  6
↑                          ↑
i                            j

第二次交換後:
2  1  4  5  0  3  9  8  7  6
            ↑          ↑
            i           j
第三次交換前:
2  1  4  5  0  3  9  8  7  6
                    ↑  ↑
                    j   i
將下標爲i的元素與最後一個元素(中軸)交換
     即,一趟快排後:
             2  1  4  5  0  3  6  8  7  9
對左邊的數組和右邊的數組重複上述過程。


選取最後一個元素作爲中軸的好處是簡單直觀,操作一致。因爲我們通常把下標爲i的元素與中軸元素作爲交換,而i則總是指向比中軸大的元素,而把大的元素放到後面總是合理的。


二、雙向描述法中的越界問題

快速排序中都非常喜歡使用雙向掃描法,然而這個方法卻存在一個越界問題,考慮如下的情況:
          8  1  4  6  9  3  5  2  7  0
           i→                        ←j

當我們選取最後一個元素作爲中軸時,j向左掃描,一直到找到一個比0小的數,但是它卻是數組中的最小值,所以j會一直向左走,直到越界。


這個問題無論是使用第一個元素作爲中軸,還是使用最後一個元素作爲中軸都會存在。所以,在雙向描述中,必然有一個方向(遠離中軸的方法)要做越界檢查。


選取最後一個元素作爲中軸的快排的關鍵代碼

  1. void QSort(DataType *data,int left,int right) 
  2.     //如果數據的個小數爲1或0則不需要排序 
  3.     if(left >= right) 
  4.         return
  5.     //取最後一個元素作爲樞紐 
  6.     DataType centre = data[right]; 
  7.     int i = left; 
  8.     int j = right-1; 
  9.     while(true
  10.     { 
  11.         //從前向後掃描,找到第一個小於樞紐的值, 
  12.         //在到達數組末尾前,必定結果循環,因爲最後一個值爲centre 
  13.         while(data[i] < centre) 
  14.             ++i; 
  15.         //從後向前掃描,此時要檢查下標,防止數組越界 
  16.         while(j >= left && data[j] > centre) 
  17.             --j; 
  18.         //如果沒有完成一趟交換,則交換 
  19.         if(i < j) 
  20.             Swap(data[i++], data[j--]); 
  21.         else 
  22.             break
  23.     } 
  24.     //把樞紐放在正確的位置 
  25.     Swap(data[i], data[right]); 
  26.     QSort(data, left, i - 1); 
  27.     QSort(data, i + 1, right); 
void QSort(DataType *data, int left, int right)
{
    //如果數據的個小數爲1或0則不需要排序
    if(left >= right)
        return;
    //取最後一個元素作爲樞紐
    DataType centre = data[right];
    int i = left;
    int j = right-1;
    while(true)
    {
        //從前向後掃描,找到第一個小於樞紐的值,
        //在到達數組末尾前,必定結果循環,因爲最後一個值爲centre
        while(data[i] < centre)
            ++i;
        //從後向前掃描,此時要檢查下標,防止數組越界
        while(j >= left && data[j] > centre)
            --j;
        //如果沒有完成一趟交換,則交換
        if(i < j)
            Swap(data[i++], data[j--]);
        else
            break;
    }
    //把樞紐放在正確的位置
    Swap(data[i], data[right]);
    QSort(data, left, i - 1);
    QSort(data, i + 1, right);
}

三、隨機選元法

我們知道,在快排中,中軸的選擇對於算法的效率是非常重要的,選擇一個好的中軸選擇策略會使算法的效率顯著提高。


無論是前面說的選取第一個元素還是最後一個元素作爲中軸,其實都是一個壞的選元方法。因爲當元素基本有序時,這兩種方法都會使算法的效率非常低,最壞情況下,是O(n^2)。


隨機選元法的思路:使用隨機數生成函數生成一個隨機數rand,隨機數的範圍爲[left, right],並用此隨機數爲下標對應的元素a[rand]作爲中軸,並與最後一個元素a[right]交換,然後進行與選取最後一個元素作爲中軸的快排一樣的算法即可。


隨機選元法仍然存在掃描越界問題,所以在遠離中軸的方法上仍然需要檢查下標。


隨機選元法的關鍵代碼

  1. void QSort(DataType *data,int left,int right) 
  2.     //如果數據的個小數爲1或0則不需要排序 
  3.     if(left >= right) 
  4.         return
  5.     //隨機選取一個元素作爲樞紐,並與最後一個元素交換 
  6.     int ic = Random(left, right); 
  7.     Swap(data[ic], data[right]); 
  8.  
  9.  
  10.     DataType centre = data[right]; 
  11.     int i = left; 
  12.     int j = right-1; 
  13.     while(true
  14.     { 
  15.         //從前向後掃描,找到第一個小於樞紐的值, 
  16.         //在到達數組末尾前,必定結果循環,因爲最後一個值爲centre 
  17.         while(data[i] < centre) 
  18.             ++i; 
  19.         //從後向前掃描,此時要檢查下標,防止數組越界 
  20.         while(j >= left && data[j] > centre) 
  21.             --j; 
  22.         //如果沒有完成一趟交換,則交換 
  23.         if(i < j) 
  24.             Swap(data[i++], data[j--]); 
  25.         else 
  26.             break
  27.     } 
  28.     //把樞紐放在正確的位置 
  29.     Swap(data[i], data[right]); 
  30.     QSort(data, left, i - 1); 
  31.     QSort(data, i + 1, right); 
  32. inlineint Random(int begin,int end) 
  33.     //產生begin至end,包括begin和end的隨機數,即[begin, end]範圍的整數 
  34.     return rand()%(end - begin + 1) + begin; 
void QSort(DataType *data, int left, int right)
{
    //如果數據的個小數爲1或0則不需要排序
    if(left >= right)
        return;
    //隨機選取一個元素作爲樞紐,並與最後一個元素交換
    int ic = Random(left, right);
    Swap(data[ic], data[right]);


    DataType centre = data[right];
    int i = left;
    int j = right-1;
    while(true)
    {
        //從前向後掃描,找到第一個小於樞紐的值,
        //在到達數組末尾前,必定結果循環,因爲最後一個值爲centre
        while(data[i] < centre)
            ++i;
        //從後向前掃描,此時要檢查下標,防止數組越界
        while(j >= left && data[j] > centre)
            --j;
        //如果沒有完成一趟交換,則交換
        if(i < j)
            Swap(data[i++], data[j--]);
        else
            break;
    }
    //把樞紐放在正確的位置
    Swap(data[i], data[right]);
    QSort(data, left, i - 1);
    QSort(data, i + 1, right);
}
inline int Random(int begin, int end)
{
    //產生begin至end,包括begin和end的隨機數,即[begin, end]範圍的整數
    return rand()%(end - begin + 1) + begin;
}

從上面的代碼也可以看出,隨機選法與選取最後一個元素作爲中軸的方法是非常相近的,只是多了把隨機的中軸放到最後的位置的操作。


四、三數中值分割法

一組N個數的中值是第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。然後使用雙向描述法,進行快排。


三數中值分割法的實現

初始數據:                    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
開始掃描:                         i→               ←j
第一次交換後:            0  1  2 9  7  3  5 8  4  6
                                              i =2              j=7
第二次交換後:            0  1  2  3  7  9  5  8  4  6
                                                  i=3   j=5
第三次交換前:            0  1  2  3  7  9  5  8  4  6
  i=4,j= 3                                3=j   i=4
第三次交換後:            0  1  2  3  4  9  5  8  7  6
     (與a[right-1]交換)


分割策略的好處

1)將三元素中最小者被分到a[left]、最大者分到a[right]是正確的,因爲當快排一趟後,比中軸小的放到左邊,而比中軸大的放到右邊,這樣就在分割的時候把它們分到了正確的位置,減少了一次比較和交換。
2)在前面所說的所有算法中,都有雙向掃描時的越界問題,而使用這個分割策略則可以解決這個問題。因爲i向右掃描時,必然會遇到不小於中軸的數a[right-1],而j在向左掃描時,必然會遇到不大於中軸的數a[left],這樣,a[right-1]和a[left]提供了一個警戒標記,所以不需要檢查下標越界的問題。

關鍵實現代碼

  1. DataType Median3(DataType *data, int left,int right) 
  2.     //取數據的頭、尾和中間三個數,並對他們進行排序 
  3.     //排序結果直接保存在數組中 
  4.     int centre = (left + right)/2; 
  5.     if(data[left] > data[centre]) 
  6.         Swap(data[left], data[centre]); 
  7.     if(data[left] > data[right]) 
  8.         Swap(data[left], data[right]); 
  9.     if(data[centre] > data[right]) 
  10.         Swap(data[centre], data[right]); 
  11.     //把中值,即樞紐與數組倒數第二個元素交換 
  12.     swap(data[centre], data[right - 1]); 
  13.     return data[right - 1];//返回樞紐 
  14.  
  15. void QSort(DataType *data,int left,int right) 
  16.     //如果需要排序的數據大於3個則使用快速排序 
  17.     if(right - left >= 3) 
  18.     { 
  19.         //取得樞紐的值 
  20.         DataType centre = Median3(data, left, right); 
  21.         int begin = left; 
  22.         int end = right - 1; 
  23.         while(true
  24.         { 
  25.             //向後掃描數組 
  26.             //由於在選擇樞紐時,已經把比樞紐值大的數據放在right位置 
  27.             //所以不會越界 
  28.             while(data[++begin] < centre); 
  29.             //向前掃描數組 
  30.             //由於在選擇樞紐時,已經把比樞紐值小的數據放在left位置 
  31.             //所以不會越界 
  32.             while(data[--end] > centre); 
  33.             //把比樞紐小的數據放在前部,大的放到後部 
  34.             if(begin < end) 
  35.                 Swap(data[begin], data[end]); 
  36.             else 
  37.             { 
  38.                 //已經對要排序的數據都與樞紐比較了一次 
  39.                 //把中樞紐保存在適當的位置,因爲begin的數一定比樞紐大 
  40.                 //所以把這個數放在數組後面 
  41.                 Swap(data[begin], data[right - 1]); 
  42.                 break
  43.             } 
  44.         } 
  45.  
  46.  
  47.         QSort(data, left, begin - 1); 
  48.         QSort(data, begin + 1, right); 
  49.     } 
  50.     else//如果要排序的數據很少,少於等於3個,則直接使用冒泡排序 
  51.     { 
  52.         BubbleSort(data+left, right - left + 1); 
  53.     } 
DataType Median3(DataType *data, int left, int right)
{
    //取數據的頭、尾和中間三個數,並對他們進行排序
    //排序結果直接保存在數組中
    int centre = (left + right)/2;
    if(data[left] > data[centre])
        Swap(data[left], data[centre]);
    if(data[left] > data[right])
        Swap(data[left], data[right]);
    if(data[centre] > data[right])
        Swap(data[centre], data[right]);
    //把中值,即樞紐與數組倒數第二個元素交換
    swap(data[centre], data[right - 1]);
    return data[right - 1];//返回樞紐
}

void QSort(DataType *data, int left, int right)
{
    //如果需要排序的數據大於3個則使用快速排序
    if(right - left >= 3)
    {
        //取得樞紐的值
        DataType centre = Median3(data, left, right);
        int begin = left;
        int end = right - 1;
        while(true)
        {
            //向後掃描數組
            //由於在選擇樞紐時,已經把比樞紐值大的數據放在right位置
            //所以不會越界
            while(data[++begin] < centre);
            //向前掃描數組
            //由於在選擇樞紐時,已經把比樞紐值小的數據放在left位置
            //所以不會越界
            while(data[--end] > centre);
            //把比樞紐小的數據放在前部,大的放到後部
            if(begin < end)
                Swap(data[begin], data[end]);
            else
            {
                //已經對要排序的數據都與樞紐比較了一次
                //把中樞紐保存在適當的位置,因爲begin的數一定比樞紐大
                //所以把這個數放在數組後面
                Swap(data[begin], data[right - 1]);
                break;
            }
        }


        QSort(data, left, begin - 1);
        QSort(data, begin + 1, right);
    }
    else //如果要排序的數據很少,少於等於3個,則直接使用冒泡排序
    {
        BubbleSort(data+left, right - left + 1);
    }
}
注意當要排序的數據很少時(小於3個),則不能進行三數取中值,此時直接使用簡單的排序(例如冒泡)即可,而且從效率的角度來考慮這也是合理的,因爲可以避免函數調用的開銷。

五、與中軸相等時的操作

當i或j在掃描中遇到與中軸相等的元素時,是停止掃描還是繼續?


我們仍然採用雙向掃描法,而且從實踐中我們知道,i與j的操作應該相同(停止或繼續)否則,一趟快排後,會出現中軸偏向一邊的現象。


下面以一下極端的例子來說明:

採用三數中值分割法
初始數據:        2  2  2  2  2  2  2  2  2
分割後:             2  2  2  2  2  2  2  2  2
                                i→            ←j
1)繼續掃描(先不考慮越界)  
                             2  2  2  2  2  2  2  2  2
                             j                            i
第一趟快排後: 2  2  2  2  2  2  2  2 2
    (a[i]與a[right-1]交換)


我們可以看到,如果繼續掃描,出現的將是快排中的最壞情況,分出來的數組極其不平均,時間複雜度爲O(N^2)。採用前面的兩種快排算法也同樣。


2)停止掃描並交換數據
初始數據:             2  2  2  2  2  2  2  2  2
分割後:                 2  2  2  2  2  2  2  2  2
                                    i→            ←j
第一次交換後:     2  2  2  2  2  2  2  2  2
                                   i=1               j=6
第二次交換後:     2  2  2  2  2  2  2  2  2
                                       i=2       j=5
第三次交換後:     2  2  2  2 2  2  2  2  2
                                      3= i   j=4
第四前交換後:     2  2  2  2  2  2  2  2  2
(與a[right-1]交換)     3= j   i=4


雖然停止掃描並交換兩個數據看起來並沒有什麼意義,因爲所有的數據都是相等的,但是它卻可以把數組平均地分爲兩個子數組。使快排達到最理想的情況,也就是說,我們是用交換來換來了效率,真是不可思議。


同時,在上面的分析中,我們是忽略了下標越界的檢查的,從上面的分析中,我們可以看到,如果繼續掃描(第1種情況),則需要在兩個方向上做檢查(這裏所講的所有算法都需要),它與我們之前所說的算法都非常的不一致。更加讓人無法容忍的是,它會使快排變成最壞的情況。


在第2種情況中,雖然要交換元素,但是,卻使快排以最理想的情況運行,並使算法與之前描述的完全一致。
總之,當遇到與中軸相等的元素時,應停止掃描。所以在我們的所有代碼中,while循環中的條件都沒有“=”。


極端的情況並不極端

上面的例子中,舉出了一個非常極端的例子來說明遇到與中軸相等的元素時的處理方法。其實在現實中,這種情況並不極端。


試想要排序的數據有10,000個,其中有2,000個元素相等,並隨機分佈在數組中,也就是說平均在一個有10個元素的數組中有2個元素相等,這是非常普遍的情況。然而隨着快排的進行,這2,000個相等的數,最終會被交換到一個連續的空間中,並出現了前面我們所說的極端情況。它就會成爲我們算法的一個瓶頸。

發佈了16 篇原創文章 · 獲贊 7 · 訪問量 5萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章