快速排序的優化 和關于遞歸的問題,說說我的想法

James Gosling

貼吧習慣,先放張大佬照片騙騙流量鎮樓。

本文有七千字,閱讀大約需要占用你10分鐘時間。

好吧。。隨便寫的,我也不知道會花多久看完。因為寫的比較爛,而且只是結合我查的資料說說想法,可能有很多錯誤的地方,也許看著看著你就不想看了。

0.

春招都講究“金三銀四”,三月份各種內推蜂擁而至,我的求職第一步卻還遲遲沒有邁出。我不想毫無準備就草草上陣,但由于復習的晚,時間上又特別緊張。最近感覺自己越來越焦慮了。面對向往的公司,就像面對喜歡的女生一樣,渴望能在一起卻又遲遲不敢邁出相遇的第一步。

最近忙著準備找工作,想趕在三月份結束前投一波內推。所以其實有挺多想分享的東西,卻一直沒時間更新博客。最近應該也不會寫太多,畢竟這個還是挺花時間的。我想著這段時間憋個大招,等到過幾個月如果拿到了滿意的offer,就也像大神們一樣寫個面經,像什么《如果拿到XX公司offer》《我的面試經歷》之類的文章供學弟學妹們膜拜一下,哈哈。當然這是求職成功的情況,我也不能保證你們肯定能看到我的這個大招。

寫這篇文章的原因是今天對遞歸問題產生了一個疑惑,問了很多人,一直也沒得到滿意的答案。感覺這個問題挺有意思,就寫出來和大家分享一下,也說說我的想法。因為是了解快排優化時候想到的問題,就連著快排優化也一起寫了。

晚上寫的,有點困,思路不太清晰。而且我也只是參考其他人的文章,分享下個人觀點,可能有很多錯誤的想法或者沒有說清楚的地方。如果有不同的觀點歡迎討論指正。下面進入正題。


1. 快排深入探索

快排是一個經典并且被廣泛應用的排序算法,是很重要的知識點。之前簡單介紹過快排,相信大家隨手寫一個快排已經沒有問題了。但是光知道怎么寫是遠遠不夠的,所以今天我們先繼續探索下快排。

1.1快排為什么快

之前分析過快排復雜度,當劃分均衡時,平均時間復雜度O(nlogn),空間O(logn);當劃分完全不均衡時,最壞時間O(n2),空間O(n)。而堆排序平均最壞時間復雜度都為O(nlogn),但為什么實際應用中快排效果好于堆排?

原因主要有三個:

  1. 雖然都是O(nlogn)級別,但是我們知道時間復雜度是近似得到的,快排前面的系數更小,所以性能更好些。
  2. 堆排比較交換次數更多。因為快排是樞軸(pivot)左邊的元素都比pivot小,右邊的元素都更大,比較交換次數會比堆排更少些。
    具體可以參考下這篇文章
    http://mindhacks.cn/2008/06/13/why-is-quicksort-so-quick/
  3. 第三個原因也是最主要的原因,和cpu緩存(cache)有關。學計算機組成原理時候我們了解過,cpu有一塊高速緩存區(cache)。堆排序要經常處理距離很遠的數,不符合局部性原理,會導致cache命中率降低,頻繁讀寫內存。不了解cache的同學可以看一下下面這篇文章。
    http://blog.csdn.net/kobesdu/article/details/39081189
1.2快排如何優化

快排就一定快嗎?當然不是的??紤]如下情況:

a.劃分不均勻

對于分治算法,當每次劃分時,算法若都能分成兩個等長的子序列時,那么分治算法效率會達到最大。如果輸入序列有序(當然正常情況算法應該判斷當序列有序時直接返回),我們選最左邊元素做樞軸點,即劃分極度不均勻時,不僅時間復雜度變成O(n2),遞歸深度也變成了n,這樣很容易令棧內存溢出。所以要盡量避免這種情況。

b.當有大量重復元素時

每趟排序對每個子序列只產生一個有序位置, 所以對數組中相等元素的處理效率不是很高。如果有大量重復元素,實際上快排做了很多無用工作。由于劃分函數的特點,對于一個每個元素都完全相同的一個序列來講,快速排序也會退化到 O(n^2)。

