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的能力和魅力。另外我真心但愿能和各位高手,读者有交流和互动,若是在评论区中出现有趣的问题和讨论的话,笔者不排斥在系列中再整理一篇额外的文章。

相关文章
相关标签/搜索