在本系列的第一篇文章《Haskell編程解決九連環(1)— 數學建模》中,咱們認識了中國古老的智力玩具九連環。經過羅列一系列的定理和推論創建了完整的遞歸模型。在本文中咱們將經過編寫Python和Haskell的代碼來解決關於九連環的第一個問題:拆解九連環最少須要幾步?同時將對編碼所涉及到的其它問題作進一步的討論。
維基百科上關於九連環的條目中有拆解n連環所需的步數,在本文中咱們將要經過編程計算來獲得下表中的這些數字,特別的,當連環的數目n=9時,結果應該是341.python
連環的數目 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
步數 | 1 | 2 | 5 | 10 | 21 | 42 | 85 | 170 | 341 |
上一篇文章中咱們羅列了一些定理與推論,這些都是創建遞歸模型的理論基礎。這裏再次將它們羅列以下,用於指導接下來的編程實現。程序員
定理1:takeOff(1)
的解法步驟序列爲[OFF 1]
,putOn(1)
的解法步驟序列爲[ON 1]
。
定理2:takeOff(2)
的解法步驟序列爲[OFF 2, OFF 1]
,putOn(2)
的解法步驟序列爲[ON 1, ON 2]
。
定理3:當n>2
時,takeOff(n)
的解法依次由如下幾個部分組成:1) takeOff(n-2)
2) OFF n
3) putOn(n-2)
4) takeOff(n-1)
;而putOn(n)
依次由如下幾個部分組成 1) putOn(n-1)
2) takeOff(n-2)
3) ON n
4) putOn(n-2)
。
推論1:takeOff(n)
的解法步驟序列和putOn(n)
的解法步驟序列互爲逆反序列。
推論2:takeOff(n)
的解法步驟序列和putOn(n)
的解法步驟序列含有的步驟數目相等。
推論3:對於任何整數m, n
,若是m>n
,那麼第m
環的狀態(裝上或是卸下)不影響takeOff(n)
或者putOn(n)
的解,同時解決takeOff(n)
或者putOn(n)
問題也不會改變第m環的狀態。算法
相信大多數的程序員小夥伴看到這裏,已經能用本身擅長的編程語言編碼實現了,在此以前讓咱們再次明確這些定理和推論在遞歸模型中的做用。編程
讓咱們先從一個命令式語言的實現開始。segmentfault
def solve(n): # (1) if n == 1: # (2) return 1 elif n == 2: # (3) return 2 else: return 2 * solve (n - 2) + solve (n - 1) + 1 # (4)
Python的實現簡單明瞭,解釋一下代碼,序號均在代碼中以註釋的形式標註。數組
takeOff
或是putOn
,統一使用solve
2 * solve (n - 2)
,乘以2是由於takeOff(n-2)
跟putOn(n-2)
的步數相等(推論2)能夠在Python的交互式環境中測試該函數,結果應該以下(省略部分輸出):數據結構
>>> def solve(n): # (1) ... if n == 1: # (2) ... return 1 ... elif n == 2: # (3) ... return 2 ... else: ... return 2 * solve (n - 2) + solve (n - 1) + 1 # (4) ... >>> solve(1) 1 >>> solve(2) 2 >>> solve(3) 5 ...... >>> solve(8) 170 >>> solve(9) 341 >>>
歐耶!結果徹底符合預期。且慢,這個實現有個嚴重的性能問題,若是咱們試圖計算一下更多環數的答案,就會發現當n大到必定程度後會變得很慢,並且隨着n的增大,性能急劇降低:編程語言
>>> import timeit >>> timeit.timeit (lambda:print(solve(30)), number=1) 715827882 0.4117885000014212 >>> timeit.timeit (lambda:print(solve(35)), number=1) 22906492245 4.801825900009135 >>> timeit.timeit (lambda:print(solve(40)), number=1) 733007751850 54.261840500024846 >>> timeit.timeit (lambda:print(solve(50)), number=1)
咱們使用timeit
給出運行所花費的時間,能夠看到在筆者的筆記本電腦上,solve(30)
還耗時不到1秒,而solve(40)
就幾乎是1分鐘了,而solve(50)
已經不能在合理的時間內給出答案了。這是爲何呢?仔細觀察遞歸算法或是畫一棵關於求解的示意樹就能夠看到對於一樣的參數咱們重複計算了不少次。例如計算solve(9)
的時候會計算solve(7)
和solve(8)
,而在計算solve(8)
的時候又會計算一遍solve(7)
,雖然每次計算出的solve(7)
事實上有着徹底相同的結果,而在代碼實現裏仍然必須不斷拆分每一個問題以及子問題直至知足基本條件。這樣該算法就有着指數級別的時間複雜度,也就是O(2^n)
。
在命令式語言中這個問題很好解決,由於命令式語言容許函數改變全局的狀態,也就是容許函數有反作用。思路是建立一個全部函數調用都可以訪問的記錄表,記下咱們已經計算過的結果,在每次函數調用時首先在記錄表中查找是否已經有了記錄,若是找到就直接返回,不然計算出結果,將其放入記錄表中備查並返回。因爲在這裏只有一個正整數的參數,咱們能夠選用數組(C/C++/Java,Python中叫作list/列表)或是一個map(C++/Java,在Python中與map對應的數據結構叫Dictionary)來做爲記錄表的實現。相信程序員小夥伴們都能輕鬆地寫出代碼。在Python中甚至有現成的實現functools.lru_cache
,這是一個函數裝飾器(Decorator)。使用該裝飾器不用對原有函數作任何改動,只須要在函數定義前加上一行裝飾器的聲明就能夠了。讓咱們在Python的交互式環境中試試:函數
>>> import functools >>> @functools.lru_cache(maxsize=None, typed=False) ... def solve(n): # (1) ... if n == 1: # (2) ... return 1 ... elif n == 2: # (3) ... return 2 ... else: ... return 2 * solve (n - 2) + solve (n - 1) + 1 # (4) ... >>> import timeit >>> timeit.timeit (lambda:print(solve(35)), number=1) 22906492245 0.00022929999977350235 >>> timeit.timeit (lambda:print(solve(40)), number=1) 733007751850 0.0006354999495670199 >>> timeit.timeit (lambda:print(solve(50)), number=1) 750599937895082 0.0007113000028766692 >>> timeit.timeit (lambda:print(solve(200)), number=1) 1071292029505993517027974728227441735014801995855195223534250 0.0006146999658085406 >>>
如今咱們能在1毫秒內計算出拆卸200連環所須要的步數,那是一個至關大的數。假如咱們平均須要1秒鐘來完成一個步驟的話,那麼該數字大概是1071292029505993517027974728227441735014801995855195223534250/60.0/60.0/24.0/365.0 = 3.397044740950005e+52
年,幾乎3.4萬億億億億億啊就億(這裏有6個億)年。性能
咱們能夠用一樣的算法和思路來編寫Haskell實現:
solve :: Int -> Integer -- (1) solve 1 = 1 -- (2) solve 2 = 2 -- (3) solve n = 2 * solve (n - 2) + solve (n - 1) + 1 -- (4)
沿着在註釋中標註的序號,咱們來解釋一下代碼:
n - 2
和n - 1
括起來。函數調用在Haskell裏具備最高的優先級,若是不使用括號,該表達式將等價於2 * (solve n) - 2 + (solve n) - 1 + 1
,這不是咱們想要表達的意思,並且將會由於對solve n
的無休止的引用,引發編譯/解釋錯誤而被拒絕。這裏彷佛對於函數solve咱們有好幾個實現,這實際上是Haskell的一種函數定義方式,叫作模式匹配(Pattern Match)。咱們知道在Haskell中沒有相似if...then...else
的條件分支語句,若是咱們須要對函數的參數作分情形的判斷,模式匹配是簡明直接的方案(有的時候也會結合另外一種叫作哨兵的機制,英文是Guard),有興趣的同窗能夠查閱相關的資料。其實在這裏模式匹配的寫法更加簡潔而且接近數學上定義該函數的方式。使用數學公式,咱們一般會有以下的定義
$$ f_{n}\left\{\begin{matrix}f_{1}=1 \\f_{2}=2 \\\forall n>2, f_{n}=2f_{n-2} + f_{n-1} + 1 \end{matrix}\right. $$
如今讓咱們在Haskell的交互式環境ghci中運行測試一下:
Prelude> :{ Prelude| solve :: Int -> Integer -- (1) Prelude| solve 1 = 1 -- (2) Prelude| solve 2 = 2 -- (3) Prelude| solve n = 2 * solve (n - 2) + solve (n - 1) + 1 -- (4) Prelude| :} Prelude> solve 1 1 Prelude> solve 2 2 Prelude> solve 3 5 ...... Prelude> solve 8 170 Prelude> solve 9 341 Prelude> :set +s Prelude> solve 30 715827882 (2.59 secs, 375,952,672 bytes) Prelude> solve 35 22906492245 (32.81 secs, 4,168,814,704 bytes) Prelude> solve 40 ???
能夠看到該實現能正確地計算出1到9環的步數。命令:set +s
是ghci的擴展命令,使得在接下來的任何表達式求值後,ghci都會輸出所用的時間以及內存大小。明顯的是相同的算法在Haskell中有着相同的性能問題。並且因爲Haskell的惰性求值,使得在問題拆分的過程當中消耗了大量的內存用於存放中間的表達式。特別的solve 35
用了32秒,以及最大4GB內存,而solve 40
就已經不能在筆者的筆記本電腦上返回了,要麼將耗盡電腦的內存,要麼將耗盡咱們的餘生。
既然問題是同樣的,是否咱們可使用和Python中相似的記錄函數計算結果的解決方案呢?答案是確定的,相似的方案是有,不過因爲Haskell純粹(Pure)函數的本質,函數不能訪問或改變全局的狀態,這些解決方案不像在命令式語言中那樣簡單和直接。例如:
若是對於如此簡單直接的問題咱們不得不用或者粗陋或者過於高深的方法來解決的話,那倒真不如不學不用Haskell了。幸運的是,Haskell可以作到簡潔高效,甚至更好。那接下來讓咱們來看一個高效而不失簡潔的方法。
若是咱們將n連環的步數當作一個數列的話,那麼只要有兩個相鄰的數字咱們就能夠計算出數列中的下一個數字。那咱們能夠構造這樣一個序列,它的每一個元素是相鄰的兩個解組成的數對(Pair),只要獲得該序列中的任何一個元素(數對)就能夠計算出下一個元素(數對)。這個序列看起來像這樣[(1,2), (2,5), (5,10), (10,21), ...]
。有了這樣一個序列,解開n連環的步數就是該序列的第n個元素(一個數對)的第一個數值。代碼實現以下:
steps :: [(Integer, Integer)] -- (1) steps = iterate (\(cur, next) -> (next, cur * 2 + next + 1)) (1, 2) -- (2) solve' :: Int -> Integer -- (3) solve' n = map fst steps !! (n-1) -- (4)
照例,讓咱們沿着註釋中的序號解釋一下代碼:
[(Integer, Integer)]
。首先它是一個序列(List,其標誌是外層的方括號),而序列中每一個元素是一個形如(Integer, Integer)
的元組(Tuple)。在Haskell中形如(a,b,c,..)
的數據結構叫作元組(Tuple),跟Python裏的Tuple比較相似。元組能夠是零元,二元,三元直到多元的,而二元元組又被稱做值對(Pair),特別的這裏的二元元組所包含的值都是整形的數值,咱們稱之爲數對。稍後咱們能夠在ghci中看到steps的頭幾個元素就是[(1,2), (2,5), (5,10), (10,21) ...]
。這一行代碼構建了steps序列,須要詳細說明一下:
iterate (+1) 0
就是天然數序列(聽說如今的天然數定義包括0),在ghci中求值take 10 $ iterate (+1) 0
將會輸出[0,1,2,3,4,5,6,7,8,9]
.(\(cur, next) -> (next, cur * 2 + next + 1))
,其功能是傳入當前值對時,計算出下一值對。請注意它的參數(cur, next)
不是說有兩個參數cur和next,實際上這裏僅有一個參數,它的類型是值對(Integer, Integer)
,這裏的語法仍然是模式匹配(Pattern Match),咱們經過匹配值對的結構將兩個名稱(name)cur和next分別綁定(Bind)到傳入的值對的兩個數值上。名稱cur和next隨後能夠在lambda函數的函數體裏被引用。該lambda函數的返回值就比較容易理解了,它就是計算出的下一個值對,算法是將當前值對的第2個值做爲結果值對的第1個值,而後根據定義公式計算出下一結果值做爲結果值對的第2個值。[(1,2), (2,5), (5,10), (10,21), ...]
,那麼map fst steps
就將是這樣一個序列[1, 2, 5, 10 ...]
,也就是n連環的解法步數的序列,那麼它的第n個元素就是n連環的解的步數了。運算符!!
正是在一個序列中經過給定的索引值i取第i個元素的操做,注意到!!
的索引值是從0開始的,那麼第n個元素的索引便是n-1。讓咱們在ghci中看看狀況:
Prelude> :{ Prelude| steps :: [(Integer, Integer)] -- (1) Prelude| steps = iterate (\(cur, next) -> (next, cur * 2 + next + 1)) (1, 2) -- (2) Prelude| Prelude| solve' :: Int -> Integer -- (3) Prelude| solve' n = map fst steps !! (n-1) -- (4) Prelude| :} Prelude> take 9 steps [(1,2),(2,5),(5,10),(10,21),(21,42),(42,85),(85,170),(170,341),(341,682)] Prelude> take 9 $ map fst steps [1,2,5,10,21,42,85,170,341] Prelude> solve' 9 341 Prelude> :set +s Prelude> solve' 200 1071292029505993517027974728227441735014801995855195223534250 (0.03 secs, 194,992 bytes)
這裏咱們看到steps的前9個元素組成的子序列爲[(1,2),(2,5),(5,10),(10,21),(21,42),(42,85),(85,170),(170,341),(341,682)]
,而map fst steps
的前9個元素爲[1,2,5,10,21,42,85,170,341]
。請注意steps是一個無窮序列,只能經過take n
函數來取得該序列的一個有限子序列並求值打印,不然貿然求值整個steps將使ghci陷入無窮的計算和輸出之中。最後上一節中出現的性能問題也已經獲得解決,solve'函數花費了0.03秒計算出了200連環的解法步數,那個熟悉的大數值,轉換爲時間的話將比太陽系的歷史和將來還長。
簡潔高效已經有了,說好的優美呢?若是前一節的實現還不夠優美的話,那麼怎樣的代碼才能夠被稱做爲優美呢?咱們這就來看一個優美而又不失簡潔高效的實現方法。這也是筆者迄今爲止最喜歡的實現方案。之因此說這個方案優美,是由於它的代碼就跟數學定義同樣公式化。是的,公式化,就這麼簡單明確。任何的工程問題,一個有效的解決方案的公式化程度越高,它就越優美,反之亦然。
該方案的思路是構建一個解的序列solutions = [F1, F2, F3, F4 ...]
,其中Fn的值就是拆卸n連環所須要的步數。那麼咱們知道:
n>2
時,Fn由F(n-2)和F(n-1)計算而來,並且計算的方法(公式)是固定的。那麼咱們能夠定義一個函數,或者等價的一個操做符⊕,使得當n>2
時Fn = F(n-2) ⊕ F(n-1)
咱們如今設solutions的除去頭兩個元素的子序列[F3, F4, F5 ...]
爲s,那麼s = [F1 ⊕ F2, F2 ⊕ F3, F3 ⊕ F4, ...]
。換一種寫法s = [F1, F2, F3, ...] Θ [F2, F3, F4, ...] = xs Θ ys
。這樣咱們看到xs實際上就是solutions,而ys是solutions刨除第1個元素F1後的子序列。那個操做符Θ其實是這樣一個函數,它接受兩個序列xs和ys,依次取出兩個序列中的對應元素,xs的第n個(設爲x)對ys的第n個(設爲y),將函數⊕做用於x和y,也就是x⊕y,全部的計算結果依次組成的序列就是函數Θ的結果。如今咱們將全部這些都寫成Haskell代碼。請注意以上提到的符號變量是如何對應出如今代碼中的。
solutions :: [Integer] solutions = 1:2:s -- (1) s = xs |-| ys -- (2) xs = solutions -- (3) ys = tail solutions -- (4) x |+| y = 2 * x + y + 1 -- (5) (|-|) = zipWith (|+|) -- (6)
代碼解釋以下:
:
是Haskell中列表(List)的值構造符(Value Constructor),能夠理解爲一個二目操做符,它的第一個參數是一個值,第二個參數是一個列表,:
將該值插入到列表的開頭做爲第一個元素,返回新的包含給定值的列表,例如1:[1,2]
的結果是[1,1,2]
。事實上咱們在代碼裏常常把列表寫成[1,2,3]
,這種形式只是語法糖而已,其本質的表示應該是1:2:3:[]
。運算符:
是右結合的,,也就是說1:2:3:[]
等價於1:(2:(3:[])))
。那麼這裏的代碼1:2:s
的結果是這樣一個序列,其第1個元素爲1,第2個元素爲2,從第3個元素開始依次是原s序列中的元素。根據上面討論的子序列s的定義能夠知道1:2:s
就是完整的solutions序列。能夠看到這行代碼實際上就是上文中「設solutions的除去頭兩個元素的子序列[F3, F4, F5 ...]
爲s」的直接表達。s = xs Θ ys
的直接表達。這裏咱們使用了自定義的操做符|-|
做爲數學公式中「Θ」的直接表達。xs,ys以及操做符|-|
都將在隨後的代碼裏定義申明,能夠注意到在(1)處的s
也是先引用然後定義的。在Haskell裏因爲函數的純粹性以及名稱不可被屢次定義,確保了名稱不會有二義性,所以名稱或者函數均可以先引用然後定義。事實上Haskeller們常常這麼作,先把頂層的表達式寫出來,而後再詳細定義那些局部的函數和名稱。這也是Haskell常常炫耀的優點,那就是儘可能書寫讓人能看明白的定義,而不是照顧編譯器。另外在這裏咱們沒有申明s,xs或ys的類型。Haskell的編譯器和解釋器有很強的類型推導能力。例如對於子序列s
,根據s在表達式(1)處出現的位置還有solutions的類型,Haskell將推導出s的類型也是[Integer]
。其實在Haskell代碼裏大部分的類型申明都不是必須的,不過對於不太熟練的Haskeller來講,最好仍是在關鍵的函數上放上類型申明,這樣能夠確保編譯器所理解的和咱們所設想的一致。s = [F1, F2, F3, ...] Θ [F2, F3, F4, ...] = xs Θ ys
,xs = [F1, F2, F3, ...] = solutions
。s = [F1, F2, F3, ...] Θ [F2, F3, F4, ...] = xs Θ ys
,ys = [F2, F3, F4, ...]
,結論是ys序列就是solutions序列刨除第1個元素,預約義的函數tail
正是這樣一個函數,它接受一個列表,刨除第一個元素,將剩下的子序列做爲結果返回。|+|
就是咱們上文討論提到的操做符⊕,也是前幾節中將F(n-2)和F(n-2)計算成Fn的表達式。在Haskell裏能夠像定義函數同樣方便地定義操做符。函數與操做符之間沒有本質的區別,區別僅在於函數缺省的定義和調用方式是前綴的,而操做符的缺省定義和調用方式是中綴的。這裏的定義就是中綴的。也能夠之前綴的方式定義或調用操做符。這裏x |+| y = ...
也可寫成(|+|) x y = ...
,兩者徹底等價。|-|
的定義。zipWith是一個預約義的高階函數。它的第一個參數是一個函數f,該函數必須接受兩個參數。而zipWith的第2和第3個參數都是一個列表,zipWith依次從兩個列表中取出相應的元素餵給函數f,將全部f的輸出結果依次所造成的列表做爲zipWith的結果。能夠看到偏函數zipWith (|+|)
事實上就是上文中提到的處理兩個列表的函數Θ。這行代碼(|-|) = zipWith (|+|)
等價於(|-|) xs ys = zipWith (|+|) xs ys
,也等價於xs |-| ys = zipWith (|+|) xs ys
咱們經過純粹的數學公式推導得出了問題的答案,然後將整個推導過程翻譯成爲代碼,這裏能夠看到翻譯到Haskell代碼的過程是直接的映射。若是咱們的數學推導過程是正確的,那麼映射後獲得的可運行的代碼就顯而易見沒有問題。這個特性至關的酷。以筆者多年的編程經驗,彷佛在命令式語言中至今不能找到至關的能力和實現方案。
讓咱們在ghci中測試這段代碼:
Prelude> :{ Prelude| solutions :: [Integer] Prelude| solutions = 1:2:s -- (1) Prelude| Prelude| s = xs |-| ys -- (2) Prelude| xs = solutions -- (3) Prelude| ys = tail solutions -- (4) Prelude| x |+| y = 2 * x + y + 1 -- (5) Prelude| (|-|) = zipWith (|+|) -- (6) Prelude| :} Prelude> take 9 solutions [1,2,5,10,21,42,85,170,341] Prelude> solutions !! 8 341 Prelude> :set +s Prelude> solutions !! 199 1071292029505993517027974728227441735014801995855195223534250 (0.02 secs, 166,760 bytes)
能夠看到該實現一樣的高效,0.02秒計算出拆解200連環的步數。
這段代碼還能夠簡化,注意到名稱s,xs,ys都只被引用過一次,徹底能夠就地展開而不用單獨定義。而函數|-|
和|+|
也僅被引用了一次,一樣能夠就地展開或是以lambda函數替代,下面就是簡化的版本:
solutions = 1:2:zipWith (\x y -> 2 * x + y + 1) solutions (tail solutions) solve'' :: Int -> Integer solve'' n = solutions !! (n-1)
還能再簡化不?那個(n-1)
是怎麼回事?看着有些礙眼。若是咱們認爲連環數目n=0時,拆解須要0步(這是符合直覺的),能夠看到F2能夠用一樣的計算方法由F0和F1算出。也就是說咱們的數學模型可以擴展到n=0的狀況。代碼能夠是:
solutions = 0:1:zipWith (\x y -> 2 * x + y + 1) solutions (tail solutions) solve'' :: Int -> Integer solve'' = (solutions !!)
嗯,簡潔,高效。優美嗎?是的,我以爲至關的優美。