您如何知道何时使用左折和何时使用右折?


99

我知道向左折叠会产生向左倾斜的树木,向右折叠会产生向右倾斜的树木,但是当我伸手折叠时,有时会发现自己陷入了头痛的想法,试图确定哪种折叠方式是适当的。我通常最终会解决整个问题,并逐步解决适用于我的问题的fold函数的实现。

所以我想知道的是:

  • 确定是左折还是右折的经验法则是什么?
  • 考虑到我面临的问题,如何快速决定使用哪种折叠类型?

Scala by Example(PDF)中有一个示例,该示例使用折叠来编写一个称为flatten的函数,该函数将元素列表的列表连接为一个列表。在这种情况下,正确的选择是正确的选择(考虑到列表的合并方式),但是我必须仔细考虑一下才能得出结论。

由于折叠是(功能)编程中的常见操作,因此我希望能够快速而自信地做出此类决定。那么...有什么提示吗?



1
这个Q比一般的Haskell更普遍。懒惰对问题的答案有很大的不同。
克里斯·康威

哦。很奇怪,以某种方式我以为我在这个问题上看到了Haskell标签,但我想没有……
ephemient

Answers:


105

您可以将折叠转换为中缀运算符表示法(在两者之间写入):

本示例使用累加器功能进行折叠 x

fold x [A, B, C, D]

因此等于

A x B x C x D

现在,您只需要考虑运算符的关联性(通过加括号即可!)。

如果您有一个左关联运算符,则将像这样设置括号

((A x B) x C) x D

在这里,您使用左折。示例(haskell样式的伪代码)

foldl (-) [1, 2, 3] == (1 - 2) - 3 == 1 - 2 - 3 // - is left-associative

如果运算符是右关联的(right fold),则将括号设置为:

A x (B x (C x D))

示例:Cons-Operator

foldr (:) [] [1, 2, 3] == 1 : (2 : (3 : [])) == 1 : 2 : 3 : [] == [1, 2, 3]

通常,算术运算符(大多数运算符)是左关联的,因此foldl更为广泛。但是在其他情况下,前缀符号+括号非常有用。


6
嗯,其实你所描述的是foldl1foldr1在哈斯克尔(foldlfoldr采取外部初始值),和Haskell的“缺点”之称(:)没有(::),但除此之外,这是正确的。你可能想补充一点,哈斯克尔另外提供foldl'/ foldl1'这是严格的变种foldl/ foldl1,因为懒算术并不总是可取的。
短暂

抱歉,我以为我在这个问题上看到了“ Haskell”标签,但它不存在。如果不是Haskell,我的评论并没有太大意义...
短暂

@ephemient您确实看到了。这是“哈斯克尔式的伪代码”。:)
laughing_man

我见过的最佳答案与折痕之间的差异有关。
AleXoundOS

60

Olin Shivers通过说“ foldl是基本列表迭代器”和“ foldr是基本列表递归运算符”来区分它们。如果您看一下foldl是如何工作的:

((1 + 2) + 3) + 4

您可以看到正在构建累加器(如在尾递归迭代中)。相反,文件夹继续:

1 + (2 + (3 + 4))

您可以在其中看到遍历基本案例4并从那里建立结果。

因此,我有一个经验法则:如果它看起来像一个列表迭代,那么以尾递归形式编写的列表迭代就很简单,而foldl是解决之道。

但是,实际上,这可能从您所使用的运算符的关联性中最为明显。如果它们是左关联的,则使用foldl。如果它们是右关联的,则使用文件夹。


29

其他海报给出了很好的答案,我不再重复他们已经说过的话。正如您在问题中给出的Scala示例一样,我将给出一个Scala特定示例。正如Tricks所说的那样,foldRight需要保留n-1堆栈帧,n列表的长度在哪里,这很容易导致堆栈溢出-甚至尾递归也不能使您免于此。

A List(1,2,3).foldRight(0)(_ + _)将减少为:

1 + List(2,3).foldRight(0)(_ + _)        // first stack frame
    2 + List(3).foldRight(0)(_ + _)      // second stack frame
        3 + 0                            // third stack frame 
// (I don't remember if the JVM allocates space 
// on the stack for the third frame as well)

List(1,2,3).foldLeft(0)(_ + _)将减少为:

(((0 + 1) + 2) + 3)

可以迭代地计算,如在实现中List所做

在严格评估的语言(如Scala)中,a foldRight可以轻松炸毁大型列表,而a foldLeft则不会。

例:

scala> List.range(1, 10000).foldLeft(0)(_ + _)
res1: Int = 49995000

scala> List.range(1, 10000).foldRight(0)(_ + _)
java.lang.StackOverflowError
        at scala.List.foldRight(List.scala:1081)
        at scala.List.foldRight(List.scala:1081)
        at scala.List.foldRight(List.scala:1081)
        at scala.List.foldRight(List.scala:1081)
        at scala.List.foldRight(List.scala:1081)
        at scala.List.foldRight(List.scala:1081)
        at scala.List.foldRight(List.scala:1081)
        at scala.List.foldRight(List.scala:1081)
        at scala.List.foldRig...

因此,我的经验法则是-对于没有特定关联性的运算符,foldLeft至少在Scala中始终使用。否则,请参考答案中给出的其他建议;)。


13
以前是这样,但是在当前的Scala版本中,对foldRight进行了更改,以将foldLeft应用于列表的反向副本。例如,在2.10.3中,github.com/scala/scala/blob/v2.10.3/src/library/scala/…。似乎此更改是在2013年初进行的-github.com/scala/scala/commit/…
Dhruv Kapoor 2014年

4

值得注意的是(我意识到这一点是显而易见的),在可交换运算符的情况下,两者几乎是等效的。在这种情况下,折叠可能是更好的选择:

foldl: (((1 + 2) + 3) + 4)可以计算每个操作并向前累积值

foldr相似: (1 + (2 + (3 + 4)))需求被打开堆栈帧1 + ?,并2 + ?计算之前3 + 4,那么它需要回去做每个计算。

我还不足以说功能语言或编译器优化方面的专家是否真的会有所作为,但使用带可换运算符的foldl显然更干净。


1
额外的堆栈框架对于大型列表肯定会有所作为。如果您的堆栈帧超过了处理器缓存的大小,则缓存丢失将影响性能。除非列表是双向链接的,否则很难将foldr设置为尾递归函数,因此除非有理由不使用,否则应使用foldl。
A. Levy

4
Haskell的懒惰性质混淆了此分析。如果要折叠的函数在第二个参数中不受限制,那么foldr它会比高效得多foldl,并且不需要任何额外的堆栈框架。
短暂

2
抱歉,我以为我在这个问题上看到了“ Haskell”标签,但它不存在。如果不是Haskell,我的评论并没有太大意义……
短暂
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.