今回は、地図上の A 地点から B 地点までの道順を求める、といった「経路の探索」と呼ばれる問題を取り上げます。「探索」にはいろいろな種類があります。たとえば、パズルを解く場合、あらゆる可能性の中から正解に行き着く手順を探すことですから、探索の一つと考えることができます。そして、探索でよく用いられる最も基本的な方法に「バックトラック (backtracking)」があります。
簡単な例として迷路を考えてみましょう。ある地点 A で道が左右に分かれているとします。ここで、左の道を選んで先へ進むと、行き止まりになってしまいました。この場合は A 地点まで戻って右の道へ進まないといけませんね。つまり、失敗したら後戻りして別の道を選ぶ、という試行錯誤をゴールに行き着くまで繰り返すわけです。これがバックトラックの基本的な考え方です。
バックトラックは迷路を解くだけではなく、いろいろな問題に応用できる方法です。とくに、すべての解を求める場合、バックトラックが適しています。すべての解をもれなく見つけることができます。もちろん、経路の探索もバックトラックで解くことができます。
このほかに、もう一つ基本的な方法として「幅優先探索」があります。バックトラックの場合、失敗したら後戻りして別の道を選び直しますが、幅優先探索の場合は、全ての経路について並行に探索を進めていきます。幅優先探索は最短手順を求めるのに適したアルゴリズムですが、問題によっては必要となるメモリの量がとても多くなり、幅優先探索を使用することができない場合があります。このような場合、「反復深化」という方法を使うと、多少時間はかかりますが、少ないメモリで最短手順を求めることができます。今回はこの 3 つの方法で経路を求めてみましょう。
まず最初に「グラフ (graph)」というデータ構造を説明します。一般にグラフというと、 円グラフや折れ線グラフといった図表を思い出す人が多いと思います。数学の「グラフ理論」では、いくつかの点とそれを結ぶ線でできた図形を「グラフ」といいます。次の図を見てください。
頂点 辺 ↓ ↓ ●─────────● │ │ │ │ │ │ ●─────────● 図 : グラフの例
上図に示すように、グラフは点とそれを接続する線から構成されています。点のことを「頂点 (vertex)」や「節点 (node)」と呼び、線のことを「辺 (edge)」や「弧 (arc)」と呼びます。グラフには 2 種類あって、辺に向きの無いグラフを「無向グラフ」といい、辺に向きがあるグラフを「有向グラフ」といいます。有向グラフは一方通行の道と考えればいいでしょう。 次の図を見てください。
(1) A──────────→B 有向グラフ (2) A←─────────→B 無向グラフ 図 : 有向グラフと無向グラフ
たとえば、図 (1) では A 地点から B 地点へ行くことができますが、一方通行のため B 地点から A 地点に戻ることはできません。これが有効グラフです。(2) の無効グラフでは、A 地点から B 地点へ行くことができるし、逆に B 地点から A 地点に戻ることもできます。
データ間のさまざまな関係を表す場合、グラフはとても役に立ちます。たとえば、下図のように経路をグラフで表すことができます。
B───D───F /│ │ A │ │ \│ │ C───E───G 図 : 経路図
上図ではアルファベットで頂点を表しています。この例では経路をグラフで表していますが、このほかにもいろいろな問題をグラフで表現することができます。
グラフをプログラムする場合、よく使われる方法に「隣接行列」と「隣接リスト」があります。隣接行列は 2 次元配列で頂点の連結を表す方法です。頂点が N 個ある場合、隣接行列は N 行 N 列の行列で表すことができます。経路図を隣接行列で表すと次のようになります。
│A B C D E F G ─┼─────── A│0 1 1 0 0 0 0 B│1 0 1 1 0 0 0 C│1 1 0 0 1 0 0 D│0 1 0 0 1 1 0 E│0 0 1 1 0 0 1 F│0 0 0 1 0 0 0 G│0 0 0 0 1 0 0 図 : 隣接行列
A に接続している頂点は B と C なので、A 行の B と C に 1 をセットし、接続していない頂点には 0 をセットします。経路が一方通行ではない無向グラフの場合は、A 列の B と C にも 1 がセットされます。これをC++でプログラムすると、次のようになります。
リスト : 隣接行列 const int N = 7; int adjacent[N][N] = { {0, 1, 1, 0, 0, 0, 0}, // A {1, 0, 1, 1, 0, 0, 0}, // B {1, 1, 0, 0, 1, 0, 0}, // C {0, 1, 0, 0, 1, 1, 0}, // D {0, 0, 1, 1, 0, 0, 1}, // E {0, 0, 0, 1, 0, 0, 0}, // F {0, 0, 0, 0, 1, 0, 0}, // G };
頂点 A から G を数値 0 から 6 に対応させるところがポイントです。隣接行列は 2 次元配列で表します。
隣接行列の欠点は、辺の数が少ない場合でも N 行 N 列の行列が必要になることです。つまり、ほとんどの要素が 0 になってしまい、メモリを浪費してしまうのです。この欠点を補う方法に隣接リストがあります。これはつながっている頂点を格納する方法です。
A => (B, C) B => (A, C, D) C => (A, B, E) D => (B, E, F) E => (C, D, G) F => (D) G => (E) 図 : 隣接リスト
これをC++でプログラムすると次のようになります。
リスト : 隣接リスト vector<vector<int>> adjacent = { {1, 2}, // 0 {0, 2, 3}, // 1 {0, 1, 4}, // 2 {1, 4, 5}, // 3 {2, 3, 6}, // 4 {3}, // 5 {4} // 6 };
隣接リストは vector を入れ子にすると簡単に定義することができます。最近の規格 (C++11) では、入れ子の vector でも { ... } で簡単に初期化することができます。
ところで、隣接リストにも欠点があります。たとえば、E (5) と G (6) が接続しているか調べるには、データを順番に調べていくしか方法がありません。このため、接続の判定に時間がかかることがあるのです。まあ、頂点に接続されている辺の数が少なければ、処理速度が極端に遅くなることはないでしょう。
それではプログラムを作りましょう。今回は隣接リストを使って、A から G までの経路をバックトラックで求めます。バックトラックを再帰呼び出しで実現する場合、次の頂点へ進むことを再帰呼び出しに対応させるのがポイントです。
たとえば、経路を探索する関数を search としましょう。search は引数として現在地点の頂点を受け取ることにします。最初は search(A) と呼び出します。そして、A から B へ進むには search(B) と呼び出します。これで B へ進むことができます。
それでは、A に戻るにはどうしたらいいのでしょう。search(B) は search(A) から呼び出されたので、search(B) の実行を終了すれば、呼び出し元である search(A) に戻ることができます。つまり、関数の実行を終了すれば、一つ手前の地点にバックトラックできるのです。このように再帰呼び出しを使うと、進むことと戻ることを関数呼び出しで簡単に実現することができます。
それでは具体的に説明しましょう。経路は vector<int> に頂点を格納して表すことにします。これを typedef で Path という別名を付けます。プログラムは次のようになります。
リスト : 深さ優先探索 // 経路 typedef vector<int> Path; // 経路の表示 void print_path(const Path& path) { for (int x : path) cout << x << " "; cout << endl; } // 深さ優先探索 void dfs(Path& path, int goal) { int p = path.back(); if (p == goal) { print_path(path); } else { for (int n : adjacent[p]) { auto iter = find(path.begin(), path.end(), n); if (iter == path.end()) { path.push_back(n); dfs(path, goal); path.pop_back(); } } } }
探索は関数 dfs で行います。引数 path が経路、引数 goal がゴールを表します。最初に、path の末尾から現在地点 p を取り出して、ゴールに到達したかチェックします。これが再帰呼び出しの停止条件になります。ゴールに到達したら print_path で経路を表示します。ここで探索を終了することもできますが、バックトラックすることで全ての経路を見つけることができます。
ゴールに到達していない場合、adjacent から頂点 p の隣接リストを取り出します。範囲 for 文で adjacent[p] の要素を順番に取り出して変数 n にセットし、dfs を再帰呼び出しします。
このとき、経路に含まれている頂点を選んではいけません。そうしないと、同じ道をぐるぐると回る巡回経路が発生し、ゴールまでたどり着くことができなくなります。このチェックを標準ライブラリ <algorithm> の関数 find で行います。返り値が終端 (path.end()) であれば、path の中に頂点 n はありません。push_back で path に n を追加します。それから、dfs を再帰呼び出しして、戻ってきたら pop_back で n を取り除きます。
最後に main から dfs を呼び出します。
リスト : 深さ優先探索 int main() { Path path = {0}; cout << "----- dfs -----\n"; dfs(path, 6); }
最初に、スタート地点 A (0) を path にセットします。あとは dfs(path, 6) と呼び出すだけです。
実行結果は次のようになります。
$ clang++ keiro.cpp $ ./a.out ----- dfs ----- 0 1 2 4 6 0 1 3 4 6 0 2 1 3 4 6 0 2 4 6
4 通りの経路を見つけることができました。このように、バックトラックによる探索は経路を先へ先へ進めるので、「縦形探索」とか「深さ優先探索 (depth first search)」と呼ばれています。このため、結果を見てもわかるように、最初に見つかる経路が最短経路とは限りません。最短経路を求めるのに適したアルゴリズムが「幅優先探索 (breadth first search)」です。
バックトラックによる探索は一つの経路を先へ先へと進めていきます。このため最初に見つかる経路が最短経路であるとは限りません。幅優先探索は全ての経路について平行に探索を進めていくため、最初に見つかる経路が最短経路となります。それでは、同じ経路図を使って幅優先探索を具体的に説明しましょう。
幅優先探索の様子を下図に示します。
[A] ─┬─ [A B] ─┬─ [A B C] ・・・・ │ └─ [A B D] ─┬─ [A B D F] 行き止まり │ └─ [A B D E] └─ [A C] ─┬─ [A C B] ・・・・ └─ [A C E] ─┬─ [A C E G] GOAL └─ [A C E D] (出発点) (2節点) (3節点) (4節点) 図 : 幅優先探索
まず、出発点 A から一つ進んだ経路 (2 節点) を全て求めます。この場合は、[A B] と [A C] の 2 つあり、これを全て記憶しておきます。次に、これらの経路から一つ進めた経路 (3 節点) を全て求めます。経路 [A B] は [A B C] と [A B D] へ進めることができますね。ほかの経路 [A C] も同様に進めて、全ての経路を記憶します。あとはこの作業をゴールに到達するまで繰り返せばいいのです。
上図では、4 節点の経路 [A C E G] でゴールに達していることがわかります。このように幅優先探索では、最初に見つかった経路が最短距離 (または最小手数) となるのです。この性質は、全ての経路を平行に進めていく探索順序から考えれば当然のことといえるでしょう。このことからバックトラックの縦形探索に対して、幅優先探索は「横形探索」と呼ばれます。このあとも探索を繰り返せば全ての経路を求めることができます。
完成までの最小手数を求めるパズルを解く場合、幅優先探索を使ってみるといいでしょう。ただし、探索を進めるにしたがって、記憶しておかなければならないデータの総数が爆発的に増加する、つまりメモリを大量消費することに注意してください。
今回の経路図の場合、メモリを大量消費することはありませんが、問題によってはマシンに搭載されているメモリが不足するため、幅優先探索を実行できない場合もあるでしょう。したがって、幅優先探索を使う場合は、メモリの消費量を抑える工夫も必要になります。
経路の管理はキューを使うと簡単です。幅優先探索でのキューの動作を下図に示します。
(1) ───── QUEUE ────── ┌── [A] │ ─────────────── │ └─→ キューからデータを取り出す (2) ───── QUEUE ────── ←─┐ ─────────────── │ │ [A] の経路を進め [A B] ───┤ キューに追加する [A C] ───┘ (3) ───── QUEUE ────── ┌── [A B] [A C] ←─┐ │ ─────────────── │ │ │ └─→ [A B] の経路を進めキューに追加 │ [A B C] [A B D] ────────┘ (4) ───── QUEUE ────── ┌── [A C] [A B C] [A B D] ←─┐ │ ─────────────── │ │ │ └─→ キューに経路がある間繰り返す ──┘ 図 : 幅優先探索とキューの動作
最初は、(1) のように出発点をキューにセットしておきます。次に、キューから経路を取り出し、(2) のように経路 [A] を一つ進めて、経路 [A B] [A C] を作り、それをキューに追加します。(3) では、経路 [A B] を取り出して、一つ進めた経路 [A B C] と [A B D] をキューに追加します。あとはキューに経路がある間、探索処理を繰り返します。
キューは先入れ先出し (FIFO) の性質を持つデータ構造です。距離の短い経路から順番に処理されるため、幅優先探索として機能するわけです。
それではプログラムを作りましょう。幅優先探索を行う関数 bfs は次のようになります。
リスト : 幅優先探索 // 新しい経路の作成 Path make_new_path(const Path& path, int x) { Path new_path = path; new_path.push_back(x); return new_path; } // 幅優先探索 void bfs(int start, int goal) { queue<Path> q; q.push(Path{start}); while (!q.empty()) { Path path = move(q.front()); q.pop(); int n = path.back(); if (n == goal) { print_path(path); } else { for (auto x : adjacent[n]) { auto iter = find(path.begin(), path.end(), x); if (iter == path.end()) q.push(make_new_path(path, x)); } } } }
引数 start がスタート地点、goal がゴール地点を表します。最初に経路を格納するキューを変数 q にセットします。最初は start だけを格納している経路を生成し、それを q.push でキューに追加します。今回はムーブセマンティクスで経路を管理することにします。C++11 に対応しているコンパイラでは、ここで所有権の移動が行われることに注意してください。あとはキューにデータがある間、探索処理を続けます。
while ループの中で、キューから経路を取り出して変数 path にセットします。このとき、関数 move を使ってムーブコンストラクタを呼び出します。次に、経路の末尾から頂点を取り出して変数 n にセットし、それがゴールに到達しているかチェックします。そうであれば print_path で経路を表示します。そうでなければ経路を一つ進めます。
隣接リスト adjacent から取り出した頂点 x が経路 p に含まれていなければ、path に x を追加した経路を make_new_path で生成し、それを q.push でキューに追加します。make_new_path は引数の経路 path をコピーコンストラクタでコピーして、新しい経路 new_path の末尾に引数 x を追加して返します。これですべての経路を求めることができます。
それでは、実際に実行してみましょう。
リスト : 幅優先探索 int main() { cout << "----- bfs -----\n"; bfs(0, 6); }
$ clang++ keiro.cpp $ ./a.out ----- bfs ----- 0 2 4 6 0 1 2 4 6 0 1 3 4 6 0 2 1 3 4 6
結果を見ればおわかりのように、最初に見つかる経路が最短で、最後に見つかる経路が最長となります。当然ですが、経路の総数は 4 通りとなります。
幅優先探索は最短手数を求めるのに適したアルゴリズムですが、生成する局面数が多くなると大量のメモリを必要とします。このため、メモリが不足するときは、幅優先探索を使うことができません。深さ優先探索の場合、メモリの消費量は少ないのですが、最初に見つかる解が最短手数とは限らないという問題点があります。
それでは、大量のメモリを使わずに最短手数を求める方法はないのでしょうか。実は、とても簡単な方法があるのです。それは、深さ優先探索の「深さ」に上限値を設定し、解が見つかるまで上限値を段階的に増やしていく、という方法です。
たとえば、1 手で解が見つからない場合は、2 手までを探索し、それでも見つからない場合は 3 手までを探索する、というように制限値を 1 手ずつ増やしていくわけです。このアルゴリズムを「反復深化 (iterative deeping)」といいます。
反復深化は最短手数を求めることができるアルゴリズムですが、幅優先探索と違って局面を保存する必要が無いため、必要となるメモリは深さ優先探索と同程度で済みます。また、プログラムも深さ優先探索と同じくらい簡単に作成することができます。ただし、同じ探索を何度も繰り返すため実行時間が増大するという欠点があります。ようするに、使用するメモリは少ないが実行時間が長くなるアルゴリズムなのです。
それでは、同じ経路図を使って反復深化を具体的に説明しましょう。反復深化のプログラムはとても簡単です。設定した上限値まで深さ優先探索を行う関数を作り、上限値を1手ずつ増やしてその関数を呼び出せばいいのです。プログラムは次のようになります。
リスト : 反復深化 void ids(Path& path, int goal, int limit) { int n = path.back(); if (path.size() == limit) { if (n == goal) print_path(path); } else { for (int x : adjacent[n]) { auto iter = find(path.begin(), path.end(), x); if (iter == path.end()) { path.push_back(x); ids(path, goal, limit); path.pop_back(); } } } } int main() { Path path = {0}; cout << "----- ids -----\n"; for (int limit = 2; limit < 7; limit++) { cout << "----- " << limit << " -----\n"; ids(path, 6, limit); } }
関数 ids の引数 limit が上限値を表します。経路の長さ path.size が上限値 limit に達したら探索を打ち切ります。このとき、ゴールに到達したかチェックします。あとは、limit の値を増やしながら ids を呼び出せばいいわけです。この処理を main で行っています。
それでは実行結果を示しましょう。
$ clang++ keiro.cpp $ ./a.out ----- ids ----- ----- 2 ----- ----- 3 ----- ----- 4 ----- 0 2 4 6 ----- 5 ----- 0 1 2 4 6 0 1 3 4 6 ----- 6 ----- 0 2 1 3 4 6
結果を見ればおわかりのように、最初に見つかる解が最短手数になります。このプログラムでは全ての経路を求めましたが、最短手数を求めるだけでよい場合は、解が見つかった時点で探索を終了すればいいでしょう。
/* * keiro.cpp : 経路の探索 * * Copyright (C) 2015-2023 Makoto Hiroi */ #include <iostream> #include <vector> #include <queue> #include <algorithm> using namespace std; // 経路 typedef vector<int> Path; // 隣接行列 vector<vector<int>> adjacent = { {1, 2}, // 0 {0, 2, 3}, // 1 {0, 1, 4}, // 2 {1, 4, 5}, // 3 {2, 3, 6}, // 4 {3}, // 5 {4} // 6 }; // 経路の表示 void print_path(const Path& path) { for (int x : path) cout << x << " "; cout << endl; } // 深さ優先探索 void dfs(Path& path, int goal) { int p = path.back(); if (p == goal) { print_path(path); } else { for (int n : adjacent[p]) { auto iter = find(path.begin(), path.end(), n); if (iter == path.end()) { path.push_back(n); dfs(path, goal); path.pop_back(); } } } } // 新しい経路の作成 Path make_new_path(const Path& path, int x) { Path new_path = path; new_path.push_back(x); return new_path; } // 幅優先探索 void bfs(int start, int goal) { queue<Path> q; q.push(Path{start}); while (!q.empty()) { Path path = move(q.front()); q.pop(); int n = path.back(); if (n == goal) { print_path(path); } else { for (auto x : adjacent[n]) { auto iter = find(path.begin(), path.end(), x); if (iter == path.end()) q.push(make_new_path(path, x)); } } } } // 反復深化 void ids(Path& path, int goal, int limit) { int n = path.back(); if (path.size() == limit) { if (n == goal) print_path(path); } else { for (int x : adjacent[n]) { auto iter = find(path.begin(), path.end(), x); if (iter == path.end()) { path.push_back(x); ids(path, goal, limit); path.pop_back(); } } } } int main() { Path path = {0}; cout << "----- dfs -----\n"; dfs(path, 6); cout << "----- bfs -----\n"; bfs(0, 6); cout << "----- ids -----\n"; for (int limit = 2; limit < 7; limit++) { cout << "----- " << limit << " -----\n"; ids(path, 6, limit); } }
$ clang++ keiro.cpp $ ./a.out ----- dfs ----- 0 1 2 4 6 0 1 3 4 6 0 2 1 3 4 6 0 2 4 6 ----- bfs ----- 0 2 4 6 0 1 2 4 6 0 1 3 4 6 0 2 1 3 4 6 ----- ids ----- ----- 2 ----- ----- 3 ----- ----- 4 ----- 0 2 4 6 ----- 5 ----- 0 1 2 4 6 0 1 3 4 6 ----- 6 ----- 0 2 1 3 4 6