为什么Haskell列表如果它是左联想的话更有效率

时间:2014-05-26 16:58:04

标签: haskell

我正在学习Haskell的基础知识,并且遇到很多教程说如果使用++从左到右构建列表比从右到左更有效。但我似乎无法理解为什么。

例如,为什么这个

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

更有效
((((a ++ b) ++ c) ++ d) ++ e) ++ f

3 个答案:

答案 0 :(得分:10)

一个简短的回答是,它不那么懒惰。另一个是它不需要的工作。

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

take 3 ([1] ++ ([2] ++ [3]))      vs.      take 3 (([1] ++ [2]) ++ [3])

我们将在每次案例检查后不断更新定义,以了解减少的执行情况。为了减少混乱,我不会展开整个定义,而只是逐步完成它们强制的参数减少。为了完整起见,以下是take(++)

的一些定义
take 0 _      = []
take n []     = []
take n (x:xs) = x : take (n-1) xs

[]     ++ ys = ys
(x:xs) ++ ys = x : (xs ++ ys)

我们注意到,对于n > 0take 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能够在(++)计算后立即消耗掉它们的值,只需两步即可返回结果的头部。

比较其他关联性。直到第3步,它才产生结果头。更深的嵌套会表现得更糟。

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进行的额外的,不必要的工作。

答案 1 :(得分:10)

归结为列表和++的实现方式。您可以将列表视为

data List a = Empty | Cons a (List a)

只需将[]替换为Empty,将:替换为Cons。这是Haskell中单链表的非常简单的定义。单链表的连接时间为O(n)n为第一个列表的长度。要理解为什么,请回想一下,对于链接列表,您持有对头部或第一个元素的引用,并且为了执行任何操作,您必须沿着列表向下走,检查每个值以查看它是否有后继。

因此,对于每个列表连接,编译器必须遍历第一个列表的整个长度。如果您有长度为abc的{​​{1}},dn1n2列表,和n3分别为表达式

n4

首先向下((a ++ b) ++ c) ++ d 构建a,然后将此结果存储为a ++ b,自x n1 a后采取n1步骤元素。你离开了

(x ++ c) ++ d

现在,编译器向下x构建x ++ c,然后将此结果作为y步骤存储在n1 + n2步骤中(它必须向下移动a的元素这次和b。你和

一起离开了
y ++ d

现在y走下来执行连接,执行n1 + n2 + n3步骤,共计n1 + (n1 + n2) + (n1 + n2 + n3) = 3n1 + 2n2 + n3步。

表达式

a ++ (b ++ (c ++ d))

编译器从内部括号开始,c ++ d -> x步骤中构造n3,导致

a ++ (b ++ x)

然后在b ++ x -> y步骤中n2,结果

a ++ y

最终在n1步骤中折叠了哪些步骤,步骤总数为n3 + n2 + n1,肯定少于3n1 + 2n2 + n3

答案 2 :(得分:0)

以下是我的理解:

在第一种情况下,

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

当你想要做一个++ b时,你已经构建了一个,你需要的只是将b附加到a。同样适用于++ b ++ c,您只需将c附加到++ b的结果,依此类推。

在第二种情况下,

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

如果你想做一个++ b ++ c,你必须首先做一个++ b,然后追加c,做一个++ b ++ c ++ d,你&#39 ; d必须再做一次++ b!然后a ++ b ++ c,然后追加d。

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