在本系列的上一篇文章《Haskell編程解決九連環(2)— 多少步驟?》中,咱們經過編寫Python和Haskell的代碼解決了關於拆解九連環最少須要多少步的問題。在本文中咱們將更進一步,輸出全部的詳細步驟。每一個步驟其實是裝上一個或者拆下一個圓環的動做。關於步驟動做的定義請參見本系列的第一篇文章《Haskell編程解決九連環(1)— 數學建模》。
維基百科上關於九連環的條目中有拆解n連環所需的步數,在本文中咱們將要經過編程計算來得出與下表中數字相對應的詳細步驟動做,特別的,當連環的數目n=9時,結果應該是包含341個動做的序列。前端
連環的數目 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
步數 | 1 | 2 | 5 | 10 | 21 | 42 | 85 | 170 | 341 |
定理與推論是咱們編程實現的基礎和指導,再次將它們羅列以下。python
定理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環的狀態。程序員
照例,咱們從一個命令式語言的實現開始。這有助於熟悉命令式編程語言的小夥伴們理解。也爲隨後的的Haskell實現設定結果規範。算法
由於涉及到大量的輸入輸出,此次咱們試着構造一個完整的程序。在該程序中提供了主函數入口,使得下面的代碼不只能在交互式環境中運行和測試,也能夠在操做系統的命令行環境直接調用。shell
#!/usr/bin/python import itertools def printAction(stepNo, act, n): # (1) print('{:d}: {:s} {:d}'.format(stepNo, act, n)) def takeOff(n, stepCount): # (2) if n == 1: printAction(next(stepCount), 'OFF', 1) elif n == 2: printAction(next(stepCount), 'OFF', 2) printAction(next(stepCount), 'OFF', 1) else: takeOff(n - 2, stepCount) printAction(next(stepCount), 'OFF', n) putOn(n - 2, stepCount) takeOff(n - 1, stepCount) def putOn(n, stepCount): if n == 1: printAction(next(stepCount), 'ON', 1) elif n == 2: printAction(next(stepCount), 'ON', 1) printAction(next(stepCount), 'ON', 2) else: putOn(n - 2, stepCount) printAction(next(stepCount), 'ON', n) takeOff(n - 2, stepCount) putOn(n - 1, stepCount) if __name__ == "__main__": # (3) n = int(input()) # (4) takeOff(n, itertools.count(start = 1)) # (5)
這裏咱們再也不贅述關於遞歸的基本條件或者遞歸的拆分算法部分。有興趣的讀者能夠參閱本系列的前兩篇文章。有一些新出現的東西值得關注。首先命令式語言並不由止任何函數具備反作用,咱們能很容易地在任何地方作輸入輸出。在代碼中你們也能夠看到函數的申明沒有任何的區別,可是咱們可以在迭代或是遞歸的過程當中打印相應的結果。下面咱們沿着在代碼中標註的序號作一些解釋。編程
stepNo
是一個整型值,就是當前輸出動做在整個解序列中的序號。該函數的輸出多是「10: ON 6」 或者 「123: OFF 9」。takeOff
和putOn
的交叉遞歸,這是比較簡單直接的方案。因爲咱們選擇了在遞歸的過程當中逐步輸出的方案,步數的計數器(stepCount)須要被傳入而且向下傳遞。takeOff
用於輸出拆解n連環的全部步驟。這裏咱們使用了itertools
裏現成的count
類做爲咱們的步驟計數器的實現,這裏設定的起始值爲1,那麼對於9連環,輸出的全部步驟將會帶有順序的計數編號1到341。將以上代碼存成一個文本文件並命名爲Main.py,就能夠將其做爲一個獨立的Python程序來啓動運行。
下面咱們在操做系統的命令行(Windows 上能夠是CMD,Linux中是shell)中測試運行一下,輸出結果應該相似於:segmentfault
c:\LearnHaskell\9ring>echo 5 | Main.py 1: OFF 1 2: OFF 3 3: ON 1 4: OFF 2 5: OFF 1 6: OFF 5 7: ON 1 8: ON 3 9: OFF 1 10: ON 1 11: ON 2 12: OFF 2 13: OFF 1 14: OFF 4 15: ON 1 16: ON 2 17: OFF 1 18: OFF 3 19: ON 1 20: OFF 2 21: OFF 1 c:\LearnHaskell\9ring>Main.py 9 1: OFF 1 2: OFF 3 3: ON 1 4: OFF 2 5: OFF 1 ..................... 336: ON 2 337: OFF 1 338: OFF 3 339: ON 1 340: OFF 2 341: OFF 1 c:\LearnHaskell\9ring>
首先咱們以命令echo
輸出5經過管道餵給咱們的程序,獲得拆解5連環的21步解法。而後咱們直接運行Main.py,程序啓動後暫停等待用戶輸入,鍵入9而後回車,程序將會輸出拆解9連環的全部341個步驟。你們也能夠經過輸出轉向將詳細解法存成一個文本文件,就像這樣echo 9 | Main.py > 9ringSolution.txt
。有興趣有耐心的小夥伴能夠對照輸出用真實的九連環驗證一下哦,341步仍是能夠操做的。
那麼這個實現是否和上一篇文章中同樣有性能問題呢?可否計算50連環甚至200連環呢?性能問題是有的,這裏的交叉遞歸的算法仍然必須拆解每一個子問題直至基本條件,因此也是具備指數級別的複雜度,也就是O(2^n)
。不過在這裏算法的低效不是主要問題,由於咱們的要求是輸出全部的解法步驟,這些步驟跟2^n
是至關的,因爲IO是特別耗時的操做,與之相比算法自己的開銷就微不足道了。因此不要試圖去計算並輸出200連環的拆解步驟,咱們的電力,電腦,咱們本身,咱們所處的星系都將不能支撐或是等到程序結束。數據結構
相應的,咱們也將Haskell的代碼寫成一個完整的程序,將如下的代碼存爲一個文本文件並命名爲rings.hs(擴展名.hs指明該文件是Haskell的源文件)。可使用Haskell的編譯器ghc將其編譯連接成二進制本地文件運行,或者使用ghc提供的解釋器猶如解釋腳本文件般地運行。app
printAction act n stepNo = do -- (1) putStr (show stepNo) -- (2) putStr ": " putStr act putStr " " putStrLn (show n) -- (3) takeOff :: Int -> Int -> IO Int -- (4) takeOff n stepNo | n == 1 = do -- (5) printAction "OFF" 1 stepNo return (stepNo + 1) -- (6) | n == 2 = do printAction "OFF" 2 stepNo printAction "OFF" 1 (stepNo + 1) return (stepNo + 2) | otherwise = do -- (7) newStepNo <- takeOff (n-2) stepNo -- (8) printAction "OFF" n newStepNo newStepNo' <- putOn (n - 2) (newStepNo + 1) takeOff (n -1) newStepNo' putOn :: Int -> Int -> IO Int putOn 1 stepNo = do printAction "ON" 1 stepNo return (stepNo + 1) putOn 2 stepNo = do printAction "ON" 1 stepNo printAction "ON" 2 (stepNo + 1) return (stepNo + 2) putOn n stepNo = do newStepNo <- putOn (n-1) stepNo newStepNo' <- takeOff (n-2) newStepNo printAction "ON" n newStepNo' putOn (n - 2) (newStepNo' + 1) main :: IO () -- (9) main = do n <- read <$> getLine -- (10) takeOff n 1 -- (11) return () -- (12)
這是一段挺長的代碼了,好在上一節咱們已經有了可供參照的Python程序,你們能夠看到大塊的結構是一一對應的。讓咱們沿着註釋中的標號分析一下代碼,特別是Haskell中特有的東西:數據結構和算法
do
關鍵字,它在這段代碼裏幾乎無處不在。其實do
是Monad的語法糖,Monad是Haskell中的一個很高層次的抽象,能夠被理解爲一個抽象的計算結構或計算語境,例如咱們熟知的列表就具備Monad所歸納的全部特性和要求的行爲方式,咱們能夠說列表是一種Monadic的結構。Monad最重要的做用之一是解決計算順序的問題。咱們知道Haskell本質上是lambda演算,在lambda演算中嵌套的表達式隱含了必定的求值順序,例如y=x+1
,若是咱們須要求值y
,那麼必須先要對x
求值。Monad的抽象幫助咱們以順序的方式來表達事實上是嵌套的lambda表達式。回到do
關鍵字,這裏咱們能夠簡單地理解爲do
後面一般跟隨了多個動做(或者表達式),do
事實上將它們「粘合」成一個大的動做,當這個「大」動做被要求執行的時候,其所包含的全部動做將會按照順序依次執行併產生結果。putStr
是Haskell的標準輸出函數,它接受一個字符串參數,執行的時候將該字符串打印到標準輸出上。show
函數是一個多態的函數,是類型類(Type Class)Show
的成員函數,在Haskell裏類型類比較相似於Java裏的接口(Interface)。簡單地說show
函數接受一個參數,並將其轉換爲字符串,任何能夠被show
函數轉換的類型必須實現(或者繼承)Show
類型類而且提供(或者繼承)show
的實現。Haskell裏的基礎數值類型以及列表,元組等類型均是Show
類型類的實例。putStr
後咱們調用了一次putStrLn
,該函數跟putStr
惟一的區別在於輸出字符串後再輸出一個新行符(\n
)。能夠看到咱們利用類型轉換函數show
和輸出函數putStr
,putStrLn
「拼湊」了一行輸出。Haskell有第三方的庫提供了printf
函數,其功能與C/C++中的printf
相似,功能上一點也不弱。takeOff
和putOn
交叉遞歸。因爲咱們要在這兩個函數中作輸出,這兩個函數的結果類型都必須包含IO結構。咱們知道輸入輸出是具備反作用的(打開文件,讀取鍵盤敲擊,改變終端顯示甚至拆毀房子,引爆炸彈都是IO的反作用),而Haskell一般的函數不容許有反作用,也就是說一般的函數不被容許作輸入輸出,這些函數也被稱爲純粹的函數。在Haskell中IO被交給非純粹(Impure)的函數來完成,這類函數必須有相似IO a
的結果類型,其中a是一個具體類型例如Int
,String
甚至是函數類型例如IO (String -> Int)
。一個IO a
類型的值能夠被看做一個動做,當該動做被成功執行(咱們一般說執行一個IO動做,求值一個表達式,其實這兩者的意義是等價的或是至關的)時,將會產生一個具備a類型的結果,該結果能夠被從IO結構中取出並被後續程序使用。這裏takeOff
函數就能夠被理解爲接受兩個Int型的參數(連環數目n和當前步驟計數器的數值),而後產生一個IO Int
的動做,當該動做被執行時(也能夠認爲是被求值),會產生一系列的輸入輸出(反作用),當這一切成功後,該動做產生一個Int值做爲動做最後的結果。在takeOff
和putOn
函數中這個Int的結果其實是作了一系列輸出後步驟計數器的下一個值。咱們知道在Haskell的函數裏,咱們不能改變一個全局的狀態,因此咱們沒法像在Python中那樣使用相似next的調用在得到計數器當前值的同時將其狀態加一以備後用,這裏只能將計數器的新值返回,由調用者負責傳遞到下一個函數調用中。|
,以後的條件表達式能夠對參數以及在模式匹配中綁定的名稱作判斷,而後是等號和函數體。咱們在上一篇文章中提到過,要對輸入的參數分情形作判斷,一般的機制是模式匹配和哨兵。這兩種機制是互補的,常常被結合在一塊兒使用。通常的,一個函數的實現能夠有一個或者多個模式匹配,每一個模式匹配能夠沒有,有一個或者多個哨兵。模式匹配和哨兵都是有順序的。在運行時,當一個函數被求值的時候,Haskell使用傳入的參數依照定義順序嘗試模式匹配,若是某個模式匹配成功,則依照定義順序嘗試該模式的哨兵,若是某個哨兵的條件表達式求值爲True,則以該哨兵等號後的函數體做爲該函數本次調用的求值表達式爲函數求值,若是該模式中的全部哨兵均返回False,則移到下一個模式繼續嘗試匹配。若是嘗試完全部的模式和哨兵仍然沒有匹配的模式且求值爲True的哨兵組合,那麼該次函數求值失敗,會報告運行錯誤。模式匹配和哨兵在能力上仍是有一些區別的,模式匹配可以匹配數值的結構以及常量,而哨兵一般不能判斷數值的結構(除非藉助一些輔助函數,而那些輔助函數實際上也是經過模式匹配返回布爾值而已),但能判斷數值範圍,複雜的布爾條件以及條件組合,固然哨兵也能處理常量。例如咱們用模式匹配判斷一個參數是不是一個形如(a,b)
的二元元組,而用哨兵判斷一個年份是不是閏年 mod n 4 == 0 && mod n 100 /= 0 || mod n 400 == 0
。回到咱們的代碼,這裏咱們對連環數目n作判斷,看它是否爲常量1或2,對此模式匹配和哨兵均有能力完成。在此爲了展現二者的語法,咱們主要使用哨兵來實現函數takeOff
而徹底使用模式匹配來實現putOn
。return
的含義與在命令式語言裏有很大的不一樣,在命令式語言裏,return
一般當即終止函數的運行,並返回一個值做爲函數運行的結果。而在Haskell裏return
是一個普通的函數,其做用是將一個值放入到一個結構中,具體是什麼結構取決於上下文,並且return
也不會終止函數或任何表達式的求值。在這裏咱們的上下文是IO Int
其結構就是IO,因此在這段代碼裏咱們看到的return
多數時候就是接受一個Int
型的值(步驟計數器的當前值)並放入到IO結構中。那麼在這裏return
的類型就應該是Int -> IO Int
。在Haskell的代碼裏,咱們能夠顯式地申明值或函數的類型,看編譯器是否贊成咱們的理解。例如咱們能夠將這行代碼改成(return :: Int -> IO Int) (stepNo + 1)
,代碼仍然能夠經過編譯。而若是咱們申明其類型爲Int -> [Int]
或是Int -> IO ()
,編譯器就會抱怨了。細心的讀者可能注意到在1,2的狀況下咱們有return
但在n
的狀況下卻沒有,那是由於do
block的最後一個動做takeOff
或putOn
的類型就是IO Int
,其中的值就是咱們打算做爲返回值的步驟計數器的最新值,因此這裏不必再經過<-
算符提取IO
結構裏的值,而後再經過return
放入到一樣的IO
結構中了。關於算符<-
咱們會在隨後討論。otherwise
是一個老是爲True的哨兵,其邏輯至關於if ... then ... else ...
中的else或者switch/case
中的default分支。<-
作的事情和return
剛好相反,其右邊是一個帶有結構的值,<-
從結構中取出值而後綁定到左邊的名稱上(能夠像在命令式語言中那樣理解爲從結構中取出值賦給左邊的「變量」)。這裏<-
的右邊是一個IO Int
的值,從其中取出那個Int
型的值綁定到名稱newStepNo
,那麼咱們能夠肯定名稱(能夠理解爲「變量」)newStepNo
就具備Int
類型。請注意=
和<-
的區別,=
不會作任何附加操做,僅僅是一個名稱綁定,若是b
是一個類型爲IO Int
的值或表達式,那麼當咱們寫a = b
時a
就具備IO Int
的類型,而若是咱們寫a <- b
,那麼a
必是Int
型的。IO ()
或是IO Int
,能夠理解爲分別對應了C/C++中的void main
和int main
。這裏的()
是一個類型,就是以前咱們提到過的零元的元組,零元元組的可能值只有一個,也是()
,這個值又被稱做unit。Haskell裏全部的函數都必須有類型,也就是說必須有返回結果,當咱們的確須要表達沒有返回值或者返回值不重要的時候(這在作純粹的輸出的時候比較常見)就可使用()
做爲返回值以及其類型。在代碼的最後一行有return ()
,那裏的()
就是零元元組的值unit。getLine
是Haskell的標準輸入函數之一,它從標準輸入設備上讀取一行數據,直至回車,將讀到的數據以字符串返回,其類型是IO String
。read
函數是Read
類型類的成員函數,它所作的事情跟上面提到的show
剛好相反,read
接受一個字符串,將其轉換成其它類型,例如Int
,Float
甚至是列表,元組等有結構的類型。操做符<$>
是函數fmap
的中綴版本,兩者徹底等價。fmap
是Functor
類型類的成員函數。Functor
是Haskell中一個很重要的抽象概念,有些中文資料中翻譯爲「函數子」或者「函子」。簡單地說Functor
是一類計算結構(或者說計算環境),這類結構在內部存放數據,而且容許可以操做該數據的函數被map over,map over在Haskell中被普遍地理解爲lift(提高),就像電梯同樣把一個函數提高到須要的層次去操做。其實最簡單直接的中文理解是「穿越」,Functor
做爲一種計算結構,其中包含必定類型的數值,Functor
容許那些能操做該數值的函數「穿越」進其結構外殼並操做內部的數值,而且不改變Functor
的原有外在結構。咱們熟知的IO,[](列表或者叫作List)都是Functor
的實例。舉個例子,咱們有函數(+1)
能操做Int
型的數據,例如(+1) 7
的結果是8
,若是咱們有個Int
型的列表[1,2,3]
,該怎樣使用(+1)
去操做其中的Int
值呢?在命令式語言中,咱們一般會經過迭代,循環等方法打開結構依次取出全部的值,而後經過代碼把函數做用到這些數值上,就地更改數據,或是複製一份結果。而在Haskell中咱們只需利用列表結構的Functor
特性將函數穿越進去操做便可,fmap (+1) [1,2,3]
或者(+1) <$> [1,2,3]
將返回給咱們[2,3,4]
。回到代碼,咱們說過getLine
的類型是IO String
,而read
在這裏的類型是String -> Int
(爲何這裏read
會把字符串轉化爲Int
而不是其餘類型呢?這是Hakell的類型推導在起做用,這裏咱們解開表達式的結果,取出IO結構裏面的值並綁定到名稱n
,隨後調用takeOff
函數將n
做爲其第一個參數,而該參數被明肯定義爲Int
,因而Haskell順利推斷出咱們調用read
是指望獲得一個整數),read <$> getLine
就具備類型IO Int
,因而咱們從IO結構中取出Int
數值綁定到名稱n。固然咱們也能夠先取出字符串綁定到一個名稱,而後再轉換,像這樣s <- getLine; let n = read s
。能夠看到使用Functor
的函數map over使代碼簡潔了一些。main
有類型IO ()
,最後必須經過return
將一個()
放入到IO
結構中。不然編譯器、解釋器將把最後一句的結果做爲函數的結果類型,咱們知道那是takeOff n 1
,其類型爲IO Int
,類型的不匹配會致使編譯錯誤。打開操做系統的命令行環境,讓咱們來看看運行的狀況:
c:\9ring>ghc rings.hs [1 of 1] Compiling Main ( rings.hs, rings.o ) Linking rings.exe ... c:\9ring>echo 5 | rings 1: OFF 1 2: OFF 3 3: ON 1 4: OFF 2 5: OFF 1 6: OFF 5 7: ON 1 8: ON 2 9: OFF 1 10: ON 3 11: ON 1 12: OFF 2 13: OFF 1 14: OFF 4 15: ON 1 16: ON 2 17: OFF 1 18: OFF 3 19: ON 1 20: OFF 2 21: OFF 1 c:\9ring>runhaskell rings.hs 9 1: OFF 1 2: OFF 3 3: ON 1 .......... 338: OFF 3 339: ON 1 340: OFF 2 341: OFF 1
咱們調用Haskell的編譯器ghc
將源文件編譯爲可執行文件,而後啓動該可執行文件,經過echo
和管道把5做爲輸入餵給該程序,獲得5連環的解。以後咱們使用Haskell提供的解釋運行命令runhaskell
以腳本解釋的方式再次啓動程序,經過鍵盤輸入9,因而獲得了9連環的341步解法。編譯運行和解釋運行在Haskell裏沒有功能上的區別,惟一的不一樣在於編譯運行會有較高的性能。
該實現雖然能正確運行,但跟Python的實現相比,這段Haskell代碼顯得異常笨拙。其中至少有兩個比較明顯的問題:
Int
和IO Int
間作轉換,能夠看到咱們屢次使用<-
將Int
的數值從IO Int
中取出,只是爲了傳遞到只接受Int
參數的函數,而後這些函數又生成IO Int
的結果,爲此咱們還建立了好些像newStepNo
這樣的一次性的名稱(變量)。對於第1點咱們須要放寬眼界,尋找別的數據結構和算法。而對第2點卻是能夠當即作些改進和簡化。咱們提到過do
只是語法糖,其背後的實質是Monad
的核心函數>>=
,>>=
接受兩個參數,像這樣被調用a >>= f
。其左邊的參數a是一個帶有結構(Monadic 結構例如IO, []等)的值,右邊的參數f是這樣一個函數,它接受一個不帶結構的數值(就是a去掉結構後的數值),產生另外一個帶結構的數值,這個結果和a具備相同的結構,可是其中的數值能夠有不一樣的類型。而算符>>=
的做用就是當其所在表達式被求值的時候,>>=
解開a的結構,取出其中的數值,把該數值餵給函數f,將函數f的結果做爲整個>>=
表達式的結果,注意到這個結果跟a具備相同的結構,若是有相應的函數,又可使用>>=
來繼續作進一步的計算。也就是說>>=
能夠方便地級聯,像數據管道同樣作多級的組合。
通常的,若是m
是具備Monad特性的結構,>>=
具備類型m a -> (a -> m b) -> m b
。在這裏咱們設m是IO,a和b都是Int
,那麼能夠看到>>=
的一個特化的版本是IO Int -> (Int -> IO Int) -> IO Int
,也就是算符>>=
接受一個IO Int
的值a,還有一個具備Int -> IO Int
類型的函數f,將a中的Int
型的值取出(上文中提到的<-
所作的工做),將該值餵給f獲得結果,該結果仍然是一個IO Int
。能夠看到偏函數takeOff n
和putOn n
都具備類型Int -> IO Int
。若是咱們在打印一個動做後返回計數器的當前值,那麼偏函數printAction act n
也將具備相同的類型。這裏就能夠看出咱們爲何把計數器的值做爲printAction的最後一個參數了。在Haskell裏函數的參數順序相對比較重要,當咱們須要使用偏函數簡化代碼時,就會發現很容易綁定參數列表前端的參數而獲得偏函數,當的確須要僅綁定參數列表中靠後的參數時,咱們將不得不定義封裝(Wrapper)函數或是lambda函數來作,會顯得略爲繁瑣。簡化的代碼以下:
printAction act n stepNo = putStr (show stepNo) >> putStr ": " >> putStr act >> putStr " " >> putStrLn (show n) >> return (stepNo + 1) takeOff :: Int -> Int -> IO Int takeOff n stepNo | n == 1 = printAction "OFF" 1 stepNo | n == 2 = printAction "OFF" 2 stepNo >>= printAction "OFF" 1 | otherwise = takeOff (n-2) stepNo >>= printAction "OFF" n >>= putOn (n - 2) >>= takeOff (n -1) putOn :: Int -> Int -> IO Int putOn 1 stepNo = printAction "ON" 1 stepNo putOn 2 stepNo = printAction "ON" 1 stepNo >>= printAction "ON" 2 putOn n stepNo = putOn (n-1) stepNo >>= takeOff (n-2) >>= printAction "ON" n >>= putOn (n - 2) main :: IO () main = do n <- read <$> getLine takeOff n 1 return ()
讀者能夠將這段代碼存成一個新的文件(例如rings1.hs)自行驗證。惟一須要說明的是算符>>
,這個算符是>>=
的姊妹版本,它的做用是丟棄左邊的數值。咱們知道>>=
保證了函數的順序執行,而且將前一部分產生的帶結構的數據從結構中取出來傳遞給下一個函數。而>>
只有順序的保證。不少時候咱們有一些沒有參數的函數,咱們須要將它們鏈入到大的函數鏈中並須要它們的計算結果,這時候>>
就頗有用了。在Haskell中>>
的實現是基於>>=
的,從下面的實現代碼中也能夠看到做爲第2個參數的函數f沒有任何參數,它具備類型m b
。
(>>) :: m a -> m b -> m b m >> f = m >>= (\_ -> f)
如今咱們來把純粹和非純粹的部分分開,也就是把算法和輸出分開。思路是takeOff n
和putOn n
均返回一個動做序列,再交由專門的輸出函數去作輸出。爲了表示動做和動做序列,咱們須要首先定義一個數據結構Action
,在Haskell裏用戶定義的數據結構也和原生的數據類型同樣被稱爲類型(Type)。Haskell裏跟定義新類型有關的語法有3種,分別由關鍵字type
,newtype
和data
界定。關於這3種語法的聯繫和區別請參閱其它相關資料。
data Action = -- (1) ON Int | OFF Int -- (2) deriving (Show) -- (3) takeOff :: Int -> [Action] -- (4) takeOff 1 = [OFF 1] takeOff 2 = [OFF 2, OFF 1] takeOff n = takeOff (n -2) ++ OFF n : putOn (n - 2) ++ takeOff (n - 1) -- (5) putOn :: Int -> [Action] putOn 1 = [ON 1] putOn 2 = [ON 1, ON 2] putOn n = putOn (n - 1) ++ takeOff (n - 2) ++ ON n : putOn (n - 2) printActions :: [Action] -> IO () -- (6) printActions acts = sequence_ $ zipWith printSingleAct [1,2..] acts -- (7) where printSingleAct stepNo act = putStr (show stepNo) -- (8) >> putStr ": " >> putStrLn (show act) main :: IO () main = takeOff.read <$> getLine >>= printActions -- (9)
能夠看到咱們算法的部分takeOff
和putOn
是純粹的函數,沒有反作用,其實現也已經至關的公式化了。全部的輸出交給了非純粹的函數printActions
去作,包括爲動做加上序號的工做。讀者能夠將這段代碼存成一個Haskell源文件例如rings2.hs,如前啓動運行,能夠看到其輸出和以前的代碼徹底一致。讓咱們來走讀一下代碼自己:
Action
是咱們爲描述動做定義的類型。這裏使用了關鍵字data
是由於咱們有多於一個的值構造子(Value Constructor),而type
和newtype
不能在這種狀況下使用。另外請注意Haskell的類型名稱必須以大寫字母開頭。Action
類型有兩個值構造子ON
和OFF
,Haskell容許一個類型有多個值構造子,值構造子定義間用操做符|
隔開,寓意值構造子之間是互斥的。這個很容易理解,當咱們構造一個該類型的值的時候,必須且僅能選用一個值構造子。ON
和OFF
均接受一個Int
類型的參數。值構造子與普通的函數無異,惟一的區別僅在於值構造子的名稱必須以大寫字母開頭而普通函數必須以小寫字母開頭(運算符是另外一種狀況)。值構造子做爲函數,它們接受參數並返回一個相關類型的值。若是在ghci中加載該代碼文件或是直接鍵入Action
的定義,可使用命令:type ON
來查看ON
的類型,其結果是Int -> Action
。若是在命令式語言中定義相似的數據結構,能夠定義一個structure
(C/C++)或是一個class
(C++/Java/Python),並擁有兩個成員,其一爲一個標誌(Flag)用於區分值的類型是ON
仍是OFF
,另外一個成員則用於存放構造時經過參數傳入的整型值。Show
。咱們知道類類型Show
提供了成員函數show
用於將該類型的一個值轉換爲字符串。在這裏Haskell自動生成的實現將把Action
的值轉換爲相似「ON 5」、「OFF 8」同樣的字符串,徹底符合咱們的要求,好巧!takeOff
和putOn
就只有一個參數了,就是目標問題圓環的數目n,根據算法思路,其返回值也變成了一個動做序列。++
接受兩個類型相同的列表a和b,將兩個列表聯結在一塊兒並返回一個新的列表,結果列表中前面依次是a中的全部元素,而後依次是b中全部的元素。操做符:
是列表的一個值構造子,它接受一個單個的元素a和一個列表l,將a插入到l的頭上,返回這個包含元素a的新列表。能夠看到這行代碼就是依據定理3將較大的問題拆分紅幾個較小的問題,而後將全部子問題的結果合併爲目標問題的結果。幸運的是大多數操做符以及函數調用的優先級都剛好符合咱們的需求,這裏僅有n-1
,n-2
須要加上括號。printActions
處理一個動做列表,將全部的動做加上序號,打印到輸出設備上。因此其名稱中使用了複數。這行的要點有好幾處,須要詳細說明:
zipWith
將序號序列([1,2..]
)和動做序列(acts
)依次配對,而後將配好的值對做爲參數傳給函數printSingleAct
,再將printSingleAct
的全部結果依次放入一個列表中做爲結果返回。函數printSingleAct
將隨後定義,它接受一個整型的序號和一個動做,將它們拼接打印成一行輸出。這裏printSingleAct
的類型是Int -> Action -> IO ()
。所以函數zipWth
的結果就是[IO ()]
,是一個IO動做的序列。sequnce_
接受一個IO動做的序列,將其中包含的IO動做依次「粘合」成一個大的IO動做,當這個大的IO動做被執行時,至關於全部原始IO動做被依次執行,而後拋棄全部原始IO動做的返回結果並返回IO ()
。sequence_
有個姊妹版本sequence
會將全部原始IO動做所返回結果中包含的數據放入到一個列表中返回。例如當傳入IO動做列表是[IO Int]
的時候,sequence_
的結果是IO ()
而sequence
的結果是IO [Int]
。這裏咱們知道zipWith
的結果是[IO ()]
,若是咱們在這裏使用sequence
將會獲得IO [()]
,咱們知道unit()
做爲返回結果對咱們來講沒有什麼意義,而且外圍函數printActions
的類型是IO ()
,因而在此簡單地選用了sequence_
。where
語法是Haskell中申明定義「局部」名稱的方法之一。所謂「局部」名稱能夠是常量或是函數,僅在包含該where
子句的表達式中可見,語法上where
子句緊跟其起做用的外圍表達式。這裏咱們經過where
子句定義了一個(在where
子句裏能夠定義多個名稱)「局部」函數printSigneAct
。printSingleAct
的實現代碼很直接,經過putStr
,putStrLn
和show
函數結合傳入的參數「拼湊」了一行輸出。細節能夠參閱上一節。takeOff.read
,咱們知道getLine
封裝在IO內的是一個字符串String。「穿越」進去的組合函數首先起做用的是read
函數,它將String
轉換爲Int
,而後這個Int
值被餵給組合中的下一個函數takeOff
,takeOff
接受一個Int
值後生成咱們的解,一個動做序列[Action]
,因爲<$>
穿越以後不改變getLine
的結構外殼IO,因而takeOff.read <$> getLine
的結果就是IO [Action]
。最後咱們使用>>=
算符打開IO的外殼,取出其中的動做序列[Action]
,將其做爲參數餵給輸出函數printActions
去輸出。而printActions
返回的IO ()
正是咱們期待的做爲整個main
函數的返回值。在這行代碼上全部的動做都完美地結合在一塊兒,一個多餘的動做都沒有,一個局部名稱或變量都不須要。在這個算法中takeOff
返回的是一個動做序列,若是咱們的目的只是解決拆卸的問題的話,那咱們徹底能夠利用推論1來簡化代碼,那樣咱們就能夠不用使用兩個函數交叉遞歸了。爲了獲得一個動做序列的逆反序列,咱們首先要有方法獲得一個動做的反動做,至於獲取一個序列的倒序序列在Haskell中有預約義的函數reverse
。如下是簡化後的代碼:
data Action = ON Int | OFF Int deriving (Show) flipAction (ON n) = OFF n -- (1) flipAction (OFF n) = ON n takeOff :: Int -> [Action] takeOff 1 = [OFF 1] takeOff 2 = [OFF 2, OFF 1] takeOff n = takeOff (n -2) ++ OFF n : (map flipAction $ reverse $ takeOff $ n - 2) ++ takeOff (n - 1) -- (2) printActions :: [Action] -> IO () printActions acts = sequence_ $ zipWith printSingleAct [1,2..] acts where printSingleAct stepNo act = putStr (show stepNo) >> putStr ": " >> putStrLn (show act) main :: IO () main = takeOff.read <$> getLine >>= printActions
僅有兩處須要說明一下:
flipAction
,使用了模式匹配來識別輸入參數是由哪一個值構造子構造的並綁定其中的整型值到名稱n,從而返回相應的反動做。這裏沒有顯式申明函數flipAction
的類型,程序員能夠一眼看出,對於編譯器更不在話下。map flipAction $ reverse $ takeOff $ n - 2
來替代原有的putOn (n - 2)
。這裏全部的$
算符都是爲了節省括號,咱們知道運算符$
擁有最低的優先級,而且是右結合的,那麼該表達式的括號版本就是map flipAction (reverse (takeOff (n - 2)))
,能夠看出首先獲得takeOff (n -2)
的解法的動做序列,而後調用函數reverse
將該動做序列反序,最後由map
函數對反序後的序列裏的每一個動做求取其反動做,結果就是putOn (n-2)
的解法動做序列。這個徹底是推論1的直接代碼映射。目前爲止,一切都不錯。不過有沒有相似上一篇文章中最後那個實現同樣的公式化解法呢?有的。首先讓咱們來作公式推導。設定解法動做序列的序列offSolutions = [S1, S2, S3 ... Sn ...]
,其中的元素Sn
就是拆解n連環的動做序列,注意offSolutions
是一個動做序列的序列,映射成代碼就是[] ([] Action)
或者語法糖[[Action]]
。能夠看出offSolutions
是一個無窮序列,而且S1 = [OFF 1]
,S2 = [OFF 2, OFF 1]
。設offSolutions
從S3
開始的子序列[S3, S4, S5 ...]
爲subS
,因而咱們有offSolutions = [S1, S2] ++ subS
。根據咱們以前的推導Sn
能夠由Sn-1
和Sn-2
以及n
計算而來,n
在這裏是須要的,注意到定理3所描述的算法裏有一步OFF n
,而Sn-1
和Sn-2
均是動做序列,從其中很難提取出所須要的n
的值。咱們把定理3定義爲一個函數f
,那麼就有Sn = f(Sn-2, Sn-1, n)
,展開有subS = [S3, S4, S5 ... Sn ...] = [f(S1, S2, 3), f(S2, S3, 4), f(S3, S4, 5) ... f(Sn-2, Sn-1, n) ...] = g([S1, S2 ... Sn-2 ...], [S2, S3 ... Sn-1 ...], [3, 4 ... n ...]) = g(xs, ys, ns)
。至此能夠看出xs
就是序列offSolutions
,ys
是offSolutions
刨除第1個元素後的子序列,ns
是從3開始的正整數序列。而函數g
是這樣一個函數,它接受3個無窮序列,從3個序列中依次取出1個值做爲參數餵給有3個參數的函數f
將函數f
的結果依次組成序列做爲結果返回。如下程序中由標籤Begin和End界定的部分就是由公式推導直接翻譯出的代碼,另外僅有main
函數的實現略有改動,其他部分均與前一程序相同。
data Action = ON Int | OFF Int deriving (Show) flipAction (ON n) = OFF n flipAction (OFF n) = ON n -- Begin offSolutions = [OFF 1]:[OFF 2, OFF 1]:subS subS = g xs ys ns g = zipWith3 f -- (1) f sn2 sn1 n = sn2 ++ OFF n:(map flipAction $ reverse sn2) ++ sn1 -- (2) xs = offSolutions ys = tail offSolutions ns = [3,4..] -- End printActions :: [Action] -> IO () printActions acts = sequence_ $ zipWith printSingleAct [1,2..] acts where printSingleAct stepNo act = putStr (show stepNo) >> putStr ": " >> putStrLn (show act) main :: IO () main = (offSolutions !!).(subtract 1).read <$> getLine >>= printActions -- (3)
該程序是完整可運行的。讀者能夠自行尋找代碼與公式推導中對應的各個要點。幾點簡要說明以下:
zipWith3
和以前咱們介紹過的zipWith
相似,區別在於zipWith3
處理3個序列,相應的做爲其第一個參數的函數f
必須接受3個參數。在Haskell的Data.List庫中預約義了zipWith
,zipWith3
直至zipWith7
。Haskell中認爲參數過多的函數難以操縱或重用,多於7個參數的函數不多見。若是的確有必要zip
8個或更多的序列,在Control.Applicative
庫中有ZipList
類型提供了完美地解決方案,其細節不在本文的討論範圍。sn2
就是公式推導中的Sn-2
,sn1
是Sn-1
。(offSolution !!).(subtract 1).read
,read
將輸入的String
轉換爲Int
,偏函數(subtract 1)
將輸入減1,這裏不能使用(-1)
,編譯器會解釋爲負一。之因此要減一是由於!!
所接受的索引是從零開始的,offSolutions
的第n個元素是拆解n連環的解,其索引爲n-1
。最後偏函數(offSolutions !!)
接受一個整型的索引值,返回offSolutions
在該索引處的元素。最後讓咱們來簡化這段代碼,簡化的機會在於如下三點:
[]
,定理3所肯定的算法仍然有效,就是說咱們的遞歸算法能夠擴展到0連環的狀況,這樣咱們就不須要在索引值上減1。ON
而負數表示OFF
,這樣取負值的操做就成爲求反動做的函數,雖然在輸出打印的時候須要額外作判斷,但節約了Action
類型的定義還有求反動做的函數,仍是能夠節約一些代碼的。offSolutions = []:[-1]: zipWith3 (\sn2 sn1 n -> sn2 ++ n:(map negate.reverse $ sn2) ++ sn1) offSolutions (tail offSolutions) [-2, -3 ..] printActions :: [Int] -> IO () printActions acts = sequence_ $ zipWith printSingleAct [1,2..] acts where printSingleAct stepNo act = putStr (show stepNo) >> putStr (if act > 0 then ": ON " else ": OFF ") >> (putStrLn.show.abs) act main :: IO () main = (offSolutions !!).read <$> getLine >>= printActions
這段代碼就沒多少好解釋的了,注意函數negate
返回給定參數的負值,而abs
則是取絕對值的函數。
依照最初的設想,本文就是系列中的最後一篇文章了,關於九連環的問題咱們都已解決。而關於Haskell的內容還多,路也還長,從此會有其它的文章或是系列來討論和展示Haskell的能力和魅力。另外我真心但願能和各位高手,讀者有交流和互動,若是在評論區中出現有趣的問題和討論的話,筆者不排斥在系列中再整理一篇額外的文章。