【问题标题】:Why is this tail-recursive Haskell function slower ?为什么这个尾递归 Haskell 函数更慢?
【发布时间】:2019-07-14 13:18:04
【问题描述】:

我试图实现一个 Haskell 函数,该函数将整数数组 A 作为输入 并产生另一个数组 B = [A[0], A[0]+A[1], A[0]+A[1]+A[2] ,...]。我知道 Data.List 中的 scanl 可以与函数 (+) 一起使用。我写了第二个实现 在看到 scanl 的源代码后(执行速度更快)。我想知道为什么第一个实现比第二个慢,尽管是尾递归的?

-- This function works slow.
ps s x [] = x
ps s x y  = ps s' x' y'
            where
                s' = s + head y
                x' = x ++ [s']
                y' = tail y

-- This function works fast.
ps' s []   = []
ps' s y    = [s'] ++ (ps' s' y') 
             where 
                s' = s + head y
                y' = tail y

上面代码的一些细节:

实现一:应该叫

ps 0 [] a

'a' 是你的数组。

实现2:应该叫做

ps' 0 a

'a' 是你的数组。

【问题讨论】:

  • 请注意,您处理的是(链接)列表,而不是数组。
  • headtail 是邪恶的。为什么不ps' s (y:ys) = s' : ps' s' ys where s' = s+y?好多了。

标签: haskell recursion tail-recursion


【解决方案1】:

您正在改变++ 的联系方式。在您的第一个函数中,您正在计算 ((([a0] ++ [a1]) ++ [a2]) ++ ...),而在第二个函数中,您正在计算 [a0] ++ ([a1] ++ ([a2] ++ ..))。将一些元素附加到列表的开头是O(1),而将一些元素附加到列表的末尾是列表长度中的O(n)。这导致总体上是线性与二次算法。

您可以通过以相反的顺序构建列表,然后在最后再次反转,或者使用 dlist 之类的东西来修复第一个示例。然而,对于大多数目的来说,第二个仍然会更好。虽然尾调用确实存在并且在 Haskell 中可能很重要,但如果您熟悉像 Scheme 或 ML 这样的严格函数式语言,那么您对如何以及何时使用它们的直觉是完全错误的。

第二个例子更好,很大程度上是因为它是增量的;它会立即开始返回消费者可能感兴趣的数据。如果您刚刚使用双反转或 dlist 技巧修复了第一个示例,则您的函数将在返回任何内容之前遍历整个列表。

【讨论】:

  • 不知道 O(1) 与 O(n) 的问题。但是第二个不会比第一个更快耗尽堆栈空间吗?
  • 不。尾声是一个重击。
  • 更准确地说,第二个例子中的递归是通过一个thunk;第二个函数实际上并不直接调用自己。相反,它返回一个包含第一个元素的列表,该列表包含在一个 thunk 上以计算列表的其余部分。所以不,第二个示例不会更快地耗尽堆栈空间。您确实需要对结果的第一个元素进行一些严格性注释,以保证最佳堆栈使用,因为书面堆栈消耗取决于结果的消耗方式。
【解决方案2】:

我想提一下,你的函数可以更容易地表达为

drop 1 . scanl (+) 0

通常,使用scanl 之类的预定义组合符来编写自己的递归方案是个好主意;它提高了可读性并减少了不必要地浪费性能的可能性。

但是,在这种情况下,我的scanl 版本和您原来的psps' 有时会由于惰性求值而导致堆栈溢出:Haskell 不一定会立即求值(取决于严格性分析) .

如果您执行last (ps' 0 [1..100000000]),您可以看到这一点。这会导致堆栈溢出。您可以通过强制 Haskell 立即评估添加的内容来解决该问题,例如通过定义您自己的严格 scanl

myscanl :: (b -> a -> b) -> b -> [a] -> [b]
myscanl f q []     = []
myscanl f q (x:xs) = q `seq` let q' = f q x in q' : myscanl f q' xs

ps' = myscanl (+) 0

然后,调用last (ps' [1..100000000]) 就可以了。

【讨论】:

  • 更简单,就像scanl1 (+)。请注意,与 foldl1'foldr1 函数不同,scanl1 对于空列表没有问题。
  • 哦,我不知道。我隐含地假设它会抛出异常。谢谢。
猜你喜欢
  • 2011-10-31
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多