在什么情况下链表有用?


110

在大多数情况下,我看到人们尝试使用链接列表,在我看来,这是一个糟糕的选择。探索链表是否是数据结构的好选择的环境可能会很有用。

理想情况下,答案应阐明选择数据结构时使用的标准,以及在特定情况下哪种数据结构最有效。

编辑:我必须说,不仅数字,而且答案的质量给我留下了深刻的印象。我只能接受一个,但是如果还没有更好的东西的话,我还要说另外两三个。只有一对夫妇(尤其是我最终接受的一对夫妇)指出了链表提供真正优势的情况。我确实认为,史蒂夫·杰索普(Steve Jessop)不仅提出了一个答案,而且提出了三个不同的答案,因此值得得到荣誉奖,我发现所有这些都给人留下了深刻的印象。当然,即使发布的内容仅是评论而不是答案,我认为尼尔的博客条目也很值得阅读-不仅内容丰富,而且也很有趣。


34
第二段的答案大约需要一个学期。
塞瓦·阿列克谢耶夫

2
我的观点是,请参阅punlet.wordpress.com/2009/12/27/letter-the-fourth。由于这似乎是一项调查,因此可能应该是CW。

1
@Neil,很好,尽管我怀疑CS Lewis会赞成。
汤姆(Tom)2010年

@Neil:我想是一种调查。通常,这是一种尝试,看是否有人能提出一个我至少可以购买的合理答案。@Seva:是的,重新阅读它,我使最后一句话比原先的意图更笼统。
杰里·科芬

2
@Yar People(包括我,我很抱歉地说)曾经使用像FORTRAN IV(没有指针的概念)之类的语言来实现没有指针的链接列表,就像它们做树一样。您使用了数组而不是“实际”内存。

Answers:


40

它们对于并发数据结构很有用。(现在下面有一个非并行的实际用法示例-如果@Neil没有提到FORTRAN ,该示例将不存在。;-)

例如,ConcurrentDictionary<TKey, TValue>在.NET 4.0 RC中,使用链接列表将散列到同一存储桶的项目链接在一起。

的基础数据结构ConcurrentStack<T>也是一个链表。

ConcurrentStack<T>是作为新线程池(基于本地“队列”的实现基本上是堆栈)的基础的数据结构之一。(另一个主要支持结构是ConcurrentQueue<T>。)

新的线程池又为新的任务并行库的工作调度提供了基础 。

因此,它们肯定是有用的-链表目前是至少一种出色的新技术的主要支撑结构之一。

(在这些情况下,单链接列表会提供令人信服的无(而不是无等待)选择,因为主要操作可以使用单个CAS(+重试)进行。在现代GC-d环境中,例如Java和.NET- ABA问题很容易避免,只需包装您在新创建的节点中添加的项目,并且不重复使用这些节点-让GC进行工作即可。ABA问题的页面还提供了锁的实现免费堆栈-实际上可以在.Net(&Java)中工作,并带有(GC-ed)节点来保存项目。)

编辑:@Neil:实际上,您提到的FORTRAN提醒我,在.NET中可能最常用和滥用的数据结构中可以找到相同类型的链表:普通的.NET泛型Dictionary<TKey, TValue>

一个数组中没有一个,但是有很多链表。

  • 它避免对插入/删除执行许多小的(取消)分配。
  • 哈希表的初始加载非常快,因为该数组是按顺序填充的(与CPU缓存配合使用非常好)。
  • 更不用说链哈希表在内存方面很昂贵-这个“技巧”将x64上的“指针大小”减少了一半。

本质上,许多链接列表存储在数组中。(每个使用的存储桶一个。)在它们之间“交织”一个免费的可重用节点列表(如果有删除的话)。数组在重新哈希开始/启动时分配,并且链的节点保留在其中。删除之后还有一个自由指针-数组索引。;-)所以-信不信由你-FORTRAN技术仍然存在。(...最常见的.NET数据结构之一;-)。


2
万一您错过了,这里是Neil的评论:“人们(包括我,我很抱歉地说)过去常常使用FORTRAN IV(没有指针的概念)之类的语言在没有指针的情况下实现链接列表。您使用了数组而不是“实际”内存。
安德拉斯·瓦斯

