我曾經聽一位大師級的程序員這樣稱讚到,「我經過刪除代碼來實現功能的提高。」而法國著名做家兼飛行家Antoine de Saint-Exupéry的說法則更具表明性,「只有在不只沒有任何功能能夠添加,並且也沒有任何功能能夠刪除的狀況下,設計師纔可以認爲本身的工做已臻完美。」 某些時候,在軟件中根本就不存在最漂亮的代碼,最漂亮的函數,或者最漂亮的程序。
固然,咱們很難對不存在的事物進行討論。本文將對經典Quicksort算法的運行時間進行全面的分析,並試圖經過這個分析來講明上述觀點。
3.1 我編寫過的最漂亮代碼
當Greg Wilson最初告訴我本書的編寫計劃時,我曾自問編寫過的最漂亮的代碼是什麼。這個有趣的問題在我腦海裏盤旋了大半天,而後我發現答案其實很簡單:Quicksort算法。但遺憾的是,根據不一樣的表達方式,這個問題有着三種不一樣的答案。
當我撰寫關於分治算法的論文時,我發現C.A.R. Hoare的Quicksort算法無疑是各類Quicksort算法的鼻祖。這是一種解決基本問題的漂亮算法,能夠用優雅的代碼實現。
我很喜歡這個算法,但我老是沒法弄明白算法中最內層的循環。我曾經花兩天的時間來調試一個使用了這個循環的複雜程序,而且幾年以來,當我須要完成相似的任務時,我會很當心地複製這段代碼。雖然這段代碼可以解決我所遇到的問題,但我卻並無真正地理解它。
我後來從Nico Lomuto那裏學到了一種優雅的劃分(partitioning)模式,而且最終編寫出了我可以理解,甚至可以證實的Quicksort算法。
William Strunk Jr.針對英語所提出的「良好的寫做風格即爲簡練」這條經驗一樣適用於代碼的編寫,所以我遵循了他的建議,「省略沒必要要的字詞」。我最終將大約40行左右的代碼縮減爲十幾行的代碼。
所以,若是要回答「你曾編寫過的最漂亮代碼是什麼?」這個問題,那麼個人答案就是:在我編寫的《Programming Pearls, Second Edition》(Addison-Wesley)一書中給出的Quichsort算法。
在示例3-1中給出了用C語言編寫的Quicksort函數。咱們在接下來的章節中將進一步地研究和改善這個函數。
void quicksort(int l, int u)程序員
{ int i, m;web
if (l >= u) return;算法
swap(l, randint(l, u));編程
m = l;小程序
for (i = l+1; i <= u; i++)數組
if (x[i] < x[l])微信
swap(++m, i);數據結構
swap(l, m);app
quicksort(l, m-1);框架
quicksort(m+1, u);
}
若是函數的調用形式是quicksort(0, n-1),那麼這段代碼將對一個全局數組x[n]進行排序。
函數的兩個參數分別是將要進行排序的子數組的下標:l是較低的下標,而u是較高的下標。函數調用swap(i,j)將會交換x[i]與x[j]這兩個元素。第一次交換操做將會按照均勻分佈的方式在l和u之間隨機地選擇一個劃分元素。
在《Programming Pearls》一書中包含了對Quicksort算法的詳細推導以及正確性證實。在本章的剩餘內容中,我將假設讀者熟悉在《Programming Pearls》中所給出的Quicksort算法以及在大多數初級算法教科書中所給出的Quicksort算法。
若是你把問題改成「在你編寫那些廣爲應用的代碼中,哪一段代碼是最漂亮的?」個人答案仍是Quicksort算法。在我和M. D. McIlroy一塊兒編寫的一篇文章中指出了在原來Unix qsort函數中的一個嚴重的性能問題。
隨後,咱們開始用C語言編寫一個新排序函數庫,而且考慮了許多不一樣的算法,包括合併排序(Merge Sort)和堆排序(Heap Sort)等算法。在比較了Quicksort的幾種實現方案後,咱們着手建立本身的Quicksort算法。
在這篇文章中描述了咱們如何設計出一個比這個算法的其餘實現要更爲清晰,速度更快以及更爲健壯的新函數——部分緣由是因爲這個函數的代碼更爲短小。
Gordon Bell的名言被證實是正確的:「在計算機系統中,那些最廉價,速度最快以及最爲可靠的組件是不存在的。」如今,這個函數已經被使用了10多年的時間,而且沒有出現任何故障。
考慮到經過縮減代碼量所獲得的好處,我最後以第三種方式來問本身在本章之初提出的問題。「你沒有編寫過的最漂亮代碼是什麼?」。我如何使用很是少的代碼來實現大量的功能?答案仍是和Quicksort有關,特別是對這個算法的性能分析。我將在下一節給出詳細介紹。
3.2 事倍功半
Quicksort是一種優雅的算法,這一點有助於對這個算法進行細緻的分析。大約在1980年左右,我與Tony Hoare曾經討論過Quicksort算法的歷史。他告訴我,當他最初開發出Quicksort時,他認爲這種算法太簡單了,不值得發表,並且直到可以分析出這種算法的預期運行時間以後,他才寫出了經典的「Quicksoft」論文。
咱們很容易看出,在最壞的狀況下,Quicksort可能須要n2的時間來對數組元素進行排序。而在最優的狀況下,它將選擇中值做爲劃分元素,所以只需nlgn次的比較就能夠完成對數組的排序。那麼,對於n個不一樣值的隨機數組來講,這個算法平均將進行多少次比較?
Hoare對於這個問題的分析很是漂亮,但不幸的是,其中所使用的數學知識超出了大多數程序員的理解範圍。當我爲本科生講授Quicksort算法時,許多學生即便在費了很大的努力以後,仍是沒法理解其中的證實過程,這令我很是沮喪。下面,咱們將從Hoare的程序開始討論,而且最後將給出一個與他的證實很接近的分析。
咱們的任務是對示例3-1中的Quicksort代碼進行修改,以分析在對元素值均不相同的數組進行排序時平均須要進行多少次比較。咱們還將努力經過最短的代碼、最短運行時間以及最小存儲空間來獲得最深的理解。
爲了肯定平均比較的次數,咱們首先對程序進行修改以統計次數。所以,在內部循環進行比較以前,咱們將增長變量comps的值(參見示例3-2)。
【示例3-2】 修改Quicksort的內部循環以統計比較次數。
for (i = l+1; i <= u; i++) {
若是用一個值n來運行程序,咱們將會看到在程序的運行過程當中總共進行了多少次比較。若是重複用n來運行程序,而且用統計的方法來分析結果,咱們將獲得Quicksort在對n個元素進行排序時平均使用了1.4 nlgn次的比較。
在理解程序的行爲上,這是一種不錯的方法。經過十三行的代碼和一些實驗能夠反應出許多問題。這裏,咱們引用做家Blaise Pascal和T. S. Eliot的話,「若是我有更多的時間,那麼我給你寫的信就會更短。」如今,咱們有充足的時間,所以就讓咱們來對代碼進行修改,而且努力編寫出更短(同時更好)的程序。
咱們要作的事情就是提升這個算法的速度,而且儘可能增長統計的精確度以及對程序的理解。因爲內部循環老是會執行u-l次比較,所以咱們能夠經過在循環外部增長一個簡單的操做來統計比較次數,這就可使程序運行得更快一些。在示例3-3的Quicksort算法中給出了這個修改。
【示例3-3】 Quicksort的內部循環,將遞增操做移到循環的外部
for (i = l+1; i <= u; i++)
這個程序會對一個數組進行排序,同時統計比較的次數。不過,若是咱們的目標只是統計比較的次數,那麼就不須要對數組進行實際地排序。在示例3-4中去掉了對元素進行排序的「實際操做」,而只是保留了程序中各類函數調用的「框架」。
【示例3-4】將Quicksort算法的框架縮減爲只進行統計
void quickcount(int l, int u)
這個程序可以實現咱們的需求,由於Quichsort在選擇劃分元素時採用的是「隨機」方式,而且咱們假設全部的元素都是不相等的。如今,這個新程序的運行時間與n成正比,而且相對於示例3-3須要的存儲空間與n成正比來講,如今所需的存儲空間縮減爲遞歸堆棧的大小,即存儲空間的平均大小與lgn成正比。
雖然在實際的程序中,數組的下標(l和u)是很是重要的,但在這個框架版本中並不重要。所以,咱們能夠用一個表示子數組大小的整數(n)來替代這兩個下標(參見示例3-5)
【示例3-5】 在Quicksort代碼框架中使用一個表示子數組大小的參數
如今,咱們能夠很天然地把這個過程整理爲一個統計比較次數的函數,這個函數將返回在隨機Quicksort算法中的比較次數。在示例3-6中給出了這個函數。
【示例3-6】 將Quicksort框架實現爲一個函數
return n-1 + cc(m-1) + cc(n-m);
在示例3-四、示例3-5和示例3-6中解決的都是相同的基本問題,而且所需的都是相同的運行時間和存儲空間。在後面的每一個示例都對這些函數的形式進行了改進,從而比這些函數更爲清晰和簡潔。
在定義發明家的矛盾時,George Póllya指出「計劃越宏大,成功的可能性就越大。」如今,咱們就來研究在分析Quicksort時的矛盾。
到目前爲止,咱們遇到的問題是,「當Quicksort對大小爲n的數組進行一次排序時,須要進行多少次比較?」咱們如今將對這個問題進行擴展,「對於大小爲n的隨機數組來講,Quichsort算法平均須要進行多少次的比較?」咱們經過對示例3-6進行擴展以引出示例3-7。
【示例3-7】 僞碼:Quicksort的平均比較次數
sum += n-1 + c(m-1) + c(n-m)
若是在輸入的數組中最多隻有一個元素,那麼Quichsort將不會進行比較,如示例3-6中所示。對於更大的n,這段代碼將考慮每一個劃分值m(從第一個元素到最後一個,每一個都是等可能的)而且肯定在這個元素的位置上進行劃分的運行開銷。
而後,這段代碼將統計這些開銷的總和(這樣就遞歸地解決了一個大小爲m-1的問題和一個大小爲n-m的問題),而後將總和除以n獲得平均值並返回這個結果。
若是咱們可以計算這個數值,那麼將使咱們實驗的功能更增強大。咱們如今無需對一個n值運行屢次來估計平均值,而只需一個簡單的實驗即可以獲得真實的平均值。不幸的是,實現這個功能是要付出代價的:這個程序的運行時間正比於3n(若是是自行參考(self-referential)的,那麼用本章中給出的技術來分析運行時間將是一個頗有趣的練習)。
示例3-7中的代碼須要必定的時間開銷,由於它重複計算了中間結果。當在程序中出現這種狀況時,咱們一般會使用動態編程來存儲中間結果,從而避免重複計算。所以,咱們將定義一個表t[N+1],其中在t[n]中存儲c[n],而且按照升序來計算它的值。咱們將用N來表示n的最大值,也就是進行排序的數組的大小。在示例3-8中給出了修改後的代碼。
【示例3-8】 在Quicksort中使用動態編程來計算
sum += n-1 + t[i-1] + t[n-i]
這個程序只對示例3-7進行了細微的修改,即用t[n]來替換c(n)。它的運行時間將正比於N2,而且所需的存儲空間正比於N。這個程序的優勢之一就是:在程序執行結束時,數組t中將包含數組中從元素0到元素N的真實平均值(而不是樣本均值的估計)。咱們能夠對這些值進行分析,從而生成在Quichsort算法中統計比較次數的計算公式。
咱們如今來對程序作進一步的簡化。第一步就是把n-1移到循環的外面,如示例3-9所示。
【示例3-9】 在Quicksort中把代碼移到循環外面來計算
如今將利用對稱性來對循環作進一步的調整。例如,當n爲4時,內部循環計算總和爲:
t[0]+t[3] + t[1]+t[2] + t[2]+t[1] + t[3]+t[0]
在上面這些組對中,第一個元素增長而第二個元素減小。所以,咱們能夠把總和改寫爲:
2 * (t[0] + t[1] + t[2] + t[3])
咱們能夠利用這種對稱性來獲得示例3-10中的Quicksort。
【示例3-10】 在Quichsort中利用了對稱性來計算
然而,在這段代碼的運行時間中一樣存在着浪費,由於它重複地計算了相同的總和。此時,咱們不是把前面全部的元素加在一塊兒,而是在循環外部初始化總和而且加上下一個元素,如示例3-11所示。
【示例3-11】 在Quicksort中刪除了內部循環來計算
這個小程序確實頗有用。程序的運行時間與N成正比,對於每一個從1到N的整數,程序將生成一張Quicksort的估計運行時間表。
咱們能夠很容易地把示例3-11用表格來實現,其中的值能夠當即用於進一步的分析。在3-1給出了最初的結果行。
這張表中的第一行數字是用代碼中的三個常量來進行初始化的。下一行(輸出的第三行)的數值是經過如下公式來計算的:
A3 = A2+1 B3 = B2 + 2*C
2 C
3 = A3-1 + B3/A3
把這些(相應的)公式記錄下來就使得這張表格變得完整了。這張表格是「我曾經編寫的最漂亮代碼」的很好的證據,即便用少許的代碼完成大量的工做。
可是,若是咱們不須要全部的值,那麼狀況將會是什麼樣?若是咱們更但願經過這種來方式分析一部分數值(例如,在20到232之間全部2的指數值)呢?雖然在示例3-11中構建了完整的表格t,但它只須要使用表格中的最新值。所以,咱們能夠用變量t的定長空間來替代table t[]的線性空間,如示例3-12所示。
【示例3-12】 Quicksoft 計算——最終版本
而後,咱們能夠插入一行代碼來測試n的適應性,而且在必要時輸出這些結果。
這個程序是咱們漫長學習旅途的終點。經過本章所採用的方式,咱們能夠證實Alan Perlis的經驗是正確的:「簡單性並非在複雜性以前,而是在複雜性以後」 ("Epigrams on Programming," Sigplan Notices, Vol. 17, Issue 9)。
3.3 觀點
在表3-2中總結了本章中對Quicksort進行分析的程序。
表 3-2 對Quicksort比較次數的統計算法的評價
在咱們對代碼的每次修改中,每一個步驟都是很直接的;不過,從示例3-6中樣本值到示例3-7中準確值的過渡過程多是最微妙的。隨着這種方式進行下去,代碼變得更快和更有用,而代碼量一樣獲得了縮減。
在19世紀中期,Robert Browning指出「少便是多(less is more)」,而這張表格正是一個證實這種極少主義哲學(minimalist philosophy)的實例。
咱們已經看到了三種大相徑庭的類型的程序。示例3-2和示例3-3是可以實際使用的Quicksort,能夠用來在對真實數組進行排序時統計比較次數。
示例3-4到示例3-6都實現了Quicksort的一種簡單模型:它們模擬算法的運行,而實際上卻沒有作任何排序工做。從示例3-7到示例3-12則實現了一種更爲複雜的模型:它們計算了比較次數的真實平均值而沒有跟蹤任何單次的運行。
* 示例3-2,示例3-4,3-7:對問題的定義進行根本的修改。
* 示例3-5,示例3-6,3-12:對函數的定義進行輕微的修改
這些技術都是很是典型的。咱們在簡化程序時常常要發出這樣的疑問,「咱們真正要解決的問題是什麼?」或者是,「有沒有更好的函數來解決這個問題?」
當我把這個分析過程講授給本科生時,這個程序最終被縮減成零行代碼,化爲一陣數學的輕煙消失了。咱們能夠把示例3-7從新解釋爲如下的循環關係:
這正是Hoare所採用的方法,而且後來由D.E.Knuth在他經典的《The Art of Computer Programming》(Addison-Wesley)一書的第三卷:排序與查找中給出的方法中給出了描述。經過從新表達編程思想的技巧和在示例3-10中使用的對稱性,使咱們能夠把遞歸部分簡化爲:
Knuth刪除了求和符號,從而引出了示例3-11,這能夠被從新表達爲一個在兩個未知量之間有着兩種循環關係的系統:
Knuth使用了「求和因子」的數學方法來實現這種解決方案:
其中 表示第n個調和數(harmonic number),即1 + 1/2 + 1/3 + … 1/n。這樣,咱們就從對程序不斷進行修改以獲得實驗數據順利地過渡到了對程序行爲進行徹底的數學分析。
在獲得這個公式以後,咱們就能夠結束咱們的討論。咱們已經遵循了Einstein的著名建議:「儘可能使每件事情變得簡單,而且直到不可能再簡單爲止。」
附加分析
Goethe的著名格言是:「建築是靜止的音樂」。按照這種說法,我能夠說「數據結構是靜止的算法。」若是咱們固定了Quichsort算法,那麼就將獲得了一個二分搜索樹的數據結構。在Knuth發表的文章中給出了這個結構而且採用相似於在Quichsort中的循環關係來分析它的運行時間。
若是要分析把一個元素插入到二分搜索樹中的平均開銷,那麼咱們能夠以這段代碼做爲起點,而且對這段代碼進行擴展來統計比較次數,而後在咱們收集的數據上進行實驗。
接下來,咱們能夠仿照前面章節中的方式來簡化代碼。一個更爲簡單的解決方案就是定義一個新的Quichsort,在這個算法中使用理想的劃分算法把有着相同關聯順序的元素劃分到兩邊。Quichsort和二分搜索樹是同構的,如圖3-1所示。
圖3-1 實現理想劃分的Quicksort以及相應的二分搜索樹
左邊的方框給出了正在進行中的理想劃分的Quicksort,右邊的圖則給出了相應的從相同輸入中構建起來的二分搜索樹。這兩個過程不只須要進行相同次數的比較,並且還將生成相同的比較集合。
經過在前面對於在一組不一樣元素上進行Quicksort實驗的平均性能分析,咱們就能夠獲得將不一樣的元素隨機插入到二分搜索樹中的平均比較次數。
3.4 本章的中心思想是什麼?
表面上看來,我「所寫的」內容就是從示例3-2到示例3-12的程序。我最初是漫不經心地編寫這些程序,而後將這些程序寫在給本科生講課的黑板上,而且最終寫到本章中。
我有條不紊地進行着這些程序的修改,而且花了大量的時間來分析這些程序,從而確信它們都是正確的。然而,除了在示例3-11中實現的表格外,我歷來沒有把任何一個示例做爲計算機程序運行過。
我在貝爾實驗室呆了將近二十年,我從許多教師那裏學到了:要「編寫」一個在大衆面前展現的程序,所涉及到的東西比鍵入這個程序要多得多。有人用代碼實現了這個程序,最初運行在一些測試示例中,而後構建了完整的系統框架、驅動程序以及一個案例庫來支撐這段代碼。
理想的狀況是,人們能夠手動地把編譯後的代碼包含到文本中,不加入任何的人爲干涉。基於這種想法,我編寫了示例3-1(以及在《Programming Pearls》中的全部代碼)。
爲了維護面子,我但願永遠都不要實現從示例3-2到示例3-12的代碼,從而使我保持誠實的名聲。然而,在計算機編程中的近四十年的實踐使我對這個任務的困難性有着深深的敬畏。我妥協了,把示例3-11用表格方式實現出來,而且無心中獲得了一個完備的解答。
當這兩個東西完美地匹配在一塊兒時,你能夠想象一下我當時的喜悅吧!所以,我向世界提供了這些漂亮的而且不曾實現的程序,雖然在這些程序中可能會有一些還未發現的錯誤,但我對這些程序的正確性仍是有必定信心的。我但願一些細微的錯誤不會掩蓋我在這些程序中所展現的那些漂亮思想。
當我爲給出這些沒有被實現過的程序感到不安時,Alan Perlis的話安慰了我,他說「軟件是否是不像任何一個事物,它就是意味着被拋棄:軟件的全部意義就是把它看做爲一個肥皂泡?」
3.5 結論
漂亮的含義有着許多來源。本章經過簡化、優雅以及精簡來刻畫了漂亮的含義。下面這些名言表達的是一樣的意思:
* 只有在不只沒有任何功能能夠添加,並且也沒有任何功能能夠刪除的狀況下,設計師纔可以認爲本身的工做已臻完美。
* 有時候,在軟件中根本就不存在最漂亮的代碼,最漂亮的函數,或者最漂亮的程序。
* 良好的寫做風格即爲簡練。省略沒必要要的字詞。(Strunk and White)
* 在計算機系統中,那些最廉價、速度最快以及最爲可靠的組件是不存在的(Bell)
* 若是我有更多的時間,那麼我給你寫的信就會越短(Pascal)
* 發明家的矛盾:計劃越宏大,成功的可能性就越大。(Pólya)
* 簡單性並非在複雜性以前,而是在複雜性以後(Perlis)
* 儘可能使每件事情變得簡單,而且直到不可能再簡單爲止(Einstein)
* 軟件有時候應該被視做爲一個肥皂泡(Perlis)
本章的內容到此結束。讀者能夠複習所學到的內容並進行模擬實驗。
對於那些想要獲得更具體信息的人們,我在下面給出了一些觀點,這些觀點分爲三類
程序分析
深刻理解程序行爲的方式之一就是修改這個程序,而後在具備表明性的數據上運行這個程序,就像示例3-2那樣。不過,咱們一般會更關心程序的某個方面而不是程序的總體。例如,咱們只是考慮Quichsort所使用的平均比較次數,而忽略了其餘的方面。Sedgewick 研究了Quichsort的其餘特性,例如算法所需的存儲空間以及各類Quicksort運行時間的其餘方面。咱們能夠關注這些關鍵問題,而暫時忽略了程序其餘不過重要的方面。
在個人一篇文章"A Case Study in Applied Algorithm Design"中指出了我曾經遇到過的一個問題:對在單元空間中找出貨郎行走路線的strip啓發式算法的性能進行評價。我估計完成這個任務所要的程序大概在100行代碼左右。
在經歷了一系列相似於本章前面看到的分析步驟以後,我只使用了十幾行代碼的模擬算法就實現了更爲精確的效果(在我寫完了這個模擬算法後,我發現Beardwood 等人["The Shortest Path Through Many Points," Proc. Cambridge Philosophical Soc., Vol. 55]已經更完整地表述了個人模擬算法,所以已經在二十幾年前就從數學上解決了這個問題)。
小段代碼
我相信計算機編程是一項實踐性的技術,而且我也贊成這個觀點:「任何技術都必須經過模仿和實踐來掌握。」 所以,想要編寫漂亮代碼的程序員應該閱讀一些漂亮的程序以及在編寫程序時模仿所學到的技術。
我發如今實踐時有個很是有用的東西就是小段代碼,也就是一二十行的代碼。編寫《Programming Pearls》這本書是一件艱苦的工做,但同時也有着極大的樂趣。我實現了每一小段代碼,而且親自把每段代碼都分解爲基本的知識。我但願其餘人在閱讀這些代碼時與我在編寫這些代碼時有着一樣的享受過程。
軟件系統
爲了有針對性,我極其詳盡地描述了一個小型任務。我相信其中的這些準則不只存在於小型程序中,它們一樣也適用於大型的程序以及計算機系統。Parnas給出了把一個系統拆分爲基本構件的技術。爲了得用快速的應用性,不要忘了Tom Duff的名言: