This commit is contained in:
krahets
2025-10-17 05:33:23 +08:00
parent 9278f3c659
commit 68bb9afb16
113 changed files with 35936 additions and 0 deletions
@@ -0,0 +1,476 @@
---
comments: true
---
# 14.2   動的プログラミング問題の特徴
前のセクションでは、動的プログラミングが問題を部分問題に分解することで元の問題を解決する方法を学びました。実際、部分問題の分解は一般的なアルゴリズムアプローチであり、分割統治法、動的プログラミング、バックトラッキングでは異なる重点があります。
- 分割統治法アルゴリズムは元の問題を複数の独立した部分問題に再帰的に分割し、最小の部分問題に到達するまで続け、バックトラッキング時に部分問題の解を組み合わせて最終的に元の問題の解を得ます。
- 動的プログラミングも問題を再帰的に分解しますが、分割統治法アルゴリズムとの主な違いは、動的プログラミングの部分問題が相互依存的であり、分解プロセス中に多くの重複する部分問題が現れることです。
- バックトラッキングアルゴリズムは試行錯誤によってすべての可能な解を網羅し、枝刈りによって不必要な探索分岐を避けます。元の問題の解は一連の決定ステップから構成され、各決定ステップ前の各部分シーケンスを部分問題として考えることができます。
実際、動的プログラミングは最適化問題を解決するためによく使用され、これらは重複する部分問題を含むだけでなく、他に2つの主要な特徴があります:最適部分構造と無記憶性です。
## 14.2.1   最適部分構造
階段登り問題を少し修正して、最適部分構造の概念を実証するのにより適したものにします。
!!! question "階段登りの最小コスト"
階段があり、一度に1段または2段上ることができ、階段の各段にはその段で支払う必要があるコストを表す非負の整数があります。非負の整数配列 $cost$ が与えられ、$cost[i]$ は $i$ 段目で支払う必要があるコストを表し、$cost[0]$ は地面(開始点)です。頂上に到達するために必要な最小コストは何ですか?
下の図に示すように、1段目、2段目、3段目のコストがそれぞれ $1$、$10$、$1$ の場合、地面から3段目に登る最小コストは $2$ です。
![3段目に登る最小コスト](dp_problem_features.assets/min_cost_cs_example.png){ class="animation-figure" }
<p align="center"> 図 14-6 &nbsp; 3段目に登る最小コスト </p>
$dp[i]$ を $i$ 段目に登る累積コストとします。$i$ 段目は $i-1$ 段目または $i-2$ 段目からのみ来ることができるため、$dp[i]$ は $dp[i-1] + cost[i]$ または $dp[i-2] + cost[i]$ のいずれかしかありえません。コストを最小化するために、2つのうち小さい方を選択すべきです:
$$
dp[i] = \min(dp[i-1], dp[i-2]) + cost[i]
$$
これにより最適部分構造の意味がわかります:**元の問題の最適解は部分問題の最適解から構築される**。
この問題は明らかに最適部分構造を持っています:2つの部分問題 $dp[i-1]$ と $dp[i-2]$ の最適解からより良い方を選択し、それを使用して元の問題 $dp[i]$ の最適解を構築します。
では、前のセクションの階段登り問題は最適部分構造を持っているでしょうか?その目標は解の数を求めることで、これは数え上げ問題のようですが、別の方法で尋ねてみましょう:「解の最大数を求める」。驚くことに、**問題が変わったにもかかわらず、最適部分構造が現れた**ことがわかります:$n$ 段目での解の最大数は、$n-1$ 段目と $n-2$ 段目での解の最大数の和に等しいです。したがって、最適部分構造の解釈は非常に柔軟で、異なる問題では異なる意味を持ちます。
状態遷移方程式と初期状態 $dp[1] = cost[1]$ および $dp[2] = cost[2]$ に従って、動的プログラミングコードを得ることができます:
=== "Python"
```python title="min_cost_climbing_stairs_dp.py"
def min_cost_climbing_stairs_dp(cost: list[int]) -> int:
"""最小コスト階段登り:動的プログラミング"""
n = len(cost) - 1
if n == 1 or n == 2:
return cost[n]
# dp テーブルを初期化、部分問題の解を格納するために使用
dp = [0] * (n + 1)
# 初期状態:最小の部分問題の解を事前設定
dp[1], dp[2] = cost[1], cost[2]
# 状態遷移:小さい部分問題から大きい部分問題を段階的に解く
for i in range(3, n + 1):
dp[i] = min(dp[i - 1], dp[i - 2]) + cost[i]
return dp[n]
```
=== "C++"
```cpp title="min_cost_climbing_stairs_dp.cpp"
/* 最小コスト階段登り:動的プログラミング */
int minCostClimbingStairsDP(vector<int> &cost) {
int n = cost.size() - 1;
if (n == 1 || n == 2)
return cost[n];
// DPテーブルを初期化し、部分問題の解を格納するために使用
vector<int> dp(n + 1);
// 初期状態:最小の部分問題の解を事前設定
dp[1] = cost[1];
dp[2] = cost[2];
// 状態遷移:小さな問題から大きな部分問題を段階的に解く
for (int i = 3; i <= n; i++) {
dp[i] = min(dp[i - 1], dp[i - 2]) + cost[i];
}
return dp[n];
}
```
=== "Java"
```java title="min_cost_climbing_stairs_dp.java"
/* 最小コスト階段登り:動的プログラミング */
int minCostClimbingStairsDP(int[] cost) {
int n = cost.length - 1;
if (n == 1 || n == 2)
return cost[n];
// DPテーブルを初期化し、部分問題の解を格納するために使用
int[] dp = new int[n + 1];
// 初期状態:最小の部分問題の解を事前設定
dp[1] = cost[1];
dp[2] = cost[2];
// 状態遷移:小さな問題から大きな部分問題を段階的に解く
for (int i = 3; i <= n; i++) {
dp[i] = Math.min(dp[i - 1], dp[i - 2]) + cost[i];
}
return dp[n];
}
```
=== "C#"
```csharp title="min_cost_climbing_stairs_dp.cs"
[class]{min_cost_climbing_stairs_dp}-[func]{MinCostClimbingStairsDP}
```
=== "Go"
```go title="min_cost_climbing_stairs_dp.go"
[class]{}-[func]{minCostClimbingStairsDP}
```
=== "Swift"
```swift title="min_cost_climbing_stairs_dp.swift"
[class]{}-[func]{minCostClimbingStairsDP}
```
=== "JS"
```javascript title="min_cost_climbing_stairs_dp.js"
[class]{}-[func]{minCostClimbingStairsDP}
```
=== "TS"
```typescript title="min_cost_climbing_stairs_dp.ts"
[class]{}-[func]{minCostClimbingStairsDP}
```
=== "Dart"
```dart title="min_cost_climbing_stairs_dp.dart"
[class]{}-[func]{minCostClimbingStairsDP}
```
=== "Rust"
```rust title="min_cost_climbing_stairs_dp.rs"
[class]{}-[func]{min_cost_climbing_stairs_dp}
```
=== "C"
```c title="min_cost_climbing_stairs_dp.c"
[class]{}-[func]{minCostClimbingStairsDP}
```
=== "Kotlin"
```kotlin title="min_cost_climbing_stairs_dp.kt"
[class]{}-[func]{minCostClimbingStairsDP}
```
=== "Ruby"
```ruby title="min_cost_climbing_stairs_dp.rb"
[class]{}-[func]{min_cost_climbing_stairs_dp}
```
=== "Zig"
```zig title="min_cost_climbing_stairs_dp.zig"
[class]{}-[func]{minCostClimbingStairsDP}
```
下の図は上記コードの動的プログラミングプロセスを示しています。
![階段登りの最小コストの動的プログラミングプロセス](dp_problem_features.assets/min_cost_cs_dp.png){ class="animation-figure" }
<p align="center"> 図 14-7 &nbsp; 階段登りの最小コストの動的プログラミングプロセス </p>
この問題も空間最適化が可能で、1次元を0に圧縮し、空間計算量を $O(n)$ から $O(1)$ に削減できます:
=== "Python"
```python title="min_cost_climbing_stairs_dp.py"
def min_cost_climbing_stairs_dp_comp(cost: list[int]) -> int:
"""最小コスト階段登り:空間最適化動的プログラミング"""
n = len(cost) - 1
if n == 1 or n == 2:
return cost[n]
a, b = cost[1], cost[2]
for i in range(3, n + 1):
a, b = b, min(a, b) + cost[i]
return b
```
=== "C++"
```cpp title="min_cost_climbing_stairs_dp.cpp"
/* 最小コスト階段登り:空間最適化動的プログラミング */
int minCostClimbingStairsDPComp(vector<int> &cost) {
int n = cost.size() - 1;
if (n == 1 || n == 2)
return cost[n];
int a = cost[1], b = cost[2];
for (int i = 3; i <= n; i++) {
int tmp = b;
b = min(a, tmp) + cost[i];
a = tmp;
}
return b;
}
```
=== "Java"
```java title="min_cost_climbing_stairs_dp.java"
/* 最小コスト階段登り:空間最適化動的プログラミング */
int minCostClimbingStairsDPComp(int[] cost) {
int n = cost.length - 1;
if (n == 1 || n == 2)
return cost[n];
int a = cost[1], b = cost[2];
for (int i = 3; i <= n; i++) {
int tmp = b;
b = Math.min(a, tmp) + cost[i];
a = tmp;
}
return b;
}
```
=== "C#"
```csharp title="min_cost_climbing_stairs_dp.cs"
[class]{min_cost_climbing_stairs_dp}-[func]{MinCostClimbingStairsDPComp}
```
=== "Go"
```go title="min_cost_climbing_stairs_dp.go"
[class]{}-[func]{minCostClimbingStairsDPComp}
```
=== "Swift"
```swift title="min_cost_climbing_stairs_dp.swift"
[class]{}-[func]{minCostClimbingStairsDPComp}
```
=== "JS"
```javascript title="min_cost_climbing_stairs_dp.js"
[class]{}-[func]{minCostClimbingStairsDPComp}
```
=== "TS"
```typescript title="min_cost_climbing_stairs_dp.ts"
[class]{}-[func]{minCostClimbingStairsDPComp}
```
=== "Dart"
```dart title="min_cost_climbing_stairs_dp.dart"
[class]{}-[func]{minCostClimbingStairsDPComp}
```
=== "Rust"
```rust title="min_cost_climbing_stairs_dp.rs"
[class]{}-[func]{min_cost_climbing_stairs_dp_comp}
```
=== "C"
```c title="min_cost_climbing_stairs_dp.c"
[class]{}-[func]{minCostClimbingStairsDPComp}
```
=== "Kotlin"
```kotlin title="min_cost_climbing_stairs_dp.kt"
[class]{}-[func]{minCostClimbingStairsDPComp}
```
=== "Ruby"
```ruby title="min_cost_climbing_stairs_dp.rb"
[class]{}-[func]{min_cost_climbing_stairs_dp_comp}
```
=== "Zig"
```zig title="min_cost_climbing_stairs_dp.zig"
[class]{}-[func]{minCostClimbingStairsDPComp}
```
## 14.2.2 &nbsp; 無記憶性
無記憶性は動的プログラミングが問題解決に効果的であることを可能にする重要な特徴の1つです。その定義は:**特定の状態が与えられたとき、その将来の発展は現在の状態のみに関連し、過去に経験したすべての状態とは無関係である**。
階段登り問題を例に取ると、状態 $i$ が与えられたとき、それは状態 $i+1$ と $i+2$ に発展し、それぞれ1段ジャンプと2段ジャンプに対応します。これら2つの選択をするとき、状態 $i$ より前の状態を考慮する必要はありません。なぜなら、それらは状態 $i$ の将来に影響しないからです。
しかし、階段登り問題に制約を追加すると、状況が変わります。
!!! question "制約付き階段登り"
$n$ 段の階段があり、毎回1段または2段上ることができますが、**1段を2回連続でジャンプすることはできません**。頂上に登る方法は何通りありますか?
下の図に示すように、3段目に登る実行可能な選択肢は2つだけで、1段を3回連続でジャンプする選択肢は制約条件を満たさないため破棄されます。
![制約付きで3段目に登る実行可能な選択肢の数](dp_problem_features.assets/climbing_stairs_constraint_example.png){ class="animation-figure" }
<p align="center"> 図 14-8 &nbsp; 制約付きで3段目に登る実行可能な選択肢の数 </p>
この問題では、前回が1段ジャンプだった場合、次回は必ず2段ジャンプでなければなりません。これは**次のステップの選択が現在の状態(現在の階段段数)だけでは独立して決定できず、前の状態(前回の階段段数)にも依存する**ことを意味します。
この問題がもはや無記憶性を満たさず、状態遷移方程式 $dp[i] = dp[i-1] + dp[i-2]$ も失敗することは容易にわかります。なぜなら $dp[i-1]$ は今回の1段ジャンプを表しますが、多くの「前回が1段ジャンプだった」選択肢を含んでおり、制約を満たすためにはこれらを直接 $dp[i]$ に含めることはできません。
このため、状態定義を拡張する必要があります:**状態 $[i, j]$ は $i$ 段目にいて、前回が $j$ 段ジャンプだったことを表す**。ここで $j \in \{1, 2\}$ です。この状態定義は前回が1段ジャンプだったか2段ジャンプだったかを効果的に区別し、現在の状態がどこから来たかを適切に判断できます。
- 前回が1段ジャンプだった場合、前々回は必ず2段ジャンプを選択していたはずです。つまり、$dp[i, 1]$ は $dp[i-1, 2]$ からのみ遷移できます。
- 前回が2段ジャンプだった場合、前々回は1段ジャンプまたは2段ジャンプを選択できました。つまり、$dp[i, 2]$ は $dp[i-2, 1]$ または $dp[i-2, 2]$ から遷移できます。
下の図に示すように、$dp[i, j]$ は状態 $[i, j]$ の解の数を表します。この時点で、状態遷移方程式は次のようになります:
$$
\begin{cases}
dp[i, 1] = dp[i-1, 2] \\
dp[i, 2] = dp[i-2, 1] + dp[i-2, 2]
\end{cases}
$$
![制約を考慮した再帰関係](dp_problem_features.assets/climbing_stairs_constraint_state_transfer.png){ class="animation-figure" }
<p align="center"> 図 14-9 &nbsp; 制約を考慮した再帰関係 </p>
最終的に、$dp[n, 1] + dp[n, 2]$ を返せばよく、この2つの合計が $n$ 段目に登る解の総数を表します:
=== "Python"
```python title="climbing_stairs_constraint_dp.py"
def climbing_stairs_constraint_dp(n: int) -> int:
"""制約付き階段登り:動的プログラミング"""
if n == 1 or n == 2:
return 1
# dp テーブルを初期化、部分問題の解を格納するために使用
dp = [[0] * 3 for _ in range(n + 1)]
# 初期状態:最小の部分問題の解を事前設定
dp[1][1], dp[1][2] = 1, 0
dp[2][1], dp[2][2] = 0, 1
# 状態遷移:小さい部分問題から大きい部分問題を段階的に解く
for i in range(3, n + 1):
dp[i][1] = dp[i - 1][2]
dp[i][2] = dp[i - 2][1] + dp[i - 2][2]
return dp[n][1] + dp[n][2]
```
=== "C++"
```cpp title="climbing_stairs_constraint_dp.cpp"
/* 制約付き階段登り:動的プログラミング */
int climbingStairsConstraintDP(int n) {
if (n == 1 || n == 2) {
return 1;
}
// DPテーブルを初期化し、部分問題の解を格納するために使用
vector<vector<int>> dp(n + 1, vector<int>(3, 0));
// 初期状態:最小の部分問題の解を事前設定
dp[1][1] = 1;
dp[1][2] = 0;
dp[2][1] = 0;
dp[2][2] = 1;
// 状態遷移:小さな問題から大きな部分問題を段階的に解く
for (int i = 3; i <= n; i++) {
dp[i][1] = dp[i - 1][2];
dp[i][2] = dp[i - 2][1] + dp[i - 2][2];
}
return dp[n][1] + dp[n][2];
}
```
=== "Java"
```java title="climbing_stairs_constraint_dp.java"
/* 制約付き階段登り:動的プログラミング */
int climbingStairsConstraintDP(int n) {
if (n == 1 || n == 2) {
return 1;
}
// DPテーブルを初期化し、部分問題の解を格納するために使用
int[][] dp = new int[n + 1][3];
// 初期状態:最小の部分問題の解を事前設定
dp[1][1] = 1;
dp[1][2] = 0;
dp[2][1] = 0;
dp[2][2] = 1;
// 状態遷移:小さな問題から大きな部分問題を段階的に解く
for (int i = 3; i <= n; i++) {
dp[i][1] = dp[i - 1][2];
dp[i][2] = dp[i - 2][1] + dp[i - 2][2];
}
return dp[n][1] + dp[n][2];
}
```
=== "C#"
```csharp title="climbing_stairs_constraint_dp.cs"
[class]{climbing_stairs_constraint_dp}-[func]{ClimbingStairsConstraintDP}
```
=== "Go"
```go title="climbing_stairs_constraint_dp.go"
[class]{}-[func]{climbingStairsConstraintDP}
```
=== "Swift"
```swift title="climbing_stairs_constraint_dp.swift"
[class]{}-[func]{climbingStairsConstraintDP}
```
=== "JS"
```javascript title="climbing_stairs_constraint_dp.js"
[class]{}-[func]{climbingStairsConstraintDP}
```
=== "TS"
```typescript title="climbing_stairs_constraint_dp.ts"
[class]{}-[func]{climbingStairsConstraintDP}
```
=== "Dart"
```dart title="climbing_stairs_constraint_dp.dart"
[class]{}-[func]{climbingStairsConstraintDP}
```
=== "Rust"
```rust title="climbing_stairs_constraint_dp.rs"
[class]{}-[func]{climbing_stairs_constraint_dp}
```
=== "C"
```c title="climbing_stairs_constraint_dp.c"
[class]{}-[func]{climbingStairsConstraintDP}
```
=== "Kotlin"
```kotlin title="climbing_stairs_constraint_dp.kt"
[class]{}-[func]{climbingStairsConstraintDP}
```
=== "Ruby"
```ruby title="climbing_stairs_constraint_dp.rb"
[class]{}-[func]{climbing_stairs_constraint_dp}
```
=== "Zig"
```zig title="climbing_stairs_constraint_dp.zig"
[class]{}-[func]{climbingStairsConstraintDP}
```
上記のケースでは、前の状態のみを考慮すればよいため、状態定義を拡張することで依然として無記憶性を満たすことができます。しかし、一部の問題では非常に深刻な「状態効果」があります。
!!! question "障害物生成付き階段登り"
$n$ 段の階段があり、毎回1段または2段上ることができます。**$i$ 段目に登ったとき、システムが自動的に $2i$ 段目に障害物を置き、その後のすべてのラウンドで $2i$ 段目にジャンプすることが禁止される**と規定されています。例えば、最初の2ラウンドで2段目と3段目にジャンプした場合、その後は4段目と6段目にジャンプできません。頂上に登る方法は何通りありますか?
この問題では、次のジャンプはすべての過去の状態に依存します。各ジャンプがより高い段に障害物を置き、将来のジャンプに影響するからです。このような問題では、動的プログラミングはしばしば解決に苦労します。
実際、多くの複雑な組み合わせ最適化問題(巡回セールスマン問題など)は無記憶性を満たしません。このような問題に対しては、通常、ヒューリスティック探索、遺伝的アルゴリズム、強化学習などの他の方法を選択して、限られた時間内に使用可能な局所最適解を得ます。
@@ -0,0 +1,695 @@
---
comments: true
---
# 14.3 &nbsp; 動的プログラミング問題解決アプローチ
前の2つのセクションでは、動的プログラミング問題の主要な特徴を紹介しました。次に、より実用的な2つの問題を一緒に探索しましょう。
1. 問題が動的プログラミング問題かどうかをどのように判断するか?
2. 動的プログラミング問題を解決する完全なステップは何か?
## 14.3.1 &nbsp; 問題の判定
一般的に言えば、問題が重複する部分問題、最適部分構造を含み、無記憶性を示す場合、通常動的プログラミング解法に適しています。しかし、問題の説明から直接これらの特徴を抽出することはしばしば困難です。したがって、通常は条件を緩和し、**まず問題がバックトラッキング(全探索)を使用した解決に適しているかどうかを観察**します。
**バックトラッキングに適した問題は通常「決定木モデル」に適合**し、これは木構造を使用して記述でき、各ノードは決定を表し、各パスは決定のシーケンスを表します。
言い換えると、問題が明示的な決定概念を含み、解が一連の決定を通じて生成される場合、それは決定木モデルに適合し、通常バックトラッキングを使用して解決できます。
この基礎の上で、動的プログラミング問題を判定するための「ボーナスポイント」があります。
- 問題に最大化(最小化)または最も(最も少ない)最適な解を見つけるという記述が含まれている。
- 問題の状態がリスト、多次元行列、または木を使用して表現でき、状態がその周囲の状態と再帰関係を持っている。
対応して、「ペナルティポイント」もあります。
- 問題の目標は最適解だけでなく、すべての可能な解を見つけることである。
- 問題の説明に順列と組み合わせの明らかな特徴があり、特定の複数の解を返す必要がある。
問題が決定木モデルに適合し、比較的明らかな「ボーナスポイント」を持つ場合、それが動的プログラミング問題であると仮定し、解決プロセス中に検証できます。
## 14.3.2 &nbsp; 問題解決ステップ
動的プログラミング問題解決プロセスは問題の性質と難易度によって異なりますが、一般的に次のステップに従います:決定の記述、状態の定義、$dp$ テーブルの確立、状態遷移方程式の導出、境界条件の決定など。
問題解決ステップをより具体的に説明するために、古典的な問題「最小経路和」を例として使用します。
!!! question
$n \times m$ の二次元グリッド `grid` が与えられ、グリッドの各セルには負でない整数が含まれ、そのセルのコストを表します。ロボットは左上のセルから始まり、各ステップで下または右にのみ移動でき、右下のセルに到達するまで続けます。左上から右下への最小経路和を返してください。
下の図は例を示しており、与えられたグリッドの最小経路和は $13$ です。
![最小経路和の例データ](dp_solution_pipeline.assets/min_path_sum_example.png){ class="animation-figure" }
<p align="center"> 図 14-10 &nbsp; 最小経路和の例データ </p>
**第1ステップ:各ラウンドの決定を考え、状態を定義し、それにより $dp$ テーブルを得る**
この問題の各ラウンドの決定は、現在のセルから下または右に1ステップ移動することです。現在のセルの行と列のインデックスが $[i, j]$ であると仮定すると、下または右に移動した後、インデックスは $[i+1, j]$ または $[i, j+1]$ になります。したがって、状態には2つの変数が含まれるべきです:行インデックスと列インデックス、$[i, j]$ と表記されます。
状態 $[i, j]$ は部分問題に対応します:開始点 $[0, 0]$ から $[i, j]$ への最小経路和、$dp[i, j]$ と表記されます。
このようにして、下の図に示す二次元 $dp$ 行列を得ます。そのサイズは入力グリッド $grid$ と同じです。
![状態定義とDPテーブル](dp_solution_pipeline.assets/min_path_sum_solution_state_definition.png){ class="animation-figure" }
<p align="center"> 図 14-11 &nbsp; 状態定義とDPテーブル </p>
!!! note
動的プログラミングとバックトラッキングは決定のシーケンスとして記述でき、状態はすべての決定変数から構成されます。問題解決の進行を記述するすべての変数を含むべきで、次の状態を導出するのに十分な情報を含んでいる必要があります。
各状態は部分問題に対応し、すべての部分問題の解を保存するための $dp$ テーブルを定義します。状態の各独立変数は $dp$ テーブルの次元です。本質的に、$dp$ テーブルは状態と部分問題の解の間のマッピングです。
**第2ステップ:最適部分構造を特定し、状態遷移方程式を導出する**
状態 $[i, j]$ について、それは上のセル $[i-1, j]$ または左のセル $[i, j-1]$ からのみ導出できます。したがって、最適部分構造は:$[i, j]$ に到達する最小経路和は、$[i, j-1]$ と $[i-1, j]$ の最小経路和の小さい方によって決定されます。
上記の分析に基づいて、下の図に示す状態遷移方程式を導出できます:
$$
dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, j]
$$
![最適部分構造と状態遷移方程式](dp_solution_pipeline.assets/min_path_sum_solution_state_transition.png){ class="animation-figure" }
<p align="center"> 図 14-12 &nbsp; 最適部分構造と状態遷移方程式 </p>
!!! note
定義された $dp$ テーブルに基づいて、元の問題と部分問題の関係を考え、部分問題の最適解から元の問題の最適解をどのように構築するか、つまり最適部分構造を見つけます。
最適部分構造を特定したら、それを使用して状態遷移方程式を構築できます。
**第3ステップ:境界条件と状態遷移順序を決定する**
この問題では、最初の行の状態は左の状態からのみ来ることができ、最初の列の状態は上の状態からのみ来ることができるため、最初の行 $i = 0$ と最初の列 $j = 0$ が境界条件です。
下の図に示すように、各セルは左のセルと上のセルから導出されるため、ループを使用して行列を走査し、外側のループは行を反復し、内側のループは列を反復します。
![境界条件と状態遷移順序](dp_solution_pipeline.assets/min_path_sum_solution_initial_state.png){ class="animation-figure" }
<p align="center"> 図 14-13 &nbsp; 境界条件と状態遷移順序 </p>
!!! note
境界条件は動的プログラミングで $dp$ テーブルを初期化するために使用され、探索では枝刈りに使用されます。
状態遷移順序の核心は、現在の問題の解を計算するとき、それが依存するすべての小さな部分問題が既に正しく計算されていることを確保することです。
上記の分析に基づいて、動的プログラミングコードを直接書くことができます。しかし、部分問題の分解はトップダウンアプローチであるため、「力任せ探索 → メモ化探索 → 動的プログラミング」の順序で実装することが習慣的な思考により適合します。
### 1. &nbsp; 方法1:力任せ探索
状態 $[i, j]$ から探索を開始し、それを常により小さな状態 $[i-1, j]$ と $[i, j-1]$ に分解します。再帰関数には以下の要素が含まれます。
- **再帰パラメータ**:状態 $[i, j]$。
- **戻り値**$[0, 0]$ から $[i, j]$ への最小経路和 $dp[i, j]$。
- **終了条件**$i = 0$ かつ $j = 0$ のとき、コスト $grid[0, 0]$ を返す。
- **枝刈り**$i < 0$ または $j < 0$ でインデックスが範囲外のとき、コスト $+\infty$ を返し、実行不可能性を表す。
実装コードは以下の通りです:
=== "Python"
```python title="min_path_sum.py"
def min_path_sum_dfs(grid: list[list[int]], i: int, j: int) -> int:
"""最小パス和:ブルートフォース探索"""
# 左上のセルの場合、探索を終了
if i == 0 and j == 0:
return grid[0][0]
# 行または列のインデックスが範囲外の場合、+∞ コストを返す
if i < 0 or j < 0:
return inf
# 左上から (i-1, j) と (i, j-1) への最小パスコストを計算
up = min_path_sum_dfs(grid, i - 1, j)
left = min_path_sum_dfs(grid, i, j - 1)
# 左上から (i, j) への最小パスコストを返す
return min(left, up) + grid[i][j]
```
=== "C++"
```cpp title="min_path_sum.cpp"
/* 最小パス和:ブルートフォース探索 */
int minPathSumDFS(vector<vector<int>> &grid, int i, int j) {
// 左上のセルの場合、探索を終了
if (i == 0 && j == 0) {
return grid[0][0];
}
// 行または列のインデックスが範囲外の場合、+∞ のコストを返す
if (i < 0 || j < 0) {
return INT_MAX;
}
// 左上から (i-1, j) と (i, j-1) への最小パスコストを計算
int up = minPathSumDFS(grid, i - 1, j);
int left = minPathSumDFS(grid, i, j - 1);
// 左上から (i, j) への最小パスコストを返す
return min(left, up) + grid[i][j];
}
```
=== "Java"
```java title="min_path_sum.java"
/* 最小パス和:ブルートフォース探索 */
int minPathSumDFS(int[][] grid, int i, int j) {
// 左上のセルの場合、探索を終了
if (i == 0 && j == 0) {
return grid[0][0];
}
// 行または列のインデックスが範囲外の場合、+∞ のコストを返す
if (i < 0 || j < 0) {
return Integer.MAX_VALUE;
}
// 左上から (i-1, j) と (i, j-1) への最小パスコストを計算
int up = minPathSumDFS(grid, i - 1, j);
int left = minPathSumDFS(grid, i, j - 1);
// 左上から (i, j) への最小パスコストを返す
return Math.min(left, up) + grid[i][j];
}
```
=== "C#"
```csharp title="min_path_sum.cs"
[class]{min_path_sum}-[func]{MinPathSumDFS}
```
=== "Go"
```go title="min_path_sum.go"
[class]{}-[func]{minPathSumDFS}
```
=== "Swift"
```swift title="min_path_sum.swift"
[class]{}-[func]{minPathSumDFS}
```
=== "JS"
```javascript title="min_path_sum.js"
[class]{}-[func]{minPathSumDFS}
```
=== "TS"
```typescript title="min_path_sum.ts"
[class]{}-[func]{minPathSumDFS}
```
=== "Dart"
```dart title="min_path_sum.dart"
[class]{}-[func]{minPathSumDFS}
```
=== "Rust"
```rust title="min_path_sum.rs"
[class]{}-[func]{min_path_sum_dfs}
```
=== "C"
```c title="min_path_sum.c"
[class]{}-[func]{minPathSumDFS}
```
=== "Kotlin"
```kotlin title="min_path_sum.kt"
[class]{}-[func]{minPathSumDFS}
```
=== "Ruby"
```ruby title="min_path_sum.rb"
[class]{}-[func]{min_path_sum_dfs}
```
=== "Zig"
```zig title="min_path_sum.zig"
[class]{}-[func]{minPathSumDFS}
```
下の図は $dp[2, 1]$ を根とする再帰木を示しており、いくつかの重複する部分問題を含み、その数はグリッド `grid` のサイズが増加すると急激に増加します。
本質的に、重複する部分問題の理由は:**左上隅から特定のセルに到達する複数のパスが存在する**ことです。
![力任せ探索の再帰木](dp_solution_pipeline.assets/min_path_sum_dfs.png){ class="animation-figure" }
<p align="center"> 図 14-14 &nbsp; 力任せ探索の再帰木 </p>
各状態には下と右の2つの選択があるため、左上隅から右下隅までの総ステップ数は $m + n - 2$ で、最悪時間計算量は $O(2^{m + n})$ です。この計算方法はグリッドエッジ近くの状況を考慮していないことに注意してください。ネットワークエッジに到達したとき、選択肢が1つしか残らないため、実際のパス数はより少なくなります。
### 2. &nbsp; 方法2:メモ化探索
グリッド `grid` と同じサイズのメモリスト `mem` を導入し、様々な部分問題の解を記録し、重複する部分問題を枝刈りします:
=== "Python"
```python title="min_path_sum.py"
def min_path_sum_dfs_mem(
grid: list[list[int]], mem: list[list[int]], i: int, j: int
) -> int:
"""最小パス和:記憶化探索"""
# 左上のセルの場合、探索を終了
if i == 0 and j == 0:
return grid[0][0]
# 行または列のインデックスが範囲外の場合、+∞ コストを返す
if i < 0 or j < 0:
return inf
# 記録がある場合、それを返す
if mem[i][j] != -1:
return mem[i][j]
# 左と上のセルからの最小パスコスト
up = min_path_sum_dfs_mem(grid, mem, i - 1, j)
left = min_path_sum_dfs_mem(grid, mem, i, j - 1)
# 左上から (i, j) への最小パスコストを記録して返す
mem[i][j] = min(left, up) + grid[i][j]
return mem[i][j]
```
=== "C++"
```cpp title="min_path_sum.cpp"
[class]{}-[func]{minPathSumDFSMem}
```
=== "Java"
```java title="min_path_sum.java"
/* 最小パス和:メモ化探索 */
int minPathSumDFSMem(int[][] grid, int[][] mem, int i, int j) {
// 左上のセルの場合、探索を終了
if (i == 0 && j == 0) {
return grid[0][0];
}
// 行または列のインデックスが範囲外の場合、+∞ のコストを返す
if (i < 0 || j < 0) {
return Integer.MAX_VALUE;
}
// 記録がある場合、それを返す
if (mem[i][j] != -1) {
return mem[i][j];
}
// 左と上のセルからの最小パスコスト
int up = minPathSumDFSMem(grid, mem, i - 1, j);
int left = minPathSumDFSMem(grid, mem, i, j - 1);
// 左上から (i, j) への最小パスコストを記録して返す
mem[i][j] = Math.min(left, up) + grid[i][j];
return mem[i][j];
}
```
=== "C#"
```csharp title="min_path_sum.cs"
[class]{min_path_sum}-[func]{MinPathSumDFSMem}
```
=== "Go"
```go title="min_path_sum.go"
[class]{}-[func]{minPathSumDFSMem}
```
=== "Swift"
```swift title="min_path_sum.swift"
[class]{}-[func]{minPathSumDFSMem}
```
=== "JS"
```javascript title="min_path_sum.js"
[class]{}-[func]{minPathSumDFSMem}
```
=== "TS"
```typescript title="min_path_sum.ts"
[class]{}-[func]{minPathSumDFSMem}
```
=== "Dart"
```dart title="min_path_sum.dart"
[class]{}-[func]{minPathSumDFSMem}
```
=== "Rust"
```rust title="min_path_sum.rs"
[class]{}-[func]{min_path_sum_dfs_mem}
```
=== "C"
```c title="min_path_sum.c"
[class]{}-[func]{minPathSumDFSMem}
```
=== "Kotlin"
```kotlin title="min_path_sum.kt"
[class]{}-[func]{minPathSumDFSMem}
```
=== "Ruby"
```ruby title="min_path_sum.rb"
[class]{}-[func]{min_path_sum_dfs_mem}
```
=== "Zig"
```zig title="min_path_sum.zig"
[class]{}-[func]{minPathSumDFSMem}
```
下の図に示すように、メモ化を導入した後、すべての部分問題の解は一度だけ計算される必要があるため、時間計算量は状態の総数、つまりグリッドサイズ $O(nm)$ に依存します。
![メモ化探索の再帰木](dp_solution_pipeline.assets/min_path_sum_dfs_mem.png){ class="animation-figure" }
<p align="center"> 図 14-15 &nbsp; メモ化探索の再帰木 </p>
### 3. &nbsp; 方法3:動的プログラミング
動的プログラミング解法を反復的に実装します。コードは以下の通りです:
=== "Python"
```python title="min_path_sum.py"
def min_path_sum_dp(grid: list[list[int]]) -> int:
"""最小パス和:動的プログラミング"""
n, m = len(grid), len(grid[0])
# dp テーブルを初期化
dp = [[0] * m for _ in range(n)]
dp[0][0] = grid[0][0]
# 状態遷移:最初の行
for j in range(1, m):
dp[0][j] = dp[0][j - 1] + grid[0][j]
# 状態遷移:最初の列
for i in range(1, n):
dp[i][0] = dp[i - 1][0] + grid[i][0]
# 状態遷移:残りの行と列
for i in range(1, n):
for j in range(1, m):
dp[i][j] = min(dp[i][j - 1], dp[i - 1][j]) + grid[i][j]
return dp[n - 1][m - 1]
```
=== "C++"
```cpp title="min_path_sum.cpp"
/* 最小パス和:動的プログラミング */
int minPathSumDP(vector<vector<int>> &grid) {
int n = grid.size(), m = grid[0].size();
// DPテーブルを初期化
vector<vector<int>> dp(n, vector<int>(m));
dp[0][0] = grid[0][0];
// 状態遷移:最初の行
for (int j = 1; j < m; j++) {
dp[0][j] = dp[0][j - 1] + grid[0][j];
}
// 状態遷移:最初の列
for (int i = 1; i < n; i++) {
dp[i][0] = dp[i - 1][0] + grid[i][0];
}
// 状態遷移:残りの行と列
for (int i = 1; i < n; i++) {
for (int j = 1; j < m; j++) {
dp[i][j] = min(dp[i][j - 1], dp[i - 1][j]) + grid[i][j];
}
}
return dp[n - 1][m - 1];
}
```
=== "Java"
```java title="min_path_sum.java"
/* 最小パス和:動的プログラミング */
int minPathSumDP(int[][] grid) {
int n = grid.length, m = grid[0].length;
// DPテーブルを初期化
int[][] dp = new int[n][m];
dp[0][0] = grid[0][0];
// 状態遷移:最初の行
for (int j = 1; j < m; j++) {
dp[0][j] = dp[0][j - 1] + grid[0][j];
}
// 状態遷移:最初の列
for (int i = 1; i < n; i++) {
dp[i][0] = dp[i - 1][0] + grid[i][0];
}
// 状態遷移:残りの行と列
for (int i = 1; i < n; i++) {
for (int j = 1; j < m; j++) {
dp[i][j] = Math.min(dp[i][j - 1], dp[i - 1][j]) + grid[i][j];
}
}
return dp[n - 1][m - 1];
}
```
=== "C#"
```csharp title="min_path_sum.cs"
[class]{min_path_sum}-[func]{MinPathSumDP}
```
=== "Go"
```go title="min_path_sum.go"
[class]{}-[func]{minPathSumDP}
```
=== "Swift"
```swift title="min_path_sum.swift"
[class]{}-[func]{minPathSumDP}
```
=== "JS"
```javascript title="min_path_sum.js"
[class]{}-[func]{minPathSumDP}
```
=== "TS"
```typescript title="min_path_sum.ts"
[class]{}-[func]{minPathSumDP}
```
=== "Dart"
```dart title="min_path_sum.dart"
[class]{}-[func]{minPathSumDP}
```
=== "Rust"
```rust title="min_path_sum.rs"
[class]{}-[func]{min_path_sum_dp}
```
=== "C"
```c title="min_path_sum.c"
[class]{}-[func]{minPathSumDP}
```
=== "Kotlin"
```kotlin title="min_path_sum.kt"
[class]{}-[func]{minPathSumDP}
```
=== "Ruby"
```ruby title="min_path_sum.rb"
[class]{}-[func]{min_path_sum_dp}
```
=== "Zig"
```zig title="min_path_sum.zig"
[class]{}-[func]{minPathSumDP}
```
下の図は最小経路和の状態遷移プロセスを示し、グリッド全体を走査するため、**時間計算量は $O(nm)$** です。
配列 `dp` のサイズは $n \times m$ であるため、**空間計算量は $O(nm)$** です。
=== "<1>"
![最小経路和の動的プログラミングプロセス](dp_solution_pipeline.assets/min_path_sum_dp_step1.png){ class="animation-figure" }
=== "<2>"
![min_path_sum_dp_step2](dp_solution_pipeline.assets/min_path_sum_dp_step2.png){ class="animation-figure" }
=== "<3>"
![min_path_sum_dp_step3](dp_solution_pipeline.assets/min_path_sum_dp_step3.png){ class="animation-figure" }
=== "<4>"
![min_path_sum_dp_step4](dp_solution_pipeline.assets/min_path_sum_dp_step4.png){ class="animation-figure" }
=== "<5>"
![min_path_sum_dp_step5](dp_solution_pipeline.assets/min_path_sum_dp_step5.png){ class="animation-figure" }
=== "<6>"
![min_path_sum_dp_step6](dp_solution_pipeline.assets/min_path_sum_dp_step6.png){ class="animation-figure" }
=== "<7>"
![min_path_sum_dp_step7](dp_solution_pipeline.assets/min_path_sum_dp_step7.png){ class="animation-figure" }
=== "<8>"
![min_path_sum_dp_step8](dp_solution_pipeline.assets/min_path_sum_dp_step8.png){ class="animation-figure" }
=== "<9>"
![min_path_sum_dp_step9](dp_solution_pipeline.assets/min_path_sum_dp_step9.png){ class="animation-figure" }
=== "<10>"
![min_path_sum_dp_step10](dp_solution_pipeline.assets/min_path_sum_dp_step10.png){ class="animation-figure" }
=== "<11>"
![min_path_sum_dp_step11](dp_solution_pipeline.assets/min_path_sum_dp_step11.png){ class="animation-figure" }
=== "<12>"
![min_path_sum_dp_step12](dp_solution_pipeline.assets/min_path_sum_dp_step12.png){ class="animation-figure" }
<p align="center"> 図 14-16 &nbsp; 最小経路和の動的プログラミングプロセス </p>
### 4. &nbsp; 空間最適化
各セルは左と上のセルのみに関連するため、単一行配列を使用して $dp$ テーブルを実装できます。
配列 `dp` は1行の状態のみを表現できるため、最初の列の状態を事前に初期化できず、各行を走査するときに更新することに注意してください:
=== "Python"
```python title="min_path_sum.py"
def min_path_sum_dp_comp(grid: list[list[int]]) -> int:
"""最小パス和:空間最適化動的プログラミング"""
n, m = len(grid), len(grid[0])
# dp テーブルを初期化
dp = [0] * m
# 状態遷移:最初の行
dp[0] = grid[0][0]
for j in range(1, m):
dp[j] = dp[j - 1] + grid[0][j]
# 状態遷移:残りの行
for i in range(1, n):
# 状態遷移:最初の列
dp[0] = dp[0] + grid[i][0]
# 状態遷移:残りの列
for j in range(1, m):
dp[j] = min(dp[j - 1], dp[j]) + grid[i][j]
return dp[m - 1]
```
=== "C++"
```cpp title="min_path_sum.cpp"
[class]{}-[func]{minPathSumDPComp}
```
=== "Java"
```java title="min_path_sum.java"
/* 最小パス和:空間最適化動的プログラミング */
int minPathSumDPComp(int[][] grid) {
int n = grid.length, m = grid[0].length;
// DPテーブルを初期化
int[] dp = new int[m];
// 状態遷移:最初の行
dp[0] = grid[0][0];
for (int j = 1; j < m; j++) {
dp[j] = dp[j - 1] + grid[0][j];
}
// 状態遷移:残りの行
for (int i = 1; i < n; i++) {
// 状態遷移:最初の列
dp[0] = dp[0] + grid[i][0];
// 状態遷移:残りの列
for (int j = 1; j < m; j++) {
dp[j] = Math.min(dp[j - 1], dp[j]) + grid[i][j];
}
}
return dp[m - 1];
}
```
=== "C#"
```csharp title="min_path_sum.cs"
[class]{min_path_sum}-[func]{MinPathSumDPComp}
```
=== "Go"
```go title="min_path_sum.go"
[class]{}-[func]{minPathSumDPComp}
```
=== "Swift"
```swift title="min_path_sum.swift"
[class]{}-[func]{minPathSumDPComp}
```
=== "JS"
```javascript title="min_path_sum.js"
[class]{}-[func]{minPathSumDPComp}
```
=== "TS"
```typescript title="min_path_sum.ts"
[class]{}-[func]{minPathSumDPComp}
```
=== "Dart"
```dart title="min_path_sum.dart"
[class]{}-[func]{minPathSumDPComp}
```
=== "Rust"
```rust title="min_path_sum.rs"
[class]{}-[func]{min_path_sum_dp_comp}
```
=== "C"
```c title="min_path_sum.c"
[class]{}-[func]{minPathSumDPComp}
```
=== "Kotlin"
```kotlin title="min_path_sum.kt"
[class]{}-[func]{minPathSumDPComp}
```
=== "Ruby"
```ruby title="min_path_sum.rb"
[class]{}-[func]{min_path_sum_dp_comp}
```
=== "Zig"
```zig title="min_path_sum.zig"
[class]{}-[func]{minPathSumDPComp}
```
@@ -0,0 +1,416 @@
---
comments: true
---
# 14.6 &nbsp; 編集距離問題
編集距離は、レーベンシュタイン距離とも呼ばれ、一つの文字列を別の文字列に変換するために必要な最小修正回数を指し、情報検索や自然言語処理で2つのシーケンス間の類似度を測定するためによく使用されます。
!!! question
2つの文字列 $s$ と $t$ が与えられたとき、$s$ を $t$ に変換するために必要な最小編集回数を返してください。
文字列に対して3種類の編集を実行できます:文字の挿入、文字の削除、または文字を他の任意の文字に置換。
下の図に示すように、`kitten``sitting` に変換するには3回の編集が必要で、2回の置換と1回の挿入を含みます。`hello``algo` に変換するには3ステップが必要で、2回の置換と1回の削除を含みます。
![編集距離の例データ](edit_distance_problem.assets/edit_distance_example.png){ class="animation-figure" }
<p align="center"> 図 14-27 &nbsp; 編集距離の例データ </p>
**編集距離問題は決定木モデルで自然に説明できます**。文字列は木のノードに対応し、1ラウンドの決定(編集操作)は木のエッジに対応します。
下の図に示すように、操作に制限がない場合、各ノードは多くのエッジを導出でき、それぞれが1つの操作に対応するため、`hello``algo` に変換する可能な経路は多数あります。
決定木の観点から、この問題の目標は、ノード `hello` とノード `algo` の間の最短経路を見つけることです。
![決定木モデルに基づいて表現された編集距離問題](edit_distance_problem.assets/edit_distance_decision_tree.png){ class="animation-figure" }
<p align="center"> 図 14-28 &nbsp; 決定木モデルに基づいて表現された編集距離問題 </p>
### 1. &nbsp; 動的プログラミングアプローチ
**ステップ1:各ラウンドの決定を考え、状態を定義し、それにより $dp$ テーブルを得る**
各ラウンドの決定は、文字列 $s$ に対して1つの編集操作を実行することを含みます。
編集プロセス中に問題のサイズを段階的に縮小することを目指し、これにより部分問題を構築できます。文字列 $s$ と $t$ の長さをそれぞれ $n$ と $m$ とします。まず、両方の文字列の末尾文字 $s[n-1]$ と $t[m-1]$ を考慮します。
- $s[n-1]$ と $t[m-1]$ が同じ場合、それらをスキップして直接 $s[n-2]$ と $t[m-2]$ を考慮できます。
- $s[n-1]$ と $t[m-1]$ が異なる場合、$s$ に対して1つの編集(挿入、削除、置換)を実行して、2つの文字列の末尾文字を一致させ、それらをスキップしてより小規模な問題を考慮できるようにする必要があります。
したがって、文字列 $s$ での各ラウンドの決定(編集操作)は、$s$ と $t$ でマッチされる残りの文字を変更します。したがって、状態は $s$ と $t$ で現在考慮されている $i$ 番目と $j$ 番目の文字であり、$[i, j]$ と表記されます。
状態 $[i, j]$ は部分問題に対応します:**$s$ の最初の $i$ 文字を $t$ の最初の $j$ 文字に変更するために必要な最小編集回数**。
これから、サイズ $(i+1) \times (j+1)$ の二次元 $dp$ テーブルを得ます。
**ステップ2:最適部分構造を特定し、状態遷移方程式を導出する**
部分問題 $dp[i, j]$ を考慮すると、これに対応する2つの文字列の末尾文字は $s[i-1]$ と $t[j-1]$ であり、下の図に示すように3つのシナリオに分けることができます。
1. $s[i-1]$ の後に $t[j-1]$ を追加すると、残りの部分問題は $dp[i, j-1]$ です。
2. $s[i-1]$ を削除すると、残りの部分問題は $dp[i-1, j]$ です。
3. $s[i-1]$ を $t[j-1]$ に置換すると、残りの部分問題は $dp[i-1, j-1]$ です。
![編集距離の状態遷移](edit_distance_problem.assets/edit_distance_state_transfer.png){ class="animation-figure" }
<p align="center"> 図 14-29 &nbsp; 編集距離の状態遷移 </p>
上記の分析に基づいて、最適部分構造を決定できます:$dp[i, j]$ の最小編集回数は、$dp[i, j-1]$、$dp[i-1, j]$、$dp[i-1, j-1]$ の中の最小値に編集ステップ $1$ を加えたものです。対応する状態遷移方程式は:
$$
dp[i, j] = \min(dp[i, j-1], dp[i-1, j], dp[i-1, j-1]) + 1
$$
注意してください。**$s[i-1]$ と $t[j-1]$ が同じ場合、現在の文字に対して編集は必要ありません**。この場合、状態遷移方程式は:
$$
dp[i, j] = dp[i-1, j-1]
$$
**ステップ3:境界条件と状態遷移の順序を決定する**
両方の文字列が空の場合、編集回数は $0$ です。つまり、$dp[0, 0] = 0$ です。$s$ が空で $t$ が空でない場合、最小編集回数は $t$ の長さに等しく、つまり最初の行 $dp[0, j] = j$ です。$s$ が空でなく $t$ が空の場合、最小編集回数は $s$ の長さに等しく、つまり最初の列 $dp[i, 0] = i$ です。
状態遷移方程式を観察すると、$dp[i, j]$ の解決は左、上、左上の解に依存するため、二重ループを使用して正しい順序で $dp$ テーブル全体を走査できます。
### 2. &nbsp; コード実装
=== "Python"
```python title="edit_distance.py"
def edit_distance_dp(s: str, t: str) -> int:
"""編集距離:動的プログラミング"""
n, m = len(s), len(t)
dp = [[0] * (m + 1) for _ in range(n + 1)]
# 状態遷移:最初の行と最初の列
for i in range(1, n + 1):
dp[i][0] = i
for j in range(1, m + 1):
dp[0][j] = j
# 状態遷移:残りの行と列
for i in range(1, n + 1):
for j in range(1, m + 1):
if s[i - 1] == t[j - 1]:
# 2 つの文字が等しい場合、これら 2 つの文字をスキップ
dp[i][j] = dp[i - 1][j - 1]
else:
# 最小編集数 = 3 つの操作(挿入、削除、置換)からの最小編集数 + 1
dp[i][j] = min(dp[i][j - 1], dp[i - 1][j], dp[i - 1][j - 1]) + 1
return dp[n][m]
```
=== "C++"
```cpp title="edit_distance.cpp"
/* 編集距離:動的プログラミング */
int editDistanceDP(string s, string t) {
int n = s.length(), m = t.length();
vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));
// 状態遷移:最初の行と最初の列
for (int i = 1; i <= n; i++) {
dp[i][0] = i;
}
for (int j = 1; j <= m; j++) {
dp[0][j] = j;
}
// 状態遷移:残りの行と列
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (s[i - 1] == t[j - 1]) {
// 2つの文字が等しい場合、これら2つの文字をスキップ
dp[i][j] = dp[i - 1][j - 1];
} else {
// 最小編集数 = 3つの操作(挿入、削除、置換)からの最小編集数 + 1
dp[i][j] = min(min(dp[i][j - 1], dp[i - 1][j]), dp[i - 1][j - 1]) + 1;
}
}
}
return dp[n][m];
}
```
=== "Java"
```java title="edit_distance.java"
/* 編集距離:動的プログラミング */
int editDistanceDP(String s, String t) {
int n = s.length(), m = t.length();
int[][] dp = new int[n + 1][m + 1];
// 状態遷移:最初の行と最初の列
for (int i = 1; i <= n; i++) {
dp[i][0] = i;
}
for (int j = 1; j <= m; j++) {
dp[0][j] = j;
}
// 状態遷移:残りの行と列
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (s.charAt(i - 1) == t.charAt(j - 1)) {
// 2つの文字が等しい場合、これら2つの文字をスキップ
dp[i][j] = dp[i - 1][j - 1];
} else {
// 最小編集数 = 3つの操作(挿入、削除、置換)からの最小編集数 + 1
dp[i][j] = Math.min(Math.min(dp[i][j - 1], dp[i - 1][j]), dp[i - 1][j - 1]) + 1;
}
}
}
return dp[n][m];
}
```
=== "C#"
```csharp title="edit_distance.cs"
[class]{edit_distance}-[func]{EditDistanceDP}
```
=== "Go"
```go title="edit_distance.go"
[class]{}-[func]{editDistanceDP}
```
=== "Swift"
```swift title="edit_distance.swift"
[class]{}-[func]{editDistanceDP}
```
=== "JS"
```javascript title="edit_distance.js"
[class]{}-[func]{editDistanceDP}
```
=== "TS"
```typescript title="edit_distance.ts"
[class]{}-[func]{editDistanceDP}
```
=== "Dart"
```dart title="edit_distance.dart"
[class]{}-[func]{editDistanceDP}
```
=== "Rust"
```rust title="edit_distance.rs"
[class]{}-[func]{edit_distance_dp}
```
=== "C"
```c title="edit_distance.c"
[class]{}-[func]{editDistanceDP}
```
=== "Kotlin"
```kotlin title="edit_distance.kt"
[class]{}-[func]{editDistanceDP}
```
=== "Ruby"
```ruby title="edit_distance.rb"
[class]{}-[func]{edit_distance_dp}
```
=== "Zig"
```zig title="edit_distance.zig"
[class]{}-[func]{editDistanceDP}
```
下の図に示すように、編集距離問題の状態遷移プロセスはナップサック問題と非常に似ており、二次元グリッドを埋めることと見なすことができます。
=== "<1>"
![編集距離の動的プログラミングプロセス](edit_distance_problem.assets/edit_distance_dp_step1.png){ class="animation-figure" }
=== "<2>"
![edit_distance_dp_step2](edit_distance_problem.assets/edit_distance_dp_step2.png){ class="animation-figure" }
=== "<3>"
![edit_distance_dp_step3](edit_distance_problem.assets/edit_distance_dp_step3.png){ class="animation-figure" }
=== "<4>"
![edit_distance_dp_step4](edit_distance_problem.assets/edit_distance_dp_step4.png){ class="animation-figure" }
=== "<5>"
![edit_distance_dp_step5](edit_distance_problem.assets/edit_distance_dp_step5.png){ class="animation-figure" }
=== "<6>"
![edit_distance_dp_step6](edit_distance_problem.assets/edit_distance_dp_step6.png){ class="animation-figure" }
=== "<7>"
![edit_distance_dp_step7](edit_distance_problem.assets/edit_distance_dp_step7.png){ class="animation-figure" }
=== "<8>"
![edit_distance_dp_step8](edit_distance_problem.assets/edit_distance_dp_step8.png){ class="animation-figure" }
=== "<9>"
![edit_distance_dp_step9](edit_distance_problem.assets/edit_distance_dp_step9.png){ class="animation-figure" }
=== "<10>"
![edit_distance_dp_step10](edit_distance_problem.assets/edit_distance_dp_step10.png){ class="animation-figure" }
=== "<11>"
![edit_distance_dp_step11](edit_distance_problem.assets/edit_distance_dp_step11.png){ class="animation-figure" }
=== "<12>"
![edit_distance_dp_step12](edit_distance_problem.assets/edit_distance_dp_step12.png){ class="animation-figure" }
=== "<13>"
![edit_distance_dp_step13](edit_distance_problem.assets/edit_distance_dp_step13.png){ class="animation-figure" }
=== "<14>"
![edit_distance_dp_step14](edit_distance_problem.assets/edit_distance_dp_step14.png){ class="animation-figure" }
=== "<15>"
![edit_distance_dp_step15](edit_distance_problem.assets/edit_distance_dp_step15.png){ class="animation-figure" }
<p align="center"> 図 14-30 &nbsp; 編集距離の動的プログラミングプロセス </p>
### 3. &nbsp; 空間最適化
$dp[i, j]$ は上の $dp[i-1, j]$、左の $dp[i, j-1]$、左上の $dp[i-1, j-1]$ の解から導出され、直接走査では左上の解 $dp[i-1, j-1]$ が失われ、逆走査では事前に $dp[i, j-1]$ を構築できないため、どちらの走査順序も実行可能ではありません。
この理由で、変数 `leftup` を使用して左上の $dp[i-1, j-1]$ からの解を一時的に保存し、左と上の解のみを考慮すればよくなります。この状況は無制限ナップサック問題と似ており、直接走査が可能です。コードは以下の通りです:
=== "Python"
```python title="edit_distance.py"
def edit_distance_dp_comp(s: str, t: str) -> int:
"""編集距離:空間最適化動的プログラミング"""
n, m = len(s), len(t)
dp = [0] * (m + 1)
# 状態遷移:最初の行
for j in range(1, m + 1):
dp[j] = j
# 状態遷移:残りの行
for i in range(1, n + 1):
# 状態遷移:最初の列
leftup = dp[0] # dp[i-1, j-1] を一時的に保存
dp[0] += 1
# 状態遷移:残りの列
for j in range(1, m + 1):
temp = dp[j]
if s[i - 1] == t[j - 1]:
# 2 つの文字が等しい場合、これら 2 つの文字をスキップ
dp[j] = leftup
else:
# 最小編集数 = 3 つの操作(挿入、削除、置換)からの最小編集数 + 1
dp[j] = min(dp[j - 1], dp[j], leftup) + 1
leftup = temp # 次の dp[i-1, j-1] のために更新
return dp[m]
```
=== "C++"
```cpp title="edit_distance.cpp"
[class]{}-[func]{editDistanceDPComp}
```
=== "Java"
```java title="edit_distance.java"
/* 編集距離:空間最適化動的プログラミング */
int editDistanceDPComp(String s, String t) {
int n = s.length(), m = t.length();
int[] dp = new int[m + 1];
// 状態遷移:最初の行
for (int j = 1; j <= m; j++) {
dp[j] = j;
}
// 状態遷移:残りの行
for (int i = 1; i <= n; i++) {
// 状態遷移:最初の列
int leftup = dp[0]; // dp[i-1, j-1] を一時的に格納
dp[0] = i;
// 状態遷移:残りの列
for (int j = 1; j <= m; j++) {
int temp = dp[j];
if (s.charAt(i - 1) == t.charAt(j - 1)) {
// 2つの文字が等しい場合、これら2つの文字をスキップ
dp[j] = leftup;
} else {
// 最小編集数 = 3つの操作(挿入、削除、置換)からの最小編集数 + 1
dp[j] = Math.min(Math.min(dp[j - 1], dp[j]), leftup) + 1;
}
leftup = temp; // 次のラウンドの dp[i-1, j-1] のために更新
}
}
return dp[m];
}
```
=== "C#"
```csharp title="edit_distance.cs"
[class]{edit_distance}-[func]{EditDistanceDPComp}
```
=== "Go"
```go title="edit_distance.go"
[class]{}-[func]{editDistanceDPComp}
```
=== "Swift"
```swift title="edit_distance.swift"
[class]{}-[func]{editDistanceDPComp}
```
=== "JS"
```javascript title="edit_distance.js"
[class]{}-[func]{editDistanceDPComp}
```
=== "TS"
```typescript title="edit_distance.ts"
[class]{}-[func]{editDistanceDPComp}
```
=== "Dart"
```dart title="edit_distance.dart"
[class]{}-[func]{editDistanceDPComp}
```
=== "Rust"
```rust title="edit_distance.rs"
[class]{}-[func]{edit_distance_dp_comp}
```
=== "C"
```c title="edit_distance.c"
[class]{}-[func]{editDistanceDPComp}
```
=== "Kotlin"
```kotlin title="edit_distance.kt"
[class]{}-[func]{editDistanceDPComp}
```
=== "Ruby"
```ruby title="edit_distance.rb"
[class]{}-[func]{edit_distance_dp_comp}
```
=== "Zig"
```zig title="edit_distance.zig"
[class]{}-[func]{editDistanceDPComp}
```
@@ -0,0 +1,24 @@
---
comments: true
icon: material/table-pivot
---
# 第 14 章 &nbsp; 動的プログラミング
![動的プログラミング](../assets/covers/chapter_dynamic_programming.jpg){ class="cover-image" }
!!! abstract
川が流れて海に注ぐように、
動的プログラミングは小さな問題の解を織り合わせて、より大きな問題の解へと導きます。一歩一歩進んで、最終的な答えが待つ彼岸へと向かいます。
## 章の内容
- [14.1 &nbsp; 動的プログラミング入門](intro_to_dynamic_programming.md)
- [14.2 &nbsp; DP問題の特性](dp_problem_features.md)
- [14.3 &nbsp; DP問題解決アプローチ](dp_solution_pipeline.md)
- [14.4 &nbsp; 0-1ナップサック問題](knapsack_problem.md)
- [14.5 &nbsp; 無制限ナップサック問題](unbounded_knapsack_problem.md)
- [14.6 &nbsp; 編集距離問題](edit_distance_problem.md)
- [14.7 &nbsp; まとめ](summary.md)
@@ -0,0 +1,821 @@
---
comments: true
---
# 14.1 &nbsp; 動的プログラミングの紹介
<u>動的プログラミング</u>は重要なアルゴリズムパラダイムであり、問題を一連の小さな部分問題に分解し、これらの部分問題の解を保存することで冗長な計算を避け、時間効率を大幅に向上させます。
このセクションでは、古典的な問題から始めて、まず力任せの探索法による解法を提示し、重複する部分問題を特定してから、より効率的な動的プログラミング解法を段階的に導出します。
!!! question "階段登り"
$n$ 段の階段があり、一度に $1$ 段または $2$ 段上ることができます。頂上に到達する方法は何通りありますか?
下の図に示すように、$3$ 段の階段の頂上に到達する方法は $3$ 通りあります。
![3段目に到達する方法の数](intro_to_dynamic_programming.assets/climbing_stairs_example.png){ class="animation-figure" }
<p align="center"> 図 14-1 &nbsp; 3段目に到達する方法の数 </p>
この問題は**バックトラッキングを用いてすべての可能性を網羅**することで方法の数を計算することを目的としています。具体的には、階段登りの問題を多段階選択プロセスとして考えます:地面から始めて、毎回 $1$ 段または $2$ 段上るかを選択し、階段の頂上に到達したら方法の数をカウントし、頂上を超えた場合はプルーニング(枝刈り)を行います。コードは以下の通りです:
=== "Python"
```python title="climbing_stairs_backtrack.py"
def backtrack(choices: list[int], state: int, n: int, res: list[int]) -> int:
"""バックトラッキング"""
# n 段目に登ったとき、解の数に 1 を加える
if state == n:
res[0] += 1
# すべての選択肢を走査
for choice in choices:
# 枝刈り:n 段を超えて登ることを許可しない
if state + choice > n:
continue
# 試行:選択を行い、状態を更新
backtrack(choices, state + choice, n, res)
# 撤回
def climbing_stairs_backtrack(n: int) -> int:
"""階段登り:バックトラッキング"""
choices = [1, 2] # 1 段または 2 段登ることを選択可能
state = 0 # 0 段目から登り始める
res = [0] # res[0] を使用して解の数を記録
backtrack(choices, state, n, res)
return res[0]
```
=== "C++"
```cpp title="climbing_stairs_backtrack.cpp"
/* バックトラッキング */
void backtrack(vector<int> &choices, int state, int n, vector<int> &res) {
// n段目に到達したとき、解の数に1を加える
if (state == n)
res[0]++;
// すべての選択肢を走査
for (auto &choice : choices) {
// 剪定:n段を超えて登ることを許可しない
if (state + choice > n)
continue;
// 試行:選択を行い、状態を更新
backtrack(choices, state + choice, n, res);
// 撤回
}
}
/* 階段登り:バックトラッキング */
int climbingStairsBacktrack(int n) {
vector<int> choices = {1, 2}; // 1段または2段登ることを選択可能
int state = 0; // 0段目から登り始める
vector<int> res = {0}; // res[0] を使用して解の数を記録
backtrack(choices, state, n, res);
return res[0];
}
```
=== "Java"
```java title="climbing_stairs_backtrack.java"
/* バックトラッキング */
void backtrack(List<Integer> choices, int state, int n, List<Integer> res) {
// n段目に到達したとき、解の数に1を加える
if (state == n)
res.set(0, res.get(0) + 1);
// すべての選択肢を走査
for (Integer choice : choices) {
// 剪定:n段を超えて登ることを許可しない
if (state + choice > n)
continue;
// 試行:選択を行い、状態を更新
backtrack(choices, state + choice, n, res);
// 撤回
}
}
/* 階段登り:バックトラッキング */
int climbingStairsBacktrack(int n) {
List<Integer> choices = Arrays.asList(1, 2); // 1段または2段登ることを選択可能
int state = 0; // 0段目から登り始める
List<Integer> res = new ArrayList<>();
res.add(0); // res[0] を使用して解の数を記録
backtrack(choices, state, n, res);
return res.get(0);
}
```
=== "C#"
```csharp title="climbing_stairs_backtrack.cs"
[class]{climbing_stairs_backtrack}-[func]{Backtrack}
[class]{climbing_stairs_backtrack}-[func]{ClimbingStairsBacktrack}
```
=== "Go"
```go title="climbing_stairs_backtrack.go"
[class]{}-[func]{backtrack}
[class]{}-[func]{climbingStairsBacktrack}
```
=== "Swift"
```swift title="climbing_stairs_backtrack.swift"
[class]{}-[func]{backtrack}
[class]{}-[func]{climbingStairsBacktrack}
```
=== "JS"
```javascript title="climbing_stairs_backtrack.js"
[class]{}-[func]{backtrack}
[class]{}-[func]{climbingStairsBacktrack}
```
=== "TS"
```typescript title="climbing_stairs_backtrack.ts"
[class]{}-[func]{backtrack}
[class]{}-[func]{climbingStairsBacktrack}
```
=== "Dart"
```dart title="climbing_stairs_backtrack.dart"
[class]{}-[func]{backtrack}
[class]{}-[func]{climbingStairsBacktrack}
```
=== "Rust"
```rust title="climbing_stairs_backtrack.rs"
[class]{}-[func]{backtrack}
[class]{}-[func]{climbing_stairs_backtrack}
```
=== "C"
```c title="climbing_stairs_backtrack.c"
[class]{}-[func]{backtrack}
[class]{}-[func]{climbingStairsBacktrack}
```
=== "Kotlin"
```kotlin title="climbing_stairs_backtrack.kt"
[class]{}-[func]{backtrack}
[class]{}-[func]{climbingStairsBacktrack}
```
=== "Ruby"
```ruby title="climbing_stairs_backtrack.rb"
[class]{}-[func]{backtrack}
[class]{}-[func]{climbing_stairs_backtrack}
```
=== "Zig"
```zig title="climbing_stairs_backtrack.zig"
[class]{}-[func]{backtrack}
[class]{}-[func]{climbingStairsBacktrack}
```
## 14.1.1 &nbsp; 方法1:力任せ探索
バックトラッキングアルゴリズムは問題を明示的に部分問題に分解しません。代わりに、問題を一連の決定ステップとして扱い、試行と枝刈りを通じてすべての可能性を探索します。
この問題を分解アプローチを使って分析できます。$dp[i]$ を $i$ 段目に到達する方法の数とします。この場合、$dp[i]$ が元の問題であり、その部分問題は次のようになります:
$$
dp[i-1], dp[i-2], \dots, dp[2], dp[1]
$$
各移動は $1$ 段または $2$ 段しか進めないため、$i$ 段目に立っているとき、前のステップは $i-1$ 段目または $i-2$ 段目のいずれかにいたはずです。つまり、$i$ 段目には $i-1$ 段目または $i-2$ 段目からしか到達できません。
これにより重要な結論が得られます:**$i-1$ 段目に到達する方法の数に $i-2$ 段目に到達する方法の数を加えたものが、$i$ 段目に到達する方法の数に等しい**。式は以下の通りです:
$$
dp[i] = dp[i-1] + dp[i-2]
$$
これは、階段登り問題において部分問題間に再帰関係があることを意味し、**元の問題の解は部分問題の解から構築できます**。下の図はこの再帰関係を示しています。
![解の数の再帰関係](intro_to_dynamic_programming.assets/climbing_stairs_state_transfer.png){ class="animation-figure" }
<p align="center"> 図 14-2 &nbsp; 解の数の再帰関係 </p>
再帰式に従って力任せ探索解法を得ることができます。$dp[n]$ から始めて、**より大きな問題を再帰的に2つの小さな部分問題の和に分解**し、解が既知の最小の部分問題 $dp[1]$ と $dp[2]$ に到達するまで続けます。$dp[1] = 1$ と $dp[2] = 2$ で、それぞれ1段目と2段目に登る方法が $1$ 通りと $2$ 通りあることを表します。
以下のコードを観察すると、標準的なバックトラッキングコードと同様に深さ優先探索に属しますが、より簡潔です:
=== "Python"
```python title="climbing_stairs_dfs.py"
def dfs(i: int) -> int:
"""探索"""
# 既知の dp[1] と dp[2] は、それらを返す
if i == 1 or i == 2:
return i
# dp[i] = dp[i-1] + dp[i-2]
count = dfs(i - 1) + dfs(i - 2)
return count
def climbing_stairs_dfs(n: int) -> int:
"""階段登り:探索"""
return dfs(n)
```
=== "C++"
```cpp title="climbing_stairs_dfs.cpp"
/* 探索 */
int dfs(int i) {
// 既知の dp[1] と dp[2] を返す
if (i == 1 || i == 2)
return i;
// dp[i] = dp[i-1] + dp[i-2]
int count = dfs(i - 1) + dfs(i - 2);
return count;
}
/* 階段登り:探索 */
int climbingStairsDFS(int n) {
return dfs(n);
}
```
=== "Java"
```java title="climbing_stairs_dfs.java"
/* 探索 */
int dfs(int i) {
// 既知の dp[1] と dp[2] を返す
if (i == 1 || i == 2)
return i;
// dp[i] = dp[i-1] + dp[i-2]
int count = dfs(i - 1) + dfs(i - 2);
return count;
}
/* 階段登り:探索 */
int climbingStairsDFS(int n) {
return dfs(n);
}
```
=== "C#"
```csharp title="climbing_stairs_dfs.cs"
[class]{climbing_stairs_dfs}-[func]{DFS}
[class]{climbing_stairs_dfs}-[func]{ClimbingStairsDFS}
```
=== "Go"
```go title="climbing_stairs_dfs.go"
[class]{}-[func]{dfs}
[class]{}-[func]{climbingStairsDFS}
```
=== "Swift"
```swift title="climbing_stairs_dfs.swift"
[class]{}-[func]{dfs}
[class]{}-[func]{climbingStairsDFS}
```
=== "JS"
```javascript title="climbing_stairs_dfs.js"
[class]{}-[func]{dfs}
[class]{}-[func]{climbingStairsDFS}
```
=== "TS"
```typescript title="climbing_stairs_dfs.ts"
[class]{}-[func]{dfs}
[class]{}-[func]{climbingStairsDFS}
```
=== "Dart"
```dart title="climbing_stairs_dfs.dart"
[class]{}-[func]{dfs}
[class]{}-[func]{climbingStairsDFS}
```
=== "Rust"
```rust title="climbing_stairs_dfs.rs"
[class]{}-[func]{dfs}
[class]{}-[func]{climbing_stairs_dfs}
```
=== "C"
```c title="climbing_stairs_dfs.c"
[class]{}-[func]{dfs}
[class]{}-[func]{climbingStairsDFS}
```
=== "Kotlin"
```kotlin title="climbing_stairs_dfs.kt"
[class]{}-[func]{dfs}
[class]{}-[func]{climbingStairsDFS}
```
=== "Ruby"
```ruby title="climbing_stairs_dfs.rb"
[class]{}-[func]{dfs}
[class]{}-[func]{climbing_stairs_dfs}
```
=== "Zig"
```zig title="climbing_stairs_dfs.zig"
[class]{}-[func]{dfs}
[class]{}-[func]{climbingStairsDFS}
```
下の図は力任せ探索によって形成される再帰木を示しています。問題 $dp[n]$ について、その再帰木の深さは $n$ で、時間計算量は $O(2^n)$ です。この指数的増加により、$n$ が大きいとプログラムの実行がはるかに遅くなり、長い待機時間が生じます。
![階段登りの再帰木](intro_to_dynamic_programming.assets/climbing_stairs_dfs_tree.png){ class="animation-figure" }
<p align="center"> 図 14-3 &nbsp; 階段登りの再帰木 </p>
上の図を観察すると、**指数時間計算量は「重複する部分問題」によって引き起こされる**ことがわかります。例えば、$dp[9]$ は $dp[8]$ と $dp[7]$ に分解され、$dp[8]$ はさらに $dp[7]$ と $dp[6]$ に分解され、両方とも部分問題 $dp[7]$ を含んでいます。
このように、部分問題にはさらに小さな重複する部分問題が含まれ、これは無限に続きます。計算リソースの大部分がこれらの重複する部分問題に浪費されています。
## 14.1.2 &nbsp; 方法2:メモ化探索
アルゴリズムの効率を向上させるため、**すべての重複する部分問題を一度だけ計算したい**と考えます。この目的のため、各部分問題の解を記録する配列 `mem` を宣言し、探索プロセス中に重複する部分問題を枝刈りします。
1. $dp[i]$ が初めて計算されるとき、後で使用するために `mem[i]` に記録します。
2. $dp[i]$ を再度計算する必要があるとき、`mem[i]` から直接結果を取得でき、その部分問題の冗長な計算を避けられます。
コードは以下の通りです:
=== "Python"
```python title="climbing_stairs_dfs_mem.py"
def dfs(i: int, mem: list[int]) -> int:
"""記憶化探索"""
# 既知の dp[1] と dp[2] は、それらを返す
if i == 1 or i == 2:
return i
# dp[i] の記録がある場合、それを返す
if mem[i] != -1:
return mem[i]
# dp[i] = dp[i-1] + dp[i-2]
count = dfs(i - 1, mem) + dfs(i - 2, mem)
# dp[i] を記録
mem[i] = count
return count
def climbing_stairs_dfs_mem(n: int) -> int:
"""階段登り:記憶化探索"""
# mem[i] は i 段目に登る解の総数を記録、-1 は記録なしを意味する
mem = [-1] * (n + 1)
return dfs(n, mem)
```
=== "C++"
```cpp title="climbing_stairs_dfs_mem.cpp"
/* メモ化探索 */
int dfs(int i, vector<int> &mem) {
// 既知の dp[1] と dp[2] を返す
if (i == 1 || i == 2)
return i;
// dp[i] の記録がある場合、それを返す
if (mem[i] != -1)
return mem[i];
// dp[i] = dp[i-1] + dp[i-2]
int count = dfs(i - 1, mem) + dfs(i - 2, mem);
// dp[i] を記録
mem[i] = count;
return count;
}
/* 階段登り:メモ化探索 */
int climbingStairsDFSMem(int n) {
// mem[i] は i 段目に登る総解数を記録、-1 は記録なしを意味する
vector<int> mem(n + 1, -1);
return dfs(n, mem);
}
```
=== "Java"
```java title="climbing_stairs_dfs_mem.java"
/* メモ化探索 */
int dfs(int i, int[] mem) {
// 既知の dp[1] と dp[2] を返す
if (i == 1 || i == 2)
return i;
// dp[i] の記録がある場合、それを返す
if (mem[i] != -1)
return mem[i];
// dp[i] = dp[i-1] + dp[i-2]
int count = dfs(i - 1, mem) + dfs(i - 2, mem);
// dp[i] を記録
mem[i] = count;
return count;
}
/* 階段登り:メモ化探索 */
int climbingStairsDFSMem(int n) {
// mem[i] は i 段目に登る総解数を記録、-1 は記録なしを意味する
int[] mem = new int[n + 1];
Arrays.fill(mem, -1);
return dfs(n, mem);
}
```
=== "C#"
```csharp title="climbing_stairs_dfs_mem.cs"
[class]{climbing_stairs_dfs_mem}-[func]{DFS}
[class]{climbing_stairs_dfs_mem}-[func]{ClimbingStairsDFSMem}
```
=== "Go"
```go title="climbing_stairs_dfs_mem.go"
[class]{}-[func]{dfsMem}
[class]{}-[func]{climbingStairsDFSMem}
```
=== "Swift"
```swift title="climbing_stairs_dfs_mem.swift"
[class]{}-[func]{dfs}
[class]{}-[func]{climbingStairsDFSMem}
```
=== "JS"
```javascript title="climbing_stairs_dfs_mem.js"
[class]{}-[func]{dfs}
[class]{}-[func]{climbingStairsDFSMem}
```
=== "TS"
```typescript title="climbing_stairs_dfs_mem.ts"
[class]{}-[func]{dfs}
[class]{}-[func]{climbingStairsDFSMem}
```
=== "Dart"
```dart title="climbing_stairs_dfs_mem.dart"
[class]{}-[func]{dfs}
[class]{}-[func]{climbingStairsDFSMem}
```
=== "Rust"
```rust title="climbing_stairs_dfs_mem.rs"
[class]{}-[func]{dfs}
[class]{}-[func]{climbing_stairs_dfs_mem}
```
=== "C"
```c title="climbing_stairs_dfs_mem.c"
[class]{}-[func]{dfs}
[class]{}-[func]{climbingStairsDFSMem}
```
=== "Kotlin"
```kotlin title="climbing_stairs_dfs_mem.kt"
[class]{}-[func]{dfs}
[class]{}-[func]{climbingStairsDFSMem}
```
=== "Ruby"
```ruby title="climbing_stairs_dfs_mem.rb"
[class]{}-[func]{dfs}
[class]{}-[func]{climbing_stairs_dfs_mem}
```
=== "Zig"
```zig title="climbing_stairs_dfs_mem.zig"
[class]{}-[func]{dfs}
[class]{}-[func]{climbingStairsDFSMem}
```
下の図を観察すると、**メモ化後、すべての重複する部分問題は一度だけ計算される必要があり、時間計算量を $O(n)$ に最適化**します。これは大幅な改善です。
![メモ化探索による再帰木](intro_to_dynamic_programming.assets/climbing_stairs_dfs_memo_tree.png){ class="animation-figure" }
<p align="center"> 図 14-4 &nbsp; メモ化探索による再帰木 </p>
## 14.1.3 &nbsp; 方法3:動的プログラミング
**メモ化探索は「トップダウン」方式**です:元の問題(根ノード)から始めて、より大きな部分問題をより小さなものに再帰的に分解し、最小の既知の部分問題(葉ノード)の解に到達するまで続けます。その後、バックトラッキングにより部分問題の解を収集し、元の問題の解を構築します。
一方、**動的プログラミングは「ボトムアップ」方式**です:最小の部分問題の解から始めて、元の問題が解決されるまで、より大きな部分問題の解を反復的に構築します。
動的プログラミングはバックトラッキングを必要としないため、ループを使った反復のみが必要で、再帰は不要です。以下のコードでは、配列 `dp` を初期化して部分問題の解を保存し、メモ化探索の配列 `mem` と同じ記録機能を果たします:
=== "Python"
```python title="climbing_stairs_dp.py"
def climbing_stairs_dp(n: int) -> int:
"""階段登り:動的プログラミング"""
if n == 1 or n == 2:
return n
# dp テーブルを初期化、部分問題の解を格納するため使用
dp = [0] * (n + 1)
# 初期状態:最小の部分問題の解を事前設定
dp[1], dp[2] = 1, 2
# 状態遷移:小さい部分問題から大きい部分問題を段階的に解く
for i in range(3, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]
```
=== "C++"
```cpp title="climbing_stairs_dp.cpp"
/* 階段登り:動的プログラミング */
int climbingStairsDP(int n) {
if (n == 1 || n == 2)
return n;
// DPテーブルを初期化し、部分問題の解を格納するために使用
vector<int> dp(n + 1);
// 初期状態:最小の部分問題の解を事前設定
dp[1] = 1;
dp[2] = 2;
// 状態遷移:小さな問題から大きな部分問題を段階的に解く
for (int i = 3; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
```
=== "Java"
```java title="climbing_stairs_dp.java"
/* 階段登り:動的プログラミング */
int climbingStairsDP(int n) {
if (n == 1 || n == 2)
return n;
// DPテーブルを初期化し、部分問題の解を格納するために使用
int[] dp = new int[n + 1];
// 初期状態:最小の部分問題の解を事前設定
dp[1] = 1;
dp[2] = 2;
// 状態遷移:小さな問題から大きな部分問題を段階的に解く
for (int i = 3; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
```
=== "C#"
```csharp title="climbing_stairs_dp.cs"
[class]{climbing_stairs_dp}-[func]{ClimbingStairsDP}
```
=== "Go"
```go title="climbing_stairs_dp.go"
[class]{}-[func]{climbingStairsDP}
```
=== "Swift"
```swift title="climbing_stairs_dp.swift"
[class]{}-[func]{climbingStairsDP}
```
=== "JS"
```javascript title="climbing_stairs_dp.js"
[class]{}-[func]{climbingStairsDP}
```
=== "TS"
```typescript title="climbing_stairs_dp.ts"
[class]{}-[func]{climbingStairsDP}
```
=== "Dart"
```dart title="climbing_stairs_dp.dart"
[class]{}-[func]{climbingStairsDP}
```
=== "Rust"
```rust title="climbing_stairs_dp.rs"
[class]{}-[func]{climbing_stairs_dp}
```
=== "C"
```c title="climbing_stairs_dp.c"
[class]{}-[func]{climbingStairsDP}
```
=== "Kotlin"
```kotlin title="climbing_stairs_dp.kt"
[class]{}-[func]{climbingStairsDP}
```
=== "Ruby"
```ruby title="climbing_stairs_dp.rb"
[class]{}-[func]{climbing_stairs_dp}
```
=== "Zig"
```zig title="climbing_stairs_dp.zig"
[class]{}-[func]{climbingStairsDP}
```
下の図は上記コードの実行プロセスをシミュレートしています。
![階段登りの動的プログラミングプロセス](intro_to_dynamic_programming.assets/climbing_stairs_dp.png){ class="animation-figure" }
<p align="center"> 図 14-5 &nbsp; 階段登りの動的プログラミングプロセス </p>
バックトラッキングアルゴリズムと同様に、動的プログラミングも「状態」の概念を使用して問題解決の特定の段階を表現し、各状態は部分問題とその局所最適解に対応します。例えば、階段登り問題の状態は現在のステップ番号 $i$ として定義されます。
上記の内容に基づいて、動的プログラミングでよく使用される用語をまとめることができます。
- 配列 `dp` は<u>DPテーブル</u>と呼ばれ、$dp[i]$ は状態 $i$ に対応する部分問題の解を表します。
- 最小の部分問題(ステップ $1$ と $2$)に対応する状態は<u>初期状態</u>と呼ばれます。
- 再帰式 $dp[i] = dp[i-1] + dp[i-2]$ は<u>状態遷移方程式</u>と呼ばれます。
## 14.1.4 &nbsp; 空間最適化
注意深い読者は**$dp[i]$ は $dp[i-1]$ と $dp[i-2]$ のみに関連するため、すべての部分問題の解を保存するために配列 `dp` を使用する必要がない**ことに気づくでしょう。単に2つの変数を使って反復的に進めることができます。コードは以下の通りです:
=== "Python"
```python title="climbing_stairs_dp.py"
def climbing_stairs_dp_comp(n: int) -> int:
"""階段登り:空間最適化動的プログラミング"""
if n == 1 or n == 2:
return n
a, b = 1, 2
for _ in range(3, n + 1):
a, b = b, a + b
return b
```
=== "C++"
```cpp title="climbing_stairs_dp.cpp"
/* 階段登り:空間最適化動的プログラミング */
int climbingStairsDPComp(int n) {
if (n == 1 || n == 2)
return n;
int a = 1, b = 2;
for (int i = 3; i <= n; i++) {
int tmp = b;
b = a + b;
a = tmp;
}
return b;
}
```
=== "Java"
```java title="climbing_stairs_dp.java"
/* 階段登り:空間最適化動的プログラミング */
int climbingStairsDPComp(int n) {
if (n == 1 || n == 2)
return n;
int a = 1, b = 2;
for (int i = 3; i <= n; i++) {
int tmp = b;
b = a + b;
a = tmp;
}
return b;
}
```
=== "C#"
```csharp title="climbing_stairs_dp.cs"
[class]{climbing_stairs_dp}-[func]{ClimbingStairsDPComp}
```
=== "Go"
```go title="climbing_stairs_dp.go"
[class]{}-[func]{climbingStairsDPComp}
```
=== "Swift"
```swift title="climbing_stairs_dp.swift"
[class]{}-[func]{climbingStairsDPComp}
```
=== "JS"
```javascript title="climbing_stairs_dp.js"
[class]{}-[func]{climbingStairsDPComp}
```
=== "TS"
```typescript title="climbing_stairs_dp.ts"
[class]{}-[func]{climbingStairsDPComp}
```
=== "Dart"
```dart title="climbing_stairs_dp.dart"
[class]{}-[func]{climbingStairsDPComp}
```
=== "Rust"
```rust title="climbing_stairs_dp.rs"
[class]{}-[func]{climbing_stairs_dp_comp}
```
=== "C"
```c title="climbing_stairs_dp.c"
[class]{}-[func]{climbingStairsDPComp}
```
=== "Kotlin"
```kotlin title="climbing_stairs_dp.kt"
[class]{}-[func]{climbingStairsDPComp}
```
=== "Ruby"
```ruby title="climbing_stairs_dp.rb"
[class]{}-[func]{climbing_stairs_dp_comp}
```
=== "Zig"
```zig title="climbing_stairs_dp.zig"
[class]{}-[func]{climbingStairsDPComp}
```
上記のコードを観察すると、配列 `dp` が占有していた空間が削除されるため、空間計算量は $O(n)$ から $O(1)$ に削減されます。
多くの動的プログラミング問題では、現在の状態は限られた数の前の状態のみに依存するため、必要な状態のみを保持し、「次元削減」によってメモリ空間を節約できます。**この空間最適化技術は「ローリング変数」または「ローリング配列」として知られています**。
@@ -0,0 +1,679 @@
---
comments: true
---
# 14.4 &nbsp; 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){ class="animation-figure" }
<p align="center"> 図 14-17 &nbsp; 0-1ナップサックの例データ </p>
0-1ナップサック問題を $n$ ラウンドの決定から構成されるプロセスとして考えることができます。各アイテムについて入れない、または入れるという2つの決定があり、したがって問題は決定木モデルに適合します。
この問題の目的は「限られた容量の下でナップサックに入れることができるアイテムの値を最大化する」ことであり、動的プログラミング問題である可能性が高いです。
**第1ステップ:各ラウンドの決定を考え、状態を定義し、それにより $dp$ テーブルを得る**
各アイテムについて、ナップサックに入れなければ容量は変わらず、入れれば容量は減少します。これから状態定義を得ることができます:現在のアイテム番号 $i$ とナップサック容量 $c$、$[i, c]$ と表記されます。
状態 $[i, c]$ は部分問題に対応します:**容量 $c$ のナップサックでの最初の $i$ 個のアイテムの最大値**、$dp[i, c]$ と表記されます。
探している解は $dp[n, cap]$ であるため、サイズ $(n+1) \times (cap+1)$ の二次元 $dp$ テーブルが必要です。
**第2ステップ:最適部分構造を特定し、状態遷移方程式を導出する**
アイテム $i$ の決定を行った後、残るのは最初の $i-1$ 個のアイテムの決定の部分問題であり、これは2つのケースに分けることができます。
- **アイテム $i$ を入れない**:ナップサック容量は変わらず、状態は $[i-1, c]$ に変わります。
- **アイテム $i$ を入れる**:ナップサック容量は $wgt[i-1]$ だけ減少し、値は $val[i-1]$ だけ増加し、状態は $[i-1, c-wgt[i-1]]$ に変わります。
上記の分析により、この問題の最適部分構造が明らかになります:**最大値 $dp[i, c]$ は、アイテム $i$ を入れない方案とアイテム $i$ を入れる方案の2つのうち、より大きな値に等しい**。これから状態遷移方程式を導出できます:
$$
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]]$ から遷移するため、2層のループを通じて $dp$ テーブル全体を順序通りに走査します。
上記の分析に従って、次に力任せ探索、メモ化探索、動的プログラミングの順序で解法を実装します。
### 1. &nbsp; 方法1:力任せ探索
探索コードには以下の要素が含まれます。
- **再帰パラメータ**:状態 $[i, c]$。
- **戻り値**:部分問題 $dp[i, c]$ の解。
- **終了条件**:アイテム番号が範囲外 $i = 0$ またはナップサックの残り容量が $0$ のとき、再帰を終了し値 $0$ を返す。
- **枝刈り**:現在のアイテムの重量がナップサックの残り容量を超える場合、唯一の選択肢はナップサックに入れないことです。
=== "Python"
```python title="knapsack.py"
def knapsack_dfs(wgt: list[int], val: list[int], i: int, c: int) -> int:
"""0-1 ナップサック:ブルートフォース探索"""
# すべてのアイテムが選択されたかナップサックに残り容量がない場合、値 0 を返す
if i == 0 or c == 0:
return 0
# ナップサック容量を超える場合、ナップサックに入れないことしか選択できない
if wgt[i - 1] > c:
return knapsack_dfs(wgt, val, i - 1, c)
# アイテム i を入れないのと入れるのとの最大値を計算
no = knapsack_dfs(wgt, val, i - 1, c)
yes = knapsack_dfs(wgt, val, i - 1, c - wgt[i - 1]) + val[i - 1]
# 2 つの選択肢のうち大きい値を返す
return max(no, yes)
```
=== "C++"
```cpp title="knapsack.cpp"
/* 0-1 ナップサック:ブルートフォース探索 */
int knapsackDFS(vector<int> &wgt, vector<int> &val, int i, int c) {
// すべてのアイテムが選択されたか、ナップサックに残り容量がない場合、値 0 を返す
if (i == 0 || c == 0) {
return 0;
}
// ナップサックの容量を超える場合、ナップサックに入れないことしか選択できない
if (wgt[i - 1] > c) {
return knapsackDFS(wgt, val, i - 1, c);
}
// アイテム i を入れない場合と入れる場合の最大値を計算
int no = knapsackDFS(wgt, val, i - 1, c);
int yes = knapsackDFS(wgt, val, i - 1, c - wgt[i - 1]) + val[i - 1];
// 2つの選択肢のより大きい値を返す
return max(no, yes);
}
```
=== "Java"
```java title="knapsack.java"
/* 0-1 ナップサック:ブルートフォース探索 */
int knapsackDFS(int[] wgt, int[] val, int i, int c) {
// すべてのアイテムが選択されたか、ナップサックに残り容量がない場合、値 0 を返す
if (i == 0 || c == 0) {
return 0;
}
// ナップサックの容量を超える場合、ナップサックに入れないことしか選択できない
if (wgt[i - 1] > c) {
return knapsackDFS(wgt, val, i - 1, c);
}
// アイテム i を入れない場合と入れる場合の最大値を計算
int no = knapsackDFS(wgt, val, i - 1, c);
int yes = knapsackDFS(wgt, val, i - 1, c - wgt[i - 1]) + val[i - 1];
// 2つの選択肢のより大きい値を返す
return Math.max(no, yes);
}
```
=== "C#"
```csharp title="knapsack.cs"
[class]{knapsack}-[func]{KnapsackDFS}
```
=== "Go"
```go title="knapsack.go"
[class]{}-[func]{knapsackDFS}
```
=== "Swift"
```swift title="knapsack.swift"
[class]{}-[func]{knapsackDFS}
```
=== "JS"
```javascript title="knapsack.js"
[class]{}-[func]{knapsackDFS}
```
=== "TS"
```typescript title="knapsack.ts"
[class]{}-[func]{knapsackDFS}
```
=== "Dart"
```dart title="knapsack.dart"
[class]{}-[func]{knapsackDFS}
```
=== "Rust"
```rust title="knapsack.rs"
[class]{}-[func]{knapsack_dfs}
```
=== "C"
```c title="knapsack.c"
[class]{}-[func]{knapsackDFS}
```
=== "Kotlin"
```kotlin title="knapsack.kt"
[class]{}-[func]{knapsackDFS}
```
=== "Ruby"
```ruby title="knapsack.rb"
[class]{}-[func]{knapsack_dfs}
```
=== "Zig"
```zig title="knapsack.zig"
[class]{}-[func]{knapsackDFS}
```
下の図に示すように、各アイテムは選択しないと選択するという2つの探索分岐を生成するため、時間計算量は $O(2^n)$ です。
再帰木を観察すると、$dp[1, 10]$ などの重複する部分問題があることが容易にわかります。アイテムが多く、ナップサック容量が大きい場合、特に同じ重量のアイテムが多い場合、重複する部分問題の数は大幅に増加します。
![0-1ナップサック問題の力任せ探索再帰木](knapsack_problem.assets/knapsack_dfs.png){ class="animation-figure" }
<p align="center"> 図 14-18 &nbsp; 0-1ナップサック問題の力任せ探索再帰木 </p>
### 2. &nbsp; 方法2:メモ化探索
重複する部分問題が一度だけ計算されることを確保するために、部分問題の解を記録するメモ化リスト `mem` を使用します。ここで `mem[i][c]` は $dp[i, c]$ に対応します。
メモ化を導入した後、**時間計算量は部分問題の数に依存**し、$O(n \times cap)$ になります。実装コードは以下の通りです:
=== "Python"
```python title="knapsack.py"
def knapsack_dfs_mem(
wgt: list[int], val: list[int], mem: list[list[int]], i: int, c: int
) -> int:
"""0-1 ナップサック:記憶化探索"""
# すべてのアイテムが選択されたかナップサックに残り容量がない場合、値 0 を返す
if i == 0 or c == 0:
return 0
# 記録がある場合、それを返す
if mem[i][c] != -1:
return mem[i][c]
# ナップサック容量を超える場合、ナップサックに入れないことしか選択できない
if wgt[i - 1] > c:
return knapsack_dfs_mem(wgt, val, mem, i - 1, c)
# アイテム i を入れないのと入れるのとの最大値を計算
no = knapsack_dfs_mem(wgt, val, mem, i - 1, c)
yes = knapsack_dfs_mem(wgt, val, mem, i - 1, c - wgt[i - 1]) + val[i - 1]
# 2 つの選択肢のうち大きい値を記録して返す
mem[i][c] = max(no, yes)
return mem[i][c]
```
=== "C++"
```cpp title="knapsack.cpp"
[class]{}-[func]{knapsackDFSMem}
```
=== "Java"
```java title="knapsack.java"
/* 0-1 ナップサック:メモ化探索 */
int knapsackDFSMem(int[] wgt, int[] val, int[][] mem, int i, int c) {
// すべてのアイテムが選択されたか、ナップサックに残り容量がない場合、値 0 を返す
if (i == 0 || c == 0) {
return 0;
}
// 記録がある場合、それを返す
if (mem[i][c] != -1) {
return mem[i][c];
}
// ナップサックの容量を超える場合、ナップサックに入れないことしか選択できない
if (wgt[i - 1] > c) {
return knapsackDFSMem(wgt, val, mem, i - 1, c);
}
// アイテム i を入れない場合と入れる場合の最大値を計算
int no = knapsackDFSMem(wgt, val, mem, i - 1, c);
int yes = knapsackDFSMem(wgt, val, mem, i - 1, c - wgt[i - 1]) + val[i - 1];
// 2つの選択肢のより大きい値を記録して返す
mem[i][c] = Math.max(no, yes);
return mem[i][c];
}
```
=== "C#"
```csharp title="knapsack.cs"
[class]{knapsack}-[func]{KnapsackDFSMem}
```
=== "Go"
```go title="knapsack.go"
[class]{}-[func]{knapsackDFSMem}
```
=== "Swift"
```swift title="knapsack.swift"
[class]{}-[func]{knapsackDFSMem}
```
=== "JS"
```javascript title="knapsack.js"
[class]{}-[func]{knapsackDFSMem}
```
=== "TS"
```typescript title="knapsack.ts"
[class]{}-[func]{knapsackDFSMem}
```
=== "Dart"
```dart title="knapsack.dart"
[class]{}-[func]{knapsackDFSMem}
```
=== "Rust"
```rust title="knapsack.rs"
[class]{}-[func]{knapsack_dfs_mem}
```
=== "C"
```c title="knapsack.c"
[class]{}-[func]{knapsackDFSMem}
```
=== "Kotlin"
```kotlin title="knapsack.kt"
[class]{}-[func]{knapsackDFSMem}
```
=== "Ruby"
```ruby title="knapsack.rb"
[class]{}-[func]{knapsack_dfs_mem}
```
=== "Zig"
```zig title="knapsack.zig"
[class]{}-[func]{knapsackDFSMem}
```
下の図はメモ化探索で枝刈りされる探索分岐を示しています。
![0-1ナップサック問題のメモ化探索再帰木](knapsack_problem.assets/knapsack_dfs_mem.png){ class="animation-figure" }
<p align="center"> 図 14-19 &nbsp; 0-1ナップサック問題のメモ化探索再帰木 </p>
### 3. &nbsp; 方法3:動的プログラミング
動的プログラミングは本質的に状態遷移中に $dp$ テーブルを埋めることを含みます。コードは下の図に示されています:
=== "Python"
```python title="knapsack.py"
def knapsack_dp(wgt: list[int], val: list[int], cap: int) -> int:
"""0-1 ナップサック:動的プログラミング"""
n = len(wgt)
# dp テーブルを初期化
dp = [[0] * (cap + 1) for _ in range(n + 1)]
# 状態遷移
for i in range(1, n + 1):
for c in range(1, cap + 1):
if wgt[i - 1] > c:
# ナップサック容量を超える場合、アイテム i を選択しない
dp[i][c] = dp[i - 1][c]
else:
# アイテム i を選択しないのと選択するのとで大きい値
dp[i][c] = max(dp[i - 1][c], dp[i - 1][c - wgt[i - 1]] + val[i - 1])
return dp[n][cap]
```
=== "C++"
```cpp title="knapsack.cpp"
/* 0-1 ナップサック:動的プログラミング */
int knapsackDP(vector<int> &wgt, vector<int> &val, int cap) {
int n = wgt.size();
// DPテーブルを初期化
vector<vector<int>> dp(n + 1, vector<int>(cap + 1, 0));
// 状態遷移
for (int i = 1; i <= n; i++) {
for (int c = 1; c <= cap; c++) {
if (wgt[i - 1] > c) {
// ナップサックの容量を超える場合、アイテム i を選択しない
dp[i][c] = dp[i - 1][c];
} else {
// 選択しない場合とアイテム i を選択する場合のより大きい値
dp[i][c] = max(dp[i - 1][c], dp[i - 1][c - wgt[i - 1]] + val[i - 1]);
}
}
}
return dp[n][cap];
}
```
=== "Java"
```java title="knapsack.java"
/* 0-1 ナップサック:動的プログラミング */
int knapsackDP(int[] wgt, int[] val, int cap) {
int n = wgt.length;
// DPテーブルを初期化
int[][] dp = new int[n + 1][cap + 1];
// 状態遷移
for (int i = 1; i <= n; i++) {
for (int c = 1; c <= cap; c++) {
if (wgt[i - 1] > c) {
// ナップサックの容量を超える場合、アイテム i を選択しない
dp[i][c] = dp[i - 1][c];
} else {
// 選択しない場合とアイテム i を選択する場合のより大きい値
dp[i][c] = Math.max(dp[i - 1][c], dp[i - 1][c - wgt[i - 1]] + val[i - 1]);
}
}
}
return dp[n][cap];
}
```
=== "C#"
```csharp title="knapsack.cs"
[class]{knapsack}-[func]{KnapsackDP}
```
=== "Go"
```go title="knapsack.go"
[class]{}-[func]{knapsackDP}
```
=== "Swift"
```swift title="knapsack.swift"
[class]{}-[func]{knapsackDP}
```
=== "JS"
```javascript title="knapsack.js"
[class]{}-[func]{knapsackDP}
```
=== "TS"
```typescript title="knapsack.ts"
[class]{}-[func]{knapsackDP}
```
=== "Dart"
```dart title="knapsack.dart"
[class]{}-[func]{knapsackDP}
```
=== "Rust"
```rust title="knapsack.rs"
[class]{}-[func]{knapsack_dp}
```
=== "C"
```c title="knapsack.c"
[class]{}-[func]{knapsackDP}
```
=== "Kotlin"
```kotlin title="knapsack.kt"
[class]{}-[func]{knapsackDP}
```
=== "Ruby"
```ruby title="knapsack.rb"
[class]{}-[func]{knapsack_dp}
```
=== "Zig"
```zig title="knapsack.zig"
[class]{}-[func]{knapsackDP}
```
下の図に示すように、時間計算量と空間計算量の両方が配列 `dp` のサイズ、つまり $O(n \times cap)$ によって決定されます。
=== "<1>"
![0-1ナップサック問題の動的プログラミングプロセス](knapsack_problem.assets/knapsack_dp_step1.png){ class="animation-figure" }
=== "<2>"
![knapsack_dp_step2](knapsack_problem.assets/knapsack_dp_step2.png){ class="animation-figure" }
=== "<3>"
![knapsack_dp_step3](knapsack_problem.assets/knapsack_dp_step3.png){ class="animation-figure" }
=== "<4>"
![knapsack_dp_step4](knapsack_problem.assets/knapsack_dp_step4.png){ class="animation-figure" }
=== "<5>"
![knapsack_dp_step5](knapsack_problem.assets/knapsack_dp_step5.png){ class="animation-figure" }
=== "<6>"
![knapsack_dp_step6](knapsack_problem.assets/knapsack_dp_step6.png){ class="animation-figure" }
=== "<7>"
![knapsack_dp_step7](knapsack_problem.assets/knapsack_dp_step7.png){ class="animation-figure" }
=== "<8>"
![knapsack_dp_step8](knapsack_problem.assets/knapsack_dp_step8.png){ class="animation-figure" }
=== "<9>"
![knapsack_dp_step9](knapsack_problem.assets/knapsack_dp_step9.png){ class="animation-figure" }
=== "<10>"
![knapsack_dp_step10](knapsack_problem.assets/knapsack_dp_step10.png){ class="animation-figure" }
=== "<11>"
![knapsack_dp_step11](knapsack_problem.assets/knapsack_dp_step11.png){ class="animation-figure" }
=== "<12>"
![knapsack_dp_step12](knapsack_problem.assets/knapsack_dp_step12.png){ class="animation-figure" }
=== "<13>"
![knapsack_dp_step13](knapsack_problem.assets/knapsack_dp_step13.png){ class="animation-figure" }
=== "<14>"
![knapsack_dp_step14](knapsack_problem.assets/knapsack_dp_step14.png){ class="animation-figure" }
<p align="center"> 図 14-20 &nbsp; 0-1ナップサック問題の動的プログラミングプロセス </p>
### 4. &nbsp; 空間最適化
各状態は上の行の状態のみに関連するため、2つの配列を使用してローリング前進させ、空間計算量を $O(n^2)$ から $O(n)$ に削減できます。
さらに考えてみると、1つの配列だけで空間最適化を達成できるでしょうか?各状態が直接上のセルまたは左上のセルから遷移することが観察できます。配列が1つしかない場合、$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){ class="animation-figure" }
=== "<2>"
![knapsack_dp_comp_step2](knapsack_problem.assets/knapsack_dp_comp_step2.png){ class="animation-figure" }
=== "<3>"
![knapsack_dp_comp_step3](knapsack_problem.assets/knapsack_dp_comp_step3.png){ class="animation-figure" }
=== "<4>"
![knapsack_dp_comp_step4](knapsack_problem.assets/knapsack_dp_comp_step4.png){ class="animation-figure" }
=== "<5>"
![knapsack_dp_comp_step5](knapsack_problem.assets/knapsack_dp_comp_step5.png){ class="animation-figure" }
=== "<6>"
![knapsack_dp_comp_step6](knapsack_problem.assets/knapsack_dp_comp_step6.png){ class="animation-figure" }
<p align="center"> 図 14-21 &nbsp; 0-1ナップサックの空間最適化動的プログラミングプロセス </p>
コード実装では、配列 `dp` の最初の次元 $i$ を削除し、内側のループを逆走査に変更するだけです:
=== "Python"
```python title="knapsack.py"
def knapsack_dp_comp(wgt: list[int], val: list[int], cap: int) -> int:
"""0-1 ナップサック:空間最適化動的プログラミング"""
n = len(wgt)
# dp テーブルを初期化
dp = [0] * (cap + 1)
# 状態遷移
for i in range(1, n + 1):
# 逆順で走査
for c in range(cap, 0, -1):
if wgt[i - 1] > c:
# ナップサック容量を超える場合、アイテム i を選択しない
dp[c] = dp[c]
else:
# アイテム i を選択しないのと選択するのとで大きい値
dp[c] = max(dp[c], dp[c - wgt[i - 1]] + val[i - 1])
return dp[cap]
```
=== "C++"
```cpp title="knapsack.cpp"
/* 0-1 ナップサック:空間最適化動的プログラミング */
int knapsackDPComp(vector<int> &wgt, vector<int> &val, int cap) {
int n = wgt.size();
// DPテーブルを初期化
vector<int> dp(cap + 1, 0);
// 状態遷移
for (int i = 1; i <= n; i++) {
// 逆順で走査
for (int c = cap; c >= 1; c--) {
if (wgt[i - 1] <= c) {
// 選択しない場合とアイテム i を選択する場合のより大きい値
dp[c] = max(dp[c], dp[c - wgt[i - 1]] + val[i - 1]);
}
}
}
return dp[cap];
}
```
=== "Java"
```java title="knapsack.java"
/* 0-1 ナップサック:空間最適化動的プログラミング */
int knapsackDPComp(int[] wgt, int[] val, int cap) {
int n = wgt.length;
// DPテーブルを初期化
int[] dp = new int[cap + 1];
// 状態遷移
for (int i = 1; i <= n; i++) {
// 逆順で走査
for (int c = cap; c >= 1; c--) {
if (wgt[i - 1] <= c) {
// 選択しない場合とアイテム i を選択する場合のより大きい値
dp[c] = Math.max(dp[c], dp[c - wgt[i - 1]] + val[i - 1]);
}
}
}
return dp[cap];
}
```
=== "C#"
```csharp title="knapsack.cs"
[class]{knapsack}-[func]{KnapsackDPComp}
```
=== "Go"
```go title="knapsack.go"
[class]{}-[func]{knapsackDPComp}
```
=== "Swift"
```swift title="knapsack.swift"
[class]{}-[func]{knapsackDPComp}
```
=== "JS"
```javascript title="knapsack.js"
[class]{}-[func]{knapsackDPComp}
```
=== "TS"
```typescript title="knapsack.ts"
[class]{}-[func]{knapsackDPComp}
```
=== "Dart"
```dart title="knapsack.dart"
[class]{}-[func]{knapsackDPComp}
```
=== "Rust"
```rust title="knapsack.rs"
[class]{}-[func]{knapsack_dp_comp}
```
=== "C"
```c title="knapsack.c"
[class]{}-[func]{knapsackDPComp}
```
=== "Kotlin"
```kotlin title="knapsack.kt"
[class]{}-[func]{knapsackDPComp}
```
=== "Ruby"
```ruby title="knapsack.rb"
[class]{}-[func]{knapsack_dp_comp}
```
=== "Zig"
```zig title="knapsack.zig"
[class]{}-[func]{knapsackDPComp}
```
@@ -0,0 +1,27 @@
---
comments: true
---
# 14.7 &nbsp; まとめ
- 動的プログラミングは問題を分解し、部分問題の解を保存することで冗長な計算を避け、計算効率を向上させます。
- 時間を考慮しなければ、すべての動的プログラミング問題はバックトラッキング(力任せ探索)を使用して解決できますが、再帰木には多くの重複する部分問題があり、効率が非常に低くなります。記憶化リストを導入することで、計算されたすべての部分問題の解を保存し、重複する部分問題が一度だけ計算されることを保証できます。
- 記憶化探索はトップダウンの再帰解法であり、動的プログラミングはボトムアップの反復アプローチに対応し、「表を埋める」ことに似ています。現在の状態は特定の局所状態のみに依存するため、dpテーブルの1次元を削除して空間計算量を削減できます。
- 部分問題の分解は汎用的なアルゴリズムアプローチであり、分割統治法、動的プログラミング、バックトラッキングで特徴が異なります。
- 動的プログラミング問題には3つの主要な特徴があります:重複する部分問題、最適部分構造、無記憶性。
- 元の問題の最適解がその部分問題の最適解から構築できる場合、最適部分構造を持ちます。
- 無記憶性とは、状態の将来の発展が現在の状態のみに依存し、過去に経験したすべての状態に依存しないことを意味します。多くの組み合わせ最適化問題にはこの特性がなく、動的プログラミングを使用して迅速に解決することはできません。
**ナップサック問題**
- ナップサック問題は最も典型的な動的プログラミング問題の1つで、0-1ナップサック、無制限ナップサック、複数ナップサックなどの変種があります。
- 0-1ナップサックの状態定義は、最初の $i$ 個のアイテムを含む容量 $c$ のナップサックでの最大値です。アイテムをナップサックに入れないまたは入れるという決定に基づいて、最適部分構造を特定し、状態遷移方程式を構築できます。空間最適化では、各状態が直接上と左上の状態に依存するため、左上の状態の上書きを避けるためにリストを逆順で走査する必要があります。
- 無制限ナップサック問題では、各種類のアイテムを選択できる数に制限がないため、アイテムを含める状態遷移は0-1ナップサックと異なります。状態が直接上と左の状態に依存するため、空間最適化では前方走査を含める必要があります。
- コイン交換問題は無制限ナップサック問題の変種で、「最大」値を求めることから「最小」コイン数を求めることに変わり、状態遷移方程式は $\max()$ を $\min()$ に変更する必要があります。ナップサックの容量を「超えない」ことを追求することから、正確に目標金額を求めることに変わり、「目標金額を構成できない」無効解を表すために $amt + 1$ を使用します。
- コイン交換問題IIは「最小コイン数」を求めることから「コインの組み合わせ数」を求めることに変わり、状態遷移方程式を $\min()$ から和算演算子に変更します。
**編集距離問題**
- 編集距離(レーベンシュタイン距離)は2つの文字列間の類似度を測定し、一つの文字列を別の文字列に変更するために必要な最小編集ステップ数として定義され、編集操作には追加、削除、置換が含まれます。
- 編集距離問題の状態定義は、$s$ の最初の $i$ 文字を $t$ の最初の $j$ 文字に変更するために必要な最小編集ステップ数です。$s[i] \ne t[j]$ の場合、追加、削除、置換の3つの決定があり、それぞれに対応する残余部分問題があります。これから最適部分構造を特定し、状態遷移方程式を構築できます。$s[i] = t[j]$ の場合、現在の文字の編集は必要ありません。
- 編集距離では、状態が直接上、左、左上の状態に依存します。したがって、空間最適化後、前方走査も逆走査も正しく状態遷移を実行できません。これに対処するため、変数を使用して左上の状態を一時的に保存し、無制限ナップサック問題の状況と同等にし、空間最適化後に前方走査を可能にします。
File diff suppressed because it is too large Load Diff