有什么实际的方法可以使链接的节点结构不可变?


26

我决定编写一个单链接列表,并制定了使内部链接节点结构不可变的计划。

不过我遇到了障碍。说我有以下链接的节点(来自先前的add操作):

1 -> 2 -> 3 -> 4

并说我想附加一个5

为此,由于node 4是不可变的,因此我需要创建的新副本4,但将其next字段替换为包含的新节点5。现在的问题是3参考旧的4;没有附加的5。现在,我需要复制3,并替换其next字段以引用4副本,但是现在2引用的是旧版本3...

换句话说,要执行追加操作,似乎需要复制整个列表。

我的问题:

  • 我的想法正确吗?有什么办法可以执行追加操作而不复制整个结构?

  • 显然,“有效Java”包含以下建议:

    类应该是不可变的,除非有充分的理由使它们可变。

    这是变异性的好例子吗?

我认为这不是建议答案的重复,因为我不是在谈论清单本身。显然,它必须是可变的才能符合该接口(无需执行诸如在内部保留新列表并通过getter检索它的操作。不过,经过深思熟虑,即使这样也需要进行一些修改;将其保持在最低限度)。我说的是列表的内部是否必须不可变。


我会说是的-Java中罕见的不可变集合是具有被覆盖以抛出的变异方法的集合,或者是CopyOnWritexxx用于多线程的极其昂贵的类。没有人期望收藏确实是不可变的(尽管它确实会造成一些怪癖)
Ordous 2015年

9
(对报价的评论。)这是一个很好的报价,常常被人们误解。由于不变性应有的优点,因此应将其应用于ValueObject,但是,如果您使用Java开发,则在预期会有可变性的地方使用不变性是不切实际的。大多数情况下,一类具有某些不变的方面,并且某些基于需求是可变的。
rwong 2015年

2
相关(可能是重复的):集合对象是否更好是不变的?“在没有性能开销的情况下,可以将此集合(LinkedList类)引入为不可变集合吗?”
蚊蚋

为什么要创建一个不变的链表?链表的最大好处是可变性,使用数组会更好吗?
Pieter B

3
您能帮我了解您的要求吗?您想拥有一个不变的清单,您想要变异吗?这不是矛盾吗?您对列表的“内部”是什么意思?您是说先前添加的节点以后不应该被修改,而只允许追加到列表中的最后一个节点?
无效

Answers:


46

使用功能语言的列表时,几乎总是使用列表的头和尾,第一个元素以及其余部分。前置很常见,因为如您所料,追加需要复制整个列表(或其他与链接列表不完全相似的惰性数据结构)。

在命令式语言中,追加更为常见,因为它在语义上趋于自然,而且您不必担心使对列表的先前版本的引用无效。

作为为什么不要求复制整个列表的示例,请考虑您具有:

2 -> 3 -> 4

加上a 1将为您提供:

1 -> 2 -> 3 -> 4

但请注意,其他人是否仍将其引用2作为其列表的开头并不重要,因为列表是不可变的,链接仅以一种方式进行。1如果您仅参考,就无法告诉甚至在那里2。现在,如果将a附加5到任一列表中,则必须制作整个列表的副本,因为否则它也将出现在另一个列表中。


嗯,很好的解释了为什么不需要前缀。我没想到。
Carcigenicate

17

您是正确的,如果您不希望就地突变任何节点,则追加操作需要复制整个列表。因为我们需要设置next(现在)倒数第二个节点的指针,这在不可变的设置中会创建一个新节点,然后我们需要设置next倒数第二个节点的指针,依此类推。

我认为这里的核心问题不是不变性,也不是append不明智的行动。两者在各自的领域中都很好。混合它们是不好的:不可变列表的自然(有效)界面强调了列表的最前面的操作,但是对于可变列表,通过从头到尾依次添加项来构建列表通常更为自然。

因此,我建议您下定决心:您需要临时接口还是持久接口?大多数操作会产生一个新列表并留下未修改的版本以供访问(持久),还是编写类似以下的代码(临时):

list.append(x);
list.prepend(y);

两种选择都很好,但是实现应该反映接口:持久性数据结构得益于不可变的节点,而短暂的结构需要内部可变性才能真正实现其隐含的性能承诺。java.util.List和其他接口是短暂的:在不可变的列表上实现它们是不合适的,并且实际上会对性能造成危害。可变数据结构上的好的算法通常与不可变数据结构上的好的算法有很大的不同,因此将可变数据结构打扮成可变的(反之亦然)会导致不良算法。

虽然持久列表有一些缺点(没有有效的追加),但是在进行功能编程时并不一定是一个严重的问题:可以通过转移思维方式并使用诸如mapfold(列出两个相对原始的函数)等高阶函数来有效地制定许多算法。 ),或反复添加。此外,没有人会强迫您仅使用此数据结构:当其他(临时的或持久的但更复杂的)更为合​​适时,请使用它们。我还应该指出,持久性列表对于其他工作负载也有一些优点:它们共享尾巴,可以节省内存。


4

如果您有一个单链表,则与前者相比,如果前者更多,那么您将使用后者。

