【问题标题】:Why don't F# lists have a tail pointer为什么 F# 列表没有尾指针
【发布时间】:2011-12-04 07:25:31
【问题描述】:

或者换一种说法,只有一个头指针的基本单链表有什么好处?我可以看到尾指针的好处是:

  • O(1) 列表连接
  • O(1) 在列表右侧追加内容

与 O(n) 列表连接(其中 n 是左侧列表的长度?)相反,这两者都是相当方便的东西。丢弃尾指针有什么好处?

【问题讨论】:

  • 我知道每次我想做一些有用的事情时我都可以反转列表,但这会将每个操作变成O(n) 操作。我想知道他们为什么不采摘这个低垂的果实并访问尾部O(1) =)
  • F# 列表是不可变的,所以无论你做什么,任何列表修改至少是O(n)
  • @RamonSnir:我认为不可变列表的意义在于您可以重复使用其中的一部分,这样您就可以在不到O(n)的时间内获得列表的“修改副本”
  • @LiHaoyi 但是你建议的操作会改变列表,因此不能用不可变列表“有效地”完成。在不改变列表的情况下唯一可以执行的操作是添加项目,这就是 (::) 运算符。
  • @LiHaoyi 一个 cons 项是一个值,然后是另一个 cons 项或 null。前置时(即h::t),您创建一个新的 cons 项,其值为h,而它的另一部分(另一个 cons 项或 null)为 t。此操作创建了一个新的 cons 项,但不影响t(因为每个 cons 项只指向“前进”,而不是“后退”)。但是当您执行t @ [h] 时,没有简单的方法可以创建您想要的列表,因为t 的结尾是空值,并且无法更改(不变性)。为了创建您想要的列表,我们需要复制t 并在末尾附加h

标签: f# linked-list


【解决方案1】:

F# 与许多其他函数式[-ish] 语言一样,具有cons-list(术语最初来自 LISP,但概念相同)。在 F# 中,:: 运算符(或 List.Cons)用于 cons'ing:注意签名是 ‘a –> ‘a list –> ‘a list(参见 Mastering F# Lists)。

不要将 cons-list 与包含离散 first[/last] 节点的不透明链接列表实现混淆 - cons-list 中的每个单元格都是 [不同] 列表的开始! 也就是说,“列表”只是从 给定 cons-cell 开始的单元链。

当以类似函数的方式使用时,这提供了一些优点:一个是所有“尾部”单元格都是共享的,并且因为每个 cons 单元格都是不可变的(“数据”可能是可变的,但这是一个不同的问题)无法更改“尾部”单元格并整理包含该单元格的所有其他列表。

由于这个属性,[新] 列表可以高效地 构建 - 也就是说,它们不需要副本 - 只需 cons 放在前面。此外,将列表解构为head :: tail 也是非常有效的——同样,没有副本——这在递归函数中通常非常有用。

此不可变属性通常不存在于 [标准可变] 双链表实现中,因为附加会添加副作用:内部“最后一个”节点(类型现在是不透明的)和“尾部”单元之一被改变。 (不可变的序列类型允许“有效的恒定时间”追加/更新,例如 immutable.Vector in Scala - 然而,与 cons-list 相比,这些是重量级对象,无非是一系列细胞聚集在一起。)

如前所述,cons-list 也有一些缺点,并不适用于所有任务 - 特别是,创建一个新列表,除了 cons'ing 到头部是一个 O(n) 操作,fsvo n,而且为了更好(或更糟)列表是不可变的。

