【问题标题】:Why is `++` for Haskell List implemented recursively and costs O(n) time?为什么 Haskell List 的 `++` 是递归实现的并且花费 O(n) 时间?
【发布时间】:2015-08-21 22:41:37
【问题描述】:

据我了解,Haskell 中的 List 类似于 C 语言中的 Linked-List。

所以对于下面的表达式:

a = [1,2,3]
b = [4,5,6]
a ++ b

Haskell 以这样的递归方式实现它:

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

时间复杂度为O(n)..

但是,我想知道为什么我不能更有效地实现++

最有效的方法可能是这样的:

  1. 复制(fork)a,我们称之为a',在O(1)时间可能会有一些技巧来做到这一点

  2. 使a' 的最后一个元素指向b 的第一个元素。这可以在O(1) 时间轻松完成..

有人对此有想法吗?谢谢!

【问题讨论】:

  • 我喜欢依赖于“可能有一些技巧可以在恒定时间内做到这一点”的算法。 :)
  • 您使用的语言(“指针”、“复制”、“链表”)在 Haskell 语义的上下文中没有多大意义。 [a] 根本不是一个链表,尽管它可能类似于 GHC 在内存中的布局方式(或者它可能不是,或者它可能根本不存在于内存中)
  • 你打算如何在不花费 O(n) 时间的情况下复制 n 个东西?
  • @immibis @jalf 只复制后面要修改的部分,也就是a的最后一个元素
  • @hanfeisun 但是倒数第二个元素需要指向新的最后一个元素。然后倒数第三个元素需要指向新的倒数第二个元素。

标签: list pointers haskell recursion functional-programming


【解决方案1】:

这几乎就是递归解决方案所做的。 a 的复制需要 O(n)(其中 na 的长度。b 的长度不会影响复杂度)。

在 O(1) 时间内复制 n 元素列表确实没有“技巧”。

【讨论】:

    【解决方案2】:

    请参阅 copy(fork) 部分是问题所在 - 递归解决方案正是这样做的(您确实必须这样做,因为您必须调整 @ 中元素的所有指针987654321@名单。

    假设a = [a1,a2,a3]b 是一些列表。

    您必须创建a3 的新副本(我们称之为a3'),因为它现在不应再指向空列表,而是指向b 的开头。

    然后你必须复制倒数第二个元素a2,因为它必须指向a3',最后——出于同样的原因——你也必须创建a1的新副本(指向到a2')。

    这正是递归定义的作用——算法没有问题——数据结构有问题(串联不好)。

    如果您不允许可变性并且想要列表的结构,您真的无能为力。

    你在其他语言中有这个。如果它们提供不可变的数据——例如在.net 中,字符串是不可变的——那么字符串连接也存在与此处相同的问题(如果你连接很多字符串,你的程序将表现不佳)。有一些解决方法 (StringBuilder) 可以更好地处理内存占用 - 但当然这些不再是不可变的数据结构。

    【讨论】:

      【解决方案3】:

      没有办法在恒定时间内进行这种连接,仅仅是因为数据结构的不变性不允许这样做。


      您可能认为您可以执行类似于“cons”运算符 (:) 的操作,该操作将附加元素 x0 添加到列表 oldList=[x1,x2,x3]front (导致newList=(x0:oldLIst)) 无需浏览整个列表。但这只是因为你没有触及现有列表oldList,而只是引用它。

      x0  :  ( x1  :  ( x2  :  ( x3  :  [] )   )   )
      ^        ^
      newList  oldList
      

      但在您的情况下 (a ++ b),我们正在讨论更新数据结构深处的引用。您想用新的尾部b 替换1:(2:(3:[]))[1,2,3] 的显式形式)中的[]。只需数一下括号,您就会发现我们必须深入内部才能找到[]。这总是很昂贵,因为我们必须复制整个外部部分,以确保a 保持不变。在结果列表中,旧的a 会指向哪里以获得未修改的列表?

      1  :  ( 2  :  ( 3  :  b  )   )
      ^                     ^
      a++b                  b
      

      在相同的数据结构中这是不可能的。所以我们需要第二个:

      1  :  ( 2  :  ( 3  :  []  )   )
      ^
      a
      

      这意味着复制那些: 节点,这必然会花费第一个列表中提到的线性时间。因此,您提到的“copy(fork)”与您所说的不同,not 在 O(1) 中。


      制作一个a的副本(fork),我们称之为a',可能有一些技巧可以在O(1)时间内完成

      当您谈论在恒定时间内分叉某些东西的“技巧”时,您可能会考虑实际上不是制作完整副本,而是创建对原始 a 的引用,并将更改存储为“注释”(例如提示:“修改尾部:使用b 而不是[]”)。

      但这就是 Haskell 所做的,由于它的懒惰,无论如何!它不会立即执行 O(n) 算法,而只是“记住”您想要一个串联列表,直到您实际访问它的元素。但这并不能使您最终免于支付费用。因为即使一开始引用很便宜(在 O(1) 中,就像您想要的那样),当您访问实际的列表元素时,++ 运算符的每个实例都会增加一点开销(“解释您添加到参考中的注释)访问连接第一部分中的每个元素,最终有效地增加了 O(n) 成本。

      【讨论】:

        猜你喜欢
        • 2018-11-05
        • 1970-01-01
        • 2020-03-22
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多