正如上一篇博文所說,今天我們來討論一下所謂的“高級排序”——快速排序。首先聲明,快速排序是一個典型而又“簡單”的分治的遞歸算法。
遞歸的威力我們在介紹插入排序時相比已經見識過了:只要我前面的隊伍是有序的,我就可以通過向前插隊來完成“我的排序”,至於前面的隊伍怎么有序……遞歸實現,我不管。
遞歸就是如此“簡單”的想法:我不管需要的條件怎么來的,反正條件的實現交給“遞歸的小弟們”去做,只要有基准情形並且向着基准情形“遞”去,就可以保證“歸”回我一個需要的條件。
不過插入排序雖然體現出了遞歸的想法,卻沒有解釋什么叫“分治”,其實分治就是分而治之的意思。如果要舉個例子的話,恐怕漢諾塔是最為合適的,畢竟大家學習C語言遞歸時應該都接觸過。
漢諾塔的遞歸解法用大白話來說就是:作為老和尚,我希望自己只用做一件事,就是把最底下的盤子移到C柱去,至於上面的盤子怎么移到B柱,交給小和尚A去做,而我把最底下的盤子移到C柱后,B柱的盤子們怎么移到C柱來,交給小和尚B去做。
上述漢諾塔的解法就是一種“分治”:把上層盤子移到B柱的任務“分給”A去“治”,而把B柱的盤子們移到C柱的任務又“分給”B去“治”,我只要把底下的盤子移到C柱就行了。需要注意的是,分治不需要什么基准情形,其本質就是將一個大的任務分成兩個或多個小的子任務,在子任務完成的情況下去更簡單地解決大任務。一般來說分治總是與遞歸同時出現,即“分治之后遞歸完成”。
那么,快速排序又是怎樣的一個分治、遞歸呢?我們就用大白話來說說快速排序的想法:
對於數組a,我隨便選一個元素a[x]作為樞紐,所有小於樞紐的元素都“站左邊去”,所有大於樞紐的元素都“站右邊去”(分治),小於樞紐的元素們給我“自行”排好隊,大於樞紐的元素們也給我“自行”排好隊(遞歸)。(若小於a[x]的元素共有i個,則它們放在a[0]到a[i-1],而后將a[x]放在a[i]處,大於a[x]的放於a[i+1]到a[size-1]處,i如何確定暫時不管)
既然有遞歸,那么就必須有基准情形,那么快速排序的基准情形會是什么呢?顯然是當數組被“遞”得只有3個元素的時候,此時對該數組進行“分治”就會直接完成排序。
我們先來試着給出快速排序的偽代碼,調用者調用方式為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萬的隨機整數數組,我們提過的“主流”排序算法的簡單比較結果如下(僅供參考):