有名なアルゴリズム BFS、DFS、ダイクストラ、および A-Star は、本質的には同じアルゴリズムの変形です。これを実際の実装で示します。
BFS、DFS、Dijkstra、A-Star などのよく知られたアルゴリズムは、本質的には同じアルゴリズムの亜種であることが判明しました。
言い換えれば、コアコンポーネントを変更することなく、これらのアルゴリズムを切り替えることができる共通のデータ構造を実装することが可能です。考慮すべき制限がいくつかありますが、このアプローチを検討するのは興味深いものでした。
これらのアルゴリズムのすべての動作コードは、私の GitHub リポジトリにあります。この記事を読みながらコードを試してみることをお勧めします。実践的な経験は理論的な理解を促進するだけでなく、学習も促進します。
グラフィック表現
25 個のノードが 5x5 グリッドに配置されたグラフを考えてみましょう。目的は、左上隅のノード 0 から右下隅のノード 24 までのパスを見つけることです。
( 0 ) - ( 1 ) - ( 2 ) - ( 3 ) - ( 4 )
| | | | |
( 5 ) - ( 6 ) - ( 7 ) - ( 8 ) - ( 9 )
| | | | |
( 10 ) - ( 11 ) - ( 12 ) - ( 13 ) - ( 14 )
| | | | |
( 15 ) - ( 16 ) - ( 17 ) - ( 18 ) - ( 19 )
| | | | |
( 20 ) - ( 21 ) - ( 22 ) - ( 23 ) - ( 24 )
上記の各アルゴリズムはこれを実現できますが、それぞれに独自の制限があります。BFS
アルゴリズムと DFS アルゴリズムは両方とも、エッジの重みを無視して、重み付けされていないグラフで動作します。任意のパスを見つけることはできますが、最適なパスであるという保証はありません。
ダイクストラのアルゴリズムと A-Star のアルゴリズムはどちらも重み付きグラフで機能しますが、負の重みを含むグラフでは使用しないでください。A-Star は、経路探索中にユークリッド座標を組み込むように最適化されているため、通常は高速です。
これらの制限を回避するために、各ノード (X、Y) に虚数座標を割り当てます。
(0, 0) - (0, 1) - (0, 2) - (0, 3) - (0, 4)
| | | | |
(1, 0) - (1, 1) - (1, 2) - (1, 3) - (1, 4)
| | | | |
(2, 0) - (2, 1) - (2, 2) - (2, 3) - (2, 4)
| | | | |
(3, 0) - (3, 1) - (3, 2) - (3, 3) - (3, 4)
| | | | |
(4, 0) - (4, 1) - (4, 2) - (4, 3) - (4, 4)
最後に、グラフの各エッジに重みを割り当てましょう。
(0, 0) -1- (0, 1) -1- (0, 2) -1- (0, 3) -2- (0, 4)
| | | | |
2 1 1 2 2
| | | | |
(1, 0) -2- (1, 1) -1- (1, 2) -2- (1, 3) -1- (1, 4)
| | | | |
2 1 1 1 1
| | | | |
(2, 0) -1- (2, 1) -1- (2, 2) -1- (2, 3) -2- (2, 4)
| | | | |
2 1 1 1 2
| | | | |
(3, 0) -2- (3, 1) -2- (3, 2) -1- (3, 3) -2- (3, 4)
| | | | |
2 1 1 2 2
| | | | |
(4, 0) -2- (4, 1) -1- (4, 2) -2- (4, 3) -2- (4, 4)
C++ では、この構造は次のように表現できます。
class GraphNode
{
public:
int X;
int Y;
};
class Graph
{
public:
vector<vector<pair<int, int>>> Edges;
vector<GraphNode> Nodes;
};
グラフ内のエッジのリストは配列の配列によって表され、インデックスはグラフ内の各エッジの出口ノードの番号に対応します。各要素には次の値のペアが含まれます。
- グラフ内の各エッジの受信ノードの数。
- エッジの重み。
この単純な構造を使用すると、グラフ内の各ノードを反復処理して、その接続に関する必要な情報をすべて取得できます。
int toNode = graph.Edges[fromNode][neighbourIndex].first;
int weight = graph.Edges[fromNode][neighbourIndex].second;
ここで、グラフ内にいくつかのカスタム接続を作成して、一般的なアルゴリズムの動作への影響を確認してみましょう。このコードはここでは主な焦点ではないため、関連するメソッドへのリンクを提供します。
ノード リストの生成
カスタム接続の作成
あるいは、より少ないコードでこのグラフ内のすべての接続と重みを遅延生成することもできます。ただし、このアプローチでは、アルゴリズムがグラフを移動する方法の実際の違いを完全には理解できない可能性があります。
一般的なアルゴリズム
汎用経路探索アルゴリズムの中心となるのは汎用データ構造であり、このプロジェクトではこれを「キュー」と呼びます。ただし、これは古典的な FIFO (先入れ先出し) データ構造ではありません。むしろ、これは、使用されるアルゴリズムに応じてキューイング メカニズムを変更できる一方で、トラバーサル中にノードのキューイングを実装できるようにする一般的な構造です。この「キュー」へのインターフェースはシンプルです。
class pathFindingBase
{
public:
virtual void insert(int node) = 0;
virtual int getFirst() = 0;
virtual bool isEmpty() = 0;
};
キューの詳細に入る前に、トラバーサル アルゴリズム自体を調べてみましょう。
本質的に、これは典型的な A-Star または Dijkstra のアルゴリズムと非常に似ています。まず、次のことを可能にするコレクションのセットを初期化する必要があります。
- 未処理 (白)、現在処理中 (灰色)、および処理/訪問済み (黒) のノードのリストを維持します。
- 開始ノードからコレクション内の各ノードまでの最短パスの現在の距離を追跡します。
- 最終的なパスを再構築できるように、前と次のノードのペアのリストを保存します。
const int INF = 1000000;
const int WHITE = 0;
const int GREY = 1;
const int BLACK = 2;
/// <summary>
/// Universal algorithm to apply Path search using BFS, DFS, Dijkstra, A-Star.
/// </summary>
vector<int> FindPath(Graph& graph, int start, int finish, int finishX, int finishY)
{
int verticesNumber = graph.Nodes.size();
// All the nodes are White colored initially
vector<int> nodeColor(verticesNumber, WHITE);
// Current shortest path found from Start to i
// is some large/INFinite number from the beginning.
vector<int> shortestPath(verticesNumber, INF);
// Index of the vertex/node that is predecessor
// of i-th vertex in a shortest path to it.
vector<int> previousVertex(verticesNumber, -1);
// We should use pointers here because we want
// to pass the pointer to a data-structure
// so it may receive all the updates automatically on every step.
auto ptrShortestPath = make_shared<vector<int>>(shortestPath);
shared_ptr<Graph> ptrGraph = make_shared<Graph>(graph);
...
次に、データ構造を初期化する必要があります。GitHub リポジトリで提供されているコードを使用すると、コードの必要な行のコメントを解除するだけで済みます。このコードはパラメーターに基づいてデータ構造を選択するようには設計されていません。より理解を深めるために積極的に試してほしいと思います (そうです、私はタフな男です:D)。
//
// TODO
// UNCOMMENT DATA STRUCTURE YOU WANT TO USE:
//dfsStack customQueue;
//bfsQueue customQueue;
//dijkstraPriorityQueue customQueue(ptrShortestPath);
//aStarQueue customQueue(finishX, finishY, ptrGraph, ptrShortestPath);
// END OF TODO
/
そして最後にアルゴリズム自体です。基本的に、これは 3 つのアルゴリズムすべてにいくつかの追加チェックを組み合わせたものです。「customQueue」を初期化し、空になるまでアルゴリズムを実行します。グラフ内の各隣接ノードを調べるとき、次に通過する必要がある可能性のある各ノードをキューに入れます。次に、メソッドを呼び出します getFirst()
。このメソッドは、アルゴリズムで次に通過する必要がある 1 つのノードをフェッチするだけです。
...
customQueue.insert(start);
nodeColor[start] = BLACK;
ptrShortestPath->at(start) = 0;
// Traverse nodes starting from start node.
while (!customQueue.isEmpty())
{
int current = customQueue.getFirst();
// If we found finish node, then let's print full path.
if (current == finish)
{
vector<int> path;
int cur = finish;
path.push_back(cur);
// Recover path node by node.
while (previousVertex[cur] != -1)
{
cur = previousVertex[cur];
path.push_back(cur);
}
// Since we are at the finish node, reverse list to be at start.
reverse(path.begin(), path.end());
return path;
}
for (int neighbourIndex = 0;
neighbourIndex < graph.Edges[current].size();
neighbourIndex++)
{
int to = graph.Edges[current][neighbourIndex].first;
int weight = graph.Edges[current][neighbourIndex].second;
if (nodeColor[to] == WHITE) // If node is not yet visited.
{
nodeColor[to] = GREY; // Mark node as "in progress".
customQueue.insert(to);
previousVertex[to] = current;
// Calculate cost of moving to this node.
ptrShortestPath->at(to) = ptrShortestPath->at(current) + weight;
}
else // Select the most optimal route.
{
if (ptrShortestPath->at(to) > ptrShortestPath->at(current) + weight)
{
ptrShortestPath->at(to) = ptrShortestPath->at(current) + weight;
}
}
}
nodeColor[current] = BLACK;
}
return {};
}
これまでのところ、実装は書籍やインターネットで見つかる他の例と大きな違いはありません。ただし、ここで重要な点があります。これは、getFirst()
ノードがトラバースされる正確な順序を決定するため、主な目的を果たすメソッドです。
幅優先検索キュー
キューのデータ構造の内部の仕組みを詳しく見てみましょう。BFS のキュー インターフェイスは最も単純なものです
#include <queue>
#include "pathFindingBase.h"
class bfsQueue : public pathFindingBase
{
private:
queue<int> _queue;
public:
virtual void insert(int node)
{
_queue.push(node);
}
virtual int getFirst()
{
int value = _queue.front();
_queue.pop();
return value;
}
virtual bool isEmpty()
{
return _queue.empty();
}
};
実際、ここでのカスタム キュー インターフェイスを、STL (標準テンプレート ライブラリ) が提供する標準 C++ キューに単純に置き換えることができます。ただし、ここでの目標は一般性です。ここで、メイン メソッド内の行のコメントを解除して、このアルゴリズムを実行するだけです。//bfsQueue customQueue; // UNCOMMENT TO USE BFS
その結果、BFS はパス 24<-19<-14<-9<-8<-7<-6<-1<-0 を見つけます。
(0, 0) - (0, 1) - (0, 2) - (0, 3) - (0, 4)
|
(1, 4)
|
(2, 4)
|
(3, 4)
|
(4, 4)
重みを考慮すると、このパスの最終コストは 11 になります。ただし、BFS も DFS も重みを考慮しないことに注意してください。代わりに、グラフ内のすべてのノードを反復処理して、遅かれ早かれ目的のノードが見つかることを期待します。
DFSキュー
DFS も見た目はあまり変わりません。STD キューをスタックに置き換えるだけです。
#include <stack>
#include "pathFindingBase.h"
class dfsStack : public pathFindingBase
{
private:
stack<int> _queue;
public:
virtual void insert(int node)
{
_queue.push(node);
}
virtual int getFirst()
{
int value = _queue.top();
_queue.pop();
return value;
}
virtual bool isEmpty()
{
return _queue.empty();
}
};
DFS は、コスト 15 のパス 24<-23<-22<-21<-20<-15<-10<-5<-0) を検索します (最適なコストの検索を優先しません)。興味深いことに、BFS と比較すると、そのトラバース方向が逆になっています。
(0, 0)
|
(1, 0)
|
(2, 0)
|
(3, 0)
|
(4, 0) - (4, 1) - (4, 2) - (4, 3) - (4, 4)
ディクストラの待ち行列
さて、ダイクストラのアルゴリズムは、グラフにおける貪欲探索アルゴリズムとして最も有名です。既知の制限 (ネガティブ パス、ループなどを処理できない) にもかかわらず、依然として人気があり、十分に効率的です。
getFirst()
この実装のメソッドでは、貪欲なメソッドを使用して、トラバースするノードを選択することに注意してください。
#include <queue>
#include "pathFindingBase.h"
class dijkstraQueue : public pathFindingBase
{
private:
vector<int> _queue;
shared_ptr<vector<int>> _shortestPaths;
public:
dijkstraQueue(shared_ptr<vector<int>> shortestPaths) : _shortestPaths(shortestPaths) { }
virtual void insert(int node)
{
_queue.push_back(node);
}
virtual int getFirst()
{
int minimum = INF;
int minimumNode = -1;
for (int i = 0; i < _queue.size(); i++)
{
int to = _queue[i];
int newDistance = _shortestPaths->at(to);
if (minimum > newDistance) // Greedy selection: select node with minimum distance on every step
{
minimum = newDistance;
minimumNode = to;
}
}
if (minimumNode != -1)
{
remove(_queue.begin(), _queue.end(), minimumNode);
}
return minimumNode;
}
virtual bool isEmpty()
{
return _queue.empty();
}
};
ダイクストラのアルゴリズムは、コスト 10 で最短かつ最適なパス 24<-19<-18<-13<-12<-7<-6<-1<-0) を見つけます。
(0, 0) -1- (0, 1)
|
1
|
(1, 1) -1- (1, 2)
|
1
|
(2, 2) -1- (2, 3)
|
1
|
(3, 3) -1- (3, 4)
|
1
|
(4, 4)
星
A-Star アルゴリズムは、地図などの座標を含むユークリッド空間内のパスを見つけるのに特に適しています。そのため、ゲームで広く使用されています。最小重みに基づく「ブラインド」貪欲検索を利用するだけでなく、ターゲットのユークリッド距離も考慮します。したがって、実際のシナリオでは、通常、ダイクストラのアルゴリズムよりもはるかに効率的です。
class aStarQueue : public pathFindingBase
{
private:
vector<int> _queue;
shared_ptr<vector<int>> _shortestPaths;
shared_ptr<Graph> _graph;
int _finishX;
int _finishY;
/// <summary>
/// Euclidian distance from node start to specified node id.
/// </summary>
int calcEuristic(int id)
{
return sqrt(
pow(abs(
_finishX > _graph->Nodes[id].X ?
_finishX - _graph->Nodes[id].X :
_graph->Nodes[id].X - _finishX), 2) +
pow(abs(
_finishY > _graph->Nodes[id].Y ?
_finishY - _graph->Nodes[id].Y :
_graph->Nodes[id].Y - _finishY), 2));
}
public:
aStarQueue(int finishX, int finishY, shared_ptr<Graph> graph, shared_ptr<vector<int>> shortestPaths)
:
_shortestPaths(shortestPaths),
_graph(graph)
{
_finishX = finishX;
_finishY = finishY;
}
virtual void insert(int node)
{
_queue.push_back(node);
}
virtual int getFirst()
{
int minimum = INF;
int minimumNode = -1;
for (int i = 0; i < _queue.size(); i++)
{
int to = _queue[i];
int newDistance = _shortestPaths->at(to);
int euristic = calcEuristic(to);
if (minimum > newDistance + euristic)
{
minimum = newDistance + euristic;
minimumNode = to;
}
}
if (minimumNode != -1)
{
_queue.erase(remove(_queue.begin(), _queue.end(), minimumNode), _queue.end());
}
return minimumNode;
}
virtual bool isEmpty()
{
return _queue.empty();
}
};
結果として、ダイクストラのアルゴリズムが最適なルートを提供するため、ダイクストラのアルゴリズムと同じ結果が得られます。
欠点がある
ただし、ダイクストラのアルゴリズムと A-Star のアルゴリズムには問題があります...
上記の実装では、共通のデータ構造でベクトル (動的配列[]) を使用しています。呼び出されるたびにgetFirst()
、ベクトル内で目的のノードを見つけるのに O(N) 時間がかかります。したがって、メイン アルゴリズムにも O(N*M) 時間がかかると仮定すると (M は近傍アルゴリズムの平均数)、全体の複雑さはほぼ 3 次になる可能性があります。これにより、大きなグラフではパフォーマンスが大幅に低下します。
この例は、4 つのアルゴリズムすべてが基本的に異なっていないことを理解するのに役立ちますが、問題は詳細です。共通のデータ構造を使用して 4 つのアルゴリズムすべてを効率的に実装することは困難です。
最高のパフォーマンス (通常、99% の場合、これが主な関心事です) を得るには、最適化により重点を置く必要があります。たとえば、ダイクストラおよび A-Star のアルゴリズムの場合、配列の代わりに優先キューを使用することは非常に意味があります。
A-Star アルゴリズムの最適化について言えば、最適化の奥深い世界を開くいくつかのリンクに言及するのは理にかなっています。A* Optimizations and Improvements (Lucho Suaya 著) および JPS+: 100x fast than A* (Steve Rabin 著)。
最後の文
この記事の目的は、すべての走査アルゴリズムが相互にどのように関連しているかを示すことです。ただし、この記事で使用されているグラフの例は、これらのアルゴリズム間のパフォーマンスの実際の違いを示すには明らかに単純すぎます。したがって、これらの例は、生産目的ではなく、主に概念的な理解のために使用してください。