程序設計實踐 雙語版3.1---馬爾可夫鏈算法

給我看你的流程圖而藏起你的表,我將仍然是莫名其妙。如果給我你的表,那麼我將不再要你的流程圖,因爲它們太明顯了。—Frederick P. Brooks, Jr., 《人月神話》

以上從Brooks的經典書中摘錄的內容想說的是,數據結構設計是程序構造過程的中心環節。一旦數據結構安排好了,算法就像是瓜熟蒂落,編碼也比較容易。

這種觀點雖然有點過於簡單化,但也不是在哄騙人。在前一章裏我們考察了各種基本數據結構,它們是許多程序的基本構件。在這一章中,我們將組合這些結構,要完成的工作是設計和實現一箇中等規模的程序。我們將說明被處理的問題將如何影響數據結構,從這裏還可以看到,一旦數據結構安排好之後,代碼將會如何自然地隨之而來。

我們的觀點的另一個方面是:程序設計語言的選擇在整個設計過程中,相對而言,並不是那麼重要。我們將抽象地設計這個程序,然後用C、C++、Java、Awk 和Perl把它寫出來。由不同實現之間的比較,可以看出語言在這裏能有什麼幫助或者妨礙,以及它們並不那麼重要的各種情況。程序的設計當然可以通過語言來裝飾,但是通常不會爲語言所左右。

我們要選擇的問題並不是很常見的,但它在基本形式上又是非常典型的:一些數據進去,另一些數據出來,其處理過程並不依賴於多少獨創性。

我們準備做的就是產生一些隨機的可以讀的英語文本。如果隨便扔出來一些隨機字母或隨機的詞,結果當然是毫無意義的。例如,一個隨機選取字母(以及空格,用作詞之間的分隔)的程序可能產生:



沒人會覺得這有什麼意思。如果以字母在英語裏出現的頻率作爲它們的權重,我們可能得到下面這樣的內容:



這個也好不到哪兒去。採用從字典裏隨機選擇詞的方式也弄不出多少意思來:



爲了得到更好一些的結果,我們需要一個帶有更多內在結構,例如包含着各短語出現頻率的統計模型。但是,我們怎麼才能得到這種統計呢?

我們當然可以抓來一大堆英語材料,仔細地研究。但是,實際上有一種更簡單也更有意思的方法。這裏有一個關鍵性的認識:用任何一個現成的某種語言的文本,可以構造出由這個文本中的語言使用情況而形成的統計模型。通過該模型生成的隨機文本將具有與原文本類似的統計性質。

3.1 馬爾可夫鏈算法

完成這種處理有一種非常漂亮的方法,那就是使用一種稱爲馬爾可夫鏈算法的技術。我們可以把輸入想像成由一些互相重疊的短語構成的序列,而該算法把每個短語分割爲兩個部分:一部分是由多個詞構成的前綴,另一部分是隻包含一個詞的後綴。馬爾可夫鏈算法能夠生成輸出短語的序列,其方法是依據(在我們的情況下)原文本的統計性質,隨機性地選擇跟在前綴後面的特定後綴。採用三個詞的短語就能夠工作得很好——利用連續兩個詞構成的前綴來選擇作爲後綴的一個詞:

設置w1和w2爲文本的前兩個詞

輸入w1和w2

循環:

隨機地選出w3,它是文本中w1w2的後綴中的一個

打印w3

把w1和w2分別換成w2和w3

重複循環爲了說明問題,假設我們要基於本章開頭的引語裏的幾個句子生成一些隨機文本。這裏採用的是兩詞前綴:



下面是一些輸入的詞對和跟隨它們之後的詞:輸入前綴跟隨的後綴詞

處理這個文本的馬爾可夫算法將首先打印出Show your,然後隨機取出flowcharts或table。如果選中了前者,那麼現在前綴就變成your flowcharts,而下一個詞應該是and或will。如果它選取tables,下一個詞就應該是and。這樣繼續下去,直到產生出足夠多的輸出,或者在找後綴時遇到了結束標誌。

我們的程序將讀入一段英語文本,並利用馬爾可夫鏈算法,基於文本中固定長度的短語的出現頻率,產生一段新文本。前綴中詞的數目是個參數,上面用的是2。如果將前綴縮短,產生出來的東西將趨向於無聊詞語,更加缺乏內聚力;如果加長前綴,則趨向於產生原始輸入的逐字拷貝。對於英語文本而言,用兩個詞的前綴選擇第三個是一個很好的折衷方式。看起來它既能重現輸入的風味,又能加上程序的古怪潤飾。什麼是一個詞?最明顯的回答是字母表字符的一個序列。這裏我們更願意把標點符號也附着在詞後,把“words”和“words.”看成是不同的詞,這樣做將有利於改進閒話生成的質量。加上標點符號,以及(間接的)語法將影響詞的選擇,雖然這種做法也可能會產生不配對的引語和括號。我們將把“詞”定義爲任何實際位於空格之間的內容,這對輸入語言並沒有造成任何限制,但卻將標點符號附到了詞上。許多語言裏都有把文本分割成“空白界定的詞”的功能,

這個功能也很容易自己實現。

