性能-为什么差异列表比常规串联更有效?

我目前正在通过“在线学习Haskell”一书来研究自己的方式,并进入了一个章节,作者在其中解释一些列表串联可能效率低下:例如

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

据说效率低下。 作者提出的解决方案是使用“差异列表”定义为

newtype DiffList a = DiffList {getDiffList :: [a] -> [a] }

instance Monoid (DiffList a) where
    mempty = DiffList (\xs -> [] ++ xs)
    (DiffList f) `mappend` (DiffList g) = DiffList (\xs -> f (g xs))

我在努力理解为什么DiffList在某些情况下比简单的串联具有更高的计算效率。 有人可以简单地向我解释为什么上面的示例如此低效,而DiffList用什么方式解决了这个问题?

2个解决方案
73 votes

问题在

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

是嵌套。 (++)的应用程序是左嵌套的,这很糟糕。 右嵌套

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

不会有问题。 这是因为DiffList被定义为

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

因此要找到要使用的方程式,实现必须深入到表达式树中

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

直到找到左操作数是否为空。 如果不为空,则将其头部抬起并冒泡至顶部,但保持左操作数的尾部不变,因此当需要连接的下一个元素时,将再次开始相同的过程。

当串联是右嵌套时,左操作数DiffList始终在顶部,并且检查是否为空/冒泡为O(1)。

但是,如果将连接嵌套在左侧,则深度为DiffList层以到达第一个元素,必须遍历树的(++ a)个节点,对于结果的每个元素(来自第一个列表,来自第二个列表的元素为(++),等等。 )。

让我们考虑DiffList

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

我们要评估DiffList。因此,首先,必须检查是否

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

是空的。 为此,必须检查是否

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

is empty. For that, it must be checked whether

(a ++ b) ++ c

is empty. For that, it must be checked whether

a ++ b

is empty. For that, it must be checked whether

a

是空的。 ew 不是,所以我们可以再次冒泡,组装

a ++ b                             = 'h':("ello" ++ b)
(a ++ b) ++ c                      = 'h':(("ello" ++ b) ++ c)
((a ++ b) ++ c) ++ d               = 'h':((("ello" ++ b) ++ c) ++ d)
(((a ++ b) ++ c) ++ d) ++ e        = 'h':(((("ello" ++ b) ++ c) ++ d) ++ e)
((((a ++ b) ++ c) ++ d) ++ e) ++ f = 'h':((((("ello" ++ b) ++ c) ++ d) ++ e) ++ f)

对于DiffList,我们必须重复,对于(++ a)s,也必须重复...

绘制树的一部分,冒泡是这样的:

            (++)
            /  \
         (++)   c
         /  \
'h':"ello"   b

成为第一

     (++)
     /  \
   (:)   c
  /   \
'h'   (++)
      /  \
 "ello"   b

然后

      (:)
      / \
    'h' (++)
        /  \
     (++)   c
     /  \
"ello"   b

一路回到顶部。 最终成为顶层DiffList的右子级的树的结构与原始树的结构完全相同,除非最左边的列表为空,否则

 (++)
 /  \
[]   b

节点仅折叠至DiffList

因此,如果您有短列表的左嵌套连接,则该连接将变成二次方,因为要获得连接的头是O(嵌套深度)操作。 通常,左嵌套的串联

(...((a_d ++ a_{d-1}) ++ a_{d-2}) ...) ++ a_2) ++ a_1

DiffList以进行完全评估。

使用差异列表(为便于说明,没有使用新型包装器),组成是否为左嵌套并不重要

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

或右嵌套。 一旦遍历嵌套到达DiffList,该(++ a)就会被提升到表达式树的顶部,因此到达(++)的每个元素都是O(1)。

实际上,一旦您需要第一个元素,整个构图就会与差异列表重新关联,

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

变成

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

之后,每个列表都是使用完前一个列表之后的顶级DiffList的紧靠左操作数。