我应该补充一点,如果Dictionary在.NET中保存的内容多得多,则采用“数组中的链接列表”方法:否则,每个节点在堆上都需要一个单独的对象-堆上分配的每个对象都有一些开销。(en.csharp-online.net/Common_Type_System%E2%80%94Object_Layout
安德拉什Vass的

很高兴知道C ++的默认值std::list在没有锁的多线程上下文中并不安全。
Mooing Duck

49

当您需要对任意长度(在编译时未知)的列表进行大量插入和删除操作,但又不需要太多搜索时,链接列表非常有用。

拆分和合并(双向链接)列表非常有效。

您还可以组合链接列表-例如,树结构可以实现为将水平链接列表(同级)连接在一起的“垂直”链接列表(父/子关系)。

将基于数组的列表用于这些目的有严格的限制:

  • 添加新项目意味着必须重新分配阵列(或者您必须分配比您所需的更多空间,以允许将来增长并减少重新分配的数量)
  • 删除项目会浪费空间或需要重新分配
  • 在末尾以外的任何地方插入项目都涉及(可能重新分配和)将大量数据复制到一个位置

5
因此问题就变成了,什么时候您需要在序列的中间进行很多插入和删除操作,但是按序号在列表中查找的次数不是很多?遍历链表通常比复制数组昂贵或昂贵,因此,您说的有关在数组中删除和插入项目的所有内容对于列表中的随机访问同样有害。LRU缓存是我能想到的一个示例,您需要在中间进行很多删除,但是您无需遍历整个列表。
史蒂夫·杰索普

2
添加到列表涉及为您添加的每个元素分配内存。这可能涉及非常昂贵的系统调用。如果必须增加数组,则添加到数组仅需要进行这样的调用。实际上,在大多数语言中(正是出于这些原因),数组是首选的数据结构,并且几乎根本不使用列表。

1
假设哪个?这种分配的惊人速度是显而易见的-通常需要将对象大小添加到指针。GC的总开销低吗?上一次我尝试在真实的应用程序上对其进行测量时,关键是Java在处理器空闲时仍在进行所有工作,因此自然不会对可见性能产生太大影响。在繁忙的CPU基准测试中,很容易使Java崩溃,并且在最坏情况下分配时间非常糟糕。但是,这是很多年前的事,自那以后,分代垃圾回收显着降低了GC的总成本。
史蒂夫·杰索普

1
@Steve:关于列表和数组之间的分配“相同”,您错了。每次需要为列表分配内存时,您只需分配一个小块-O(1)。对于数组,您必须为整个列表分配一个足够大的新块,然后复制整个列表-O(n)。要插入列表中的已知位置,您需要更新固定数量的指针-O(1),但是要插入到数组中并复制以后的所有项目,将其向上复制一个位置以为插入留出空间-O(n)。因此,在许多情况下,数组的效率比LL低得多。
杰森·威廉姆斯

1
@杰里:我明白。我的观点是,重新分配数组的大部分成本不是分配内存,而是需要将整个数组的内容复制到新的内存中。要插入数组的项目0,必须将整个数组的内容复制到内存中的一个位置。我并不是说数组是坏的。只是在某些情况下不需要随机访问,并且最好使用真正恒定时间的LL插入/删除/重新链接。
杰森·威廉姆斯

20

链接列表非常灵活:通过修改一个指针,您可以进行大量更改,而同一操作在数组列表中的效率将非常低。


是否有可能激发人们为什么只使用列表而不使用集合或地图?
patrik '16

14

数组是通常比较链表的数据结构。

当您必须对列表本身进行大量修改而数组的性能要优于直接访问元素的列表时,通常情况下链表很有用。

与相对操作成本(n =列表/数组长度)相比,这是可以对列表和数组执行的操作的列表:

  • 添加元素:
    • 在列表上,您只需要为新元素分配内存并重定向指针。O(1)
    • 在阵列上,您必须重新放置阵列。上)
  • 删除元素
    • 在列表上,您只需重定向指针。O(1)。
    • 在数组上,如果要删除的元素不是数组的第一个或最后一个元素,则花费O(n)时间重新放置数组;否则,您可以简单地将指针重新定位到数组的开头或减小数组的长度
  • 使元素处于已知位置:
    • 在列表上,您必须将列表从第一个元素移到特定位置的元素。最坏的情况:O(n)
    • 在数组上,您可以立即访问元素。O(1)

