把《程式設計珠璣》讀薄

發表於2016-12-17

開篇

具體化你的解決的問題。下面是A和B的對話。

A:我該如何對磁碟檔案進行排序?
B:需要排序的內容是什麼?檔案中有多少條記錄?每個記錄的格式是什麼?
A:該檔案包含至多10,000,000個記錄,每條記錄都是一個7位整數。
B:如果檔案那麼小,為什麼要使用磁碟排序呢?為什麼不在主存中對它排序?
A:該功能是某大型系統中的一部分,大概只能提供1MB主存給它。
B:你能將記錄方面的內容說得更詳細一些嗎?
A:每個記錄是一個7位正整數,沒有其它的關聯資料,每個整數至多隻能出現一次。
… …

經過一系統的問題,我們可以將一個定義模糊不清的問題變得具體而清晰:

輸入:
所輸入的是一個檔案,至多包含n個正整數,每個正整數都要小於n,這裡n=10^7。
如果輸入時某一個整數出現了兩次,就會產生一個致命的錯誤。
這些整數與其它任何資料都不關聯。
輸出:
以增序形式輸出經過排序的整數列表。
約束:
大概有1MB的可用主存,但可用磁碟空間充足。執行時間至多允許幾分鐘,
10秒鐘是最適宜的執行時間。

如果主存容量不是嚴苛地限制在1MB,比如說可以是1MB多,或是1~2MB之間, 那麼我們就可以一次性將所有資料都載入到主存中,用Bitmap來做。 10,000,000個數就需要10,000,000位,也就是10,000,000b = 1.25MB。

程式可分為三個部分:第一,初始化所有的位為0;第二,讀取檔案中每個整數, 如果該整數對應的位已經為1,說明前面已經出現過這個整數,丟擲異常,退出程式 (輸入要求每個整數都只能出現一次)。否則,將相應的位置1;第三, 檢查每個位,如果某個位是1,就寫出相應的整數,從而建立已排序的輸出檔案。

如果主存容量嚴苛地限制在1MB,而使用Bitmap需要1.25MB, 因此無法一次載入完成排序。那麼,我們可以將該檔案分割成兩個檔案, 再分別用Bitmap處理。分割策略可以簡單地把前一半的資料放到一個檔案, 後一半的資料放到另一個檔案,分別排序後再做歸併。 也可以把檔案中小於某個數(比如5,000,000)的整數放到一個檔案,叫less.txt, 把其餘的整數放到另一個檔案,叫greater.txt。分別排序後, 把greater.txt的排序結果追加到less.txt的排序結果即可。

啊哈!演算法

第2章圍繞3個問題展開。

  • 給定一個包含32位整數的順序檔案,它至多隻能包含40億個這樣的整數, 並且整數的次序是隨機的。請查詢一個此檔案中不存在的32位整數。 在有足夠主存的情況下,你會如何解決這個問題? 如果你可以使用若干外部臨時檔案,但可用主存卻只有上百位元組, 你會如何解決這個問題?

這是CTCI中的一道題目,詳細解答請戳以下連結:

請猛戳我

  • 請將一個具有n個元素的一維向量向左旋轉i個位置。例如,假設n=8,i=3, 那麼向量abcdefgh旋轉之後得到向量defghabc。

這個問題很常見了,做3次翻轉即可,無需額外空間:

  • 給定一本英語單詞詞典,請找出所有的變位詞集。例如,因為“pots”, “stop”,“tops”相互之間都是由另一個詞的各個字母改變序列而構成的, 因此這些詞相互之間就是變位詞。

這個問題可以分3步來解決。第一步將每個單詞按字典序排序, 做為原單詞的簽名,這樣一來,變位詞就會具有相同的簽名。 第二步對所有的單詞按照其簽名進行排序,這樣一來,變位詞就會聚集到一起。 第三步將變位詞分組,形成變位詞集。示意圖如下:

anagrams

資料決定程式結構

恰當的資料檢視實際上決定了程式的結構。 我們常常可以通過重新組織內部資料來使程式變得小而美。

發明家悖論:更一般性的問題也許更容易解決。(有時候吧)

程式設計師在節省空間方面無計可施時,將自己從程式碼中解脫出來, 退回起點並集中心力研究資料,常常能有奇效。資料的表示形式是程式設計的根本。