根據這裏所採用的方法,輸出中所有的詞、所有的兩詞短語以及所有三個詞的短語都必然

是原來輸入中出現過的,但是,也會有許多四個詞或更多個詞的短語將被組合產生出來。下面

幾個句子是由我們在本章裏將要開發的程序生成的,給它提供的文本是艾尼思特·海明威的《太陽照樣升起》的第4章:



我們很幸運,在這裏標點符號沒出問題。實際中卻未必總能這樣。

3.2 數據結構的選擇

有多少輸入需要處理?程序應該運行得多快?要求程序讀完一整本書並不是不合理的,因此我們需要準備對付輸入規模n=100 000個詞甚至更多的情況。輸出將包括幾百甚至幾千個詞,而程序的運行應該在若干秒內完成,而不是幾分鐘。假定輸入文本有100 000個詞,n已經相當大了,因此,如果還要求程序運行得足夠快,這個算法就不會太簡單。

馬爾可夫算法必須在看到了所有輸入之後才能開始產生輸出。所以它必須以某種形式存儲整個輸入。一個可能的方式是讀完整個輸入,將它存爲一個長長的字符串。情況的另一方面也很明顯,輸入必須被分解成詞。如果另用一個指向文本中各詞的指針數組,輸出的生成將很簡單:產生一個詞,掃描輸入中的詞,看看剛輸出的前綴有哪些可能的後綴,然後隨機選取一個。但是,這個方法意味着生成每個詞都需要掃描100 000個輸入詞。1000個輸出就意味着上億次字符串比較。這樣做肯定快不了。

另一種可能性是存儲單個的詞,給每個詞關聯一個鏈表,指出該詞在文本中的位置。這樣就可以對詞進行快速定位。在這裏可以使用第2章提出的散列表。但是,這種方式並沒有直接觸及到馬爾可夫算法的需要。在這裏最需要的是能夠由前綴出發快速地確定對應的後綴。

我們需要有一種數據結構,它能較好地表示前綴以及與之相關聯的後綴。程序將分兩部分,第一部分是輸入,它構造表示短語的整個數據結構;第二部分是隨後的輸出,它使用這個數據結構,生成隨機的輸出。這兩部分都需要(快速地)查詢前綴:輸入過程中需要更新與前綴相關的後綴;輸出時需要對可能後綴做隨機選擇。這些分析提醒我們使用一種散列結構,其關鍵碼是前綴,其值是對應於該前綴的所有可能後綴的集合。

爲了描述的方便,我們將假定採用二詞前綴,在這種情況下,每個輸出詞都是根據它前面的一對詞得到的。前綴中詞的個數對設計本身並沒有影響,程序應該能對付任意的前綴長度,但給定一個數能使下面的討論更具體些。我們把一個前綴和它所有可能後綴的集合放在一起,稱其爲一個狀態,這是馬爾可夫算法的標準術語。

對於一個特定前綴,我們需要存儲所有能跟隨它的後綴,以便將來取用。這些後綴是無序的,一次一個地加進去。我們不知道後綴將會有多少,因此,需要一種能容易且高效地增長的數據結構,例如鏈表或者動態數組。在產生輸出的時候,我們要能從關聯於特定前綴的後綴集合中隨機地選出一個後綴。還有,數據項絕不會被刪除。

如果一個短語出現多次,那麼又該怎麼辦?例如,短語“might appear twice”可能在文本里出現兩次,而“might appear once”只出現了一次。這個情況有兩種可能的表示方式:或者在“might appear”的後綴表裏放兩個“twice”;或者是隻放一個,但還要給它附帶一個計數值爲2的計數器。我們對用或不用計數器的方式都做過試驗。不用計數器的情況處理起來比較簡單,因爲在加入後綴時不必檢查它是否已經存在。試驗說明這兩種方式在執行時間上的差別是微不足道的。

總結一下:每個狀態由一個前綴和一個後綴鏈表組成。所有這些信息存在一個散列表裏,以前綴作爲關鍵碼。每個前綴是一個固定大小的詞集合。如果一個後綴在給定前綴下的出現多於一次,則每個出現都單獨包含在有關鏈表裏。

下面的問題是如何表示詞本身。最簡單的方法是把它們存儲爲獨立的字符串。在一般文本里總是有許多反覆出現的詞,如果爲單詞另外建一個散列表有可能節約存儲,因爲在這種情況下每個詞只需要存儲一次。此外,這樣做還能加快前綴散列的速度,因爲這時每個詞都只有一個惟一地址,可以直接比較指針而不是比較詞裏的各個字符。我們把這種做法留做練習。下面採用每個詞都分開存放的方式。

補充閱讀:

隱藏在設計模式後面的基本思想是:大部分程序所採用的不過是很少幾種不同的設計結構,與此類似,實際上也只有不多的幾種基本數據結構。說的遠一點,這與我們在第1章討論過的編碼習慣用法也是很相像的。這方面的經典參考文獻是Erich Gamma、Richard Helm、Ralph Johnson和John Vlissides的《設計模式:可重用面向對象軟件的要素》(Design Pattern: Elements of Reusable Object-Oriented Software,Addison-Wesley,1995)。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章