为什么Haskell列表在';他是左撇子

为什么Haskell列表在';他是左撇子,haskell,Haskell,我正在学习Haskell的基础知识,并遇到许多教程说,如果使用++从左到右构造列表比从右到左更有效。但我似乎不明白为什么 比如,为什么会这样 a ++ (b ++ (c ++ (d ++ (e ++ f)))) 它的效率比 ((((a ++ b) ++ c) ++ d) ++ e) ++ f 以下是我的理解: 在第一种情况下 a ++ (b ++ (c ++ (d ++ (e ++ f)))) 当你想做a++b时,a已经被构造好了,你所需要的只是把b附加到a上。对于a++b++c也是如此,

我正在学习Haskell的基础知识,并遇到许多教程说,如果使用++从左到右构造列表比从右到左更有效。但我似乎不明白为什么

比如,为什么会这样

a ++ (b ++ (c ++ (d ++ (e ++ f))))
它的效率比

((((a ++ b) ++ c) ++ d) ++ e) ++ f

以下是我的理解:

在第一种情况下

a ++ (b ++ (c ++ (d ++ (e ++ f))))
当你想做a++b时,a已经被构造好了,你所需要的只是把b附加到a上。对于a++b++c也是如此,您只需要在a++b的结果中添加c,依此类推

而在第二种情况下,

((((a ++ b) ++ c) ++ d) ++ e) ++ f
当你想做a++b++c时,你必须先做a++b,然后附加c,要做a++b++c++d,你必须再做a++b!然后a++b++c,然后附加d


因此,在第二种情况下,有大量重复计算,因此速度较慢。

一个简短的答案是,它不那么懒惰。另一个原因是它做了不必要的工作

Haskell是懒惰的,所以如果您想了解操作特性,就必须在解构下检查值。或者,从技术上讲,我们应该使用这个列表来了解它的性能。让我们计算一个简单的例子,以2为例

take 3 ([1] ++ ([2] ++ [3]))      vs.      take 3 (([1] ++ [2]) ++ [3])
我们将在每次案例检查后不断更新定义,以查看缩减的执行情况。为了减少混乱,我将不展开整个定义,而只是一步一步地介绍它们强制的参数缩减。为了完整起见,这里有一些关于
take
(++)

我们注意到,对于
n>0
take n
检查它的下一个参数,而
(++)
总是并且永远只检查它的第一个参数

take 3 ([1] ++ ([2] ++ [3]))
take 3 (1 : ([] ++ ([2] ++ [3])))
1 : take 2 ([] ++ ([2] ++ [3]))
1 : take 2 ([2] ++ [3])
1 : take 2 (2 : ([] ++ [3]))
1 : 2 : take 1 ([] ++ [3])        -- *
1 : 2 : take 1 [3]
1 : 2 : take 1 (3 : [])
1 : 2 : 3 : take 0 []
1 : 2 : 3 : []
正如此操作所显示的,
take
能够在
(++)
的计算值可用时立即惰性地消耗这些值,只需两步就返回结果的开头

比较其他关联性。直到第三步,它才产生结果水头。更深层的筑巢效果会更差

take 3 (([1] ++ [2]) ++ [3])
take 3 ((1 : ([] ++ [2])) ++ [3])
take 3 (1 : (([] ++ [2]) ++ [3]))
1 : take 2 (([] ++ [2]) ++ [3])     -- *
1 : take 2 ([2] ++ [3]
1 : take 2 (2 : ([] ++ [3]))
1 : 2 : take 1 ([] ++ [3])          -- *
1 : 2 : take 1 [3]
1 : 2 : take 1 (3 : [])
1 : 2 : 3 : take 0 []
1 : 2 : 3 : []

这里我们看到,
take
在返回部分结果之前,被迫将计算左嵌套调用的结果提升到“顶部”。这种提升由于
(++)
的嵌套更多和左手参数更长而加剧。还值得注意的是,在带星号的步骤中,我们在左侧附加了一个空列表。在后一个示例中,这发生在中间步骤上,如
([]+[2])++[3]
,而在前一个示例中,这发生在整个计算上,如
[]+([2]+[3])
例示了对嵌套附录的LHS所做的额外的、不必要的工作。

归结到如何实现列表和
+
。您可以想象列表的实现方式如下

data List a = Empty | Cons a (List a)
只需将
[]
替换为
替换为
Cons
。这是Haskell中单链表的一个非常简单的定义。单链表的串联时间为
O(n)
,其中
n
是第一个列表的长度。要理解原因,请回想一下,对于链接列表,您持有对头元素或第一个元素的引用,并且为了执行任何操作,您必须在列表中遍历,检查每个值以查看它是否有后续值

因此,对于每个列表串联,编译器必须遍历第一个列表的整个长度。如果列表中的
a
b
c
d
的长度分别为
n1
n2
n3
n4
,则对于表达式

((a ++ b) ++ c) ++ d
a ++ (b ++ (c ++ d))
它首先向下走
a
构造
a++b
,然后将该结果存储为
x
,因为
a
具有
n1
元素,所以它采取
n1
步骤。你只剩下

(x ++ c) ++ d
y ++ d
现在编译器向下遍历
x
以构造
x++c
,然后将此结果存储为
y
中的
n1+n2
步骤(这次它必须遍历
a
b
的元素)。你只剩下

(x ++ c) ++ d
y ++ d
现在向下走
y
,执行级联,采取
n1+n2+n3
步骤,总共
n1+(n1+n2)+(n1+n2+n3)=3n1+2n2+n3
步骤

为了表达

((a ++ b) ++ c) ++ d
a ++ (b ++ (c ++ d))
编译器从内圆括号开始,在
n3
步骤中构造
c++d->x
,结果是

a ++ (b ++ x)
a ++ y
然后在
n2
步骤中
b++x->y
,导致

a ++ (b ++ x)
a ++ y

最终在
n1
步骤中折叠,步骤总数为
n3+n2+n1
,肯定少于
3n1+2n2+n3
,谢谢。我认为你的回答和J.Abrahamson的结合很好地解释了这一点。