重要的是,前置函数DiffList可以在不检查其自变量的情况下开始产生其结果,以便从

             ($)
             / \
           (.)  f
           / \
         (.) (e ++)
         / \
       (.) (d ++)
       / \
     (.) (c ++)
     / \
(a ++) (b ++)

通过

           ($)---------
           /           \
         (.)           ($)
         / \           / \
       (.) (d ++) (e ++)  f
       / \
     (.) (c ++)
     / \
(a ++) (b ++)

     ($)
     / \
(a ++) ($)
       / \
  (b ++) ($)
         / \
    (c ++) ($)
           / \
      (d ++) ($)
             / \
        (e ++)  f

不需要了解最终列表DiffList的组成函数,因此仅需重写(++ a)。 然后是顶层

     ($)
     / \
(a ++)  stuff

变成

 (++)
 /  \
a    stuff

只需一步即可获得DiffList的所有元素。 在这个例子中,我们只有纯左嵌套,只需要重写一次即可。 如果该位置的功能不是(例如)(++ a),而是一个左嵌套的组合(++),则顶级重新关联将保持不变,并且当它成为顶级O(n²)之后的左侧操作数时,它将重新关联。 以前的所有列表都已被使用。

所有重新关联所需的总工作量为DiffList,因此,连接的总成本为(++ a)。(这意味着,通过插入许多深层嵌套的(++),也可能导致性能下降。)

newtype DiffList a = DiffList {getDiffList :: [a] -> [a] }

instance Monoid (DiffList a) where
    mempty = DiffList (\xs -> [] ++ xs)
    (DiffList f) `mappend` (DiffList g) = DiffList (\xs -> f (g xs))

只是将其包装起来,以便抽象处理更加方便。

DiffList (a ++) `mappend` DiffList (b ++) ~> DiffList ((a ++) . (b++))

请注意,这仅对不需要检查其参数即可开始产生输出的函数有效,如果在DiffLists中包装了任意函数,则无法保证效率。 特别是,追加((++ a),是否包裹)可以在右嵌套时创建(++)的左嵌套树,因此,如果暴露了DiffList构造函数,则可以创建O(n²)的串联行为。

Daniel Fischer answered 2020-08-04T22:25:40Z
7 votes

查看串联的定义可能会有所帮助:

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

如您所见,为了连接两个列表,您需要越过左侧列表并为其创建一个“副本”,这样您才能更改其结尾(这是因为您不能直接更改旧列表的结尾) 名单,由于不可更改)。

如果您以正确的关联方式进行串联,则没有问题。 插入列表后,将不再需要再次触摸列表(请注意++的定义永远不会检查右侧的列表),因此每个列表元素仅“一次”插入,总时间为O(N)。

--This is O(n)
(a ++ (b ++ (c ++ (d ++ (e ++ f)))))

但是,如果您以左关联的方式进行串联,则每次将另一个列表片段添加到末尾时,都必须“拆除”“重建”列表并“重建”“当前”列表。 它会被插入,并在以后添加任何片段时也是如此!就像您连续天真地多次调用strcat会遇到C的问题。


至于差异列表,诀窍在于它们在末尾保留了一个明确的“漏洞”。 当您将DList转换回普通列表时,您可以将其传递到孔中,然后将其准备就绪。 另一方面,普通列表总是以[]插入最后的孔,因此,如果要更改此列表(连接时),则需要撕开列表才能到达该点。

首先,带有功能的差异列表的定义看起来令人生畏,但实际上非常简单。 您可以从面向对象的角度查看它们,方法是将它们视为不透明的对象“ toList”方法,该方法接收应插入到最后的孔中的列表,该列表将返回DL的内部前缀以及所提供的尾部。 之所以有效,是因为您仅在完成所有转换之后才插入“孔”。

hugomg answered 2020-08-04T22:26:24Z
translate from https://stackoverflow.com:/questions/13879260/why-are-difference-lists-more-efficient-than-regular-concatenation