paint-brush
C# でフロイド・ワーシャル アルゴリズムを适用して全ペア较短経路の「ルート」を見つける策略 に@olegkarasik
336 測定値
336 測定値

C# でフロイド・ワーシャル アルゴリズムを使用して全ペア最短経路の「ルート」を見つける方法

Oleg Karasik17m2024/10/15
Read on Terminal Reader

長すぎる; 読むには

この記事では、ルート追跡機能を使用して Floyd-Warshall アルゴリズムの従来の実装を拡張し、後で最短経路を再構築する方法を説明します。
featured image - C# でフロイド・ワーシャル アルゴリズムを使用して全ペア最短経路の「ルート」を見つける方法
Oleg Karasik HackerNoon profile picture

導入

前回の投稿では、使用して全ペア最短経路問題を解決する方法について説明しました。また、並列処理とベクトル化を使用してアルゴリズムのパフォーマンスを向上させるいくつかの方法についても検討しました。


しかし、フロイド・ワーシャル アルゴリズムの結果について考えると、すぐに興味深い点に気付くでしょう。グラフ内の頂点間の距離 (很短経路の値) は分かっていますが、「ルート」は分かりません。つまり、どの頂点が各很短経路に寄与しているかは分かりません。また、得られた結果からこれらの「ルート」を再構築することもできません。


この文章发表では、皆さんと一緒にフロイド・ワーシャル アルゴリズムを拡張してみましょう。今回は、距離を計算して「ルート」を再構築できることを確認します。

少しだけ知られている理論…

コードとベンチマークに進む前に、Floyd-Warshall アルゴリズムの仕組みに関する理論を再確認しましょう。


アルゴリズムのテキストによる説明は次のとおりです。
  1. まず、サイズNのグラフ ( G ) をサイズN x Nの行列 ( W ) として表します。ここで、各セルW[i,j]には、頂点iから頂点jへのエッジの重みが含まれます (図 1 を参照)。頂点間にエッジがない場合は、セルは特別なNO_EDGE値に設定されます (図 1 では暗いセルとして表示)。


  2. これからは、 W[i,j]には頂点ijの間の距離が含まれると言います。


  3. 次に、頂点kを取り、すべての頂点ペアW[i,j]を反復処理して、距離i ⭢ k ⭢ jij間の現在既知の距離よりも小さいかどうかを確認します。


  4. 最小の値はW[i,j]に格納され、 Gのすべての頂点がkとして使用されるまで、次のkに対してステップ3が繰り返されます。


図 1. 5 つの頂点を持つ有向重み付きグラフの視覚的形式 (左) と重み付き行列形式 (右) での表現。


被疑コードは次のとおりです。
 algorithm FloydWarshall(W) do for k = 0 to N - 1 do for i = 0 to N - 1 do for j = 0 to N - 1 do W[i,j] = min(W[i,j], W[i,k] + W[k,j]) end for end for end for end algorithm

完了すると、行列WのすべてのセルW[i,j]には、頂点ij間にパスがない場合はNO_EDGE値が含まれます。


冒頭で述べたように、この情報だけでは、これらの頂点間のルートを再構築することは不要能です。では、これらのルートを再構築するにはどうすればよいでしょうか?


まあ…当たり前のように聞こえるかもしれませんが…もっとデータを収集する必不可少があります!

… 少し同じ理論だが、別の角度から

フロイド・ワーシャルアルゴリズムの本質は、中間頂点kを通るiからjまでの潜在的に新しいパスの距離を持つ既知の距離W[i,j]の区画です。


この詳細にこれほど特别注意を向けるのは、それがルート情報をどのように保护できるかという鍵となるからです。先ほどの 5 つの頂点のグラフ (図 2 を对比) を取り、これに対して Floyd-Warshall アルゴリズムを実行してみましょう。


図 2. 5 つの頂点を持つ有向重み付きグラフ (左) とその重み行列 (右)


