Haskell foldl'表现不佳(++)

时间:2013-02-18 14:27:20

标签: performance haskell lazy-evaluation strictness weak-head-normal-form

我有这段代码:

import Data.List

newList_bad  lst = foldl' (\acc x -> acc ++ [x*2]) [] lst
newList_good lst = foldl' (\acc x -> x*2 : acc) [] lst

这些函数返回列表,每个元素乘以2:

*Main> newList_bad [1..10]
[2,4,6,8,10,12,14,16,18,20]
*Main> newList_good [1..10]
[20,18,16,14,12,10,8,6,4,2]

在ghci:

*Main> sum $ newList_bad [1..15000]
225015000
(5.24 secs, 4767099960 bytes)
*Main> sum $ newList_good [1..15000]
225015000
(0.03 secs, 3190716 bytes)

为什么newList_bad功能的工作速度比newList_good慢200倍?我知道这不是一个很好的解决方案。但为什么这个无辜的代码运作得如此之慢?

这是什么“4767099960字节”?对于那个简单的操作,Haskell使用4 GiB ??

编译后:

C:\1>ghc -O --make test.hs
C:\1>test.exe
225015000
Time for sum (newList_bad [1..15000]) is 4.445889s
225015000
Time for sum (newList_good [1..15000]) is 0.0025005s

3 个答案:

答案 0 :(得分:15)

关于这个问题存在很多困惑。给出的通常原因是“在列表末尾反复追加需要重复遍历列表,因此O(n^2)”。但在严格评估下,它只会如此简单。在懒惰的评估下,一切都应该被延迟,所以它引出了一个问题,即是否确实存在这些重复的遍历和附加。最后的添加是通过在前面消耗来触发的,并且由于我们在前面消耗的列表越来越短,因此这些操作的确切时间是不清楚的。因此,真正的答案更为微妙,并在懒惰评估下处理特定的减少步骤。

直接的罪魁祸首是foldl'只强制其累加器参数为弱头正常形式 - 即直到暴露出非严格的构造函数。这里涉及的功能是

(a:b)++c = a:(b++c)    -- does nothing with 'b', only pulls 'a' up
[]++c = c              -- so '++' only forces 1st elt from its left arg

foldl' f z [] = z
foldl' f z (x:xs) = let w=f z x in w `seq` foldl' f w xs

sum xs = sum_ xs 0     -- forces elts fom its arg one by one
sum_ [] a = a
sum_ (x:xs) a = sum_ xs (a+x)

