为什么Haskell和Scheme使用单链接列表?


12

双链表具有最小的开销(每个单元格只有另一个指针),并且允许您附加到两端并来回移动,通常会很有趣。


list构造函数可以插入到单链接列表的开头,而无需修改原始列表。这对于函数式编程很重要。双链表几乎涉及修改,但不是很纯正。
tp1

3
想一想,您将如何构建一个双向链接的不可变列表?您需要next使上一个元素的prev指针指向下一个元素,而下一个元素的指针指向上一个元素。但是,这两个元素之一先于另一个元素创建,这意味着这些元素之一需要具有一个指向尚不存在的对象的指针!请记住,您不能先创建一个元素,然后再创建另一个元素,然后再设置指针–它们是不可变的。(注意:我知道有一种方法可以利用懒惰,称为“打结”。)
JörgW Mittag

1
在大多数情况下,通常不需要双链表。如果需要反向访问它们,请将列表中的项目推入堆栈,然后将它们逐一弹出以执行O(n)反向算法。
尼尔

Answers:


23

好吧,如果您看起来更深一点,实际上它们都还包括基本语言中的数组:

  • 第五次修订的计划报告(R5RS)包括向量类型,它们是固定大小的整数索引集合,随机访问的时间要好于线性时间。
  • Haskell 98报告也具有数组类型

但是,功能性编程指令长期以来一直强调数组而不是单链表或双链表。实际上,很可能过分强调了。但是,有几个原因。

第一个是单链接列表是最简单但也是最有用的递归数据类型之一。用户定义的等效于Haskell列表类型的定义如下:

data List a           -- A list with element type `a`...
  = Empty             -- is either the empty list...
  | Cell a (List a)   -- or a pair with an `a` and the rest of the list. 

列表是递归数据类型这一事实意味着,处理列表的函数通常使用结构递归。用Haskell术语:在列表构造函数上进行模式匹配,然后在列表的子部分递归。在这两个基本函数定义中,我使用变量as来引用列表的末尾。因此请注意,递归调用在列表中“下降”:

map :: (a -> b) -> List a -> List b
map f Empty = Empty
map f (Cell a as) = Cell (f a) (map f as)

filter :: (a -> Bool) -> List a -> List a
filter p Empty = Empty
filter p (Cell a as)
    | p a = Cell a (filter p as)
    | otherwise = filter p as

这种技术保证您的函数将在所有有限列表中终止,并且是一种很好的解决问题的技术-它倾向于将问题自然地分解为更简单,更易处理的子部分。

因此,单链列表可能是向学生介绍这些技术的最佳数据类型,这在函数式编程中非常重要。

第二个原因不是“为什么是单链表”原因,而是“为什么不是双链表或数组”原因:后一种数据类型通常需要突变(可修改的变量),这在函数编程中非常常见。避开。碰巧的是:

  • 在像Scheme这样的热切语言中,如果不使用mutation,就无法创建一个双向链接列表。
  • 在像Haskell这样的惰性语言中,您可以创建一个双向链接列表,而无需使用突变。但是,每当您基于该列表创建一个新列表时,就必须复制大多数(如果不是全部)原始结构。使用单链接列表,您可以编写使用“结构共享”的函数,而新列表可以在适当时重用旧列表的单元格。
  • 传统上,如果您以不变的方式使用数组,则意味着每次您想要修改数组时,都必须复制整个内容。(vector但是,最近的Haskell库(例如)发现了可以大大改善此问题的技术)。

第三个也是最后一个原因主要适用于像Haskell这样的惰性语言:在实践中,惰性单链接列表实际上更类似于迭代器,而不是适用于内存中列表。如果您的代码按顺序使用列表元素并将其丢弃,那么当您逐步浏览列表时,目标代码将仅实现列表单元格及其内容。

这意味着整个列表不需要一次存在于内存中,只需当前单元即可。可以对当前单元格之前的单元格进行垃圾收集(使用双链表是不可能的);直到当前单元格才需要计算比当前单元格晚的单元格。

