Haskell編程解決九連環(3)— 詳細的步驟

摘要

在本系列的上一篇文章《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

定理1takeOff(1)的解法步驟序列爲[OFF 1]putOn(1)的解法步驟序列爲[ON 1]
定理2takeOff(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)
推論1takeOff(n)的解法步驟序列和putOn(n)的解法步驟序列互爲逆反序列。
推論2takeOff(n)的解法步驟序列和putOn(n)的解法步驟序列含有的步驟數目相等。
推論3:對於任何整數m, n,若是m>n,那麼第m環的狀態(裝上或是卸下)不影響takeOff(n)或者putOn(n)的解,同時解決takeOff(n)或者putOn(n)問題也不會改變第m環的狀態。程序員

照例,咱們從一個命令式語言的實現開始。這有助於熟悉命令式編程語言的小夥伴們理解。也爲隨後的的Haskell實現設定結果規範。算法

Python 實現

由於涉及到大量的輸入輸出,此次咱們試着構造一個完整的程序。在該程序中提供了主函數入口,使得下面的代碼不只能在交互式環境中運行和測試,也能夠在操做系統的命令行環境直接調用。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)

這裏咱們再也不贅述關於遞歸的基本條件或者遞歸的拆分算法部分。有興趣的讀者能夠參閱本系列的前兩篇文章。有一些新出現的東西值得關注。首先命令式語言並不由止任何函數具備反作用,咱們能很容易地在任何地方作輸入輸出。在代碼中你們也能夠看到函數的申明沒有任何的區別,可是咱們可以在迭代或是遞歸的過程當中打印相應的結果。下面咱們沿着在代碼中標註的序號作一些解釋。編程

  1. 雖然本文的目的是展現Haskell代碼的優美,可是我也喜歡Python,並不打算故意寫出醜陋的Python代碼。把重複出現的代碼提煉成一個函數在全部編程語言裏都是一個好習慣。參數stepNo是一個整型值,就是當前輸出動做在整個解序列中的序號。該函數的輸出多是「10: ON 6」 或者 「123: OFF 9」。
  2. 輸出詳細步驟的要求讓咱們不得不關注於不一樣的動做,根據定理咱們選擇了takeOffputOn的交叉遞歸,這是比較簡單直接的方案。因爲咱們選擇了在遞歸的過程當中逐步輸出的方案,步數的計數器(stepCount)須要被傳入而且向下傳遞。
  3. 這是Python中主函數的入口,當咱們在操做系統層面上將這段代碼做爲一個獨立的程序運行時,啓動的進程將從這個入口進入並開始執行代碼。
  4. 從標準輸入設備讀取一行輸入,並轉化爲一個整數。這裏沒有考慮輸入檢驗的部分。一般在產品代碼裏,對輸入有效性的檢驗是必不可少的。在這裏有一個當即可見的風險,若是用戶輸入一個負數的話,這段程序將會陷入無窮的遞歸直至棧溢出。
  5. 調用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 實現 (1)