这是对这两种常用数据结构和基本数据结构的非常低级的比较,您可以看到列表在必须对其自身进行大量修改(删除或添加元素)的情况下表现更好。另一方面,当您必须直接访问数组的元素时,数组的性能要优于列表。

从内存分配的角度来看,列表是更好的,因为不需要将所有元素彼此相邻。另一方面,存储指向下一个(甚至指向前一个)元素的指针的开销很小(很少)。

了解这些差异对于开发人员在实现中的列表和数组之间进行选择非常重要。

请注意,这是列表和数组的比较。对于此处报告的问题,有很好的解决方案(例如:SkipLists,Dynamic Arrays等)。在这个答案中,我考虑了每个程序员都应该知道的基本数据结构。


对于列表的良好实现和数组的糟糕实现,这确实是正确的。大多数阵列实现都比您认为的要复杂得多。而且我认为您不了解动态内存分配的代价如何。

该答案不应涵盖数据结构大学课程的课程。这是比较时考虑到的链接列表和数组而编写的比较,这些列表和数组是按照您,我和大多数人所知道的方式实现的。几何扩展数组,跳过列表等是我知道,我使用和研究过的解决方案,但这需要更深入的说明,并且不适合使用stackoverflow的答案。
Andrea Zilio 2010年

1
“从内存分配的角度来看,列表是更好的,因为不需要将所有元素彼此相邻。” 相反,连续的容器更好,因为它们使元素彼此相邻。在现代计算机上,数据局部性是至上的。内存中的所有跳跃都会破坏您的缓存性能,并导致在(有效)随机位置插入元素的程序在使用动态数组(例如C ++)时std::vector比在链接列表(例如C ++ )中执行得更快,这std::list仅仅是因为遍历清单太贵了。
大卫·斯通

@DavidStone也许我还不够清楚,但是用那句话我指的是这样的事实,即您不需要有连续的空间来存储元素。具体来说,如果您要存储的东西不是太小,并且可用内存有限,则可能没有足够的连续可用空间来存储数据,但是您可能可以使用列表来代替数据(即使您会有指针的开销) ...两者都是由于占用的空间和您提到的性能问题而引起的。我可能应该更新答案以使其更清楚。
Andrea Zilio'5

4

对于单元格分配器或对象池中的空闲列表,单链接列表是一个不错的选择:

  1. 您只需要一个堆栈,因此单链列表就足够了。
  2. 一切都已经分为节点。如果单元格足够大以包含指针,则侵入列表节点没有分配开销。
  3. 向量或双端队列会给每个块带来一个指针的开销。考虑到当您第一次创建堆时,所有单元都是免费的,因此这是很重要的,因此这是前期成本。在最坏的情况下,它会使每个单元的内存需求增加一倍。

好吧,同意。但是实际上有多少程序员在创建此类东西?大多数只是简单地重新实现std :: list等给您的东西。实际上,“侵入式”通常与您所赋予的含义略有不同-每个可能的列表元素都包含一个与数据分开的指针。

1
多少?大于0,小于一百万;-) Jerry的问题是“良好使用列表”,还是“每个程序员每天都使用良好列表”,还是介于两者之间?我不知道包含在作为列表元素的对象中的列表节点的“侵入式”以外的任何其他名称-是否作为联合的一部分(用C术语表示)。第3点仅适用于让您做到的语言-C,C ++,汇编程序很好。Java不好。
史蒂夫·杰索普

4

双链列表是定义hashmap的顺序的好选择,该顺序还定义了元素的顺序(Java中的LinkedHashMap),尤其是在最后一次访问时:

  1. 比关联的向量或双端队列更多的内存开销(2个指针而不是1个指针),但是插入/删除性能更好。
  2. 无需分配开销,因为无论如何您都需要一个哈希条目节点。
  3. 与指针的矢量或双端队列相比,引用的局部性没有其他问题,因为您必须将每个对象以任何一种方式拉入内存。

当然,与更复杂和可调整的方法相比,您首先可以争论一下LRU缓存是否是一个好主意,但是如果要使用LRU缓存,这是一个相当不错的实现。您不想在每次读取访问时在向量上执行中间删除并添加到末端或双端队列,但是将节点移到尾部通常很好。


4

当您无法控制数据的存储位置时,链表是很自然的选择之一,但是您仍然需要以某种方式从一个对象到达另一个对象。

