C ++でのグラフの問題について、隣接リストまたは隣接行列は何が良いですか?それぞれの長所と短所は何ですか?
std::list
(または、さらに優れていますがstd::vector
)。
std::deque
またはstd::set
。それは、グラフが時間とともにどのように変化するか、そしてどのアルゴリズムで実行するかによって異なります。
C ++でのグラフの問題について、隣接リストまたは隣接行列は何が良いですか?それぞれの長所と短所は何ですか?
std::list
(または、さらに優れていますがstd::vector
)。
std::deque
またはstd::set
。それは、グラフが時間とともにどのように変化するか、そしてどのアルゴリズムで実行するかによって異なります。
回答:
問題によって異なります。
言語に関係なく、言及されているすべてがデータ構造自体に関するものであるため、この答えはC ++だけのものではありません。そして、私の答えは、隣接リストと行列の基本構造を知っていると仮定しています。
メモリが主な関心事である場合は、次の式に従ってループを許可する単純なグラフを作成できます。
隣接行列は、N占める2 /8バイトの空間(エントリごとに1ビット)。
隣接リストは8eのスペースを占めます。eはエッジの数です(32ビットコンピューター)。
グラフの密度をd = e / n 2 (エッジの数をエッジの最大数で割ったもの)として定義すると、リストが行列よりも多くのメモリを消費する「ブレークポイント」を見つけることができます。
8E> N 2 /8 D> 1/64
したがって、これらの数値(まだ32ビット固有)では、ブレークポイントは1/64に到達します。密度(e / n 2)が1/64より大きい場合、メモリを節約したい場合は行列が適しています。
これについては、ウィキペディア(隣接マトリックスに関する記事)や他の多くのサイトで読むことができます。
補足:キーが頂点のペア(無向のみ)であるハッシュテーブルを使用することにより、隣接行列のスペース効率を向上させることができます。
隣接リストは、既存のエッジのみを表すコンパクトな方法です。ただし、これには特定のエッジのルックアップが遅くなる可能性があります。リストが順序付けされていない場合、各リストは頂点の次数である限り、特定のエッジをチェックする最悪の場合のルックアップ時間はO(n)になる可能性があります。ただし、頂点の近傍を検索することは簡単になり、疎または小さなグラフの場合、隣接リストを反復するコストは無視できる可能性があります。
一方、隣接行列は、一定のルックアップ時間を提供するためにより多くのスペースを使用します。可能なすべてのエントリが存在するため、インデックスを使用して一定の時間でエッジの存在を確認できます。ただし、可能性のあるすべての近傍をチェックする必要があるため、近傍ルックアップはO(n)を使用します。明らかなスペースの欠点は、スパースグラフの場合、多くのパディングが追加されることです。この詳細については、上記のメモリに関する説明を参照してください。
それでも何を使用すればよいかわからない場合:ほとんどの実際の問題は、疎または大規模なグラフを生成するため、隣接リストの表現に適しています。それらを実装するのは難しいように思えるかもしれませんが、実装されていないと確信しています。BFSまたはDFSを記述し、ノードのすべてのネイバーをフェッチしたい場合、それらは1行のコードだけです。ただし、隣接リストは一般的に宣伝していません。
e = n / s
。ここs
で、はポインターのサイズです。
さて、グラフの基本的な操作の時間と空間の複雑さをまとめました。
下の画像は一目瞭然です。
グラフが密であると予想される場合は隣接行列がどのように望ましいか、グラフが疎であると予想される場合は隣接リストがどのように望ましいかに注意してください。
私はいくつかの仮定をしました。複雑さ(時間または空間)を明確にする必要があるかどうかを尋ねます。(たとえば、スパースグラフの場合、新しい頂点を追加するとエッジが数個しか追加されないと想定しているため、Enを小さな定数にしています。バーテックス。)
間違いがあったら教えてください。
それはあなたが探しているものに依存します。
隣接行列あなたは、2つの頂点間の特定のエッジがグラフに属し、あなたはまた、迅速な挿入およびエッジの欠失を有することができるかどうかについての質問に高速に答えることができます。欠点は、あなたがあなたのグラフがまばらである場合は特に、非常に非効率的である、特に多くの頂点を持つグラフの、過度のスペースを使用しなければならないことです。
一方、隣接リストでは、エッジを見つけるために適切なリストを検索する必要があるため、特定のエッジがグラフに含まれているかどうかを確認することは困難ですが、スペース効率が向上します。
ただし、一般的に、隣接リストはグラフのほとんどのアプリケーションに適切なデータ構造です。
n個のノードとm個のエッジを持つグラフがあるとします。
隣接行列:行と列の数 がnである行列を作成しているため、メモリ内ではn 2に比例するスペースを使用します。uとvという名前の2つのノードの間にエッジがあるかどうかを確認すると、Θ(1)時間かかります。たとえば、(1、2)がエッジであるかどうかのチェックは、コードでは次のようになります。
if(matrix[1][2] == 1)
すべてのエッジを識別したい場合、ここで行列を反復処理する必要があります。これには2つのネストされたループが必要で、Θ(n 2)かかります。(行列の上三角部分を使用してすべてのエッジを決定できますが、これもΘ(n 2)になります)
隣接リスト: 各ノードが別のリストも指すリストを作成しています。リストにはn個の要素があり、各要素はこのノードの隣接要素の数と等しいアイテム数のリストを指します(視覚化を向上させるには画像を参照してください)。したがって、n + mに比例するメモリのスペースが必要になります。(u、v)がエッジかどうかを確認するには、O(deg(u))時間かかります。この場合、deg(u)はuの近傍の数と等しくなります。せいぜい、uが指すリストを繰り返し処理する必要があるからです。すべてのエッジを識別するには、Θ(n + m)かかります。
グラフ例の隣接リスト
C ++でグラフ分析を検討している場合、おそらく最初に開始するのは、BFSを含む多数のアルゴリズムを実装するブーストグラフライブラリです。
編集
SOに関するこの前の質問はおそらく役立つでしょう:
How-to-Create-ac-boost-undirected-graph-and-traverse-it-in-depth-first-searc h
これは例で最もよく答えられます。
たとえば、フロイドワーシャルを考えてみてください。隣接行列を使用する必要があります。そうしないと、アルゴリズムが漸近的に遅くなります。
または、それが30,000の頂点の密なグラフである場合はどうなりますか?隣接行列は、エッジあたり16ビット(隣接リストに必要な最小値)ではなく、頂点のペアあたり1ビットを格納するため、1.7 GBではなく107 MBになるため、意味がある場合があります。
ただし、DFS、BFS(およびそれを使用するアルゴリズム、Edmonds-Karpなど)、優先度優先検索(Dijkstra、Prim、A *)などのアルゴリズムの場合、隣接リストはマトリックスと同じくらい優れています。まあ、グラフが密である場合、マトリックスにはわずかなエッジがあるかもしれませんが、それは目立たない定数係数によるものだけです。(どれくらいですか?実験の問題です。)
an adjacency list is as good as a matrix
はそれらの場合にどう思いますか?
keyser5053のメモリ使用量に関する回答に追加します。
有向グラフの場合、隣接行列(エッジあたり1ビット)n^2 * (1)
はメモリのビットを消費します。
以下のために完全グラフ(64ビット・ポインタを有する)、隣接リスト消費n * (n * 64)
リストオーバーヘッドを除くメモリのビットを、。
不完全なグラフの場合、隣接リストは0
リストのオーバーヘッドを除いてメモリのビットを消費します。
隣接リストの場合、次の式を使用してe
、隣接行列がメモリに最適になるまでのエッジの最大数()を決定できます。
edges = n^2 / s
エッジの最大数を決定しs
ます。は、プラットフォームのポインターサイズです。
グラフが動的に更新されている場合、(ノードあたりの)平均エッジカウントでこの効率を維持できますn / s
。
64ビットポインターと動的グラフのいくつかの例(動的グラフは、変更が加えられるたびにゼロから再計算するのではなく、変更後に問題の解を効率的に更新します。)
n
が300である有向グラフの場合、隣接リストを使用するノードごとのエッジの最適数は次のとおりです。
= 300 / 64
= 4
これをkeyser5053の式d = e / n^2
(ここe
で、合計エッジ数)に代入すると、ブレークポイント(1 / s
)の下にあることがわかります。
d = (4 * 300) / (300 * 300)
d < 1/64
aka 0.0133 < 0.0156
ただし、ポインタの64ビットはやり過ぎになる可能性があります。代わりに16ビット整数をポインターオフセットとして使用する場合、ポイントを分割する前に最大18のエッジに合わせることができます。
= 300 / 16
= 18
d = ((18 * 300) / (300^2))
d < 1/16
aka 0.06 < 0.0625
これらの例はそれぞれ、隣接リスト自体のオーバーヘッドを無視します(64*2
ベクトルおよび64ビットポインターの場合)。
d = (4 * 300) / (300 * 300)
ないのd = 4 / (300 * 300)
ですが、理解できませんか?式であるのでd = e / n^2
。
他の回答が他の側面をカバーしているので、通常の隣接リスト表現のトレードオフを克服することに触れます。
との隣接リストのグラフを表現することが可能であるEdgeExistsのを利用することによって、一定の償却時間のクエリ辞書とHashSetのデータ構造。アイデアは、頂点をディクショナリに保持することです。頂点ごとに、エッジを持つ他の頂点を参照するハッシュセットを保持します。
この実装での小さなトレードオフの1つは、エッジがここで2回表されるため、通常の隣接リストのようにO(V + E)ではなくスペースの複雑さO(V + 2E)になることです(各頂点には独自のハッシュセットがあるため)エッジの)。しかし、などの操作AddVertex、 AddEdge、RemoveEdgeはを除いて、この実装で償却時間O(1)で行うことができるRemoveVertex隣接マトリックス状O(V)となります。これは、実装が単純であることを除いて、隣接行列には特定の利点がないことを意味します。この隣接リストの実装では、ほぼ同じパフォーマンスでスパースグラフのスペースを節約できます。
詳細については、Github C#リポジトリの以下の実装をご覧ください。加重グラフの場合、加重値に対応するために、辞書とハッシュのセットの組み合わせではなく、ネストされた辞書を使用することに注意してください。同様に、有向グラフの場合、インエッジとアウトエッジに個別のハッシュセットがあります。
注:レイジー削除を使用すると、RemoveVertex操作をさらに最適化して、償却されたO(1)にすることができます。ただし、そのアイデアはテストしていません。たとえば、削除時に頂点をディクショナリで削除済みとしてマークし、他の操作中に孤立したエッジを遅延してクリアします。