链接列表在什么情况下有用?

时间:2010-03-11 22:30:53

标签: language-agnostic data-structures linked-list

大多数时候,我看到人们尝试使用链接列表,在我看来,这似乎是一个糟糕(或非常差)的选择。也许有必要探讨链表是否是数据结构的良好选择的情况。

理想情况下,答案将阐述用于选择数据结构的标准,以及哪些数据结构在特定情况下可能最有效。

编辑:我必须说,我不仅对数字而且对答案的质量印象深刻。我只能接受一个,但如果有一些更好的东西不存在,那么还有两三个我不得不说会值得接受。只有一对(特别是我最终接受的那个)指出了链表提供了真正优势的情况。我确实认为Steve Jessop不仅要提出一个,而且要提出三个不同的答案,值得一提,我发现这些答案令人印象深刻。当然,即使它只是作为评论发布而不是答案,我认为Neil的博客条目也值得一读 - 不仅信息丰富,而且非常有趣。

15 个答案:

答案 0 :(得分:49)

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

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

您还可以组合链接列表 - 例如树结构可以实现为连接水平链表(兄弟姐妹)的“垂直”链表(父/子关系)。

为这些目的使用基于数组的列表存在严重的局限性:

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

答案 1 :(得分:39)

它们对并发数据结构非常有用。 (现在下面有一个非并发的实际使用示例 - 如果@Neil没有提到FORTRAN,则不会出现这种情况.--)

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

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

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

新的线程池反过来为新的工作安排提供了基础 Task Parallel Library

因此它们当然有用 - 链表目前是至少一项伟大新技术的主要支持结构之一。

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

修改: @Neil: 实际上,你提到的关于FORTRAN的内容提醒我,在.NET中最常使用和滥用的数据结构中可以找到相同类型的链表: 普通的.NET泛型Dictionary<TKey, TValue>

不是一个,但许多链表存储在一个数组中。

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

基本上,许多链表存储在一个数组中。 (每个桶使用一个。) 可重用节点的免费列表在它们之间“交织”(如果有删除)。 在start / on rehash中分配一个数组,并在其中保存链的节点。还有一个 free 指针 - 数组的索引 - 在删除之后。 ;-)所以 - 信不信由你 - FORTRAN技术仍然存在。 (......而不是其他地方,而不是最常用的.NET数据结构之一; - )。

答案 2 :(得分:20)

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

答案 3 :(得分:14)

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

当您必须对列表本身进行大量修改时,通常链接列表非常有用,而阵列的性能优于直接元素访问的列表。

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

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

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

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

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

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

答案 4 :(得分:4)

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

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

答案 5 :(得分:4)

双链表是定义hashmap排序的一个很好的选择,它也定义了元素的顺序(Java中的LinkedHashMap),特别是在按上次访问排序时:

  1. 比关联的vector或deque(2个指针而不是1个)更多的内存开销,但更好的插入/删除性能。
  2. 没有分配开销,因为无论如何都需要一个节点用于哈希条目。
  3. 与指针的向量或双端队列相比,引用的位置没有其他问题,因为您必须以任何方式将每个对象拉入内存。
  4. 当然,你可以争论一个LRU缓存是否是一个好主意,与更复杂和可调整的东西相比,但是如果你将有一个,这是一个相当不错的实现。您不希望在每次读取访问时对向量或双端转移执行从中间删除和添加到结尾,但将节点移动到尾部通常很好。

答案 6 :(得分:3)

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

答案 7 :(得分:3)

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

  1. 快速添加到头部,(append (list x) (L))(append (list y) (L))几乎可以共享所有数据。不需要在没有写入的语言中进行写时复制。功能程序员知道如何利用这一点。
  2. 遗憾的是,添加到尾部很慢,但任何其他实现也是如此。
  3. 相比之下,向量或双端队列通常在两端都很慢,需要(至少在我的两个不同的附加示例中)复制整个列表(向量)或索引块和数据块被附加到(双端)。实际上,在大型列表中可能会有一些东西要说,因为某些原因需要在尾部添加,我没有充分了解函数式编程来判断。

答案 8 :(得分:3)

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

例如,在C ++中实现内存跟踪(新的/删除替换)时,您需要一些控制数据结构来跟踪哪些指针已被释放,您完全需要自己实现这些指针。另一种方法是对每个数据块的开头进行全面定位并添加链接列表。