下面是退回起點進行思考時的幾條原則:

  • 使用陣列重新編寫重複程式碼。冗長的相似程式碼常常可以使用最簡單的資料結構—— 陣列來更好地表述。
  • 封裝複雜結構。當需要非常複雜的資料結構時,使用抽象術語進行定義, 並將操作表示為類。
  • 儘可能使用高階工具。超文字,名字-值對,電子表格,資料庫, 程式語言等都是特定問題領域中的強大的工具。
  • 從資料得出程式的結構。在動手編寫程式碼之前,優秀的程式設計師會徹底理解輸入, 輸出和中間資料結構,並圍繞這些結構建立程式。

提到的書籍:Polya的《How to Solve it》,中文書《怎樣解題》; Kernighan和Plauger的《Elements of Programming Style》;Fred Brooks的《人月神話》 Steve McConnell的《程式碼大全》;《Rapid Development》; 《Software Project Survival Guide》

編寫正確的程式

本章以二分搜尋為例子,講述瞭如何對程式進行驗證及正確性分析。

深入閱讀:David Gries的《Science of Programming》 是程式驗證領域裡極佳的一本入門書籍。

程式設計中的次要問題

到目前為止,你已經做了一切該做的事:通過深入挖掘定義了正確的問題, 通過仔細選擇演算法和資料結構平衡了真正的需求,通過程式驗證技術寫出了優雅的程式碼, 並且對其正確性相當有把握。萬事俱備,只欠程式設計。

  • 使用斷言assert
  • 自動化測試程式

進階閱讀:《Practice of Programming》第5章(除錯),第6章(測試) 《Code Complete》第25章(單元測試),第26章(除錯)

程式效能分析

下圖展示了一個程式的效能提升過程, 該程式的作用是對三維空間中n個物體的運動進行模擬。從圖中可以看出, 一個程式可以從多方面進行效能提升,而其中演算法和資料結構的選擇又顯得尤為重要。

improve

從設計層面提升程式效能:

  1. 問題定義。良好的問題定義可以有效減少程式執行時間和程式長度。
  2. 系統結構。將大型系統分解成模組,也許是決定其效能的最重要的單個因素。
  3. 演算法和資料結構。這個不用說了。
  4. 程式碼調優。針對程式碼本身的改進。
  5. 系統軟體。有時候改變系統所基於的軟體比改變系統本身更容易。
  6. 硬體。更快的硬體可以提高系統的效能。

深入閱讀:Butler Lampson的“Hints for Computer System Design”, 該論文特別適合於整合硬體和軟體的計算機系統設計。

粗略估算

這一章講述了估算技術,我認為是相當有用的一章。

文中先丟擲一個問題:密西西比河一天流出多少水?如果讓你來回答, 你會怎麼答,注意不能去Google哦。

作者是這麼回答這個問題:假設河的出口大約有1英里寬和20英尺深(1/250英里), 而河水的流速是每小時5英里,也就是每天120英里。則可以計算出一天的流量:

1英里 * 1/250英里 * 120英里/天 約等於 1/2 英里^3/天

上述算式非常簡單,可是在看到這些文字之前,如果有人真的問你, 密西西比河一天流出多少水?你真的能答上來嗎?還是愣了一下後,擺擺手,說: 這我哪知道!

對於上面的問題,我們至少可以注意到以下兩點:

  1. 你需要把問題轉換成一個可計算的具體模型。這一點往往不需要太擔心, 因為我們做的是估算,所以可以忽視很多無關緊要的因素,可以去簡化你的模型, 記住我們要的只是一個粗略計算的結果。比如對於上面的問題, 計算密西西比河一天流出多少水其實就是計算其一天的流量,利用中學所學知識, 流量 = 截面積 x 流速,那我們就只需計算密西西比河的出水口的截面積和流速即可。 我們可以將出水口簡化成一個矩形,因此就只需要知道出水口的寬和深即可。
  2. 你需要知道常識性的東西。上面我們已經把問題轉換成了一個可計算的具體模型: 流量 = 出水口寬 x 出水口深 x 流速。接下來呢?你需要代入具體的數值去求得答案。 而這就需要你具備一些常識性的知識了。比如作者就估計了密西西比河的出口有1英里寬, 20英尺深(如果你估計只有幾十米寬,那就相差得太離譜了)。 這些常識性的知識比第1點更值得關注,因為你無法給出一個靠譜的估算值往往是因為這點。

