【问题标题】:How to avoid recomputation of a pure function with the same parameters?如何避免重新计算具有相同参数的纯函数?
【发布时间】:2014-11-15 19:26:46
【问题描述】:

我有以下函数将计数列表转换为离散概率密度函数:

freq2prob l = [ (curr / (sum l))) | curr <- l ]

不幸的是,(sum l) 是为每个 l 元素计算的,这使得计算复杂度不必要地高。

处理这个问题的最简洁、优雅、“haskellic”的方法是什么?

【问题讨论】:

  • 很简单:freq2prob l = [ curr / s | let s = sum l, curr &lt;- l ]
  • 谢谢!但尚不清楚为什么let s = sum l 存在并且不在列表理解之外。 curr 值在每次迭代时被替换。分配给s 的位置让人认为它也属于每个迭代。
  • 它是之前的“迭代规范”,所以它只做了一次。你甚至可以写[1 | 1&lt;0],里面没有任何迭代。

标签: haskell sum complexity-theory probability memoization


【解决方案1】:

很简单:

freq2prob l = [ curr / s | let s = sum l, curr <- l ] 

你也可以把它放在列表理解之外:freq2prob l = let s = sum l in [ curr / s | curr &lt;- l ](注意in)。这实际上是相同的计算。

那是因为第一个本质上是翻译成

freq2prob :: (Fractional a) => [a] -> [a]
freq2prob l = [ curr / s | let s = sum l, curr <- l ] 
 = do
     let s = sum l
     curr <- l
     return (curr / s)
 = let s=sum l in
   l >>= (\curr -> [curr / s])
   -- concatMap (\curr -> [curr / s]) l
   -- map (\curr -> curr / s) l

第二个,显然是相同的代码,

freq2prob l = let s = sum l in [ curr / s | curr <- l ]
 = let s = sum l in
   do
     curr <- l
     return (curr / s)
 = let s=sum l in
   l >>= (\curr -> [curr / s])

【讨论】:

  • 很好的解释,像往常一样。
【解决方案2】:

我们可以为此使用 let 语句或 where 子句:

freq2prob l = let s = sum l in 
              [ curr / s | curr <- l ]

freq2prob l = [ curr / s | curr <- l ] 
    where s = sum l

但是使用比列表推导式更高阶的函数会更惯用,因为你对每个元素都做同样的事情:

freq2prob l = map (/sum l) l

除法函数(/sum l)中的sum l只会被计算一次。

这是因为在评估map f xs 时,编译器不会犯创建函数f 的多个副本以分别评估的基本错误;这是一个 thunk,每次需要它的地方都会指向它。

作为一个简单而直接的测试,我们可以调查 ghci 中的粗略计时统计数据,以确定重复使用相同的函数或每次略有不同的函数是否明显更快。首先我会检查 sum 的结果是否通常缓存在 ghci 中:

ghci> sum [2..10000000]
50000004999999
(8.31 secs, 1533723640 bytes)
ghci> sum [2..10000000]
50000004999999
(8.58 secs, 1816661888 bytes)

因此您可以看到它没有被缓存,并且这些粗略的统计数据存在一些差异。 现在让我们每次都乘以同样复杂的东西:

ghci> map (* sum [2..10000000]) [1..10]
[50000004999999,100000009999998,150000014999997,200000019999996,250000024999995,300000029999994,350000034999993,400000039999992,450000044999991,500000049999990]
(8.30 secs, 1534499200 bytes)

所以(包括一点差异,使用map 将十个数字乘以sum [2..10000000] 所花费的时间几乎与乘以一个数字所用的时间完全相同。十对数字相乘几乎不需要任何时间。所以 ghci (解释器,甚至不是优化编译器)没有引入同一计算的多个副本。

这不是因为 ghci 很聪明,而是因为惰性求值是纯函数式编程的一个很好的特性,它永远不会做不必要的工作。在大多数编程语言中,很难优化在各处传递冗长的计算而不是将其结果保存在变量中。

现在让我们将其与每次进行稍微不同的计算进行比较,每次计算时我们加起来的数字会稍微少一些。

ghci> map (\x -> sum [x..10000000]) [1..10]
[50000005000000,50000004999999,50000004999997,50000004999994,50000004999990,50000004999985,50000004999979,50000004999972,50000004999964,50000004999955]
(77.98 secs, 16796207024 bytes)

嗯,这花费了大约十倍的时间,正如我们预期的那样,因为现在我们要求它每次都做不同的事情。我可以为您验证,这对每个数字都有暂停,而当我们没有更改计算成本高的数字时,它只评估了一次,并且暂停在第一个数字之前,其余的很快出现。

【讨论】:

  • 您能否解释一下为什么(/sum l) 中的sum l 保证会被缓存?
  • @WillNess 因为美妙的懒惰评估。解释器不会引入同一常量表达式sum l 的多个副本。我已经用更完整的解释编辑了答案。
  • “解释器不会引入同一个常量表达式的多个副本sum l”,而是引入函数(如(\x-&gt; x / sum l)"are not memoized by GHC"see also)。是的,谢谢。
  • @WillNess 是的。 x / sum l 不是常量表达式,这就是它不起作用的原因。我认为 Constant Applicative Form 正是我们正在寻找的标准。
猜你喜欢
  • 2014-11-13
  • 2015-12-20
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2016-04-19
  • 2016-11-21
相关资源
最近更新 更多