在本系列的第一篇文章《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 !!)
嗯,简洁,高效。优美吗?是的,我以为至关的优美。