为什么这个尾递归Haskell函数更慢?

时间:2014-04-21 13:41:45

标签: arrays haskell recursion tail-recursion

我试图实现一个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

有关上述代码的一些细节:

实施1:应该称为

ps 0 [] a

其中'a'是你的数组。

实施2:应该称为

ps' 0 a

其中'a'是你的数组。

2 个答案:

答案 0 :(得分:6)

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

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

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

答案 1 :(得分:1)

我想提一下,您的功能可以更容易地表达为

drop 1 . scanl (+) 0

通常,最好使用预定义的组合器,如scanl,以支持编写自己的递归方案;它提高了可读性,减少了你不必要地挥霍性能的可能性。

但是,在这种情况下,由于延迟评估,我的scanl版本和原始ps以及ps'有时会导致堆栈溢出: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])