web-dev-qa-db-ja.com

LinkedListが一般的にリストよりも遅いのはなぜですか?

いくつかの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/

40
JP Richardson

pdateあなたのコメントに応えて):その通りです。big-O表記だけで議論することは必ずしも役に立ちません。ジェームズの回答へのリンクを元の回答に含めました。彼は、List<T>が一般的にLinkedList<T>よりも優れている技術的な理由についてすでに十分な説明を提供しているからです。

基本的に、それはメモリ割り当てと局所性の問題です。コレクションのすべての要素が内部的に配列に格納されている場合(List<T>の場合のように)、すべてが1つの連続したメモリブロックにあり、アクセスできます_(非常に迅速に。これはadding(これは単にすでに割り当てられた配列内の場所に書き込むため)および反復(これは完全に切断されたメモリ場所へのポインタをたどる必要はなく、非常に近い多くのメモリ場所にアクセスするため)。

LinkedList<T>特殊なコレクションであり、リストの中央)からランダムな挿入または削除を実行している場合にのみ、List<T>よりも優れています。それでも、多分

スケーリングの問題については、その通りです。big-O表記が、操作scales)の程度に関するものである場合、O(1)操作は最終的にO( > 1)十分な大きさの入力が与えられた場合の操作—これは明らかに2,000万回の反復で目的としていたことです。

これが、List<T>.Add償却された複雑さ のO(1)を持っていると述べた理由です。つまり、リストへの追加は入力のサイズに比例してスケーリングする操作であり、リンクリストの場合と同じ(効果的に)です。リスト自体のサイズを変更する必要がある場合があるという事実を忘れてください(これは「償却済み」の出番です。まだ行っていない場合は、そのWikipediaの記事にアクセスすることをお勧めします。それらはscalesameです。

さて、興味深いことに、そしておそらく直感に反して、これは、どちらかといえば、List<T>LinkedList<T>のパフォーマンスの違いを意味します(ここでも、を追加)に関しては、実際にはより明白なの数としてその理由は、リストの内部配列のスペースが不足すると、リストは2倍配列のサイズになります。したがって、要素が増えると、サイズ変更操作の頻度は減少—)になります。アレイのサイズが基本的に変更されない程度まで。

したがって、List<T>が4つの要素を保持するのに十分な大きさの内部配列で始まるとしましょう(確かには覚えていませんが、それは正確だと思います)。次に、最大2,000万個の要素を追加すると、合計で〜(log2(20000000)-1)または23回。これを2000万回と比較してください。LinkedList<T>かなり効率の低いAddLastを実行しています。これにより、呼び出しごとに新しいLinkedListNode<T>が割り当てられ、23のサイズ変更は突然かなり重要ではないように見えます。

これがお役に立てば幸いです。不明な点がある場合はお知らせください。できる限り明確にしたり、修正したりします。


ジェームズ は正しいです。

Big-O表記は、アルゴリズムのパフォーマンスscales。保証されたO(1)時間で実行されるものが何かを上回ることを意味するものではありません。それ以外の場合は、償却されたO(1)時間で実行されます(List<T>の場合と同様)。

2つの仕事の選択肢があり、そのうちの1つは、時折渋滞に見舞われる道路を5マイル下って通勤する必要があるとします。通常、このドライブには約10分かかりますが、悪い日には30分ほどかかることもあります。もう1つの仕事は60マイル離れていますが、高速道路は常に晴れていて、渋滞はありません。このドライブ常には1時間かかります。

これは基本的に、リストの最後に追加するためのList<T>LinkedList<T>の状況です。

32
Dan Tao

プリミティブのリストがあることに注意してください。 Listの場合、これはintの配列全体を作成するため非常に単純であり、より多くのメモリを割り当てる必要がない場合にこれらをシフトダウンするのは非常に簡単です。

これを、intをラップするために常にメモリを割り当てる必要があるLinkedListと比較してください。したがって、メモリ割り当てがおそらくあなたの時間に最も貢献しているものだと思います。すでにノードが割り当てられている場合は、全体的に高速になるはずです。 LinkedListNodeを使用して検証するAddFirstのオーバーロードを試してみます(つまり、タイマーのスコープ外にLinkedListNodeを作成し、追加のタイミングを計ります)。

反復も同様です。リンクをたどるよりも、内部配列の次のインデックスに移動する方がはるかに効率的です。

14

ジェームズが彼の答えで述べたように、メモリ割り当てはおそらくLinkedListが遅い理由の1つです。

さらに、大きな違いは無効なテストに起因すると思います。リンクリストの最初にアイテムを追加していますが、通常のリストの最後にアイテムを追加しています。通常のリストの先頭に項目を追加すると、ベンチマーク結果がシフトしてLinkedListになりませんか?

