日雇いの仕事(貪欲法)

問題

日雇いの仕事が $ 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となります。)

H - Grid 1(動的計画法⑩)

問題

縦 H 行、横 W 列のグリッドがあります。 

上から i 行目、左から j 列目のマスを (i,j) で表します。

各 i, j ( 1 ≦ i ≦ H, 1 ≦ j ≦ W) について、マス (i, j) の情報が文字 a[i, j] によって与えられます。

a[i, j] が . ならばマス (i,j) は空マスであり、a[i, j] が # ならばマス (i, j) は壁のマスです。

マス (1, 1) および (H, W) は空のマスであることが保証されています。

マス (1, 1) から出発し、右または下に隣り合う空マスへの移動を繰り返すことで、マス (H,W) まで辿り着こうとしています。

マス (1, 1) から (H, W) までの経路は何通りあるでしょうか。

答えは非常に大きくなりうるので、10**9 + 7 で割った余りを求めてください。

解法・ソースコード

単純に 左上から右下までの経路 を求めるという問題で、移動するたびにカウントアップすれば経路の数を求めることができます。

もちろん移動先が壁の場合は、カウントアップしません。

また、問題文の最後にあるように、109 + 7を超えた場合は余りを求めるために 109 + 7を引いています。

(最後に 10**9 + 7 で割ってその余りを求める方法もありますが、カウントアップ途中で数字が大きくなりオーバーフローする可能性があります)

