foldl是尾递归的,那么foldr如何比foldl运行得更快?


75

我想测试foldl vs foldr。从我所看到的情况来看,由于尾部递归优化,您应该在可能的情况下使用foldl over foldr。

这很有道理。但是,运行此测试后,我感到困惑:

文件夹(使用时间命令时需要0.057s):

a::a -> [a] -> [a]
a x = ([x] ++ )

main = putStrLn(show ( sum (foldr a [] [0.. 100000])))

foldl(使用time命令时需要0.089s):

b::[b] -> b -> [b]
b xs = ( ++ xs). (\y->[y])

main = putStrLn(show ( sum (foldl b [] [0.. 100000])))

显然,这个例子很简单,但是我对为什么foldr击败foldl感到困惑。这不是一个明显的案例,即foldl获胜吗?


4
顺便说一句,我将使用列表构造函数编写aa = (:)
John L

1
是。我将其设为([x] ++)的唯一原因是尝试使a和b尽可能接近,以便尽可能地比较折叠位置
Ori

3
...和b作为b = flip (:)
Will Ness 2014年

Answers:


108

欢迎来到懒惰的评估世界。

当您从严格评估的角度考虑它时,foldl看起来“好”,而foldr看起来“不好”,因为foldl是尾递归的,但是foldr必须在堆栈中建立一个塔,这样它才能首先处理最后一个项目。

但是,懒惰的评估会扭转局面。以map函数的定义为例:

map :: (a -> b) -> [a] -> [b]
map _ []     = []
map f (x:xs) = f x : map f xs

如果Haskell使用严格的评估,效果会不太好,因为它必须先计算尾部,然后再添加项目(对于列表中的所有项目)。看来,有效执行此操作的唯一方法是反向构建元素。

但是,由于Haskell的惰性评估,此映射功能实际上是有效的。可以将Haskell中的列表视为生成器,并且此map函数通过将f应用于输入列表的第一项来生成其第一项。当需要第二项时,它将再次执行相同的操作(不使用额外的空间)。

事实证明,map可以这样描述foldr

map f xs = foldr (\x ys -> f x : ys) [] xs

很难看清楚,但是懒惰的评估开始了,因为foldr可以立即给出f其第一个参数:

foldr f z []     = z
foldr f z (x:xs) = f x (foldr f z xs)

因为fdefine bymap可以仅使用第一个参数返回结果列表的第一项,所以fold可以在恒定空间中延迟运行。

现在,懒惰的评估确实会反击。例如,尝试运行sum [1..1000000]。它产生堆栈溢出。为什么要这样 它应该只是从左到右评估,对吗?

让我们看看Haskell如何评估它:

foldl f z []     = z
foldl f z (x:xs) = foldl f (f z x) xs

sum = foldl (+) 0

sum [1..1000000] = foldl (+) 0 [1..1000000]
                 = foldl (+) ((+) 0 1) [2..1000000]
                 = foldl (+) ((+) ((+) 0 1) 2) [3..1000000]
                 = foldl (+) ((+) ((+) ((+) 0 1) 2) 3) [4..1000000]
                   ...
                 = (+) ((+) ((+) (...) 999999) 1000000)

Haskell太懒了,无法立即执行添加操作。取而代之的是,它最终以一堆未经评估的重击而被迫获得一个数字。堆栈溢出在此评估期间发生,因为它必须进行深度递归以评估所有重击。

幸运的是,Data.List中有一个特殊的函数,foldl'它严格执行。 foldl' (+) 0 [1..1000000]不会堆栈溢出。(注意:在您的测试中,我尝试用替换foldlfoldl',但实际上它使运行速度变慢。)


需要注意的是sum,由于严格性分析,通常可以正常工作。
singpolyma 2012年

3
“看来,有效地做到这一点的唯一方法是反向构建元素。” 如果您的编译器执行尾部递归模态优化优化,则不是。:)然后,它就像使用严格的数据构造函数进行有保护的递归一样。
Will Ness 2012年

1
@WillNess:GHC没有这种优化吗?
艾略特·卡梅隆2014年