例如,当在C ++中实现内存跟踪(新的/删除替换)时,您要么需要一些控制数据结构,以跟踪释放了哪些指针,您完全需要自己实现。替代方法是总体化并将链接列表添加到每个数据块的开头。

因为您总是立即知道,在调用delete时您在列表中的位置,因此可以轻松地放弃O(1)中的内存。在O(1)中还添加了刚刚被分配的新块。在这种情况下,很少需要遍历列表,因此这里的O(n)成本不是问题(无论如何遍历结构都是O(n))。


3

当您需要高速推,弹出和旋转并且不介意O(n)索引时,它们很有用。


与(例如)双端队列相比,您是否曾经费心时间C ++链表?

@Neil:不能说我有。
伊格纳西奥·巴斯克斯

@Neil:如果C ++故意破坏了它的链表类以使其比其他任何容器都慢(这与事实不远),那么与语言不可知的问题有什么关系?侵入式链表仍然是链表。
史蒂夫·杰索普

@Steve C ++是一种语言。我看不出它怎么会有意志。如果您建议C ++委员会的成员以某种方式破坏了链表(在逻辑上,对于许多操作而言,链表必须很慢),那么请说出有罪的人!

3
这并不是真正的破坏活动-外部列表节点有其优势,但性能并不是其中之一。但是,在权衡您所知道的同一件事时,肯定每个人都知道,这是很难很好地利用的std::list。侵入式列表不符合容器元素的最低要求的C ++哲学。
史蒂夫·杰索普

3

单链列表是功能编程语言中常见的“列表”数据类型的明显实现:

  1. 添加到头部非常快,(append (list x) (L))并且(append (list y) (L))可以共享几乎所有数据。无需使用没有写操作的语言进行写时复制。函数式程序员知道如何利用这一点。
  2. 不幸的是,添加到尾部的速度很慢,但其他实现也会如此。

相比之下,向量或双端队列在任一端添加通常会比较慢,至少(在我的两个截然不同的示例中)要求复制整个列表(向量)或索引块和数据块被附加到(双端队列)。实际上,出于某些原因,在大型列表上可能有一些要说的是双端队列,由于某些原因,确实需要在末尾添加该队列,我对函数式编程的了解不足。


3

链表的一个很好用法的例子是,链表元素很大,即。足够大,以至只能同时容纳一两个CPU缓存。在这一点上,连续的块容器(如用于迭代的矢量或数组)具有的优势或多或少都被取消了,并且如果实时发生许多插入和删除操作,则可能具有性能优势。


2

根据我的经验,实现稀疏矩阵和斐波那契堆。链接列表使您可以更好地控制此类数据结构的整体结构。虽然我不确定稀疏矩阵是否最好使用链接列表来实现-也许有更好的方法,但是它确实有助于在Undergrad CS中使用链接列表来学习稀疏矩阵的来龙去脉:)


1

有两个互补操作,它们在列表上很容易成为O(1),并且很难在其他数据结构的O(1)中实现-假定需要维护元素的顺序,则可以从任意位置删除和插入元素。

哈希映射显然可以在O(1)中进行插入和删除,但是您不能按顺序遍历元素。

鉴于上述事实,哈希映射可以与链表结合使用,以创建一个漂亮的LRU缓存:一种映射,该映射存储固定数量的键值对,并删除最近访问最少的键,以便为新键腾出空间。

哈希图中的条目需要具有指向链接列表节点的指针。访问哈希图时,链接列表节点从其当前位置取消链接,并移动到列表的开头(O(1),是链接列表的意思!)。当需要删除最近最少使用的元素时,需要删除列表尾部的元素(再次假设您保持指向尾部节点的指针为O(1))以及相关的哈希映射条目(因此从哈希映射的列表是必需的。)


1

考虑到链表在系统的域驱动设计风格实现中可能非常有用,该实现中包含与重复互锁的部分。

想到的一个例子可能是您要对吊链进行建模。如果您想知道任何特定链接上的张力,您的界面可以包含用于“表观”重量的吸气剂。其实现将包括一个链接,要求其下一个链接提供其表观权重,然后将其自身的权重添加到结果中。这样,将通过链的客户端的一次调用来评估直到底部的整个长度。

作为喜欢像自然语言那样阅读的代码的支持者,我喜欢这将如何使程序员询问链节其承受的重量。它还使您不必担心在链接实现的边界内计算这些属性的孩子,而无需使用链权重计算服务。”


1

