在我的计算机科学课上,我们使用Haskell来解决"皇后"你必须在nxn板上找到n个皇后的所有可能位置的问题。这是我们给出的代码:
queens n = solve n
where
solve 0 = [ [] ]
solve k = [ h:partial | partial <- solve(k-1), h <- [0..(n-1)], safe h partial ]
safe h partial = and [ not (checks h partial i) | i <- [0..(length partial)-1] ]
checks h partial i = h == partial!!i || abs(h-partial!!i) == i+1
然而,当我第一次进入它时,我意外地在解决k中交换了顺序,发现它仍然提供了正确的解决方案,但需要更长的时间:
queens n = solve n
where
solve 0 = [ [] ]
solve k = [ h:partial | h <- [0..(n-1)], partial <- solve(k-1), safe h partial ]
safe h partial = and [ not (checks h partial i) | i <- [0..(length partial)-1] ]
checks h partial i = h == partial!!i || abs(h-partial!!i) == i+1
为什么第二个版本需要更长的时间?我的思维过程是第二个版本在每一步都进行递归,而第一个版本只进行一次递归然后回溯。这不是一个家庭作业问题,我只是好奇,觉得它会帮助我更好地理解这门语言。
答案 0 :(得分:4)
我的猜测是你的第一个版本执行深度优先遍历,而第二个版本执行树的广度优先遍历(see Tree Traversal on Wikipedia)。
随着问题的复杂性随着电路板的大小而增加,第二个版本使用越来越多的内存来跟踪树的每个级别,而第一个版本会快速忘记它访问的上一个分支。
管理内存需要花费很多时间!
通过enabling profiling,您可以看到Haskell运行时如何与您的函数一起运行。
如果您比较通话次数,它们严格相同,但第二种版本仍然需要更多时间:
COST CENTRE MODULE no. entries %time %alloc %time %alloc
MAIN MAIN 44 0 0.0 0.0 100.0 100.0
main Main 89 0 0.3 0.0 0.3 0.0
CAF Main 87 0 0.0 0.0 99.7 100.0
main Main 88 1 0.2 0.6 99.7 100.0
queens2 Main 94 1 0.0 0.0 55.6 48.2
queens2.solve Main 95 13 3.2 0.8 55.6 48.2
queens2.safe Main 96 10103868 42.1 47.5 52.3 47.5
queens2.checks Main 100 37512342 10.2 0.0 10.2 0.0
queens1 Main 90 1 0.0 0.0 43.9 51.1
queens1.solve Main 91 13 2.0 1.6 43.9 51.1
queens1.safe Main 92 10103868 29.3 49.5 41.9 49.5
queens1.checks Main 93 37512342 12.7 0.0 12.7 0.0
查看堆配置文件会告诉您实际发生了什么。
第一个版本有一个小而且常量的堆使用:
虽然第二个版本有一个巨大的堆使用,它也必须面对垃圾收集(看看峰值):
答案 1 :(得分:4)
简单地说,
[ ... | x <- f 42, n <- [1..100] ]
会将f 42
评估为一次列表,对于此列表中的每个元素x
,它会生成从n
到1
的所有100
。取而代之的是,
[ ... | n <- [1..100], x <- f 42 ]
将首先从n
生成1
到100
,并为每个人调用f 42
。所以f
现在被调用100次而不是一次。
这与使用嵌套循环时命令式编程中发生的情况没有什么不同:
for x in f(42): # calls f once
for n in range(1,100):
...
for n in range(1,100):
for x in f(42): # calls f 100 times
...
您的算法是递归的这一事实使得此交换特别昂贵,因为每次递归调用都会累积额外的成本因子(100以上)。
您还可以尝试将f 42
的结果绑定到某个变量,这样就不需要重新计算它,即使您以相反的方式嵌套它也是如此:
[ ... | let xs = f 42, n <- [1..100], x <- xs ]
请注意,这会将整个xs
列表保留在整个循环的内存中,从而防止它被垃圾回收。实际上,xs
将对n=1
进行全面评估,然后重新用于n
的更高值。
答案 2 :(得分:1)
查看核心,第一个函数在核心中生成单个函数,这是尾递归(常量堆栈空间 - 非常快速且非常好的函数。感谢GHC!)。但是,第二个生成两个函数:一个用于执行内循环的单个步骤;和第二个看起来像
的功能 loop x = case x of { 0 -> someDefault; _ -> do1 (loop (x-1)) }
此函数可能不具备性能,因为do1
必须遍历整个输入列表,并且每次迭代都会将新元素附加到列表中(意味着do1
的输入列表长度单调增长) 。而快速版本的核心功能是直接生成输出列表,而不必处理其他列表。我相信很难推断出列表理解的表现,所以首先将函数翻译为不使用它们:
guard b = if b then [()] else []
solve_good k =
concatMap (\partial ->
concatMap (\h ->
guard (safe h partial) >> return (h:partial)
) [0..n-1]
) (solve $ k-1)
solve_bad k =
concatMap (\h ->
concatMap (\partial ->
guard (safe h partial) >> return (h:partial)
) (solve $ k-1)
) [0..n-1]
转换是相当机械的,并在Haskell报告中的某处详细说明,但基本上<-
变为concatMap
,条件变为guard
s。现在可以更容易地看到正在发生的事情 - solve_good
一次进行递归调用,然后在递归创建的列表上进行concatMap
。但是,solve_bad
使外部concatMap
内的递归调用在内,这意味着它可能(可能)为[0..n-1]
中的每个元素重新计算。请注意,solve $ k-1
没有语义原因在内部concatMap
- 它不依赖于concatMap绑定的值(h
变量)因此它可以安全地提升到绑定h
的concatMap之上(如solve_good
中所做的那样)。