summer_tree_home

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

Bulls and Cows (Incinerator) - 隠された4桁の数字を当てろ

いやー、初めてギブアップしようかと思った問題。
数時間考えて、絶対に無理だと思ったが、なんとかクリアできた。

どんな問題?

Bulls and Cows
http://www.checkio.org/mission/bulls-and-cows/

隠された4桁の数字を当てるゲームで勝利せよ。
こちらが4桁の数字を推測して伝えると、相手は、

  • 数字も位置も合っている個数(Bulls)
  • 数字は合っているが位置が間違っている個数(Cows)

というBullsとCowsを調べて結果を返す。
これを繰り返し、8ターン以内に正解を見つけることができれば勝利。
なお、4桁の数字は重複しない。

引数は、相手とのやりとりをリスト形式で受け取る。
["1234 0B2C", "6512 2B2C"] であれば、
1回目のこちらの推測した数字は 1234、その結果が 0B2C
2回目のこちらの推測した数字は 6512、その結果が 2B2C
となる。
結果が 0B2C なら、Bullsが0、Cowsが2を意味する。(結果が 4B0C、つまりBullsが4になれば勝利。)

どうやって解く?

またも、どこから手を付けて良いのやら、さっぱりわからぬ・・・。
まず、数字の位置はおいといて、とりあえず4つの数字を当てるゲームとして考えてみる。

試行錯誤、その1

まず、Bulls+Cowsの数が、正解の数字の数だから、
"1234 0B2C"
からは、{1,2,3,4} に 正解が2個あることがわかる。
"6512 2B2C"
からは、{6,5,1,2} に 正解が4個、つまり、4つの数字は6,5,1,2で確定している。

例えば、
['0123 0B3C', '4567 0B1C', '8901 0B1C']
であれば、

  • {0,1,2,3}に3個
  • {4,5,6,7}に1個
  • {8,9,0,1}に1個

ということがわかる。さらに、

  • {0,1,2,3,4,5,6,7,8,9}に4個

という条件もある。

以上の条件から、

  • {0~9}(4個) - {0,1,2,3}(3個) - {4,5,6,7}(1個) から、{8,9}に0個 とわかる。
  • {8,9,0,1}(1個) - {8,9}(0個) から、{0,1}に1個 とわかる。
  • {0,1,2,3}(3個) - {0,1}(1個) から{2,3}に2個 とわかる。

この時点で、8,9はどちらも×、2,3はどちらも○だと確定した。
未確定は0,1,4,5,6,7だから、ここで、次の推測を'0145'として…


うーん、なんかこんがらがってきた。この方法で解ける気がしない。

試行錯誤、その2

最初の3ターンは、毎回同じように推測、例えば、'0123','2345','4567'と決めて、この結果から絞っていく方法はどうだろう。

例えば、

  • {0,1,2,3}に3個
  • {2,3,4,5}に3個
  • {4,5,6,7}に1個

であれば、
{0,1},{1,2},{2,3},{3,0}のいずれも、1~2個
{2,3},{3,4},{4,5},{5,2}のいずれも、1~2個
{4,5},{5,6},{6,7},{7,4}のいずれも、0~1個
となる。
{4,5}は、0~1個 かつ 1~2個 だから、1個だと確定する。{4,5}に1個 がわかれば、

  • {4,5,6,7}に1個 から、{6,7}に0個 と確定する。
  • {2,3,4,5}に3個 から、{2,3}に2個 と確定する。

{2,3}に2個 がわかれば、{0,1,2,3}に3個 から、{0,1}に1個 だと確定する。
また、{0,1,2,3}に3個、{4,5,6,7}に1個 だから、{8,9}に0個 だと確定。
で、次に{0,1}と{4,5}を確定できるような数字、例えば'0423'と'0523'を聞けば、4つの数字がすべて確定する。

ここまでで5ターン使っているので、後3ターン以内で位置を確定する必要があるが・・・。

いや、そもそも最初の分布がもっと違っていたら、数字を確定できない場合もあるだろう。
例えば、

  • {0,1,2,3}に2個
  • {2,3,4,5}に2個
  • {4,5,6,7}に1個

の場合だと、何一つ確定できない。

この方針もちょっと無理そうだ。

ちょっと諦めてみる

ダメだ、まったく解ける気がしない。ただ、ここまで全ての問題を順番にクリアしてきたので、ギブアップはしたくない。ちょっと汚い方法でもいいから解けないものか。

Restrict Prime(素数の判定問題)では、あらかじめ10000以下の素数を全部計算してコード中に書いている人がいた。こういうパターンならできるかも。
4桁の数字(重複無し)ってことは、10x9x8x7だから、えーと5040通り。これくらいなら、あらかじめ計算しておくのも可能だろう。
5040通りの全ての数字について、推測した数が'0123'のとき、Bulls&Cowsはどうなるかの分布を計算してみた。