我发现链表在网格和图像处理,物理引擎和光线跟踪等性能至关重要的领域中工作时,最有用的情况之一是,使用链表实际上可以提高引用的局部性并减少堆分配,有时甚至可以减少内存使用量。直截了当的选择。

现在看来好像是一个完整的矛盾词,因为链表通常因其相反而臭名昭著,因此链表可以执行所有操作,但是它们具有独特的属性,因为每个链表节点都有固定的大小和对齐要求,我们可以利用该要求来允许它们将被连续存储,并以可变大小的东西无法固定的方式以恒定的时间删除。

结果,让我们来做个比喻,就是存储一个包含一百万个嵌套的可变长度子序列的可变长度序列。一个具体的示例是一个索引网格,该网格存储一百万个多边形(一些三角形,一些四边形,一些五边形,一些六边形等),有时从网格中的任何位置删除多边形,有时重建多边形以将顶点插入现有多边形或删除一个。在这种情况下,如果我们存储一百万个tiny std::vectors,那么最终将面临每个向量的堆分配以及潜在的爆炸性内存使用。SmallVectors在一般情况下,一百万个微小对象可能不会遭受那么多的问题,但是然后,未单独进行堆分配的预分配缓冲区仍然可能导致爆炸性的内存使用。

这里的问题是一百万 std::vector实例将试图存储一百万个可变长度的东西。可变长度的东西倾向于需要堆分配,因为如果它们没有将内容存储在堆中的其他地方,它们将不能非常有效地连续存储并不能以恒定时间(至少在没有非常复杂的分配器的情况下以直接方式)被删除。

相反,如果我们这样做:

struct FaceVertex
{
    // Points to next vertex in polygon or -1
    // if we're at the end of the polygon.
    int next;
    ...
};

struct Polygon
{
     // Points to first vertex in polygon.
    int first_vertex;
    ...
};

struct Mesh
{
    // Stores all the face vertices for all polygons.
    std::vector<FaceVertex> fvs;

    // Stores all the polygons.
    std::vector<Polygon> polys;
};

...然后我们大大减少了堆分配和缓存未命中的数量。现在,我们只需要在存储在整个网格中的两个向量之一超过其容量(摊余成本)的情况下,才需要进行堆分配,而不必为访问的每个多边形都要求堆分配和可能的强制高速缓存未命中。尽管从一个顶点到另一个顶点的步幅仍可能导致其缓存未命中的份额,但与每个多边形存储一个单独的动态数组相比,它仍然通常要少得多,因为节点是连续存储的,并且有可能相邻的顶点可能在逐出之前可以访问(特别是考虑到许多多边形将一次添加所有顶点,这使得多边形顶点的绝大部分完美连续)。

这是另一个示例:

在此处输入图片说明

...格网单元用于加速粒子与粒子的碰撞,例如每单帧移动1600万个粒子。在该粒子网格示例中,使用链接列表,我们只需更改3个索引就可以将粒子从一个网格单元移动到另一个网格单元。从向量擦除并推回另一个向量可能会更加昂贵,并且会引入更多的堆分配。链表也将单元的内存减少到32位。向量取决于实现方式,可以将其动态数组预先分配给一个空向量,该动态数组可能需要32个字节。如果我们有大约一百万个网格单元,那将是一个很大的差异。

……这是我发现最近最有用的链接列表的地方,我特别发现“索引链接列表”变体很有用,因为32位索引使64位计算机上链接的内存需求减半了,这意味着节点连续存储在数组中。

通常,我还将它们与索引的空闲列表结合使用,以允许在任何地方进行恒定时间的删除和插入:

在此处输入图片说明

在这种情况下,next如果节点已删除,则索引将指向下一个空闲索引;如果节点尚未被删除,则索引将指向下一个使用的索引。

这是我最近发现链表的第一个用例。例如,当我们要存储一百万个可变长度子序列,平均每个子序列有4个元素(但有时要删除元素并将其添加到这些子序列之一)时,链接列表使我们可以存储400万个子序列链接列表节点是连续的,而不是分别单独堆分配的一百万个容器:一个巨大的向量,即不是一百万个小的向量。


0

我过去在C / C ++应用程序中使用过链表(甚至是双链表)。它早于.NET甚至是stl。

我现在可能不会使用.NET语言的链表,因为您所需的所有遍历代码都是通过Linq扩展方法提供的。

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.