今回の問題は、「ナイトの巡歴」です。将棋の桂馬は前方向2カ所にしか動けませんが、 チェスのナイトは前後左右の8カ所に動くことができます。下図を見てください。
| O | O | |||
| O | O | |||
| K | ||||
| O | O | |||
| O | O |
「ナイトの巡歴」は、ナイトを動かしてすべてのマス目を訪れる経路を求める問題です。 チェス本来の8x8盤面では、長い時間が掛かってしまいますので、まずは3x4での解法プログラムを紹介します。
use strict;
my %jump;
my $m = 4; my $n = 3; my $start = "11";
foreach (grep /^[1-$m][1-$n]$/, 11 .. ($m . $n)) {
my ($i, $j) = split //, $_;
@{$jump{$_}} = grep /^[1-$m][1-$n]$/, (($i - 2) . ($j - 1), ($i - 2) . ($j + 1),
($i - 1) . ($j - 2), ($i - 1) . ($j + 2),
($i + 1) . ($j - 2), ($i + 1) . ($j + 2),
($i + 2) . ($j - 1), ($i + 2) . ($j + 1));
}
my @keiro = ([$start]);
while (@keiro) {
my $ref = pop(@keiro);
my $c_p = $ref->[0];
my @before = gather($ref);
foreach my $n_p (@{$jump{$c_p}}) {
next if grep /$n_p/, @before;
if (@before == ($m * $n - 1)) {
print join(" => ", @before), " => $n_p\n";
} else {
push @keiro, [$n_p, $ref];
}
}
}
sub gather {
my $tmp_ref = shift;
my @work;
while ($tmp_ref) {
my $pos;
($pos, $tmp_ref) = @$tmp_ref;
unshift @work, $pos;
}
return @work;
}まず最初に、準備段階として、盤面のマス目毎に番号を付けます。 番号の付け方としては、1から順番に付ける方法もありますが、今回は左上を 11 として右下は 43 になるように付けます。その部分のプログラムを見てみましょう。
my $m = 4; my $n = 3; foreach (grep /^[1-$m][1-$n]$/, 11 .. ($m . $n))
変数 $m に行数、変数 $n に列数を指定します。そして、foreach の LIST 部で盤面を作成してしまいます。作成される盤面は、下図のようになります。
| 11 | 12 | 13 |
| 21 | 22 | 23 |
| 31 | 32 | 33 |
| 41 | 42 | 43 |
次に、foreach のループを使ってマス目毎に移動リストを作成します。 マス目が2桁の数字になっているので、計算によって移動リストを作成することができます。
foreach (grep /^[1-$m][1-$n]$/, 11 .. ($m . $n)) {
my ($i, $j) = split //, $_;
@{$jump{$_}} = grep /^[1-$m][1-$n]$/, (($i - 2) . ($j - 1), ($i - 2) . ($j + 1),
($i - 1) . ($j - 2), ($i - 1) . ($j + 2),
($i + 1) . ($j - 2), ($i + 1) . ($j + 2),
($i + 2) . ($j - 1), ($i + 2) . ($j + 1));
}
このコードを実行することによって、ハッシュ %jump が作成されます。これは、以下のコードを実行するのと等価になります (4x3の場合)。
%jump = ( 11 => [23, 32],
12 => [31, 33],
...
23 => [11, 31, 42],
31 => [12, 23, 43],
...
42 => [21, 23],
43 => [22, 31]);
これで、準備は完了です。ここでのポイントは、変数 $m と $n を指定することによって、9x9までの任意の盤面と移動リストを作成できることです。 このようにしておけば、盤面の大きさを変更してテストするのに非常に便利になります。
さていよいよ、経路の探索です。その前に、経路情報の管理方法について述べます。配列 @keiro は、最初に [$start] だけが入っていますが、その後は探索途中の末尾のデータが格納されます。 そのデータ構造は、[pos, ref] のようになっており、pos は現在のマス目の位置、ref はその前の無名配列へのリファレンスになっています。その前の無名配列は、位置とその前の前へのリファレンスを 持っています。このようにして、スタート位置からの経路を保持します (サブルーチン gather は、経路を収集する役割をする)。それでは、経路の探索のプログラムを見てみましょう。
while (@keiro) {
my $ref = pop(@keiro);
my $c_p = $ref->[0];
my @before = gather($ref);
foreach my $n_p (@{$jump{$c_p}}) {
next if grep /$n_p/, @before;
if (@before == ($m * $n - 1)) {
print join(" => ", @before), " => ", $n_p, "\n";
} else {
push @keiro, [$n_p, $ref];
}
}
}
今回のプログラムでは、while ループを使ってみました。まず、@keiro から pop を使って1つ取り出します。ここでの pop は、少し偽物の深さ優先探索 (幅併用型) になります。代わりに shift 使えば、(こちらは正真正銘の) 幅優先探索になります。 pop で取り出したのは、配列へのリファレンスなのでそれを保存し、そのリファレンスから変数 $c_p (現在位置) と @before (スタート位置からの経路情報) を作成します。 後は簡単で、%jump から次の移動位置を取得して、すでに訪れたマス目ならスキップし、 ゴールの場合は表示します。また、探索の途中の場合は、@keiro に保存して次のループに移ります。
プログラムを実行して、いろいろとテストしてみることにしましょう。 まずは、このプログラムを実行して得られる出力です。
11 => 32 => 13 => 21 => 42 => 23 => 31 => 43 => 22 => 41 => 33 => 12 11 => 32 => 13 => 21 => 42 => 23 => 31 => 12 => 33 => 41 => 22 => 43
次に、盤面の大きさを変えながらテストしてみます。 求めるのは、経路数と掛かる時間です。なお時間の計測は、次のコードを使いました。
my $sw = time(); # プログラムの最初の方に置く ... END { print time() - $sw, " seconds\n"; } # プログラムの最後に置く
END ブロックを使うと、1つの経路を探索した時点で exit でプログラムを終了したとしても実行されます。全経路探索でも、1つの経路探索でも共通して使えます。 以下のデータは、私のパソコンで実行した結果です。
| while版 | | | 再帰版 | |||
|---|---|---|---|---|---|
| 経路数 | 全経路 | 1つ | | | 1つ | |
| 4 x 4 | 0 | 1 | - | | | - |
| 5 x 4 | 32 | 5 | 0 | | | 0 |
| 5 x 5 | 304 | 308 | 9 | | | 1 |
| 6 x 5 | 4542 | 20630 | 90 | | | 7 |
| 6 x 6 | - | - | 27 | | | 46 |
| 7 x 6 | - | - | 2 | | | 2729 |
| 7 x 7 | - | - | 32224 | | | 52 |
| 8 x 7 | - | - | 113 | | | 59 |
| 8 x 8 | - | - | give up | | | 8830 |
$m と $n を1ずつ交互に増やして、経路数と、全経路探索に掛かった時間と、 1つの経路探索に掛かった時間を調べてみました。4x4の盤面では、正解はありません。 全経路探索に掛かる時間は、5x5では5分ほどですが、6x5では5時間40分ほどになってしまいます。 これほどの割合で、時間が増えるとは思ってもいませんでした。この割合で時間が増えるとすると、6x6では ..... 。という訳で、6x6以上の盤面での全経路探索は諦めました。
1つだけ解を求める探索の方は、頑張って (別に自分でやったのではないが) チェス本来の8x8の大きさまでやってみました。事前に多少の当たり外れがあることは予想していましたが、 これほど極端に現れるとは驚きです。7x6の盤面ではわずか2秒しか掛かりませんが、 7x7の盤面では9時間弱も掛かってしまいます。それでいて、8x7の盤面では2分弱です。 8x8の盤面では、48時間経過しても終了せず、降参しました。ここでプログラムを以下のように少し変更して、 1つの経路探索の結果を見てみます。変更するのは、while の部分です。
search ([$start]);
sub search {
my $ref = shift;
my $c_p = $ref->[0];
my @before = gather($ref);
foreach my $n_p (@{$jump{$c_p}}) {
next if grep /$n_p/, @before;
if (@before == ($m * $n - 1)) {
print join(" => ", @before), " => $n_p\n";
exit;
} else {
search([$n_p, $ref]);
}
}
}
元のプログラムでは %jump のリストは末尾から探索されますが、 こちら再帰版のプログラムでは先頭から探索されます。また、再帰版は純粋な深さ優先探索のため、 効率の面でも優ります (ただし、全経路探索の場合は、変わりません)。 先ほどの表の最後に、再帰版の経路探索に掛かった時間を追加しておきます。 今度は8x8の盤面まで探索することができましたが、 相変わらずのランダムな結果です。結論として、1つの深さ優先探索結果だけでは、 プログラムの実行速度を論ずることができない、ということでしょうか。
最後に8x8の盤面で、短時間に探索できるプログラムを紹介しましょう。 前の表では、"降参" と "147分" でしたが、次のプログラムでは果たして ..... 。 プログラムの解説はしませんので、考えてみてください。でも、インチキだと言わないで下さいね。
use strict;
my @areas = ([ grep /^[1-4][1-3]$/, 11 .. 43 ], [ grep /^[5-8][1-3]$/, 51 .. 83 ],
[ grep /^[1-4][4-8]$/, 14 .. 48 ], [ grep /^[5-8][4-8]$/, 54 .. 88 ]);
my @patt = ("[1-4][1-3]", "[5-8][1-3]", "[1-4][4-8]", "[5-8][4-8]");
search(0, 12, 'dummy', '11');
sub search {
my ($no, $elms, $jump, $before) = @_;
my $c_p = substr($before, -2);
if ($jump eq 'dummy') {
my %jump;
foreach ($c_p, @{$areas[$no]}) {
my ($i, $j) = split //, $_;
@{$jump{$_}} = grep /^$patt[$no]$/, (($i - 2) . ($j - 1), ($i - 2) . ($j + 1),
($i - 1) . ($j - 2), ($i - 1) . ($j + 2),
($i + 1) . ($j - 2), ($i + 1) . ($j + 2),
($i + 2) . ($j - 1), ($i + 2) . ($j + 1));
}
$jump = \%jump;
}
foreach my $n_p (@{$jump->{$c_p}}) {
next if $before =~ /\b$n_p\b/;
if ($before =~ tr/:// != ($elms - 2)) {
search($no, $elms, $jump, $before . ':'. $n_p);
} else {
if ($before =~ tr/:// == 62) {
my $count = 0;
foreach my $pos (split(/:/, $before), $n_p) {
$count++;
if ($count % 10 && $count != 64) { print "$pos => "; }
elsif ($count % 10 && $count == 64) { print "$pos\n"; }
else { print "$pos =>\n"; }
}
exit;
} else {
search($no + 1, $elms + @{$areas[$no + 1]}, 'dummy', $before . ':' . $n_p);
}
}
}
}(2005/05/10)