1
据我所知,@ 3noch。但这基本上不适用,因为Haskell是惰性的,并且正如我在前面所暗示的,惰性保护的递归等效于它。和更正:应改为懒数据构造,笔者认为:在map f (x:xs) = f x : map f xs,的递归map守卫列表构造:。如果:严格的话,将没有任何防护,只需简单地递归即可。我认为那里缺少一个逗号:“ ...那么,就像使用严格的数据构造函数一样,即使它是受保护的递归,也可以工作”-如果存在TRMCO。
威尔·尼斯

我怀疑一个..您在上一个foldl示例中说:“ Haskell太懒了,无法继续执行加法操作,所以这会导致堆栈溢出”。所以用完内存,而不是用完堆栈内存..这就是为什么.. foldr立即崩溃,因为堆栈内存小于系统总内存。
Ashish Negi 2015年

27

编辑:再次查看此问题后,我认为当前所有解释都不够,所以我写了更长的解释。

所不同的是如何foldlfoldr应用他们的降噪功能。查看foldr案例,我们可以将其扩展为

foldr (\x -> [x] ++ ) [] [0..10000]
[0] ++ foldr a [] [1..10000]
[0] ++ ([1] ++ foldr a [] [2..10000])
...

该列表由处理sum,其使用情况如下:

sum = foldl' (+) 0
foldl' (+) 0 ([0] ++ ([1] ++ ... ++ [10000]))
foldl' (+) 0 (0 : [1] ++ ... ++ [10000])     -- get head of list from '++' definition
foldl' (+) 0 ([1] ++ [2] ++ ... ++ [10000])  -- add accumulator and head of list
foldl' (+) 0 (1 : [2] ++ ... ++ [10000])
foldl' (+) 1 ([2] ++ ... ++ [10000])
...

我省略了列表串联的详细信息,但这是减少操作的方式。重要的是要对所有内容进行处理,以最大程度地减少列表遍历。该foldr只遍历该列表一次,串联都不需要连续列表遍历,并sum最终会消耗一个合格名单。至关重要的是,列表的头部从foldr立即到可用sum,因此sum可以立即开始工作,并且可以在生成值时进行gc'd处理。使用诸如的融合框架vector,即使中间列表也可能被融合。

将此与foldl功能对比:

b xs = ( ++xs) . (\y->[y])
foldl b [] [0..10000]
foldl b ( [0] ++ [] ) [1..10000]
foldl b ( [1] ++ ([0] ++ []) ) [2..10000]
foldl b ( [2] ++ ([1] ++ ([0] ++ [])) ) [3..10000]
...

请注意,直到foldl完成,现在列表的头部才可用。这意味着必须先在内存中构造整个列表,然后sum才能开始工作。总体而言,效率低得多。运行这两个版本+RTS -s显示的垃圾收集性能比foldl版本糟糕。

这也是foldl'无济于事的情况。增加的严格性foldl'不会更改中间列表的创建方式。在foldl'完成之前,列表的头一直不可用,因此结果仍然比使用慢foldr

我使用以下规则来确定最佳选择 fold

  • 对于折叠 减少,请使用foldl'(例如,这将是唯一/最终遍历)
  • 否则使用foldr
  • 不要使用foldl

在大多数情况下,这foldr是最佳的折叠函数,因为遍历方向对于列表的延迟评估是最佳的。它也是唯一能够处理无限列表的人。foldl'在某些情况下,额外的严格性可以使其更快,但这取决于您将如何使用该结构以及它的懒惰程度。


不幸的是,sum使用foldl而不是foldl'(除非它们最近已修复)。
乔伊·亚当斯

我的如意算盘。争论仍然成立。您只需要用一个巨大的油门来更换蓄能器。
约翰·L

1
foldl1' (+) [1..100000000]几乎立即执行,而其他所有折叠都需要几秒钟或导致堆栈溢出。
Mateen Ulhaq

9

我认为还没有人真正说过这个问题的真正答案,除非我遗漏了一些东西(这很可能是正确的,并受到反对派的欢迎)。

我认为在这种情况下最大的不同是foldr建立这样的列表:

[0] ++([1] ++([2] ++(... ++ [1000000])))

foldl构建这样的列表:

(((([[0] ++ [1])++ [2])++ ...)++ [999888])++ [999999])++ [1000000]

区别细微,但请注意,foldr版本中++始终只有一个列表元素作为其左参数。在该foldl版本中,最多包含999999个元素++的左参数(平均约500000),而右参数中仅包含一个元素。

