为什么LinkedList通常比列表慢?

时间:2011-05-12 19:02:42

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

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

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

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/

6 个答案:

答案 0 :(得分:31)

更新(响应您的评论):您说得对,讨论big-O符号本身并不完全有用。我在原始回复中包含了詹姆斯答案的链接,因为他已经提供了一个很好的解释,说明为什么List<T>一般优于LinkedList<T>的技术原因。

基本上,这是内存分配和本地化的问题。当你的所有集合元素都在内部存储在一个数组中时(就像List<T>的情况一样),它就在一个连续的内存块中,可以非常快速地访问 。这既适用于添加(因为它只是写入已分配数组中的位置)以及迭代(因为它访问了非常接近的许多内存位置)而不必按照指针完全断开内存位置。)

LinkedList<T>是一个专门的集合,在您从中间执行随机插入或删除的情况下,它只会优于List<T>列表 - 即便如此,只有可能

关于缩放的问题:你是对的,如果大O符号完全是关于操作扩展的程度,那么O(1)操作最终应该击败O( &gt; 1)给定足够大的输入操作 - 这显然是你想要的2000万次迭代。

这就是为什么我提到List<T>.Add有一个 amortized complexity的O(1)。这意味着添加到列表也是一个与输入大小成线性比例的操作,与链表相同(有效)。忘记这个事实,偶尔列表必须自己调整大小(这是“摊销”的地方;我鼓励你访问维基百科的文章,如果你还没有)。他们缩放 相同

现在,有趣的是,或许可能违反直觉,这意味着,如果有的话,List<T>LinkedList<T>之间的性能差异(再次,当涉及添加时)随着元素数量的增加,实际上变得更明显。原因是当列表的内部数组中的空间不足时,它加倍数组的大小;因此,随着越来越多的元素,调整操作的频率减少 - 到数组基本上从不调整大小的程度。

所以让我们说一个List<T>开始时内部数组大到足以容纳4个元素(我相信这是准确的,尽管我不记得确定)。然后,当您添加多达2000万个元素时,它会自行调整总数〜(log 2 (20000000) - 1)或 23次。将此与 2000万次相比较,您在AddLast上执行相当效率较低的LinkedList<T>LinkedListNode<T>会分配新的List<T>每次通话,这23个大小的突然显得非常微不足道。

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


James是正确的。

请记住,big-O表示法旨在让您了解算法的性能如何扩展。这并不意味着在保证的O(1)时间内执行的事情将胜过在摊销的O(1)时间内执行的其他事情(如List<T>的情况)。

假设您可以选择两个工作,其中一个工作需要在一条路上行驶5英里,偶尔会遇到交通拥堵。通常这个驱动器应该花费大约10分钟,但在糟糕的一天它可能更像是30分钟。另一项工作是60英里远,但高速公路总是很清晰,从来没有任何交通拥堵。此驱动器总是需要一个小时。

这基本上是LinkedList<T>和{{1}}的情况,目的是添加到列表的末尾。

答案 1 :(得分:14)

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

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

迭代是类似的,转到内部数组中的下一个索引比跟踪链接要高效得多。

答案 2 :(得分:5)

正如詹姆斯在答案中所说,内存分配可能是导致LinkedList变慢的一个原因。

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

答案 3 :(得分:4)

我强烈推荐文章 Number crunching: why you should never use a linked-list again 。其他任何地方都没有多少,但我花了很多时间试图找出为什么 LinkedList&lt; T&gt; List&lt慢得多; T&gt; 在我发现之前我认为显然会偏向链表的情况下,在查看之后,事情变得更有意义了:

  

链接列表包含不相交的内存区域中的项目,因此,可以说它是缓存线恶意,因为它最大化缓存未命中。不相交的内存使得遍历列表导致频繁且昂贵的意外RAM查找。

     

另一方面,向量[相当于 ArrayList 列表&lt; T&gt; ]将其项目存储在相邻内存中,并且因此这样做,能够最大化缓存利用率并避免缓存未命中。通常,在实践中,这不仅可以抵消在改组数据时产生的成本。

如果您希望从更具权威性的来源听到此消息,请访问MSDN上的Tips for Improving Time-Critical Code

  

有时看起来的数据结构很糟糕,因为 位置不佳。以下是两个例子:

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

  •   
  • 使用动态分配的链接列表的哈希表会降低性能。通过扩展,使用动态分配的链表来存储其内容的哈希表可能执行得更糟。 事实上,在最后的分析中,通过数组进行简单的线性搜索实际上可能更快(取决于具体情况)。基于数组的哈希表(IIRC,字典&lt; TKey,TValue&gt; 是基于数组的)是一种经常被忽视的实现,通常具有卓越的性能。

    < / LI>   

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

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

我运行测试代码5次,并在它们之间调用GC.Collect()。在链表中插入2000万个节点需要193-211ms(198ms),而77-89ms(81ms),所以即使没有分配,标准列表也要快2倍。迭代列表需要54-59ms,相比链表的76-101ms,这是一个更温和的50% - 更快。

答案 4 :(得分:2)

我已经完成了相同的测试,ListLinkedList将实际对象(实际上是Annonymous Types)插入到列表中,并且在这种情况下,Linked List也比List慢。

但是,如果您插入这样的项目而不是使用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()会导致它遍历整个列表到最后?

答案 5 :(得分:2)

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

虽然您的打印语句显示"List Insert"您实际上调用了List<T>.Add,这是List实际上擅长的一种“插入”。添加是一个特殊情况,只是使用下一个元素是底层存储阵列,没有什么必须移开。尝试使用List<T>.Insert代替最糟糕的情况,而不是最好的情况。

修改

总而言之,为了插入,列表是一种特殊用途的数据结构,只能在一种插入时快速:附加到末尾。链表是一种通用数据结构,在插入列表的任何位置时同样快。还有一个细节:链表具有更高的内存和CPU开销,因此其固定成本更高。

因此,您的基准测试会将通用链接列表插入与特殊用途列表附加到最后进行比较,因此精确调整的优化数据结构正如预期的那样运行良好并不奇怪。如果您希望链表有利地进行比较,您需要一个基准列表,该列表会发现具有挑战性,这意味着您需要在列表的开头或中间插入。