最初は、すべてのグラフ エッジがわかっているので、次のパスが得られます: 0 ⭢ 1 :2 0 ⭢ 4 :10 1 ⭢ 3 :1 2 ⭢ 4 :1 3 ⭢ 2 :1および3 ⭢ 4 :3

前回の投稿の「from」⭢「to」:「distance」形式を使用して、パスを記述します。


- 著者注

また、頂点0につながるエッジがないことにもわかっているので、 k = 0のアルゴリズムを実行しても意味がありません。頂点0から頂点1につながるエッジ ( 0 ⭢ 1 ) が 1 つあることも明らかです。これにより、すべてのi != 0 (ここではi 「from」です) の実行もまったく意味がなくなります。また、頂点1には24とのエッジがあるため、 2でも4でもないj (ここではjは「to」です) に対してアルゴリズムを実行することも意味がありません。


そのため、最初のステップでは、 k = 1i = 0j = 2,4のアルゴリズムを実行します。


ステップパスコメント
1 0 ⭢ 1 ⭢ 2パスが見つかりました。距離 = 3 (何もありませんでした)
2 0 ⭢ 1 ⭢ 4パスが見つかりました。距離 = 8 (很久以前は 10)。


2 つのパスが見つかりました。新しいパス ( 0 ⭢ 1 ⭢ 2 ) とショートカット ( 0 ⭢ 1 ⭢ 4 ) です。どちらも頂点1を通ります。この情報 ( 1通って24に到達したという事実) を今すぐどこかに保存しないと、その情報は永久に失われます (これは私たちが望んでいることとはまったく逆です)。


では、どうすればよいでしょうか。行列W新しい値で更新し (図 3a を参照)、行列Wと同じサイズだがNO_EDGE値で初期化された新しい行列RのセルR[0,2]R[0,4]kの値 ( k = 1 ) を格納します (図 3b を参照)。


図 3. k = 1、i = 1、j = 2,4 で Floyd-Warshall アルゴリズムを実行した後の行列 W (a) と R (b) の内容。新しい値または更新された値は上線で示されています。


今は、行列Rが正確に何であるかに焦点を当てないでください。そのまま続けて、次のk = 2のアルゴリズムを実行してみましょう。


ここでは、 k = 1,の場合と同じ分析 (どの組み合わせを実行するのが理にかなっているかを理解するため) を行いますが、今回はグラフGの代わりに行列W使用します。行列W 、特に列 #2 と行 #2 に注目してください (図 4)。


図 4. グラフ内の頂点 2 から他の頂点へのパスを強調表示します。


