フリップ・イット
今回取り上げるパズルは、フリップ・イットです。
オリジナルとは少し異なっていますが、ルールは次の通りです。
- 空き升目に移動できる石は、他の石を飛び越こすことができるものだけです。
空き升目の隣の石は、移動することができません。
- 他の石に飛び越された石は、裏返しになります。飛び越した石は、そのままです。
- 最初の配置と同じ配置で、すべての石を最短手数で裏返しにします。
3つのスタート図は、JavaScript が有効で version 1.1 以降あれば、
実際に動かして遊べるようになっています。
(空き升目とそのとなりの升目を除く) 升目をマウスでクリックすると、石の升目は空き升目に移動し、
中間の升目は反転します。パズルの右に、次節の解法プログラムで求めた最短手数を表示しています。
解法プログラムを見る前に挑戦してみてください。
プログラム
use strict;
my $n = 5;
my (%head, %tail, @result);
foreach (0 .. ($n % 2 ? int($n / 2) : $n / 2 - 1)) {
my $start = "B" x $n;
substr($start, $_, 1, "_");
(my $goal = $start) =~ s/B/W/g;
my @board = ([$start, $n]);
%head = ($start => 0);
%tail = ($goal => 0);
OUT: while (@board) {
my ($board, $p_pos) = @{shift(@board)};
(my $r_board = $board) =~ tr/BW/WB/;
my $c_pos = index($board, '_');
my @n_pos = grep { $_ < ($c_pos - 1) or $_ > ($c_pos + 1) } 0 .. ($n - 1);
foreach my $n_pos (@n_pos) {
next if $p_pos == $n_pos;
my $temp = $board;
my $i = $c_pos < $n_pos ? $c_pos + 1 : $n_pos + 1;
my $len = abs($c_pos - $n_pos) - 1;
my $rev = substr($temp, $i, $len);
$rev =~ y/BW/WB/;
substr($temp, $i, $len, $rev);
substr($temp, $c_pos, 1) = substr($temp, $n_pos, 1, "_");
if (grep /$temp/, keys %tail) {
push @result, [gather($board), gather($temp)];
last OUT;
} else {
next if grep /$temp/, keys %head;
$head{$temp} = $board;
push @board, [$temp, $c_pos];
$temp =~ tr/BW/WB/;
if (grep /$temp/, keys %head) {
push @result, [gather($temp), gather($r_board)];
last OUT;
} else {
$tail{$temp} = $r_board;
}
}
}
}
}
sub gather {
my $board = shift; my @work;
if (grep /$board/, keys %head) {
while ($board) {
unshift @work, $board;
$board = $head{$board};
}
} else {
while ($board) {
push @work, $board;
$board = $tail{$board};
}
}
return @work;
}
my $max = 0;
foreach (@result) {
$max = @$_ if $max < @$_;
}
foreach my $i (0 .. ($max - 1)) {
my $line = "";
foreach my $j (0 .. $#result) {
if ($i < @{$result[$j]}) {
$line .= " ${$result[$j]}[$i]";
$line =~ s/ // if $j == 0;
} else {
$line = $line . " " . " " x $n;
$line =~ s/ // if $j == 0;
}
}
print "$line\n";
}
プログラムの解説
今回のパズルでは、盤面を単純な文字列で扱います。黒石を 'B' で、白石を 'W'
で、空き升目を '_' で表します。盤面は、'_BBBB' や 'BW_BB' のような文字列となります。
プログラムの最初で指定している $n は、盤面の大きさです。石の数は、$n よりも1つ少なくなります。
正解があるのは、盤面の大きさが5、石の数が4以上です。
今回のパズルは、ゴールの盤面が決まっています。通常はスタートの盤面からパズルを解きますが、
ゴールの盤面が決まっている場合は、ゴールの盤面からスタートの盤面を目指しても同様に解くこともできます。
しかし、単にゴールから探索して解を得たとしても、スタートから探索するのと同じで意味がありません。
双方向から探索して、途中で繋ぎ合わせることができたらどうでしょうか。
まずは、スタートとゴールの盤面から2手目までを進めた盤面を見てみましょう。
スタート図 | | スタートから1手 |
| スタートから2手 |
|
---> |
|
---> |
|
| | | | |
| ---> |
|
---> |
|
| | | | |
| ---> |
|
---> |
|
| | | | |
| | | ---> |
|
ゴール図 | | ゴールから1手 |
| ゴールから2手 |
|
---> |
|
---> |
|
| | | | |
| ---> |
|
---> |
|
| | | | |
| ---> |
|
---> |
|
| | | | |
| | | ---> |
|
スタート図から1手進めると、3つの盤面を生成することができます。
1手目の盤面からは、それぞれ1つから2つの2手目の盤面を生成することができます。
ゴールからの盤面の生成も同様です。ゴールから生成した盤面をよく見てみると、
スタート図から生成した盤面の ● と ○ を入れ替えたものと同じであることがわかります。
すなわち、スタート図から得られた盤面の ● と ○ を入れ替えれば、
ゴール図からの盤面を生成したことになります。
今回のパズルでは、盤面データの管理は単純なハッシュを利用しています。
スター図からの盤面は %head に、ゴール図からの盤面は %tail に格納します。
ハッシュのキーには生成した盤面を、値には生成元の盤面を格納しておきます。%tail
への登録は、単に %head で生成したものを B と W を入れ替えるだけで済みます。
2手進めたときのハッシュは、次のようになります。
%head = ( '_BBBB' => undef, %tail = ( '_WWWW' => undef,
'BW_BB' => '_BBBB', 'WB_WW' => '_WWWW',
'BWW_B' => '_BBBB', 'WBB_W' => '_WWWW',
'BWWW_' => '_BBBB', 'WBBB_' => '_WWWW',
'BWBW_' => 'BW_BB', 'WBWB_' => 'WB_WW',
'B_BWB' => 'BWW_B', 'W_WBW' => 'WBB_W',
'BW_BW' => 'BWWW_', 'WB_WB' => 'WBBB_',
'B_BBW' => 'BWWW_', 'W_WWB' => 'WBBB_',
); );
2つのハッシュでは、生成した盤面から最初の盤面までたどるのほ容易です。
%head ハッシュから新しい盤面を生成したら、まず %tail ハッシュのキーを調べます。生成した盤面が %tail
ハッシュのキーにある場合は、2つのハッシュの経路を繋ぎ合わせれば、正解を得ることができます。%tail
キーに生成した盤面がない場合は、%head ハッシュのキーを調べます。
生成した盤面がすでにハッシュのキーに登録してあるときには、
短い手数かまたは同じ手数で生成済みなので登録をスキップします。%tail
への登録は、単に B と W を入れ替えるだけです。
ここで、現在の盤面から次の盤面を生成する仕組みを説明しましょう。
以下は、その部分のコードです。
my ($board, $p_pos) = @{shift(@board)}; # $p_pos -- 前の空き升目の位置
(my $r_board = $board) =~ tr/BW/WB/;
my $c_pos = index($board, '_'); # $c_pos -- 現在の空き升目の位置
my @n_pos = grep { $_ < ($c_pos - 1) or $_ > ($c_pos + 1) } 0 .. ($n - 1);
foreach my $n_pos (@n_pos) { # $n_pos -- 次の空き升目の位置
next if $p_pos == $n_pos;
my $temp = $board;
my $i = $c_pos < $n_pos ? $c_pos + 1 : $n_pos + 1; # 反転する石の開始位置
my $len = abs($c_pos - $n_pos) - 1; # 反転する石の数
my $rev = substr($temp, $i, $len); # 反転部を取り出す
$rev =~ y/BW/WB/; # 反転
substr($temp, $i, $len, $rev); # 反転したものを元の文字列に組み込む
substr($temp, $c_pos, 1) = substr($temp, $n_pos, 1, "_"); # 空き升目と移動先の石を入れ替える
次の盤面を生成するには、2つの作業が必要です。1つ目は、中間の石を反転することです。
盤面文字列全体の石を反転するのは簡単ですが、一部だけ反転するのには少し工夫が必要です。
ここでは盤面文字列から反転する部分文字列を取り出して、部分文字列を反転して、元の盤面文字列を置き換える、
という方法で行っています。2つ目は、現在の空き升目と次の移動先の石とを入れ替えることです。
引数4つの substr と左辺値としての substr を使用すると、一度に入れ替えることができます。
今回の探索では、while ループを使用しています。条件部は、探索途中のボードを格納した配列
@board です。探索の開始時には、スタートの盤面だけが入っています。探索を開始すると、スタートの盤面を @board
から取り出して、1手目の盤面をすべて生成して @board に格納します。2手目以降も同じような方法で生成して、
最短手順の解が見つかったら、そこで while ループを終了させます。
以上が今回のパズルの探索の仕組みです。
今回のパズルを実行すると、以下のような結果になります。
$n: 5 $n: 6
_BBBB B_BBB BB_BB _BBBBB B_BBBB BB_BBB
BWW_B BBWW_ BBBW_ BW_BBB BBW_BB _WBBBB
B_BWB BB_BW _WWBB BWBW_B BBWBW_ BBW_BB
BBWB_ _WBBW WB_BB _BWBBB _WBWBB BBWBW_
BB_WW BBW_W WBBW_ WW_BBB WBW_BB _WBWBB
_WBWW B_BBW W_WBB WWBW_B WBWBW_ WBW_BB
WBWB_ BWWW_ WBB_B W_WBWB W_BWBB WBWBW_
WB_WW BW_BW _WWWB WBBWB_ WWW_BB W_BWBB
_WWWW _BBBW WB_WB WBB_WW WWWBW_ WWW_BB
BWW_W WBBB_ _WWWWW WW_WBW WWWBW_
B_BWW W_WWB WWBB_W WW_WBW
BWWB_ WWB_B W_WWWW WWBB_W
BW_WW _BWWB _BWWWW
_BBWW WW_WB WW_WWW
WWWB_ WWBB_
WW_WW _BWWW
_BWWW WW_WW
WWB_W
W_WWW
(2007/07/01, 2008/07/01更新)
TopPage