Performance Haskell foldl';使用(+;+;)的性能不佳

Performance Haskell foldl';使用(+;+;)的性能不佳,performance,haskell,lazy-evaluation,strictness,weak-head-normal-form,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..1

我有以下代码:

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\u bad
函数比
newList\u 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
经典的列表行为

回顾:

(:)  -- O(1) complexity
(++) -- O(n) complexity
因此,您正在创建一个O(n^2)算法,而不是一个O(n)算法


对于这种以增量方式追加到列表的常见情况,请尝试使用,或者在末尾使用相反的方法。

关于这个问题,存在很多混淆。通常给出的理由是“在列表末尾重复追加需要重复遍历列表,因此是
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

请注意,到目前为止,我们只执行了
O(n)
步骤
a^2
可立即用于
sum
的消费,但
b^2
不可用剩下的是
++
表达式的左嵌套结构。
其余部分最好在中解释。它的要点是,要获得
b^2
,必须执行
O(n-1)
步骤,并且在该访问之后留下的结构仍然是嵌套的,因此下一次访问将采取
O(n-2)
步骤,等等-经典的
O(n^2)
行为。因此,真正的原因是
++
没有强制或重新排列其参数,使其具有足够的效率

这实际上是违反直觉的。我们可以期待懒惰的评估在这里神奇地为我们“做到”。毕竟,我们只是表达了在将来将
[x^2]
添加到列表末尾的意图,实际上我们并没有立即这样做。因此,这里的时间是关闭的,但它可以被正确地设置-当我们访问列表时,新的元素将被添加到其中并立即被使用,如果时间是正确的:如果
c^2
将被添加到
b^2
之后的列表中(从空间上看),比如说,就在
b^2
被使用之前,遍历/访问总是
O(1)

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

newlist_dl lst = foldl' (\z x-> (z . (x^2 :)) ) id lst
如果您仔细想想,它看起来与您的
+[x^2]
版本完全相同。它表达了同样的意图,并留下了左嵌套结构

正如Daniel Fischer在同一答案中所解释的,不同之处在于a
()
,当第一次强制时,会在
O(n)
步骤中将自身重新排列成右嵌套的
($)
结构
1,之后每次访问都是
O(1)
而追加的时间正是上段所述的最佳时间,因此我们只剩下整体
O(n)
行为



这有点不可思议,但确实发生了。:)

从更大的角度补充其他答案:对于惰性列表,在返回列表的函数中使用
foldl'
通常是个坏主意<代码>foldl'通常在将列表缩减为严格(非惰性)标量值(例如,对列表求和)时非常有用。但是当你建立一个列表时,由于懒惰,
foldr
通常更好;
构造函数是惰性的,因此在实际需要时才计算列表的尾部

就你而言:

newList_foldr lst = foldr (\x acc -> x*2 : acc) [] lst
这实际上与
地图(*2)
相同:

评估(使用第一个
映射
-较少的定义):

当强制执行
newList[1..10]
时,Haskell将对此进行评估。只有当结果的消费者需要它时,它才会进一步评估,并且只需要满足消费者的需求。例如:

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
newList
也可以用于无限列表:

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

newList_foldr [1..] = [2,4..]
firstElem (newList_foldr [1..]) = 2
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
基于
foldr
的算法需要4个步骤来计算
firstElem_foldr(newList[1..10])
,而基于
foldl'
的算法需要21个步骤。更糟糕的是,这4个步骤是一个固定成本,而这21个步骤与输入列表的长度成比例-
firstElem(newList\u good[1..150000])
需要300001个步骤,而
firstElem(newList\u foldr[1..150000]
需要5个步骤,就这一点而言


还要注意的是,
firstElem(newList_foldr[1.10])
在恒定空间和恒定时间中运行(必须这样做;您需要比恒定时间更多的时间来分配比恒定空间更多的空间)-“
foldl
是尾部递归的,在常量空间中运行,
foldr
不是尾部递归的,在线性空间或更糟的空间中运行”-在Haskell中不成立。

这些问题讨论了为什么添加到列表(示例中的
acc++[x*2]
步骤)效率低下。

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