performance - 为什么差异列表比 Haskell 中的常规串联更有效?

标签 performance list haskell time-complexity difference-lists

我目前正在通过 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 的方式如何?解决这个问题?

最佳答案

问题在

((((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对于结果的每个元素(来自第一个列表,n-1 来自第二个等等),必须遍历树的节点。

让我们考虑一下 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'也是...

绘制树的一部分,冒泡是这样的:
            (++)
            /  \
         (++)   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]])来全面评估。

使用差异列表(为了简化说明而没有新型包装器),组合是否左嵌套并不重要
((((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 ++) ,顶级重新关联将保持不变,当它成为顶级 ($) 的左操作数时,它将重新关联。在之前的所有列表都被消耗掉之后。

所有重新关联所需的总工作为 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++))

请注意,如果任意函数包装在 DiffList 中,则它仅对不需要检查其参数即可开始生成输出的函数有效。 s,你没有这样的效率保证。特别是,追加( (++ a) ,无论是否包裹)可以创建 (++) 的左嵌套树当组合成右嵌套时,您可以创建 O(n²)如果 DiffList 的连接行为构造函数被暴露。

关于performance - 为什么差异列表比 Haskell 中的常规串联更有效?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/13879260/

相关文章:

python - 如何迭代不同列表的乘积?

haskell - 为什么我不能将查找结果与 Haskell 中的 Nothing 进行比较?

java - JFreeChart图表渲染问题

javascript - Table 和 3000+ tr 的 AngularJs 性能问题?

sql-server - 删除大量行

python - 列表/数组范围外的零

python - 将字符串拆分为整数

haskell - 哪个映射必须包含所有可能的键?

if-statement - Haskell 中有多重条件 if 吗?

mysql - 简单的 mysql join 执行时间太长