針對上面兩種情況,提出對應的優化方式a、b。

a. 優化樞軸點選取方式

1.三數取中,選三個數(也可以更多)作為樣本,取其中位數作為樞軸點,這樣劃分更均衡。

2.直接找到中位數作樞軸點。選取中位數的可以在 O(n)時間內完成。證明見《算法導論(第二版) 》 第九章中位數。

b. 三分序列

經典快排分成大于樞軸和小于樞軸兩堆,而遇到等于樞軸的元素時根據具體實現不同全部在樞軸左邊或右邊。所以才會導致元素全部一樣時退化到O(n^2)的問題。所以改進方法是在分區的時候,將序列分為 3 堆,一堆小于中軸元素,一堆等于中軸元素,一堆大于中軸元。次遞歸調用快速排序的時候,只需對小于和大于中軸元素的兩堆數據進行排序。

實現方法和荷蘭國旗問題完全一樣(關于荷蘭國旗問題可以參考經典排序相關面試題)。

掃描過程

除了上面的兩種優化方式,當序列很小時改用插排也是常用的優化方式。

c. 小數組變插入排序

為什么當數據很少時要用插排呢?其實有時候當規模較小時分治算法效率是不如普通算法的。插入排序中包含的常數因子較小,使得當 n 較小時,算法運行得更快。因此,當數組遞歸子序列的規模在一個固定閥值(閥值指數組的最大長度)以下時,采用插入排序對子序列進行排序,能夠縮短排序時間。一般閥值選取在 7到10 個元素,我記得Java里的Arrays.sort()方法好像是用的7個元素(當元素是基本類型時sort用的是優化版快排,當元素是對象時用的是歸并排序,因為對于對象需要穩定性)。

d. 多線程快排

首先要特別說明,多線程是個很重要的知識,無論在操作系統還是java語言里。面試中也是必問的東西,一定要好好掌握。

我們之前的優化都局限在單線程程序。快速排序對每個子序列的排序都是獨立的,因此快排是比較適合用多線程實現的。那為什么要用多線程實現呢?

對于多核處理器,單線程只會映射到一個CPU上,而多線程會映射到多個CPU上,這樣多線程實現的快排可以利用計算機的并行處理能力來提高排序的性能。這也是寫多線程排序的實際應用場景。

對于單核處理器,多線程技術在一些情況如涉及大量IO,也可以起到最大限度地利用CPU資源的這個目的。不過對于快排這種情況,僅僅是做一種簡單的計算,其間不涉及任何可能是使線程掛起的操作,如I/O讀寫,等待某種事件等等。多個線程與單個線程相比,增加了切換的開銷,理論上應該更慢,所以在這種情況下,并沒有必要使用什么多線程、并行的技術來優化快排,沒什么實際意義,還不如多從別的角度想想有沒有優化空間。

而我在網上看到的一些文章,他們寫的多線程排序其實并不是考慮多核并行這個特性,各線程只是并發的(注意并行和并發區別)。這些代碼可能雙核的機器生成了有上千個線程,但是他們的測試結果卻非常快,如果和單線程程序比較發現快了幾個數量級。


單線程

多線程

圖片來自該文章http://blog.sina.com.cn/s/blog_c33b15000102x3ls.html
(注意這篇博客里寫的利用計算機并行處理能力是不準確的,如果僅僅希望利用計算機的并行能力,雙核的電腦寫兩個線程處理就可以達到)

這樣做能大大提高運行時間的真正原因是,多個線程可以讓操作系統分給程序更多的時間片來執行代碼。這和操作系統的調度策略有關。對同樣一個進程來講,多一個線程就可以多分到CPU時間。舉例說明,假如在你的程序啟動前,系統中已經有50個線程在運行,那么當你的程序啟動后,假如他只有一個線程,那么平均來講,它將獲得1/51的CPU時間,而如果他有兩個線程,那么就會獲得2/52的CPU時間(這里僅僅是舉例,真實情況當然更復雜),就是說這樣做只是提高了快排程序搶奪資源的能力,相應的,其他程序運行時間就要更久一點。