5
Steven Jeuris

この記事を強くお勧めします数の計算:リンクリストを二度と使用しない理由。他にないものはあまりありませんが、なぜLinkedList <T>List <T>]よりもはるかに遅いのかを理解するためにかなりの時間を費やしましたリンクリストを見つける前に明らかに好むと思った状況では、それを調べた後、物事はもう少し理にかなっています。

リンクリストのアイテムはメモリの互いに素な領域にあり、その結果、キャッシュミスを最大化するため、キャッシュラインが敵対的であると言えます。互いに素なメモリにより、リストをトラバースすると、予期しない頻繁でコストのかかるRAMルックアップが発生します。

一方、ベクトル[ArrayListまたはList <T>]は、アイテムが隣接メモリに格納されているため、次のようになります。キャッシュ使用率を最大化し、キャッシュミスを回避できます。多くの場合、実際には、これはデータをシャッフルするときに発生するコストを相殺する以上のものです。

より信頼できる情報源からそれを聞きたい場合は、MSDNの 時間クリティカルコードを改善するためのヒント からです。

見栄えのするデータ構造が貧弱なために恐ろしいことが判明することがあります参照の局所性。次に2つの例を示します。

  • 動的に割り当てられたリンクリストLinkedListNode <T>は参照型であるため、動的に割り当てられます)は、アイテムを検索するとき、またはアイテムをトラバースするときに、プログラムのパフォーマンスを低下させる可能性があります。リストの最後まで、スキップされた各リンクはキャッシュを見逃したり、ページフォールトを引き起こしたりする可能性があります。 単純な配列に基づくリストの実装は、キャッシュが改善され、ページフォールトが少ないため、実際にははるかに高速になる可能性があります —配列の拡張が困難になるという事実を考慮しても、それでも高速になる可能性があります。

  • 動的に割り当てられたリンクリストを使用するハッシュテーブルは、パフォーマンスを低下させる可能性があります。ひいては、動的に割り当てられたリンクリストを使用してコンテンツを格納するハッシュテーブルは、パフォーマンスが大幅に低下する可能性があります。 実際、最終的な分析では、配列を介した単純な線形検索の方が実際には高速である可能性があります(状況によって異なります)。配列ベースのハッシュテーブル(IIRC、Dictionary <TKey、TValue>は配列ベースです)は見過ごされがちな実装であり、パフォーマンスが優れていることがよくあります。


これは、パフォーマンステストを行った元の(あまり役に立たない)回答です。

一般的なコンセンサスは、リンクリストが追加のたびにメモリを割り当てているようです(ノードがクラスであるため)。それは事実のようです。リストにアイテムを追加する時限コードから割り当てコードを分離し、結果から要点を作成しようとしました: https://Gist.github.com/zeldafreak/d11ae7781f5d43206f65

テストコードを5回実行し、それらの間でGC.Collect()を呼び出します。リンクリストに2,000万ノードを挿入するには、77〜89ミリ秒(81ミリ秒)と比較して193〜211ミリ秒(198ミリ秒)かかるため、割り当てがなくても、標準リストの方が2倍強速くなります。リストの反復には54〜59ミリ秒かかりますが、リンクリストの76〜101ミリ秒は、より控えめな50%の速度です。

4
David Schwartz

ListLinkedListを使用して同じテストを実行し、実際のオブジェクト(実際には匿名タイプ)をリストに挿入しました。その場合も、リンクリストはリストよりも低速です。

ただし、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()によってリスト全体が最後までトラバースされますか?

2
CodingWithSpike

他の回答はこれについて言及していなかったので、私は別のものを追加しています。

印刷ステートメントには"List Insert"と書かれていますが、実際にはList<T>.Addと呼ばれています。これは、Listが実際に得意とする「挿入」の一種です。 Addは、次の要素を使用する特殊なケースであり、基になるストレージアレイであり、邪魔にならないように移動する必要はありません。代わりにList<T>.Insertを実際に使用して、最良の場合ではなく最悪の場合にしてみてください。

編集:

要約すると、挿入の目的で、リストは1種類の挿入でのみ高速な特別な目的のデータ構造です。最後に追加します。リンクリストは、リストのどこにでも同じように高速に挿入できる汎用データ構造です。そしてもう1つ詳細があります。リンクリストはメモリとCPUのオーバーヘッドが高いため、固定費が高くなります。

したがって、ベンチマークでは、汎用のリンクリストの挿入と最後の特殊な目的のリストの追加を比較します。したがって、意図したとおりに使用されている、微調整された最適化されたデータ構造が良好に機能していることは驚くべきことではありません。リンクリストを有利に比較したい場合は、リストが難しいと感じるベンチマークが必要です。つまり、リストの最初または途中に挿入する必要があります。

2
Rick Sladkey