Nim(古典的なゲーム)

Nim

石の山が $ n $ 個あって、それぞれ $ a_i $ 個の石を含んでいます。

Nancy と Tom は交互に空ではない山を1つ選び、そこから1つ以上の石を取ります。

Nancy の番からはじまり、最後の石を取った方が勝ちです。

両者が最善を尽くした場合、どちらが勝つでしょうか?

解法・ソースコード

この問題は Nim と呼ばれる古典的なゲームです。

このゲームの考え方は多くのゲームの基礎となります。

このゲームの勝ち負けの判定には 排他的論理和(XOR) を使います。

  $ a_1 XOR a_2 XOR … a_n \neq 0 → 勝ちの状態 $
  $ a_1 XOR a_2 XOR … a_n = 0 → 負けの状態 $

排他的論理和(XOR) を計算し、0でなければNancyの勝ち、0ならばBobの勝ちとなります。


上記を証明します。

  • 負けの状態からは勝ちの状態にしかいけない。
    XORが0の状態から1つ以上石を取ると、XORは必ず0ではなくなります。
  • 勝ちの状態からは常に負けの状態を作れる。
    XORの結果を2進数で見たとき一番上のビットが立っているような山を選びます。
    そこから1番上のビットが0になり、かつ残りのXOR結果が1ビットが反転するように石を取るとXORを0にできます。

[Google Colaboratory]

1
2
3
4
5
6
7
8
9
10
11
12
13
#--------- 入力例 1 ---------
n = 3 # 山の数
a = [1, 2, 4] # 山ごとにある石の数
#--------- 入力例 2 ---------
# n = 3 # 山の数
# a = [1, 2, 3] # 山ごとにある石の数
#----------------------------
x = 0
for a1 in a:
x ^= a1
print(bin(x))

print('解:', 'Tom' if x == 0 else 'Nancy')

[実行結果(入力例1)]

0b1

0b11

0b111

解: Nancy

[実行結果(入力例2)]

0b1

0b11

0b0

解: Tom

素数ライブラリ(SymPy)

SymPy

SymPyライブラリを使うと、素数を簡単に扱うことができます。

Google Colaboratoryでは、SymPy がすでにインストールされていますが、ローカル環境では下記のコマンドでSymPyをインストールする必要があります。

[Google Colaboratory]

1
pip install sympy

素数を求める

一定範囲の素数を求める場合は、sieve.primerangeを使います。

[Google Colaboratory]

1
2
3
from sympy import sieve

print([i for i in sieve.primerange(1, 30)])

[実行結果]

[2, 3, 5, 7, 11, 13, 17, 19, 23, 29]

素数かどうかを判定する

ある数値が素数かどうかを判定する場合は、isprimeを使います。

[Google Colaboratory]

1
2
3
from sympy import isprime

print(isprime(101))

[実行結果]

True

文字列操作(区間分割・連長圧縮)

問題

文字列 $ S $ が与えられます。

これは、1つ以上の 単語 を連結したものとなっています。

各単語は2文字以上であり、最初の文字と最後の文字が大文字で、残りの文字は小文字となっています。

これらの単語を辞書順に並べて(大文字小文字の違いは無視)、その順に連結して出力して下さい。

解法・ソースコード

この問題は 連長圧縮 と同じ方法で解くことができます。

添え字 $ i $ を先頭から順に動かしながら、下記のように処理していきます。

🔹 大文字 $ s[i] $ に対して、その次の大文字がどこにあるのか探す。
  その大文字を $ s[j] $ とする。
🔹 $ s[i] $ から $ s[j] $ までの部分文字列を1つの単語として切り出す。
🔹 $ i = j + 1 $ と更新する。
  $ j $ は単語の終わりを表す添え字なので、その次の $ j + 1 $ に $ i $ を移動する。

上記の処理で取り出した単語のリストをソートして、連結して出力します。 

大文字小文字を無視した状態での辞書順比較は sorted(words, key=str.lower) として行うことができます。

