Haskell编程解决九连环(2)— 多少步骤?

摘要

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

定理与推论

上一篇文章中咱们罗列了一些定理与推论,这些都是创建递归模型的理论基础。这里再次将它们罗列以下,用于指导接下来的编程实现。程序员

定理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环的状态。算法

相信大多数的程序员小伙伴看到这里,已经能用本身擅长的编程语言编码实现了,在此以前让咱们再次明确这些定理和推论在递归模型中的做用。编程

  • 定理1和定理2肯定了递归结束的基本条件
  • 定理3描述了怎样把一个较大的问题拆分红几个较小的问题,从而一步步拆分直至到达递归结束的基本条件
  • 推论3事实上明确了咱们能够在整个过程当中放心地把任何一个较大的问题拆分红多个较小的问题
  • 推论1和推论2使得咱们在某些状况下能使用等价的替代算法,从而简化程序代码。

让咱们先从一个命令式语言的实现开始。segmentfault

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)

Python的实现简单明了,解释一下代码,序号均在代码中以注释的形式标注。数组

  1. 根据推论2,既然咱们咱们只关心步骤的数量,就再也不须要区分takeOff或是putOn,统一使用solve
  2. 定理1所描述的基本条件,拆卸1连环仅需1步
  3. 定理2所描述的基本条件,拆卸2连环须要2步
  4. 根据定理3把较大的问题拆分红较小尺寸的一样问题,注意那个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 实现 (1)

咱们能够用一样的算法和思路来编写Haskell实现:

solve :: Int -> Integer    -- (1)
solve 1 = 1                    -- (2)
solve 2 = 2                    -- (3)
solve n = 2 * solve (n - 2) + solve (n - 1) + 1  -- (4)

沿着在注释中标注的序号,咱们来解释一下代码:

  1. 在Haskell里Int类型是由4字节或8字节表示的有符号整数,是有边界的,咱们知道50连环或者200连环的所需步数将会是一个很大的整数,Int显然是远远不够用的。因此这里使用了Integer做为返回结果的类型,Integer自己没有大小的限制,它可以表现的最大值只受限于电脑的内存容量。
  2. 同Python代码解释2
  3. 同Python代码解释3
  4. 同Python代码解释4。这里必须使用括号将n - 2n - 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中的状态(State)类能够用于处理这种状况。使用状态类的实现代码自己会很简洁,不过因为Haskell的状态事实上是至关高层次的抽象,对于初学者而言理解起来仍是有至关的难度。

若是对于如此简单直接的问题咱们不得不用或者粗陋或者过于高深的方法来解决的话,那倒真不如不学不用Haskell了。幸运的是,Haskell可以作到简洁高效,甚至更好。那接下来让咱们来看一个高效而不失简洁的方法。

Haskell 实现 (2)

若是咱们将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)