它甚至远不止于此。在几种流行的Haskell库中使用了一种称为融合的技术,在该库中,编译器分析您的列表处理代码,并发现依次生成和使用的中间列表,然后“丢弃”中间列表。有了这些知识,编译器就可以完全消除这些列表单元的内存分配。这意味着在编译后,Haskell源程序中的单链接列表实际上可能变成循环而不是数据结构。

融合也是上述vector库用来为不可变数组生成有效代码的技术。极为流行的bytestring(字节数组)和text(Unicode字符串)库也是如此,它们是用来代替Haskell的不是很好的本机String类型([Char]与字符的单链接列表相同)的。因此,在现代的Haskell中,存在一种趋势,即带有融合支持的不可变数组类型变得非常普遍。

在单链接列表中,您可以前进但不能向后退,这有利于列表融合。这在函数式编程中提出了一个非常重要的主题:使用数据类型的“形状”派生计算的“形状”。如果要顺序处理元素,则单链接列表是一种数据类型,当您通过结构递归使用它时,它会非常自然地为您提供该访问模式。如果您想使用“分而治之”的策略来解决问题,那么树数据结构将很好地支持这一点。

很多人很早就退出了函数式编程旅行,因此他们接触了单链接列表,但没有接触更高级的基础思想。


1
真是个好答案!
Elliot Gorokhovsky

14

因为它们在不变性方面表现良好。假设您有两个不可变的列表,[1, 2, 3][10, 2, 3]。表示为单链接列表,其中列表中的每个项目都是一个节点,其中包含该项目以及指向列表其余部分的指针,它们看起来像这样:

node -> node -> node -> empty
 1       2       3

node -> node -> node -> empty
 10       2       3

看看各[2, 3]部分如何相同?对于可变数据结构,它们是两个不同的列表,因为将新数据写入其中一个的代码不必影响使用另一个数据的代码。但是,对于不可变的数据,我们知道列表的内容将永远不会改变,并且代码无法写入新数据。因此,我们可以重复使用尾部并使两个列表共享其结构的一部分:

node -> node -> node -> empty
 1      ^ 2       3
        |
node ---+
 10

由于使用这两个列表的代码将永远不会使它们变异,因此我们不必担心更改一个列表会影响另一个列表。这也意味着,将项目添加到列表的最前面时,您不必复制并创建一个新的列表。

但是,如果你尝试代表[1, 2, 3][10, 2, 3]双向链表:

node <-> node <-> node <-> empty
 1       2       3

node <-> node <-> node <-> empty
 10       2       3

现在,尾巴不再相同了。第[2, 3]一个指针指向1头部,第二个指针指向10。此外,如果要在列表的开头添加新项目,则必须对列表的前一个标题进行突变以使其指向新的标题。

通过让每个节点存储一个已知头列表并创建新列表来修改多头问题,可以解决该问题,但是当列表的版本不同时,您必须维护该列表以进行垃圾回收周期由于在不同的代码段中使用,因此具有不同的生存期。它增加了复杂性和开销,并且在大多数情况下不值得。


8
但是,尾巴共享不会像您暗示的那样发生。通常,没有人会遍历内存中的所有列表,而是寻找合并常见后缀的机会。共享就发生了,它脱离了算法的编写方式,例如,如果带有参数的xs函数1:xs在一个地方和10:xs另一个地方构造。

0

@sacundim的回答大部分是正确的,但是在权衡语言设计和实际要求方面还有其他一些重要见解。

对象和引用

这些语言通常授权(或假设)对象具有未结合的动态区段(或C中的说法,寿命,虽然不是完全相同的,由于的意思的差异的对象这些语言中,见下文)由缺省情况下,避免了第一类的引用(例如,C语言中的对象指针)和语义规则中的不可预测行为(例如,ISO C中与语义有关的未定义行为)。

