淺入淺出數據結構(20)——快速排序

  正如上一篇博文所說,今天咱們來討論一下所謂的「高級排序」——快速排序。首先聲明,快速排序是一個典型而又「簡單」的分治的遞歸算法。算法

  遞歸的威力咱們在介紹插入排序時相比已經見識過了:只要我前面的隊伍是有序的,我就能夠經過向前插隊來完成「個人排序」,至於前面的隊伍怎麼有序……遞歸實現,我無論。數組

  遞歸就是如此「簡單」的想法:我無論須要的條件怎麼來的,反正條件的實現交給「遞歸的小弟們」去作,只要有基準情形而且向着基準情形「遞」去,就能夠保證「歸」回我一個須要的條件。安全

  不過插入排序雖然體現出了遞歸的想法,卻沒有解釋什麼叫「分治」,其實分治就是分而治之的意思。若是要舉個例子的話,恐怕漢諾塔是最爲合適的,畢竟你們學習C語言遞歸時應該都接觸過。函數

  漢諾塔的遞歸解法用大白話來講就是:做爲老和尚,我但願本身只用作一件事,就是把最底下的盤子移到C柱去,至於上面的盤子怎麼移到B柱,交給小和尚A去作,而我把最底下的盤子移到C柱後,B柱的盤子們怎麼移到C柱來,交給小和尚B去作。學習

  

  上述漢諾塔的解法就是一種「分治」:把上層盤子移到B柱的任務「分給」A去「治」,而把B柱的盤子們移到C柱的任務又「分給」B去「治」,我只要把底下的盤子移到C柱就好了。須要注意的是,分治不須要什麼基準情形,其本質就是將一個大的任務分紅兩個或多個小的子任務,在子任務完成的狀況下去更簡單地解決大任務。通常來講分治老是與遞歸同時出現,即「分治以後遞歸完成」。ui

    

 

  那麼,快速排序又是怎樣的一個分治、遞歸呢?咱們就用大白話來講說快速排序的想法:spa

  對於數組a,我隨便選一個元素a[x]做爲樞紐,全部小於樞紐的元素都「站左邊去」,全部大於樞紐的元素都「站右邊去」(分治),小於樞紐的元素們給我「自行」排好隊,大於樞紐的元素們也給我「自行」排好隊(遞歸)。(若小於a[x]的元素共有i個,則它們放在a[0]到a[i-1],然後將a[x]放在a[i]處,大於a[x]的放於a[i+1]到a[size-1]處,i如何肯定暫時無論)code

  既然有遞歸,那麼就必須有基準情形,那麼快速排序的基準情形會是什麼呢?顯然是當數組被「遞」得只有3個元素的時候,此時對該數組進行「分治」就會直接完成排序。blog

  咱們先來試着給出快速排序的僞代碼,調用者調用方式爲QuickSort(a,0,size-1):排序

