Files
hello-algo/ja/docs/chapter_dynamic_programming/knapsack_problem.md
T
Yudong Jin 7257641e0e Retranslate the complete Japanese online edition from Chinese (#2008)
* Retranslate the complete Japanese online edition from Chinese

* Fit Japanese homepage labels and localize heading permalinks

* Point Japanese contribution instructions to the Japanese preview

* Replace locale conditionals with multilingual UI tables
2026-10-30 02:12:26 +08:00

169 lines
9.2 KiB
Markdown

# 0-1 ナップサック問題
ナップサック問題は動的計画法の入門に適しており、最もよく登場する問題の形式です。0-1 ナップサック問題、個数制限なしナップサック問題、個数制限付きナップサック問題など、多くの変種があります。
この節では、まず最も一般的な 0-1 ナップサック問題を解きます。
!!! question
$n$ 個の品物があり、$i$ 番目の重さは $wgt[i-1]$、価値は $val[i-1]$ です。容量 $cap$ のナップサックがあり、各品物は 1 回だけ選べます。容量の制限内で入れられる品物の最大価値を求めてください。
下図のように、品物の番号 $i$ は $1$ から始まり、配列のインデックスは $0$ から始まるので、品物 $i$ の重さと価値は $wgt[i-1]$ と $val[i-1]$ に対応します。
![0-1 ナップサック問題のデータ例](knapsack_problem.assets/knapsack_example.png)
0-1 ナップサック問題は、$n$ 回の選択からなる過程と捉えられます。各品物について、入れないか入れるかの二つを選ぶため、決定木モデルに当てはまります。
目標が「容量の制限内で入れられる品物の最大価値」を求めることなので、動的計画法の問題である可能性が高いと考えられます。
**第 1 段階:各回の選択を考えて状態を定義し、$dp$ テーブルを作る**
各品物を入れなければ容量は変わらず、入れれば容量が減ります。そこで状態を、現在の品物の番号 $i$ とナップサックの容量 $c$ からなる $[i, c]$ と定義します。
状態 $[i, c]$ に対応する部分問題は、**最初の $i$ 個の品物を使い、容量 $c$ のナップサックで得られる最大価値**で、その解を $dp[i, c]$ とします。
求めるのは $dp[n, cap]$ なので、$(n+1) \times (cap+1)$ の二次元の $dp$ テーブルが必要です。
**第 2 段階:最適部分構造を見つけ、状態遷移式を導く**
品物 $i$ をどうするか決めると、残るのは最初の $i-1$ 個に関する部分問題です。次の二つの場合に分かれます。
- **品物 $i$ を入れない**:容量は変わらず、状態は $[i-1, c]$ になります。
- **品物 $i$ を入れる**:容量は $wgt[i-1]$ 減り、価値は $val[i-1]$ 増え、状態は $[i-1, c-wgt[i-1]]$ になります。
ここから最適部分構造がわかります。**最大価値 $dp[i, c]$ は、品物 $i$ を入れない場合と品物 $i$ を入れる場合のうち、大きい方の価値です**。したがって、状態遷移式は次のようになります。
$$
dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
$$
現在の品物の重さ $wgt[i - 1]$ が残り容量 $c$ を超える場合は、入れない方しか選べないことに注意してください。
**第 3 段階:境界条件と状態遷移の順序を決める**
品物がない場合、または容量が $0$ の場合、最大価値は $0$ です。つまり、先頭列 $dp[i, 0]$ と先頭行 $dp[0, c]$ はすべて $0$ です。
現在の状態 $[i, c]$ は、上の状態 $[i-1, c]$ と左上の状態 $[i-1, c-wgt[i-1]]$ から遷移するため、二重ループで $dp$ テーブル全体を順方向に走査すれば十分です。
以上の分析に基づき、全探索、メモ化探索、動的計画法を順に実装します。
### 方法 1:全探索
探索のコードには、次の要素があります。
- **再帰の引数**:状態 $[i, c]$ です。
- **戻り値**:部分問題の解 $dp[i, c]$ です。
- **終了条件**:品物の番号が範囲外の $i = 0$ になった場合、または残り容量が $0$ になった場合は、再帰を終了して価値 $0$ を返します。
- **枝刈り**:現在の品物の重さが残り容量を超える場合は、入れない方だけを選びます。
```src
[file]{knapsack}-[class]{}-[func]{knapsack_dfs}
```
下図のように、各品物から「選ばない」「選ぶ」の二つの探索分岐が生じるため、時間計算量は $O(2^n)$ です。
再帰木には、$dp[1, 10]$ などの重複した部分問題があります。品物が多く、容量が大きく、特に同じ重さの品物が多いと、重複する部分問題の数は大幅に増えます。
![0-1 ナップサック問題の全探索の再帰木](knapsack_problem.assets/knapsack_dfs.png)
### 方法 2:メモ化探索
重複した部分問題を 1 回だけ計算するため、記録用リスト `mem` に解を保存します。`mem[i][c]` は $dp[i, c]$ に対応します。
メモ化すると、**時間計算量は部分問題の数で決まり**、$O(n \times cap)$ になります。実装は次のとおりです。
```src
[file]{knapsack}-[class]{}-[func]{knapsack_dfs_mem}
```
下図に、メモ化探索で枝刈りされる探索分岐を示します。
![0-1 ナップサック問題のメモ化探索の再帰木](knapsack_problem.assets/knapsack_dfs_mem.png)
### 方法 3:動的計画法
動的計画法は、本質的には状態遷移によって $dp$ テーブルを埋める過程です。コードを次に示します。
```src
[file]{knapsack}-[class]{}-[func]{knapsack_dp}
```
下図のように、時間計算量と空間計算量は、どちらも配列 `dp` の大きさで決まり、$O(n \times cap)$ です。
=== "<1>"
![0-1 ナップサック問題を動的計画法で解く流れ](knapsack_problem.assets/knapsack_dp_step1.png)
=== "<2>"
![0-1 ナップサック問題を動的計画法で解く流れ:手順 2](knapsack_problem.assets/knapsack_dp_step2.png)
=== "<3>"
![0-1 ナップサック問題を動的計画法で解く流れ:手順 3](knapsack_problem.assets/knapsack_dp_step3.png)
=== "<4>"
![0-1 ナップサック問題を動的計画法で解く流れ:手順 4](knapsack_problem.assets/knapsack_dp_step4.png)
=== "<5>"
![0-1 ナップサック問題を動的計画法で解く流れ:手順 5](knapsack_problem.assets/knapsack_dp_step5.png)
=== "<6>"
![0-1 ナップサック問題を動的計画法で解く流れ:手順 6](knapsack_problem.assets/knapsack_dp_step6.png)
=== "<7>"
![0-1 ナップサック問題を動的計画法で解く流れ:手順 7](knapsack_problem.assets/knapsack_dp_step7.png)
=== "<8>"
![0-1 ナップサック問題を動的計画法で解く流れ:手順 8](knapsack_problem.assets/knapsack_dp_step8.png)
=== "<9>"
![0-1 ナップサック問題を動的計画法で解く流れ:手順 9](knapsack_problem.assets/knapsack_dp_step9.png)
=== "<10>"
![0-1 ナップサック問題を動的計画法で解く流れ:手順 10](knapsack_problem.assets/knapsack_dp_step10.png)
=== "<11>"
![0-1 ナップサック問題を動的計画法で解く流れ:手順 11](knapsack_problem.assets/knapsack_dp_step11.png)
=== "<12>"
![0-1 ナップサック問題を動的計画法で解く流れ:手順 12](knapsack_problem.assets/knapsack_dp_step12.png)
=== "<13>"
![0-1 ナップサック問題を動的計画法で解く流れ:手順 13](knapsack_problem.assets/knapsack_dp_step13.png)
=== "<14>"
![0-1 ナップサック問題を動的計画法で解く流れ:手順 14](knapsack_problem.assets/knapsack_dp_step14.png)
### 空間の最適化
各状態は一つ前の行の状態だけに依存するため、二つの配列を交互に使えば、空間計算量を $O(n \times cap)$ から $O(cap)$ に下げられます。
さらに、配列を一つだけにできるでしょうか。各状態は真上か左上のマスから遷移します。配列が一つなら、$i$ 行目の走査を始める時点では、まだ $i-1$ 行目の状態が入っています。
- 順方向に走査すると、$dp[i, j]$ を計算する時点で、左上の $dp[i-1, 1]$ ~ $dp[i-1, j-1]$ がすでに上書きされている可能性があり、正しく状態遷移できません。
- 逆方向に走査すれば、必要な値は上書きされず、正しく状態遷移できます。
下図に、配列一つで $i = 1$ 行目から $i = 2$ 行目へ移る過程を示します。順方向と逆方向の違いを考えてみてください。
=== "<1>"
![空間最適化後の 0-1 ナップサック問題の動的計画法](knapsack_problem.assets/knapsack_dp_comp_step1.png)
=== "<2>"
![空間最適化後の 0-1 ナップサック問題の動的計画法:手順 2](knapsack_problem.assets/knapsack_dp_comp_step2.png)
=== "<3>"
![空間最適化後の 0-1 ナップサック問題の動的計画法:手順 3](knapsack_problem.assets/knapsack_dp_comp_step3.png)
=== "<4>"
![空間最適化後の 0-1 ナップサック問題の動的計画法:手順 4](knapsack_problem.assets/knapsack_dp_comp_step4.png)
=== "<5>"
![空間最適化後の 0-1 ナップサック問題の動的計画法:手順 5](knapsack_problem.assets/knapsack_dp_comp_step5.png)
=== "<6>"
![空間最適化後の 0-1 ナップサック問題の動的計画法:手順 6](knapsack_problem.assets/knapsack_dp_comp_step6.png)
実装では、配列 `dp` の第 1 次元 $i$ を取り除き、内側のループを逆順にするだけです。
```src
[file]{knapsack}-[class]{}-[func]{knapsack_dp_comp}
```