或者换句话说,使用一个只有头指针的基本单链表有什么好处?我可以看到尾指针的好处是:
- O(1)列表拼接
- O(1)将内容添加到列表的右侧
这两个都是非常方便的东西,而不是O(n)列表连接(其中n是左侧列表的长度?)。放下尾巴有什么好处?
f#像许多其他函数式[-ish]语言一样,有一个cons-list(术语最初来自LISP,但概念是相同的)。在f#中,::
运算符(或List.Cons
)用于控制:注意签名是‘a –> ‘a list –> ‘a list
(参见掌握f#列表)。
不要混淆cons-list和不透明的链表实现,后者包含一个离散的第一个[/最后一个]节点—— cons-list中的每个单元格都是一个[不同的]列表的开始!也就是说,"列表"就是给定 cons-cell,从开始的单元格链。
当以类似函数的方式使用时,这提供了一些优点:一是所有的"尾部"单元格都是共享的,并且由于每个cons-cell都是不可变的("数据"可能是可变的,但这是另一个问题),因此没有办法更改"尾部"单元格并弄乱包含该单元格的所有其他列表。
由于这个属性,[new]列表可以有效地构建——也就是说,它们不需要副本——只需通过将移到前面。此外,将列表解构为head :: tail
也是非常有效的——同样,没有复制——这在递归函数中通常非常有用。
这个不可变的属性通常不存在于[标准可变]双链表实现中,因为附加会增加副作用:内部的"最后"节点(类型现在是不透明的)和一个"尾"单元格被改变。有不可变序列类型,允许"有效恒定时间"的追加/更新,例如immutable。Scala中的Vector——然而,与cons-list相比,这些都是重量级对象,cons-list只不过是一系列单元格组合在一起。
如前所述,cons-list也有缺点,它并不适用于所有任务——特别是,创建一个新列表,除了cons指向头是一个O(n)操作,fsvo (n),而且无论如何,列表是不可变的。我建议创建自己的concat
版本,看看这个操作是如何完成的。(《为什么我喜欢f#:列表-基础》一文涵盖了这一点。)
幸福的编码。
参见相关文章:为什么在函数式语言中只能在列表前加上前缀?
f#列表是不可变的,没有"追加/连接"这样的东西,而只是创建新的列表(可能重用旧列表的一些后缀)。不变性有许多优点,超出了这个问题的范围。(所有纯语言和大多数函数式语言都有这种数据结构,它不是f# -ism。)
参见
http://diditwith.net/2008/03/03/WhyILoveFListsTheBasics.aspx有很好的图解来解释事情(例如,为什么不可变列表在前面比在后面更便宜)。
除了其他人所说的:如果您需要高效但不可变的数据结构(这应该是一种习惯的f#方式),您必须考虑阅读Chris Okasaki的《纯功能数据结构》。还有一篇论文(本书的基础)。
除了已经说过的,MSDN上的函数式编程介绍部分有一篇关于使用函数式列表的文章,解释了列表是如何工作的,并在c#中实现了它们,所以这可能是理解它们是如何工作的好方法(以及为什么向最后一个元素添加引用不能有效地实现append)。
如果您需要将内容添加到列表的末尾和前面,那么您需要使用不同的数据结构。例如,Norman Ramsey在这里发布了DList
的源代码,它具有这些属性(实现不是习惯的f#,但应该很容易修复)。
如果你发现你想要一个具有更好性能的追加操作列表,可以看看f# PowerPack中的QueueList
和FSharpx扩展库中的JoinList
。
QueueList
封装了两个列表。当使用cons前置时,它将一个元素前置到第一个列表,就像cons-list一样。但是,如果要添加单个元素,则可以将其推到第二个列表的顶部。当第一个列表的元素用完时,在第二个列表上运行List.rev
,这两个列表被交换,使列表恢复顺序,并释放第二个列表以添加新元素。
JoinList
使用一个区分联合来更有效地附加整个列表,并且有点复杂。
对于标准的cons-list操作,它们的性能明显较差,但在其他场景中提供了更好的性能。
您可以在文章重构模式匹配中阅读更多关于这些结构的信息。
正如其他人指出的那样,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#只使用一种类型,就像我回答的开头一样。可能有一种方法可以只使用一种类型来做你所建议的事情,但对我来说并不明显。
- 连接需要改变原来的"尾"节点实例。虽然这个不应该影响程序,但它是一个突变点,大多数函数式语言倾向于避免。
或者换句话说,拥有一个只有头指针的基本单链表有什么好处?我可以看到尾指针的好处是:
- O(1)列表拼接
- O(1)将内容添加到列表的右侧
与O(n)个列表连接(其中n是左侧列表的长度?)相比,这两个都是相当方便的东西。
If by "尾指针"您的意思是从每个列表指向列表中最后一个元素的指针,单独使用它不能提供您所引用的任何好处。尽管您可以快速获取列表中的最后一个元素,但由于它是不可变的,因此无法对其进行任何操作。
你可以像你说的那样写一个可变的双链表,但是可变性会使使用它的程序更加难以推理,因为你用它调用的每个函数都可能改变它。
正如Brian所说,有纯功能的可选列表。然而,在普通操作中,它们比f#使用的简单单链表慢很多倍。
去掉尾指针有什么好处?
在几乎所有的列表操作中减少30%的空间使用和更好的性能