import itertools

def get_bulls_and_cows(guess, secret):
    bulls = cows = 0
    for u, g in zip(guess, secret):
        if u == g:
            bulls += 1
        elif u in secret:
            cows += 1
    return "{0}B{1}C".format(bulls, cows)

def test():
    numbers = [''.join(p) for p in itertools.permutations('0123456789', r=4)]  # 5040 numbers

    guess = '0123'
    dic = dict()
    for secret in numbers:
        bc = get_bulls_and_cows(guess, secret)
        if bc in dic:
            dic[bc] += 1
        else:
            dic[bc] = 1
    for k in sorted(dic):
        print('|{}|{}|'.format(k, dic[k]))

結果はこちら。

0B0C 360
0B1C 1440
0B2C 1260
0B3C 264
0B4C 9
1B0C 480
1B1C 720
1B2C 216
1B3C 8
2B0C 180
2B1C 72
2B2C 6
3B0C 24
4B0C 1

つまり、'0123'と推測して、その結果が'0B0C'なら、答えは360個に絞れるということになる。結果が'4B0C'のときは、答えは1個、つまり推測がズバリ正解だったというわけだ。一番多い場合でも1440個。これを繰り返せば、徐々に答えが絞れていくから、8ターン繰り返せば確定できそうだな。

問題は、結果が'0B0C'だった場合に、次はどう推測すべきだろう?ランダムに推測を8ターン繰り返しても、答えが確定できるとは思えない。
そうか、正解が360個に絞れたら、次はその360個のうちの一つを返せばいい。そうすれば、少なくとも 1/360 で正解に当たるはずだ。当たらなくても、次の答えの分布を見て、さらに候補を絞ることができる。

あれ、これをそのまま書けばいいんじゃないか?
5040個の数字リストのなかから、結果(Bulls&Cows)が一致する数字だけを残していく。残った数字候補から1つ選んで戻り値にする。

まとめ

import itertools

def get_bulls_and_cows(guess, secret):
    bulls = cows = 0
    for u, g in zip(guess, secret):
        if u == g:
            bulls += 1
        elif u in secret:
            cows += 1
    return "{0}B{1}C".format(bulls, cows)

def checkio(data):
    if len(data) == 0:
        return '0123'  # 最初は0123

    numbers = [''.join(p) for p in itertools.permutations('0123456789', r=4)]  # 5040 numbers
    for s in data:
        guess, bull_cow = s.split(' ')
        # 5040個の数字の中から、BullsCowsが一致するものだけを残す
        numbers = [n for n in numbers if get_bulls_and_cows(guess, n) == bull_cow]
    # 残った数字から1つ選んで返す
    return numbers[0]

あれれ、すごく短く書けてしまった。

とりあえず、Run & Check...

なんとクリアしてしまった!
あんなに考えてもわからなかったのに、こんなあっさりと。

失敗ケースを分析

テストはクリアしたものの、すべての数字(5040個)で本当に8ターン以内に解けるのかテストしてみた。

for n in [''.join(p) for p in itertools.permutations('0123456789', r=4)]:
    check_solution(checkio, n)

かなり時間がかかるが、なんとか全部チェックが終わった。
正解までに必要なターン数(step)の分布も調べてみたが、次の通り。

step数 個数
1 1
2 13
3 108
4 596
5 1668
6 1768
7 752
8 129
9 5

残念ながら、8ターンでは解けず、9ターンまでかかる数字が5つあった。
5293, 9204, 9214, 9241, 9431

失敗例を分析してみると、例えば、5293の場合、8ターンでの引数(data)と、残った候補(numbers)はこうなっていた。
data = ['0123 1B1C', '0245 1B1C', '0356 0B2C', '1543 1B1C', '1625 0B2C', '4263 2B0C', '5273 3B0C']
numbers = ['5283', '5293']
残り2つにまで絞れているが、numbers[0]の'5283'を返したため、Failとなったのだ。

9204の場合
data = ['0123 0B2C', '1045 0B2C', '2354 1B1C', '2406 1B2C', '2560 0B2C', '3604 2B0C', '7204 3B0C']
numbers = ['8204', '9204']
こちらも同じように8ターンで2つの候補が残っている。

ターン数を減らすには、単純にnumbers[0]を返すのではなく、候補の中から、より答えに近いものを探す工夫が必要なのだろう。



他の人の答えを見るのは後日にして、とりあえず今日はこのくらいにしておく。
厳密にはクリアしたとは言えないが、自力でここまでできたので、よしとしよう。

(っていうか、8ターンっていう縛りは厳しすぎるよなぁ。10ターン以内でクリア、余裕のある人は7ターンを目指す、というようにしてほしい。)