【问题标题】:Why are difference lists more efficient than regular concatenation in Haskell?为什么差异列表比 Haskell 中的常规连接更有效?
【发布时间】:2012-12-02 11:43:54
【问题描述】:

我目前正在阅读Learn you a 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 是通过什么方式解决这个问题的?

【问题讨论】:

    标签: performance list haskell time-complexity difference-lists


    【解决方案1】:

    问题

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

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

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

    不会有问题。那是因为(++) 被定义为

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

    所以要找到使用哪个方程,实现必须深入表达式树

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

    直到它找出左操作数是否为空。如果它不是空的,它的头部被取出并冒泡到顶部,但左操作数的尾部保持不变,所以当需要连接的下一个元素时,同样的过程再次开始。

    当连接是右嵌套时,(++) 的左操作数总是在顶部,检查空头/冒泡是 O(1)。

    但是当串联是左嵌套时,n 层深,要到达第一个元素,必须遍历树的 n 节点,对于结果的每个元素(来自第一个列表,@987654330 @对于那些来自第二个等的人。

    让我们考虑a = "hello"

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

    我们想要评估take 5 hi。所以首先,必须检查是否

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

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

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

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

    (a ++ b) ++ c
    

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

    a ++ b
    

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

    a
    

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

    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)
    

    对于'e',我们必须重复,对于'l's 也是...

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

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

    成为第一

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

    然后

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

    一直回到顶部。最后成为顶层(:)右孩子的树的结构,和原树的结构完全一样,除非最左边的列表是空的,当

     (++)
     /  \
    []   b
    

    节点被折叠成b

    因此,如果您有短列表的左嵌套连接,则连接变为二次,因为要获得连接的头部是 O(嵌套深度)操作。一般来说,左嵌套的串联

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

    O(sum [i * length a_i | i <- [1 .. d]]) 以进行全面评估。

    使用差异列表(为了简化说明,没有 newtype 包装器),组合是否左嵌套并不重要

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

    或右嵌套。一旦你遍历了嵌套到达(a ++)(++) 就会被提升到表达式树的顶部,因此获取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))))
    

    在此之后,每个列表都是顶级(++)在前一个列表被消耗后的最左边的操作数。

    其中重要的是,前置函数 (a ++) 可以在不检查其参数的情况下开始生成结果,以便重新关联

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

    通过

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

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

    不需要知道最终列表f的组合函数,所以只是O(depth)的重写。然后是顶层

         ($)
         / \
    (a ++)  stuff
    

    变成

     (++)
     /  \
    a    stuff
    

    并且a的所有元素都可以一步获取。在这个例子中,我们有纯左嵌套,只需要重写一次。如果不是(例如)(d ++),那个地方的函数是一个左嵌套组合,(((g ++) . (h ++)) . (i ++)) . (j ++),顶级重新关联将保持不变,当它成为顶部的左操作数时,它将重新关联-level ($) 在所有之前的列表都被消费完之后。

    所有重新关联所需的总工作量为O(number of lists),因此连接的总成本为O(number of lists + sum (map length lists))。 (这意味着你也可以通过插入大量左嵌套的([] ++) 来带来糟糕的性能。)

    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²) 连接行为。

    【讨论】:

    • -1 实际上并没有解释为什么差异列表操作总是线性的
    • 现在好点了吗? (P.S.:我很欣赏这个解释,并承认我在这一点上有点太短了。)
    • 显然,这提供了更多信息,但如果您有时间,解释差异列表的定义如何导致这种行为可能对初学者非常有帮助。
    • 我见过的差异列表的最佳解释!
    • 顺便说一句:谢谢@Marcin,你踢了我的身后。
    【解决方案2】:

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

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

    如您所见,为了连接两个列表,您需要遍历左侧列表并创建它的“副本”,这样您就可以更改其结尾(这是因为您不能直接更改结尾旧列表,由于不可变性)。

    如果您以正确的关联方式进行连接,则没有问题。一旦插入,列表将永远不必再次被触及(注意 ++ 的定义如何从不检查右侧的列表),因此每个列表元素仅插入“一次”,总时间为 O(N)。

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

    但是,如果您以左关联方式进行连接,那么每次您将另一个列表片段添加到末尾时,“当前”列表都必须“拆除”和“重建”。每个列表元素将在插入时迭代,以及无论何时追加未来的片段!这就像你在 C 中遇到的问题,如果你天真地连续多次调用 strcat。


    至于差异列表,诀窍在于它们在末尾保留了一个明确的“洞”。当您将 DList 转换回普通列表时,您会将您想要的内容传递给它,它就可以开始使用了。另一方面,普通列表总是用[] 堵住最后的漏洞,所以如果你想改变它(在连接时),那么你需要撕开列表才能到达那一点。

    用函数定义差异列表起初看起来很吓人,但实际上非常简单。您可以从面向对象的角度查看它们,将它们视为不透明对象“toList”方法,该方法接收您应该插入到孔中的列表,最后返回 DL 的内部前缀加上提供的尾部。这很有效,因为您只有在完成所有转换后才将“孔”堵在最后。

    【讨论】:

    • 这个答案对于 Haskell 来说是不准确的。使用惰性求值,左列表实际上不会重新创建。尽管如此,正如 Daniel Fischer 所解释的那样,向左关联仍然会导致访问列表头部的速度变慢。
    猜你喜欢
    • 2018-06-08
    • 2011-06-03
    • 2012-04-20
    • 2013-10-21
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2014-10-07
    • 2019-05-01
    相关资源
    最近更新 更多