我目前正在通过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以什么方式解决了这个问题?
答案 0 :(得分:72)
中的问题
((((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)。< / p>
事实上,只要你需要第一个元素,整个作品就会与差异列表重新关联,
((((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
中,则没有这样的效率保证。特别是,附加((++ a)
,包装或不包装)可以在组合右嵌套时创建(++)
的左嵌套树,因此如果{O(n²)
,则可以创建DiffList
连接行为。 {1}}构造函数已暴露。
答案 1 :(得分:7)
查看串联的定义可能会有所帮助:
[] ++ 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的内部前缀加上提供的尾部。这是有效的,因为你只需要在转换完所有内容后插入“洞”。