Puzzle DE Go! は、パズルを題材に Go 言語でプログラミングを楽しみましょう、というお気楽なページです。どのプログラミング言語でもそうですが、上達の秘訣は実際にプログラムを作って、その動作を確認してみることです。ところが、いざとなると「さて、何を作ろうか?」と困ってしまう方も多いのではないでしょうか。
このようなときにぴったりな題材が「パズルの解法」です。なんといっても、実際にパズルが解けたときの喜びはとても大きく、プログラムを作る意欲をかきたててくれます。そして、解を求めるだけではなく、実行時間を短縮するためにプログラムを改良するのです。これがパズルプログラミングの醍醐味といってもいいでしょう。
簡単なパズルは力任せでも解くことができますが、少し複雑なパズルになると力任せでは時間がかかってしまいます。ところが、パズルの性質や特徴を見極めてプログラムを作ると、実行時間を劇的に短縮できる場合があります。プログラミングに興味をお持ちの方は、ぜひパズルの解法にも挑戦してみてください。
今回は「経路の探索」を例題に、パズルを解くのに必要となる基本的なデータ構造とアルゴリズムについて説明します。
まず最初に「グラフ (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 がセットされます。これを Go 言語でプログラムすると、次のようになります。
リスト : 隣接行列 var adjacent = [][]int{ {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 次元配列 (Go 言語ではスライスのスライス) で表します。
隣接行列の欠点は、辺の数が少ない場合でも 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) 図 : 隣接リスト
これを Go 言語でプログラムすると次のようになります。
リスト : 隣接リスト const ( A = iota B C D E F G Size // 頂点の個数 ) var adjacent = [][]int{ {B, C}, // A {A, C, D}, // B {A, B, E}, // C {B, E, F}, // D {C, D, G}, // E {D}, // F {E}, // G }
隣接行列と同様に、頂点 A から G を数値 0 から 6 に対応させています。これを const で定義しています。このように、Go 言語ではスライスを使うと簡単に隣接リストを表すことができます。
ところで、隣接リストにも欠点があります。たとえば、E と G が接続しているか調べるには、データを順番に調べていくしか方法がありません。このため、接続の判定に時間がかかることがあるのです。まあ、頂点に接続されている辺の数が少なければ、処理速度が極端に遅くなることはないでしょう。
それでは簡単な例題として、地図上の A 地点から B 地点までの道順を求めるプログラムを作ってみましょう。「探索」にはいろいろな種類があります。パズルを解くプログラムも、あらゆる可能性の中から正解に行き着く手順を探すことですから、探索の一つと考えることができます。そして、探索でよく用いられる最も基本的な方法が「バックトラック (backtracking) 」です。もちろん、経路の探索もバックトラックで解くことができます。
今回は隣接リストを使って、A から G までの経路をバックトラックで求めます。バックトラックを再帰呼び出しで実現する場合、次の頂点へ進むことを再帰呼び出しに対応させるのがポイントです。たとえば、経路を探索する関数を search としましょう。search は引数として現在地点の頂点を受け取ることにします。最初は search(A) と呼び出します。そして、A から B へ進むには search(B) と呼び出します。これで B へ進むことができます。
それでは、A に戻るにはどうしたらいいのでしょう。search(B) は search(A) から呼び出されたので、search(B) の実行を終了すれば、呼び出し元である search(A) に戻ることができます。つまり、関数の実行を終了すれば、一つ手前の地点にバックトラックできるのです。このように再帰呼び出しを使うと、進むことと戻ることを関数呼び出しで簡単に実現することができます。
それでは具体的に説明しましょう。経路はスライスに頂点を格納して表すことにします。プログラムは次のようになります。
リスト : 深さ優先探索 func member(n int, xs []int) bool { for _, x := range xs { if n == x { return true } } return false } func dfs(goal int, path []int) { n := path[len(path) - 1] if n == goal { fmt.Println(path) } else { for _, x := range adjacent[n] { if !member(x, path) { dfs(goal, append(path, x)) } } } } func depthFirstSearch(start, goal int) { path := make([]int, 0, Size ) dfs(goal, append(path, start)) }
関数 depthFirstSearch は start から goal までの経路を求めます。実際の処理は関数 dfs で行います。引数 path が経路を表します。最初に、path の末尾から現在地点 n を取り出します。そして、n がゴール goal かチェックします。これが再帰呼び出しの停止条件になります。ゴールに到達したら Println で経路を表示します。ここで探索を終了することもできますが、バックトラックすることで全ての経路を見つけることができます。
ゴールに到達していない場合、adjacent から頂点 n の隣接リストを取り出します。そして、for ループでスライスの要素を順番に取り出して、dfs を再帰呼び出しします。このとき、経路に含まれている頂点を選んではいけません。そうしないと、同じ道をぐるぐると回る巡回経路が発生し、ゴールまでたどり着くことができなくなります。このチェックを関数 member で行います。経路 path の中に頂点 x がないことを確認してから、append で path に x を追加して dfs を再帰呼び出しします。
実行結果は次のようになります。
リスト : 深さ優先探索 (dfs.go) func main() { depthFirstSearch(A, G) }
C>go run dfs.go [0 1 2 4 6] [0 1 3 4 6] [0 2 1 3 4 6] [0 2 4 6]
4 通りの経路を見つけることができました。バックトラックによる探索は、経路を先へ先へ進めるので、「縦形探索」とか「深さ優先探索」と呼ばれています。このため、結果を見てもわかるように、最初に見つかる経路が最短経路とは限りません。最短経路を求めるのに適したアルゴリズムが「幅優先探索」です。
バックトラックによる探索は「深さ優先探索」や「縦形探索」とも呼ばれるように、一つの経路を先へ先へと進めていきます。このため最初に見つかる経路が最短経路であるとは限りません。幅優先探索は全ての経路について平行に探索を進めていくため、最初に見つかる経路が最短経路となります。それでは、同じ経路図を使って幅優先探索を具体的に説明しましょう。
幅優先探索の様子を下図に示します。
[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) の性質を持つデータ構造です。距離の短い経路から順番に処理されるため、幅優先探索として機能するわけです。
プログラムは次のようになります。
リスト : 幅優先探索 // 新しい経路を作る func makePath(path []int, x int) []int { k := len(path) newPath := make([]int, k + 1) copy(newPath, path) newPath[k] = x return newPath } // 幅優先探索 func breadthFirstSearch(start, goal int) { que := make([][]int, 0) front := 0 que = append(que, []int{start}) for ; front < len(que); front++ { path := que[front] n := path[len(path) - 1] if n == goal { fmt.Println(path) } else { for _, x := range adjacent[n] { if !member(x, path) { que = append(que, makePath(path, x)) } } } } }
キューはスライスを使って実装します。先頭データの位置を front とします。キューからデータを取り出すときは front の位置のデータを取り出して front の値を +1 します。キューにデータを追加するときはスライスの末尾に追加します。これでキューの動作を実現することができます。配列を使ってキューを実装する場合、配列をリングバッファとして使うのが一般的です。詳細は Appendix:配列によるキューの実装 をお読みください。
最初に make でキューの本体 (スライス) を生成し、変数 que にセットします。次に、変数 front を 0 に初期化して、que の末尾にスタート地点の経路 [ ]int{start} を追加します。あとは、キューに経路がある間、for ループで探索を行います。キューから経路を取り出して path にセットします。そして、path の末尾から現在位置を求め、それを変数 n にセットします。n が goal と等しければ Println で経路を表示します。
そうでなければ、経路を一つ進めます。この処理は深さ優先探索とほぼ同じですが、新しい経路を関数 makePath で生成し、それをキューに追加していくところが異なります。makePath は新しいスライスを生成することに注意してください。これで全ての経路を求めることができます。
それでは、実際に実行してみましょう。
リスト : 幅優先探索 (bfs.go) func main() { breadthFirstSearch(A, G) }
C>go run bfs.go [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手ずつ増やしてその関数を呼び出せばいいのです。プログラムは次のようになります。
リスト : 反復深化 func ids(goal, limit int, path []int) { n := path[len(path) - 1] if len(path) == limit { if n == goal { fmt.Println(path) } } else { for _, x := range adjacent[n] { if !member(x, path) { ids(goal, limit, append(path, x)) } } } } func idSearch(start, goal int) { path := make([]int, 1, Size) path[0] = start for i := 1; i <= Size; i++ { fmt.Println("----", i, "----") ids(goal, i, path) } }
関数 ids の引数 limit が上限値を表します。経路の長さが上限値 limit に達したら探索を打ち切ります。このとき、ゴールに到達したかチェックします。あとは、limit の値を増やしながら ids を呼び出せばいいわけです。それでは実行結果を示しましょう。
リスト : 幅優先探索 (ids.go) func main() { idSearch(A, G) }
C>go run ids.go ---- 1 ---- ---- 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] ---- 7 ----
結果を見ればおわかりのように、最初に見つかる解が最短手数になります。このプログラムでは全ての経路を求めましたが、最短手数を求めるだけでよい場合は、解が見つかった時点で探索を終了すればいいでしょう。
配列を使ってキューを実装する場合、先頭位置を示す front と末尾を示す rear を用意し、front と rear の間にあるデータをキューに格納されているデータとするのがポイントです。次の図を見てください。
0 1 2 3 4 5 6 7 8 9 rear = 0 ↓ queue [ ] : queue は空 front= 0 ↑ rear = 3 ↓ queue [10 20 30 ] : データの追加 front= 0 ↑ rear = 3 ↓ queue [10 20 30 ] : 10を取り出す front= 1 ↑ rear = 3 ↓ queue [10 20 30 ] : 20,30を取り出す front= 3 ↑ 図 : キューの動作
最初、キューは空の状態で、rear, front ともに 0 です。データの追加は、rear が示す位置にデータを書き込み、rear の値を +1 します。データ 10, 20, 30 を追加すると、図のようにデータが追加され rear は 3 になります。このとき front は 0 のままなので、先頭のデータは 10 ということになります。
次に、データを取り出す場合、front の示すデータを取り出しから front の値を +1 します。この場合、front が 0 なので 10 を取り出して front の値は 1 となり、次のデータ 20 が先頭になります。データを順番に 20, 30 と取り出していくと、3 つしかデータを書き込んでいないので当然キューは空になります。このとき front は 3 になり rear と同じ値になります。このように、front と rear の値が 0 の場合だけが空の状態ではなく、front と rear の値が等しくなると、キューは空になることに注意してください。
rear, fornt ともに値は増加していく方向なので、いつかは配列の範囲をオーバーします。このため、配列を先頭と末尾が繋がっているリング状と考え、rear, front が配列の範囲を超えたら 0 に戻すことにします。これを「循環配列」とか「リングバッファ」と呼びます。一般に、配列を使ってキューを実現する場合は、リングバッファとするのが普通です。
それでは実際にプログラムを作ってみましょう。今回はプログラムを簡単にするため、格納するデータの型を int とします。最初に、基本的な操作関数 (メソッド) を説明します。
キューは構造体を使って定義します。次のリストを見てください。
リスト : データ型の定義 type Queue struct { front, rear, count int buff []int } // キューの生成 func newQueue(size int) *Queue { q := new(Queue) q.buff = make([]int, size) return q }
型名は Queue としました。count はキューに格納されたデータ数をカウントします。この変数を用意することで、キューの状態を簡単にチェックすることができます。buff がキューの本体 (スライス) です。
関数 newQueue はキューを生成します。引数 size がキューの大きさです。組み込み関数 new で Queue のメモリを確保し、make で大きさ size のスライスを生成して q.buff にセットします。
次はデータを追加するメソッド enqueue を作ります。
リスト : キューにデータを追加する func (q *Queue) enqueue(x int) bool { if q.count == len(q.buff) { return false } else { q.buff[q.rear] = x q.rear++ q.count++ if q.rear >= len(q.buff) { q.rear = 0 } return true } }
最初に q.count と len(q.buff) を比較して、キューが満杯かチェックします。そうであれば、return で false を返します。データは q.rear の位置に格納し、q.count と q.rear の値を更新します。そして、q.rear の値がスライスの範囲を超えたならば 0 に戻します。
次は、キューからデータを取り出すメソッド dequeue を作ります。
リスト : キューからデータを取り出す func (q *Queue) dequeue() (int, bool) { if q.count == 0 { return 0, false } else { x := q.buff[q.front] q.front++ q.count-- if q.front >= len(q.buff) { q.front = 0 } return x, true } }
まず、キューにデータがあるか q.count の値をチェックします。キューが空の場合は return で 0 と false を返します。データがある場合は q.front の位置にあるデータを取り出して変数 x にセットします。あとは、q.count と q.front の値を更新し、q.front の値がスライスの範囲を超えたら 0 に戻します。
あとの操作関数は簡単なので説明は省略します。リストをお読みくださいませ。
リスト : キューの操作関数 func (q *Queue) clear() { q.front, q.rear, q.count = 0, 0, 0 } func (q *Queue) length() int { return q.count } func (q *Queue) isEmpty() bool { return q.count == 0 } func (q *Queue) isFull() bool { return q.count != 0 }
これでプログラムは完成です。それでは、実際に実行してみましょう。
リスト ; 簡単な使用例 func main() { que := newQueue(8) fmt.Println(que.isEmpty()) for i := 0; i < 8; i++ { que.enqueue(i) fmt.Println(que.length()) } fmt.Println(que.isFull()) for !que.isEmpty() { fmt.Println(que.dequeue()) } }
C>go run queue.go true 1 2 3 4 5 6 7 8 true 0 true 1 true 2 true 3 true 4 true 5 true 6 true 7 true
newQueue でキューを作成して変数 que にセットします。for ループでキューにデータを 8 個セットします。これでキューは満杯になるので、これ以上データを追加することはできません。次に、dequeue でデータを取り出します。先に入れたデータから順番に取り出されていることがわかりますね。これでキューは空の状態になります。