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
This commit is contained in:
Yudong Jin
2026-10-30 02:12:26 +08:00
committed by GitHub
parent 455e8c1281
commit 7257641e0e
1987 changed files with 23531 additions and 23261 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,79 +1,79 @@
# 動的計画法の問題特性
# 動的計画法の問題の特徴
前節では、動的計画法が部分問題への分解によってどのように元の問題を解くのかを学びました。実際、部分問題への分解は汎用的なアルゴリズムの考え方であり、分割統治法、動的計画法、バックトラッキングでは重視点が異なります。
前の節では、動的計画法が部分問題への分解によって元の問題を解く仕組みを学びました。部分問題への分解は汎用的な考え方ですが、分割統治、動的計画法、バックトラッキングでは重点が異なります。
- 分割統治法は、元の問題を再帰的に複数の互いに独立した部分問題へ分割し、最小の部分問題に至るまで分解したうえで、バックトラック時に部分問題の解を統合し、最終的に元の問題の解を得ます。
- 動的計画法も問題を再帰的に分解しますが、分割統治法との主な違いは、動的計画法における部分問題が相互依存しており、分解の過程で多数の重複部分問題が現れることです。
- バックトラッキング法は、試行と巻き戻しの中ですべての可能な解を列挙し、枝刈りによって不要な探索分岐を避けます。元の問題の解は一連の意思決定ステップから構成されるため、各決定ステップ以前の部分系列を一つの部分問題と見なせます。
- 分割統治は、元の問題を互いに独立した複数の部分問題へ再帰的に分割し、最小の部分問題まで進みます。その後、戻りながら解を統合し、元の問題の解を得ます。
- 動的計画法も問題を再帰的に分解します。分割統治との主な違いは、部分問題が互いに依存し、分解の過程で多くの重複した部分問題が現れることです。
- バックトラッキングは試行と後戻りを繰り返してすべての可能な解を列挙し、枝刈りで不要な探索分岐を避けます。元の問題の解は一連の判断で構成され、各判断の直前までの部分列を一つの部分問題と捉えられます。
実際、動的計画法は最適化問題を解くためによく用いられます。これらは重複部分問題を含むだけでなく、さらに二つの大きな特性、すなわち最適部分構造と無後効性を備えています。
実際、動的計画法は最適化問題を解くためによく使われます。そうした問題は部分問題の重複に加え、最適部分構造と、後続の判断が過去の経路に依存しない性質という二つの重要な特徴を持ちます。
## 最適部分構造
階段昇り問題を少し変更し、最適部分構造の概念をより示しやすくします。
階段を上る問題を少し変えて、最適部分構造を説明しやすくしてみましょう。
!!! question "階段昇りの最小コスト"
!!! question "最小コストで階段を上る"
階段が与えられ、各ステップで $1$ 段または $2$ 段上ることができます。各段には非負整数が貼られており、その段に到達するために支払う必要があるコストを表します。非負整数配列 $cost$ が与えられ、$cost[i]$ は第 $i$ 段で支払うコストを表し、$cost[0]$ は地面(開始地点)です。頂上に到達するために必要な最小コストを求めてください。
階段があり、1 回で $1$ 段または $2$ 段上れます。各段には非負の整数が書かれており、その段で支払うコストを表します。非負整数の配列 $cost$ が与えられ、$cost[i]$ は $i$ 段目でのコスト、$cost[0]$ は地面(開始点)に対応します。一番上まで到達するための最小コストを求めてください。
下図に示すように、第 $1$、$2$、$3$ 段のコストがそれぞれ $1$、$10$、$1$ である場合、地面から第 $3$ 段まで上る最小コストは $2$ です。
下図のように、$1$、$2$、$3$ 段目のコストがそれぞれ $1$、$10$、$1$ なら、地面から $3$ 段目まで上る最小コストは $2$ です。
![第 3 段まで上る最小コスト](dp_problem_features.assets/min_cost_cs_example.png)
![3 段目まで上る最小コスト](dp_problem_features.assets/min_cost_cs_example.png)
$dp[i]$ を第 $i$ 段まで上るのに累積して支払ったコストとします。第 $i$ 段には $i - 1$ 段または $i - 2$ 段からしか到達できないため、$dp[i]$ は $dp[i - 1] + cost[i]$ または $dp[i - 2] + cost[i]$ のいずれかになります。コストをできるだけ小さくするには、この二つのうち小さいほうを選べばよいです。
$i$ 段目までの累積コストを $dp[i]$ とします。$i$ 段目には $i - 1$ 段目か $i - 2$ 段目からしか来られないため、$dp[i]$ は $dp[i - 1] + cost[i]$ または $dp[i - 2] + cost[i]$ です。コストをできるだけ小さくするには、二つのうち小さい方を選びます。
$$
dp[i] = \min(dp[i-1], dp[i-2]) + cost[i]
$$
ここから最適部分構造の意味を導けます。**元の問題の最適解は、部分問題の最適解から構築される**ということです。
ここから、最適部分構造の意味がわかります。**元の問題の最適解は、部分問題の最適解から組み立てられる**ということです。
この問題が最適部分構造を持つことは明らかです。二つの部分問題の最適解 $dp[i-1]$ と $dp[i-2]$ からより良いほうを選び、それを用いて元の問題 $dp[i]$ の最適解を構築しています。
この問題には明らかに最適部分構造があります。二つの部分問題の最適解 $dp[i-1]$ と $dp[i-2]$ からよい方を選び、それを使って元の問題 $dp[i]$ の最適解を組み立てるからです。
では、前節の階段昇り問題には最適部分構造があるのでしょうか。その目的は方法数を求めることで、一見すると計数問題です。しかし問い方を変えて「最大の方法数を求める」とすると、意外にも、**問題の変更前後は等価であるにもかかわらず、最適部分構造が現れます**。すなわち、第 $n$ 段の最大方法数は第 $n-1$ 段と第 $n-2$ 段の最大方法数の和に等しいのです。このように、最適部分構造の解釈は比較的柔軟であり、問題によって意味合いが異なります。
では、前の節の階段の問題にも最適部分構造はあるのでしょうか。目的は方法の数を求めることで、一見すると数え上げの問題です。しかし「方法の数の最大値を求める」と言い換えると、**問題自体は同じなのに、最適部分構造が見えてきます**。$n$ 段目までの方法の最大数は、$n-1$ 段目と $n-2$ 段目までの方法の最大数の和です。このように、最適部分構造は柔軟に解釈でき、問題によって意味合いが異なります。
状態遷移方程式と初期状態 $dp[1] = cost[1]$ および $dp[2] = cost[2]$ に基づいて、次の動的計画法コードが得られます。
状態遷移式と、初期状態 $dp[1] = cost[1]$、$dp[2] = cost[2]$ から、動的計画法のコードが得られます。
```src
[file]{min_cost_climbing_stairs_dp}-[class]{}-[func]{min_cost_climbing_stairs_dp}
```
下図は上記コードの動的計画法の過程を示しています。
下図に、上のコードによる動的計画法の流れを示します。
![階段昇り最小コストの動的計画法の過程](dp_problem_features.assets/min_cost_cs_dp.png)
![最小コストで階段を上る動的計画法の流れ](dp_problem_features.assets/min_cost_cs_dp.png)
この問題では空間最適化も可能であり、一次元をゼロ次元まで圧縮することで、空間計算量を $O(n)$ から $O(1)$ に削減できます。
この問題も、一次元を零次元に圧縮する空間最適化により、空間計算量を $O(n)$ から $O(1)$ に下げられます。
```src
[file]{min_cost_climbing_stairs_dp}-[class]{}-[func]{min_cost_climbing_stairs_dp_comp}
```
## 無後効性
## 後続の判断が過去の経路に依存しない性質
無後効性は、動的計画法が問題を効率よく解ける重要な特性の一つであり、その定義は次のとおりです。**ある確定した状態が与えられたとき、その後の発展は現在の状態のみに依存し、過去に経たすべての状態には依存しない**。
動的計画法が問題を効率よく解くうえで重要なのが、後続の判断が過去の経路に依存しない性質です。これは、**ある状態が決まれば、その後の展開は現在の状態だけで決まり、そこまでに通ったすべての状態には依存しない**ことを意味します。
階段昇り問題を例にすると、状態 $i$ が与えられたとき、そこから状態 $i+1$ と状態 $i+2$ へ発展し、それぞれ $1$ 段進む場合と $2$ 段進む場合に対応します。この二つの選択を行う際、状態 $i$ より前の状態を考慮する必要はなく、それらは状態 $i$ の将来に影響を与えません。
階段の問題では、状態 $i$ から状態 $i+1$ または $i+2$ に進めます。これは、それぞれ $1$ 段または $2$ 段上る選択です。どちらを選ぶときも、$i$ より前の状態を考慮する必要はなく、過去の状態は $i$ から先の展開に影響しません。
しかし、階段昇り問題に制約を一つ追加すると、状況は変わります。
しかし、階段の問題に制約を一つ加えると、事情が変わります。
!!! question "制約付き階段昇り"
!!! question "制約付きの階段上り"
全部で $n$ 段ある階段が与えられ、各ステップで $1$ 段または $2$ 段上ることができます。**ただし、連続する 2 回で $1$ 段ずつ上ることはできません**。頂上まで上る方法は何通りあるでしょうか。
$n$ 段の階段があります。1 回で $1$ 段または $2$ 段上れますが、**$1$ 段ずつ上ることを 2 回連続では行えません**。一番上まで上る方法は何通りありますか。
下図に示すように、第 $3$ 段まで上る実行可能な方法は $2$ 通りしか残りません。そのうち、$1$ 段ずつ 3 回連続で上る方法は制約を満たさないため除外されます。
下図のように、$3$ 段目まで上る有効な方法は $2$ 通りだけになります。$1$ 段ずつ 3 回続けて上る方法は制約を満たさないため、除外されます。
![制約付きで第 3 段まで上る方法数](dp_problem_features.assets/climbing_stairs_constraint_example.png)
![制約付きで 3 段目まで上る方法の数](dp_problem_features.assets/climbing_stairs_constraint_example.png)
この問題では、前回が $1$ 段上りだった場合、次回は必ず $2$ 段上らなければなりません。これは、**次の一手が現在の状態(現在いる階段の段数)だけでは独立に決まらず、一つ前の状態(前回いた段数)にも関係する**ことを意味します。
この問題では、前回 $1$ 段上ったなら、次は必ず $2$ 段上る必要があります。つまり、**次の選択は現在の状態(現在の段数)だけでは決まらず、一つ前の状態(前回いた段数)にも依存します**。
容易に分かるように、この問題はもはや無後効性を満たしておらず、状態遷移方程式 $dp[i] = dp[i-1] + dp[i-2]$ も成立しません。というのも、$dp[i-1]$ は今回 $1$ 段上る場合を表しますが、その中には「前回も $1$ 段上ってきた」方法が多数含まれており、制約を満たすためには $dp[i-1]$ をそのまま $dp[i]$ に加えることができないからです。
この状態の定義では、過去の経路に依存しない性質が成り立たず、状態遷移式 $dp[i] = dp[i-1] + dp[i-2]$ も使えません。$dp[i-1]$ は今回 $1$ 段上る場合に対応しますが、その中には「前回も $1$ 段上った」方法が多数含まれます。制約を満たすためには、$dp[i-1]$ をそのまま $dp[i]$ に加えられません。
このため、状態定義を拡張する必要があります。**状態 $[i, j]$ は、第 $i$ 段にいて前回に $j$ 段上ったことを表す**とし、ここで $j \in \{1, 2\}$ です。この状態定義により、前回が $1$ 段上りか $2$ 段上りかを有効に区別でき、現在の状態がどこから来たのかを判断できます。
そこで状態の定義を拡張し、**状態 $[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]$ から遷移できます。
- 前回 $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]$ に対応する方法数を表します。このとき状態遷移方程式は次のようになります。
下図のように、$dp[i, j]$ を状態 $[i, j]$ に対応する方法の数とすると、状態遷移式は次のようになります。
$$
\begin{cases}
@@ -82,20 +82,20 @@ dp[i, 2] = dp[i-2, 1] + dp[i-2, 2]
\end{cases}
$$
![制約を考慮した漸化関係](dp_problem_features.assets/climbing_stairs_constraint_state_transfer.png)
![制約を考慮した漸化式](dp_problem_features.assets/climbing_stairs_constraint_state_transfer.png)
最終的に、$dp[n, 1] + dp[n, 2]$ を返せば十分であり、その和が第 $n$ 段まで上る方法の総数を表します。
最後に $dp[n, 1] + dp[n, 2]$ を返します。この和が、$n$ 段目まで上る方法の総数です。
```src
[file]{climbing_stairs_constraint_dp}-[class]{}-[func]{climbing_stairs_constraint_dp}
```
上の例では、追加で考慮すべきなのは一つ前の状態だけであるため、状態定義を拡張することで問題を再び無後効性に適合させることができます。しかし、問題によっては非常に強い「後効性」があります。
この例では、一つ前の状態を追加で考慮するだけなので、状態を拡張すれば、再び過去の経路に依存しない形にできます。しかし、「過去の経路への依存」が非常に強い問題もあります。
!!! question "階段昇りと障害物生成"
!!! question "階段を上ると障害物ができる問題"
全部で $n$ 段ある階段が与えられ、各ステップで $1$ 段または $2$ 段上ることができます。**第 $i$ 段に到達すると、システムは自動的に第 $2i$ 段に障害物を置き、それ以降はどの回でも第 $2i$ 段へ跳ぶことができない**とします。例えば、最初の 2 回でそれぞれ第 $2$ 段、第 $3$ 段に到達した場合、その後は第 $4$ 段と第 $6$ 段に跳ぶことはできません。頂上まで上る方法は何通りあるでしょうか。
$n$ 段の階段があり、1 回で $1$ 段または $2$ 段上れます。**$i$ 段目に到達すると、システムが自動的に $2i$ 段目に障害物を置き、それ以降は $2i$ 段目に上れなくなる**ものとします。たとえば、最初の 2 回で $2$ 段目、$3$ 段目に上ったら、その後は $4$ 段目、$6$ 段目には上れません。一番上まで上る方法は何通りありますか。
この問題では、次の跳躍が過去のすべての状態に依存します。なぜなら、各跳躍がより高い段に障害物を設置し、将来の跳躍に影響するからです。この種の問題は、動的計画法では解きにくいことが多いです。
この問題では、毎回の移動が上の段に障害物を置き、その後の移動に影響するため、次の移動は過去のすべての状態に依存します。この種の問題は、動的計画法では解くのが難しいことが多いです。
実際、多くの複雑な組合せ最適化問題(例えば巡回セールスマン問題)は無後効性を満たしません。このような問題に対しては、通常、ヒューリスティック探索、遺伝的アルゴリズム、強化学習などの他の方法を用いて、限られた時間内に実用的な局所最適解を得ます。
実際、多くの複雑な組合せ最適化問題(巡回セールスマン問題など)は、過去の経路に依存しない性質を満たしません。そのような問題には、ヒューリスティック探索、遺伝的アルゴリズム、強化学習などの別の方法を選び、限られた時間で利用可能な局所最適解を得ることが一般的です。

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -1,182 +1,182 @@
# 動的計画法の問題解決の考え方
# 動的計画法の解き方
前の 2 節では動的計画法の問題の主要な特徴を紹介しました。ここからは、さらに実用的な 2 つの問題を一緒に考えていきます。
前の 2 節では、動的計画法の問題の主な特徴を説明しました。ここからは、より実践的な二つの疑問を考えていきます。
1. ある問題が動的計画法の問題かどうかを、どのように判断すればよいでしょうか?
2. 動的計画法の問題を解くには、どこから着手し、完全な手順はどのようなものでしょうか?
1. ある問題が動的計画法に適しているかどうかは、どのように判断するのでしょうか。
2. 動的計画法の問題は何から手を付け、どのような手順で解けばよいのでしょうか。
## 問題の判定
## 問題の見極め
一般に、ある問題が重複部分問題と最適部分構造を含み、さらに無後效性を満たしているなら、通常は動的計画法で解くのに適しています。しかし、問題文からこれらの性質を直接読み取るのは簡単ではありません。そのため通常は条件を少し緩めて、**まずその問題がバックトラッキング(全探索)で解くのに適しているか**を観察します。
一般に、部分問題の重複と最適部分構造があり、後続の判断が過去の経路に依存しない問題は、動的計画法に適しています。ただし、問題文からこれらの性質を直接見抜くのは難しいため、通常は条件を緩め、**まずバックトラッキング(総当たり)で解けそうかを考えます**。
**バックトラッキングで解くのに適した問題は、通常「決定木モデル」を満たします**。この種の問題は木構造で表現でき、各ノードは 1 つの決定を表し、各経路は 1 つの決定列を表します。
**バックトラッキングに適した問題は、通常「決定木モデル」で表せます**。木構造で記述でき、各ノードが判断を、各経路が一連の判断を表す問題です。
言い換えると、問題に明確な決定の概念が含まれており、解が一連の決定によって生成されるなら、その問題は決定木モデルを満たし、通常はバックトラッキングで解くことができます。
言い換えると、明確な選択があり、その選択を順に行って解が得られるなら、決定木モデルに当てはまり、通常はバックトラッキングで解けます。
これに加えて、動的計画法の問題には判定のための「加点要素」もあります。
これに加え、動的計画法の問題らしいと判断する「プラス材料」があります。
- 問題文に最大(最小)や最多(最少)などの最適化に関する記述がある。
- 問題の状態が配列、多次元行列、または木で表現でき、ある状態とその周辺の状態の間に漸化的な関係がある。
- 最大・最小、最多・最少などの最適化を求めています。
- 状態をリスト、多次元行列、木などで表せ、ある状態とその周囲の状態の間に漸化式があります。
反対に、「減点要素」もあります。
反対に、次のような「マイナス材料」もあります。
- 問題の目的が最適解を求めることではなく、あり得るすべての解を列挙することである。
- 問題文に明確な順列・組合せの特徴があり、具体的な複数の解を返す必要がある。
- 最適解ではなく、可能な解をすべて求めることが目標です。
- 順列や組合せの特徴が明らかで、具体的な複数の方法を返す必要があります。
ある問題が決定木モデルを満たし、さらに比較的明確な「加点要素」を備えているなら、その問題は動的計画法の問題であると仮定し、解く過程でそれを検証できます。
決定木モデルに当てはまり、「プラス材料」もはっきりしているなら、動的計画法の問題と仮定して取り組み、解く過程で確かめていけます。
## 問題を解く手順
動的計画法の解法の流れは問題の性質や難易度によって異なりますが、通常は次の手順に従います。すなわち、決定を記述し、状態を定義し、$dp$ テーブルを構築し、状態遷移方程式を導出し、境界条件を定めます。
動的計画法の手順は問題の性質や難しさによって異なりますが、通常は、選択の記述、状態の定義、$dp$ テーブルの作成、状態遷移式の導出、境界条件の決定などの順で進めます。
解法の手順をより具体的に示すために、ここでは古典的な問題である「最小経路和」を例にします。
手順を具体的に示すため、典型的な「経路和の最小化」問題を使います。
!!! question
$n \times m$ の 2 次元グリッド `grid` が与えられます。グリッドの各セルには非負整数が格納されており、そのセルのコストを表します。ロボットは左上のセルを始点とし、毎回下または右に 1 マスだけ移動して、右下のセルまで進みます。左上から右下までの最小経路和を返してください。
$n \times m$ の二次元グリッド `grid` が与えられます。各マスには、そのマスのコストを表す非負の整数があります。ロボットは左上のマスから出発し、毎回、下または右に 1 マスだけ進んで、右下のマスを目指します。左上から右下までの経路和の最小値を返してください。
次の図は 1 つの例を示しており、このグリッドの最小経路和は $13$ です。
下図のグリッドでは、経路和の最小値は $13$ です。
![最小経路和のサンプルデータ](dp_solution_pipeline.assets/min_path_sum_example.png)
![経路和の最小化問題のデータ例](dp_solution_pipeline.assets/min_path_sum_example.png)
**ステップ 1:各ラウンドの決定を考え、状態を定義して、$dp$ テーブルを得る**
**第 1 段階:各回の選択を考えて状態を定義し、$dp$ テーブルを作る**
この問題における各ラウンドの決定は、現在のマスから下または右へ 1 マス進むことです。現在のマスの行・列インデックスを $[i, j]$ とすると、下または右へ 1 マス進んだ後のインデックスは $[i+1, j]$ または $[i, j+1]$ になります。したがって、状態には行インデックスと列インデックスの 2 つの変数を含め、$[i, j]$ と表します。
各回の選択は、現在のマスから下または右に 1 マス進むことです。現在の行と列のインデックスを $[i, j]$ とすると、移動後は $[i+1, j]$ または $[i, j+1]$ になります。したがって、状態は行と列の二つの変数を含む $[i, j]$ とします。
状態 $[i, j]$ に対応する部分問題は、始点 $[0, 0]$ から $[i, j]$ まで進む最小経路和であり、その解を $dp[i, j]$ と記します。
状態 $[i, j]$ に対応する部分問題は、開始点 $[0, 0]$ から $[i, j]$ までの経路和の最小値を求めることで、その解を $dp[i, j]$ とします。
これで、次の図に示す 2 次元の $dp$ 行列が得られます。そのサイズは入力グリッド $grid$ と同じです。
これで、下図の二次元の $dp$ 行列が得られます。大きさは入力グリッド $grid$ と同じです。
![状態の定義と dp テーブル](dp_solution_pipeline.assets/min_path_sum_solution_state_definition.png)
!!! note
動的計画法とバックトラッキングの過程は、いずれも 1 つの決定列として記述できます。そして状態は、すべての決定変数から構成されます。状態には解法の進行状況を表すすべての変数が含まれているべきであり、次の状態を導くのに十分な情報を持っている必要があります。
動的計画法やバックトラッキングの過程は、一連の判断として表せます。状態はすべての判断変数で構成し、問題を解く過程の進み具合を表す変数をすべて含めます。次の状態を導くのに十分な情報を持たせる必要があります。
各状態は 1 つの部分問題に対応しており、すべての部分問題の解を保存するために $dp$ テーブルを定義します。状態の各独立変数は、$dp$ テーブルの 1 つの次元に対応します。本質的に、$dp$ テーブルは状態と部分問題の解との対応関係です。
各状態は一つの部分問題に対応し、$dp$ テーブルにすべての部分問題の解を保存します。状態の各独立変数が、$dp$ テーブルの一つの次元になります。本質的には、$dp$ テーブルは状態から部分問題の解への対応表です。
**ステップ 2:最適部分構造を見つけ、状態遷移方程式を導出する**
**第 2 段階:最適部分構造を見つけ、状態遷移式を導く**
状態 $[i, j]$ は、上のマス $[i-1, j]$ または左のマス $[i, j-1]$ からしか遷移してきません。したがって最適部分構造は、$[i, j]$ に到達する最小経路和が、$[i, j-1]$ の最小経路和と $[i-1, j]$ の最小経路和のうち小さい方によって決まる、ということです。
状態 $[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)
![最適部分構造と状態遷移式](dp_solution_pipeline.assets/min_path_sum_solution_state_transition.png)
!!! note
定義済みの $dp$ テーブルに基づいて、元の問題と部分問題の関係を考え、部分問題の最適解から元の問題の最適解を構成する方法、すなわち最適部分構造を見つけます。
定義した $dp$ テーブルをもとに、元の問題と部分問題の関係を考えます。部分問題の最適解から元の問題の最適解を組み立てる方法、すなわち最適部分構造を見つけます。
ひとたび最適部分構造が見つかれば、それを使って状態遷移方程式を構築できます。
最適部分構造が見つかれば、それを使って状態遷移式を組み立てられます。
**ステップ 3:境界条件と状態遷移の順序を決める**
**第 3 段階:境界条件と状態遷移の順序を決める**
この問題では、先頭行にある状態は左の状態からしか得られず、先頭列にある状態は上の状態からしか得られません。したがって、先頭行 $i = 0$ と先頭列 $j = 0$ が境界条件になります。
この問題では、先頭行の状態には左からしか到達できず、先頭列の状態には上からしか到達できません。そのため、先頭行 $i = 0$ と先頭列 $j = 0$ が境界条件になります。
次の図に示すように、各マスは左のマスと上のマスから遷移してくるため、ループを用いて行列を走査します。外側のループで各行を、内側のループで各列を走査します。
下図のように、各マスは左と上のマスから遷移するため、ループで行列を走査します。外側のループで行を、内側のループで列を走査します。
![境界条件と状態遷移の順序](dp_solution_pipeline.assets/min_path_sum_solution_initial_state.png)
!!! note
境界条件は、動的計画法では $dp$ テーブルの初期化に使われ、探索では枝刈りに使われます。
境界条件は、動的計画法では $dp$ テーブルの初期化に、探索では枝刈りに使います。
状態遷移の順序で重要なのは、現在の問題の解を計算するときに、それが依存するより小さな部分問題の解がすべてすでに正しく計算済みであることを保証する点です。
状態遷移の順序で重要なのは、現在の問題の解を計算する時点で、依存するすべての小さな部分問題の解が正しく計算済みであることです。
以上の分析により、すでに動的計画法のコードを直接書くことができます。しかし、部分問題への分解はトップダウンの考え方であるため、「力任せ探索 $\rightarrow$ メモ化探索 $\rightarrow$ 動的計画法」の順に実装するほうが、思考の流れにはより自然です。
ここまでの分析から、動的計画法のコードを直接書けます。ただし、部分問題への分解は上から下への考え方なので、「全探索 $\rightarrow$ メモ化探索 $\rightarrow$ 動的計画法」の順に実装する方が、自然な思考の流れに沿っています。
### 方法 1:力任せ探索
### 方法 1:全探索
状態 $[i, j]$ から探索を開始し、より小さな状態 $[i-1, j]$ と $[i, j-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$ を返し、実行不可能であることを表す。
- **再帰の引数**:状態 $[i, j]$ です。
- **戻り値**:$[0, 0]$ から $[i, j]$ までの経路和の最小値 $dp[i, j]$ です。
- **終了条件**:$i = 0$ かつ $j = 0$ なら、コスト $grid[0, 0]$ を返します。
- **枝刈り**:$i < 0$ または $j < 0$ なら範囲外なので、到達不可能を表すコスト $+\infty$ を返します。
実装コードは次のとおりです。
実装は次のとおりです。
```src
[file]{min_path_sum}-[class]{}-[func]{min_path_sum_dfs}
```
次の図は、$dp[2, 1]$ を根ノードとする再帰木を示しています。この中にはいくつかの重複部分問題が含まれており、その数はグリッド `grid` のサイズが大きくなるにつれて急激に増加します。
下図は、$dp[2, 1]$ を根とする再帰木です。部分問題の重複が含まれており、グリッド `grid` が大きくなると、その数は急激に増えます。
本質的に、重複部分問題が生じる理由は、**左上からあるセルへ到達する経路が複数存在すること**にあります。
部分問題が重複する本質的な理由は、**左上からあるマスに到達する経路が複数あること**です。
![力任せ探索の再帰木](dp_solution_pipeline.assets/min_path_sum_dfs.png)
![全探索の再帰木](dp_solution_pipeline.assets/min_path_sum_dfs.png)
各状態には下と右の 2 通りの選択肢があり、左上から右下まで進むには合計で $m + n - 2$ 歩必要です。したがって最悪時間計算量は $O(2^{m + n})$ です。ここで、$n$ と $m$ はそれぞれグリッドの行数と列数を表します。なお、この見積もりではグリッド境界付近の状況を考慮していません。境界に達すると選択肢は 1 つだけになるため、実際の経路数はこれより少なくなります。
各状態で下と右の二つの選択肢があり、左上から右下までは計 $m + n - 2$ 歩なので、最悪時間計算量は $O(2^{m + n})$ です。ここで $n$ と $m$ はそれぞれ行数と列数です。ただし、この計算ではグリッドの端付近を考慮していません。端に達すると選択肢は一つになるため、実際の経路数はこれより少なくなります。
### 方法 2:メモ化探索
グリッド `grid` と同じサイズのメモ配列 `mem` を導入し、各部分問題の解を記録して、重複部分問題を枝刈りします。
グリッド `grid` と同じ大きさの記録用リスト `mem` を導入し、各部分問題の解を保存して、重複する部分問題を枝刈りします。
```src
[file]{min_path_sum}-[class]{}-[func]{min_path_sum_dfs_mem}
```
次の図に示すように、メモ化を導入すると、すべての部分問題の解は 1 回だけ計算すればよくなります。したがって時間計算量は状態総数、すなわちグリッドサイズの $O(nm)$ に依存します。
下図のように、メモ化すると各部分問題の解を 1 回だけ計算すれば済みます。そのため、時間計算量は状態の総数、すなわちグリッドの大きさ $O(nm)$ になります。
![メモ化探索の再帰木](dp_solution_pipeline.assets/min_path_sum_dfs_mem.png)
### 方法 3:動的計画法
反復に基づいて動的計画法の解法を実装すると、コードは次のようになります。
反復による動的計画法の実装を次に示します。
```src
[file]{min_path_sum}-[class]{}-[func]{min_path_sum_dp}
```
次の図は最小経路和の状態遷移の過程を示しています。グリッド全体を走査するため、**時間計算量は $O(nm)$** です。
下図に、経路和の最小化の状態遷移を示します。グリッド全体を走査するため、**時間計算量は $O(nm)$ です**。
配列 `dp` のサイズは $n \times m$ であるため、**空間計算量は $O(nm)$** です。
配列 `dp` の大きさは $n \times m$ なので、**空間計算量は $O(nm)$ です**。
=== "<1>"
![最小経路和の動的計画法の過程](dp_solution_pipeline.assets/min_path_sum_dp_step1.png)
![経路和の最小化を動的計画法で解く流れ](dp_solution_pipeline.assets/min_path_sum_dp_step1.png)
=== "<2>"
![min_path_sum_dp_step2](dp_solution_pipeline.assets/min_path_sum_dp_step2.png)
![経路和の最小化を動的計画法で解く流れ:手順 2](dp_solution_pipeline.assets/min_path_sum_dp_step2.png)
=== "<3>"
![min_path_sum_dp_step3](dp_solution_pipeline.assets/min_path_sum_dp_step3.png)
![経路和の最小化を動的計画法で解く流れ:手順 3](dp_solution_pipeline.assets/min_path_sum_dp_step3.png)
=== "<4>"
![min_path_sum_dp_step4](dp_solution_pipeline.assets/min_path_sum_dp_step4.png)
![経路和の最小化を動的計画法で解く流れ:手順 4](dp_solution_pipeline.assets/min_path_sum_dp_step4.png)
=== "<5>"
![min_path_sum_dp_step5](dp_solution_pipeline.assets/min_path_sum_dp_step5.png)
![経路和の最小化を動的計画法で解く流れ:手順 5](dp_solution_pipeline.assets/min_path_sum_dp_step5.png)
=== "<6>"
![min_path_sum_dp_step6](dp_solution_pipeline.assets/min_path_sum_dp_step6.png)
![経路和の最小化を動的計画法で解く流れ:手順 6](dp_solution_pipeline.assets/min_path_sum_dp_step6.png)
=== "<7>"
![min_path_sum_dp_step7](dp_solution_pipeline.assets/min_path_sum_dp_step7.png)
![経路和の最小化を動的計画法で解く流れ:手順 7](dp_solution_pipeline.assets/min_path_sum_dp_step7.png)
=== "<8>"
![min_path_sum_dp_step8](dp_solution_pipeline.assets/min_path_sum_dp_step8.png)
![経路和の最小化を動的計画法で解く流れ:手順 8](dp_solution_pipeline.assets/min_path_sum_dp_step8.png)
=== "<9>"
![min_path_sum_dp_step9](dp_solution_pipeline.assets/min_path_sum_dp_step9.png)
![経路和の最小化を動的計画法で解く流れ:手順 9](dp_solution_pipeline.assets/min_path_sum_dp_step9.png)
=== "<10>"
![min_path_sum_dp_step10](dp_solution_pipeline.assets/min_path_sum_dp_step10.png)
![経路和の最小化を動的計画法で解く流れ:手順 10](dp_solution_pipeline.assets/min_path_sum_dp_step10.png)
=== "<11>"
![min_path_sum_dp_step11](dp_solution_pipeline.assets/min_path_sum_dp_step11.png)
![経路和の最小化を動的計画法で解く流れ:手順 11](dp_solution_pipeline.assets/min_path_sum_dp_step11.png)
=== "<12>"
![min_path_sum_dp_step12](dp_solution_pipeline.assets/min_path_sum_dp_step12.png)
![経路和の最小化を動的計画法で解く流れ:手順 12](dp_solution_pipeline.assets/min_path_sum_dp_step12.png)
### 空間最適化
### 空間の最適化
各マスは左のマスと上のマスにのみ関係するため、1 行の配列だけを使って $dp$ テーブルを実装できます。
各マスが依存するのは左と上のマスだけなので、1 行分の配列だけで $dp$ テーブルを実装できます。
ただし、配列 `dp` は 1 行分の状態しか表せないため、先頭列の状態を事前に初期化することはできず、各行を走査するときに更新する必要があります。
配列 `dp` は 1 行分の状態しか表せないため、先頭列をあらかじめ初期化することはできません。各行を走査するときに、その先頭の状態を更新することに注意してください。
```src
[file]{min_path_sum}-[class]{}-[func]{min_path_sum_dp_comp}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -1,69 +1,69 @@
# 編集距離問題
編集距離は、Levenshtein 距離とも呼ばれ、2つの文字列の相互変換に必要な最小の編集回数を指し、通常は情報検索や自然言語処理において2つの系列の類似度を測るために用いられます。
編集距離は Levenshtein 距離とも呼ばれ、一方の文字列をもう一方に変換するために必要な最小の変更回数を表します。情報検索や自然言語処理で、二つの列の類似度を測るためによく使われます。
!!! question
2つの文字列 $s$ と $t$ を入力し、$s$ を $t$ に変換するのに必要な最小編集回数を返してください。
二つの文字列 $s$ と $t$ が与えられます。$s$ を $t$ に変換するための最小の編集回数を返してください。
1つの文字列に対して3種類の編集操作を行えます。1文字の挿入、1文字の削除、任意の文字への置換です。
文字列には、文字を一つ挿入する、文字を一つ削除する、文字を任意の文字に置き換える、という 3 種類の編集操作を行えます。
下図に示すように、`kitten` を `sitting` に変換するには 3 回の編集が必要で、内訳は 2 回の置換と 1 回の挿入です。`hello` を `algo` に変換する場合も 3 回必要で、内訳は 2 回の置換と 1 回の削除です。
下図のように、`kitten` を `sitting` に変換するには、置換 2 回と挿入 1 回の計 3 回が必要です。`hello` を `algo` に変換するには、置換 2 回と削除 1 回の計 3 回が必要です。
![編集距離のサンプルデータ](edit_distance_problem.assets/edit_distance_example.png)
![編集距離のデータ例](edit_distance_problem.assets/edit_distance_example.png)
**編集距離問題は決定木モデルで自然に説明できます**。文字列が木のノードに対応し、1回の決定(1回の編集操作)が木の1本の辺に対応します。
**編集距離問題は、決定木モデルで自然に説明できます**。文字列が木のノードに対応し、1 回の判断(編集操作)が辺に対応します。
下図に示すように、操作に制限がない場合、各ノードからは多くの辺を派生でき、それぞれの辺が1種類の操作に対応します。これは `hello` から `algo` への変換に多くの経路があり得ることを意味します。
下図のように、操作を制限しなければ各ノードから多くの辺が伸び、各辺が一つの操作に対応します。つまり、`hello` から `algo` への変換には、多くの経路が考えられます。
決定木の観点から見ると、本問の目標はノード `hello` とノード `algo` の間の最短経路を求めることです。
決定木の観点では、この問題の目標はノード `hello` とノード `algo` の間の最短経路を求めることです。
![決定木モデルに基づく編集距離問題の表現](edit_distance_problem.assets/edit_distance_decision_tree.png)
![決定木モデルによる編集距離問題の表現](edit_distance_problem.assets/edit_distance_decision_tree.png)
### 動的計画法の考え方
**第1ステップ:各ラウンドの決定を考え、状態を定義して、$dp$ テーブルを得る**
**第 1 段階:各回の選択を考えて状態を定義し、$dp$ テーブルを作る**
各ラウンドの決定は、文字列 $s$ に対して1回の編集操作を行うことです。
各回の選択は、文字列 $s$ に編集操作を 1 回行うことです。
編集操作の過程で問題の規模が徐々に小さくなることを期待します。そうして初めて部分問題を構築できます。文字列 $s$ と $t$ の長さをそれぞれ $n$ と $m$ とし、まず両文字列の末尾の文字 $s[n-1]$ と $t[m-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回の編集(挿入、削除、置換)を行い、両文字列の末尾の文字を同じにします。そうすることでそれらをスキップし、より小さい問題を考えられます。
- $s[n-1]$ と $t[m-1]$ が同じなら、それらを飛ばして $s[n-2]$ と $t[m-2]$ を考えられます。
- $s[n-1]$ と $t[m-1]$ が異なるなら、$s$ に編集(挿入、削除、置換)を 1 回行って末尾の文字を一致させ、それらを飛ばして、より小さな問題を考えます。
つまり、文字列 $s$ に対する各ラウンドの決定(編集操作)は、$s$ と $t$ における残りの未一致文字を変化させます。したがって、状態は現在 $s$ と $t$ で考えている第 $i$ と第 $j$ 文字とし、$[i, j]$ と記します。
つまり、文字列 $s$ に対する各回の判断(編集操作)によって、$s$ と $t$ のまだ対応させていない文字が変わります。そこで、現在考えている $s$ の $i$ 番目と $t$ の $j$ 番目の文字を状態とし、$[i, j]$ と表します。
状態 $[i, j]$ に対応する部分問題は、**$s$ の先頭 $i$ 文字を $t$ の先頭 $j$ 文字に変換するのに必要な最小編集回数**です。
状態 $[i, j]$ の部分問題は、**$s$ の先頭 $i$ 文字を $t$ の先頭 $j$ 文字に変換するための最小編集回数**です。
これにより、サイズが $(n+1) \times (m+1)$ の2次元 $dp$ テーブルが得られます。
これで、$(n+1) \times (m+1)$ の二次元の $dp$ テーブルが得られます。
**第2ステップ:最適部分構造を見つけ、状態遷移方程式を導く**
**第 2 段階:最適部分構造を見つけ、状態遷移式を導く**
部分問題 $dp[i, j]$ を考えます。これに対応する2つの文字列の末尾文字は $s[i-1]$ と $t[j-1]$ であり、編集操作の違いに応じて下図の3つの場合に分けられます。
部分問題 $dp[i, j]$ に対応する二つの文字列の末尾は、$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]$ です。
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)
以上の分析から、最適部分構造は次のように得られます。$dp[i, j]$ の最小編集回数は、$dp[i, j-1]$、$dp[i-1, j]$、$dp[i-1, j-1]$ の3つのうち最小の編集回数に、今回の編集回数 $1$ を加えたものです。対応する状態遷移方程式は次のとおりです:
ここから最適部分構造が得られます。$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]$ が同じ場合、現在の文字を編集する必要はありません**。この場合の状態遷移方程式は次のとおりです:
**$s[i-1]$ と $t[j-1]$ が同じなら、その文字を編集する必要はありません**。この場合の状態遷移式は次のとおりです。
$$
dp[i, j] = dp[i-1, j-1]
$$
**第3ステップ:境界条件と状態遷移の順序を決める**
**第 3 段階:境界条件と状態遷移の順序を決める**
2つの文字列がともに空のとき、編集回数は $0$、すなわち $dp[0, 0] = 0$ です。$s$ が空で $t$ が空でないとき、最小編集回数は $t$ の長さに等しいため、先頭行は $dp[0, j] = j$ です。$s$ が空でなく $t$ が空のとき、最小編集回数は $s$ の長さに等しいため、先頭列は $dp[i, 0] = i$ です。
両方の文字列が空なら編集回数は $0$、すなわち $dp[0, 0] = 0$ です。$s$ が空で $t$ が空でないなら、最小編集回数は $t$ の長さなので、先頭行は $dp[0, j] = j$ です。$s$ が空でなく $t$ が空なら、最小編集回数は $s$ の長さなので、先頭列は $dp[i, 0] = i$ です。
状態遷移方程式を観察すると、$dp[i, j]$ の解は左、上、左上の解に依存します。そのため、2重ループで $dp$ テーブル全体を順方向に走査すれば十分です。
状態遷移式から、解 $dp[i, j]$ は左、上、左上の解に依存します。したがって、二重ループで $dp$ テーブル全体を順方向に走査すれば十分です。
### コードの実装
@@ -71,58 +71,58 @@ $$
[file]{edit_distance}-[class]{}-[func]{edit_distance_dp}
```
下図に示すように、編集距離問題の状態遷移の過程はナップサック問題と非常によく似ており、どちらも2次元グリッドを埋めていく過程とみなせます。
下図のように、編集距離問題の状態遷移はナップサック問題によく似ており、どちらも二次元の表を埋める過程と捉えられます。
=== "<1>"
![編集距離の動的計画法の過程](edit_distance_problem.assets/edit_distance_dp_step1.png)
![編集距離を動的計画法で求める流れ](edit_distance_problem.assets/edit_distance_dp_step1.png)
=== "<2>"
![edit_distance_dp_step2](edit_distance_problem.assets/edit_distance_dp_step2.png)
![編集距離を動的計画法で求める流れ:手順 2](edit_distance_problem.assets/edit_distance_dp_step2.png)
=== "<3>"
![edit_distance_dp_step3](edit_distance_problem.assets/edit_distance_dp_step3.png)
![編集距離を動的計画法で求める流れ:手順 3](edit_distance_problem.assets/edit_distance_dp_step3.png)
=== "<4>"
![edit_distance_dp_step4](edit_distance_problem.assets/edit_distance_dp_step4.png)
![編集距離を動的計画法で求める流れ:手順 4](edit_distance_problem.assets/edit_distance_dp_step4.png)
=== "<5>"
![edit_distance_dp_step5](edit_distance_problem.assets/edit_distance_dp_step5.png)
![編集距離を動的計画法で求める流れ:手順 5](edit_distance_problem.assets/edit_distance_dp_step5.png)
=== "<6>"
![edit_distance_dp_step6](edit_distance_problem.assets/edit_distance_dp_step6.png)
![編集距離を動的計画法で求める流れ:手順 6](edit_distance_problem.assets/edit_distance_dp_step6.png)
=== "<7>"
![edit_distance_dp_step7](edit_distance_problem.assets/edit_distance_dp_step7.png)
![編集距離を動的計画法で求める流れ:手順 7](edit_distance_problem.assets/edit_distance_dp_step7.png)
=== "<8>"
![edit_distance_dp_step8](edit_distance_problem.assets/edit_distance_dp_step8.png)
![編集距離を動的計画法で求める流れ:手順 8](edit_distance_problem.assets/edit_distance_dp_step8.png)
=== "<9>"
![edit_distance_dp_step9](edit_distance_problem.assets/edit_distance_dp_step9.png)
![編集距離を動的計画法で求める流れ:手順 9](edit_distance_problem.assets/edit_distance_dp_step9.png)
=== "<10>"
![edit_distance_dp_step10](edit_distance_problem.assets/edit_distance_dp_step10.png)
![編集距離を動的計画法で求める流れ:手順 10](edit_distance_problem.assets/edit_distance_dp_step10.png)
=== "<11>"
![edit_distance_dp_step11](edit_distance_problem.assets/edit_distance_dp_step11.png)
![編集距離を動的計画法で求める流れ:手順 11](edit_distance_problem.assets/edit_distance_dp_step11.png)
=== "<12>"
![edit_distance_dp_step12](edit_distance_problem.assets/edit_distance_dp_step12.png)
![編集距離を動的計画法で求める流れ:手順 12](edit_distance_problem.assets/edit_distance_dp_step12.png)
=== "<13>"
![edit_distance_dp_step13](edit_distance_problem.assets/edit_distance_dp_step13.png)
![編集距離を動的計画法で求める流れ:手順 13](edit_distance_problem.assets/edit_distance_dp_step13.png)
=== "<14>"
![edit_distance_dp_step14](edit_distance_problem.assets/edit_distance_dp_step14.png)
![編集距離を動的計画法で求める流れ:手順 14](edit_distance_problem.assets/edit_distance_dp_step14.png)
=== "<15>"
![edit_distance_dp_step15](edit_distance_problem.assets/edit_distance_dp_step15.png)
![編集距離を動的計画法で求める流れ:手順 15](edit_distance_problem.assets/edit_distance_dp_step15.png)
### 空間最適化
### 空間の最適化
$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]$ を事前に構築できません。そのため、どちらの走査順序も適切ではありません。
$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]$ を一時保存し、左と上の解だけを考えればよくなります。このときの状況は完全ナップサック問題と同じであり、順方向走査を用いることができます。コードは次のとおりです:
そこで、変数 `leftup` に左上の解 $dp[i-1, j-1]$ を一時保存すれば、左と上の解だけを考えればよくなります。これは個数制限なしナップサック問題と同じ状況なので、順方向に走査できます。コードを次に示します。
```src
[file]{edit_distance}-[class]{}-[func]{edit_distance_dp_comp}

View File

@@ -1,111 +1,111 @@
# 演習
# 練習問題
## 確認問題
## 知識の確認
### 動的計画法が適している場面
### 動的計画法に適した場面
ある生徒は、「漸化式を書けるなら、必ず動的計画法を使うべきです」と言いました。
次の 3 つの処理には、動的計画法、バックトラッキング、または `dp` テーブルを作らないループや数式のどれが適しているかを判断し、重要な理由を 1 つ書いてください。
ある生徒が「漸化式を書けるなら、必ず動的計画法を使うべきだ」と言いました。
次の各作業には、動的計画法、バックトラッキング、または `dp` 表を作らずループや数式を使う方法のどれがより適しているかを判断し、主な理由を一つ書いてください。
<!-- numbered-subquestions -->
1. 額面 `[1, 3, 4]` の硬貨を使って金額 6 を作るときの最小硬貨枚数を求める。各硬貨は何度でも使える。
2. `[1, 2, 3]` のすべての順列を出力する。
3. $1 + 2 + \dots + n$ を計算する。
1. 額面 `[1, 3, 4]` の硬貨で金額 6 を作るための最少枚数を求めます。各硬貨は何枚でも使えます。
2. `[1, 2, 3]` の全順列を出力します。
3. $1 + 2 + \dots + n$ を計算します。
動的計画法が適していると判断した処理については、`dp[i]` が何を表すかも説明してください。
動的計画法に適していると判断した作業については、`dp[i]` が何を表すかも説明してください。
??? success "解答"
??? success "解答例"
1. 動的計画法が適しています。`dp[i]` を、金額 `i` を作るのに必要な最小硬貨枚数とします。
`i` 以下の各硬貨 `c` について、`dp[i-c] + 1` を答えの候補とし、
それらの候補の最小値を選べます。異なる選び方でも同じ金額を繰り返し扱い、より大きな金額の最適解をより小さな金額の最適解から作れるためです。
金額 6 の答えは 2 で、`3 + 3` と選びます。
1. 動的計画法に適しています。`dp[i]` を、金額 `i` を作るために必要な硬貨の最少枚数とします。
`i` 以下の額面の各硬貨 `c` について `dp[i-c] + 1` を答えの候補とし、
その最小値を取ります。異なる選択でも同じ金額に繰り返し行き着き、大きな金額の最適解も小さな金額の最適解から構成できます。
金額 6 の答えは、`3 + 3` の 2 枚です。
2. バックトラッキングを使うべきです。すべての 6 通りの順列を 1 つずつ生成する問題なので、1 つの選択を試して探索を続け、
選択を取り消して別の分岐を試すバックトラッキングが使えます。どのような方法でも、これらの順列を実際に出力するには列挙を省けません。
2. バックトラッキングを使います。求められるのは 6 個すべての順列を一つずつ生成することであり、バックトラッキングなら、一つを選んで探索を進め、
選択を取り消して別の分岐を試す作業を体系的に行えます。どの方法でも、実際に順列を出力するときには列挙を省略できません。
3. ループまたは等差数列の公式で十分です。`S(i) = S(i-1) + i` という漸化式は書けますが、`S(i)` の計算が依存するのは 1 つ前の `S(i-1)` だけで、
各部分和は 1 回だけ計算すればよく、重複部分問題がありません。そのため、`dp` テーブルは不要です。「漸化式を書ける」ことと「動的計画法が必要である」ことは同じではありません。
3. ループまたは等差数列の公式で十分です。`S(i) = S(i-1) + i` と書けますが、`S(i)` は一つの小さな `S(i-1)` だけに依存し、
各部分和は一回計算すれば済みます。重複する部分問題がないため、`dp` 表は不要です。「漸化式を書ける」ことと「動的計画法が必要」なことは同じではありません。
### ナップサック表の 1 つの値を求める
### ナップサックの表の一マスを計算する
0-1 ナップサック問題を考えます。品物の重さは `wgt = [1, 2, 3]`、価値は `val = [5, 11, 15]`、ナップサックの容量は 4 です。
`dp[i][c]` は、先頭から $i$ 個の品物だけを考え、ナップサックの容量上限が $c$ のときに得られる最大価値を表します。
ナップサックをちょうど満たす必要はありません。
0-1 ナップサック問題で、品物の重さは `wgt = [1, 2, 3]`、価値は `val = [5, 11, 15]`、容量は 4 です。
`dp[i][c]` は、最初の $i$ 個の品物だけを考え、容量の上限を $c$ としたときに得られる最大の価値を表します。
ナップサックをちょうど満杯にする必要はありません。
ここでは状態 `dp[3][4]` だけを計算します。`dp[2][4] = 16`、`dp[2][1] = 5` であることが分かっています。
状態 `dp[3][4]` だけを計算します。`dp[2][4] = 16`、`dp[2][1] = 5` は分かっているものとします。
<!-- numbered-subquestions -->
1. 3 番目の品物を選ばない場合、候補となる価値はいくつですか?
2. 3 番目の品物を選ぶ場合、容量はいくつ残り、候補となる価値はいくつですか?
3. `dp[3][4]` はいくつにすべきですか?どの品物を選んだことに対応しますか?
1. 3 番目の品物を選ばない場合、価値の候補はいくつですか。
2. 3 番目の品物を選ぶ場合、残りの容量と価値の候補はいくつですか。
3. `dp[3][4]` はいくつになりますか。どの品物を選ぶことに対応しますか。
??? success "解答"
??? success "解答例"
1. 3 番目の品物を選ばない場合は、最初の 2 つの品物による結果をそのまま使うため、候補となる価値は `dp[2][4] = 16` です。
1. 3 番目の品物を選ばない場合は、最初の二つの品物の結果を使い、候補は `dp[2][4] = 16` です。
2. 3 番目の品物の重さは 3 なので、入れた後の残り容量は $4-3=1$ です。候補となる価値は
2. 3 番目の品物の重さは 3 なので、入れた後の残りの容量は $4-3=1$ です。価値の候補は
`dp[2][1] + 15 = 5 + 15 = 20` です。
3. 16 と 20 を比較して、`dp[3][4] = 20` とします。これは 1 番目と 3 番目の品物を選ぶことに対応し、
総重量は $1+3=4$、総価値は $5+15=20$ です。
3. 16 と 20 を比較して、`dp[3][4] = 20` となります。これは 1 番目と 3 番目の品物を選ぶことに対応し、
重さの合計は $1+3=4$、価値の合計は $5+15=20$ です。
この状態の計算は、0-1 ナップサックにおける「選ぶか、選ばないか」という 1 回の比較を表しています。
この状態の計算は、0-1 ナップサック問題での一回の「選ぶか選ばないか」の比較を表しています。
### ナップサック容量を更新する順序
### ナップサックの容量を更新する順序
0-1 ナップサックに、重さ 2、価値 5 の品物が 1 つだけあり、ナップサックの容量は 4 です。
各品物は 1 回までしか選べません。最初の一次元配列は `dp = [0, 0, 0, 0, 0]` です。
0-1 ナップサック問題に、重さ 2、価値 5 の品物が一つだけあります。ナップサックの容量は 4 です。
各品物は一回だけ選べ、初期の一次元配列は `dp = [0, 0, 0, 0, 0]` です。
ある生徒は、この品物を処理するとき、容量を 2 から 4 の順で更新しました。
ある生徒がこの品物を処理するとき、容量 2 から 4 の順に更新しました。
- `dp[2]` を更新すると 5 になる。
- `dp[3]` を更新しても 5 になる。
- `dp[4]` を更新するとき、更新したばかりの `dp[2]` を使うため、`dp[4] = 10` になる。
- `dp[2]` を更新して 5 を得ました。
- `dp[3]` を更新して、ここでも 5 を得ました。
- `dp[4]` の更新では、今求めた `dp[2]` を使ったため、`dp[4] = 10` となりました。
<!-- numbered-subquestions -->
1. `dp[4] = 10` という結果は正しいですか?その理由も説明してください。
2. 「各品物は 1 回までしか選べない」という条件に基づくと、`dp[4]` はいくつですか?
3. 各品物を処理するとき、容量は大きいほうから小さいほうへ更新しますか、それとも小さいほうから大きいほうへ更新しますか?その順序によって、どのような問題を防げますか?
1. `dp[4] = 10` は正しい結果ですか。理由も説明してください。
2. 「各品物は一回だけ選べる」という条件から、`dp[4]` はいくつになるべきですか。
3. 各品物を処理するとき、容量は大きい順と小さい順のどちらで更新すべきですか。その順序によって、どの問題を避けられますか。
??? success "解答"
??? success "解答例"
1. この結果は正しくありません。価値 10 は、この価値 5 の品物を 2 回入れたことに相当し、
「各品物は 1 回までしか選べない」という条件に反します。
1. 正しくありません。価値 10 は、価値 5 の同じ品物を二回入れたことに相当し、
「各品物は一回だけ選べる」という条件に違反します。
2. ナップサックへ入れられるのはこの品物 1 つだけなので、正しい `dp[4]` は 5 です。
2. 入れられるのはこの一つの品物だけなので、正しい `dp[4]` は 5 です。
3. 容量は大きいほうから小さいほうへ、つまり 4、3、2 の順に更新します。
この順序なら、`dp[c]` の計算時に読み取る `dp[c-2]` は、現在の品物を処理する前の値のままなので、
同じ回の中で現在の品物を繰り返し使うことを防げます。
3. 容量は大きい順、つまり 4、3、2 の順に更新します。
そうすれば、`dp[c]` を計算するときに読み取る `dp[c-2]` は、まだ現在の品物を処理する前の結果なので、
同じ反復内で現在の品物を重複して使わずに済みます。
## プログラミング演習
### 階段を上る方法の数
### 階段の上り方の数
`n` 段の階段があります。1 回に 1 段または 2 段だけ上ることができ、ちょうど `n` 段目に着かなければなりません。
異なる上り方が何通りあるかを求めてください。`n >= 1` とし、それぞれの上り方は、各回に 1 段進むか 2 段進むかだけで区別します。
一次元の動的計画法の配列を使って求め、2 つの状態だけを残す空間最適化は、ここでは使わないでください。
`n` 段の階段があります。一回に 1 段か 2 段だけ上れ、ちょうど第 `n` 段に到達する必要があります。
異なる上り方が何通りあるかを計算してください。`n >= 1` とし、各歩で 1 段進むか 2 段進むかによってのみ上り方を区別します。
一次元の動的計画法の配列を使い、二つの状態だけを残す空間最適化は、ここでは行わないでください。
??? tip "解法のヒント"
??? tip "解くためのヒント"
1. i 段目へ到達する最後の一歩では、1 段または 2 段だけ上れます
2. そのため、dp[i] = dp[i-1] + dp[i-2] となります
3. まず n が 1 と 2 の場合を処理し、3 段目から表を埋めます
1. 第 i 段に到達する最後の一歩は、1 段または 2 段しか進めません
2. したがって、dp[i] = dp[i-1] + dp[i-2] が成り立ちます
3. まず n が 1 と 2 の場合を処理し、第 3 段から表を埋めます
[LeetCode](https://leetcode.com/problems/climbing-stairs/){ .rounded-button .exercise-button target="_blank" rel="noopener noreferrer" }
### 0-1 ナップサック
### 0-1 ナップサック問題
同じ長さの配列 `wgt` と `val` が与えられます。`i` 番目の品物の重さは正の整数 `wgt[i]`、価値は 0 以上の整数 `val[i]` で、
ナップサックの容量 `cap` は 0 以上の整数です。各品物は 1 回までしか選べません。総重量が `cap` 以下という条件で、
ナップサックに入れられる最大の総価値を求めてください。一次元の動的計画法を使って実装してください。
同じ長さの配列 `wgt` と `val` が与えられます。品物 `i` の重さは正の整数 `wgt[i]`、価値は非負整数 `val[i]` です。
ナップサックの容量 `cap` は非負整数です。各品物は一回だけ選べます。重さの合計が `cap` を超えない範囲で、
ナップサックに入れられる価値の合計の最大値を求めてください。一次元の動的計画法で実装してください。
??? tip "解法のヒント"
??? tip "解くためのヒント"
1. 長さ cap + 1 の配列 dp を初期化します。dp[c] は、容量上限が c のときの最大価値を表します
2. 品物 i を処理するとき、「選ばない」場合の dp[c] と、「選ぶ」場合の dp[c-wgt[i]] + val[i] を比較します
3. 同じ回の中で現在の品物を繰り返し選ばないように、容量は必ず大きいほうから小さいほうへ更新します
1. 長さ cap + 1 の配列 dp を初期化します。dp[c] は、容量の上限が c の場合の最大の価値を表します
2. 品物 i を処理するときは、「選ばない」場合の dp[c] と、「選ぶ」場合の dp[c-wgt[i]] + val[i] を比較します
3. 同じ反復内で現在の品物を重複して選ばないよう、容量は必ず大きい順に更新します

View File

@@ -4,6 +4,6 @@
!!! abstract
小川は川へと注ぎ、河川は大海へと注ぐ。
小川は河川に合流し、河川は大海へと注ぎます。
動的計画法は小さな問題の解を集めて大きな問題の答えとし、一歩ずつ私たちを問題解決の彼岸へと導く。
動的計画法は、小さな問題の解を集めて大きな問題の答えを作り、問題解決という対岸へ一歩ずつ導いてくれます。

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,110 +1,110 @@
# 動的計画法入門
# 動的計画法への第一歩
<u>動的計画法(dynamic programming)</u>は重要なアルゴリズムパラダイムであり、問題をより小さな部分問題の列に分解し、それらの解を保存して重複計算を避けることで、時間効率を大幅に向上させます。
<u>動的計画法(dynamic programming)</u>は、問題を一連の小さな部分問題に分解し、その解を保存することで重複した計算を避け、時間効率を大幅に高める重要なアルゴリズムの枠組みです。
本節では、古典的な例題から始めて、まずその力任せのバックトラッキング解法を示し、そこに含まれる重複部分問題を観察したうえで、より効率的な動的計画法の解法を段階的に導きます。
この節では、古典的な問題を取り上げ、まず総当たりのバックトラッキングによる解法を示します。そこに含まれる部分問題の重複を確認し、より効率的な動的計画法へと段階的に発展させます。
!!! question "階段を上る"
全体で $n$ 段ある階段が与えられ、各ステップで $1$ 段または $2$ 段上ることができます。頂上まで到達する方法は何通りあるでしょうか?
$n$ 段の階段があります。1 回で $1$ 段または $2$ 段上れるとき、一番上まで上る方法は何通りありますか。
次の図に示すように、$3$ 段の階段では、頂上まで到達する方法は全部で $3$ 通りあります。
下図のように、$3$ 段の階段を上る方法は $3$ 通りあります。
![3 段の階段を上る方法の数](intro_to_dynamic_programming.assets/climbing_stairs_example.png)
![3 段目まで上る方法の数](intro_to_dynamic_programming.assets/climbing_stairs_example.png)
この問題の目的は方法の総数を求めることです。**考えられるすべての可能性をバックトラッキングで総当たりすることができます**。具体的には、階段を上ることを複数ラウンドの選択過程とみなし、地面から出発して各ラウンドで $1$ 段または $2$ 段上ります。階段の頂上に到達するたびに方法数を $1$ 増やし、頂上を越えた場合は枝刈りします。コードは次のとおりです:
求めたいのは方法の数なので、**バックトラッキングですべての可能性を列挙することが考えられます**。具体的には、階段を上ることを選択の繰り返しと捉えます。地面から出発し、毎回 $1$ 段か $2$ 段かを選び、一番上に着くたびに方法の数を $1$ 増やします。一番上を超えた場合は枝刈りします。コードは次のとおりです。
```src
[file]{climbing_stairs_backtrack}-[class]{}-[func]{climbing_stairs_backtrack}
```
## 方法 1:総当たり探索
## 方法 1:全探索
バックトラッキング法は通常、問題を明示的に分解するのではなく、問題解決を一連の意思決定ステップとみなし、試行と枝刈りによってあらゆる可能な解を探索します。
バックトラッキングでは通常、問題を明示的に分解するのではなく、一連の意思決定として捉えます。試行と枝刈りを通じて、可能な解をすべて探索します。
この問題を問題分解の観点から分析してみましょう。$i$ 段目まで上る方法が全部で $dp[i]$ 通りあるとすると、$dp[i]$ が元の問題であり、その部分問題には次が含まれます:
この問題を、分解するという観点から考えてみましょう。$i$ 段目まで上る方法が $dp[i]$ 通りあるとすると、$dp[i]$ が元の問題であり、部分問題には次のものがあります。
$$
dp[i-1], dp[i-2], \dots, dp[2], dp[1]
$$
各ラウンドでは $1$ 段または $2$ 段しか上れないため、$i$ 段目の階段に立っているとき、直前のラウンドでは $i - 1$ 段目または $i - 2$ 段目にしか立てません。言い換えると、$i -1$ 段目または $i - 2$ 段目からしか $i$ 段目へ進めません。
毎回 $1$ 段か $2$ 段しか上れないので、$i$ 段目にいるなら、その直前にいたのは $i - 1$ 段目か $i - 2$ 段目です。つまり、$i$ 段目には $i -1$ 段目または $i - 2$ 段目からしか来られません。
ここから重要な帰結が得られます。**$i - 1$ 段目まで上る方法数と $i - 2$ 段目まで上る方法数の和が、$i$ 段目まで上る方法数に等しい**のです。式は次のとおりです:
ここから、重要な結論が得られます。**$i - 1$ 段目まで上る方法の数と、$i - 2$ 段目まで上る方法の数の和が、$i$ 段目まで上る方法の数になります**。式で表すと、次のとおりです。
$$
dp[i] = dp[i-1] + dp[i-2]
$$
これは、階段を上る問題では各部分問題の間に漸化関係があり、**元の問題の解は部分問題の解から構築できる**ことを意味します。次の図はこの漸化関係を示しています。
つまり、階段を上る問題では部分問題の間に漸化式があり、**部分問題の解から元の問題の解を組み立てられます**。下図に、この関係を示します。
![方法数の漸化関係](intro_to_dynamic_programming.assets/climbing_stairs_state_transfer.png)
![方法の数の漸化式](intro_to_dynamic_programming.assets/climbing_stairs_state_transfer.png)
漸化式に基づいて総当たり探索の解法を得ることができます。$dp[n]$ を出発点とし、**より大きな問題を再帰的に 2 つのより小さな問題の和へ分解**していき、最小部分問題 $dp[1]$ と $dp[2]$ に到達したら返します。ここで最小部分問題の解は既知であり、$dp[1] = 1$、$dp[2] = 2$ です。これは、第 $1$ 段目と第 $2$ 段目まで上る方法がそれぞれ $1$ 通り、$2$ 通りであることを表します。
この漸化式から、全探索による解法が得られます。$dp[n]$ から始め、**大きな問題を二つの小さな問題の和へ再帰的に分解し**、最小の部分問題 $dp[1]$ と $dp[2]$ に到達したら戻ります。その解は既知で、$dp[1] = 1$、$dp[2] = 2$ です。つまり、$1$ 段目と $2$ 段目まで上る方法は、それぞれ $1$ 通りと $2$ 通りです。
次のコードを見ると、標準的なバックトラッキングコードと同じく深さ優先探索に属しますが、より簡潔です:
次のコードは、通常のバックトラッキングのコードと同じく深さ優先探索ですが、より簡潔です。
```src
[file]{climbing_stairs_dfs}-[class]{}-[func]{climbing_stairs_dfs}
```
次の図は総当たり探索によって形成される再帰木を示しています。問題 $dp[n]$ に対して、その再帰木の深さは $n$、時間計算量は $O(2^n)$ です。指数オーダーは爆発的に増加するため、比較的大きな $n$ を入力すると長時間待たされることになります。
下図に、全探索でできる再帰木を示します。問題 $dp[n]$ の再帰木の深さは $n$ で、時間計算量は $O(2^n)$ です。指数のオーダーは爆発的に増えるため、大きな $n$ を入力すると、長時間待つことになります。
![階段上りに対応する再帰木](intro_to_dynamic_programming.assets/climbing_stairs_dfs_tree.png)
![階段を上る問題の再帰木](intro_to_dynamic_programming.assets/climbing_stairs_dfs_tree.png)
上の図を見ると、**指数オーダーの時間計算量は「重複部分問題」によって生じています**。たとえば $dp[9]$ は $dp[8]$ と $dp[7]$ に分解され、$dp[8]$ は $dp[7]$ と $dp[6]$ に分解されるため、どちらにも部分問題 $dp[7]$ が含まれています。
上図から、**指数のオーダーの時間計算量は「部分問題の重複」によって生じている**ことがわかります。たとえば $dp[9]$ は $dp[8]$ と $dp[7]$ に分解され、$dp[8]$ は $dp[7]$ と $dp[6]$ に分解されます。どちらにも部分問題 $dp[7]$ が含まれます。
このように、部分問題の中にはさらに小さな重複部分問題が含まれ、それが際限なく続いていきます。計算資源の大部分は、こうした重複部分問題に浪費されています。
さらに、その部分問題にも小さな部分問題の重複が含まれ、同じことが次々に繰り返されます。計算資源の大部分が、こうした重複した部分問題に費やされています。
## 方法 2:メモ化探索
アルゴリズム効率を高めるため、**すべての重複部分問題を 1 回だけ計算したい**と考えます。そのために、各部分問題の解を記録する配列 `mem` を宣言し、探索の過程で重複部分問題を枝刈りします。
効率を高めるには、**重複する部分問題をそれぞれ 1 回だけ計算したい**ところです。そこで配列 `mem` に各部分問題の解を記録し、探索中に重複する部分問題を枝刈りします。
1. $dp[i]$ を初めて計算したとき、その結果を `mem[i]` に記録して後で使えるようにします。
2. 再び $dp[i]$ を計算する必要が生じたときは、`mem[i]` から直接結果を取得し、その部分問題の重複計算を避けます。
1. $dp[i]$ を初めて計算したら、後で使えるよう `mem[i]` に記録します。
2. 再び $dp[i]$ が必要になったら、`mem[i]` から直接取得し、同じ部分問題の再計算を避けます。
コードは次のとおりです:
コードは次のとおりです。
```src
[file]{climbing_stairs_dfs_mem}-[class]{}-[func]{climbing_stairs_dfs_mem}
```
次の図を見ると、**メモ化を行うことで、すべての重複部分問題は 1 回だけ計算すればよくなり、時間計算量は $O(n)$ まで改善されます**。これは大きな飛躍です。
下図のように、**メモ化すると、重複する部分問題はすべて 1 回の計算で済み、時間計算量は $O(n)$ に改善されます**。これは大きな進歩です。
![メモ化探索に対応する再帰木](intro_to_dynamic_programming.assets/climbing_stairs_dfs_memo_tree.png)
![メモ化探索の再帰木](intro_to_dynamic_programming.assets/climbing_stairs_dfs_memo_tree.png)
## 方法 3:動的計画法
**メモ化探索は「トップダウン」の方法**です。元の問題(根ノード)から始めて、より大きな部分問題を再帰的により小さな部分問題へ分解し、解が既知である最小部分問題(葉ノード)に至ります。その後、バックトラックしながら各層で部分問題の解を集め、元の問題の解を構築します。
**メモ化探索は「上から下へ」進む方法です**。元の問題(根ノード)から始め、大きな部分問題を小さな部分問題へ再帰的に分解し、解が既知の最小の部分問題(葉ノード)まで進みます。その後、戻りながら各段階の解を集め、元の問題の解を組み立てます。
これとは対照的に、**動的計画法は「ボトムアップ」の方法**です。最小部分問題の解から始めて、より大きな部分問題の解を反復的に構築し、最終的に元の問題の解を得ます。
対して、**動的計画法は「下から上へ」進む方法です**。最小の部分問題の解から始め、反復によってより大きな部分問題の解を組み立て、元の問題の解まで到達します。
動的計画法にはバックトラックの過程が含まれないため、再帰を使う必要はなく、ループによる反復だけで実装できます。次のコードでは、部分問題の解を保存する配列 `dp` を初期化しており、これはメモ化探索における配列 `mem` と同じ記録の役割を果たします:
動的計画法には引き返す過程がないため、再帰は不要で、ループによる反復だけで実装できます。次のコードでは、部分問題の解を保存する配列 `dp` を初期化します。これは、メモ化探索の配列 `mem` と同じ記録の役割を果たします。
```src
[file]{climbing_stairs_dp}-[class]{}-[func]{climbing_stairs_dp}
```
次の図は、以上のコードの実行過程をシミュレートしたものです。
下図に、上のコードの実行過程を示します。
![階段上りの動的計画法の過程](intro_to_dynamic_programming.assets/climbing_stairs_dp.png)
![階段を上る問題を動的計画法で解く流れ](intro_to_dynamic_programming.assets/climbing_stairs_dp.png)
バックトラッキング法と同様に、動的計画法でも問題解決の特定段階を表すために「状態」という概念を用います。各状態は 1 つの部分問題と、それに対応する局所最適解に対応します。たとえば、階段を上る問題では、状態は現在いる階段の段数 $i$ と定義されます。
バックトラッキングと同様に、動的計画法でも「状態」を使って問題を解く途中の段階を表します。各状態は、一つの部分問題と、それに対応する局所的な最適解に対応します。たとえば階段を上る問題では、現在いる段数 $i$ を状態とします。
以上を踏まえると、動的計画法のよく使われる用語を次のようにまとめられます。
ここまでの内容から、動的計画法の主な用語をまとめられます。
- 配列 `dp` を <u>dp テーブル</u> と呼び、$dp[i]$ は状態 $i$ に対応する部分問題の解を表します。
- 最小部分問題に対応する状態(第 $1$ 段目と第 $2$ 段目の階段)を<u>初期状態</u>と呼びます。
- 漸化式 $dp[i] = dp[i-1] + dp[i-2]$ を<u>状態遷移方程式</u>と呼びます。
- 配列 `dp` を <u>dp テーブル</u>と呼び、$dp[i]$ は状態 $i$ に対応する部分問題の解を表します。
- 最小の部分問題に対応する状態($1$ 段目と $2$ 段目)を<u>初期状態</u>と呼びます。
- 漸化式 $dp[i] = dp[i-1] + dp[i-2]$ を<u>状態遷移式</u>と呼びます。
## 空間最適化
## 空間の最適化
注意深い読者は気づいたかもしれません。**$dp[i]$ は $dp[i-1]$ と $dp[i-2]$ にしか依存しないため、すべての部分問題の解を保存するために配列 `dp` を使う必要はありません**。2 つの変数を順に更新していくだけで十分です。コードは次のとおりです:
注意深く見ると、**$dp[i]$ が依存するのは $dp[i-1]$ と $dp[i-2]$ だけなので、配列 `dp` にすべての部分問題の解を保存する必要はありません**。二つの変数を順に更新していけば十分です。コードは次のとおりです。
```src
[file]{climbing_stairs_dp}-[class]{}-[func]{climbing_stairs_dp_comp}
```
上のコードを見ると、配列 `dp` が占めていた領域を省けるため、空間計算量は $O(n)$ から $O(1)$ へと下がります。
上のコードでは配列 `dp` が不要になったため、空間計算量は $O(n)$ から $O(1)$ に下がります。
動的計画法の問題では、現在の状態はしばしば直前の限られた個数の状態にしか関係しません。このような場合は、必要な状態だけを保持し、「次元削減」によってメモリ空間を節約できます。**この空間最適化の技巧は「ローリング変数」または「ローリング配列」と呼ばれます**。
動的計画法では、現在の状態が直前の限られた数の状態だけに依存することがよくあります。その場合、必要な状態だけを保持し、「次元を減らす」ことでメモリを節約できます。**この空間最適化の技法を「ローリング変数」または「ローリング配列」と呼びます**。

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

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

View File

@@ -2,24 +2,24 @@
### 要点の振り返り
- 動的計画法は問題を分解し、部分問題の解を保存することで重複計算を避け、計算効率を高めます。
- 時間を考慮しなければ、すべての動的計画法の問題はバックトラッキング(総当たり探索)で解けますが、再帰木には大量の重複部分問題が存在するため、効率はきわめて低くなります。メモ化配列を導入すると、計算済みのすべての部分問題の解を保存でき、重複部分問題が 1 回だけ計算されることを保証できます。
- メモ化探索はトップダウンの再帰的解法であり、それに対応する動的計画法はボトムアップの漸化式による解法で、ちょうど「表を埋める」ようなものです。現在の状態は一部の局所状態にのみ依存するため、$dp$ 表の 1 次元を削減して空間計算量を下げることができます。
- 部分問題への分解は汎用的なアルゴリズムの考え方であり、分割統治、動的計画法、バックトラッキングではそれぞれ異なる性質を持ちます。
- 動的計画法の問題には 3 つの大きな特徴があります。重複部分問題、最適部分構造、無後効性です。
- 元の問題の最適解が部分問題の最適解から構築できるなら、その問題は最適部分構造を持ちます。
- 無後効性とは、ある状態の将来の発展がその状態のみに関係し、過去に経たすべての状態とは無関係であることを指します。多くの組合せ最適化問題は無後効性を持たず、動的計画法で高速に解くことはできません。
- 動的計画法は問題を分解し、部分問題の解を保存して重複計算を避け、計算効率を高めます。
- 時間を度外視すれば、動的計画法の問題はすべてバックトラッキング(全探索)で解けます。ただし、再帰木に大量の重複した部分問題が含まれ、非常に非効率です。記録用リストを導入して計算済みの解を保存すれば、重複する部分問題を 1 回だけ計算できます。
- メモ化探索は上から下への再帰的な解法です。対応する動的計画法は下から上へ漸化式を適用する解法で、「表を埋める」ように進めます。現在の状態が一部の近隣の状態だけに依存するため、$dp$ テーブルの次元を一つ減らして空間計算量を下げられます。
- 部分問題への分解は汎用的なアルゴリズムの考え方ですが、分割統治、動的計画法、バックトラッキングでは性質が異なります。
- 動的計画法の問題には、部分問題の重複、最適部分構造、後続の判断が過去の経路に依存しない性質という 3 大特徴があります。
- 元の問題の最適解を部分問題の最適解から組み立てられるなら、最適部分構造があります。
- 後続の判断が過去の経路に依存しない性質とは、その後の展開が現在の状態だけで決まり、過去に通ったすべての状態には依存しないことです。多くの組合せ最適化問題はこの性質を持たず、動的計画法で高速に解くことはできません。
**ナップサック問題**
- ナップサック問題は最も典型的な動的計画法の問題の 1 つであり、0-1 ナップサック、完全ナップサック、多重ナップサックなどの派生があります。
- 0-1 ナップサックの状態は、容量 $c$ のナップサックに対して、前 $i$ 個の品物で得られる最大価値として定義されます。ナップサックに入れない場合と入れる場合の 2 つの判断から最適部分構造を得て、状態遷移方程式を構築できます。空間最適化では、各状態が真上と左上の状態に依存するため、左上の状態が上書きされるのを避けるために配列を逆順に走査する必要があります。
- 完全ナップサック問題では各品物の選択数に制限がないため、品物を入れる場合の状態遷移は 0-1 ナップサック問題とは異なります。状態は真上と真左の状態に依存するので、空間最適化では順方向に走査するべきです。
- コイン両替問題は完全ナップサック問題の変種です。「最大」価値を求める問題から「最小」の硬貨枚数を求める問題へ変わるため、状態遷移方程式の $\max()$ は $\min()$ に置き換える必要があります。また、ナップサック容量を「超えない」ことを目指すのではなく、目標金額を「ちょうど」作ることを目指すため、$amt + 1$ を「目標金額を作れない」無効解の表現として用います。
- コイン両替問題 II では、「最少硬貨枚数」を求める問題から「硬貨の組合せ数」を求める問題へ変わるため、状態遷移方程式も $\min()$ から総和演算子へ対応して変わります。
- ナップサック問題は動的計画法の最も典型的な問題の一つで、0-1、個数制限なし、個数制限付きなどの変種があります。
- 0-1 ナップサック問題では、最初の $i$ 個の品物から選んで容量 $c$ のナップサックに入れたときの最大価値を状態に対応させます。「入れない」「入れる」の二つの選択から最適部分構造と状態遷移式が得られます。各状態は真上と左上の状態に依存するため、空間最適化では逆順に走査し、左上の状態が上書きされるのを防ぎます。
- 個数制限なしナップサック問題では各品物を選ぶ個数に制限がないため、入れる場合の状態遷移が 0-1 の場合と異なります。状態が真上と真左に依存するので、空間最適化では順方向に走査します。
- 硬貨の両替問題は、個数制限なしナップサック問題の変種です。価値の「最大化」が硬貨の枚数の「最小化」に変わるため、状態遷移式の $\max()$ を $\min()$ に変えます。また、容量を「超えない」条件が目標金額に「ちょうど一致する」条件に変わるため、$amt + 1$ で「目標金額を作れない」という無効な解を表します。
- 硬貨の両替問題 II では、「硬貨の最小枚数」の代わりに「硬貨の組み合わせの数」を求めるため、状態遷移式も $\min()$ から和に変わります。
**編集距離問題**
- 編集距離(Levenshtein 距離)は 2 つの文字列間の類似度を測るために用いられ、ある文字列を別の文字列へ変換するための最小編集回数として定義されます。編集操作には追加、削除、置換が含まれます。
- 編集距離問題の状態は、$s$ の前 $i$ 文字を $t$ の前 $j$ 文字へ変更するのに必要な最小編集回数として定義されます。$s[i-1] \ne t[j-1]$ のときは、追加、削除、置換の 3 つの判断があり、それぞれに対応する残りの部分問題があります。これにより最適部分構造を見いだし、状態遷移方程式を構築できます。一方、$s[i-1] = t[j-1]$ のときは現在の文字を編集する必要はありません。
- 編集距離では、状態は真上、真左、左上の状態に依存します。そのため、空間最適化後は順方向でも逆方向でも正しく状態遷移できません。そこで、変数を 1 つ用いて左上の状態を一時保存し、完全ナップサック問題と等価な形へ変換することで、空間最適化後に順方向走査を行えるようにします。
- 編集距離(Levenshtein 距離)は二つの文字列の類似度を測るもので、一方をもう一方に変換するための最小編集回数と定義されます。編集操作には、挿入、削除、置換があります。
- 編集距離問題では、$s$ の先頭 $i$ 文字を $t$ の先頭 $j$ 文字に変換する最小編集回数を状態に対応させます。$s[i-1] \ne t[j-1]$ なら挿入・削除・置換の 3 通りの選択があり、それぞれに残る部分問題があります。そこから最適部分構造と状態遷移式を導けます。$s[i-1] = t[j-1]$ なら、その文字は編集不要です。
- 編集距離では状態が真上、真左、左上に依存するため、空間最適化後は順方向でも逆方向でも、そのままでは正しく遷移できません。左上の状態を変数に一時保存すれば、個数制限なしナップサック問題と同じ状況になり、空間最適化後も順方向に走査できます。

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Some files were not shown because too many files have changed in this diff Show More