void QuickSort(int *a, unsigned int left,unsigned int right)
{
    //若a[left]到a[right]元素個數大於2,則繼續分治、遞歸
    if (left < right&&left != right - 1)
    {
        /*僞代碼:隨機選一個a[x],要求x>=left&&x<=right,做爲樞紐*//*僞代碼:將a[left]到a[right]中全部小於median的元素置於a[left]到a[i-1]處(i未知)*/
        /*僞代碼:將a[left]到a[right]中全部大於median的元素置於a[i+1]到a[right]處*/
/*僞代碼:將a[x]放在a[i]處*/
QuickSort(a, left, i - 1); QuickSort(a, i + 1, right); } //若a[left]到a[right]元素個數恰爲2,則直接排序 else if (left == right - 1) { if(a[left]>a[right]) swap(&a[left], &a[right]); } //若left==right,則說明只有一個元素,已爲「有序」 }

  雖然快速排序的想法看似比較簡單,但其實現仍是有「坑」與「捷徑」的,接下來咱們就一步一步實現快速排序,看看都有什麼「坑」與「捷徑」。

 

  回顧快速排序的想法和僞代碼,能夠看出其第一步就是「隨便選一個a[x]」,這看似簡單的第一步實際上是一個「坑」,由於雖然說是隨便選,但「選一個a[x]」仍是要寫出具體代碼來的,因此隨便選一個樞紐的代碼該如何寫,就有了三種作法:

  1.既然是隨便選,那就直接選a[left]好了。

  這個作法是最不可取的,緣由很是簡單,假設數組已經接近有序,那麼選取a[left]做爲樞紐就很容易致使分治變得「無效」,由於a[left]極可能就是最小的元素。

  2.既然要隨便選,那就選a[rand()%(right-left+1)]好了

  這個作法可取,可是問題出在計算隨機數上,計算隨機數多少須要一點代價,並且計算隨機數對於排序這件事並無直接幫助。

  3.三數中值法

  很顯然,這就是咱們的主角了。相比於方法1,本方法要更加安全,而相比於方法2,本方法要更加廉價而且能夠幫助到排序自己。那麼三數中值法到底是怎樣的呢?其實就是:令center=(left+right)/2,而後選a[left]、a[center]、a[right]三者的中值做爲樞紐。不過在選取中值的同時,咱們也獲取了這三者的大小信息,所以能夠順便將這三者「放好位置」,因此說相比於方法2,本方法對於排序要更有幫助一些。

 

  三數中值法咱們通常用一個單獨的函數來實現(其返回值即樞紐的值):

int MedianOf3(int *a, unsigned int left, unsigned int right)
{
    unsigned int center = (left + right) / 2;//前兩個if保證a[left]存儲着三數最小值
    //最後一個if保證a[center]爲三數中值,a[right]爲三數最大值
    //三個if不只選出了樞紐,同時將另外兩元素的分治工做完成
    if (a[left] > a[center])
        swap(&a[left],&a[center]);
    if (a[left] > a[right])
        swap(&a[left],&a[right]);
    if (a[center] > a[right])
        swap(&a[center],&a[right]);

    //最後,將樞紐與a[right-1]交換,即「將樞紐放在a[right-1]處」
    swap(&a[center],&a[right-1]);

    return a[right-1];
}

  在三數中值法的代碼中,最後咱們將樞紐放在了a[right-1]處,這是爲何呢?接下來的講解能夠解釋這個作法的緣由。

 

  實現了樞紐的選取後,接下來要實現的就是分治的「分」,在上述快速排序的想法中咱們說過,咱們將小於樞紐的元素們放在a[left]至a[i-1]處,樞紐放在a[i]處,大於樞紐的元素們放在a[i+1]至a[right]處。可是問題來了,怎麼肯定i的值呢?其實能夠確定的是,在開始分治(與樞紐的比較)前,i是絕對不可能知道的。只有全部元素都與樞紐比較完了才知道i究竟是多少。

  不過,既然確定了必須比完才知道,那咱們就「比完再知道」唄。具體想法就是:

  1.先令樞紐與a[right-1]交換,即將樞紐暫且放在a[right-1]處(在三數中值代碼中已經完成此步驟)

  2.設變量l_pos從left+1開始遞增,直觀地說就是「讓l_pos從數組左側開始向右逐個掃描元素」(a[left]已經在三數中值時分治完畢,不須要再掃描)

  若l_pos掃描到a[l_pos]>樞紐,則l_pos暫停掃描

  3.設變量r_pos從right-2開始遞減,直觀地說就是「讓r_pos從數組右側開始向左逐個掃描元素」(a[right]已分治,a[right-1]爲樞紐)

  若r_pos掃描到a[r_pos]<樞紐,則r_pos暫停掃描

  4.當l_pos與r_pos均中止時(若元素互異,必然存在此狀況),若l_pos<r_pos(直觀地說就是它們「還沒有碰頭」),則交換a[l_pos]與a[r_pos],而後l_pos繼續向右掃描,r_pos繼續向左掃描。若l_pos>r_pos,則它們「已經碰頭」,此時應有l_pos=r_pos+1,即l_pos就在r_pos右邊,因而咱們完全中止二者的掃描,並肯定了i的值爲此時的l_pos。

 

  畫圖三張,以茲參考

  圖1,表示一種初始狀態:

  

 

  圖2,表示當a[l_pos]>樞紐,a[r_pos]<樞紐,且還沒有結束時

  

 

  圖3,表示結束狀況

  

 

 

  對應的分治部分代碼就是這樣:

//初始化l_pos與r_pos
unsigned int l_pos = left + 1, r_pos = right - 2;
//根據三數中值法得出樞紐同時完成兩個元素的分配
int median = MedianOf3(a, left, right);

//l_pos與r_pos不斷向中間掃描
while (1)
{
        while (a[l_pos] < median)
                l_pos++;
        while (a[r_pos] > median)
                r_pos++;
        //l_pos與r_pos均暫停掃描的兩種狀況
        if (l_pos < r_pos)
                swap(&a[l_pos], &a[r_pos]);
        else
                break;
}
//最後記得將樞紐交換至正確位置
swap(&a[l_pos], &a[right - 1]);

 

  解決了選取樞紐與分治,快速排序就算是完成了,從以前所給的快速排序僞代碼就能夠看出這一點,僞代碼中沒有解決的就是這兩個地方:

void QuickSort(int *a, unsigned int left,unsigned int right)
{
    if (left < right&&left != right - 1)
    {
        //選樞紐:
//隨機選一個a[x],要求x>=left&&x<=right,做爲樞紐median //分治:
//將a[left]到a[right]中全部小於median的元素置於a[left]到a[i-1]處 //將a[left]到a[right]中全部大於median的元素置於a[i+1]到a[right]處 //將a[x]放在a[i]處

//遞歸
QuickSort(a, left, i - 1); QuickSort(a, i + 1, right); } else if (left == right - 1) { if(a[left]>a[right]) swap(&a[left], &a[right]); } }

  將僞代碼中未完成部分填上,就有了以下快速排序:

void QuickSort(int *a, unsigned int left, unsigned int right)
{
    //若a[left]到a[right]元素個數大於2,則繼續分治、遞歸
    if (left < right&&left != right - 1)
    {
        /*——————選樞紐——————*/
        int median = MedianOf3(a, left, right);//根據三數中值法得出樞紐同時完成兩個元素的分配

        /*——————分治——————*/
        unsigned int l_pos = left + 1, r_pos = right - 2;//初始化l_pos與r_pos
        //l_pos與r_pos不斷向中間掃描
        while (1)
        {
            while (a[l_pos] < median)
                l_pos++;
            while (a[r_pos] > median)
                r_pos++;
            //l_pos與r_pos均暫停掃描的兩種狀況
            if (l_pos < r_pos)
                swap(&a[l_pos], &a[r_pos]);
            else
                break;
        }
        //最後記得將樞紐交換至正確位置
        swap(&a[l_pos], &a[right - 1]);

        /*——————遞歸——————*/
        QuickSort(a, left, l_pos - 1);
        QuickSort(a, l_pos + 1, right);
    }
    //若a[left]到a[right]元素個數恰爲2,則直接排序
    else if (left == right - 1)
    {
        if (a[left]>a[right])
            swap(&a[left], &a[right]);
    }
    //若left==right,則說明只有一個元素,已爲「有序」
}

 

  可是請注意!上述代碼是有問題的,這是不容易察覺的第二個坑!坑在何處呢?讓咱們揪出上述代碼的一部分:

        while (1)
        {
            while (a[l_pos] < median)
                l_pos++;
            while (a[r_pos] > median)
                r_pos++;
            //l_pos與r_pos均暫停掃描的兩種狀況
            if (l_pos < r_pos)
                swap(&a[l_pos], &a[r_pos]);
            else
                break;
        }

  若是數組的元素必定互異,那麼這一部分代碼沒有問題,可是若是數組元素存在相同,那麼這部分代碼就可能出現問題。

  假設l_pos暫停了掃描,緣由是a[l_pos]==median,並且r_pos也暫停了掃描而且也是由於a[r_pos]==median,那麼單純交換a[l_pos]與a[r_pos]只會使循環陷入死循環,由於兩個子循環的判斷條件將永遠爲false,從而l_pos與r_pos一直不變。

  那麼該如何解決這個問題呢?最直接的辦法就是增長新的判斷,判斷a[l_pos]與a[r_pos]是否都與median相等,若是是則不交換二者,改成令l_pos++和r_pos--。

  可是實際上咱們存在一個解決此問題的捷徑,這個捷徑的思路解提及來稍顯麻煩:既然問題是出在交換後l_pos與r_pos不會變化(遞增與遞減),那就在子循環處改成先變化再比較不就行了:

        while (1)
        {
            while (a[++l_pos] < median)
                /*Do nothing*/;
            while (a[--r_pos] > median)
                /*Do nothing*/;
            if (l_pos < r_pos)
                swap(&a[l_pos], &a[r_pos]);
            else
                break;
        }

  同時,由於l_pos與r_pos都變成了「先變化再比較」,因此二者的初始值也要改變爲:

unsigned int l_pos = left, r_pos = right - 1;

 

 

  因而,完整的快速排序就實現好了,代碼以下:

void QSort(int *a, unsigned int left, unsigned int right)
{
    if (left < right&&left != right - 1)
    {
        unsigned int l_pos = left, r_pos = right - 1;
        int median = MedianOf3(a, left, right);
        int temp;
        while (1)
        {
            while (a[++l_pos] < median);
            while (a[--r_pos] > median);
            if (l_pos < r_pos)
            {
                temp = a[l_pos];
                a[l_pos] = a[r_pos];
                a[r_pos] = temp;
            }
            else
                break;
        }
        temp = a[l_pos];
        a[l_pos] = a[right - 1];
        a[right - 1] = temp;
        QSort(a, left, l_pos - 1);
        QSort(a, l_pos + 1, right);
    }
    else if (left == right - 1 && a[left] > a[right])
    {
        int temp = a[left];
        a[left] = a[right];
        a[right] = temp;
    }
}

  爲了方便調用者,咱們能夠實現一個簡單的「接口」:

void QuickSort(int *a, unsigned int size)
{
    return QSort(a, 0, size - 1);
}

 

 

 

  對於快速排序,還要注意的一點是當數組的大小N很小時,快速排序是不如插入排序的,而且須要注意的是因爲快速排序的遞歸,必然會出現「小數組」。所以實際實現快速排序時每每選擇對小數組執行一個插入排序。即:

void QSort(int *a, unsigned int left, unsigned int right)
{
    if (left < right-N)
    {
        //此部分代碼略
    }
    else
    {
        InsertionSort(a,right-left+1);
    }
}

 

  至此,快速排序實現完畢。

 

  接下來咱們試着分析一下快速排序的時間複雜度。這一部分咱們將分爲兩個小部分:快速排序的最壞狀況,快速排序的最好狀況。

 

  首先,爲了方便分析,咱們假設快速排序的樞紐選擇是徹底隨機的。對於大小爲N的數組,快速排序耗時設爲T(N),則T(1)=1。因而,T(N)=T(i)+T(N-i-1)+c*N,其中i爲小於樞紐的元素個數,c爲未知常數,c*N表明分治階段耗費的線性時間。

  那麼,快速排序的最壞狀況就是每一次選取的樞紐都是最小的元素,此時i=0,上述公式變爲:

  T(N)=T(N-1)+c*N,遞推此公式可得

  T(N-1)=T(N-2)+c*(N-1)

  T(N-2)=T(N-3)+c*(N-2)

  ……

  T(2)=T(1)+c*2

  將上述公式左側與右側均所有相加,得:

  T(N)+T(N-1)+T(N-2)+……+T(2) = T(N-1)+T(N-2)+……T(2)+T(1)+c*(2+3+4+5+……+N)

  化簡可得:

  T(N)=T(1)+c*(2+3+4+……+N)=1+c*(2+3+4+……+N)=O(N2)

  也就是咱們在上一篇博文提到的,快速排序最壞狀況爲O(N2)

  不難看出,選擇樞紐時不管是徹底隨機仍是三數中值,快速排序都不容易出現這樣的最壞狀況。

 

  接下來咱們看看快速排序的最好狀況。快速排序的最好狀況顯然就是每次樞紐都選擇了整個剩餘數組的中間值,爲了簡化推導,咱們假定遞歸時將樞紐自己也帶進去,而且N爲2的冪。從而

  T(N)=2*T(N/2)+c*N

  兩邊同時除以N,得:

  T(N)/N=T(N/2)/(N/2)+c,遞推此公式可得:

  T(N/2)/(N/2)=T(N/4)/(N/4)+c

  T(N/4)/(N/4)=T(N/8)/T(N/8)+c

  ……

  T(2)/2=T(1)/1+c

  將上述公式左側與右側均所有相加, 得:

  T(N)/N+T(N/2)/(N/2)+……+T(2)/2=T(N/2)/(N/2)+……T(1)/1+c*logN

  化簡,得:

  T(N)/N=T(1)/1+c*logN,即T(N)=N*c*logN=O(N*logN)。因此快速排序的最好狀況就是O(N*logN)

 

  快速排序的平均狀況分析的公式繁雜,且化簡須要高深的數學,此處只給出基本思路:既然樞紐是隨機的,那麼小於樞紐的元素個數i就也是隨機位於[0,N-1],那麼i的平均值就應該是(0+1+2+……+(N-1))/N,同理大於樞紐的元素個數平均值爲(0+1+2+……+(N-1))/N,基於這兩點,T(N)=2*(0+1+2+……+(N-1))/N+c*N,依據此公式進行遞推、相加、化簡,能夠得出平均時間爲O(N*logN)

 

  對於快速排序的分析並非只有本文所提,好比在l_pos於r_pos掃描的過程當中,咱們爲何選擇在a[l_pos]或a[r_pos]等於median時停下,而不是繼續掃描呢?這是有緣由的,簡述就是:防止極端狀況下l_pos及r_pos越界,同時使分得的兩個子數組大小更加平衡。可是更多的分析本文就不作介紹了,時間有限╮(╯_╰)╭

 

 

  最後,對於大小爲20萬的隨機整數數組,咱們提過的「主流」排序算法的簡單比較結果以下(僅供參考):

   

相關文章
相關標籤/搜索