诸如prolog和haskel之类的功能语言提供了获取前端元素和数组其余部分的简便方法。追加到后面的是O(n)操作,每个节点都进行复制。


1
我用过Haskell。Afaik,它还通过使用惰性来规避部分问题。我正在执行附加操作,因为我认为这正是List接口所期望的(尽管那里我可能错了)。我也不认为这确实可以回答问题。整个列表仍然需要复制;因为不需要完全遍历,所以它只会使对最后添加的元素的访问更快。
Carcigenicate,2015年

根据与基础数据结构/算法不匹配的接口创建类,很容易导致效率低下。
棘轮怪胎

JavaDocs明确使用单词“ append”。您是说为了实现而最好忽略它?
Carcigenicate

2
@Carcigenicate不,我是说尝试将具有不可变节点的单链列表放入“java.util.List
棘轮怪胎

1
由于懒惰,将不再有链表。没有列表,因此没有突变(追加)。而是有一些代码可根据要求生成下一个项目。但是,不能在使用列表的任何地方使用它。即,如果您使用Java编写了一种方法,该方法希望使作为参数传入的列表发生突变,则该列表首先必须是可变的。生成器方法需要完整的代码重组(重组)才能使其正常工作-并使从物理上消除列表成为可能。
rwong 2015年

4

正如其他人指出的那样,正确的是,不可变的单链接列表在执行追加操作时需要复制整个列表。

通常,您可以使用根据cons(前置)操作实现算法的解决方法,然后一次反转最终列表。这仍然需要复制一次列表,但是复杂度开销在列表的长度上是线性的,而通过重复使用append可以轻松获得二次复杂度。

差异列表(参见例如此处)是一个有趣的选择。差异列表包装列表并在恒定时间内提供追加操作。基本上,只要需要添加包装,就可以使用包装器,完成后再转换回列表。这在某种程度上类似于使用a StringBuilder构造字符串时所执行的操作,最后String通过调用获得结果(不可变!)toString。一个差异是a StringBuilder是可变的,但差异列表是不可变的。同样,当您将差异列表转换回列表时,您仍然必须构造整个新列表,但是同样,您只需要执行一次即可。

实现一个DList提供与Haskell的接口相似的接口的不可变类应该非常容易Data.DList


4

您必须观看Immutable.js的创建者Lee Byron制作的精彩视频,来自2015 React conf 。它将为您提供指针和结构,以了解如何实现重复内容的有效不可变列表。基本思想是:-只要两个列表使用相同的节点(相同的值,相同的下一个节点),就使用相同的节点-当列表开始不同时,将在分歧节点处创建一个结构,该结构保存指向该节点的指针每个列表的下一个特定节点

这个来自react教程的图像可能比我坏掉的英语更清晰:在此处输入图片说明


2

严格来说,它不是Java,但我建议您阅读用Scala编写的不可变但性能良好的索引持久数据结构的本文:

http://www.codecommit.com/blog/scala/implementing-persistent-vectors-in-scala

由于它是一个Scala数据结构,因此也可以从Java中使用它(稍微有些冗长)。它基于Clojure中可用的数据结构,而且我敢肯定还有一个“本地” Java库也提供它。

另外,关于构造不可变数据结构的说明:通常需要的是某种“构建器”,它允许您通过在元素中附加元素(在单个线程内)来“突变”正在构造的数据结构。追加完成后,您可以在“正在构建中”的对象上调用方法,例如“” .build().result()“构建”该对象,从而为您提供了一个不变的数据结构,您可以安全地共享它。



1

有时可能有用的一种方法是拥有两类列表持有对象:一个前向列表对象,该对象final引用一个前向链接列表中的第一个节点;以及一个初始为空的非final引用(当非-null)标识以相反顺序保存相同项目的反向列表对象,以及反向列表对象,该对象具有final对反向链接列表的最后一项的引用以及最初为null的非最终引用(当非-null)标识以相同顺序保存相同项目的转发列表对象。

将项目添加到转发列表之前或将项目添加到反向列表仅需要创建一个链接到final引用所标识的节点的新节点,并创建一个与原始final引用类型相同的新列表对象。到那个新节点。

将项目添加到前向列表或添加到反向列表将需要具有相反类型的列表;第一次使用特定列表时,应创建相反类型的新对象并存储引用;重复该操作应重新使用列表。

请注意,无论列表对象对相反类型列表的引用为空还是标识相反顺序列表,其外部状态都将被视为相同。final即使使用多线程代码,也不需要做任何变量,因为每个列表对象都将final引用其内容的完整副本。

如果一个线程上的代码创建并缓存了列表的反向副本,并且缓存该副本的字段不是可变的,则另一线程上的代码可能看不到缓存的列表,但这是唯一的不利影响因此,另一个线程将需要做更多的工作来构建列表的另一个反向副本。因为这样的操作将最坏地影响效率,但是不会影响正确性,并且由于volatile变量会造成自身的效率降低,因此使变量具有非易失性并接受偶尔的冗余操作的可能性通常会更好。

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.