當我們懂得如何把一個問題具體化定義出來併為其選用適當的模型, 並且我們也積累了必要的常識性的知識後,回答那些初看起來無從下手的問題也就不難了。 這就是估算的力量。

以下是估算時的一些有用提示:

  • 兩個答案比一個答案好。即鼓勵你從多個角度去對一個問題進行估算, 如果從不同角度得到的答案差別都不大,說明這個估算值是比較靠譜的。
  • 快速檢驗。即量綱檢驗。即等式兩邊最終的量綱要一致。 這一點在等式簡單的時候相當顯而易見。比如位移的單位是米,時間單位是秒, 速度單位是米/秒,那顯然我們應該要用位移去除以時間來得到速度, 這樣才能保證它們單位的一致。你可能會說,我了個去,這種小學生都懂的事, 你好意思拿出來講。其實不然,當你面對的是一個具有多個變數的複雜物理公式, 或者你提出某種物理假設,正在考慮將其公式化,該方法可以切切實實地幫你做出檢驗。
  • 經驗法則。“72法則”:1.假設以年利率r%投資一筆錢y年,如果r*y = 72, 那麼你的投資差不多會翻倍。2.如果一個盤子裡的菌群以每小時3%的速率增長, 那麼其數量每天(24小時)都會翻倍。在誤差不超過千分之五的情況下, pi秒就是一個納世紀。也就是說:3.14秒 = 10^(-9) * 100年 = 10^(-7) 年

也就是說,1年大概是3.14×10^7 秒。所以如果有人告訴你,一個程式執行10^7 秒, 你應該能很快反應出,他說的其實是4個月。

  • 實踐。與許多其他活動一樣,估算技巧只能通過實踐來提高。

如果問題的規模太大,我們還可以通過求解它的小規模同質問題來做估算。比如, 我們想測試某個程式執行10億次需要多長時間,如果你真去跑10億次, 說不定執行幾個小時都沒結束,那不是很悲劇?我們可以執行這個程式1萬次或是10萬次, 得出結果然後倍增它即可。當然,這個結果未必是準確的, 因為你沒法保證執行時間是隨著執行次數線性增加的。謹慎起見,我們可以執行不同的次數, 來觀察它的變化趨勢。比如執行10次,100次,1000次,10000次等, 觀察它的執行時間是否是線性增加的,或是一條二次曲線。

有時候,我們需要為估算的結果乘上一個安全係數。比如, 我們預估完成某項功能需要時間t,那根據以往經驗,也許我們需要為這個值乘上2或4, 這樣也許才是一個靠譜的預估值。

Little定律:系統中物體的平均數量等於物體離開系統的平均速率和每個物體在系統中停留 的平均時間的乘積。(如果物體離開和進入系統的總體出入流是平衡的, 那麼離開速率也就是進入速率)

舉個例子,比如你正在排除等待進入一個火爆的夜總會, 你可以通過估計人們進入的速率來了解自己還要等待多長時間。根據Little定律, 你可以推論:這個地方可以容納約60人,每個人在裡面逗留時間大約是3小時, 因此我們進入夜總會的速率大概是每小時20人。現在隊伍中我們前面還有20人, 也就意味著我們還要等待大約一個小時。

深入閱讀:Darrell Huff的《How To Lie With Statistics》;關鍵詞: 費米近似(Fermi estimate, Fermi problem)

演算法設計技術

這一章就一個小問題研究了4種不同的演算法,重點強調這些演算法的設計技術。 研究的這個小問題是一個非常常見的面試題:子陣列之和的最大值。 如果之前沒有聽過,建議Google之。

深入閱讀:Aho,Hopcroft和Ullman的《Data Structures and Algorithms》 Cormen,Leiserson,Rivest和Stein的《Introduction to Algorithms》

程式碼調優

前面各章討論了提高程式效率的高層次方法:問題定義,系統結構, 演算法設計及資料結構選擇。本章討論的則是低層次的方法:程式碼調優。

程式碼調優的最重要原理就是儘量少用它。不成熟的優化是大量程式設計災害的根源。 它會危及程式的正確性,功能性以及可維護性。當效率很重要時, 第一步就是對系統進行效能監視,以確定其執行時間的分佈狀況。 效率問題可以由多種方法來解決,只有在確信沒有更好的解決方案時才考慮進行程式碼調優。