相應的,咱們也將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中特有的東西:數據結構和算法

  1. 和Python中同樣,將輸出一個步驟的代碼提取出來成爲一個函數。細心的讀者也許注意到這裏將參數stepNo移到了最後,在上一節的Python程序中,stepNo因爲會首先輸出,很天然地被做爲第一個參數,在Python中參數的順序並不重要,只要在調用的地方按照正確的順序傳入參數便可。在Haskell中這個改動是通過仔細考慮的,其做用在稍後的代碼簡化中咱們就能夠看到。另外請注意do關鍵字,它在這段代碼裏幾乎無處不在。其實do是Monad的語法糖,Monad是Haskell中的一個很高層次的抽象,能夠被理解爲一個抽象的計算結構或計算語境,例如咱們熟知的列表就具備Monad所歸納的全部特性和要求的行爲方式,咱們能夠說列表是一種Monadic的結構。Monad最重要的做用之一是解決計算順序的問題。咱們知道Haskell本質上是lambda演算,在lambda演算中嵌套的表達式隱含了必定的求值順序,例如y=x+1,若是咱們須要求值y,那麼必須先要對x求值。Monad的抽象幫助咱們以順序的方式來表達事實上是嵌套的lambda表達式。回到do關鍵字,這裏咱們能夠簡單地理解爲do後面一般跟隨了多個動做(或者表達式),do事實上將它們「粘合」成一個大的動做,當這個「大」動做被要求執行的時候,其所包含的全部動做將會按照順序依次執行併產生結果。
  2. putStr是Haskell的標準輸出函數,它接受一個字符串參數,執行的時候將該字符串打印到標準輸出上。show函數是一個多態的函數,是類型類(Type Class)Show的成員函數,在Haskell裏類型類比較相似於Java裏的接口(Interface)。簡單地說show函數接受一個參數,並將其轉換爲字符串,任何能夠被show函數轉換的類型必須實現(或者繼承)Show類型類而且提供(或者繼承)show的實現。Haskell裏的基礎數值類型以及列表,元組等類型均是Show類型類的實例。
  3. 在調用了屢次putStr後咱們調用了一次putStrLn,該函數跟putStr惟一的區別在於輸出字符串後再輸出一個新行符(\n)。能夠看到咱們利用類型轉換函數show和輸出函數putStrputStrLn「拼湊」了一行輸出。Haskell有第三方的庫提供了printf函數,其功能與C/C++中的printf相似,功能上一點也不弱。
  4. 和Python代碼同樣,咱們選擇了takeOffputOn交叉遞歸。因爲咱們要在這兩個函數中作輸出,這兩個函數的結果類型都必須包含IO結構。咱們知道輸入輸出是具備反作用的(打開文件,讀取鍵盤敲擊,改變終端顯示甚至拆毀房子,引爆炸彈都是IO的反作用),而Haskell一般的函數不容許有反作用,也就是說一般的函數不被容許作輸入輸出,這些函數也被稱爲純粹的函數。在Haskell中IO被交給非純粹(Impure)的函數來完成,這類函數必須有相似IO a的結果類型,其中a是一個具體類型例如IntString甚至是函數類型例如IO (String -> Int)。一個IO a類型的值能夠被看做一個動做,當該動做被成功執行(咱們一般說執行一個IO動做,求值一個表達式,其實這兩者的意義是等價的或是至關的)時,將會產生一個具備a類型的結果,該結果能夠被從IO結構中取出並被後續程序使用。這裏takeOff函數就能夠被理解爲接受兩個Int型的參數(連環數目n和當前步驟計數器的數值),而後產生一個IO Int的動做,當該動做被執行時(也能夠認爲是被求值),會產生一系列的輸入輸出(反作用),當這一切成功後,該動做產生一個Int值做爲動做最後的結果。在takeOffputOn函數中這個Int的結果其實是作了一系列輸出後步驟計數器的下一個值。咱們知道在Haskell的函數裏,咱們不能改變一個全局的狀態,因此咱們沒法像在Python中那樣使用相似next的調用在得到計數器當前值的同時將其狀態加一以備後用,這裏只能將計數器的新值返回,由調用者負責傳遞到下一個函數調用中。
  5. 這一行使用的是哨兵(Guard)的機制,其語法是函數參數模式匹配後面緊跟一個或多個形如「| <條件表達式> = <函數體>」的定義結構,首先是一個豎線|,以後的條件表達式能夠對參數以及在模式匹配中綁定的名稱作判斷,而後是等號和函數體。咱們在上一篇文章中提到過,要對輸入的參數分情形作判斷,一般的機制是模式匹配和哨兵。這兩種機制是互補的,常常被結合在一塊兒使用。通常的,一個函數的實現能夠有一個或者多個模式匹配,每一個模式匹配能夠沒有,有一個或者多個哨兵。模式匹配和哨兵都是有順序的。在運行時,當一個函數被求值的時候,Haskell使用傳入的參數依照定義順序嘗試模式匹配,若是某個模式匹配成功,則依照定義順序嘗試該模式的哨兵,若是某個哨兵的條件表達式求值爲True,則以該哨兵等號後的函數體做爲該函數本次調用的求值表達式爲函數求值,若是該模式中的全部哨兵均返回False,則移到下一個模式繼續嘗試匹配。若是嘗試完全部的模式和哨兵仍然沒有匹配的模式且求值爲True的哨兵組合,那麼該次函數求值失敗,會報告運行錯誤。模式匹配和哨兵在能力上仍是有一些區別的,模式匹配可以匹配數值的結構以及常量,而哨兵一般不能判斷數值的結構(除非藉助一些輔助函數,而那些輔助函數實際上也是經過模式匹配返回布爾值而已),但能判斷數值範圍,複雜的布爾條件以及條件組合,固然哨兵也能處理常量。例如咱們用模式匹配判斷一個參數是不是一個形如(a,b)的二元元組,而用哨兵判斷一個年份是不是閏年 mod n 4 == 0 && mod n 100 /= 0 || mod n 400 == 0。回到咱們的代碼,這裏咱們對連環數目n作判斷,看它是否爲常量1或2,對此模式匹配和哨兵均有能力完成。在此爲了展現二者的語法,咱們主要使用哨兵來實現函數takeOff而徹底使用模式匹配來實現putOn
  6. 在Haskell裏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的狀況下卻沒有,那是由於doblock的最後一個動做takeOffputOn的類型就是IO Int,其中的值就是咱們打算做爲返回值的步驟計數器的最新值,因此這裏不必再經過<-算符提取IO結構裏的值,而後再經過return放入到一樣的IO結構中了。關於算符<-咱們會在隨後討論。
  7. otherwise是一個老是爲True的哨兵,其邏輯至關於if ... then ... else ...中的else或者switch/case中的default分支。
  8. 操做符<-作的事情和return剛好相反,其右邊是一個帶有結構的值,<-從結構中取出值而後綁定到左邊的名稱上(能夠像在命令式語言中那樣理解爲從結構中取出值賦給左邊的「變量」)。這裏<-的右邊是一個IO Int的值,從其中取出那個Int型的值綁定到名稱newStepNo,那麼咱們能夠肯定名稱(能夠理解爲「變量」)newStepNo 就具備Int類型。請注意=<-的區別,=不會作任何附加操做,僅僅是一個名稱綁定,若是b是一個類型爲IO Int的值或表達式,那麼當咱們寫a = ba就具備IO Int的類型,而若是咱們寫a <- b,那麼a必是Int型的。
  9. 這就是Haskell的主函數。主函數必須是IO類型的,其類型能夠是IO ()或是IO Int,能夠理解爲分別對應了C/C++中的void mainint main。這裏的()是一個類型,就是以前咱們提到過的零元的元組,零元元組的可能值只有一個,也是(),這個值又被稱做unit。Haskell裏全部的函數都必須有類型,也就是說必須有返回結果,當咱們的確須要表達沒有返回值或者返回值不重要的時候(這在作純粹的輸出的時候比較常見)就可使用()做爲返回值以及其類型。在代碼的最後一行有return (),那裏的()就是零元元組的值unit。
  10. getLine是Haskell的標準輸入函數之一,它從標準輸入設備上讀取一行數據,直至回車,將讀到的數據以字符串返回,其類型是IO Stringread函數是Read類型類的成員函數,它所作的事情跟上面提到的show剛好相反,read接受一個字符串,將其轉換成其它類型,例如IntFloat甚至是列表,元組等有結構的類型。操做符<$>是函數fmap的中綴版本,兩者徹底等價。fmapFunctor類型類的成員函數。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使代碼簡潔了一些。
  11. 拆卸n連環是咱們的目標問題,遞歸開始的地方。一樣設定步驟序號的起始值爲1。
  12. 這是編譯器所要求的,由於咱們的主函數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代碼顯得異常笨拙。其中至少有兩個比較明顯的問題:

  1. 數學運算和輸入輸出混在一塊兒,這對於函數式編程天生不友好。特別在Haskell裏,講究把純粹(pure)和非純粹(impure)的部分徹底隔離開來,以數學建模和公式推演的方式來完成純粹部分的理論系統的創建,而使用傳統的調試,測試的方法來驗證和保證非純粹部分的正確性。把純粹非純粹部分隔離開的作法不但能提升程序的模塊化水平,讓咱們更加易於驗證算法的正確性,並且常常能幫助咱們大幅度地簡化代碼。
  2. 有很大一部分代碼用於不停地在IntIO 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 nputOn 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)

