【问题标题】:List manipulation performance in HaskellHaskell 中的列表操作性能
【发布时间】:2015-07-27 11:41:12
【问题描述】:

我目前正在学习 Haskell,我对以下内容感到好奇:

如果我在 Haskell 的列表中添加一个元素,Haskell 会返回一个(完全?)新列表,并且不会操作原始列表。

现在假设我有一个包含一百万个元素的列表,并且我在末尾附加了一个元素。 Haskell 是否“复制”整个列表(100 万个元素)并将元素添加到该副本中?或者是否有一个巧妙的“技巧”在幕后进行以避免复制整个列表?

如果没有“技巧”,复制大型列表的过程是否没有我想象的那么昂贵?

【问题讨论】:

    标签: list haskell data-structures append immutability


    【解决方案1】:

    这是一个非常复杂的问题,因为 Haskell 和 GHC 有两个特性:

    1. 懒惰评估
    2. 列表融合

    列表融合意味着在某些情况下,GHC 可以将列表处理代码重写为不分配列表单元的循环。因此,根据使用它的上下文,相同的代码不会产生额外的成本。

    惰性求值意味着如果一个操作的结果没有被消费,那么你就不用支付计算它的成本。例如,这很便宜,因为您只需要构造列表的前十个元素:

    example = take 10 ([1..1000000] ++ [1000001])
    

    事实上,在该代码中,take 10 可以与列表追加融合,因此它与 [1..10] 相同。

    但是让我们假设我们正在使用我们创建的所有列表的所有元素,并且编译器没有融合我们的列表操作。现在回答您的问题:

    如果我将一个元素添加到 Haskell 中的列表中,Haskell 会返回一个(完全?)新列表,并且不会操作原始列表。现在假设我有一个包含一百万个元素的列表,并在末尾附加一个元素。 Haskell 是否“复制”整个列表(100 万个元素)并将元素添加到该副本中?或者是否有一个巧妙的“技巧”在幕后进行以避免复制整个列表?

    存在避免复制整个列表的技巧,但是通过附加到其末尾,您可以击败它们。要理解的是,函数式数据结构通常被设计为“修改”它们的操作将利用 结构共享 来尽可能多地重用旧结构。例如,附加两个列表可以这样定义:

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

    看这个定义,你可以知道列表ys 将在结果中被重用。因此,如果我们有 xs = [1..3]ys = [4..5]xs ++ ys,所有这些都被完全评估并同时保留在内存中,它看起来像这样的内存:

               +---+---+    +---+---+    +---+---+
          xs = | 1 | -----> | 2 | -----> | 3 | -----> []
               +---+---+    +---+---+    +---+---+
    
               +---+---+    +---+---+ 
          ys = | 4 | -----> | 5 | -----> []
               +---+---+    +---+---+    
                 ^
                 |
                 +------------------------------------+
                                                      |
               +---+---+    +---+---+    +---+---+    |
    xs ++ ys = | 1 | -----> | 2 | -----> | 3 | -------+
               +---+---+    +---+---+    +---+---+
    

    这就是这么说:如果您执行xs ++ ys,并且它没有融合,并且您消耗整个列表,那么这将创建xs的副本,但将内存重用于@987654335 @。

    但是现在让我们再看看你的这个问题:

    现在假设我有一个包含一百万个元素的列表,并且我在末尾附加了一个元素。 Haskell 是否“复制”整个列表(100 万个元素)并将元素添加到该副本中?

    类似于[1..1000000] ++ [1000001],是的,它会复制整百万个元素。但另一方面,[0] ++ [1..1000000] 只会复制[0]。经验法则是这样的:

    • 在列表的开头添加元素是最有效的。
    • 在列表末尾添加元素通常效率低下,特别是如果您一遍又一遍地这样做。

    这类问题的一般解决方案是:

    1. 修改您的算法,以便以列表有效支持的访问模式使用列表。
    2. 不要使用列表;使用其他一些序列数据结构来有效地支持您手头问题所需的访问模式。另一个答案提到了差异列表,但其他值得一提的是:

    【讨论】:

    • 不错!我不知道结构共享。
    【解决方案2】:

    这取决于您使用的数据结构。如果您使用的是普通的 Haskell 列表,这些将类似于 C 或 C++ 中的典型链表实现。使用这种结构,追加和索引(最坏情况)是 O(n) 复杂度,而前置是 O(1) 复杂度。如果您经常添加并且您的列表呈线性增长,那么这实际上将是 O(n^2)。对于大型列表,这是一个问题。这与您使用的语言无关,Haskell、C、C++、Python、Java、C#,甚至是汇编程序。

    但是,如果您要使用像 Data.Sequence.Seq 这样的结构,那么它会在内部使用适当的结构来提供 O(1) 的前置和附加,但代价是它会占用更多的 RAM。但是,所有数据结构都有权衡取舍,这取决于您要使用哪一种。

    或者,您也可以使用Data.Vector.VectorData.Array.Array,它们都提供固定长度、连续的内存数组,但追加和前置的成本很高,因为您必须将整个数组复制到 RAM 中的新位置。但是,索引是 O(1),并且映射或折叠其中一个结构会快得多,因为数组的块可以一次放入 CPU 缓存中,而不是元素分散在各处的链表或序列你的内存。

    Haskell 是否“复制”整个列表(100 万个元素)并将元素添加到该副本中?

    不一定,编译器可以确定仅将最后一个值的next 指针更改为指向新值而不是空列表是否安全,或者如果它不安全,则可能需要复制整个列表。不过,这些问题是数据结构所固有的,而不是语言所固有的。一般来说,我会说 Haskell 的列表比 C 链表更好,因为编译器比程序员更能分析何时安全,而 C 编译器不会做这种分析,他们只是按照他们的方式做'被告知。

    【讨论】:

    • 我同意你的说法,但你的大 O 表示法不正确。 O(500000500000) == O(1) == 恒定时间(参见 en.wikipedia.org/wiki/… )。当然,您可以争辩说,如果您尝试“追加一百万次元素”,那么它总是在 O(1) 中运行,因为没有剩余变量,并且“追加一百万次”操作确实在恒定时间内运行。但我认为这不是你想说的。
    • @JohannesWeiß 更好?
    • 我知道您并没有完全声明,但 GHC 从未以您在上一段中描述的方式更改现有堆​​对象的值。 (GHC 最擅长的是避免在堆上构建中间值。)
    • 嘿@ReidBarton,你能告诉我更多(或发布资源)GHC 如何避免在堆上构造中间值吗?
    • @jackrandom,这是一个复杂的主题。查找有关内联、拆箱和列表融合的信息。
    【解决方案3】:

    使用列表时,追加成本很高,并且必须复制列表,尽管不是元素。此外,前置很便宜,因为新值只是指向原始列表。

    "third" 追加到["first", "second"]:新列表为(:) "first" ((:) "second" ((:) "third" []))。因此,第一个构造函数必须是一个新的,因为第二个参数必须是一个新的值,因为...虽然字符串不重复。新列表指向内存中的相同字符串。

    请注意,在旧值被丢弃的情况下,编译器可能会决定重用它,而不是为新值分配内存并垃圾收集旧值。无论如何,追加将在 O(n) 中完成,因为它需要找到它的结尾。

    现在,如果您的程序向列表追加了很多内容,您可能希望使用不同的数据结构以便能够在 O(1) 中追加,例如 DList 来自包 dlist。 (https://hackage.haskell.org/package/dlist-0.5/docs/Data-DList.html)

    【讨论】:

    • 附件不是问题。没有什么能阻止列表的实现,它们的元素存储在一个大的预分配数组中,加上startend 位置。 xsxs ++ [a] 都可以使用相同的数组。如果我们从中间开始,或者使用(指向)数组块的列表(/数组),即使前置也不是问题。 插入 是有问题的。 case xs of (a:as) ... 只会在幕后从 xs = (start,end,array) 创建 as = (start+1,end,array)
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多