所以实际的减少序列是(g = foldl' f

sum $ foldl' (\acc x-> acc++[x^2]) []          [a,b,c,d,e]
sum $ g  []                                    [a,b,c,d,e]
      g  [a^2]                                   [b,c,d,e]
      g  (a^2:([]++[b^2]))                         [c,d,e]
      g  (a^2:(([]++[b^2])++[c^2]))                  [d,e]
      g  (a^2:((([]++[b^2])++[c^2])++[d^2]))           [e]
      g  (a^2:(((([]++[b^2])++[c^2])++[d^2])++[e^2]))   []
sum $ (a^2:(((([]++[b^2])++[c^2])++[d^2])++[e^2]))

请注意,到目前为止我们只执行了O(n)步骤。 a^2消费可立即使用sum,但b^2不是++我们留在这里使用b^2表达式的左嵌套结构。其余部分最好在this answer by Daniel Fischer中说明。它的要点是要获得O(n-1),必须执行O(n-2)步骤 - 并且在此访问之后留下的结构仍然是左嵌套的,因此下一次访问将需要O(n^2)步骤,等等 - 经典的++行为。因此,真正的原因是[x^2] 并未强制或重新排列其论据足以提高效率

这实际上是违反直觉的。我们可以期待懒惰的评估在这里为我们神奇地“做”。毕竟我们只表达了将c^2添加到未来列表的末尾的意图,我们实际上并没有立即执行此操作。因此,这里的时间是关闭的,但它可以正确 - 当我们访问列表时,如果时机正确,则会将新元素添加到其中并立即使用 :if {{1}在b^2(空格)之后将被添加到列表中,比如说,就在消耗之前(及时) b^2,遍历/访问将永远是O(1)

这是通过所谓的“差异列表”技术实现的:

newlist_dl lst = foldl' (\z x-> (z . (x^2 :)) ) id lst

如果你想到这一点,它看起来与你的++[x^2]版本完全相同。它表达了相同的意图,并且也留下了左嵌套结构。

差异,正如Daniel Fischer在同一个回答中所解释的那样,一个(.),当第一次被迫时, 重新排列($)步骤中的右嵌套O(n)结构 1 ,之后每次访问都为O(1),并且附录的时间与所描述的完全一致在上一段中,我们留下了整体O(n)行为。


1 这是一种神奇的,但它确实发生了。 :)

答案 1 :(得分:12)

经典列表行为。

回想:

(:)  -- O(1) complexity
(++) -- O(n) complexity

所以你要创建一个O(n ^ 2)算法,而不是O(n)算法。

对于递增附加到列表的常见情况,请尝试使用dlist,或者在结尾处反向。

答案 2 :(得分:1)

用一些更大的视角补充其他答案:使用惰性列表,在返回列表的函数中使用foldl'通常是一个坏主意。当您将列表缩减为严格(非惰性)标量值(例如,对列表求和)时,foldl'通常很有用。但是当你构建一个列表作为结果时,foldr通常会更好,因为懒惰; :构造函数是惰性的,因此在实际需要之前不会计算列表的尾部。

在你的情况下:

newList_foldr lst = foldr (\x acc -> x*2 : acc) [] lst

这实际上与map (*2)相同:

newList_foldr lst = map (*2) lst
map f lst = foldr (\x acc -> f x : acc) [] lst

评估(使用第一个,map - 更少的定义):

newList_foldr [1..10] 
  = foldr (\x acc -> x*2 : acc) [] [1..10]
  = foldr (\x acc -> x*2 : acc) [] (1:[2..10])
  = 1*2 : foldr (\x rest -> f x : acc) [] [2..10]

这就是Haskell在强制newList [1..10]时评估的内容。如果这个结果的消费者需要它,它只会进一步评估 - 并且只需要满足消费者所需的一小部分。例如:

firstElem [] = Nothing
firstElem (x:_) = Just x

firstElem (newList_foldr [1..10])
  -- firstElem only needs to evaluate newList [1..10] enough to determine 
  -- which of its subcases applies—empty list or pair.
  = firstElem (foldr (\x acc -> x*2 : acc) [] [1..10])
  = firstElem (foldr (\x acc -> x*2 : acc) [] (1:[2..10]))
  = firstElem (1*2 : foldr (\x rest -> f x : acc) [] [2..10])
  -- firstElem doesn't need the tail, so it's never computed!
  = Just (1*2)

这也意味着基于foldr的{​​{1}}也可以使用无限列表:

newList

另一方面,如果使用newList_foldr [1..] = [2,4..] firstElem (newList_foldr [1..]) = 2 ,则必须始终计算整个列表,这也意味着您无法处理无限列表:

foldl'

基于firstElem (newList_good [1..]) -- doesn't terminate firstElem (newList_good [1..10]) = firstElem (foldl' (\acc x -> x*2 : acc) [] [1..10]) = firstElem (foldl' (\acc x -> x*2 : acc) [] (1:[2..10])) = firstElem (foldl' (\acc x -> x*2 : acc) [2] [2..10]) -- we can't short circuit here because the [2] is "inside" the foldl', so -- firstElem can't see it = firstElem (foldl' (\acc x -> x*2 : acc) [2] (2:[3..10])) = firstElem (foldl' (\acc x -> x*2 : acc) [4,2] [3..10]) ... = firstElem (foldl' (\acc x -> x*2 : acc) [18,16,14,12,10,8,6,4,2] (10:[])) = firstElem (foldl' (\acc x -> x*2 : acc) [20,18,16,14,12,10,8,6,4,2] []) = firstElem [20,18,16,14,12,10,8,6,4,2] = firstElem (20:[18,16,14,12,10,8,6,4,2]) = Just 20 的算法需要4个步骤来计算foldr,而基于firstElem_foldr (newList [1..10])的算法则需要21个步骤。更糟糕的是,4步是恒定成本,而21是与输入列表的长度成正比 - foldl'需要300,001步,而firstElem (newList_good [1..150000])需要5步,{{1}就此而言。

另请注意,firstElem (newList_foldr [1..150000]在恒定空间和恒定时间内运行(必须;您需要的不仅仅是恒定时间来分配超过常量空间)。来自严格语言的亲firstElem (newList_foldr [1..]真理 - “firstElem (newList_foldr [1.10])是尾递归并且在恒定空间中运行,foldl不是尾递归并且在线性空间中运行或更糟” - 不是真的Haskell中。