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