ここで、頂点0と頂点1 2の 2 つのパスと、頂点2から頂点3と頂点4 (行 #2) への 2 つのパスがあることがわかります。これを知ると、 k = 2i = 0,1j = 3,4の組み合わせに対してのみアルゴリズムを実行することが理にかなっています。


ステップパスコメント
1 0 ⭢ 2 ⭢ 3パスが見つかりました。距離 = 4 (何もありませんでした)
2 0 ⭢ 2 ⭢ 4パスが見つかりました。距離 = 6 (此前は 8)
3 1 ⭢ 2 ⭢ 3パスが見つかりました。距離 = 2 (何もありませんでした)
4 1 ⭢ 2 ⭢ 4パスが見つかりました。距離 = 4 (现在は 6)


すでに行ったように、セルW[0,3]W[0,4]W[1,3]W[1,4]新しい距離で更新し、セルR[0,3]R[0,4]R[1,3]R[1,4]k = 2で更新します(図5を参照)。


図 5. k = 2、i = 0,1、j = 3,4 で Floyd-Warshall アルゴリズムを実行した後の行列 W (a) と R (b) の内容。新しい値または更新された値は上線で示されています。


処理すべき残りの部分はk = 3だけです (グラフ内に頂点4から他の頂点につながるエッジがないため)。

もう一度、行列W (図 6) を見てみましょう。


図 6. グラフ内の頂点 3 から他の頂点へのパスを強調表示します。


行列Wによれば、頂点0 (列 # 2 ) から頂点3へのパスは 3 つあり、頂点3から頂点4 1行 #3) へのパスは 1 つあります。したがって、処理するパスは次のようになります。


ステップパスコメント
1 0 ⭢ 3 ⭢ 4パスが見つかりました。距離 = 5 (很早以前は 6)
2 1 ⭢ 3 ⭢ 4パスが見つかりました。距離 = 3 (很多年は 4)
3 2 ⭢ 3 ⭢ 4パスが見つかりました。距離 = 2 (一年前は 3)


これはアルゴリズムの最後の反復でした。残っているのは、セルW[0,4]W[1,4]W[2,4]新しい距離で更新し、セルR[0,4]R[1,4]R[2,4]3に設定することだけです。


アルゴリズムの最後には次のようになります (図 7)。


図 7. k = 3、i = 0,1,2、j = 4 で Floyd-Warshall アルゴリズムを実行した後の行列 W (a) と R (b) の内容。新しい値または更新された値は上線で示されています。


前回の投稿からわかるように、行列WにはグラフGの最短経路のペアがすべて含まれています。しかし、行列R内には何が格納されているのでしょうか?

帰郷

新しい最短経路が見つかるたびに、行列Rの対応するセルをkの現在の値で更新していました。最初はこのアクションは不思議に思えるかもしれませんが、意味は非常に単純です。つまり、目的の頂点に到達した頂点を保存していたのです: i ⭢ k ⭢ j (ここではkから直接jに到達しています)。


この瞬間は非常に重要です。なぜなら、どこから来たかがわかれば、頂点j (「to」) から頂点i (「from」) まで (ロブスターのように) 逆方向に移動してルートを再構築できるからです。


以下は、 iからjへのルートを再構築するアルゴリズムのテキストによる説明です。

  1. 空の動的配列Xを準備します。
  2. まずセルR[i,j]から値z読み取ります。
  3. zNO_EDGEの場合、 iからjへのルートが見つかったので、ステップ 7 に進みます。
  4. 動的配列Xの先頭にz追加します。
  5. セルR[i,z]の値をzに読み込みます。
  6. 手順 3、4、5 を、手順 3 の終了条件に達するまで繰り返します。
  7. X の前にi追加します。
  8. j Xに追加します。
  9. これで、動的配列X iからjへのルートが含まれるようになりました。
上記のアルゴリズムは既存のパスに対してのみ機能することに要注意してください。また、パフォーマンスの観点からも完璧ではなく、最適化できることは確かです。ただし、この发稿の範囲内で、认为しやすく、紙に再現しやすいように主要的に説明しています。


- 著者注

被疑コードではさらにシンプルになります。
 algorithm RebuildRoute(i, j, R) x = array() z = R[i,j] while (z ne NO_EDGE) do x.prepend(z) z = R[i,z] end while x.prepend(i) x.append(j) return x end algorithm

グラフGで試して、頂点0から頂点4までのルートを再構築してみましょう (図 8 を参照)。


図 8. 頂点 0 から頂点 4 へのルートの再構築をグラフ G (左) と行列 R (右) の両方で色を使用して視覚化した図。


上記の図のテキストによる説明は次のとおりです。
  1. まず、 R[0,4]から値を読み取ることから始めます。結果は3になります。アルゴリズムによれば、これはルートが頂点3から頂点4に行くことを意味します (青色で表示)。


  2. R[0,4]の値はNO_EDGEではないため、先に進んでR[0,3]の値を読み取ります。結果は2 (緑色で表示)になります。


  3. ここでも、 R[0,3]の値はNO_EDGEではないため、先に進んでR[0,2]の値を読み取ります。結果は1になります(赤で表示)。


  4. 最後に、 R[0,1]の値を読み取ります。これはNO_EDGE値になります。つまり、ルートに寄与する頂点は04以外にはありません。したがって、結果のルートは0 ⭢ 1 ⭢ 2 ⭢ 3 ⭢ 4となり、グラフを見ると、これは確かに頂点0から頂点4への最短経路に対応しています。


思慮深い読者はこう尋ねるかもしれません。

行列Rから読み取ったすべてのデータが同じパスに属していることをどのように確認できますか?


- 思慮深い読者

非常に良い質問です。行列Wの最短経路値を更新するときに、行列R新しい値で更新するので、確実です。したがって、最終的には、行列Rのすべての行に最短経路に関連するデータが含まれます。それ以上でもそれ以下でもありません。


さて、理論は終わりました。次は実装の時間です。

C#での実装

前回の投稿では、Floyd-Warshall アルゴリズムのオリジナル バージョンを実装するだけでなく、スパース グラフ、並列処理、ベクトル化のサポートなど、さまざまな最適化も統合し、最終的にこれらすべてを組み合わせました。


接下来はこれをすべて繰り返す用不着はありません。ただし、ルート記憶をアルゴリズムのオリジナル バージョンとベクトル化バージョン (スパース グラフをサポート) に統合する技术を紹介したいと思います。

オリジナルアルゴリズムへの統合

信じ難いかもしれませんが、ルート記憶をアルゴリズムのオリジナルバージョンに統合するには、次の操控を行うだけです。
  1. 関数シグネチャを拡張して、 Rマトリックスを別のパラメーターとして含めます ( int[] routes


  2. 最短経路が変更されるたびに、 routesに k を保存します (行: 2 および 14)。


左右です。コードはたった 1 行半です。
 public void BaselineWithRoutes( int[] matrix, int[] routes, int sz) { for (var k = 0; k < sz; ++k) { for (var i = 0; i < sz; ++i) { for (var j = 0; j < sz; ++j) { var distance = matrix[i * sz + k] + matrix[k * sz + j]; if (matrix[i * sz + j] > distance) { matrix[i * sz + j] = distance; routes[i * sz + j] = k; } } } } }

ベクトル化アルゴリズムへの統合

ベクトル化されたバージョン(スパース グラフのサポート付き)への統合には、弄完までにもう少し労力がかかりますが、范畴的な手順は同じです。
  1. 関数シグネチャを拡張して、 Rマトリックスを別のパラメーターとして含めます ( int[] routes


  2. 各反復で、 k値の新しいベクトルを初期化します (行: 6)。


  3. 最短経路が変更されるたびに、 k値のベクトルをroutesに保存します (行: 31-32)。


  4. 元のアルゴリズムを更行したのと同じ的办法で、アルゴリズムのベクトル化されていない的部分を更行します (行: 41)。
 public void SpartialVectorOptimisationsWithRoutes( int[] matrix, int[] routes, int sz) { for (var k = 0; k < sz; ++k) { var k_vec = new Vector<int>(k); for (var i = 0; i < sz; ++i) { if (matrix[i * sz + k] == Constants.NO_EDGE) { continue; } var ik_vec = new Vector<int>(matrix[i * sz + k]); var j = 0; for (; j < sz - Vector<int>.Count; j += Vector<int>.Count) { var ij_vec = new Vector<int>(matrix, i * sz + j); var ikj_vec = new Vector<int>(matrix, k * sz + j) + ik_vec; var lt_vec = Vector.LessThan(ij_vec, ikj_vec); if (lt_vec == new Vector<int>(-1)) { continue; } var r_vec = Vector.ConditionalSelect(lt_vec, ij_vec, ikj_vec); r_vec.CopyTo(matrix, i * sz + j); var ro_vec = new Vector<int>(routes, i * sz + j); var rk_vec = Vector.ConditionalSelect(lt_vec, ro_vec, k_vec); rk_vec.CopyTo(routes, i * sz + j); } for (; j < sz; ++j) { var distance = matrix[i * sz + k] + matrix[k * sz + j]; if (matrix[i * sz + j] > distance) { matrix[i * sz + j] = distance; routes[i * sz + j] = k; } } } } }

ちょっとした注意点 – Vector.ConditionalSelect操作は、入力ベクトルの対応する 2 つの値のうち小さい方に等しい値を持つ新しいベクトルを返します。つまり、ベクトルlt_vecの値が-1に等しい場合、操作はij_vecから値を選択し、それ以外の場合はk_vecから値を選択します。


- 著者注

ベンチマーク

ルート情報を収集するのは、非常に正确的に思えます。なぜなら、既存のアルゴリズムに簡単に統合できるからです。ただし、デフォルトで統合することがどれだけ実用的かを見てみましょう。


ここに、ありとなしの 2 セットのベンチマークがあります (どちらもアルゴリズムのオリジナル バージョンとベクトル化されたバージョンを実行します)。すべてのベンチマークは、Intel Core i5-6200U CPU 2.30GHz (Skylake) プロセッサを搭載し、Windows 10 を実行しているマシンで、 v0.13.1 (.NET 6.0.4 x64) によって実行されました。


すべての実験は、 前回の投稿で使用した定義済みのグラフ セット (300、600、1200、2400、および 4800 頂点) で実行されました。

ソース コードと実験グラフは、GitHub のリポジトリで入手できます。実験グラフは、 Data/Sparse-Graphs.zipディレクトリにあります。この記事のすべてのベンチマークは、 ファイルに実装されています。

以下はベンチマーク結果です。Baseline およびBaselineWithRoutesメソッドはアルゴリズムのオリジナル バージョンに対応し、 SpartialVectorOptimisationsおよびSpartialVectorOptimisationsWithRoutesメソッドBaselineアルゴリズムのベクトル化バージョン (スパース グラフのサポート付き) に対応します。


办法サイズ总值(ミリ秒)エラー (ミリ秒)標準偏移 (ミリ秒)
ベースライン300 40.233 0.0572 0.0477
ルート付きベースライン300 40.349 0.1284 0.1201
有些ベクトル最適化300 4.472 0.0168 0.0140
ルート付き部位ベクトル最適化300 4.517 0.0218 0.0193
ベースライン600 324.637 5.6161 4.6897
ルート付きベースライン600 321.173 1.4339 1.2711
一部分ベクトル最適化600 32.133 0.2151 0.1679
ルート付き个部分ベクトル最適化600 34.646 0.1286 0.1073
ベースライン1200 2,656.024 6.9640 5.8153
ルート付きベースライン1200 2,657.883 8.8814 7.4164
地方ベクトル最適化1200 361.435 2.5877 2.2940
ルート付き区域ベクトル最適化1200 381.625 3.6975 3.2777
ベースライン2400 21,059.519 38.2291 33.8891
ルート付きベースライン2400 20,954.852 56.4719 50.0609
这部分ベクトル最適化2400 3,029.488 12.1528 11.3677
ルート付き位置ベクトル最適化2400 3,079.006 8.6125 7.1918

ベースライン

4800

164,869.803

547.6675

427.5828

ルート付きベースライン

4800

184,305. 980

210.9535

164.6986

区域ベクトル最適化4800 21,882.379 200.2820 177.5448
ルート付き的部分ベクトル最適化4800 21,004.612 79.8752 70.8073


ベンチマーク結果の解釈は簡単ではありません。詳しく調べると、ルート収集によって実際にアルゴリズムの実行强度が速くなった場合と、まったく大きな影響がなかった場合の組み合わせが見つかります。


これは非常に混乱しているように見えます (もしそうなら、とが出演するこの を聞いて、計測にどのようなトリッキーな要素が影響するかをよりよく理解することを強くお勧めします)。ここでの私の最善の見解は、「混乱」する動作は優れた CPU キャッシュ パフォーマンスによって発生するということです (グラフがキャッシュを飽和させるほど大きくないため)。部分的に、この理論は、最大のグラフで大幅な劣化が見られる「太字」のサンプルに基づいています。ただし、私はそれを検証していません。


一般的に、ベンチマークでは、極めて高性能なシナリオや巨大なグラフについて話しているのではない限り、デフォルトで両方のアルゴリズムにルート記憶を統合しても問題ないことを示しています (ただし、1 つの行列WRではなく 2 つの行列を割り当てる必要があるため、メモリ消費量が 2 倍になることに注意してください)。


残っているのはルート再構築アルゴリズムの実装だけです。

ルート再構築アルゴリズムの実装

C# でのルート再構築アルゴリズムの実装は簡単で、これまでに示した疑似コードをほぼ完全に反映しています (動的配列を表すためにLinkedList<T>を使用します)。

 public static IEnumerable<int> RebuildWithLinkedList( int[] routes, int sz, int i, int j) { var x = new LinkedList<int>(); var z = routes[i * sz + j]; while (z != Constants.NO_EDGE) { x.AddFirst(z); z = routes[i * sz + z]; } x.AddFirst(i); x.AddLast(j); return x; }

上記のアルゴリズムは完璧ではなく、間違いなく改善できます。最も簡単な改善方法は、 szサイズの配列を事前に割り当てて、逆の順序で埋めることです。

 public static IEnumerable<int> RebuildWithArray( int[] routes, int sz, int i, int j) { var x = new int[sz]; var y = sz - 1; // Fill array from the end x[y--] = j; var z = routes[i * sz + j]; while (z != Constants.NO_EDGE) { x[y--] = z; z = routes[i * sz + z]; } x[y] = i; // Create an array segment from 'y' to the end of the array // // This is required because 'sz' (and therefore length of the array x) // equals to the number of vertices in the graph // // So, in cases when route doesn't span through // all vertices - we need return a segment of the array return new ArraySegment<int>(x, y, sz - y); }

この「最適化」により割り当て回数は 1 回に減りますが、必ずしもアルゴリズムが「高速化」されたり、「割り当てるメモリが少なくなる」わけではありません。ここでのポイントは、 iからjへのルートを順序付ける必要がある場合、常に行列Rから取得したデータを「反転」する必要があり、それを回避する方法はないということです。


しかし、データを逆の順序で说明できれば、LINQ を合理利用して尽量不要な割り当てを避让できます。
 public static IEnumerable<int> RebuildWithReverseYield( int[] routes, int sz, int i, int j) { yield return j; var z = routes[i * sz + j]; while (z != Constants.NO_EDGE) { yield return z; z = routes[i * sz + z]; } yield return i; }
このコードを「変更」または「促进」する具体方法には、さらに多くのバリエーションがあります。ここで更重要なのは、どのような「変更」にも、メモリや CPU サイクルの面でマイナス面があることを覚えておくことです。


ぜひ随意に実験してみてください。有机会性はほぼ無限です。

すべてのルート アルゴリズムの実装は、GitHub のファイルで確認できます。

結論


この投稿では、フロイド・ワーシャル アルゴリズムの背後にある理論を深く掘り下げ、最短経路「ルート」を記憶できるように拡張しました。次に、 前回の投稿の C# 実装 (オリジナルとベクトル化) を更新しました。最後に、アルゴリズムのいくつかのバージョンを実装して、「ルート」を再構築しました。


これまで何度も繰り返してきましたが、同時に、この中で何か新しい興味深いものを見つけられたことを願っています。これは、全ペア最快経路問題の終わりではありません。これは始まりに過ぎません。


楽しんで読んでいただけたなら幸いです。また次回お会いしましょう!
바카라사이트 바카라사이트 온라인바카라