【问题标题】:Why is a LinkedList Generally Slower than a List?为什么 LinkedList 通常比 List 慢?
【发布时间】:2011-08-24 09:26:49
【问题描述】:

我开始在我的一些 C# 算法中使用一些 LinkedList 而不是列表,希望能够加快它们的速度。但是,我注意到他们只是感觉慢了一些。像任何优秀的开发人员一样,我认为我应该尽职尽责并验证我的感受。所以我决定对一些简单的循环进行基准测试。

我认为用一些随机整数填充集合就足够了。我在调试模式下运行此代码以避免任何编译器优化。这是我使用的代码:

var rand = new Random(Environment.TickCount);
var ll = new LinkedList<int>();
var list = new List<int>();
int count = 20000000;

BenchmarkTimer.Start("Linked List Insert");
for (int x = 0; x < count; ++x)
  ll.AddFirst(rand.Next(int.MaxValue));
BenchmarkTimer.StopAndOutput();

BenchmarkTimer.Start("List Insert");
for (int x = 0; x < count; ++x)
  list.Add(rand.Next(int.MaxValue));
BenchmarkTimer.StopAndOutput();

int y = 0;
BenchmarkTimer.Start("Linked List Iterate");
foreach (var i in ll)
  ++y; //some atomic operation;
BenchmarkTimer.StopAndOutput();

int z = 0;
BenchmarkTimer.Start("List Iterate");
foreach (var i in list)
  ++z; //some atomic operation;
BenchmarkTimer.StopAndOutput();

这是输出:

Linked List Insert: 8959.808 ms
List Insert: 845.856 ms
Linked List Iterate: 203.632 ms
List Iterate: 125.312 ms

这个结果让我很困惑。链接列表插入应该是 O(1),而列表插入是 Θ(1),如果需要调整大小,则需要 O(n)(因为复制)。由于枚举器,两个列表迭代都应该是 O(1)。我查看了反汇编的输出,并没有对情况有太多了解。

其他人对这是为什么有任何想法?我错过了什么明显的东西吗?

注意:这里是简单 BenchmarkTimer 类的源代码:http://procbits.com/2010/08/25/benchmarking-c-apps-algorithms/

【问题讨论】:

  • 链接列表不是为每个插入(LinkedListNode)分配内存并创建一个新对象,而列表插入除了为调整大小的数组的副本之外不分配内存?这种副作用对时间的影响可能比算法的 Big-O 值更大。
  • 这可能是真的,但为什么迭代更慢?
  • 小心!您似乎对 Big-O-notation 感到困惑。它不会告诉您算法/实现的速度有多快,它会告诉您它扩展的程度。
  • 顺便说一句,添加到最后,List&lt;&gt; 只需要调整O(log2(n)) 的大小,n 是最终列表的大小(在您的情况下约为 24 倍),因为它的容量每增加一倍时间。无论如何,0xA3 是完全正确的。
  • 0xA3:我一定是用 big-O 错误地陈述了我的分析?我认为通过举一个包含 2000 万次迭代的示例来证明规模问题。

标签: c# .net performance list linked-list


【解决方案1】:

更新(回应您的评论):您是对的,单独讨论大 O 表示法并不十分有用。我在最初的回复中包含了指向 James 答案的链接,因为他已经很好地解释了为什么 List&lt;T&gt; 总体上优于 LinkedList&lt;T&gt; 的技术原因。

基本上,这是内存分配和位置的问题。当您的所有集合元素都存储在内部数组中时(如List&lt;T&gt; 的情况),它们都在一个连续的内存块中,可以非常快速地访问。这适用于 adding(因为这只是写入已分配数组中的一个位置)以及 iterating(因为这会访问许多非常靠近的内存位置而不必遵循指向完全断开的内存位置的指针)。

LinkedList&lt;T&gt; 是一个专门的 集合,只有在您从列表的中间 执行随机插入或删除的情况下,它才会胜过List&lt;T&gt;——即便如此,也只有也许。

至于缩放的问题:你是对的,如果大 O 表示法是关于操作缩放的好坏,那么 O(1) 操作最终应该击败 O( >1) 给定足够大的输入的操作——这显然是你想要的 2000 万次迭代。

这就是为什么我提到 List&lt;T&gt;.Add 有一个 amortized complexity 的 O(1)。这意味着添加到列表也是一个随输入大小线性缩放的操作,与链表相同(有效)。忘记列表有时必须自行调整大小的事实(这就是“摊销”的来源;如果您还没有访问过维基百科的文章,我鼓励您访问)。它们缩放相同。

现在,有趣的是,也许与直觉相反,这意味着List&lt;T&gt; 和LinkedList&lt;T&gt; 之间的性能差异(同样,当涉及到添加)实际上变成了随着元素数量的增加更明显。原因是当列表的内部数组空间不足时,它加倍数组的大小;因此,随着元素越来越多,调整大小操作的频率减少——到了数组基本上从不调整大小的程度。