此外,此类语言中的(一流)对象的概念在保守上受到限制:默认情况下,未指定和保证“位置”属性。这在某些对象没有不受限制的动态范围(例如,在C和C ++中)的类似于ALGOL的语言中是完全不同的,其中对象基本上是指某种“类型化存储”,通常与内存位置结合在一起。

对对象内的存储进行编码还有其他好处,例如能够在其整个生命周期内附加确定性的计算效果,但这是另一个主题。

数据结构仿真问题

没有一流的引用,由于这些数据结构表示的性质以及这些语言中有限的原始操作,单链接列表无法有效且可移植地模拟许多传统(急切/可变)数据结构。(相反,在C语言中,即使在严格符合标准的程序中,您也可以很容易地获得链表。)与实践中的单链表相比,诸如数组/向量之类的替代数据结构确实具有一些优越的性能。因此,R 5 RS引入了新的原始操作。

但是向量/数组类型与双向链接列表确实存在差异。通常假定数组具有O(1)访问时间复杂度和较少的空间开销,这是列表无法共享的出色属性。(尽管严格来说,ISO C不能保证两者,但是用户几乎总是希望得到它,并且没有实际实现会明显违反这些隐式保证。)OTOH,一个双向链接的列表通常会使这两个属性甚至比单独链接的列表更糟糕。 ,而数组或向量(以及整数索引)也以更少的开销支持向后/向前迭代。因此,双链表通常不会表现更好。更糟的是,当使用底层实现环境(例如libc)提供的默认分配器时,有关列表动态内存分配的缓存效率和延迟的性能比数组/矢量的性能灾难性地差。因此,如果没有非常具体且“灵巧”的运行时充分优化此类对象的创建,则数组/向量类型通常是链表的首选。(例如,使用ISO C ++,需要注意的是std::vector应首选std::list默认。)因此,引入新的原语来具体支撑件(doubly-)链表绝对不是以支持阵列/在实践矢量数据结构,从而是有利的。

公平地说,列表仍然具有比数组/向量更好的一些特定属性:

  • 列表是基于节点的。从列表中删除元素不会使对其他节点中其他元素的引用无效。(对于某些树或图形数据结构也是如此。)OTOH,数组/向量可以引用无效的尾随位置(在某些情况下需要大量重新分配)。
  • 列表可以在O(1)时间内拼接。用当前阵列/向量重建新阵列/向量的成本要高得多。

但是,对于具有内置单链接列表支持的语言而言,这些属性并不是太重要,后者已经可以使用这种功能。尽管仍然存在差异,但在具有动态范围强制性的对象的语言中(通常意味着有垃圾收集器将悬空的引用拒之门外),根据意图的不同,失效也可能不那么重要。因此,双链列表获胜的唯一情况可能是:

  • 既需要非重新分配保证,又需要双向迭代要求。(如果元素访问的性能很重要并且数据集足够大,那么我将选择二进制搜索树或哈希表。)
  • 需要有效的双向拼接操作。这是相当罕见的。(我仅满足在浏览器中实现线性历史记录之类的要求。)

不变性和混叠

在像Haskell这样的纯语言中,对象是不可变的。Scheme的对象通常无需更改即可使用。这样的事实使得有可能通过对象实习有效地提高存储效率-动态共享具有相同值的多个对象的隐式共享。

这是语言设计中一种积极的高级优化策略。但是,这确实涉及实施问题。实际上,它将隐式别名引入底层存储单元。这使别名分析更加困难。结果,消除非一流参考的开销的可能性可能较小,即使用户根本不去碰它们也是如此。在像Scheme这样的语言中,一旦不能完全排除突变,这也会干扰并行性。不过,使用懒惰的语言可能还可以(无论如何,它已经存在由于重击而导致的性能问题)。

对于通用编程,这种语言设计选择可能会出现问题。但是使用一些常见的功能编码模式,这些语言似乎仍然可以正常工作。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.