为什么这个解决方案适用于"皇后"在Haskell中,困境的运行速度比另一个慢得多?

时间:2015-12-10 03:54:52

标签: haskell recursion

在我的计算机科学课上,我们使用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

为什么第二个版本需要更长的时间?我的思维过程是第二个版本在每一步都进行递归,而第一个版本只进行一次递归然后回溯。这不是一个家庭作业问题,我只是好奇,觉得它会帮助我更好地理解这门语言。

3 个答案:

答案 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

查看堆配置文件会告诉您实际发生了什么。

第一个版本有一个小而且常量的堆使用:

enter image description here

虽然第二个版本有一个巨大的堆使用,它也必须面对垃圾收集(看看峰值):

enter image description here

答案 1 :(得分:4)

简单地说,

[ ... | x <- f 42, n <- [1..100] ]

会将f 42评估为一次列表,对于此列表中的每个元素x,它会生成从n1的所有100。取而代之的是,

[ ... | n <- [1..100], x <- f 42 ]

将首先从n生成1100,并为每个人调用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中所做的那样)。