多線程版的快速排序,還沒來得及親自實現。正好最近在看《Thinking in Java》,等看到并發的時候一定要自己寫一個多線程的快排。我也強烈推薦你這么做。因為我覺得如果面試時候聊到了快排,能把常用的優化方式說出來只能令面試官覺得你基礎不錯。但是如果能隨手寫出來一個多線程版的快排,聊一聊這么做的利弊,那一定能讓人眼前一亮。


另外Java7里提供了一個用于并行執行任務的框架,Fork/Join框架。如果你能繼續“不經意間”提起Java7的這個新框架,再用Fork/Join再寫一個,然后聊聊Java7\8的新特性,那面試官的表情一定是下面這樣的。


友情提示:裝逼有風險,實踐需謹慎

下面是我看的幾篇文章,詳細介紹了多線程快速排序的內容,大家可以好好看看。因為還沒親自實現,我就先不貼代碼了。不過需要注意搞清楚到底是因為什么原因他們的代碼提升了效率。就像我上面分析的一樣,其實有些代碼并不完全依靠多核并行的優勢,更大程度是靠多線程獲得更多CPU時間。這也可以解釋為什么有的文章里分別實現了普通多線程版和Fork Join版,測試后卻發現用Fork/Join的程序并沒有提高效率。

參考文章:
http://xueshu.baidu.com/s?wd=paperuri%3A%2849940c24be6eb4d18b7f2ef4791caee2%29&filter=sc_long_sign&tn=SE_xueshusource_2kduw22v&sc_vurl=http%3A%2F%2Fd.g.wanfangdata.com.cn%2FPeriodical_wxjyyy201616007.aspx&ie=utf-8&sc_us=1579342160275162004

http://www.tuicool.com/articles/nQfMRb

http://www.oschina.net/code/snippet_145230_44938

http://blog.csdn.net/wobenfengya/article/details/12318851

http://blog.csdn.net/wobenfengya/article/details/12422415

e. 按位拆分快速排序并行算法

關于這個算法我也不了解。是我在網上搜索資料的時候偶然看到一篇論文提到的。摘要如下:

針對大數據量排序算法優化問題,提出一種基于Java的按位拆分的排序新算法。該排序算法按照位拆分數據,并結合Java的多線程對拆分的數據進行并行處理。數據實驗結果表明,對于大數據量排序,該算法性能明顯優于快速排序算法,而且算法具有很好的并行效率。

我也沒有仔細看。這里把鏈接附上,有興趣的同學可以看看,弄明白了記得給我講一下是怎么回事哦。
基于Java的按位拆分快速排序并行算法

f. 在知乎上看到的一個思路(存疑)

知乎上看到的一個答案里最后提了這么一句:

最后一個來自《算法導論》的喪心病狂的思路:選定一個 k,當序列長度小于 k 時,sort 函數直接不作處理返回原序列。整個序列經過這樣一次 sort 之后當然不是有序的,此時對這個序列做一次插入排序(因為插入排序在處理 “幾乎” 有序的序列時,運行非??欤?。根據算導的結論,這樣的復雜度是 O(nk + n log(n/k)) 的。
作者:匿名用戶
鏈接:https://www.zhihu.com/question/19841543/answer/20110508

因為答主說了是來自《算法導論》里提供的思路,所以我就把它也算上了。但是我個人認為這個方法并沒什么卵用。。。

我是這樣分析它的復雜度的。按照這個思路,第一次快排的時間復雜度是O((n/k)log(n/k))≈O(nlogn-nlogk)(常系數k比較小忽略了)。針對這個幾乎有序的序列,插排的復雜度是O(nk),兩次加起來就是O(nlogn-nlogk+nk),和答主給的復雜度是一樣的,但問題是O(nlogn-nlogk+nk)>O(nlogn)啊。。。這樣做的目的是什么。。

其實看過我之前文章的同學應該知道,處理幾乎有序序列最快的原地排序方式并不是插排,而是堆排方式(參考經典排序相關面試題)。這種方式能把幾乎有序序列的排序優化到O(n*logk),但即使這樣,加起來總的復雜度還是O(nlogn-nlogk+nlogk)=O(nlogn),這不是一樣的嗎?

也可能是我困了沒想明白。。如果有不同意我想法的同學歡迎在評論區糾正。

2.遞歸

剛剛說了這么多,那跟遞歸有什么關系呢?

好吧。。。并沒有什么關系。


開個玩笑~當然有關系了。

因為快排就是一個遞歸算法,我們知道遞歸的問題是如果遞歸太深容易棧溢出。而針對快排的優化,還有一個角度是對遞歸的優化。網上很多文章都提到一個叫做尾遞歸優化的東西,比如下面這篇文章。
http://blog.csdn.net/gtzh110/article/details/48730131

為了更好的理解,我了解了一下什么叫尾遞歸。

2.1 尾遞歸

尾遞歸是尾調用(Tail Call)的一種。尾調用(Tail Call)是函數式編程的一個重要概念,就是指某個函數的最后一步是調用另一個函數(注意這里是最后一步,不是最后一行)。
具體概念看這兩篇文章吧,我就不復述了。
尾調用優化 - 阮一峰的網絡日志
遞歸與尾遞歸總結
還有這個漫談遞歸系列,寫的也非常好,介紹了關于遞歸的各種知識,有興趣的同學可以看一下。我覺得主要是函數式語言的程序員用得到尾遞歸,對于我們java程序員了解即可(我們一般直接寫迭代了)。
漫談遞歸:從斐波那契開始了解尾遞歸
漫談遞歸:尾遞歸與CPS
漫談遞歸:補充一些Continuation的知識
漫談遞歸:PHP里的尾遞歸及其優化
漫談遞歸:從匯編看尾遞歸的優化

2.2 所謂的“快排尾遞歸優化”真的是尾遞歸優化嗎?

了解完什么是尾遞歸后,我反而對“快速排序的尾遞歸優化”的方法產生了疑問。比如有博客里是這么寫的:

QUICKSORT中的第二次遞歸調用并不是必須的,可以用迭代控制結構來代替它,這種技術叫做“尾遞歸”,大多數的編譯器也使用了這項技術。
來自:http://blog.csdn.net/michealtx/article/details/7181906?t=1461199221296

關鍵代碼如下:

void QuickSort(int *a,int p,int r)  
{  
    int q=0;  
    while(p<r)//直到把右半邊數組劃分成最多只有一個元素為止,就排完了!
    {  
        q=RandomPartition(a,p,r);  
        QuickSort(a,p,q-1);  
        p=q+1;  
    }  
}  

首先上面代碼中遞歸調用并不是最后一步,甚至最后一行都不是,和我們上面看到的尾遞歸的定義不太一樣。如果我們暫且不考慮它到底是不是尾遞歸,那么用這樣的方式到底能不能降低遞歸深度呢?
Erlang學習:快速排序和尾遞歸
這篇文章里分析了一下為什么可以降低遞歸深度。但是我個人認為這樣做是完全不能起到優化作用的。我們可以自己舉個具體例子,畫個遞歸樹。我得出的結果是普通遞歸的快排和所謂的“尾遞歸優化”的快排的遞歸過程是一樣的,都是先對樞軸左邊的子數組遞歸調用,再對樞軸右邊的子數組遞歸調用。上面代碼的只是把對樞軸右邊的子數組的遞歸調用放到了下一次循環里。所以我覺得只是換了個寫法而已,實質并么有改變。而且我們也舉個極端點的例子,就是劃分極度不均衡,每次樞軸點右面的子數組大小都為0,遞歸樹此時所以節點都只有左孩子,退化成一條鏈表,此時遞歸深度為n。然后用上面代碼模擬一下發現遞歸深度還是n,沒有優化。所以對于很多博客里給出的這種思路我是不贊同的。

那么快排有沒有辦法寫成真正的尾遞歸的形式呢?要弄明白這個問題,就要思考什么樣遞歸可以改成尾遞歸,什么樣的遞歸不能改,是不是所有遞歸都可以改?

2.3 深入尾遞歸

先給出我的結論,快排可以寫成真正的尾遞歸形式,而且所以的遞歸都可以寫成尾遞歸的形式。因為尾遞歸和循環本質上是一樣的(參考上面從匯編角度講尾遞歸優化那篇文章,可以理解成編譯器會把尾遞歸優化成循環),我們可以用人工模擬系統棧的方式用循環改寫所有遞歸,那么尾遞歸也同樣可以這樣做。但是這么做究竟有沒有意義,就是另一個需要討論的話題了。

通過阮一峰的尾調用優化這篇文章里的例子,我們可以發現,其實文章里給的例子可以很輕松的寫成循環形式。因為尾遞歸實際上就是把每次變化的變量通過參數傳遞給遞歸函數了,跟我們平時寫循環不斷更新局部變量是一樣的。而事實上java程序員們也是這么做的,因為java編譯器不提供尾遞歸優化,所以遇到這樣的能寫成尾遞歸的問題大家一般都直接用循環寫了。就像下面求階乘的代碼所示。

//尾遞歸
int factorial(int n,int total) {
  if (n == 1) return total;
  return factorial(n - 1, n * total);
}

factorial(5, 1) // 120

//循環
int factorial2(n){
  int total=1;
  while(n>0){
    total=total*n;
    n--;
  }
  return total;
}

我們可以發現,其實上面尾遞歸函數和循環函數是一樣的,都是通過變量total保存函數運算結果,然后通過參數傳遞給下層遞歸函數。

如果我們可以確切的知道下層函數需要從上層函數得到什么信息,就可以像上面的方法一樣,把所有用到的內部變量改寫成函數的參數。但是我們知道很多遞歸函數是不滿足這種情況的。比如快排,比如二叉樹的深度優先遍歷。在回溯的過程我們需要知道所有上層函數的信息,而這些信息,也就是遞歸深度,是不確定的,不能通過固定的參數傳遞。

但是如果我們把一個棧結構(List)作為參數呢?這樣不就可以把所有函數調用信息傳遞下去了。就像How to implement tail-recursive quick sort in Scalastack overflow里的這個問題里的做的一樣。如果我寫的不清楚,你可以看一下這個回答,里面有實現代碼,不過是scala的,看著有點費勁。

我覺得主要是函數式語言的程序員會使用這樣的方式把普通遞歸改寫成尾遞歸,畢竟函數式語言沒有循環結構。對于java程序員來說都是直接用循環寫了。但是這兩種方式本質上是一樣的。都是用人工模擬系統棧的思路實現的。把系統棧轉化成建在堆上的人工棧。就像上面stack overflow里回答的一樣。

Any recursive function can be be converted to use the heap, rather than the stack, to track the context. The process is calledtrampolining
.

Tail recursion requires you to pass work, both completed and work-to-do, forward on each step. So you just have to encapsulate your work-to-do on the heap instead of the stack. You can use a list as a stack, so that's easy enough.

關于那個trampolining,是函數式編程里的一個概念,也是為了優化遞歸(因為遞歸對于函數式編程非常關鍵)。跟我想表達的東西沒什么聯系,如果對它好奇,可以跳轉到下面的鏈接What is a trampoline function?。因為我也沒有弄明白它。。。了解的同學可以給我講講。。

寫到這里我的體會就是:

  1. 函數式編程有必要了解一點,尤其是現在scala這么火。
  2. 多上上國外的程序員論壇還是有好處的,有些東西英文資料更多一些。
  3. 要多思考,多學習。
2.4 遞歸與非遞歸

寫到這里,關于快排能不能尾遞歸優化的問題已經解決了,我的回答就是快排可以寫成尾遞歸,但這是函數式語言程序員喜歡做的事,和java程序員用循環改寫遞歸是一樣的。那么接下來的問題就是這么做有意義嗎?或者說,有沒有必要把所有遞歸改寫成循環?快排改成非遞歸版速度能更快嗎?

寫到這里真的好困了。。。所以我簡單點說吧,下次有機會再詳細說一說遞歸與循環。

關于遞歸和非遞歸的問題,也分兩個陣營,循環黨和遞歸黨。由于以前受到過爆菊爆棧的傷害,而且一直聽到的觀點就是遞歸調用有棧溢出問題,并且效率低,只是寫著簡單點,思路好理解。所以我之前一直是堅定的循環黨,奉行循環至上,屬于遇到遞歸算法就一定會想辦法寫個非遞歸版本出來那種,平時也喜歡動不動就拿個非遞歸版的算法來炫耀。

奉行白人至上的3k黨

但如果遞歸真的一無是處,函數式語言為什么要用遞歸表示循環?Java里的Arrays.sort方法為什么不寫成非遞歸的?于是信仰不堅定的我很快就改陣營了。。。

我的想法是,??臻g比較小,遞歸深度太深時容易棧溢出,用自己維護一個棧來模擬遞歸的方式,雖然空間復雜度一樣,但可以把這部分內存轉移到堆上,防止棧溢出。在時間效率上,遞歸在函數調用上也有一個時間開銷。

但問題是,其實我們在寫遞歸時候,如果遇到棧溢出情況,往往是我們自己的算法有問題,而不是遞歸本身的問題。比如快排沒有隨機化選取樞軸時遞歸深度可能為n,但如果合理設計算法,去掉冗余,logn的遞歸深度一般是不會導致棧溢出的。即使是TB級別的數據,遞歸深度也不過是40個棧幀(當然這么算是不準確的,只是為表達我的意思)。而且棧是不是會爆掉這個很大程度上是取決于程序員對程序的把控的。比如說如果程序寫的不好,在棧幀內存放大的數據,比如遞歸函數里申請 1KB 的棧上空間(例如寫一個 char s[1024]; ),這個遞歸函數的最大調用深度達到 100 以上,就會導致 stack overflow(假設棧空間100KB)。如果輸入數據稍微大一點就爆棧,首先應該反思自己的算法設計和代碼,而不是歸罪于遞歸。有大量冗余過程的算法,即使通過在堆上維護人工棧避免了棧溢出,也會因為太久的運行時間令人難以忍受。

而關于時間效率問題,雖然函數調用有時間開銷。但是函數調用的開銷主要是參數信息的壓棧出棧,跳轉(jmp or call),其中壓棧出棧在非遞歸算法里也要執行,而且由于自己維護的棧在堆上,涉及到開辟空間和垃圾回收,List容量不夠擴容時也需要額外的時間開銷,速度反而比系統棧更慢。另外現在編譯器對遞歸也會進行優化,所以說遞歸時間效率上更慢我覺得也是不妥的。即使有差距,應該也小到可以說是效率幾乎一樣了。

所以我覺得,既然遞歸有這么大的表現力,而且效率也并不更低,為什么要犧牲掉簡潔優雅的代碼,去費勁改成循環呢?當然我這里指的是那種需要人工模擬系統棧去模擬遞歸過程的遞歸。而像上面提到的求階乘的那種遞歸,我認為都是屬于不必要的遞歸,因為可以很輕松的用循環實現?;蛘咭部梢苑Q為是可以優化的遞歸。總之,優化算法,最重要的應該是結合具體情況對算法本身優化,而不是粗暴的把遞歸改成非遞歸。

3.

最后放張圖作為結尾吧。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 從三月份找實習到現在,面了一些公司,掛了不少,但最終還是拿到小米、百度、阿里、京東、新浪、CVTE、樂視家的研發崗...
    時芥藍閱讀 42,366評論 11 349
  • Java8張圖 11、字符串不變性 12、equals()方法、hashCode()方法的區別 13、...
    Miley_MOJIE閱讀 3,731評論 0 11
  • 昨天剛了解[零和博弈]這個概念,今天就談到了博弈理論,不得不說無巧不成書啊。 零和博弈在于不是你死就是我亡,在博弈...
    環盈閱讀 793評論 0 0
  • 我的第一個淡奶油裱花蛋糕是做的芭比娃娃。想想那時候的那種緊張,無從下手,都有點好笑。但也因為邁出那關鍵的一步,才會...
    幽弦閱讀 1,562評論 0 3
  • 1月2號,忽聞父親已經在回武漢的火車上。這都臘月了,不是計劃好這個春節不回武漢而和弟弟一家在臨沂過年的嗎?我慌忙給...
    死侃腦殼的老妖婆閱讀 767評論 17 16