链表在啥情况下有用?
Posted
技术标签:
【中文标题】链表在啥情况下有用?【英文标题】:Under what circumstances are linked lists useful?链表在什么情况下有用? 【发布时间】:2011-01-26 14:50:15 【问题描述】:大多数时候我看到人们尝试使用链表,在我看来这是一个糟糕(或非常糟糕)的选择。也许探索链表在什么情况下是或不是一个好的数据结构选择是有用的。
理想情况下,答案应说明选择数据结构时使用的标准,以及在特定情况下哪些数据结构可能工作得最好。
编辑:我必须说,我对答案的数量和质量都印象深刻。我只能接受一个,但如果没有更好的东西,我不得不说还有两三个值得接受。只有一对(尤其是我最终接受的那个)指出了链表提供真正优势的情况。我确实认为史蒂夫·杰索普(Steve Jessop)应该得到某种荣誉奖,因为他提出的不仅仅是一个,而是三个不同的答案,我觉得所有这些都令人印象深刻。当然,尽管它只是作为评论发布,而不是作为答案发布,但我认为 Neil 的博客条目也非常值得一读——不仅内容丰富,而且非常有趣。
【问题讨论】:
第二段的答案大约需要一个学期。 我的意见见punchlet.wordpress.com/2009/12/27/letter-the-fourth。由于这似乎是一项调查,因此应该是 CW。 @Neil,很好,虽然我怀疑 C.S. Lewis 会同意。 @Neil:我想这是一项调查。大多数情况下,这是一种尝试,看看是否有人能提出一个我至少可以认为是合理的基础的答案。 @Seva:是的,重读它,我把最后一句话写得比我原本打算的更笼统。 @Yar 人们(包括我,我很遗憾地说)过去常常在 FORTRAN IV(没有指针的概念)等语言中实现没有指针的链表,就像他们做树一样。您使用了数组而不是“真实”内存。 【参考方案1】:当您需要在任意(编译时未知)长度的列表上进行大量插入和删除但又不需过多搜索时,链表非常有用。
拆分和加入(双向链接)列表非常有效。
您还可以组合链表 - 例如树结构可以实现为将水平链表(兄弟)连接在一起的“垂直”链表(父/子关系)。
为这些目的使用基于数组的列表有严重的限制:
添加新项目意味着必须重新分配数组(或者您必须分配比您需要的空间更多的空间以供未来增长并减少重新分配的数量) 删除项目会浪费空间或需要重新分配 在除末尾之外的任何位置插入项目涉及(可能重新分配和)将大量数据复制到一个位置【讨论】:
所以问题归结为,当 do 时,您需要在序列中间进行大量插入和删除,但按序号在列表中查找的次数并不多?遍历链表通常比复制数组更昂贵,因此您所说的在数组中删除和插入项目的所有内容对于列表中的随机访问同样不利。 LRU 缓存是我能想到的一个例子,你需要在中间删除很多,但你永远不需要遍历列表。 添加到列表涉及为您添加的每个元素分配内存。这可能涉及非常昂贵的系统调用。如果数组必须增长,添加到数组只需要这样的调用。事实上,在大多数语言中(正是出于这些原因),数组是首选的数据结构,而列表几乎不被使用。 “这可能涉及系统调用”在其他地方您似乎批评其他人假设一个糟糕的数组实现(未能摊销指数重新分配)。为什么现在要对糟糕的列表实现发出可怕的声音(未能为节点使用合适的分配策略)?例如,在 Java 中,内存分配速度惊人地快,即使考虑到 GC 的 Java 时间成本,它也比典型的 C 实现快得多。 假设哪个?这种分配速度惊人地快是显而易见的——通常需要将对象大小添加到指针中。 GC 的总开销很低吗?上次我尝试在一个真实的应用程序上测量它时,关键是当处理器处于空闲状态时,Java 正在完成所有工作,所以它自然不会对可见性能产生太大影响。在繁忙的 CPU 基准测试中,很容易扰乱 Java,并获得非常糟糕的最坏情况分配时间。不过,这是很多年前的事了,从那时起,分代垃圾收集显着降低了 GC 的总成本。 @Steve:您认为列表和数组之间的分配“相同”是错误的。每次需要为列表分配内存时,只需分配一个小块 - O(1)。对于数组,您必须为整个列表分配一个足够大的新块,然后复制整个列表 - O(n)。要插入到列表中的已知位置,您需要更新固定数量的指针 - O(1),但是要插入到数组中并将任何后面的项目复制到一个位置以便为插入腾出空间 - O(n)。因此,在许多情况下,数组的效率远低于 LL。【参考方案2】:它们对于并发数据结构很有用。 (现在下面有一个非并发的实际使用示例 - 如果@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 (&Java) 中使用(GC-ed)节点来保存项目。)
编辑:
@尼尔:
实际上,您提到的关于 FORTRAN 的内容提醒了我,可能在 .NET 中最常用和滥用的数据结构中可以找到相同类型的链表:
普通的 .NET 通用 Dictionary<TKey, TValue>
。
不是一个,而是许多链表存储在一个数组中。
它避免了在插入/删除时执行许多小的(取消)分配。 哈希表的初始加载非常快,因为数组是按顺序填充的(与 CPU 缓存配合得很好)。 更不用说链式哈希表在内存方面的成本很高 - 这个“技巧”在 x64 上将“指针大小”减少了一半。本质上,许多链表存储在一个数组中。 (每个使用的桶一个。) 可重用节点的免费列表在它们之间“交织”(如果有删除)。 在开始/重新散列时分配一个数组,并将链的节点保存在其中。还有一个 free 指针 - 数组的索引 - 在删除之后。 ;-) 所以 - 信不信由你 - FORTRAN 技术仍然存在。 (...在最常用的 .NET 数据结构之一中,别无他法 ;-)。
【讨论】:
如果你错过了,这里是 Neil 的评论:“人们(包括我,我很遗憾地说)过去常常在 FORTRAN IV 之类的语言中实现没有指针的链表(它没有指针),就像树一样。你使用数组而不是“真实”内存。” 我应该补充一点,在Dictionary
的情况下,“数组中的链表”方法在 .NET 中可以节省更多:否则每个节点都需要堆上的单独对象 - 并且每个对象在堆上分配有一些开销。 (en.csharp-online.net/Common_Type_System%E2%80%94Object_Layout)
很高兴知道 C++ 的默认 std::list
在没有锁的多线程上下文中是不安全的。【参考方案3】:
链表非常灵活:只要修改一个指针,就可以进行大范围的更改,同样的操作在数组链表中效率非常低。
【讨论】:
是否有可能激发为什么使用列表而不是集合或地图?【参考方案4】:数组是链表通常用来比较的数据结构。
当您必须对列表本身进行大量修改而数组在直接元素访问上比列表执行得更好时,通常链表很有用。
这里是可以对列表和数组执行的操作列表,与相对操作成本(n = 列表/数组长度)相比:
添加元素: 在列表中,您只需为新元素和重定向指针分配内存。 O(1) 在阵列上,您必须重新定位阵列。 O(n) 删除元素 在列表中,您只需重定向指针。 O(1)。 在数组上,如果要删除的元素不是数组的第一个或最后一个元素,则需要花费 O(n) 时间重新定位数组;否则您可以简单地将指针重新定位到数组的开头或减少数组长度 在已知位置获取元素: 在列表中,您必须将列表从第一个元素遍历到特定位置的元素。最坏情况:O(n) 在数组上,您可以立即访问元素。 O(1)这是这两种流行和基本数据结构的非常低级的比较,您可以看到列表在您必须对其自身进行大量修改(删除或添加元素)的情况下表现更好。 另一方面,当您必须直接访问数组的元素时,数组的性能优于列表。
从内存分配的角度来看,列表更好,因为不需要让所有元素彼此相邻。另一方面,存储指向下一个(甚至前一个)元素的指针会产生(很少的)开销。
了解这些差异对于开发人员在其实现中选择列表和数组非常重要。
请注意,这是列表和数组的比较。这里报告的问题有很好的解决方案(例如:SkipLists、动态数组等)。 在这个答案中,我考虑了每个程序员都应该知道的基本数据结构。
【讨论】:
这对于列表的良好实现和数组的糟糕实现来说有些真实。大多数数组实现比您认为的要复杂得多。而且我认为您不了解动态内存分配的成本。 这个答案不应该涵盖数据结构大学课程的课程。这是一个考虑到链接列表和数组的比较,它们以您、我和大多数人都知道的方式实现。几何扩展数组、跳过列表等......是我知道、我使用和研究的解决方案,但这需要更深入的解释,而且不适合 *** 的答案。 "从内存分配的角度来看,列表更好,因为没有必要让所有元素彼此相邻。"相反,连续容器更好因为它们使元素彼此相邻。在现代计算机上,数据本地化是王道。所有在内存中的跳跃都会扼杀你的缓存性能,并导致在(有效的)随机位置插入元素的程序使用动态数组(如 C++std::vector
)比使用链表(如 C++ @987654322)执行得更快@,只是因为遍历列表的代价太大了。
@DavidStone 也许我还不够清楚,但那句话我指的是你不需要有连续的空间来存储你的元素。特别是,如果您想存储不太小的东西并且可用内存有限,您可能没有足够的 连续可用空间 来存储数据,但您可能可以使用列表来适应数据(即使你将有指针的开销......因为它们占用的空间和你提到的性能问题)。我可能应该更新我的答案以使其更清楚。【参考方案5】:
当您需要高速推送、弹出和旋转并且不介意 O(n) 索引时,它们很有用。
【讨论】:
您是否曾经费心将 C++ 链表与(比如说)双端队列进行比较? @Neil:不能说我有。 @Neil:如果 C++ 故意破坏其链表类以使其比任何其他容器慢(这与事实相差不远),那与语言无关题?侵入式链表仍然是链表。 @Steve C++ 是一种语言。我看不出它怎么会有意志。如果您建议 C++ 委员会的成员以某种方式破坏了链表(从逻辑上讲,链表对于许多操作来说一定很慢),那么请说出罪魁祸首! 这不是真正的破坏 - 外部列表节点有其优势,但性能不是其中之一。但是,在权衡您所知道的同一件事时,肯定每个人都知道,那就是很难为std::list
找到一个好的用途。侵入性列表不符合 C++ 对容器元素的最低要求的理念。【参考方案6】:
单链表是单元分配器或对象池中空闲列表的不错选择:
-
您只需要一个堆栈,因此单链表就足够了。
一切都已经划分为节点。侵入式列表节点没有分配开销,前提是单元格足够大以包含指针。
向量或双端队列会产生每个块一个指针的开销。这很重要,因为当您第一次创建堆时,所有单元都是空闲的,因此这是一个前期成本。在最坏的情况下,它会使每个单元的内存需求翻倍。
【讨论】:
嗯,同意。但是有多少程序员真正在创造这样的东西呢?大多数只是重新实现 std::list 等给你的东西。实际上,“侵入性”通常与您给出的含义略有不同 - 每个可能的列表元素都包含一个与数据分开的指针。 多少?大于 0,小于 100 万 ;-) Jerry 的问题是“充分利用列表”,还是“充分利用每个程序员每天都使用的列表”,还是介于两者之间?对于包含在作为列表元素的对象中的列表节点,除了“侵入性”之外,我不知道任何其他名称 - 无论是否作为联合的一部分(用 C 术语表示)。第 3 点仅适用于允许您这样做的语言 - C、C++、汇编程序好。 Java 不好。【参考方案7】:双向链表是定义哈希图排序的好选择,哈希图还定义了元素的顺序(Java 中的 LinkedHashMap),尤其是在按最后一次访问排序时:
-
比关联的向量或双端队列更多的内存开销(2 个指针而不是 1 个),但插入/删除性能更好。
没有分配开销,因为无论如何您都需要一个节点来存储哈希条目。
与指针的向量或双端队列相比,引用的局部性没有额外的问题,因为无论哪种方式都必须将每个对象拉入内存。
当然,与更复杂和可调整的东西相比,您可以首先争论 LRU 缓存是否是一个好主意,但如果您要拥有一个,这是一个相当不错的实现。您不想在每次读取访问时对向量或双端队列执行从中间删除并添加到末端,但将节点移动到尾部通常很好。
【讨论】:
【参考方案8】:当您无法控制数据的存储位置时,链表是自然的选择之一,但您仍然需要以某种方式从一个对象获取到下一个对象。
例如,在 C++ 中实现内存跟踪(新建/删除替换)时,您需要一些控制数据结构来跟踪哪些指针已被释放,您完全需要自己实现。另一种方法是过度分配并在每个数据块的开头添加一个链表。
因为您总是立即知道,当调用 delete 时您在列表中的哪个位置,您可以轻松地在 O(1) 内放弃内存。在 O(1) 中添加一个刚刚分配的新块。在这种情况下,很少需要遍历列表,因此 O(n) 成本在这里不是问题(遍历结构无论如何都是 O(n))。
【讨论】:
【参考方案9】:单链表是函数式编程语言中常见“列表”数据类型的明显实现:
-
添加到头部速度很快,
(append (list x) (L))
和 (append (list y) (L))
可以共享几乎所有的数据。无需在没有写入的语言中进行写时复制。函数式程序员知道如何利用这一点。
不幸的是,添加到尾部很慢,但任何其他实现也会如此。
相比之下,在任一端添加向量或双端队列通常会很慢,需要(至少在我的两个不同追加的示例中)复制整个列表(向量)或索引块和附加到(deque)的数据块。实际上,对于大型列表中的双端队列可能有一些话要说,出于某种原因确实需要在尾部添加,我对函数式编程没有足够的了解来判断。
【讨论】:
【参考方案10】:链表的一个很好的使用例子是列表元素非常大,即。足够大,以至于只有一两个可以同时放入 CPU 缓存中。在这一点上,用于迭代的向量或数组等连续块容器的优势或多或少被抵消了,如果实时发生许多插入和删除,则可能会获得性能优势。
【讨论】:
【参考方案11】:根据我的经验,实现稀疏矩阵和斐波那契堆。链接列表使您可以更好地控制此类数据结构的整体结构。虽然我不确定是否最好使用链表实现稀疏矩阵 - 可能有更好的方法,但它确实有助于在本科 CS 中使用链表学习稀疏矩阵的来龙去脉 :)
【讨论】:
【参考方案12】:对于在网格和图像处理、物理引擎和光线跟踪等性能关键领域工作的链表,我发现其中一个最有用的情况是,使用链表实际上可以提高引用的局部性并减少堆分配,有时甚至会减少内存与简单的替代方案相比。
现在这似乎是一个完全矛盾的说法,链表可以做到所有这些,因为它们经常做相反的事情而臭名昭著,但它们有一个独特的属性,即每个列表节点都有固定的大小和对齐要求,我们可以允许它们以可变大小的东西不能的方式连续存储和以恒定时间删除。
因此,让我们举一个例子,我们想要做一个类比等价的存储可变长度序列,其中包含一百万个嵌套的可变长度子序列。一个具体的例子是一个索引网格存储一百万个多边形(一些三角形,一些四边形,一些五边形,一些六边形等),有时多边形会从网格中的任何位置移除,有时会重建多边形以将顶点插入现有多边形或删除一个。在这种情况下,如果我们存储一百万个微小的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 万个链表节点是连续的,而不是 100 万个容器,每个容器都是单独堆分配的:一个巨大的向量,而不是一百万个小的向量。
【讨论】:
【参考方案13】:有两个互补的操作在列表上是微不足道的 O(1) 并且在其他数据结构中很难在 O(1) 中实现 - 从任意位置删除和插入元素,假设您需要保持元素的顺序.
哈希映射显然可以在 O(1) 中进行插入和删除,但是你不能按顺序迭代元素。
鉴于上述事实,哈希映射可以与链表结合以创建一个漂亮的 LRU 缓存:一个存储固定数量的键值对并丢弃最近最少访问的键为新键腾出空间的映射。
哈希映射中的条目需要有指向链表节点的指针。访问哈希映射时,链表节点从其当前位置取消链接并移动到链表的头部(O(1),链表耶!)。当需要删除最近最少使用的元素时,需要删除列表尾部的元素(再次 O(1) 假设您保留指向尾节点的指针)以及相关的哈希映射条目(因此从哈希映射的列表是必要的。)
【讨论】:
【参考方案14】:考虑到链表在系统的领域驱动设计风格实现中可能非常有用,该系统包括与重复互锁的部分。
如果您要为吊链建模,您可能会想到一个例子。如果您想知道任何特定链接上的张力是什么,您的界面可以包含一个用于“表观”重量的吸气剂。其实现将包括一个链接询问其下一个链接的表观权重,然后将其自身的权重添加到结果中。这样,从链的客户端发出的一次调用就可以评估从底部到底部的整个长度。
作为读起来像自然语言的代码的支持者,我喜欢这样可以让程序员询问链环它承载了多少重量。它还保留了在链接实现的边界内计算这些属性的孩子的关注,从而消除了对链权重计算服务的需求”。
【讨论】:
【参考方案15】:我过去在 C/C++ 应用程序中使用过链表(甚至是双向链表)。这是在 .NET 甚至 stl 之前。
我现在可能不会在 .NET 语言中使用链表,因为您需要的所有遍历代码都是通过 Linq 扩展方法为您提供的。
【讨论】:
以上是关于链表在啥情况下有用?的主要内容,如果未能解决你的问题,请参考以下文章