参考ブログ:動的計画法を詳しく解説
1. 動的プログラミングとは何ですか?
動的プログラミング(英語: Dynamic programming、DP と呼ばれる)は、元の問題を比較的単純な部分問題に分解することによって複雑な問題を解決するために、数学、経営科学、コンピューターサイエンス、経済学、生物情報学で使用される手法です。動的プログラミングは、多くの場合、重複する部分問題と最適な部分構造のプロパティを伴う問題に適しています。
1.1 重複する部分問題と最適な部分構造
1.1.1 重複する部分問題
重複する部分問題とは、問題解決プロセス中に同じ部分問題が複数回使用される状況を指します。つまり、問題解決のさまざまな段階で、解決する必要がある部分問題が同じである可能性があります。部分問題の重複は動的計画法アルゴリズム設計の基礎の 1 つであり、部分問題の重複を使用すると、計算の繰り返しが減り、アルゴリズムの効率が向上します。
1.1.2 最適な下部構造
最適な部分構造とは、問題の最適な解決策が部分問題の最適な解決策から構築できることを意味します。つまり、問題の最適解には、部分問題の最適解が含まれています。平たく言えば、大きな問題に対する最適な解決策は、小さな問題に対する最適な解決策から導き出すことができます。これは動的プログラミングの重要な特性の 1 つです。
1.1.3 例
たとえば、n 個の要素を含むシーケンスがあり、最も長く増加するサブシーケンス (LIS、最長増加サブシーケンス) を見つける必要があるとします。この問題には最適な部分構造特性があります。シーケンスの LIS がわかっている場合、要素が最後に追加される場合は、次の 2 つのケースがあります。
- 要素が現在の LIS の最後の要素より大きい場合、新しいシーケンスの LIS は現在の LIS にこの要素を加えたものとなり、長さは元のシーケンスの LIS 長に 1 を加えたものになります。
- 要素が現在の LIS の最後の要素以下である場合、現在の LIS は影響を受けず、新しいシーケンスの LIS は元のシーケンスの LIS のままです。
したがって、問題に対する最適な解決策は、既知の部分問題に対する最適な解決策から構築できます。
1.2 動的プログラミングの核となる考え方
動的プログラミングの中心的な考え方は、部分問題を分割し、過去を記憶し、繰り返しの計算を減らすことです。
インターネット上でよくある例を見てみましょう。
A : "1+1+1+1+1+1+1+1 =?"
A : "上面等式的值是多少"
B : 计算 "8"
A : 在上面等式的左边写上 "1+" 呢?
A : "此时等式的值为多少"
B : 很快得出答案 "9"
A : "你怎么这么快就知道答案了"
A : "只要在8的基础上加1就行了"
A : "所以你不用重新计算,因为你记住了第一个等式的值为8!动态规划算法也可以说是 '记住求过的解来节省时间'"
1.3 カエルから動的プログラミングへのジャンプ
Leetcode の元の質問: カエルは一度に 1 段または 2 段飛び上がることができます。カエルが 10 段の階段を何通り飛び越えられるかを調べてください。
1.3.1 問題解決のアイデア
基本的な考え方:動的計画法は、小さな問題の重なり合いに基づいて、小さな問題の解決策から大きな問題の解決策を徐々に決定し、f(1) から f(10) の方向に上に向かって押し上げて問題を解決します。それはボトムアップと呼ばれ、上向きの解決策です。
これはどういう意味ですか? 最初のステップから押し上げていき、n 番目のステップまでのジャンプの回数を f(n) と定義するとします。
- ステップが 1 つしかない場合、ジャンプ方法は 1 つだけです。つまり、f(1) = 1 です。
- ステップが 2 つしかない場合、ジャンプする方法は 2 つあります。1 つ目は、2 つのレベルを直接スキップすることです。2 つ目は、最初に 1 つのレベルをスキップし、次に別のレベルをスキップすることです。つまり、f(2) = 2;
- 3段の場合、ジャンプ方法も2通りあります。1 つ目は、最初のステップから直接 2 ステップジャンプすることです。2 つ目は、ステップ 2 から 1 つジャンプすることです。つまり、 f(3) = f(1) + f(2);
- 4 段目にジャンプしたい場合は、3 段目にジャンプしてから 1 段目を上がるか、最初に 2 段目にジャンプしてから 2 段ずつ上がります。つまり、 f(4) = f(2) + f(3);
この時点で、次の式が得られます。
f(1) = 1;
f(2) = 2;
f(3) = f(1) + f(2);
f(4) = f(2) + f(3);
…
f(10) = f(8) + f(9);
つまり f(n) = f(n - 2) + f(n - 1)。
ここで、動的計画法の典型的な特性がこの質問でどのように表されるかを見てみましょう。
- 最適部分構造: f(n-1) と f(n-2) を f(n) の最適部分構造と呼びます。
- 重複する副問題: たとえば、f(10)= f(9)+f(8)、f(9) = f(8) + f(7)、f(8) は重複する副問題です。
- 状態遷移方程式:f(n)= f(n-1)+f(n-2) を状態遷移方程式といいます。
- 境界: f(1) = 1、f(2) = 2 が境界です。
1.3.2 コード
コードのアイデアは次のとおりです。
コード:
class Solution {
public:
int numWays(int n) {
int dp[101] = {
0};
int mod = 1000000007;
dp[0] = 1;
dp[1] = 1;
dp[2] = 2;
for (int i = 3; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
dp[i] %= mod;
}
return dp[n] % mod;
}
};
このメソッドの空間計算量は O(n) ですが、上の図をよく見ると、f(n) は最初の 2 つの数値のみに依存することがわかります。そのため、格納するには 2 つの変数 a と b だけが必要です。それはニーズを満たすことができるので、空間複雑さは O(1) です。
コード:
class Solution {
public:
int numWays(int n) {
if (n < 2) {
return 1;
}
if (n == 2) {
return 2;
}
int a = 1;
int b = 2;
int temp = 0;
for (int i = 3; i <= n; i++) {
temp = (a + b)% 1000000007;
a = b;
b = temp;
}
return temp;
}
};
2. 動的プログラミングの問題解決ルーチン
2.1 核となるアイデア
動的プログラミングの中心的な考え方は、部分問題を分割し、過去を記憶し、繰り返しの計算を減らすことです。また、動的プログラミングは一般にボトムアップです。ここでは、カエルのジャンプ問題に基づいて、動的プログラミングのアイデアを要約します。
- 徹底的な分析
- 境界を決める
- ルールを見つけて最適な部分構造を決定する
- 状態遷移方程式を書く
2.1.1 ケースバイケース分析
- 徹底的な分析
- ステップ数が 1 の場合、f(1) =1 というジャンプ方法があります。
- ステップが 2 つしかない場合、ジャンプする方法は 2 つあり、1 つ目は 2 ステップを直接ジャンプする方法、2 つ目は、最初に 1 ステップをジャンプしてから次のステップをジャンプする方法です。つまり、f(2) = 2;
- ステップが 3 段の場合、3 段目にジャンプしたい場合は、最初に 2 段目にジャンプしてから 1 段目をジャンプするか、最初に 1 段目にジャンプしてから 2 段ずつ上がることができます。時間。したがって、 f(3) = f(2) + f(1) =3
- 段数が4の場合、3段目にジャンプしたい場合は、3段目にジャンプしてから1段ずつ上がるか、先に2段目にジャンプしてから2段ずつ上がってください。 。したがって、 f(4) = f(3) + f(2) =5
- ステップが5になると…
- 境界を決める
徹底的に分析した結果、ステップ数が1または2の場合、カエルのジャンプ方法が明確にわかることがわかりました。f(1) =1、f(2) = 2、ステップ n>=3 の場合、ルール f(3) = f(2) + f(1) =3 が示されているため、f(1) = 1 , f(2) = 2 はカエルがジャンプする境界です。
- パターンを見つけて最適な部分構造を決定する
n>=3 の場合、f(n) = f(n-1) + f(n-2) の法則が示されているので、f(n-1) と f(n-2) を f(n ) 最適な部分構造。最適な基礎構造は何でしょうか? このような説明があります。
動的計画法の問題は、実際には再帰問題です。現在の決定結果が f(n) であるとすると、最適な部分構造は f(nk) を最適にすることです。最適な部分構造の性質は、n の状態への遷移が最適であり、その後の決定とは何の関係もないことです。つまり、その後の決定で以前の局所最適解を安全に使用できるようにするプロパティです。
- 前の 3 つのステップ、徹底的な分析、境界と最適な部分構造の決定を通じて、状態遷移方程式を導き出すことができます。
3. 質問例
3.1 サブシーケンスの増加
3.1.1. 徹底的な分析:
- nums が 10 のみの場合、最も長いサブシーケンス [10] の長さは 1 になります。
- nums を 9 に加算すると、最長部分列 [10] または [9]、長さ 1。
- nums を 2 に加算すると、最長のサブシーケンスは [10] または [9] または [2] となり、長さは 1 になります。
- nums を 5 に加算すると、最長の部分列 [2, 5]、長さ 2 になります。
- nums を 3 に加算すると、最長のサブシーケンスは [2, 5] または [2, 3] となり、長さは 2 になります。
- nums を 7 に加算すると、最長のサブシーケンスは [2, 5, 7] または [2, 3, 7] となり、長さは 3 になります。
… - 別の要素 18 が nums に追加される場合、最も長く増加する部分列は [2,5,7,101] または [2,3,7,101] または [2,5,7,18] または [2,3,7,18] になります。長さは4です。
3.1.2 境界の決定
nums 配列の各要素について、トラバースと検索を開始していないときは、最初の最長サブシーケンスはそれ自体の長さ 1 です。
3.1.3 パターンを見つけて最適な部分構造を決定する
上記の分析を通じて、次のようなルールを見つけることができます。
nums[i] で終わる自動増加サブシーケンスの場合は、nums[i] より小さい nums[j] で終わるサブシーケンスを見つけて、nums[i] を追加します。明らかに、さまざまな新しいサブシーケンスが形成される可能性がありますが、最も長いもの、つまり最も長く増加するサブシーケンスを選択します。
最適な部分構造を取得します。
最長増加サブシーケンス (nums[i]) = max(最長増加サブシーケンス (nums[j])) + nums[i]; 0<= j < i、nums[j] < nums[i];
3.1.4 状態遷移方程式を書く
nums 配列の要素で終わる最長のサブシーケンスの長さを格納するように dp 配列を設定し、それを 1 に初期化し、最適な部分構造から状態遷移方程式を取得します。
dp[i] = max(dp[j]) + 1; 0<= j < i、nums[j] < nums[i];
3.1.5 コード
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
vector<int> dp(nums.size(), 1);
int ans = 1;
for (int i = 1; i < nums.size(); i++) {
for (int j = 0; j < i; j++) {
if (nums[j] < nums[i]) {
dp[i] = max(dp[i], dp[j] + 1);
}
}
ans = max(ans, dp[i]);
}
return ans;
}
};
3.2 最長の文字列チェーン
この質問は前の質問と非常に似ていますが、この質問では、単語チェーンの単語の順序が元の単語配列の順序である必要はないことに注意してください。
3.2.1 徹底的な分析
ここで、この質問では単語連鎖の語順が元の単語配列の順序である必要はないため、分析は元の単語配列の順序で網羅的に行うことができないことに注意してください。単語連鎖の特性に応じて、元の単語配列の最も短い単語から開始して、最も長い単語に向かって網羅的に分析する必要があります。
例 1:
- 単語に "a" のみが含まれる場合、最長の単語チェーン ["a"] の長さは 1 になります。
- 単語を "b" に追加すると、最長の単語チェーンは ["a"] または ["b"] となり、長さは 1 になります。
- 「ba」に単語を追加すると、最長の単語チェーンは ["a", "ba"] または ["b", "ba"]] となり、長さは 2 になります。
- 「bca」に単語を追加すると、最長の単語チェーンは ["a", "ba", "bca"] または ["b", "ba", "bca"] となり、長さは 3 になります。
- 「bda」に単語を追加すると、最長の単語チェーンは ["a", "ba", "bda"] または ["b", "ba", "bda"] となり、長さは 3 になります。
- "bdca" に単語を追加する場合、最も長い単語連鎖 ["a"、"ba"、"bca"、"bdca"] または ["b"、"ba"、"bca"、"bdca"] または [ " a"、"ba"、"bda"、"bdca"] または ["b"、"ba"、"bda"、"bdca"]、長さ 4。
3.2.2 境界の決定
元の単語配列内の各単語について、トラバースと検索を開始する前に、その最長の単語チェーンはそれ自体であり、長さは 1 です。
3.2.3 パターンを見つけて最適な部分構造を決定する
各単語[i]について、その先行単語[j]が元の配列に存在する場合、単語チェーンの 1 つは、先行単語[j]の単語チェーンに独自の単語[i]を加えたものになります。最も長いサブチェーンは、それらの中で最も長いサブチェーンです。
最適な部分構造を取得する
最長のサブチェーン (words[i]) = max(words[j]) + Words[i]; Words[j] は Words[i] の前身です
3.2.4 状態遷移方程式を書く
元の配列を走査するときに、元の単語配列内の最短の単語から最長の単語まで確実に走査するには、最初に元の配列をソートする必要があります。
各単語の最長のサブチェーンの長さを単語配列に格納するように dp 配列を設定し、状態遷移方程式を取得します。
dp[i] = max(dp[j]) + 1; 0 <= j < i、dp[j] は dp[i] の前身です
この式で、dp[i] の先行語 dp[j] を見つけるには、words[i] を一度に 1 文字ずつ削減して、すべての先行語を取得し、元の単語配列の前の部分を走査する必要があります。 Words[i].続行する前に、この先行タスクが存在するかどうかを確認してください。もちろんこれは面倒で、単語が長くなるにつれて必要な時間は急増します。では、このプロセスを簡素化する方法はあるのでしょうか?
はい、ハッシュ テーブルを使用して、単語自体をキー値として使用し、各単語の最長のサブチェーン長を保存します。このようにして、word[i] のすべての先行文字を直接使用してハッシュ テーブルにアクセスし、この先行文字が存在するかどうかを判断し、それを操作するという 2 つのタスクを同時に完了できます。
状態遷移方程式をハッシュテーブルで書き直す。
dp[words[i]] = max(dp[word]) + 1; word は、words[i] のすべての前身です。
3.2.5 コード
class Solution {
public:
int longestStrChain(vector<string>& words) {
unordered_map<string, int> cnt;
sort(words.begin(), words.end(), [](const string a, const string b) {
return a.size() < b.size();
});
int res = 0;
for (string word : words) {
cnt[word] = 1;
for (int i = 0; i < word.size(); i++) {
string prev = word.substr(0, i) + word.substr(i + 1, word.size());
if (cnt[prev]) {
cnt[word] = max(cnt[prev] + 1, cnt[word]);
}
}
res = max(cnt[word], res);
}
return res;
}
};