因为您总是知道,当调用删除时您在列表中的位置,您可以轻松地放弃O(1)中的内存。另外添加一个刚被malloced的新块在O(1)中。在这种情况下很少需要走在列表中,所以O(n)成本在这里不是问题(无论如何,走一个结构都是O(n)。

答案 9 :(得分:2)

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

答案 10 :(得分:2)

链接列表的良好用法的一个示例是列表元素非常大,即。足够大,只有一两个可以同时适应CPU缓存。此时,用于迭代的连续块容器(如矢量或数组)的优势或多或少地无效,并且如果实时发生许多插入和移除,则可能具有性能优势。

答案 11 :(得分:1)

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

想到的一个例子可能是你要为挂链建模。如果您想知道任何特定链接的张力是什么,那么您的界面可能包含一个用于显示&#34;明显&#34;重量。其实现将包括一个链接,询问其下一个链接的明显权重,然后将其自己的权重添加到结果中。通过这种方式,可以通过链的客户端进行一次调用来评估整个长度到底部。

作为一个看起来像自然语言的代码的支持者,我喜欢这会让程序员询问链接链接带有多少重量。它还关注在链接实现的边界内计算这些属性的孩子,无需链式权重计算服务&#34;。

答案 12 :(得分:1)

我发现在网格和图像处理,物理引擎和光线跟踪等性能关键字段中工作的链表最有用的一种情况是使用链表时实际上改善了引用的局部性并减少了堆分配,有时甚至减少了内存使用与直接替代方案相比。

现在看起来像一个完整的矛盾,链接列表可以做到这一切,因为他们因为经常做相反而臭名昭着,但他们有一个独特的属性,因为每个列表节点都有固定的大小和对齐要求,我们可以利用它来连续存储它们并以可变大小的东西不能以恒定时间移除它们。

结果,让我们假设我们想要类似地存储包含一百万个嵌套的可变长度子序列的可变长度序列。一个具体的例子是存储一百万个多边形(一些三角形,一些四边形,一些五边形,一些六边形等)的索引网格,有时多边形从网格中的任何地方移除,有时多边形被重建以将顶点插入现有多边形或删除一个。在这种情况下,如果我们存储了一百万个小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;
};

...然后我们大大减少了堆分配和缓存未命中数。我们现在只需要在整个网格中存储的两个向量之一超过其容量(摊余成本)时,就需要堆分配,而不是要求我们访问的每个多边形的堆分配和潜在的强制缓存未命中。虽然从一个顶点到另一个顶点的步幅可能仍会导致其缓存未命中,但它仍然通常小于每个单独的多边形存储单独的动态数组,因为节点是连续存储的,并且相邻顶点可能存在在逐出之前被访问(特别是考虑到许多多边形将同时添加它们的顶点,这使得狮子的多边形顶点的份额完全连续)。

这是另一个例子:

enter image description here

...网格单元用于加速粒子 - 粒子碰撞,例如,每一帧移动1600万个粒子。在该粒子网格示例中,使用链接列表,我们可以通过仅更改3个索引将粒子从一个网格单元移动到另一个网格单元格。从向量中删除并推回到另一个可能会相当昂贵并引入更多堆分配。链表还将单元的内存减少到32位。取决于实现,向量可以预先将其动态数组分配给空向量可以占用32个字节的点。如果我们有大约一百万个网格单元,那就非常不同了。

...这就是我发现链接列表最有用的地方,我特别发现“索引链表”的种类很有用,因为32位索引将64位机器上的链接的内存需求减半意味着节点连续存储在一个数组中。

我经常将它们与索引的自由列表结合起来,以便在任何地方进行恒定时间删除和插入:

enter image description here

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

这是我现在为链接列表找到的头号用例。当我们想要存储一百万个可变长度的子序列时,比如平均每个4个元素(但有时会删除元素并添加到其中一个子序列中),链表允许我们存储400万个连续列表节点连续而不是100万个容器,每个容器都是单独堆分配的:一个巨大的矢量,即不是一百万个小矢量。

答案 13 :(得分:0)

我过去在C / C ++应用程序中使用了链表(甚至是双链表)。这是.NET之前甚至是stl。

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

答案 14 :(得分:0)

有两个互补的操作,在列表上简单地为O(1),在其他数据结构中很难在O(1)中实现 - 从任意位置移除和插入元素,假设您需要维护元素的顺序。

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

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

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