[Google Colaboratory]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#--------- 入力例 ----------
s = "FisHDoGCaTAAAaAAbCAC"
#---------------------------
words = [] # 単語のリスト

# 文字列 s を単語ごとに分解する
i = 0
while i < len(s):
j = i + 1 # s[j] が大文字になる j を見つける
while j < len(s) and s[j].islower():
j += 1
# 単語を切り出してリストに追加
words.append(s[i:j + 1])

i = j + 1 # i を j + 1 に移動

# 単語を大文字小文字を無視した状態でソート
words = sorted(words, key=str.lower)

print('解:', ''.join(words))

[実行結果]

解: AAAaAAbCACCaTDoGFisH

問題文で指示された通りの文字列を出力することができました。

区間分割・連長圧縮

区間分割・連長圧縮

$ N $ 個の要素を区分に分割する問題は、比較的よく出題されます。

区分に分割する処理の一例として、連長圧縮 があります。

連長圧縮 は、"AAABBBBBAACDD" といった文字列を ('A', 3), ('B', 5), ('A', 2), ('C', 1), ('D', 2) のようにデータ1つ分と連続した長さとの組で表現します。

ソースコード

連長圧縮 を行うサンプルソースは次のようになります。

[Google Colaboratory]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#--------- 入力例 ----------
s = 'AAABBBBBAACDD'
#---------------------------
n = len(s) # 文字列の長さ
i = 0
while i < n:
# s[j] != s[i] となる位置を探す
j = i
while j < n and s[j] == s[i]:
j += 1
# 文字 s[i] が j - 1 文字連続したことがわかる
print(s[i], j - i)
# i を j の位置へ移動
i = j

[実行結果]

A 3
B 5
A 2
C 1
D 2

文字列を区間ごとに分割することができました。

じゃんけん(全探索)

問題

コンピュータと $ N $ 回じゃんけんをします。

コンピュータの出す手はあらかじめ分かっていて、“G”はグー“C”はチョキ“P”はパー を表します。

あなたが、$ N $ 回のじゃんけんで出した 指の本数の合計 がちょうど $ M $ 本になるようにじゃんけんをすると、最高で何回 じゃんけんに勝つことができるでしょうか。

解法・ソースコード

まず今回はじゃんけんの指の合計数を求める問題なので、順番は気にする必要がありません。

次に、じゃんけんで出せる指の合計数が決まっているので、パー(指5本)の回数が決まればチョキ(指2本)の回数が決まり、チョキの回数が決まればグー(指0本)の回数が決まります。

とういうことで、パーの出す回数について全探索を行えば解を導くことができます。

グー、チョキ、パーを自分が g, c, p 回、コンピュータが cnt_g, cnt_c, cnt_p 回出すとき、自分がじゃんけんに勝てる回数は最大で min (g, enemy_c) + min (c, enemy_p) + min (p, enemy_g) 回となります。

ただし次の2点に関して注意が必要です。

🔹 グーを出す回数がマイナスになっていない
🔹 指の本数が $ M $ 本になっている

[Google Colaboratory]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#--------- 入力例1 ----------
n,m =4, 7 # じゃんけん数、じゃんけんで出す指の数の合計
s = 'CGPC' # コンピュータの出すじゃんけんの手
#--------- 入力例2 ----------
# n,m =5, 10 # じゃんけん数、じゃんけんで出す指の数の合計
# s = 'GPCPC' # コンピュータの出すじゃんけんの手
#---------------------------
cnt_g, cnt_p, cnt_c = 0, 0, 0
for s1 in s:
if s1 == 'G':
cnt_g += 1
elif s1 == 'P':
cnt_p += 1
else:
cnt_c += 1

