本記事の構成および論理分析にはAI(人工知能)を使用しています。情報の正確性は、システム管理者(UNIXユーザー)による手動検証済みです。
【ナップサック問題で遊ぼう】Pythonで全探索から動的計画法まで|定番問題をコードパズルにしてみた | UNIX Cafe

アルゴリズムを学んでいると、よく登場する定番問題に 「ナップサック問題」 があります。
名前だけ聞くと少し難しそうですが、やっていることはとても身近です。重さと価値の違う品物がいくつかあり、ナップサックに入れられる重さには上限があります。その中で、できるだけ価値が高くなるように品物を選ぶ、という小さなパズルです。
今回は、このナップサック問題を Python で遊びながら見ていきます。最初は全部の選び方を試す素直な方法から始めて、最後は動的計画法(DP)で効率よく解くところまで進みます。
ナップサック問題ってなに?
本題に入る前に、少しだけ 0/1ナップサック問題 のルールを整理しておきます。
たとえば、次のような品物があるとします。
| 品物 | 重さ | 価値 |
|---|---|---|
| A | 2 | 3 |
| B | 1 | 2 |
| C | 3 | 4 |
| D | 2 | 2 |
ナップサックの容量は 5 です。
このとき、合計の重さが 5 以下になるように品物を選び、その中で価値の合計を最大にします。
- Aを入れる? 入れない?
- Bを入れる? 入れない?
- Cを入れる? 入れない?
- Dを入れる? 入れない?
ひとつひとつの品物について「選ぶ」か「選ばない」かを決めるので、これは 0/1ナップサック問題 と呼ばれます。同じ品物を何度も入れることはできません。
Pythonでは、重さと価値をリストで表せます。
weights = [2, 1, 3, 2]
values = [3, 2, 4, 2]
capacity = 5
n = len(weights)目標は、capacity を超えない範囲で、価値の合計を最大にすることです。
ナップサック問題の基本ルール
今回使うルールを、もう少しゲームっぽく並べるとこうなります。
| ルール | 内容 |
|---|---|
| 品物には重さがある | バッグの容量を超えると失敗 |
| 品物には価値がある | 価値の合計をできるだけ大きくしたい |
| 各品物は1回だけ | 同じ品物を何度も選べない |
| 答えは1つとは限らない | 同じ最大価値になる選び方が複数あることもある |
今回の例では、AとCを選ぶと重さは 2 + 3 = 5、価値は 3 + 4 = 7 になります。
また、A、B、Dを選んでも重さは 2 + 1 + 2 = 5、価値は 3 + 2 + 2 = 7 です。
どちらも容量ぴったりで、価値は 7。つまり、最大価値になる選び方が複数あります。
その1:まずは全部試してみるナップサック(全探索)
まずは一番わかりやすい方法から見てみましょう。
品物が4個なら、それぞれについて「選ぶ」「選ばない」の2通りがあります。つまり、全部で 2 * 2 * 2 * 2 = 16 通りです。
16通りくらいなら、全部試してしまえば答えが出ます。
今回は、まず knapsack_bruteforce.py というファイルを作るイメージで、中心になる関数を見ていきます。
def knapsack_bruteforce(weights, values, capacity):
n = len(weights)
best = 0
for mask in range(1 << n):
total_weight = 0
total_value = 0
for i in range(n):
if mask & (1 << i):
total_weight += weights[i]
total_value += values[i]
if total_weight <= capacity and total_value > best:
best = total_value
return bestこのコードは、すべての選び方をビットで表して調べています。
関数の引数
def knapsack_bruteforce(weights, values, capacity):weights: 各品物の重さvalues: 各品物の価値capacity: ナップサックに入れられる最大重量
Pythonのリストは len(weights) で要素数を調べられます。そのため、品物の数 n は関数の中で求めています。
ビットで選び方を表す
for mask in range(1 << n):1 << n は、2 の n 乗を意味します。
品物が n 個あるとき、それぞれの品物について「入れる / 入れない」の2通りがあるので、組み合わせは全部で 2^n 通りあります。
品物が4個なら 16 通りなので、mask は 0 から 15 まで動きます。
| mask | 2進数 | 選び方のイメージ |
|---|---|---|
| 0 | 0000 | 何も選ばない |
| 1 | 0001 | Aだけ選ぶ |
| 3 | 0011 | AとBを選ぶ |
| 5 | 0101 | AとCを選ぶ |
| 15 | 1111 | 全部選ぶ |
各ビットが 1 ならその品物を選び、0 なら選ばない、と考えます。
if mask & (1 << i):
total_weight += weights[i]
total_value += values[i]mask & (1 << i) は、i 番目のビットが立っているかどうかを調べています。立っていれば、その品物を選んでいるという意味です。
全探索版の完成コード
def knapsack_bruteforce(weights, values, capacity):
n = len(weights)
best = 0
for mask in range(1 << n):
total_weight = 0
total_value = 0
for i in range(n):
if mask & (1 << i):
total_weight += weights[i]
total_value += values[i]
if total_weight <= capacity and total_value > best:
best = total_value
return best
weights = [2, 1, 3, 2]
values = [3, 2, 4, 2]
capacity = 5
answer = knapsack_bruteforce(weights, values, capacity)
print(f"最大価値: {answer}")実行すると、最大価値は 7 になります。
python3 knapsack_bruteforce.py最大価値: 7全探索の弱点
全探索は考え方がとても素直です。ただし、品物の数が増えると組み合わせの数が急激に増えます。
| 品物の数 | 組み合わせ数 |
|---|---|
| 4 | 16 |
| 10 | 1024 |
| 20 | 1,048,576 |
| 30 | 1,073,741,824 |
品物が30個になると、10億通り以上を調べることになります。ここから先は、もう少し賢い方法がほしくなります。
その2:動的計画法(DP)で解く
次に、ナップサック問題の定番解法である 動的計画法(DP) を使います。
DPでは、「ここまでの品物を見たとき、容量がこの重さなら、最大価値はいくつか」という小さな答えを表に保存していきます。
たとえば、dp[i][w] を次のように考えます。
i個目までの品物を見て、容量wまで使えるときの最大価値
品物を1つずつ見ながら、「この品物を入れない場合」と「この品物を入れる場合」を比べます。
DPの更新ルール
品物 i の重さを weights[i - 1]、価値を values[i - 1] とすると、次のように更新できます。
dp[i][w] = dp[i - 1][w]これは「品物 i を入れない」場合です。前の行の答えをそのまま使います。
if w >= weights[i - 1]:
candidate = dp[i - 1][w - weights[i - 1]] + values[i - 1]
dp[i][w] = max(dp[i][w], candidate)これは「品物 i を入れる」場合です。入れられる重さが残っているなら、入れたときの価値を計算し、より大きいほうを採用します。
DP版の完成コード
Pythonでは、リスト内包表記を使うと2次元のDP表を作りやすいです。
def knapsack_dp(weights, values, capacity):
n = len(weights)
dp = [[0] * (capacity + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for w in range(capacity + 1):
dp[i][w] = dp[i - 1][w]
if w >= weights[i - 1]:
candidate = dp[i - 1][w - weights[i - 1]] + values[i - 1]
dp[i][w] = max(dp[i][w], candidate)
return dp[n][capacity]
weights = [2, 1, 3, 2]
values = [3, 2, 4, 2]
capacity = 5
answer = knapsack_dp(weights, values, capacity)
print(f"最大価値: {answer}")実行結果は、全探索と同じく 7 です。
python3 knapsack_dp.py最大価値: 7その3:1次元DPでメモリを節約する
さきほどのDPは、dp[n + 1][capacity + 1] という2次元の表を使いました。考え方はわかりやすいのですが、容量が大きくなるとメモリをたくさん使います。
実は、0/1ナップサック問題では dp[w] だけを持つ1次元DPにもできます。
for i in range(n):
for w in range(capacity, weights[i] - 1, -1):
dp[w] = max(dp[w], dp[w - weights[i]] + values[i])ここで大事なのは、w を大きいほうから小さいほうへ動かすことです。
小さいほうから更新してしまうと、同じ品物を同じ回の中で何度も使ったような状態になります。0/1ナップサックでは各品物は1回だけなので、後ろから更新します。
1次元DP版の完成コード
def knapsack_dp_1d(weights, values, capacity):
n = len(weights)
dp = [0] * (capacity + 1)
for i in range(n):
for w in range(capacity, weights[i] - 1, -1):
dp[w] = max(dp[w], dp[w - weights[i]] + values[i])
return dp[capacity]
weights = [2, 1, 3, 2]
values = [3, 2, 4, 2]
capacity = 5
answer = knapsack_dp_1d(weights, values, capacity)
print(f"最大価値: {answer}")こちらも答えは 7 です。
python3 knapsack_dp_1d.py最大価値: 7全探索とDPの違い
最後に、全探索とDPの違いを整理しておきます。
| 方法 | 考え方 | 計算量の目安 | 特徴 |
|---|---|---|---|
| 全探索 | すべての選び方を試す | O(n * 2^n) | わかりやすいが品物が増えると重い |
| 2次元DP | 品物数と容量で表を作る | O(n * capacity) | 理解しやすく、途中の状態も見やすい |
| 1次元DP | 容量だけのリストで更新する | O(n * capacity) | メモリを節約できる |
DPは、容量 capacity が大きすぎる場合には注意が必要ですが、全探索よりずっと現実的に使える場面が多いです。
Pythonで書くときの注意点
- リストの要素数は
len(weights)で調べられる 1 << nを使う全探索は、nが大きいと実行時間が急に増える- 2次元DPのリストは大きくなりやすいので、容量が大きいときは1次元DPも検討する
- 1次元DPでは、容量
wを後ろから更新することが重要
まとめ
ナップサック問題は、アルゴリズムの考え方がぎゅっと詰まった定番問題です。
- 全探索では、すべての選び方をビットで表して試せる
- 品物が増えると、全探索は急に重くなる
- DPを使うと、同じような計算を表に保存して効率よく解ける
- Pythonではリストを使って、DPの表や配列を短く書ける
最初は全探索で「全部試す」感覚をつかみ、そのあとDPで「前に計算した答えを使い回す」感覚に進むと、ナップサック問題はかなり理解しやすくなります。
C言語で学ぶナップサック問題
C言語で配列やビット演算を使って解きたい場合は、こちらの記事も参考にしてください。