但是,++它花费的时间与左参数的大小成正比,因为它必须将整个左参数列表都看到末尾,然后将最后一个元素指向右参数的第一个元素(最好,也许实际上它需要复制)。正确的参数列表未更改,因此它的大小无关紧要。

这就是为什么foldl版本慢得多的原因。我认为这与懒惰无关。


folder为OP花费的时间更少。
Ashish Negi

@克林顿这是有道理的。但是确实你的答案只解释了OP的具体例子或之间一般相差foldlfoldr?对我而言,OP最初是一个不好的例子,因为这两个折叠功能完全不同。
dhu

7

问题在于尾部递归优化是内存优化,而不是执行时间优化!

尾递归优化避免了记住每个递归调用的值的需要。

因此,foldl实际上是“好”,而foldr是“坏”。

例如,考虑foldr和foldl的定义:

foldl f z [] = z
foldl f z (x:xs) = foldl f (z `f` x) xs

foldr f z [] = z
foldr f z (x:xs) = x `f` (foldr f z xs)

这就是表达式“ foldl(+)0 [1,2,3]”的计算方式:

foldl (+) 0 [1, 2, 3]
foldl (+) (0+1) [2, 3]
foldl (+) ((0+1)+2) [3]
foldl (+) (((0+1)+2)+3) [ ]
(((0+1)+2)+3)
((1+2)+3)
(3+3)
6

请注意,foldl不会记住值0、1、2 ...,而是将整个表达式(((0 + 1)+2)+3)惰性地传递为参数,并且直到最后一次求值时才对其求值foldl,它到达基本情况并返回传递的值,因为尚未评估第二个参数(z)。

另一方面,这就是文件夹的工作方式:

foldr (+) 0 [1, 2, 3]
1 + (foldr (+) 0 [2, 3])
1 + (2 + (foldr (+) 0 [3]))
1 + (2 + (3 + (foldr (+) 0 [])))
1 + (2 + (3 + 0)))
1 + (2 + 3)
1 + 5
6

此处的重要区别是foldl在上一次调用中对整个表达式求值,从而避免了需要返回到记住的值(folder no)的情况。folder为每个调用记住一个整数,并在每个调用中执行加法运算。

重要的是要记住,foldr和foldl并不总是等效的。例如,尝试以拥抱方式计算以下表达式:

foldr (&&) True (False:(repeat True))

foldl (&&) True (False:(repeat True))

foldr和foldl仅在此处描述的某些条件下等效

(对不起,我的英语不好)


2

对于a,[0.. 100000]需要立即扩展列表,以便文件夹可以从最后一个元素开始。然后将其折叠在一起,中间结果是

[100000]
[99999, 100000]
[99998, 99999, 100000]
...
[0.. 100000] -- i.e., the original list

由于不允许任何人更改此列表值(Haskell是纯函数式语言),因此编译器可以自由地重用该值。中间值,[99999, 100000]甚至可以只是指向扩展的指针[0.. 100000]列表的而不是单独的列表。

对于b,请查看中间值:

[0]
[0, 1]
[0, 1, 2]
...
[0, 1, ..., 99999]
[0.. 100000]

这些中间列表中的每一个都不能重复使用,因为如果您更改列表的末尾,则您还更改了指向该列表的任何其他值。因此,您要创建大量额外的列表,这些列表需要花费一些时间才能在内存中建立。因此,在这种情况下,您将花费更多时间分配和填充这些中间值的列表。

由于您只是在复制列表,因此运行起来会更快,因为它首先扩展了整个列表,然后不断将指针从列表的后面移到前面。


1

尾部优化也foldl没有foldr。它只是foldl'

但是在您的情况下使用++withfoldl'并不是一个好主意,因为对的连续评估++将导致遍历不断增长的累加器。


-3

好吧,让我以一种明显的区别来重写您的函数-

a :: a -> [a] -> [a]
a = (:)

b :: [b] -> b -> [b]
b = flip (:)

您会看到b比a更复杂。如果要精确,则a需要减少一个步骤来计算值,但是b需要两个步骤。这使您正在测量的时差,在第二个示例中,必须执行两倍的减少。

// edit:但是时间复杂度是相同的,所以我不会对此太在意。


3
我尝试将其更改为a = flip $ flip (:),但这并没有明显改变执行时间,因此,我认为翻转参数以容纳foldl并不是问题。
哈罗德L
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.