summer_tree_home

Check iOでPython3をマスターするぜっ

Haunted House (O'Reilly) - お化け屋敷

O'Reilly最後の問題だけど、めちゃくちゃ難しかった。
いちおうクリアはできたれど、運が良ければクリアできるという解答しかできなかった・・・。

どんな問題?

Haunted House
http://www.checkio.org/mission/haunted-house/

お化け屋敷から幽霊に捕まらないように抜け出せ。
ステファン(自分)と幽霊が、交互に移動を繰り返す。
幽霊は、ステファンとの直線距離が近くなるように移動する。

これは、繰り返しcheckio()関数が呼び出されるタイプの問題である。

  • 4×4の16部屋があり、各部屋ごとに移動可能な方角が決まっている。
  • 初期位置は、幽霊が#1、ステファンが#16の部屋。
  • #1の北側が出口となる。
  • 各ターンごとに、かならず移動しなくてはならない。
  • 30ターン以内に抜け出さないとアウト。
  • 出口以外で、家の外側に進むとアウト。

引数は、部屋のデータ、ステファンの位置、幽霊の位置が渡される。
各部屋ごとの移動不可能な方角は、文字列で指定される。例えば"NS"なら北と南には進めない。
この文字列16個分のリストが、部屋のデータとなる。

戻り値は、ステファンが次に進む方角を文字列で返す。

例題:

checkio(
    ["", "S", "S", "",
     "E", "NW", "NS", "",
     "E", "WS", "NS", "",
     "", "N", "N", ""],
    16, 1)
checkio(
    ["", "", "", "",
     "E", "ESW", "ESW", "W",
     "E", "ENW", "ENW", "W",
     "", "", "", ""],
    11, 6)

どうやって解く?

基本的には迷路の探索だけど、問題は障害物(幽霊)が毎ターン変化する点。
さて、どうしたものか?

まず、幽霊の動き方は check_solution() 関数に書いてあるので、見てみよう。

  • 東西南北から移動可能な方角を調べる。
  • それぞれの移動先から、ステファンまでの直線距離を調べる。
  • 直線距離がもっとも短くなる方角に移動する。
  • 同じ距離が複数あれば、random.choice()で選ぶ。

幽霊がランダムで動くというのがやっかいだなぁ。


こちらが一歩進むと、幽霊も一歩進む、しかも幽霊の動きはランダムな場合もある。
・・・どうやって最適なルートを求めればいいのかさっぱり???

とりあえずは、幽霊が動かないものとして考えてみよう。これなら普通の迷路探索になる。BFSで最短ルートを求めて、最短ルートの第一歩目を戻り値とする。実際には幽霊は動くので、うまくいかないかもしれないけど、とりあえず書いてみよう。

まず、進行可能な方角を取得する関数と、次の部屋番号を取得する関数を定義。これは、check_solution() に書かれてあるものを参考にした。

    def get_directions(no):
        # 部屋番号から進行可能な方角を取得
        result = set('NEWS') - set(house[no - 1])
        if no % 4 == 0:
            result -= {'E'}
        elif no % 4 == 1:
            result -= {'W'}
        if no <= 4:
            result -= {'N'}
        elif no >= 13:
            result -= {'S'}
        return result

    def get_next_pos(no, direction):
        # 現在の部屋番号と方角から、次の部屋番号を取得
        return no + {"N": -4, "S": 4, "E": 1, "W": -1}[direction]

幽霊の隣の部屋(=幽霊が一歩で進める部屋)に進むと、幽霊のターンで捕まってしまうので、そこは通らないようにする。幽霊と幽霊の隣の部屋を取得する関数はこちら。

    def near_ghost():
        return {get_next_pos(ghost, d) for d in get_directions(ghost)} | {ghost}

後はいつものようにBFSで探索。幽霊がルートをふさいでいる場合は答えが見つからないので、そのときはランダムで進むことにした。

まとめ

import collections
import random

def checkio(house, stephan, ghost):
    def get_directions(no):
        # noから進める方向を探す
        result = set('NEWS') - set(house[no - 1])
        if no % 4 == 0:
            result -= {'E'}
        elif no % 4 == 1:
            result -= {'W'}
        if no <= 4:
            result -= {'N'}
        elif no >= 13:
            result -= {'S'}
        return result

    def get_next_pos(no, direction):
        return no + {"N": -4, "S": 4, "E": 1, "W": -1}[direction]

    def near_ghost():
        return {get_next_pos(ghost, d) for d in get_directions(ghost)} | {ghost}

    if stephan == 1:
        return 'N'

    queue = collections.deque()
    queue.append((stephan, [], set()))  # pos, route, visited
    while queue:
        pos, route, visited = queue.popleft()
        if pos in near_ghost():
            continue
        if pos == 1:
            return route[0]
        for d in get_directions(pos):
            new_pos = get_next_pos(pos, d)
            if new_pos not in visited:
                queue.append((new_pos, route + [d], visited | {new_pos}))

    # BFSでルートが見つからなければランダム
    dirs = [d for d in get_directions(stephan) if get_next_pos(stephan, d) not in near_ghost()]
    return random.choice(dirs)

実行してみたが、残念ながら、これではクリアできなかった。そんなに甘くなかったか。


ところで、ネタバレになるが、テストでは、4種類のマップが使われる。
f:id:summer_tree:20140408114953p:plain
セルフチェック用に(1)と(2)は書かれているので、(3)と(4)も追加しておくと便利だ。

    assert check_solution(checkio,
                          ["", "", "ES", "W",
                           "E", "W", "N", "",
                           "E", "WS", "S", "",
                           "", "N", "N", ""]), "3rd example"
    assert check_solution(checkio,
                          ["", "S", "", "",
                           "E", "WNE", "W", "",
                           "", "E", "W", "",
                           "", "E", "W", ""]), "4th example"


先ほどのコードだが、自分のPC上で何度もテストしてみると、(体感で)5回に1回ぐらいはクリアできる場合がある。自分も幽霊もランダム要素があるので、運が良ければクリアできるのだろう。CheckiOのRun&Checkボタンを連打すれば、そのうちクリアできそうだが、、、。せめて、もう少し勝率を上げたいものだ。

  • 最初は幽霊を無視して、幽霊が近くなってきたら幽霊を避けるようにする?
  • やはり幽霊の移動も考慮にいれる?どうやればいいだろう?

あれこれ悩んでみたが、うまくいきそうな気がしない。頭がウニになってきた。


テストの結果を見ると、例えば、(1)と(4)に関しては、ステファンの最初の一歩が'W'(#15への移動)だとうまくいくことが多いようだ。ステファンが#15に進むと、幽霊がそれにつられて#5に進む。その後、ステファンが逆方向に進み、そのまま逃げ切る、というパターンだ。特に(4)に関しては、最初の一歩が'W'でないとクリアできないんじゃないだろうか?

というわけで、すごく単純だが、第一歩目は必ず'W'に行くようにしてみた。

    if ghost == 1 and stephan == 16:  # 最初は西へゆけ!
        return 'W'

PC上でテストしてみると、(体感で)5回に3回ぐらいはクリアできる。
CheckiOでRun&Checkしてみると、、おおっ、なんとクリアしてしまったではないか!!!

まとめ2

import collections
import random

def checkio(house, stephan, ghost):
    def get_directions(no):
        result = set('NEWS') - set(house[no-1])
        if no % 4 == 0:
            result -= {'E'}
        elif no % 4 == 1:
            result -= {'W'}
        if no <= 4:
            result -= {'N'}
        elif no >= 13:
            result -= {'S'}
        return result

    def get_next_pos(no, direction):
        return no + {"N": -4, "S": 4, "E": 1, "W": -1}[direction]

    def near_ghost():
        return {get_next_pos(ghost, d) for d in get_directions(ghost)} | {ghost}

    if stephan == 1:
        return 'N'
    if ghost == 1 and stephan == 16:
        return 'W'

    queue = collections.deque()
    queue.append((stephan, [], set()))  # pos, route, visited
    while queue:
        pos, route, visited = queue.popleft()
        if pos in near_ghost():
            continue
        if pos == 1:
            return route[0]
        for d in get_directions(pos):
            new_pos = get_next_pos(pos, d)
            if new_pos not in visited:
                queue.append((new_pos, route + [d], visited | {new_pos}))

    dirs = [d for d in get_directions(stephan) if get_next_pos(stephan, d) not in near_ghost()]
    return random.choice(dirs)||<

さすがに、5回に3回成功というレベルのものを公開するのは気が引けるので、CheckiOでの公開はやめておいた。

これで「解いた」とは言えないが、それでも他の人の答えを見ることができるようになったのは嬉しい。
では、他の人たちの答えを見せてもらおうではないかっ。

続く