Minesweeper (Electronic Station) - マインスイーパー
どんな問題?
Minesweeper
http://www.checkio.org/mission/minesweeper/
マインスイーパーのゲームをクリアせよ。
- フィールドは10x10の固定とする。
- 左上 (0,0) には地雷は無い。
今回の問題は、いままでと少し違っていて、checkio()関数が何度も繰り返し呼ばれる。
chekio()の引数としてフィールド状態を受け取るので、次にどのセルを開くか(または、どのセルが地雷か)を戻り値として返す。すると、テストルーチンが、指定したセルをクリックする。地雷が爆発したらゲームオーバー。セーフなら、クリック後のフィールド状態を引数として、再度checkio()関数が呼び出される。これを繰り返して、地雷を爆発させずに、すべての安全なセルを開くか、すべての地雷をマークすればクリアとなる。
フィールドは10x10の固定サイズで、-1がまだ未開封のセル、0~8は数字(周囲の地雷数)、9はマークされた地雷となる。セルはfield[i][j]で指定する。戻り値で、開くセルを指定する場合は、[False, i, j]、地雷をマークする場合は、[True, i, j] とする。
ちなみに、数字0の場合、テストルーチン側が周囲の未開封セルを自動的に開いてくれるので、自分で数字0の周囲を開ける必要はない。
どうやって解く?
最初は、何から手を付ければいいかさっぱりだったが、マインスイーパーの基本ルールに返ってみる。
単純なケース
- 数字が1のとき、周辺に地雷が0個、未開封セルが1個 →未開封セルは地雷
- 数字が2のとき、周辺に地雷が1個、未開封セルが1個 →未開封セルは地雷
- 数字が3のとき、周辺に地雷が1個、未開封セルが2個 →未開封セルは2個とも地雷
「数字-周辺の地雷数」が未発見の地雷数となるので、未発見の地雷数と未開封セルの数が等しければ、未開封セルはすべて地雷ということがわかる。
- 数字が1のとき、周辺に地雷が1個、未開封セルが1個 →未開封セルは安全
- 数字が2のとき、周辺に地雷が2個、未開封セルが1個 →未開封セルは安全
未発見の地雷数が0なら、未開封セルは安全(地雷ではない)とわかる。
まあ、ここまでは単純に数字セル1個だけを見てわかるケースだ。
より複雑なケース
ところが、マインスイーパーでは、複数の数字セルを見ないと解けないケースがある。例えば、
01□ 02□ 01□
このような場合だ。□が未開封セルで、上からA,B,Cと呼ぶことにする。
- 上の数字1から、A,Bの1つが地雷 …条件(1)
- 中の数字2から、A,B,Cの2つが地雷 …条件(2)
- 下の数字1から、B,Cの1つが地雷 …条件(3)
一つ一つの条件からは何も確定できない。しかし、条件(1)と(2)を同時に満たすためにはCが地雷と確定できる。すると条件(3)からBがセーフだと確定でき、条件(1)からAが地雷だと確定できる。
このように複数の条件から判断しないといけないケースがやっかいだ。
まずは単純なケースを解く
とりあえず、最初は単純なケースだけ考えてみる。
def checkio(field): def get(p): return field[p[1]][p[0]] def all_cells(): h = len(field) w = len(field[0]) return [(x, y) for y in range(h) for x in range(w)] def get_around_cells(center): return [p for p in all_cells() if p != center and abs(center[1] - p[1]) <= 1 and abs(center[0] - p[0]) <= 1] for pt in all_cells(): n = get(pt) if 0 <= n <= 8: covered_cells = [p for p in get_around_cells(pt) if get(p) == -1] # 未開封のセル if len(covered_cells) > 0: covered_mines = n - len([p for p in get_around_cells(pt) if get(p) == 9]) # 未発見の地雷数 cell = covered_cells[0] # 未開封のセルの1つ if len(covered_cells) == covered_mines: return [True, cell[1], cell[0]] elif covered_mines == 0: return [False, cell[1], cell[0]] return [False, 0, 0]
get_around_cells()がわかりにくいかもしれないが、centerの周辺8マス(centerは含まない)の座標を返している。
周辺の未開封セルをリストアップして、未発見の地雷数と比較している。数字が0のときにも、未開封セルがあれば開くようにしておいた。
ここまではいいんだよな。とりあえず、Run & Check
Task solved! クリア~
って、ちょっと待てーい。
これ、まだ単純なケースしか想定してないんだぞ。
だいたい、テストは3パターンだけなの?しかも、エディタにあらかじめ書いてあるSimple,Gate,Various と同じ内容。これでクリアって言われても、納得できんぞー!!!
このコードで解けない問題なんていっぱいあるはず。例えば、、、どんなんだろ?ちょっといろんな問題を作って試してみる。
と、その前に、ブラウザ上のエディタでは何かと不便なので、ここからは自分のPC上のPythonでテストしている。
ランダムに問題を作ってみる
問題を作成するには、地雷原を定義して、check_solution()に渡せばよい。地雷原は10x10のfieldで定義する。0が安全、1が地雷となる。
import random def make_random_field(mine_percent=20): field = [[int(random.randint(0, 99) < mine_percent) for x in range(10)] for y in range(10)] field[0][0] = field[1][0] = field[0][1] = field[1][1] = 0 return field
これで、ランダムな地雷原を作成できる。左上(0,0)はもちろん、(1,0)(1,1)(0,1)に地雷があっても、運試し問題になるので、左上4ヶ所に地雷は無いようにした。もちろん、解いていくと、運試し問題だったということはありえる。ちなみに、運試し問題とは、
1□□ □□□ □□□
こんなふうに、安全にクリックできる場所がないケースだ。
作った問題を試すには、
assert check_solution(checkio, make_random_field()), "random"
と書いて実行する。
フィールドを表示(デバッグ用)
解いてる過程が見えないので、もうちょっと見やすくしてみた。
def show_field(field): s = '012345678●□' print('\n'.join(''.join(s[num] for num in line) for line in field) + '\n')
checkio()内で、show_field(field) とすれば、下記のように表示される。●が地雷、□は未開封セルだ。
0000000000 0112111110 01●□□□□□10 02□□□21110 01□□□10000 01□□□10000 01□□□11110 01□□□□□□10 0112111110 0000000000
解けない問題を発見
あれこれ試して、先ほどの私のコードでは解けない問題がいくつも見つかった。
追加テストとして載せておく。
assert check_solution(checkio, [ [0, 0, 0, 0, 1, 1, 0, 0, 0, 0], [0, 0, 1, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 1, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [1, 0, 1, 0, 0, 0, 0, 0, 0, 0], [0, 0, 1, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]]), "additional test 1" assert check_solution(checkio, [ [0, 0, 0, 0, 0, 0, 0, 0, 0, 1], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 1, 0], [0, 0, 1, 0, 0, 0, 0, 0, 0, 1], [0, 0, 0, 0, 0, 1, 0, 0, 1, 0], [0, 0, 0, 0, 1, 0, 0, 1, 0, 0], [0, 0, 0, 0, 0, 1, 0, 1, 1, 1], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [1, 0, 0, 1, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 1, 0, 0, 0, 0]]), "additional test 2"
先ほどの私の書いたコードでは、additional test 1の場合、
0112□□□□□□ 01●3□□□□□□ 012□□□□□□□ 001□□□□□□□ 121□□□□□□□ □□□□□□□□□□ □□□□□□□□□□ □□□□□□□□□□ □□□□□□□□□□ □□□□□□□□□□
ここで止まってしまう。しかし、□(2,5)は地雷、□(3,4)はセーフだと確定できるはずだ。
なぜ、□(2,5)が地雷だと確定できるかというと、
001 121 □□□ ↑↑↑ ABC
未開封セルを左からA,B,Cとすると、A,Bのうち1つが地雷、A,B,Cのうち2つが地雷、つまりCが地雷となるのだ。
さて、こういった複雑なケースでもクリアできるようなコードを書いてみよう。