假设List&lt;T&gt; 以一个足以容纳 4 个元素的内部数组开头(我相信这是准确的,尽管我记不太清了)。然后,当您添加多达 2000 万个元素时,它会调整自己的大小,总共 ~(log2(20000000) - 1) 或 23 次。将此与 2000 万次相比,您在 LinkedList&lt;T&gt; 上执行的效率相当较低,LinkedList&lt;T&gt; 每次调用都会分配一个新的 LinkedListNode&lt;T&gt;,并且这 23 种调整大小突然变得微不足道了。

我希望这会有所帮助!如果我对任何一点不清楚,请告诉我,我会尽力澄清和/或纠正自己。


James 正在播放。

请记住,大 O 表示法旨在让您了解算法的性能如何扩展。这并不意味着在保证 O(1) 时间内执行的东西会优于在摊销 O(1) 时间内执行的其他东西(就像List&lt;T&gt; 的情况一样)。

假设您有两项工作可供选择,其中一项需要在偶尔遇到交通拥堵的道路上通勤 5 英里。通常,这个车程大约需要 10 分钟,但在糟糕的一天可能需要 30 分钟。另一份工作在 60 英里外,但高速公路始终畅通无阻,从不堵车。这条路总是需要你一个小时。

这基本上是List&lt;T&gt; 和LinkedList&lt;T&gt; 用于添加到列表末尾的情况。

【讨论】:

  • 我一定是用 big-O 错误地陈述了我的分析?我认为通过给出一个包含 2000 万次迭代的示例来证明规模问题。但是,我认为答案不需要仅仅退化为对 big-O 的解释,就像互联网搜索者会欣赏的那样。
  • @JP:对您的困惑的简短回答是,添加到List&lt;T&gt; 是也是 O(1) 当平均超过实际使用时(这就是摊销的意思)。很简单,List&lt;T&gt; 的伪常数性能比LinkedList&lt;T&gt; 快得多。因此,您没有看到可伸缩性的差异,因为这两个集合的伸缩性相同(用于添加)。阅读我更新的答案以获得更深入的解释。
【解决方案2】:

请记住,您已经获得了原语列表。对于 List 这很简单,因为它创建了一个完整的 int 数组,并且在不需要分配更多内存时很容易将它们向下移动。

将此与始终必须分配内存来包装整数的 LinkedList 进行对比。因此,我认为内存分配可能对您的时间贡献最大。如果您已经分配了节点,则总体上应该更快。我会尝试用一个 LinkedListNode 来验证 AddFirst 的重载(也就是说,在计时器范围之外创建 LinkedListNode,只需定时添加它)。

迭代类似,在内部数组中转到下一个索引比跟踪链接效率更高。

【讨论】:

  • 我要补充一点,相邻分配的内存(如List&lt;T&gt;)比随机分配的内存更能利用内存缓存。然后更少的缓存未命中,因此更快的访问时间。
【解决方案3】:

正如 James 在他的回答中所说,内存分配可能是 LinkedList 较慢的原因之一。

此外,我认为主要差异源于无效测试。您将项目添加到链表的开头,但添加到普通列表的末尾。在普通列表的开头添加项目不会使基准测试结果再次偏向 LinkedList 吗?

【讨论】:

  • 是的。因为这样每次添加到列表中都是 O(n)。这是一个更不公平的比较。如果 LinkedList 同时具有 Head 和 Tail 元素,那么添加到前面或末尾实际上是同一件事且无关紧要。当然,除非实际使用需要将项目插入列表的前面。
  • @mellamokb:只是说这是一个 LinkedList 会更快的用例。 LinkedList 旨在优化此类操作,当然会导致一些额外的开销,这在他的基准测试中通过进行不公平的比较而被注意到。除非比较是为了表明 LinkedList 将元素添加到列表后面比 List 慢,这是意料之中的。
  • 这是一个很好的观点@Steven,添加到 LinkedList 的末尾或开头是 O(1),添加到 List 的末尾是 O(1) - 不包括调整大小 - 但是 O (n) 开始。
  • 只有你和 Rick 的回答才能解决这个问题。
【解决方案4】:

我强烈推荐这篇文章Number crunching: why you should never use a linked-list again。没有什么其他地方没有的,但我花了很多时间试图弄清楚为什么 LinkedList 比 List 在我发现之前我认为显然会支持链表的情况下,在查看之后,事情变得更有意义了:

链表在不相交的内存区域中有项目,因此可以说它是cache line hostile,因为它最大限度地增加了缓存未命中。不相交的内存使得遍历列表会导致频繁且代价高昂的意外 RAM 查找。