[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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#--------- 入力例1 ----------
mp = ['...#',
'.#..',
'....']
#--------- 入力例2 ----------
# mp = ['..',
# '#.',
# '..',
# '.#',
# '..']
#--------- 入力例3 ----------
# mp = ['..#..',
# '.....',
# '#...#',
# '.....',
# '..#..']
#--------- 入力例4 ----------
# mp = ['....................',
# '....................',
# '....................',
# '....................',
# '....................',
# '....................',
# '....................',
# '....................',
# '....................',
# '....................',
# '....................',
# '....................',
# '....................',
# '....................',
# '....................',
# '....................',
# '....................',
# '....................',
# '....................',
# '....................']
#----------------------------
H, W = len(mp), len(mp[0])
MOD = 10**9 + 7

# メモ化テーブル初期化
memo = [[0] * W for _ in range(H)]
# 初期条件
memo[0][0] = 1

# 全マス目を探索
for i in range(H):
for j in range(W):
if i + 1 < H and mp[i + 1][j] == '.':
memo[i + 1][j] += memo[i][j] # カウントアップ
# 計算の途中で余りをとる
if memo[i + 1][j] >= MOD:
memo[i + 1][j] -= MOD
if j + 1 < W and mp[i][j + 1] == '.':
memo[i][j + 1] += memo[i][j] # カウントアップ
# 計算の途中で余りをとる
if memo[i][j + 1] >= MOD:
memo[i][j + 1] -= MOD

print('解:', memo[H - 1][W - 1])

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

解: 3

経路は 3 通りあります。

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

解: 0

経路が存在しません。

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

解: 24

経路は 24 通りあります。

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

解: 345263555

経路は 345263555 通りあります。

途中で 10**9 + 7 を超えているため、その余りが経路数となります。

Longest Path(トポロジカルソート)

トポロジカルソート

トポロジカルソート は、グラフ理論において、有向非巡回グラフの各ノードを順序付けして、どのノードもその出力辺の先のノードより前にくるように並べることです。

有向非巡回グラフは必ず トポロジカルソート することができます。

タスク間に依存関係がある場合、どんな順番でタスクをこなせば良いかを決めるのに役立つアルゴリズムです。

問題

頂点数がN、辺の数がMの有向グラフ G があります。 

頂点には 1,2,…,N と番号が振られています。 

各 i (1 ≦ i ≦ M) について、i 番目の有向辺は頂点 x_i から y_i へ張られています。

G は有向閉路を含みません。

G の有向パスのうち、最長のものの長さを求めてください。 

(有向パスの長さとは、有向パスに含まれる辺の本数のことです。)

解法・ソースコード

この問題は、頂点を トポロジカルソート で並べ替えて、その順番に有向パスでの通過数をカウントすることで解くことができます。

[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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
#--------- 入力例1 ----------
# ノードの数
n = 4
# 全エッジ( x → y )
lst = [(1, 2),
(1, 3),
(3, 2),
(2, 4),
(3, 4)]
#--------- 入力例2 ----------
# # ノードの数
# n = 6
# # 全エッジ( x → y )
# lst = [(2, 3),
# (4, 5),
# (5, 6)]
#--------- 入力例3 ----------
# # ノードの数
# n = 5
# # 全エッジ( x → y )
# lst = [(5, 3),
# (2, 3),
# (2, 4),
# (5, 2),
# (5, 1),
# (1, 4),
# (4, 3),
# (1, 3)]
#----------------------------
from collections import defaultdict

# トポロジーソートを行うクラス
class Graph:
def __init__(self,vertices):
self.graph = defaultdict(list)
self.V = vertices

def addEdge(self,u,v):
self.graph[u].append(v)

def topologicalSortUtil(self, v, visited,stack):
visited[v] = True
for i in self.graph[v]:
if visited[i] == False:
self.topologicalSortUtil(i,visited,stack)

stack.insert(0,v)

def topologicalSort(self):
visited = [False]*self.V
stack =[]

for i in range(self.V):
if visited[i] == False:
self.topologicalSortUtil(i,visited,stack)
return stack

g = Graph(n + 1) # 0スタートなので頂点を1つ多めに
for a, b in lst:
g.addEdge(a, b) # 有向パス追加

print ("トポロジカルソートの結果:", g.topologicalSort())

# トポロジカルソート順に通過数をカウント
memo = {} # カウント(キー:頂点番号、バリュー:通過数)
for x in list(g.topologicalSort()):
for a, b in lst:
if x == a:
memo[b] = memo.get(a, 0) + 1

print('解:', max(memo.values()))

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

トポロジカルソートの結果: [1, 3, 2, 4, 0]

解: 3

1→3→2→4 の有向パスが最長になります。

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

トポロジカルソートの結果: [4, 5, 6, 2, 3, 1, 0]

解: 2

4→5→6 の有向パスが最長になります。

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

トポロジカルソートの結果: [5, 2, 1, 4, 3, 0]

解: 3

5→2→4→35→1→4→3 の有向パスが最長になります。

F - LCS(動的計画法⑨)

問題

文字列 a および b が与えられます。

a の部分列かつ b の部分列であるような文字列のうち、最長のものをひとつ求めてください。

【注釈】

文字列 x の 部分列 とは、x から 0 個以上の文字を取り除いた後、

残りの文字を元の順序で連結して得られる文字列のことです。

解法・ソースコード

最長共通部分列 の長さを求める問題は以前の記事で解いています。

最長共通部分列(動的計画法③)- https://ailogsite.netlify.app/2022/12/16/2022/1216/

今回は、さらに「最長共通部分列を具体的にどれか1つ」求める方法を考えます。

基本的な考え方としては メモ化テーブルの 終端ノード memo[(len(a), len(b)] から順に どのノードから更新されて来たのか、その元となる部分を辿って行く ことになります。

メモ化テーブルの値を見ながら、今いる ノード (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
30
31
32
33
34
35
36
37
38
39
40
#---------- 入力例1 ----------
a = 'axyb'
b = 'abyxb'
#---------- 入力例2 ----------
# a = 'aa'
# b = 'xayaz'
#---------- 入力例3 ----------
# a = 'a'
# b = 'z'
#---------- 入力例4 ----------
# a = 'abracadabra'
# b = 'avadakedavra'
#-----------------------------
# メモ化テーブル
memo = {}
x = ''
for i, a1 in enumerate(a):
for j, b1 in enumerate(b):
# print(i,j,a1,b1)
if a1 == b1:
# print(' #', a1,i,j)
memo[i + 1, j + 1] = memo.get((i, j), 0) + 1
else:
memo[i + 1, j + 1] = max(memo.get((i, j + 1), 0), memo.get((i + 1, j), 0))
print('長さ:', memo[(len(a), len(b))])

# メモ化テーブルの値を見ながら、今いるノード (i,j)(i,j) がどのノードから更新されて来たのかを特定する
i, j = len(a), len(b)

res = '' # 求める最長の文字列
while i > 0 and j > 0:
if memo.get((i, j), 0) == memo.get((i - 1, j), 0): # (i-1, j) -> (i, j) と更新されていた場合
i -= 1
elif memo.get((i, j), 0) == memo.get((i, j - 1), 0): # (i, j-1) -> (i, j) と更新されていた場合
j -= 1
else: # (i-1, j-1) -> (i, j) と更新されていた場合
res = a[i - 1] + res # 求める文字列を復元する a[j - 1]でも可
i -= 1
j -= 1
print('解:', res)

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

長さ: 3
解: axb

答えは axb または ayb です。 どちらを出力しても正解です。

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

長さ: 2
解: aa

答えは aa となります。

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

長さ: 0   
解: 

答えは (空文字列) です。

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

長さ: 7
解: aaadara

答えは aaadara となります。

ナップサック問題(動的計画法⑧)

問題

N 個の品物があります。 品物には 1,2,…,N と番号が振られています。

各 i (1≦i≦N) について、品物 i の重さは w_i で、価値は v_iです。

N 個の品物のうちいくつかを選び、ナップサックに入れて持ちかえります。 

ナップサックの容量は W であり、持ち帰る品物の重さの総和は W 以下でなければなりません。

持ち帰る品物の価値の総和の最大値を求めてください。

解法・ソースコード

今回は、もっとも単純な ナップサック問題 のおさらいです。

品物ごと許容範囲の重さごと にループを回して、品物を入れるか入れないかを選択し、価値の高くなる方の選択結果を メモ化テーブル に格納していきます。

[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
33
34
35
36
37
38
39
40
41
42
43
44
45
#--------- 入力例1 ----------
w = 8
items = [{'weight':3, 'value':30},
{'weight':4, 'value':50},
{'weight':5, 'value':60}]
#--------- 入力例2 ----------
# w = 5
# items = [{'weight':1, 'value':1000000000},
# {'weight':1, 'value':1000000000},
# {'weight':1, 'value':1000000000},
# {'weight':1, 'value':1000000000},
# {'weight':1, 'value':1000000000}]
#--------- 入力例3 ----------
# w = 15
# items = [{'weight':6, 'value':5},
# {'weight':5, 'value':6},
# {'weight':6, 'value':4},
# {'weight':6, 'value':6},
# {'weight':3, 'value':5},
# {'weight':7, 'value':2}]
#----------------------------
n = len(items) # 品物の総数

# メモ化テーブル(動的計画法用)
memo = {}

for i, item in enumerate(items): # 品物ごとにループ
for j in range(w + 1): # 重さ+1までループ
if j < items[i]['weight']: # 重さが許容範囲外の場合
# 品物を入れない
memo[i + 1, j] = memo.get((i, j), 0)
else: # 重さが許容範囲内の場合
# 品物を入れない場合
a = memo.get((i, j), 0)
# 品物を入れる場合(許容範囲の重さを減らし、価値を増やす)
b = memo.get((i, j - items[i]['weight']), 0) + items[i]['value']
memo[i + 1, j] = max(a, b) # 価値が高いほうを選択

print('【メモ化テーブルの内容】')
for i, item in enumerate(items): # 品物ごとにループ
for j in range(w): # 重さごとにループ
print(str(memo[i+1, j+1]).rjust(2, ' '), end=' ')
print()
print('-----------------------')
print('解(価値の総和の最大値):', memo[len(items), w])

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

【メモ化テーブルの内容】
 0  0 30 30 30 30 30 30 
 0  0 30 50 50 50 80 80 
 0  0 30 50 60 60 80 90 
-----------------------
解(価値の総和の最大値): 90

品物 1,3 を選ぶと、重さの総和は 3+5=8 となり、価値の総和は 30+60=90 となります。

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

【メモ化テーブルの内容】
1000000000 1000000000 1000000000 1000000000 1000000000 
1000000000 2000000000 2000000000 2000000000 2000000000 
1000000000 2000000000 3000000000 3000000000 3000000000 
1000000000 2000000000 3000000000 4000000000 4000000000 
1000000000 2000000000 3000000000 4000000000 5000000000 
-----------------------
解(価値の総和の最大値): 5000000000

全部の品物 を持っていくことができるので、価値の総和は 5000000000 となります。

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

【メモ化テーブルの内容】
 0  0  0  0  0  5  5  5  5  5  5  5  5  5  5 
 0  0  0  0  6  6  6  6  6  6 11 11 11 11 11 
 0  0  0  0  6  6  6  6  6  6 11 11 11 11 11 
 0  0  0  0  6  6  6  6  6  6 12 12 12 12 12 
 0  0  5  5  6  6  6 11 11 11 12 12 12 17 17 
 0  0  5  5  6  6  6 11 11 11 12 12 12 17 17 
-----------------------
解(価値の総和の最大値): 17

品物 2,4,5 を選ぶと、重さの総和は 5+6+3=14 で価値の総和は 6+6+5=17 となります。

夏休み(動的計画法⑦)

問題

 N 日の夏休みがあります。
 
各 i (1 ≦ i ≦ N) について、i 日目には次の活動のうちひとつを選んで行います。

 A: 海で泳ぐ。 幸福度 a_i を得る。

 B: 山で虫取りをする。 幸福度 b_i を得る。
 
 C: 家で宿題をする。 幸福度 c_i を得る。

2 日以上連続で同じ活動を行うことはできません。

幸福度の 総和の最大値 を求めてください。

解法・ソースコード

N日目にそれぞれの活動を行った場合の幸福度を管理するためのメモ化テーブル(動的計画法のための2次元配列)を用意します。

i - 1日目に jの活動、i日目に kの活動 を行う場合の 幸福度memo1 を計算し、一番大きいものを memo[i + 1][k] に代入します。

[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
#--------- 入力例1 ----------
lst = [(10, 40, 70),
(20, 50, 80),
(30, 60, 90)]
#--------- 入力例2 ----------
# lst = [(100, 10, 1)]
#--------- 入力例3 ----------
# lst = [(6, 7, 8),
# (8, 8, 3),
# (2, 5, 2),
# (7, 8, 6),
# (4, 6, 8),
# (2, 3, 4),
# (7, 5, 1)]
#----------------------------
n = len(lst) # 夏休みの日数
# メモ化テーブル(動的計画法用の2次元配列。第1引数:夏休みの日数+1日、第2引数:3つの活動)
memo = [[0 for i in range(3)] for j in range(n + 1)]

for i in range(n): # 夏休みの日数分ループ
for j in range(3): # 前日の活動
for k in range(3): # 当日の活動
if j == k: # 前日と同じ活動はできない
continue
# i-1日目(前日)にjの活動、i日目にkの活動を行う場合の幸福度
memo1 = memo[i][j] + lst[i][k]
# memo[i + 1][k]はi日目にkを行ったときの得られる累積の最大幸福度
memo[i + 1][k] = max(memo[i + 1][k], memo1)

for i in range(n + 1):
print(f'{i}日目の累積最大幸福度', memo[i])
print('解(幸福度の総和の最大値):',max(memo[n]))

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

0日目の累積最大幸福度 [0, 0, 0]

1日目の累積最大幸福度 [10, 40, 70]

2日目の累積最大幸福度 [90, 120, 120]

3日目の累積最大幸福度 [150, 180, 210]

解(幸福度の総和の最大値): 210

C, B, C の順に活動を行うと、幸福度の総和は 70 + 50 + 90 = 210 となります。

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

0日目の累積最大幸福度 [0, 0, 0]

1日目の累積最大幸福度 [100, 10, 1]

解(幸福度の総和の最大値): 100

A の活動を行うと最大幸福になるのは明らかです。

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

0日目の累積最大幸福度 [0, 0, 0]

1日目の累積最大幸福度 [6, 7, 8]

2日目の累積最大幸福度 [16, 16, 10]

3日目の累積最大幸福度 [18, 21, 18]

4日目の累積最大幸福度 [28, 26, 27]

5日目の累積最大幸福度 [31, 34, 36]

6日目の累積最大幸福度 [38, 39, 38]

7日目の累積最大幸福度 [46, 43, 40]

解(幸福度の総和の最大値): 46

C, A, B, A, C, B, A の順に活動を行えば最大幸福になります。

文字列判定(動的計画法⑥)

問題

英小文字の文字列  があります。

空文字  に対して次の操作を繰り返すことで、S=T である状態にできるかどうかを判定してください。

「 の末尾に"dream"、"dreamer"、"erase"、"eraser"のいずれかを追加する」

解法・ソースコード

まず 再帰関数 を用いた 全探索解法 を考えます。

次のように 再帰関数 を定義します。

rec(i) ← 文字列Sの最初のi文字分が、"dream"、"dreamer"、"erase"、"eraser"の連結で作れるかどうかを表すブール値(True/False)

文字列Sの長さをNとしたとき、rec(n)が最終的な答えとなります。

rec(i)を求めるために、次の4パターンに分けて考えます。

  • s[0:i]の末尾を”erase”で区切る場合
    rec(i - 5)がTrue かつ s[0:1]の最後の5文字が”erase”であるならば、rec(i)はTrue
  • s[0:i]の末尾を”eraser”で区切る場合
    rec(i - 6)がTrue かつ s[0:1]の最後の5文字が”eraser”であるならば、rec(i)はTrue
  • s[0:i]の末尾を”dream”で区切る場合
    rec(i - 5)がTrue かつ s[0:1]の最後の5文字が”dream”であるならば、rec(i)はTrue
  • s[0:i]の末尾を”dreamer”で区切る場合
    rec(i - 7)がTrue かつ s[0:1]の最後の5文字が”dreamer”であるならば、rec(i)はTrue

この 再帰関数 rec(i) を踏まえて、動的計画法 を表す 配列memo を次のように定義します。

memo[i] ← s[0:i]が、"dream"、"dreamer"、"erase"、"eraser"の連結で作れるどうかを表すブール値(True/False)

memo[0]、memo[1]、…、memo[n] の値を順番に求めていきます。

[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
#---------- 入力例 ----------
s = 'erasedream'
#----------------------------
n = len(s) # 文字列の長さ
memo = [False] * (n + 1) # メモ化テーブル(動的計画法用の配列)
memo[0] = True

# memo[0]、memo[1]、…、memo[n]を順に求める
for i in range(n + 1):
if i >= 5 and memo[i - 5] and s[i - 5:i] == 'erase':
print(i, 'erase')
memo[i] = True
if i >= 6 and memo[i - 6] and s[i - 6:i] == 'eraser':
print(i, 'eraser')
memo[i] = True
if i >= 5 and memo[i - 5] and s[i - 5:i] == 'dream':
print(i, 'dream')
memo[i] = True
if i >= 7 and memo[i - 7] and s[i - 7:i] == 'dreamer':
print(i, 'dreamer')
memo[i] = True
print('memo', memo)
print('解:Yes' if memo[n] else '解:No')

[実行結果]

5 erase

10 dream

memo [True, False, False, False, False, True, False, False, False, False, True]

解:Yes

“erase” + “dream” で、“erasedream” という文字列が作成できるので、正解は “Yes”(作成できる) となります。