我建议您创建自己的concat 版本,看看这个操作是如何真正完成的。 (文章Why I love F#: Lists - The Basics 涵盖了这一点。)

编码愉快。


另见相关帖子:Why can you only prepend to lists in functional languages?

【讨论】:

  • 我以前从未听说过这个名字。你有我可以跟进的链接吗?搜索“缺点列表”只会让我看到一大堆带有“优点和缺点列表”的页面。大概他们允许一些优化尾指针/双向链表不允许,我想找出什么
  • @Li Haoyi 这是一个LISP术语。我添加了一个链接。
【解决方案2】:

F# 列表是不可变的,没有“追加/连接”之类的东西,而只是创建新列表(可能重用旧列表的某些后缀)。不变性有很多优点,超出了这个问题的范围。 (所有纯语言和大多数函数式语言都有这种数据结构,它不是 F#-ism。)

另见

http://diditwith.net/2008/03/03/WhyILoveFListsTheBasics.aspx

有很好的图表来解释事情(例如,为什么在前面的 consing 比在不可变列表的末尾便宜)。

【讨论】:

  • 我的意思是将 concat 附加为表达式而不是语句。这不是很正常的使用方式吗?我不知道除了 concat 之外,我还能如何命名“listA = listB + listC”。不可变列表也可以有尾指针,并且连接速度更快!
  • 无论是表达式还是语句,如果列表是不可变的,则需要为连接的结果分配一个新的列表对象,而尾指针对性能没有帮助(除非您正在谈论为不可变列表提供快速连接的非常先进的数据结构。
【解决方案3】:

除了其他人所说的:如果您需要高效但不可变的数据结构(这应该是一种惯用的 F# 方式),您必须考虑阅读Chris Okasaki, Purely Functional Data Structures。还有一个thesis 可用(本书的基础)。

【讨论】:

    【解决方案4】:

    除了已经说过的,MSDN 上的Introducing Functional Programming 部分有一篇关于Working with Functional Lists 的文章,它解释了列表的工作原理并在 C# 中实现了它们,因此这可能是了解它们如何工作的好方法(以及为什么添加对最后一个元素的引用不允许高效实现追加)。

    如果您需要将内容附加到列表的末尾以及前面,那么您需要一个不同的数据结构。例如,Norman Ramsey 发布了 DList 的源代码,其中包含这些 properties here(实现不是惯用的 F#,但应该很容易修复)。

    【讨论】:

      【解决方案5】:

      如果您发现您想要一个对追加操作具有更好性能的列表,请查看 F# PowerPack 中的 QueueList 和 FSharpx 扩展库中的 JoinList

      QueueList 封装了两个列表。当您使用 cons 进行前置时,它会将一个元素添加到第一个列表中,就像一个 cons-list 一样。但是,如果要附加单个元素,可以将其推到第二个列表的顶部。当第一个列表中的元素用完时,List.rev 会在第二个列表上运行,然后交换两者,让您的列表恢复原状并释放第二个列表以追加新元素。

      JoinList 使用有区别的联合来更有效地附加整个列表,并且涉及更多。

      对于标准的 cons-list 操作,两者的性能显然较差,但在其他情况下提供更好的性能。

      您可以在文章Refactoring Pattern Matching 中阅读有关这些结构的更多信息。

      【讨论】:

      • 看起来 JoinList 与上面 Tomas 引用的 DList 或多或少相同,正如 Norman Ramsey 在链接问题中指出的那样,它起源于 John Hughes。
      【解决方案6】:

      正如其他人所指出的,F# 列表可以由数据结构表示:

      List<T> { T Value; List<T> Tail; }
      

      从这里开始,惯例是列表从您引用的List 开始,直到Tail 为空。基于该定义,其他答案中的好处/功能/限制自然而然。

      但是,您最初的问题似乎是为什么未将列表定义为:

      List<T> { Node<T> Head; Node<T> Tail; }
      Node<T> { T Value; Node<T> Next; }
      

      这样的结构将允许对列表进行附加和前置操作,而对原始列表的引用没有任何可见的影响,因为它仍然只能看到现在扩展列表的“窗口”。虽然这(有时)允许 O(1) 级联,但这样的功能会面临几个问题:

      • 串联只起作用一次。这可能会导致意外的性能行为,其中一个连接是 O(1),但下一个是 O(n)。比如说:

         listA = makeList1 ()
         listB = makeList2 ()
         listC = makeList3 ()
         listD = listA + listB //modified Node at tail of A for O(1)
         listE = listA + listC //must now make copy of A to concat with C
        

        您可能会争辩说,在可能的情况下节省时间是值得的,但不知道什么时候是 O(1) 以及什么时候是 O(n) 是反对该功能的有力论据。

      • 现在所有列表都占用两倍的空间,即使您从未打算连接它们。
      • 您现在有一个单独的列表和节点类型。在当前的实现中,我相信 F# 只使用一种类型,就像我的答案开头一样。可能有一种方法可以仅使用一种类型来执行您的建议,但这对我来说并不明显。
      • 连接需要改变原始的“尾”节点实例。虽然这不应该影响程序,但它是一个突变点,大多数函数式语言都倾向于避免。

      【讨论】:

        【解决方案7】:

        或者换一种说法,只有一个头指针的基本单链表有什么好处?我可以看到尾指针的好处是:

        • O(1) 列表连接
        • O(1) 在列表右侧追加内容

        与 O(n) 列表连接(其中 n 是左侧列表的长度?)相反,这两者都是相当方便的东西。

        如果“尾指针”是指从每个列表指向列表中最后一个元素的指针,则不能单独使用它来提供您所引用的任何一个好处。虽然您可以快速找到列表中的最后一个元素,但您无法对它做任何事情,因为它是不可变的。

        如你所说,你可以编写一个可变的双向链表,但可变性会使使用它的程序更难推理,因为你调用的每个函数都可能改变它。

        正如 Brian 所说,存在纯粹的功能性可连接列表。但是,它们在常见操作上比 F# 使用的简单单链表慢很多倍。

        丢弃尾指针有什么好处?

        几乎所有列表操作的空间使用量减少 30%,性能更佳。

        【讨论】:

          猜你喜欢
          • 1970-01-01
          • 2021-10-17
          • 2011-01-19
          • 2019-08-25
          • 1970-01-01
          • 2022-07-16
          • 2021-09-29
          • 1970-01-01
          • 2021-07-23
          相关资源
          最近更新 更多