快速排序及优化
轉(zhuǎn)自:http://blog.csdn.net/zuiaituantuan/article/details/5978009
看了編程珠璣Programming Perls第11章關(guān)于快速排序的討論,發(fā)現(xiàn)自己長年用庫函數(shù),已經(jīng)忘了快排怎么寫。于是整理下思路和資料,把至今所了解的快排的方方面面記錄與此。
?
綱要
一、算法描述(Algorithm Description)
快速排序由C.A.R.Hoare于1962年提出,算法相當(dāng)簡單精煉,基本策略是隨機(jī)分治。
首先選取一個樞紐元(pivot),然后將數(shù)據(jù)劃分成左右兩部分,左邊的大于(或等于)樞紐元,右邊的小于(或等于樞紐元),最后遞歸處理左右兩部分。
分治算法一般分成三個部分:分解、解決以及合并??炫攀蔷偷嘏判?#xff0c;所以就不需要合并了。只需要劃分(partition)和解決(遞歸)兩個步驟。因?yàn)閯澐值慕Y(jié)果決定遞歸的位置,所以Partition是整個算法的核心。
對數(shù)組S排序的形式化的描述如下(REF[1]):
二、時間復(fù)雜度分析(Time Complexity)
快速排序最佳運(yùn)行時間O(nlogn),最壞運(yùn)行時間O(N^2),隨機(jī)化以后期望運(yùn)行時間O(nlogn),關(guān)于這些任何一本算法數(shù)據(jù)結(jié)構(gòu)書上都有證明,就不寫在這了,一下兩點(diǎn)很重要:
所以訴時間復(fù)雜度的分析都是圍繞樞紐元的位置展開討論的。
三、具體實(shí)現(xiàn)細(xì)節(jié)(Details of Implementaion)
1、劃分(Partirion)
為了方便討論,將Partition從QuickSort函數(shù)里提出來,就像算法導(dǎo)論里一樣。實(shí)際實(shí)現(xiàn)時我更傾向于合并在一起,就一個函數(shù),減少了函數(shù)調(diào)用次數(shù)。
劃分又分成兩個步驟:選取樞紐元
和按樞紐元將數(shù)組分成左右兩部
a.選取樞紐元(Pivot Selection)
固定位置
同樣是為了方便,將選取樞紐元單獨(dú)提出來成一個函數(shù):select_pivot(T A[], int p, int q),該函數(shù)從A[p...q]中選取一個樞紐元并返回,且樞紐元放置在左端(A[p]的位置)。
對于完全隨機(jī)的數(shù)據(jù),樞紐元的選取不是很重要,往往直接取左端的元素作為樞紐元。
但是實(shí)際應(yīng)用中,數(shù)據(jù)往往是部分有序的,如果仍用兩端的元素最為樞紐元,則會產(chǎn)生很不好的劃分,使算法退化成O(n^2)。所以要采用一些手段避免這種情況,我知道的有“隨機(jī)選取法”和“三數(shù)取中法”。
隨機(jī)選取
顧名思義就是從A[p...q]中隨機(jī)選擇一個樞紐元,這個用庫函數(shù)可以很容易實(shí)現(xiàn)
其中randInt(p, q)隨機(jī)返回[p, q]中的一個數(shù),C/C++里可由stdlib.h中的rand函數(shù)模擬。
三數(shù)取中
即取三個元素的中間數(shù)作為樞紐元,一般是取左端、右斷和中間三個數(shù),也可以隨機(jī)選取。(REF[1])
b.按樞紐元將數(shù)組分成左右兩部分
雖然說分割方法只影響算法時間復(fù)雜度的系數(shù),但是一個好系數(shù)也是比較重要的。這也就是為什么實(shí)際應(yīng)用中寧愿選擇可能退化成O(n^2)的快速排序,也不用穩(wěn)定的堆排序(堆排序交換次數(shù)太多,導(dǎo)致系數(shù)很大)。
常見的分割方法有三種:
單向掃描
單向掃描代碼非常簡單,只有短短的幾行,思路也比較清晰。該算法由N.Lomuto提出,算法導(dǎo)論上也采用了這種算法。對于數(shù)組A[p...q], 該算法用一個循環(huán)掃描整個區(qū)間,并維護(hù)一個標(biāo)志m,使得循環(huán)不變量(loop invariant)A[p+1...m] < A[p] && A[m+1, i-1] >= x[l]始終成立。(REF[2],REF[3])
順便廢話幾句,在看國外的書的時候,發(fā)現(xiàn)老外在分析和測試算法尤其是循環(huán)時,非常重視不變量(invariant)的使用。確立一個不變量,在循環(huán)開始之前和結(jié)束之后檢查這個不變量,是一個很好的保持算法正確性的手段。
事實(shí)上第一種算法需要的交換次數(shù)比較多,而且如果采用選取左端元素作為樞紐元的方法,該算法在輸入數(shù)組中元素全部相同時退化成O(n^2)。第二種方法可以避免這個問題。
雙向掃描
雙向掃描用兩個標(biāo)志i、j,分別初始化成數(shù)組的兩端。主循環(huán)里嵌套兩個內(nèi)循環(huán):第一個內(nèi)循環(huán)i從左向右移過小于樞紐元的元素,遇到大元素時停止;第二個循環(huán)j從右向左移過大于樞紐元的元素,遇到小元素時停止。然后主循環(huán)檢查i、j是否相交并交換A[i]、A[j]。
雙向掃描可以正常處理所有元素相同的情況,而且交換次數(shù)比單向掃描要少。
Hoare的雙向掃描
這種方法是Hoare在62年最初提出快速排序采用的方法,與前面的雙向掃描基本相同,但是更難理解,手算了幾組數(shù)據(jù)才搞明白:(REF[2])
需要注意的是,返回值j并不是樞紐元的位置,但是仍然保證了A[p..j] <= A[j+1...q]。這種方法在效率上于雙向掃描差別甚微,只是代碼相對更為緊湊,并且用A[p]做哨兵元素減少了內(nèi)層循環(huán)的一個if測試。
http://www.see2say.com/channel/music/player.aspx?v_album_id=9804
改進(jìn)的雙向掃描
樞紐元保存在一個臨時變量中,這樣左端的位置可視為空閑。j從右向左掃描,直到A[j]小于等于樞紐元,檢查i、j是否相交并將A[j]賦給空閑位 置A[i],這時A[j]變成空閑位置;i從左向右掃描,直到A[i]大于等于樞紐元,檢查i、j是否相交并將A[i]賦給空閑位置A[j],然后 A[i]變成空閑位置。重復(fù)上述過程,最后直到i、j相交跳出循環(huán)。最后把樞紐元放到空閑位置上。
這種類似迭代的方法,每次只需一次賦值,減少了內(nèi)存讀寫次數(shù),而前面幾種的方法一次交換需要三次賦值操作。由于沒有哨兵元素,不得不在內(nèi)層循環(huán)里判 斷i、j是否相交,實(shí)際上反而增加了很多內(nèi)存讀取操作。但是由于循環(huán)計(jì)數(shù)器往往被放在寄存器了,而如果待排數(shù)組很大,訪問其元素會頻繁的cache miss,所以用計(jì)數(shù)器的訪問次數(shù)換取待排數(shù)組的訪存是值得的。
關(guān)于雙向掃描的幾個問題
1.內(nèi)層循環(huán)中的while測試是用“嚴(yán)格大于/小于”還是”大于等于/小于等于”。
一般的想法是用大于等于/小于等于,忽略與樞紐元相同的元素,這樣可以減少不必要的交換,因?yàn)檫@些元素?zé)o論放在哪一邊都是一樣的。但是如果遇到所有 元素都一樣的情況,這種方法每次都會產(chǎn)生最壞的劃分,也就是一邊1個元素,令一邊n-1個元素,使得時間復(fù)雜度變成O(N^2)。而如果用嚴(yán)格大于/小 于,雖然兩邊指針每此只挪動1位,但是它們會在正中間相遇,產(chǎn)生一個最好的劃分,時間復(fù)雜度為log(2,n)。
另一個因素是,如果將樞紐元放在數(shù)組兩端,用嚴(yán)格大于/小于就可以將樞紐元作為一個哨兵元素,從而減少內(nèi)層循環(huán)的一個測試。
由以上兩點(diǎn),內(nèi)層循環(huán)中的while測試一般用“嚴(yán)格大于/小于”。
2.對于小數(shù)組特殊處理
按照上面的方法,遞歸會持續(xù)到分區(qū)只有一個元素。而事實(shí)上,當(dāng)分割到一定大小后,繼續(xù)分割的效率比插入排序要差。由統(tǒng)計(jì)方法得到的數(shù)值是50左右(REF[3]),也有采用20的(REF[1]), 這樣原先的QuickSort就可以寫成這樣。
二、分治
分治這里看起來沒什么可說的,就是一樞紐元為中心,左右遞歸,實(shí)際上也有一些技巧。
1.尾遞歸(Tail recursion)
快排算法和大多數(shù)分治排序算法一樣,都有兩次遞歸調(diào)用。但是快排與歸并排序不同,歸并的遞歸則在函數(shù)一開始, 快排的遞歸在函數(shù)尾部,這就使得快排代碼可以實(shí)施尾遞歸優(yōu)化。第一次遞歸以后,變量p就沒有用處了, 也就是說第二次遞歸可以用迭代控制結(jié)構(gòu)代替。雖然這種優(yōu)化一般是有編譯器實(shí)施,但是也可以人為的模擬:
采用這種方法可以縮減堆棧深度,由原來的O(n)縮減為O(logn)。
?
總結(jié)
- 上一篇: Java编译型语言还是解释型语言
- 下一篇: 面试题整理17 输入一个字符串判断一个字