【问题标题】:Why does this solution to the "queens" dilemma run so much slower than the other in Haskell?为什么这个“女王”困境的解决方案比 Haskell 中的另一个解决方案运行得慢得多?
【发布时间】:2015-12-10 03:54:52
【问题描述】:

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

但是,第一次输入时,我不小心调换了solve 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

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

【问题讨论】:

  • 真正的问题是这个类在哪里,为什么不使用 SBV?

标签: haskell recursion


【解决方案1】:

简单地说,

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

将评估f 42 一次到一个列表,并且对于该列表中的每个元素x,它将生成从1100 的所有ns。相反,

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

将首先从1100 生成一个n,并为每个人调用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】:

我的猜测是,您的第一个版本执行深度优先遍历,而您的第二个版本执行树的广度优先遍历 (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

查看堆配置文件可以告诉您真正发生了什么。

第一个版本的堆使用量很小且恒定:

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

【讨论】:

    【解决方案3】:

    查看内核,第一个函数在内核中生成一个单个函数,它是尾递归的(恒定的堆栈空间 - 非常快速且非常好的函数。感谢 GHC!)。然而,第二个生成两个函数:一个做内循环的单步;第二个函数看起来像

    loop x = case x of { 0 -&gt; someDefault; _ -&gt; 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 报告中的某处进行了详细说明,但基本上 &lt;- 变为 concatMap 并且条件变为 guards。现在更容易看到正在发生的事情 - solve_good 进行一次递归调用,然后 concatMaps 遍历该递归创建的列表。然而,solve_bad 将递归调用 inside 置于外部concatMap,这意味着它可能(很可能)会为[0..n-1] 中的每个元素重新计算。请注意,solve $ k-1 没有语义原因位于内部 concatMap - 它不依赖于 concatMap 绑定的值(h 变量),因此它可以安全提升到绑定h 的 concatMap 上方(就像在solve_good 中所做的那样)。

    【讨论】:

      猜你喜欢
      • 2021-05-20
      • 1970-01-01
      • 2021-02-10
      • 1970-01-01
      • 1970-01-01
      • 2021-06-23
      • 1970-01-01
      • 2010-11-14
      • 1970-01-01
      相关资源
      最近更新 更多