事實上,如果不是十分十分必要,不要去做程式碼調優, 因為它會犧牲掉軟體的其他許多性質。

so,just skip this chapter。

節省空間

本章講述了節省空間的一些重要方法。

減少程式所需資料的儲存空間,一般有以下方法:

  • 不儲存,重新計算。
  • 稀疏資料結構。下面著重講一下這點。
  • 資料壓縮。可以通過壓縮的方式對物件進行編碼,以減少儲存空間。
  • 分配策略。只有在需要的時候才進行分配。
  • 垃圾回收。對廢棄的儲存空間進行回收再利用。

以下是節省程式碼空間的幾種通用技術:

  • 函式定義。用函式替換程式碼中的常見模式可以簡化程式,同時減少程式碼的空間需求。
  • 解釋程式。用解釋程式命令替換長的程式文字。
  • 翻譯成機器語言。可以將大型系統中的關鍵部分用匯編語言進行手工編碼。

稀疏資料結構

假設我們有一個200 x 200的矩陣(共40000個元素),裡面只有2000個元素有值, 其它的都為0,示意圖如下:

matrix

顯然這是一個稀疏矩陣,直接用一個200 x 200 的二維陣列來儲存這些資料會造成大量的空間浪費,共需要200x200x4B=160KB。 所以,我們應該想辦法用另一種形式來儲存這些資料。

方法一

使用陣列表示所有的列,同時使用連結串列來表示給定列中的活躍元素。 如下圖所示:

sparse_matrix

該結構中,有200個指標(colhead)和2000條記錄(每條記錄是兩個整數和一個指標), 佔用空間是200x4B + 2000x12B = 24800B = 24.8KB, 比直接用二維陣列儲存(160KB)要小很多。

方法二

我們可以開三個陣列來儲存這些數,如下圖所示:

3array

firstincol是一個長度為201的陣列,對於第i列,在陣列row中, 下標為firstincol[i]到firstincol[i+1]-1對應的行元素非0, 其值儲存在相應的pointnum陣列中。

比如對於上圖,在第0列中,元素值非0的行有3行,分別是row[0],row[1],row[2], 元素值是pointnum[0],pointnum[1],pointnum[2];在第1列中,元素值非0的行有2行, 分別是row[3],row[4],元素值是pointnum[3],pointnum[4]。依次類推。

該結構所需要的儲存空間為2x2000x4B + 201x4B = 16804B = 16.8KB。 由於row陣列中的元素全部都小於200,所以每個元素可以用一個unsigned char來儲存, firstincol陣列中元素最大也就2000,所以可以用一個short(或unsigned short)來儲存, pointnum中的元素是一個4B的int, 最終所需空間變為:2000x4B + 2000x1B + 201x2B = 10402B = 10.4KB。

深入閱讀:Fred Brooks的《人月神話》

排序

本章先簡單介紹了插入排序,然後著重講述快速排序。

插入排序

快速排序

我們在這裡規定:小於等於pivot的元素移到左邊,大於pivot的元素移到右邊。

實現1:單向移動版本

這個版本的關鍵是設定一快一慢兩個指標,慢指標左側都是小於等於pivot(包含慢指標所在位置), 慢指標到快指標之間的值是大於pivot,快指標右側的值是還未比較過的。示意圖如下:

快指標一次一步向前走,遇到大於pivot什麼也不做繼續向前走。遇到小於等於pivot的元素, 則慢指標slow向前走一步,然後交換快慢指標指向的元素。一次劃分結束後, 再遞迴對左右兩側的元素進行快排。程式碼如下:

排序陣列a只需要呼叫QSort(a, 0, n)即可。該思路同樣可以很容易地在連結串列上實現:

排序頭指標為head的單連結串列只需呼叫qsort(head, NULL)即可。

實現2:雙向移動版本

版本1能能夠快速完成對隨機整數陣列的排序,但如果陣列有序, 或是陣列中元素相同,快排的時間複雜度會退化成O(n^2 ),效能變得非常差。

一種緩解方案是使用雙向移動版本的快排,它每次劃分也是使用兩個指標, 不過一個是從左向右移動,一個是從右向左移動,示意圖如下:

指標j不斷向左移動,直到遇到小於等於pivot,就交換指標i和j所指元素 (指標i一開始指向pivot);指標i不斷向右移動,直到遇到大於pivot的, 就交換指標i和j所指元素。pivot在這個過程中,不斷地換來換去, 最終會停在分界線上,分界線左邊都是小於等於它的元素,右邊都是大於它的元素。 這樣就避免了最後還要交換一次pivot的操作,程式碼也變得美觀許多。

當然,如果對於partition函式,你如果覺得大迴圈內的兩個swap還是做了些無用功的話, 也可以把pivot的賦值放到最後一步,而不是在這個過程中swap來swap去的。程式碼如下:

如果陣列基本有序,那隨機選擇pivot(而不像上面那樣選擇第一個做為pivot) 會得到更好的效能。在partition函式裡,我們只需要在陣列中隨機選一個元素, 然後將它和陣列中第一個元素交換,後面的劃分程式碼無需改變, 就可以達到隨機選擇pivot的效果。

進一步優化

對於小陣列,用插入排序之類的簡單方法來排序反而會更快,因此在快排中, 當陣列長度小於某個值時,我們就什麼也不做。對應到程式碼中, 就是修改quicksort中的if條件:

其中cutoff是一個小整數。程式結束時,陣列並不是有序的, 而是被組合成一塊一塊隨機排列的值,並且滿足這樣的條件: 某一塊中的元素小於它右邊任何塊中的元素。我們必須通過另一種排序演算法對塊內進行排序。 由於陣列是幾乎有序的,因此插入排序比較適用。

這種方法結合了快排和插入排序,讓它們去做各自擅長的事情,往往比單純用快排要快。

深入閱讀:Don Knuth的《The Art of Computer Programming, Volume 3: Sorting and Searching》;Robert Sedgewick的《Algorithms》; 《Algorithms in C》,《Algorithms in C++》,《Algorithms in Java》。

取樣問題

本章講述了一個小的隨機抽樣問題,並用不同的方法來解決它。

問題:對於整數m和n,其中m有序列表, 不允許重複。

比如m=3, n=5,那麼一種可能輸出是0,2,3(要求有序)。實現1來自Knuth的TAOCP, 時間複雜度O(n):

其中,bigrand()的作用是返回一個很大的隨機整數。

實現2:在一個初始為空的集合裡面插入隨機整數,直到個數足夠。程式碼如下:

實現3:把包含整數0~n-1的陣列順序打亂,然後把前m個元素排序輸出。 該方法的效能通常不如Knuth的演算法。程式碼如下:

深入閱讀:Don Knuth的《The Art of Computer Programming, Volume 2: Seminumerical Algorithms》

搜尋

本章詳細研究這樣一個搜尋問題:在沒有其他相關資料的情況下,如何儲存一組整數? 為些介紹了5種資料結構:有序陣列,有序連結串列,二叉搜尋樹,箱,位向量。

其中,二叉搜尋樹應該熟練掌握,以下是一種實現:

本章主要介紹堆,下面是關於堆的一些主要操作:

字串

程式1:迴圈輸入並將每個單詞插入集合S(忽略重複單詞),然後排序輸出。

程式2:單詞計數

程式3:建立自己的雜湊表(雜湊表),以下是一種實現:

字尾陣列

假設我們有以下字串及一個char*陣列:

我們讓指標pc[i]指向字串的第i個字元,即:

這時候我們輸出pc[i],會得到字串”hawstein”的所有字尾:

然後,我們對陣列pc進行排序,將所有字尾按字典序排序:

其中,比較函式cmp如下:

這時,我們再輸出pc[i],會得到排序後的結果:

我們把陣列pc稱為“字尾陣列”。這裡需要注意,陣列pc 中儲存的是指向每個字尾首字元的地址。我們也可以儲存每個字尾首字元在原陣列中的下標, 效果是一樣的。

本章中用字尾陣列解決了一個小問題:可重疊最長重複子串。比如對於字串”banana”, 其字尾陣列為:

掃描一次陣列,比較相鄰子串,找到相鄰子串的最長公共字首即可。本例為”ana”, 其中一個a是重疊的。

字尾陣列是處理字串的有力工具,常見的兩種實現方法是:倍增演算法和DC3演算法。 推薦閱讀以下材料來學習字尾陣列:

許智磊,《字尾陣列》
羅穗騫,《字尾陣列——處理字串的有力工具》

相關文章