照例,让咱们沿着注释中的序号解释一下代码:

  1. steps就是咱们打算构造的数对的序列。它能够被理解为一个没有参数的函数,这样的函数在Haskell里也被称为一个定义(Definition)。再来看看steps的结果类型,事实上也就是steps的类型[(Integer, Integer)]。首先它是一个序列(List,其标志是外层的方括号),而序列中每一个元素是一个形如(Integer, Integer)的元组(Tuple)。在Haskell中形如(a,b,c,..)的数据结构叫作元组(Tuple),跟Python里的Tuple比较相似。元组能够是零元,二元,三元直到多元的,而二元元组又被称做值对(Pair),特别的这里的二元元组所包含的值都是整形的数值,咱们称之为数对。稍后咱们能够在ghci中看到steps的头几个元素就是[(1,2), (2,5), (5,10), (10,21) ...]
  2. 这一行代码构建了steps序列,须要详细说明一下:

    • 预约义的函数iterate接受一个函数f和一个初始值i,将i做为参数喂给f,而后将结果做为参数再喂给f,在这个不断重复的过程当中将历次获得的计算结果扩展为一个无穷的序列。例如iterate (+1) 0就是天然数序列(听说如今的天然数定义包括0),在ghci中求值take 10 $ iterate (+1) 0将会输出[0,1,2,3,4,5,6,7,8,9].
    • 传给iterate做为初始值的值对(1,2)会被做为iterate结果序列中的第1个元素,而后被喂给传入的lambda函数,计算结果将做为iterate结果序列中的第2个元素,以此递推。该初始值就是由1连环和2连环的步数组成的值对。
    • 传给iterate的第一个参数是一个lambda函数(\(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个值。
  3. solve'函数是上一节中的solve的姊妹版本,有着相同的类型。
  4. map函数接受一个函数f和一个序列,将f做用于序列中的每一个元素,将全部结果的序列返回做为结果。其做用至关于C++ STL 算法库中的 for_each,Java的stream.map以及Python中的map函数。这里咱们传给map的函数是fst,其做用是返回二元元组中的第一个值。咱们已经知道steps是这样一个序列[(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连环的解法步数,那个熟悉的大数值,转换为时间的话将比太阳系的历史和将来还长。

Haskell 实现 (3)

简洁高效已经有了,说好的优美呢?若是前一节的实现还不够优美的话,那么怎样的代码才能够被称做为优美呢?咱们这就来看一个优美而又不失简洁高效的实现方法。这也是笔者迄今为止最喜欢的实现方案。之因此说这个方案优美,是由于它的代码就跟数学定义同样公式化。是的,公式化,就这么简单明确。任何的工程问题,一个有效的解决方案的公式化程度越高,它就越优美,反之亦然。
该方案的思路是构建一个解的序列solutions = [F1, F2, F3, F4 ...],其中Fn的值就是拆卸n连环所须要的步数。那么咱们知道:

  • solutions是一个无穷序列,其中包含的元素是整数。
  • F1 = 1,F2 = 2
  • F3由F1和F2计算而来,F4由F2和F3计算而来...通常的当n>2时,Fn由F(n-2)和F(n-1)计算而来,并且计算的方法(公式)是固定的。那么咱们能够定义一个函数,或者等价的一个操做符⊕,使得当n>2Fn = 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)

代码解释以下:

  1. 冒号:是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”的直接表达。
  2. 这行代码是上文中s = xs Θ ys的直接表达。这里咱们使用了自定义的操做符|-|做为数学公式中“Θ”的直接表达。xs,ys以及操做符|-|都将在随后的代码里定义申明,能够注意到在(1)处的s也是先引用然后定义的。在Haskell里因为函数的纯粹性以及名称不可被屡次定义,确保了名称不会有二义性,所以名称或者函数均可以先引用然后定义。事实上Haskeller们常常这么作,先把顶层的表达式写出来,而后再详细定义那些局部的函数和名称。这也是Haskell常常炫耀的优点,那就是尽可能书写让人能看明白的定义,而不是照顾编译器。另外在这里咱们没有申明s,xs或ys的类型。Haskell的编译器和解释器有很强的类型推导能力。例如对于子序列s,根据s在表达式(1)处出现的位置还有solutions的类型,Haskell将推导出s的类型也是[Integer]。其实在Haskell代码里大部分的类型申明都不是必须的,不过对于不太熟练的Haskeller来讲,最好仍是在关键的函数上放上类型申明,这样能够确保编译器所理解的和咱们所设想的一致。
  3. 根据上文中的推导s = [F1, F2, F3, ...] Θ [F2, F3, F4, ...] = xs Θ ysxs = [F1, F2, F3, ...] = solutions
  4. 根据上文中的推导s = [F1, F2, F3, ...] Θ [F2, F3, F4, ...] = xs Θ ysys = [F2, F3, F4, ...],结论是ys序列就是solutions序列刨除第1个元素,预约义的函数tail正是这样一个函数,它接受一个列表,刨除第一个元素,将剩下的子序列做为结果返回。
  5. 操做符|+|就是咱们上文讨论提到的操做符⊕,也是前几节中将F(n-2)和F(n-2)计算成Fn的表达式。在Haskell里能够像定义函数同样方便地定义操做符。函数与操做符之间没有本质的区别,区别仅在于函数缺省的定义和调用方式是前缀的,而操做符的缺省定义和调用方式是中缀的。这里的定义就是中缀的。也能够之前缀的方式定义或调用操做符。这里x |+| y = ...也可写成(|+|) x y = ...,两者彻底等价。
  6. 操做符|-|的定义。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 !!)

嗯,简洁,高效。优美吗?是的,我以为至关的优美。

下一篇文章:《Haskell编程解决九连环(3)— 详细的步骤》

相关文章
相关标签/搜索