关于haskell:(++)与懒惰评估的表现

The performance of (++) with lazy evaluation

我一直在想这件事,但还没有找到令人满意的答案。

为什么(++)是"昂贵的"?在Lazy评估下,我们不会评估像

1
xs ++ ys

在必要之前,甚至在那时,我们只会在需要的时候评估我们需要的部分。

有人能解释一下我遗漏了什么吗?


如果您访问整个结果列表,那么Lazy Evaluation将不会保存任何计算。它只会延迟到您需要每个特定元素为止,但最后,您必须计算相同的东西。

如果遍历连接列表xs ++ ys,那么访问第一部分的每个元素(xs会增加一点常量开销,检查xs是否已使用。

所以,如果你把++与左边或右边联系起来,会有很大的不同。

  • 如果将长度为kn列表与(..(xs1 ++ xs2) ... ) ++ xsn一样左对齐,那么访问第一个k元素的每个元素将花费O(n)时间,访问下一个k元素的每个元素将花费O(n-1)等,因此遍历整个列表将花费O(k n^2)。你可以查一下

    1
    sum $ foldl (++) [] (replicate 100000 [1])

    需要很长时间。

  • 如果您将长度为kn列表与右边的xs1 ++ ( ..(xsn_1 ++ xsn) .. )列表相关联,那么每个元素的开销都是恒定的,因此遍历整个列表将仅为O(k n)。你可以查一下

    1
    sum $ foldr (++) [] (replicate 100000 [1])

    是相当合理的。

编辑:这只是隐藏在ShowS后面的魔法。如果您将每个字符串xs转换为showString xs :: String -> String(showString只是(++)的别名),并组合这些函数,那么无论您如何关联它们的组合,在最后它们都将从右到左应用——这正是我们获得线性时间复杂性所需要的。(这仅仅是因为(f . g) xf (g x)。)

你们两个都可以查一下

1
length $ (foldl (.) id (replicate 1000000 (showString"x")))""

1
length $ (foldr (.) id (replicate 1000000 (showString"x")))""

在合理的时间内运行(foldr要快一点,因为从右边组合函数时开销较小,但两者在元素数量上都是线性的)。


它本身并不太贵,当你开始从左到右组合大量的++时,问题就出现了:这样一个链的评估如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
  ( ([1,2] ++ [3,4]) ++ [5,6] ) ++ [7,8]
let a = ([1,2] ++ [3,4]) ++ [5,6]
        ≡ let b = [1,2] ++ [3,4]
                ≡ let c = [1,2]
                  in  head c : tail c ++ [3,4]
                    ≡ 1 : [2] ++ [3,4]
                    ≡ 1 : 2 : [] ++ [3,4]
                    ≡ 1 : 2 : [3,4]
                    ≡ [1,2,3,4]
          in  head b : tail b ++ [5,6]
            ≡ 1 : [2,3,4] ++ [5,6]
            ≡ 1:2 : [3,4] ++ [5,6]
            ≡ 1:2:3 : [4] ++ [5,6]
            ≡ 1:2:3:4 : [] ++ [5,6]
            ≡ 1:2:3:4:[5,6]
            ≡ [1,2,3,4,5,6]
  in head a : tail a ++ [7,8]
   ≡ 1 : [2,3,4,5,6] ++ [7,8]
   ≡ 1:2 : [3,4,5,6] ++ [7,8]
   ≡ 1:2:3 : [4,5,6] ++ [7,8]
   ≡ 1:2:3:4 : [5,6] ++ [7,8]
   ≡ 1:2:3:4:5 : [6] ++ [7,8]
   ≡ 1:2:3:4:5:6 : [] ++ [7,8]
   ≡ 1:2:3:4:5:6 : [7,8]
   ≡ [1,2,3,4,5,6,7,8]

你可以清楚地看到二次复杂性。即使您只想评估第n个元素,您仍然需要在所有这些let中挖掘自己的方法。这就是为什么++infixr,因为[1,2] ++ ( [3,4] ++ ([5,6] ++ [7,8]) )实际上效率更高。但是,如果你在设计一个简单的串行化器时不小心,你可能很容易得到一个像上面那样的链。这就是为什么初学者受到关于++的警告的主要原因。

除此之外,与Prelude.++操作相比,Bytestring操作速度较慢,原因很简单,即它通过遍历链接列表工作,而链接列表总是缓存使用率不理想等,但这并不是问题所在;这会阻止您获得C类性能,但仅使用普通列表的正确编写程序,并且++可以我很容易与用Python编写的类似程序竞争。


我想在彼得的回答中加上一两件事。

正如他指出的那样,在开始时反复添加列表是相当便宜的,而在底部添加列表则不是。这是真的,只要你使用哈斯克尔的列表。但是,在某些情况下,您必须附加到末尾(例如,您正在构建一个要打印的字符串)。对于常规列表,您必须处理他回答中提到的二次复杂性,但是在这些情况下有一种更好的解决方案:差异列表(另请参见我关于主题的问题)。

长话短说,通过将列表描述为函数的组合,而不是将较短的列表串联,您可以在差异列表的开始或结束时,通过在固定时间内组合函数来附加列表或单个元素。完成后,可以在线性时间(以元素的数量)中提取常规列表。