C++のグラフの問題には、隣接リストまたは隣接行列の方が優れていますか?それぞれの長所と短所は何ですか?
それは問題次第です。
この答えはC++だけのものではありません。なぜなら、言語に関係なく、言及されているものはすべてデータ構造そのものに関するものだからです。そして、私の答えは、隣接リストとマトリックスの基本構造を知っていると仮定しています。
メモリが主な関心事である場合、ループを許可する単純なグラフについては、次の式に従うことができます。
隣接行列はnを占有します2/ 8バイトスペース(エントリごとに1ビット)。
隣接リストは8eスペースを占有します。eはエッジの数です(32ビットコンピューター)。
グラフの密度をd = e/nとして定義すると2 (エッジの数をエッジの最大数で割った値)、リストが行列よりも多くのメモリを占有する「ブレークポイント」を見つけることができます。
8e> n2/ 8whend> 1/64
したがって、これらの数値(まだ32ビット固有)を使用すると、ブレークポイントは1/64に到達します。密度(e/n2)は1/64よりも大きいため、メモリを節約する場合はmatrixをお勧めします。
これについては wikipedia (隣接行列に関する記事)および他の多くのサイトで読むことができます。
サイドノート:キーが頂点のペアであるハッシュテーブルを使用することで、隣接行列のスペース効率を改善できます(無向のみ)。
隣接リストは、既存のエッジのみを表すコンパクトな方法です。ただし、特定のエッジのルックアップが遅くなる可能性があります。各リストは頂点の程度と同じ長さであるため、リストが順序付けされていない場合、特定のEdgeをチェックする最悪の場合のルックアップ時間はO(n)になります。ただし、頂点の近傍の検索は簡単になり、スパースグラフまたは小さなグラフの場合、隣接リストを反復処理するコストは無視できる場合があります。
一方、隣接行列は、一定のルックアップ時間を提供するためにより多くのスペースを使用します。考えられるすべてのエントリが存在するため、インデックスを使用して一定時間内にEdgeの存在を確認できます。ただし、可能性のあるすべてのネイバーをチェックする必要があるため、ネイバールックアップにはO(n)が必要です。明らかなスペースの欠点は、スパースグラフに対して多くのパディングが追加されることです。詳細については、上記のメモリに関する説明を参照してください。
まだ何を使うべきかわからない場合:ほとんどの現実世界の問題は、スパースおよび/または大きなグラフを生成します。実装するのは難しいように思えるかもしれませんが、そうではないことを保証します。BFSまたはDFSを記述し、ノードのすべてのネイバーをフェッチする場合、コードは1行だけです。ただし、一般に隣接リストを宣伝しているわけではないことに注意してください。
さて、グラフの基本操作の時間と空間の複雑さをまとめました。
以下の画像は一目瞭然です。
グラフが密であると予想される場合の隣接行列の推奨方法、およびグラフが疎であると予想される場合の隣接リストの推奨方法。
いくつかの仮定を立てました。複雑さ(時間または空間)を明確にする必要があるかどうかを尋ねます。 (たとえば、スパースグラフの場合、新しい頂点を追加するとエッジがわずかに追加されると仮定したため、Enを小さな定数とみなしました。頂点。)
間違いがあれば教えてください。
それはあなたが探しているものに依存します。
隣接行列を使用すると、2つの頂点間の特定のエッジがグラフに属しているかどうかに関する質問にすばやく答えることができ、また、エッジをすばやく挿入および削除できます。 欠点は、特に多くの頂点を持つグラフの場合、余分なスペースを使用する必要があることです。これは、特にグラフが疎の場合、非常に非効率的です。
一方、隣接リストを使用すると、特定のエッジがグラフ内にあるかどうかを確認するのが難しくなります。適切なリストを検索してEdgeを見つけることができますが、それらはよりスペース効率的です。
ただし、一般的に、隣接リストは、ほとんどのグラフのアプリケーションに適したデータ構造です。
nのノード数とmの数のエッジを持つグラフがあると仮定しましょう。
隣接マトリックス:n数の行と列を持つメモリーを作成しますnに比例するスペースを取ります2。 uおよびvという名前の2つのノード間にEdgeがあるかどうかを確認するには、Θ(1)時間かかります。たとえば、(1、2)のチェックは、コードで次のようになります。
if(matrix[1][2] == 1)
すべてのエッジを識別したい場合は、2つのネストされたループが必要で、Θ(n2)。 (マトリックスの上三角部分を使用してすべてのエッジを決定できますが、やはりΘ(n2))
隣接リスト:各ノードが別のリストを指すリストを作成しています。リストにはn要素があり、各要素はこのノードの近隣の数に等しいアイテムの数を持つリストを指します(視覚化のために画像を見てください)。そのため、n + mに比例するメモリ領域が必要になります。 (u、v)がEdgeであるかどうかを確認するには、O(deg(u))時間を要します。この場合、deg(u)はuの隣接数に等しくなります。せいぜい、uが指すリストを反復処理する必要があるからです。すべてのエッジを識別するには、Θ(n + m)が必要です。
グラフ例の隣接リスト
C++でのグラフ分析を検討している場合、最初に開始するのはおそらくBFSを含む多くのアルゴリズムを実装する boost graph library です。
編集
SOに関するこの前の質問はおそらく役立つでしょう:
how-to-create-a-c-boost-undirected-graph-and-traverse-it-in-depth-first-searc h
これには、例を挙げて回答するのが最適です。
Floyd-Warshall を例に考えてください。隣接行列を使用する必要があります。そうしないと、アルゴリズムは漸近的に遅くなります。
それとも、それが30,000個の頂点の密なグラフである場合はどうでしょうか?次に、エッジあたり16ビット(隣接リストに必要な最小値)ではなく、頂点のペアごとに1ビットを格納するため、隣接行列が意味をなす場合があります。これは1.7 GBではなく107 MBです。
しかし、DFS、BFS(およびEdmonds-Karpなどのそれを使用するアルゴリズム)、優先度優先検索(Dijkstra、Prim、A *)などのアルゴリズムの場合、隣接リストはマトリックスと同じくらい優れています。まあ、グラフが密集しているとき、マトリックスはわずかなエッジを持っているかもしれませんが、それは目立たない定数因子だけです。 (いくらですか?実験の問題です。)
隣接マトリックスの実装に応じて、効率的な実装のために、グラフの「n」を早く知る必要があります。グラフがあまりにも動的であり、時々マトリックスの拡張を必要とする場合、それもマイナス面としてカウントすることができますか?
メモリ使用量に関する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
は合計Edgeカウント)に接続すると、ブレークポイント(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ビットポインターの場合は[64*2
])。
他の答えが他の側面をカバーしているので、通常の隣接リスト表現のトレードオフを克服することに触れます。
DictionaryおよびHashSetデータ構造を利用することにより、EdgeExists償却された一定時間のクエリで隣接リストのグラフを表すことができます。アイデアは、頂点をディクショナリに保持し、各頂点について、エッジを持つ他の頂点を参照するハッシュセットを保持することです。
この実装の1つの小さなトレードオフは、エッジがここで2回表されるため、通常の隣接リストのようにO(V + E)の代わりにスペースの複雑さO(V + 2E)を持つことです(各頂点には独自のハッシュセットがあるため)エッジ)。しかし、AddVertex、AddEdge、RemoveEdgeなどの操作は、この実装では償却時間で実行できます。O(1) RemoveVertexを除きます。これは、O(V)のような隣接行列を取ります。これは、実装の単純さの隣接行列以外に特定の利点がないことを意味します。この隣接リストの実装では、スパースグラフのスペースをほぼ同じパフォーマンスで節約できます。
詳細については、Github C#リポジトリの以下の実装をご覧ください。重み付きグラフの場合、辞書とハッシュセットの組み合わせではなく、ネストされた辞書を使用して、重み値に対応することに注意してください。同様に、有向グラフには、インエッジとアウトエッジ用の個別のハッシュセットがあります。
注:遅延削除を使用すると、そのアイデアをテストしていなくても、RemoveVertex O(1)の操作をさらに最適化できます。たとえば、削除時に頂点を辞書で削除済みとしてマークし、他の操作中に孤立エッジを遅延クリアします。
隣接行列またはリストの代わりにハッシュテーブルを使用すると、すべての操作でより良いまたは同じbig-Oランタイムとスペースが得られます(エッジのチェックはO(1)
、隣接するすべてのエッジの取得はO(degree)
など)。
ただし、実行時とスペースの両方に一定の要素オーバーヘッドがあります(ハッシュテーブルはリンクリストや配列ルックアップほど高速ではなく、衝突を減らすためにかなりの余分なスペースが必要です)。