Haskell 實現 (2)

如今咱們來把純粹和非純粹的部分分開,也就是把算法和輸出分開。思路是takeOff nputOn n均返回一個動做序列,再交由專門的輸出函數去作輸出。爲了表示動做和動做序列,咱們須要首先定義一個數據結構Action,在Haskell裏用戶定義的數據結構也和原生的數據類型同樣被稱爲類型(Type)。Haskell裏跟定義新類型有關的語法有3種,分別由關鍵字typenewtypedata界定。關於這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)

能夠看到咱們算法的部分takeOffputOn是純粹的函數,沒有反作用,其實現也已經至關的公式化了。全部的輸出交給了非純粹的函數printActions去作,包括爲動做加上序號的工做。讀者能夠將這段代碼存成一個Haskell源文件例如rings2.hs,如前啓動運行,能夠看到其輸出和以前的代碼徹底一致。讓咱們來走讀一下代碼自己:

  1. Action是咱們爲描述動做定義的類型。這裏使用了關鍵字data是由於咱們有多於一個的值構造子(Value Constructor),而typenewtype不能在這種狀況下使用。另外請注意Haskell的類型名稱必須以大寫字母開頭。
  2. Action類型有兩個值構造子ONOFF,Haskell容許一個類型有多個值構造子,值構造子定義間用操做符|隔開,寓意值構造子之間是互斥的。這個很容易理解,當咱們構造一個該類型的值的時候,必須且僅能選用一個值構造子。ONOFF均接受一個Int類型的參數。值構造子與普通的函數無異,惟一的區別僅在於值構造子的名稱必須以大寫字母開頭而普通函數必須以小寫字母開頭(運算符是另外一種狀況)。值構造子做爲函數,它們接受參數並返回一個相關類型的值。若是在ghci中加載該代碼文件或是直接鍵入Action的定義,可使用命令:type ON來查看ON的類型,其結果是Int -> Action。若是在命令式語言中定義相似的數據結構,能夠定義一個structure(C/C++)或是一個class(C++/Java/Python),並擁有兩個成員,其一爲一個標誌(Flag)用於區分值的類型是ON仍是OFF,另外一個成員則用於存放構造時經過參數傳入的整型值。
  3. Haskell中有不少基礎類類型能夠被自動繼承,實際上就是由編譯器經過必定的規則自動爲咱們編寫實現,這其中就包括Show。咱們知道類類型Show提供了成員函數show用於將該類型的一個值轉換爲字符串。在這裏Haskell自動生成的實現將把Action的值轉換爲相似「ON 5」、「OFF 8」同樣的字符串,徹底符合咱們的要求,好巧!
  4. 爲動做編排序號的工做交給了專門的輸出函數去作,takeOffputOn就只有一個參數了,就是目標問題圓環的數目n,根據算法思路,其返回值也變成了一個動做序列。
  5. 操做符++接受兩個類型相同的列表a和b,將兩個列表聯結在一塊兒並返回一個新的列表,結果列表中前面依次是a中的全部元素,而後依次是b中全部的元素。操做符:是列表的一個值構造子,它接受一個單個的元素a和一個列表l,將a插入到l的頭上,返回這個包含元素a的新列表。能夠看到這行代碼就是依據定理3將較大的問題拆分紅幾個較小的問題,而後將全部子問題的結果合併爲目標問題的結果。幸運的是大多數操做符以及函數調用的優先級都剛好符合咱們的需求,這裏僅有n-1n-2須要加上括號。
  6. printActions處理一個動做列表,將全部的動做加上序號,打印到輸出設備上。因此其名稱中使用了複數。
  7. 這行的要點有好幾處,須要詳細說明:

    • [1,2..]是一個無窮的正整數序列,做爲動做的序號使用。
    • zipWith是咱們的老朋友了,在上一篇文章中咱們見過。這裏zipWith將序號序列([1,2..])和動做序列(acts)依次配對,而後將配好的值對做爲參數傳給函數printSingleAct,再將printSingleAct的全部結果依次放入一個列表中做爲結果返回。函數printSingleAct將隨後定義,它接受一個整型的序號和一個動做,將它們拼接打印成一行輸出。這裏printSingleAct的類型是Int -> Action -> IO ()。所以函數zipWth的結果就是[IO ()],是一個IO動做的序列。
    • sequnce_函數後面有比較抽象的數學理論的支持和演化歷史。這裏咱們能夠簡單地理解爲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_
  8. where語法是Haskell中申明定義「局部」名稱的方法之一。所謂「局部」名稱能夠是常量或是函數,僅在包含該where子句的表達式中可見,語法上where子句緊跟其起做用的外圍表達式。這裏咱們經過where子句定義了一個(在where子句裏能夠定義多個名稱)「局部」函數printSigneActprintSingleAct的實現代碼很直接,經過putStrputStrLnshow函數結合傳入的參數「拼湊」了一行輸出。細節能夠參閱上一節。
  9. 此次咱們選擇「穿越」進IO結構外殼的是組合函數takeOff.read,咱們知道getLine封裝在IO內的是一個字符串String。「穿越」進去的組合函數首先起做用的是read函數,它將String轉換爲Int,而後這個Int值被餵給組合中的下一個函數takeOfftakeOff接受一個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

僅有兩處須要說明一下:

  1. 定義並實現了求一個動做的反動做的函數flipAction,使用了模式匹配來識別輸入參數是由哪一個值構造子構造的並綁定其中的整型值到名稱n,從而返回相應的反動做。這裏沒有顯式申明函數flipAction的類型,程序員能夠一眼看出,對於編譯器更不在話下。
  2. 咱們使用了map flipAction $ reverse $ takeOff $ n - 2來替代原有的putOn (n - 2)。這裏全部的$算符都是爲了節省括號,咱們知道運算符$擁有最低的優先級,而且是右結合的,那麼該表達式的括號版本就是map flipAction (reverse (takeOff (n - 2))),能夠看出首先獲得takeOff (n -2)的解法的動做序列,而後調用函數reverse將該動做序列反序,最後由map函數對反序後的序列裏的每一個動做求取其反動做,結果就是putOn (n-2)的解法動做序列。這個徹底是推論1的直接代碼映射。

Haskell 實現 (3)

目前爲止,一切都不錯。不過有沒有相似上一篇文章中最後那個實現同樣的公式化解法呢?有的。首先讓咱們來作公式推導。設定解法動做序列的序列offSolutions = [S1, S2, S3 ... Sn ...],其中的元素Sn就是拆解n連環的動做序列,注意offSolutions是一個動做序列的序列,映射成代碼就是[] ([] Action)或者語法糖[[Action]]。能夠看出offSolutions是一個無窮序列,而且S1 = [OFF 1]S2 = [OFF 2, OFF 1]。設offSolutionsS3開始的子序列[S3, S4, S5 ...]subS,因而咱們有offSolutions = [S1, S2] ++ subS。根據咱們以前的推導Sn能夠由Sn-1Sn-2以及n計算而來,n在這裏是須要的,注意到定理3所描述的算法裏有一步OFF n,而Sn-1Sn-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就是序列offSolutionsysoffSolutions刨除第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)

該程序是完整可運行的。讀者能夠自行尋找代碼與公式推導中對應的各個要點。幾點簡要說明以下:

  1. 預約義函數zipWith3和以前咱們介紹過的zipWith相似,區別在於zipWith3處理3個序列,相應的做爲其第一個參數的函數f必須接受3個參數。在Haskell的Data.List庫中預約義了zipWithzipWith3直至zipWith7。Haskell中認爲參數過多的函數難以操縱或重用,多於7個參數的函數不多見。若是的確有必要zip8個或更多的序列,在Control.Applicative庫中有ZipList類型提供了完美地解決方案,其細節不在本文的討論範圍。
  2. sn2就是公式推導中的Sn-2sn1Sn-1
  3. 此次「穿越」進IO結構的是組合函數(offSolution !!).(subtract 1).readread將輸入的String轉換爲Int,偏函數(subtract 1)將輸入減1,這裏不能使用(-1),編譯器會解釋爲負一。之因此要減一是由於!!所接受的索引是從零開始的,offSolutions的第n個元素是拆解n連環的解,其索引爲n-1。最後偏函數(offSolutions !!)接受一個整型的索引值,返回offSolutions在該索引處的元素。

最後讓咱們來簡化這段代碼,簡化的機會在於如下三點:

  1. 僅使用一次的名稱能夠就地展開,命名函數轉換爲lambda版本。
  2. 合乎直覺地,若是咱們認爲拆解0連環的全部步驟爲空序列[],定理3所肯定的算法仍然有效,就是說咱們的遞歸算法能夠擴展到0連環的狀況,這樣咱們就不須要在索引值上減1。
  3. 還有一個不尋常的技巧(Trick,花招,爛招?),咱們能夠用一個有符號的整數來表示一個動做,正數表示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的能力和魅力。另外我真心但願能和各位高手,讀者有交流和互動,若是在評論區中出現有趣的問題和討論的話,筆者不排斥在系列中再整理一篇額外的文章。

相關文章
相關標籤/搜索