はじめに
この記事は、Pythonで作成した、
n桁版Hit&Blow(ヌメロン(Numer0n))の出題者側プログラムおよび質問者側プログラム
https://qiita.com/gx3n-inue/items/396eddf69fbfa1eacc65
を、Elixirに移植した話の紹介です。
日本国内では 「Hit&Blow (ヒット・アンド・ブロー)」 という名前で紹介されることが多い数当て遊びの一種ですが、海外では Bulls&Cows などとも呼ばれています。
派生形のゲームとしては、テレビ番組でも取り上げられた Numer0n(ヌメロン) が有名ですね。
今回、Elixirで作成したソースコードは下記に配置しております。
https://github.com/NobuyukiInoue/exHitAndBlow
桁数n は、 2桁〜10桁 の中から選ぶことができます。
offenceモード(質問者モード)
人間側が用意した番号を、コンピュータに当てさせるモードです。
コンピュータ側がランダムで候補を選択してきますので、表示された番号と自分の答えを照合して、
Hit数とBlow数を答えてあげてください。
(最小化戦略等はまだ実装していません。4桁の場合で、平均回答数は5.4回程度です。)
$ ./main offence 4
N ... 4
(remaining count = 5040) Is your number 0329 ?
[1] : please input H, B = 1,2
input response is Hit = 1, Blow = 2
(以下、Hit = 4 まで繰り返し)
回答が面倒な時は、事前に答えを与えておいて自動で回答する、自動回答モードもあります。
$ ./main offence 4 false 1234
N ... 4
set answer number ... 1234
(remaining count = 5040) Is your number 7986 ?
input response is Hit = 0, Blow = 0
(remaining count = 360) Is your number 5142 ?
input response is Hit = 0, Blow = 3
(remaining count = 88) Is your number 0521 ?
input response is Hit = 0, Blow = 2
(remaining count = 16) Is your number 2453 ?
input response is Hit = 0, Blow = 3
(remaining count = 3) Is your number 1234 ?
input response is Hit = 4, Blow = 0
calculate successful.
===== challenge history ======
[1] (5040) --> 7986 (0, 0)
[2] ( 360) --> 5142 (0, 3)
[3] ( 88) --> 0521 (0, 2)
[4] ( 16) --> 2453 (0, 3)
[5] ( 3) --> 1234 (4, 0)
defenceモード(出題者モード)
コンピュータ側が用意した番号を、人間側が推測して当てていくモードです。
(自分で考えるのは面倒なので、もう一つウインドウを開いて、offenceモードが指定してきた番号を入力してあげても良いかも)
$ ./main defence 4
N ... 4
[0] : select number xxxx = 0123
set select number ... 0123
input response is Hit = 0, Blow = 2
[1] : select number xxxx = 4567
set select number ... 4567
input response is Hit = 1, Blow = 0
[2] : select number xxxx =
(以下、Hit = 4 まで繰り返し)
repeatモード
offenceモード(質問者モード)を、指定回数自動で繰り返すモードです。
ちなみに、5040問チャレンジさせても、
(M1 Macbook Air 2020)では、13秒台で回答が終わります。
Python3版では、36秒台でしたので、Elixir、かなり速いですねー
全ての回答の最後には、何回で答えまでだどりついたかの集計も表示されます。
ResultCount[5036] = 5
ResultCount[5037] = 6
ResultCount[5038] = 5
ResultCount[5039] = 4
======== distribution ========
1 ... 2
2 ... 9
3 ... 92
4 ... 632
5 ... 1820
6 ... 1821
7 ... 614
8 ... 47
9 ... 3
Distribution list Total = 5040
==============================
Total Questions = 27551
Total Average = 5.466468253968254
==============================
Total execution time ... 13.828[s]
処理内容
Hit&Blowで必須となるいくつかの基本的な処理だけ、Elixirでの作成例を紹介させていただきます。
基本的には、PythonではWhileやForなどで記述していた繰り返し処理は、
再帰処理で記述していますが、Enum.reduce()などを駆使した書き方もあるかもしれません
リストの中に指定した要素が含まれているかどうか調べる
まずは、、リストの中に、指定した要素が含まれているかを調べる処理です。
重複なしのn桁の番号を生成するには、チェック時に必須です。
Enum.find_index() で実現できますが、fn 以下の処理まで記述していると見づらずくなるので、
下記のような自作の関数から呼び出すようにしています。
リストarrの中から、targetの値が見つかったら true、見つからなかった場合は false を返します。
@spec enum_contains(arr :: [integer], target :: integer) :: bool
def enum_contains(arr, target) do
not is_nil(Enum.find_index(arr, fn item -> item == target end))
end
今回のHit&Blowでは見つかった位置のインデックス番号は必要ありませんが、
見つかったインデックス番号を返すのであれば、Enum.find_index()の戻り値を返すだけですね。
@spec enum_index(arr :: [integer], target :: integer) :: integer | nil
def enum_index(arr, target) do
Enum.find_index(arr, fn item -> item == target end)
end
iex(10)> Lib_hit_and_blow.enum_contains([0,1,2,3,4,5],4)
true
iex(11)> Lib_hit_and_blow.enum_contains([0,1,2,3,4,5],9)
false
iex(11)> Lib_hit_and_blow.enum_index([0,1,2,3,4,5],5)
5
iex(12)> Lib_hit_and_blow.enum_index([0,1,2,3,4,5],6)
nil
ちなみに、文字列の中に、指定した文字列が含まれているか調べるには、
String.contains?() という関数があります。
iex(12)> target="abcdefg"
"abcdefg"
iex(13)> String.contains?(target, "cd")
true
iex(14)> String.contains?(target, "gh")
false
互いに異なるn桁のランダムな番号を返す
答えの番号や、チャレンジする番号は乱数を使って生成しています。
乱数を使ってn桁の(各桁の値が重複しない)ランダムな番号を生成する処理を、以下のように記述してみました。
@spec create_random_n_digits_number(n :: integer) :: [integer]
def create_random_n_digits_number(n) do
# :rand.seed :os.timestamp
create_random_n_digits_number(n, [])
end
@spec create_random_n_digits_number(n :: integer, nums :: [integer]) :: [integer]
def create_random_n_digits_number(n, nums) do
if Enum.count(nums) == n do
nums
else
num = (:rand.uniform 10) - 1
if enum_contains(nums, num) do
create_random_n_digits_number(n, nums)
else
create_random_n_digits_number(n, [num] ++ nums)
end
end
end
iex(3)> Lib_hit_and_blow.create_random_n_digits_number(4)
[4, 7, 9, 1]
iex(4)> Lib_hit_and_blow.create_random_n_digits_number(10)
[7, 9, 5, 4, 0, 6, 1, 8, 3, 2]
答えの候補を生成する
1回目の回答の候補は、4桁の場合は(10x9x8x7=)5040通りありますが、
5040通りの候補を[integer]型で生成する処理は、以下のように作成してみました。
1桁目、2桁目...というように、再帰で生成しているので、そのままだと
[[[[0,1,2,3],[0,1,2,4],[0,1,2,5], ...[0,1,2,9]],[[1,0,2,3],[1,0,2,4], ...]]]
といったネストしたリストとなってしまうので、
各再帰処理から戻った直後に、Enum.flat_map()を使って、フラットなリストに変換しています。
(ちなみに、リストへの新たな要素の追加は、末尾より先頭に追加した方が速いのですが、末尾の方が理解しやすいので、今回は末尾に追加しています)
@spec create_target_numbers(n :: integer) :: [[integer]]
def create_target_numbers(n) do
if n > 0 do
target_numbers =
for i <- 0..9 do
create_target_numbers(n - 1, [i])
end
Enum.flat_map(target_numbers, fn array -> Enum.filter(array,fn item -> item != 0 end ) end)
end
end
@spec create_target_numbers(n :: integer, work_number :: [integer]) :: [integer]
def create_target_numbers(0, work_number) do
work_number
end
def create_target_numbers(n, work_number) do
target_numbers =
for i <- 0..9, not enum_contains(work_number, i) do
create_target_numbers(n - 1, work_number ++ [i])
end
if n > 1 do
Enum.flat_map(target_numbers, fn array -> Enum.filter(array,fn item -> item != 0 end ) end)
else
target_numbers
end
end
iex(13)> Lib_hit_and_blow.create_target_numbers(4)
[
[0, 1, 2, 3],
[0, 1, 2, 4],
[0, 1, 2, 5],
(中略)
[0, 1, 8, 5],
[0, 1, 8, ...],
[0, 1, ...],
[0, ...],
[...],
...
]
1回目のチャレンジの際は、上記のリスト中の5040個の要素の中から、ランダムに1つを選んでチャレンジしますが、
2回目以降は、1回目の結果(Hit数, Blow数)から、条件に合わない番号は除外し、条件として可能性のある番号のみを、次の回答の候補群として再生成しています。
候補群の生成が終わると、その中からランダムに1つを選んで、次のチャレンジを行います。
あとは、、Hit数 == n になるまで繰り返すだけですね。
答えの番号とチャレンジした番号を照合し、Hit数とBlow数を返す
答えの数値が0123
チャレンジした数値が2156
の場合、 Hit数は1 , Blow数は1 となりますが、
2つの数値を比較して、Hit数とBlow数を返す処理は以下のように記述してみました。
answer_number が答えの数値、 target_number が候補として入力した数値です。
タブルで、{Hit数, Blow数} を返します。
@spec response_check(n :: integer, answer_number :: [integer], target_number :: [integer]) :: {integer, integer}
def response_check(n, answer_number, target_number) do
response_check(n, answer_number, target_number, 0, 0, 0)
end
@spec response_check(n :: integer, answer_number :: [integer], target_number :: [integer], col :: integer, hit :: integer, blow :: integer) :: {integer, integer}
def response_check(n, answer_number, target_number, col, hit, blow) do
if col >= n do
{hit, blow}
else
if Enum.at(target_number, col) == Enum.at(answer_number, col) do
response_check(n, answer_number, target_number, col + 1, hit + 1, blow)
else
if enum_contains(answer_number, Enum.at(target_number, col)) do
response_check(n, answer_number, target_number, col + 1, hit, blow + 1)
else
response_check(n, answer_number, target_number, col + 1, hit, blow)
end
end
end
end
その後、 @mnishiguchi にご助言をいただいて、(コメントに記載あり)
Enum.zip(), Enum.reduce() を使った書き換え例を紹介していただきました。
ありがとうございます。
Elixirでもこういう書き方ができますよ。よかったら試してみてください。Happy coding!
def response_check(n, answer_number, target_number) do
for {n, m} <- Enum.zip(answer_number, target_number), reduce: {0, 0} do
{hit, blow} ->
cond do
n == m -> {hit + 1, blow}
n in target_number -> {hit, blow + 1}
true -> {hit, blow}
end
end
end
def response_check(n, answer_number, target_number) do
Enum.zip(answer_number, target_number)
|> Enum.reduce({0, 0}, fn
{n, m}, {hit, blow} when n == m ->
{hit + 1, blow}
{n, _}, {hit, blow} ->
if n in target_number,
do: {hit, blow + 1},
else: {hit, blow}
end)
end
iex(16)> Lib_hit_and_blow.response_check(4, [0,1,2,3], [2,1,5,6])
{1, 1}
ちなみに、Pythonだと下記のような処理になります。
def response_check(n:int, answer_number:str, target_number:str) -> (int, int):
"""
H, B = 0, 0
for i in range(0, n):
if target_number[i] == answer_number[i]:
H += 1
else:
for j in range(0, n):
if i != j and target_number[i] == answer_number[j]:
B += 1
return H, B
"""
H, B = 0, 0
for n, m in zip(answer_number, target_number):
if n == m:
H += 1
elif n in target_number:
B += 1
return H, B
最後に
関数型言語では、各繰り返し処理の中はImutableで値の更新ができないという特徴に、なかなか慣れず、
移植には1週間ほどかかってしまいました。
再帰処理に頼ってループ処理を行なっていますが、Enum系の関数も使いこなさねば...