res = 0
for p in range(m // 5 + 1): # グーを出せる回数分ループ
c = (m - p * 5) // 2 # チョキの回数を求める
g = n - p - c # グーの回数を求める
if g < 0: # グーの回数がマイナスの場合パス
pass
elif p * 5 + c * 2 == m: # 指の合計本数が指定回数と同じ場合
x = min(cnt_g, p) + min(cnt_p, c) + min(cnt_c, g)
res = max(res, x)

print('解:', res)

[実行結果(入力例1)]

解:4

パーを1回、チョキを1回、グーを2回だすと、指の本数は全部で7本となり、最大4回 勝つことができます。

[実行結果(入力例2)]

解:3

パーを2回、チョキを0回、グーを3回だすと、指の本数は全部で10本となり、最大3回 勝つことができます。

最安値を求める(動的計画法)

問題

くだもの屋さんで、ミカンが $ x $ 個 $ a $ 円、 $ y $ 個 $ b $ 円、 $ z $ 個 $ c $ 円で売られています。

ミカンの買い方を工夫したとき、$ n $ 個のミカンを買うために必要な金額の最小値はいくらでしょうか。

(買い方を工夫した結果、買ったミカンが $ n + 1 $ 個以上になってもよいものとします。)

[条件]
🔹 $ x < y < z $
🔹 $ 1 \leqq n,x,y,z,a,b,c \leqq 1000 $

解法・ソースコード

memo[n] をちょうど $ n $ 個のミカンを買うのに必要な金額の最小値とすると、memo[0] ~ memo[k-1] と memo[k] の関係は memo[k] = min(memo[k - x] + a, memo[k - y] + b、memo[k - z] + c) となります。

memo を添字が大きい方から memo[k] = min(memo[k], memo[k + 1]) のように書き換えていけば、memo[n] が $ n $ 個以上のミカンを買うのに必要な金額の最小値となります。

$ n $ 個のミカンを最も安く手に入れたいときに $ n+z $ 個以上のりんごを買うのは無意味なので、memo は memo[n + z - 1] 程度まで計算しておけば大丈夫です。

[Google Colaboratory]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#--------- 入力例1 ----------
n = 9 # 買いたいミカン数
x, a = 2, 100 # ミカンの個数と金額
y, b = 3, 125 # ミカンの個数と金額
z, c = 5, 200 # ミカンの個数と金額
#--------- 入力例2 ----------
# n = 145 # 買いたいミカン数
# x, a = 12, 752 # ミカンの個数と金額
# y, b = 102, 2483 # ミカンの個数と金額
# z, c = 703, 2810 # ミカンの個数と金額
#---------------------------
# 大きな数値(デフォルト値)
INF = 999999

# メモ化テーブル
memo = {}
memo[0] = 0

for i in range(1, n + z):
memo[i] = min(memo.get(i, INF), memo.get(i - x, INF) + a)
memo[i] = min(memo.get(i, INF), memo.get(i - y, INF) + b)
memo[i] = min(memo.get(i, INF), memo.get(i - z, INF) + c)

print('解:', min(v for k, v in memo.items() if k >= n))

[実行結果(入力例1)]

解: 375

3個125円のミカンを、3回分買うとトータル9個のミカンを 最小金額 375円 で買うことができます。

[実行結果(入力例2)]

解: 2810

703個2810円のミカンを、1回分買うとトータル145個のミカンを 最小金額 2810円 で買うことができます。

日雇いの仕事(貪欲法)

問題

日雇いの仕事が $ N $ 件あります。

$ i $ 件目の日雇いの仕事を請けて働くと、その $ A_i $ 日後に報酬 $ B_i $ が得られます。

これから日雇いの仕事の中から1日に1件だけ選んで働くことができます。

ただし、一度請けたことのある日雇いの仕事をもう一度選ぶことはできません。

今日から $ M $ 日後まで( $ M $ 日後を含む)に得られる 報酬の合計の最大値 を求めて下さい。

なお、日雇いの仕事は今日(0日後)から請けて働くことができます。

解法・ソースコード

まず報酬を最適化する方法として、次の2つが思い浮かびます。

🔹 報酬が多い仕事から選んでいく。
🔹 報酬を受け取るまでの期間が長い仕事から選んでいく。

しかしこの2つの方法では反例が見つかってうまくいきません。


この問題では、締め切りである $ M $ 日目から $ 0 $ 日目へとさかのぼるように考えるとうまくいきます。

解法としては次の通りです。

$ i = M - 1, M - 2, …, 0 $ に対して

🔹 残っている仕事のうち
🔹 $ A_i \leqq M - i $ である仕事であり
🔹 $ B_i $ が最大のもの

を $ i $ 日目に入れていきます。ただしそのような仕事がない場合はスキップします。


さらに、この解法を高速化するために ヒープ を使います。

$ i = M - 1, M - 2, …, 0 $ に対して

🔹 ヒープに $ A_i \leqq M - i $ であるような仕事を全て挿入する。
🔹 ヒープから $ B_i $ が最大のものを取り出す(ヒープから削除)。ただしヒープが空の場合はスキップする。

[Google Colaboratory]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#--------- 入力例 ----------
m = 4 # M日後
# (days)日後に、(reward)の報酬を受け取れる仕事
jobs = [{'days':4, 'reward':3},
{'days':4, 'reward':1},
{'days':2, 'reward':2}]
#---------------------------
from heapq import heappush, heappop

# days_reward[v]:days[i] = v となる i に対する reward[i] の集合
days_reward = [[] for _ in range(m + 1)]
for job in jobs:
# 報酬の支払いが M 日を超える場合はパス
if job['days'] > m:
pass
else:
# データ登録
days_reward[job['days']].append(job['reward'])

res = 0 # 報酬の最大値
que = [] # ヒープ

# M - 1 日目から 0 日目へとさかのぼる(days_reward[0]から順に見る)
for reward in days_reward:
for reward1 in reward:
# ヒープは小さい順となるので符号反転して追加
heappush(que, reward1 * -1)
if que:
x = heappop(que)
res -= x

print('解:', res)

[実行結果]

解: 5

次のように仕事を請けることで、合計5の報酬を受け取ることができます。

🔹 今日(0日目)に $ 1 $ 件目の仕事を請けます。4日目に報酬 $ 3 $ を受け取れます。
🔹 明日(1日目)に $ 3 $ 件目の仕事を請けます。2日後の3日目に報酬 $ 2 $ を受け取れます。

したがって正解は報酬 $ 5 $ となります。

Boxes and Candies(貪欲法)

貪欲法

貪欲法 とは、$ N $ ステップからなる意思決定問題において、ある特定のルールに基づいて その場での最善の選択 を繰り返していくアルゴリズムです。

問題

$ N $ 個の箱が一列に並んでおり、左から $ i $ 番目の箱には $ a_i $ 個のキャンディーが入っています。

次の操作を何回でも行うことができます。

🔹 キャンディーが1個以上入っている箱を1つ選び、その箱のキャンディを1個食べる。

この操作を何回か行うことで、「どの隣り合う2つの箱についても、それらの箱に入っているキャンディの個数の総和が $ x $ 以下である」という状態にしたいと考えています。

これを達成するために必要な 最小の操作回数 を求めて下さい。

解法・ソースコード

まず、左端の箱に注目してみます。

$ a_0 + a_1 \leqq x $ である場合は、$ a_0 $ を減らす必要はありません。

そして $ a_0 + a_1 > x $ であっても $ a_0 \leqq x $ である限りは $ a_0 $ を減らす必要はあります。

なぜなら $ a_0 $ を減らすよりは $ a_1 $ を減らした法が、$ a_2 $ のことを考えても得だからです。

以上をまとめると次のようにすればよいことが分かります。

🔹 $ a_0 > x $ ならば、$ a_0 = x $ とする。
🔹 各 $ i = 0, 1, …, N - 2 $ に対して、$ a_i + a_{i + 1} > x $ ならば $ a_{i} + a_{i + 1} - x $ だけ減らす。

[Google Colaboratory]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#--------- 入力例 ----------
x = 3
lst = [2, 2, 2]
#---------------------------
n = len(lst) # 箱の個数

res = 0 # 解(最小の操作回数)

# 先頭要素のみ x からの超過分を先に減らしておく。
if lst[0] > x:
res += a[0] - x
a[0] = x

# 左端から順に処理する
for i in range(n - 1):
if lst[i] + lst[i + 1] > x:
res += lst[i] + lst[i + 1] - x
lst[i + 1] = x - lst[i]
print('【最終の状態(それぞれの箱に入っているキャンディの数)】')
print(lst)
print('------------------')
print('解(操作の最小回数):', res)
【最終の状態(それぞれの箱に入っているキャンディの数)】

[2, 1, 2]

------------------

解(操作の最小回数): 1

2番目の箱のキャンディを1個食べる方法が最適で、どの隣接する箱のキャンディの個数の総和も3となります。

したがって、最小の操作回数は1回です。

貪欲法のまとめ

貪欲法 の考え方をまとめます。

🔹 端から考えていき、後によいものを残す。
 ( $ a_0 $ はそのままに $ a_1 $ を減らした方が、後のことを考えるとよい)
🔹 交換しても悪化しない。
 ( $ a_0 $ の代わりに $ a_1 $ の方を減らしても、解が悪化することはない)

Many Requirements(全探索)

問題

正整数 $ N, M, Q $ と、4つの整数の組( $ a_i, b_i, c_i, d_i $ )が $ Q $ 組与えられます。

以下の条件を満たす長さ $ N $ の整列 $ A_1, A_2, …, A_N $ (数列の添字は 1 始まり)を考えます。

🔹 $ A_i $ は正の整数
🔹 $ 1 \leqq A_1 \leqq A_2 \leqq … \leqq A_n \leqq M $

この数列のスコアを「 $ A_{b_i} - A_{a_i} = c_i $ を満たす $ i $ について $ d_i $ の総和 」とします。

数列 $ A $ の最大スコアを求めて下さい。

解法・ソースコード

この問題は、数列 $ A $ を全探索 すれば解くことができます。

次の条件を満たす長さ $ N $ の数列を全列挙します。

🔹 $ 1 \leqq A_i \leqq M $
🔹 $ A_1 \leqq A_2 \leqq … \leqq A_N $

この数列に対しスコアを計算し、最大値を出力すればよいことになります。

数列の全列挙には combinations_with_replacement関数 を使っています。(24行目)

[Google Colaboratory]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from itertools import combinations_with_replacement
#--------- 入力例 ----------
n, m, q = 3, 4, 3
a = [1, 1, 2]
b = [3, 2, 3]
c = [3, 2, 2]
d = [100, 10, 10]
#---------------------------
# 添字(a, b)を0始まりにする
for i in range(q):
a[i] -= 1
b[i] -= 1

# 数列Aのスコアを算出
def calc_score(A):
score = 0
for ai, bi, ci, di in zip(a, b, c, d):
if A[bi] - A [ai] == ci:
score += di
return score

res = 0 # 最大値
# 数列Aを全探索する
for A in combinations_with_replacement(range(1, m + 1), n):
x = calc_score(A)
print(A, x)
res = max(res, x)
print('------------------')
print('解(最大値):', res)

[実行結果]

(1, 1, 1) 0
(1, 1, 2) 0
(1, 1, 3) 10
(1, 1, 4) 100
(1, 2, 2) 0
(1, 2, 3) 0
(1, 2, 4) 110
(1, 3, 3) 10
(1, 3, 4) 110
(1, 4, 4) 100
(2, 2, 2) 0
(2, 2, 3) 0
(2, 2, 4) 10
(2, 3, 3) 0
(2, 3, 4) 0
(2, 4, 4) 10
(3, 3, 3) 0
(3, 3, 4) 0
(3, 4, 4) 0
(4, 4, 4) 0
------------------
解(最大値): 110

$ A = [1, 2, 4] $ または $ A = [1, 3, 4] $ とする場合に最大となり、この数列のスコアは 110点 となります。

I - Coins(動的計画法⑪)

問題

N 枚のコインがあります。 (N は正の奇数)

コインには 1, 2, …, N と番号が振られています。 

各 i (1 ≦ i ≦ N) について、コイン i を投げると、確率 p_i で表が出て、確率 1 − p_i で裏が出ます。

すべてのコインを投げた時に、表の個数が裏の個数を 上回る確率 を求めてください。

解法・ソースコード

メモ化テーブルを i 枚目までで表が j 回出る確率 で作成します。

[Google Colaboratory]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#--------- 入力例1 ----------
coins = [0.30, 0.60, 0.80]
#--------- 入力例2 ----------
# coins = [0.50]
#--------- 入力例3 ----------
# coins = [0.42, 0.01, 0.42, 0.99, 0.42]
#----------------------------
n = len(coins) # コインの枚数

# memo[i, j] = i枚目までで表がj回出る確率
memo = {}
memo[0, 0] = 1 # 初期値(表が出る確率100%)

for i in range(1, n + 1): # コインi枚目
for j in range(n + 1): # 表がj回でる確率
if j == 0: # 必ず裏になる場合
memo[i, 0] = memo.get((i - 1, 0), 0) * (1 - coins[i - 1])
else: # [1つ左上の確率に表の出る確率をかけた数値]+[1つ上の確率に裏の出る確率をかけた数値]
memo[i, j] = memo.get((i - 1, j - 1), 0) * coins[i - 1] + memo.get((i - 1, j), 0) * (1 - coins[i - 1])

print('【メモ化テーブルの内容】')
for i in range(n + 1):
for j in range(n + 1):
print('{:.02f}'.format(memo.get((i, j), 0)), end=' ')
print()
print()

# 表の個数が裏の個数を上回る確率を合算
print('解:', sum(memo[n, j] for j in range(n//2 + 1, n + 1)))

実行結果に表示される【メモ化テーブルの内容】の見方ですが、一番左上から一番右下への確率は、すべてのコインが表 になる確率です。

一方、一番左上から一番左下への確率は、すべてのコインが裏 になる確率を表します。

[実行結果(入力例1)]

【メモ化テーブルの内容】
1.00 0.00 0.00 0.00 
0.70 0.30 0.00 0.00 
0.28 0.54 0.18 0.00 
0.06 0.33 0.47 0.14 

解: 0.612

表の個数が裏の個数を上回るような各ケースの確率を計算すると、次のようになります。

  • (コイン1,コイン2,コイン3)=(表,表,表) となる確率は、0.3×0.6×0.8=0.144
  • (コイン1,コイン2,コイン3)=(裏,表,表) となる確率は、0.7×0.6×0.8=0.336
  • (コイン1,コイン2,コイン3)=(表,裏,表) となる確率は、0.3×0.4×0.8=0.096
  • (コイン1,コイン2,コイン3)=(表,表,裏) となる確率は、0.3×0.6×0.2=0.036

よって、表の個数が裏の個数を上回る確率は、0.144 + 0.336 + 0.096 + 0.036 = 0.612 です。

[実行結果(入力例2)]

【メモ化テーブルの内容】
1.00 0.00 
0.50 0.50 

解: 0.5

表になる確率が0.5(50%)のコインを1枚だけ投げるので、正解は明らかに 0.5(50%) です。

[実行結果(入力例3)]

【メモ化テーブルの内容】
1.00 0.00 0.00 0.00 0.00 0.00 
0.58 0.42 0.00 0.00 0.00 0.00 
0.57 0.42 0.00 0.00 0.00 0.00 
0.33 0.49 0.18 0.00 0.00 0.00 
0.00 0.33 0.48 0.18 0.00 0.00 
0.00 0.20 0.42 0.31 0.08 0.00 

解: 0.3821815872

【メモ化テーブルの内容】で見ると、一番下の段の右半分を合算した 0.3821815872 が解となります。

(【メモ化テーブルの内容】では簡易化するため小数点第2までしか表示していません。端数を含めて計算すると0.3821815872となります。)