9.9.4 快速排序最佳化
剛才講的快速排序還是有不少可以改進的地方,我們來看一些最佳化的方案。
1.最佳化選取樞軸
如果我們選取的pivotkey是處於整個序列的中間位置,那麼我們可以將整個序列分成小數集合和大數集合了。但注意,我剛才說的是“如果……是中間”,那麼假如我們選取的pivotkey不是中間數如何呢。比如我們前面講冒泡和簡單選擇排序一直用到的數組{9,1,5,8,3,7,4,6,2},由代碼第4行“pivotkey=L->r[low];”知道,我們應該選取9作為第一個樞軸pivotkey。此時,經過一輪“pivot=Partition(L,1,9);”轉換後,它只是更換了9與2的位置,並且返回9給pivot,整個系列並沒有實質性的變化。如圖9-9-8。
就是說,代碼第4行“pivotkey=L->r[low];”變成了一個潛在的效能瓶頸。排序速度的快慢取決於L.r[1]的關鍵字處在整個序列的位置,L.r[1]太小或者太大,都會影響效能(比如第一例子中的50就是一個中間數,而第二例子的9就是一個相對整個序列過大的數)。因為在現實中,待排序的系列極有可能是基本有序的,此時,總是 固定選取第一個關鍵字(其實無論是固定選取哪一個位置的關鍵字)作為首個樞軸就變成了極為不合理的作法。
改進辦法,有人提出,應該隨機獲得一個low與high之間的數rnd,讓它的關鍵字L.r[rnd]與L.r[low]交換,此時就不容易出現這樣的情況。這被稱為 隨機選取樞軸法。應該說,這在某種程度上,解決了對於基本有序的序列快速排序時的效能瓶頸。不過,隨機就有些撞大運的感覺,萬一沒撞成功,隨機到了依然是很小或很大的關鍵字怎麼辦呢。
再改進,於是就有了 三數取中(median-of-three)法。 即取三個關鍵字先進行排序,將中間數作為樞軸,一般是取左端、右端和中間三個數,也可以隨機選取。這樣至少這個中間數一定不會是最小或者最大的數,從機率來說,取三個數均為最小或最大數的可能性是微乎其微的,因此中間數位於較為中間的值的可能性就大大提高了。由於整個序列是無序狀態,隨機選取三個數和從左中右端取三個數其實是一回事,而且隨機數產生器本身還會帶來時間上的開銷,因此隨機產生不予考慮。
我們來看看取左端、右端和中間三個數的實現代碼,在Partition函數代碼的第3行與第4行之間增加這樣一段代碼。
int pivotkey; int m = low + (high - low) / 2; /* 計算數組中間的元素的下標 */ if (L->r[low]>L->r[high]) swap(L,low,high); /* 交換左端與右端資料,保證左端較小 */ if (L->r[m]>L->r[high]) swap(L,high,m); /* 交換中間與右端資料,保證中間較小 */ if (L->r[m]>L->r[low]) swap(L,m,low); /* 交換中間與左端資料,保證左端較小 */ /* 此時L.r[low]已經為整個序列左中右三個關鍵字的中間值。*/pivotkey=L->r[low]; /* 用子表的第一個記錄作樞軸記錄 */
試想一下,我們對數組{9,1,5,8,3,7,4,6,2},取左9,中3,右2來比較,使得L.r[low]=3,一定要比9和2要來得更為合理。
三數取中對小數組來說有很大的機率選擇到一個比較好的pivotkey,但是對於非常大的待排序的序列來說還是不足以保證能夠選擇出一個好的pivotkey,因此還有個辦法是所謂九數取中(median-of-nine),它是先從數組中分三次取樣,每次取三個數,三個樣品各取出中數,然後從這三個中數當中再取出一個中數作為樞軸。顯然這就更加保證了取到的pivotkey是比較接近中間值的關鍵字。有興趣的同學可以自己去實現一下代碼,這裡不再詳述了。
2. 最佳化不必要的交換
觀察圖9-9-1~圖9-9-6,我們發現,50這個關鍵字,其位置變化是1→9→3→6→5,可其實,它的最終目標就是5,當中的交換其實是不需要的。因此我們對Partition函數的代碼再進行最佳化。
/* 快速排序最佳化演算法 */int Partition1(SqList *L,int low,int high){ int pivotkey; //這裡省略三數取中代碼 pivotkey=L->r[low]; /* 用子表的第一個記錄作樞軸記錄 */ L->r[0]=pivotkey; /* 將樞軸關鍵字備份到L->r[0] */ while(low<high) /* 從表的兩端交替地向中間掃描 */ { while(low<high&&L->r[high]>=pivotkey) high--; L->r[low]=L->r[high]; /* 採用替換而不是交換的方式進行操作 */ while(low<high&&L->r[low]<=pivotkey) low++; L->r[high]=L->r[low]; /* 採用替換而不是交換的方式進行操作 */ } L->r[low]=L->r[0]; /* 將樞軸數值替換回L.r[low] */ return low; /* 返回樞軸所在位置 */}
注意代碼中高亮部分的改變。我們事實將pivotkey備份到L.r[0]中,然後在之前是swap時,只作替換的工作,最終當low與high會合,即找到了樞軸的位置時,再將L.r[0]的數值賦值回L.r[low]。因為這當中少了多次交換資料的操作,在效能上又得到了部分的提高。如圖9-9-9所示。
3. 最佳化小數組時排序方案
對於一個數學科學家,博士生導師,他可以攻克世界性的難題,可以培養最優秀的數學博士,但讓他去教小學生“1+1=2”的算術課程,那還真未必會比常年在小學學校裡耕耘的數學老師教得好。換句話說,大材小用有時會變得反而不好用。剛才我談到了對於非常大的數組的解決辦法。那麼相反的情況,如果數組非常小,其實快速排序反而不如直接插入排序來得更好(直接插入是簡單排序中效能最好)。其原因在於快速排序用到了遞迴操作,在大量資料排序時,這點效能影響相對於它的整體演算法優勢而言是可以忽略的,但如果數組只有幾個記錄需要排序時,這就成了一個大炮打蚊子的大問題。因此我們需要改進一下QSort函數。
#define MAX_LENGTH_INSERT_SORT 7 /* 數組長度閥值 *//* 對順序表L中的子序列L.r[low..high]作快速排序 */void QSort(SqList &L,int low,int high){ int pivot; if((high-low)>MAX_LENGTH_INSERT_SORT) /*當high-low大於常數時快速排序*/ { pivot=Partition(L,low,high); /* 將L.r[low..high]一分為二,算出樞軸值pivot */ QSort(L,low,pivot-1); /* 對低子表遞迴排序 */ QSort(L,pivot+1,high); /* 對高子表遞迴排序 */ } else /* 當high-low小於等於常數時用直接插入排序 */ InsertSort(L); }
我們增加了一個判斷,當high-low不大於某個常數時(有資料認為7比較合適,也有認為50更合理,實際應用可適當調整),就用直接插入排序,這樣就能保證最大化的利用兩種排序的優勢來完成排序工作。
4. 最佳化遞迴操作
大家知道,遞迴對效能是有一定影響的,QSort函數在其尾部有兩次遞迴操作。如果待排序的序列劃分極端不平衡,遞迴的深度將趨近於n,而不是平衡時的log2n,這就不僅僅是速度快慢的問題了。棧的大小是很有限的,每次遞迴調用都會耗費一定的棧空間,函數的參數越多,每次遞迴耗費的空間也越多。因此如果能減少遞迴,將會大大提高效能。
於是我們對QSort實施尾遞迴最佳化。來看代碼。
/* 對順序表L中的子序列L.r[low..high]作快速排序 */void QSort1(SqList *L,int low,int high){ int pivot; if((high-low)>MAX_LENGTH_INSERT_SORT) { while(low<high) { pivot=Partition1(L,low,high); /* L.r[low..high]一分為二,算出樞軸值pivot */ QSort1(L,low,pivot-1); /* 對低子表遞迴排序 */ low=pivot+1; /* 尾遞迴 */ } } else InsertSort(L);}
當我們將if改成while後(見高亮代碼部分),因為第一次遞迴以後,變數low就沒有用處了,所以可以將pivot+1賦值給low,再迴圈後,來一次Partition(L,low,high),其效果等同於“QSort(L,pivot+1,high);”。結果相同,但因採用迭代而不是遞迴的方法可以縮減堆棧深度,從而提高了整體效能。
在現實的應用中,比如C++、java、PHP、C#、VB、Javascript等等都有對快速排序演算法的實現 ,實現方式上略有不同,但基本上都是在我們講解的快速排序法基礎上的精神體現。
我們現在學過的排序演算法,有按照實現方法分類命名的,如簡單選擇排序、直接插入排序、歸併排序,有按照其排序的方式類比現實世界命名的,比如冒泡排序、堆排序,還有用人名命名的,比如希爾排序。但是剛才我們講的排序,卻用“快速”來命名,這也就意味著只要再有人找到更好的排序法,此“快速”就會名不符實,不過,至少今天,TonyHoare發明的快速排序法經過多次的最佳化後,在整體效能上,依然是排序演算法王者。我們應該要好好研究並掌握它。
出處:http://www.cnblogs.com/cj723/archive/2011/04/28/2031345.html