另一方面,向量 [相当于 ArrayList 或 List] 将其项目存储在 相邻内存 中,因此这样做,能够最大限度地提高缓存利用率并避免缓存未命中。通常,在实践中,这远远抵消了对数据进行洗牌时产生的成本。

如果您想从更权威的来源听到这一点,这是来自 MSDN 上的Tips for Improving Time-Critical Code:

有时,看起来很棒的数据结构会因为引用的局部性差而变得很糟糕。这里有两个例子:

  • 动态分配的链表(LinkedListNode 是引用类型,因此是动态分配的)会降低程序性能,因为当您搜索项目或你遍历一个列表到最后,每个跳过的链接都可能错过缓存或导致页面错误。 由于更好​​的缓存和更少的页面错误,基于简单数组的列表实现实际上可能快得多——即使考虑到数组更难增长的事实,它仍然可能更快。 p>

  • 使用动态分配的链表的哈希表会降低性能。通过扩展,使用动态分配的链表来存储其内容的哈希表的性能可能会更差。 事实上,归根结底,通过数组进行简单的线性搜索实际上可能更快(视情况而定)。基于数组的哈希表(IIRC, Dictionary 是基于数组的) 是一种经常被忽视的实现,它往往具有卓越的性能。


这是我做一些性能测试的原始答案(远没有那么有用)。

普遍的共识似乎是链表在每次添加时都分配内存(因为节点是一个类),而且似乎确实如此。我试图将分配代码与将项目添加到列表中的定时代码隔离开来,并从结果中得出一个要点:https://gist.github.com/zeldafreak/d11ae7781f5d43206f65

我运行测试代码 5 次并在它们之间调用 GC.Collect()。将 2000 万个节点插入链表需要 193-211 毫秒(198 毫秒),而 77-89 毫秒(81 毫秒),因此即使没有分配,标准链表也快 2 倍多一点。迭代一个列表需要 54-59 毫秒,而链表需要 76-101 毫秒,这要快 50% 左右。

【讨论】:

    【解决方案5】:

    我已经用List 和LinkedList 将实际对象(实际上是匿名类型)插入到列表中进行了相同的测试,在这种情况下,链接列表也比列表慢。

    但是,如果您插入这样的项目,而不是使用 AddFirst 和 AddLast,LinkedList 确实会加快速度:

    LinkedList<T> list = new LinkedList<T>();
    LinkedListNode<T> last = null;
    foreach(var x in aLotOfStuff)
    {
        if(last == null)
            last = list.AddFirst(x);
        else
            last = list.AddAfter(last, x);
    }
    

    AddAfter 似乎比 AddLast 快。我会假设.NET 在内部会通过 ref 跟踪“tail”/last 对象,并在执行 AddLast() 时直接找到它,但也许 AddLast() 会导致它遍历整个列表到最后?

    【讨论】:

      【解决方案6】:

      由于其他答案没有提到这一点,我正在添加另一个。

      虽然您的打印语句显示"List Insert",但您实际上调用了List&lt;T&gt;.Add,这是List 实际上擅长的一种“插入”。 Add 是一个特殊情况,只使用下一个元素是底层存储数组,没有任何东西需要移开。尝试真正使用List&lt;T&gt;.Insert,而不是让它成为最坏的情况而不是最好的情况。

      编辑:

      总而言之,就插入而言,列表是一种特殊用途的数据结构,它只在一种插入方面速度很快:追加到末尾。链表是一种通用数据结构,在将任何位置插入到列表中时都同样快速。还有一个细节:链表的内存和 CPU 开销更高,因此它的固定成本更高。

      因此,您的基准测试将通用链接列表插入与特殊用途列表附加到末尾进行了比较,因此完全按预期使用的微调优化数据结构表现良好也就不足为奇了。如果您希望链表能够更好地进行比较,您需要一个列表会发现具有挑战性的基准,这意味着您需要在列表的开头或中间插入。

      【讨论】:

      • 公平点。在这两种情况下我应该使用的语言是“添加”,因为我还在链接列表上调用了“AddFirst”。
      • @JP:没问题,我不是在争论这个词。我是说 List.Insert(0, item) 与 LinkedList.AddFirst 相同。尝试运行 that,你会感到震惊。
      • Rick:我认为 AddFirst 或 AddLast 之间的性能不会有差异,因为我猜想 .NET 实现会保留指向头部和尾部的指针。想法?重点是,如果我使用 AddLast 和 Add 进行比较...?
      • @JP:是的,LinkedList 不在乎你在哪里插入。你的故事的寓意是,如果你总是在最后添加,那么 List 会胜出,不问任何问题,故事结束。如果您在开头或中间插入,LinkedList 只能希望更快。
      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 2011-09-04
      • 2011-11-04
      • 2015-04-10
      • 2014-10-03
      • 2014-02-21
      • 2018-10-17
      • 2012-03-22
      相关资源
      最近更新 更多