回答:
rev4:ユーザーSammaronによる非常に雄弁なコメントは、おそらく、この回答は以前はトップダウンとボトムアップを混同していたと述べています。元々この回答(rev3)と他の回答では「ボトムアップはメモ化」(「サブ問題を想定」)でしたが、逆の場合もあります(つまり、「トップダウン」は「サブ問題を想定」と「ボトムアップ」は「サブ問題を構成する」かもしれません)。以前、私はメモ化について、動的プログラミングのサブタイプとは異なる種類の動的プログラミングであることを読みました。購読していないにもかかわらず、私はその視点を引用していた。文献に適切な参照が見つかるまで、この回答を用語にとらわれないように書き直しました。また、この回答をコミュニティーWikiに変換しました。学術的な情報源を優先してください。参考文献一覧:} {文献:5 }
動的プログラミングとは、重複する作業の再計算を回避する方法で計算を順序付けることです。主な問題(サブ問題のツリーのルート)、およびサブ問題(サブツリー)があります。サブ問題は通常繰り返され、重複します。
たとえば、お気に入りのフィボナッチの例を考えてみましょう。単純な再帰呼び出しを行った場合、これはサブ問題の完全なツリーです。
TOP of the tree
fib(4)
fib(3)...................... + fib(2)
fib(2)......... + fib(1) fib(1)........... + fib(0)
fib(1) + fib(0) fib(1) fib(1) fib(0)
fib(1) fib(0)
BOTTOM of the tree
(他のいくつかのまれな問題では、このツリーはいくつかのブランチで無限であり、終了しないことを表している可能性があります。したがって、ツリーの下部が無限に大きくなることがあります。したがって、明らかにするサブ問題を決定するための戦略/アルゴリズムが必要になる場合があります。)
動的プログラミングには、相互に排他的ではない少なくとも2つの主要な技法があります。
メモ化-これは自由放任のアプローチです。すでにすべての部分問題を計算していて、最適な評価順序が何であるかわからないと仮定します。通常は、ルートから再帰呼び出し(または同等の反復)を実行し、最適な評価順序に近づくことを期待するか、最適な評価順序に到達するのに役立つ証拠を取得します。結果をキャッシュするため、再帰呼び出しで副問題が再計算されないようにし、重複するサブツリーが再計算されないようにします。
fib(100)
、あなたはこれを呼ぶだろう、それは呼ぶだろうfib(100)=fib(99)+fib(98)
呼び出すことになる、fib(99)=fib(98)+fib(97)
、...等...、呼び出すことになりますfib(2)=fib(1)+fib(0)=1+0=1
。その後、最終的に解決しますがfib(3)=fib(2)+fib(1)
、fib(2)
キャッシュしているため、再計算する必要はありません。集計-動的プログラミングを「テーブル充填」アルゴリズムと考えることもできます(通常、多次元ですが、この「テーブル」には非常にまれなケースで非ユークリッドジオメトリがある場合があります*)。これはメモ化に似ていますが、よりアクティブで、1つの追加ステップが含まれます。事前に、計算を行う正確な順序を選択する必要があります。これは、順序が静的である必要があることを意味するのではなく、メモ化よりもはるかに柔軟性があることを意味します。
fib(2)
、fib(3)
、fib(4)
...あなたがより簡単に次のものを計算することができるようにすべての値をキャッシュします。テーブルを埋める(別の形式のキャッシング)と考えることもできます。(最も一般的には、「動的プログラミング」パラダイムでは、プログラマーはツリー全体を検討し、その後、必要なプロパティを最適化できるサブ問題を評価するための戦略を実装するアルゴリズムを記述します(通常、時間の複雑さとスペースの複雑さ)戦略は、特定のサブ問題からどこかに開始する必要があり、おそらくそれらの評価の結果に基づいて適応する可能性があります。「動的プログラミング」の一般的な意味では、これらのサブ問題をキャッシュしようとする可能性があります。 、おそらくさまざまなデータ構造のグラフの場合など、微妙な区別を付けてサブ問題を再検討しないようにしてください。多くの場合、これらのデータ構造は配列やテーブルのようにコアにあります。サブ問題の解決策は、もう必要ありません。)
[以前は、この回答はトップダウンとボトムアップの用語について述べていました。完全にではありませんが、これらの用語と全単射の可能性があるメモ化と集計と呼ばれる2つの主要なアプローチが明らかにあります。ほとんどの人が使用する一般的な用語はまだ「動的プログラミング」であり、「動的プログラミング」の特定のサブタイプを指すために「メモ化」と言う人もいます。この答えは、コミュニティが学術論文で適切な参照を見つけることができるまで、どちらがトップダウンかボトムアップかを述べることを拒否します。結局のところ、用語ではなく区別を理解することが重要です。]
メモ化はコーディングが非常に簡単であり(通常、*自動的に行う「メモライザ」注釈またはラッパー関数を記述できます)、これが最初のアプローチになるはずです。集計の欠点は、注文を考え出す必要があることです。
*(これは実際に関数を自分で記述している場合、および/または純粋でない/機能しないプログラミング言語でコーディングしている場合にのみ簡単です...たとえば、誰かがすでにコンパイル済みのfib
関数をすでに記述している場合、それは必ずそれ自体に再帰呼び出しを行います。これらの再帰呼び出しが新しいメモ化された関数(元のメモ化されていない関数ではない)を呼び出すことを保証せずに、関数を魔法のようにメモ化することはできません。
当然ではないかもしれませんが、トップダウンとボトムアップの両方が再帰または反復的なテーブル充填で実装できることに注意してください。
メモ化を使用すると、ツリーが非常に深い場合(例:)、fib(10^6)
遅延計算をスタックに配置する必要があり、10 ^ 6になるため、スタック領域が不足します。
サブ問題を訪問する(または試行する)順序が最適でない場合、特にサブ問題を計算する方法が複数ある場合(通常はキャッシングでこれを解決できますが、理論的にはキャッシングで可能性がある場合)一部のエキゾチックなケースではありません)。メモ化は通常、時間の複雑さを空間の複雑さに追加します(たとえば、集計では、Fibで集計を使用するとO(1)スペースを使用できますが、Fibでメモを作成するとO(N)を使用できます。スタックスペース)。
非常に複雑な問題も行っている場合は、表を作成する以外に方法がない可能性があります(または、メモを目的の場所に導くために、少なくともより積極的な役割を果たします)。また、最適化が絶対的に重要であり、最適化する必要がある状況にある場合、表を作成することで、他の方法では正常化できないメモ化では行われない最適化を実行できます。私の控えめな意見では、通常のソフトウェアエンジニアリングでは、これらの2つのケースはどちらも発生しません。そのため、何か(スタックスペースなど)が集計を必要としない限り、メモ(「回答をキャッシュする関数」)を使用します...技術的には、スタックブローアウトを回避するために、1)許可する言語でスタックサイズの制限を増やすか、2)一定の係数の追加作業を実行してスタックを仮想化します(ick)、
ここでは、DPの一般的な問題だけでなく、メモと表を興味深いことに区別する、特に関心のある例を示します。たとえば、1つの定式化が他の定式化よりもはるかに簡単な場合や、基本的には集計が必要な最適化がある場合があります。
python memoization decorator
ます。一部の言語では、メモ化パターンをカプセル化するマクロまたはコードを記述できます。メモ化パターンは、「関数を呼び出すのではなく、キャッシュから値を検索する(値がそこにない場合は、最初に計算してキャッシュに追加する)」にすぎません。
fib(513)
。ここで、用語が多すぎて邪魔になっていると感じています。1)不要になったサブ問題はいつでも破棄できます。2)不要な部分問題の計算を常に回避できます。3)1と2は、サブ問題を格納する明示的なデータ構造がないと、コーディングがはるかに難しくなる可能性があります。または、関数呼び出し間で制御フローを織り込む必要がある場合は、さらに困難になります(状態または継続が必要になる場合があります)。
トップダウンとボトムアップDPは、同じ問題を解決する2つの異なる方法です。フィボナッチ数を計算するためのメモ化(トップダウン)と動的(ボトムアップ)のプログラミングソリューションについて考えてみましょう。
fib_cache = {}
def memo_fib(n):
global fib_cache
if n == 0 or n == 1:
return 1
if n in fib_cache:
return fib_cache[n]
ret = memo_fib(n - 1) + memo_fib(n - 2)
fib_cache[n] = ret
return ret
def dp_fib(n):
partial_answers = [1, 1]
while len(partial_answers) <= n:
partial_answers.append(partial_answers[-1] + partial_answers[-2])
return partial_answers[n]
print memo_fib(5), dp_fib(5)
個人的には、メモ化はずっと自然だと思います。再帰的な関数を取り、それを機械的なプロセスでメモすることができます(最初の回答をキャッシュで検索して、可能であればそれを返します。それ以外の場合は再帰的に計算してから、戻る前に、将来の使用のために計算をキャッシュに保存します)。動的プログラミングでは、ソリューションが計算される順序をエンコードする必要があります。これにより、依存する小さな問題の前に「大きな問題」が計算されなくなります。
動的プログラミングの主要な機能は、重複するサブ問題の存在です。つまり、解決しようとしている問題はサブ問題に分割でき、それらのサブ問題の多くはサブサブ問題を共有します。それは「分断して征服」のようなものですが、同じことを何度も繰り返すことになります。2003年以降、これらの問題を指導または説明するときに使用した例:フィボナッチ数を再帰的に計算できます。
def fib(n):
if n < 2:
return n
return fib(n-1) + fib(n-2)
お好きな言語を使用して、で実行してみてくださいfib(50)
。非常に長い時間がかかります。だいたいfib(50)
それ自体と同じくらいの時間!しかし、多くの不要な作業が行われています。fib(50)
はfib(49)
and を呼び出しますが、値が同じであっても、fib(48)
両方がを呼び出しfib(47)
てしまいます。実際、fib(47)
は3回計算されます。からの直接呼び出し、からfib(49)
の直接呼び出しfib(48)
、および別のからの直接呼び出しfib(48)
によって、の計算によって生成されたものですfib(49)
...したがって、重複するサブ問題があります。
素晴らしいニュース:同じ値を何度も計算する必要はありません。一度計算したら、結果をキャッシュし、次にキャッシュされた値を使用します!これが動的プログラミングの本質です。「トップダウン」、「メモ化」など、好きな名前を付けることができます。このアプローチは非常に直感的で、実装が非常に簡単です。最初に再帰的なソリューションを記述し、小さなテストでテストし、メモ(すでに計算された値のキャッシュ)を追加し、そして---ビンゴ!---完了です。
通常、再帰なしでボトムアップで機能する同等の反復プログラムを作成することもできます。この場合、これはより自然なアプローチです。1から50までループして、すべてのフィボナッチ数を計算します。
fib[0] = 0
fib[1] = 1
for i in range(48):
fib[i+2] = fib[i] + fib[i+1]
興味深いシナリオでは、ボトムアップソリューションは通常、理解するのがより困難です。ただし、一度理解すれば、通常、アルゴリズムがどのように機能するかをより明確に把握できます。実際には、重要な問題を解決するときは、最初にトップダウンアプローチを記述し、小さな例でテストすることをお勧めします。次に、ボトムアップソリューションを記述し、2つを比較して、同じ結果が得られることを確認します。理想的には、2つのソリューションを自動的に比較します。理想的には、多くのテストを生成する小さなルーチンを作成します- すべて特定のサイズまでの小規模なテスト---両方のソリューションが同じ結果をもたらすことを検証します。その後、本番環境でボトムアップソリューションを使用しますが、トップボトムコードは保持し、コメント化します。これにより、他の開発者があなたが何をしているのかを簡単に理解できるようになります。ボトムアップコードは、たとえ自分で書いても、何をしているのかを正確に理解しているとしても、非常にわかりにくい場合があります。
多くのアプリケーションでは、再帰呼び出しのオーバーヘッドのため、ボトムアップアプローチはわずかに高速です。スタックオーバーフローは、特定の問題で問題になることもあります。これは、入力データに大きく依存する可能性があることに注意してください。場合によっては、動的プログラミングを十分に理解していないと、スタックオーバーフローを引き起こすテストを記述できない場合がありますが、いつかはこれがまだ発生する可能性があります。
現在、問題の空間が非常に大きく、すべてのサブ問題を解決することができないため、トップダウンアプローチが唯一の実行可能な解決策である問題があります。ただし、入力が解決される部分問題のほんの一部しか必要としないため、「キャッシュ」は依然として妥当な時間で機能します---しかし、明確に定義するのは難しいので、どの部分問題を解決する必要があるか、つまりボトムを書く必要があります-アップソリューション。一方、すべての副問題を解決する必要があることがわかっている状況もあります。この場合、続けてボトムアップを使用します。
個人的には、段落の最適化、つまりワードラップの最適化問題にトップボトムを使用します(Knuth-Plassの改行アルゴリズムを調べます。少なくともTeXが使用し、Adobe Systemsの一部のソフトウェアも同様のアプローチを使用しています)。高速フーリエ変換にはボトムアップを使用します。
例としてフィボナッチシリーズを見てみましょう
1,1,2,3,5,8,13,21....
first number: 1
Second number: 1
Third Number: 2
別の言い方をすると、
Bottom(first) number: 1
Top (Eighth) number on the given sequence: 21
最初の5つのフィボナッチ数の場合
Bottom(first) number :1
Top (fifth) number: 5
例として、再帰的なフィボナッチ級数アルゴリズムを見てみましょう
public int rcursive(int n) {
if ((n == 1) || (n == 2)) {
return 1;
} else {
return rcursive(n - 1) + rcursive(n - 2);
}
}
次のコマンドでこのプログラムを実行すると
rcursive(5);
アルゴリズムを詳しく調べると、5番目の数を生成するために3番目と4番目の数が必要です。したがって、私の再帰は実際にはtop(5)から始まり、それからすべてのボトム/低い数値に行きます。このアプローチは、実際にはトップダウンアプローチです。
同じ計算を複数回行うことを避けるために、動的プログラミング手法を使用します。以前に計算した値を保存して再利用します。この手法はメモ化と呼ばれます。動的プログラミングには、現在の問題を議論するのに必要のないメモ化以外にもあります。
トップダウン
オリジナルのアルゴリズムを書き直し、メモ化された手法を追加しましょう。
public int memoized(int n, int[] memo) {
if (n <= 2) {
return 1;
} else if (memo[n] != -1) {
return memo[n];
} else {
memo[n] = memoized(n - 1, memo) + memoized(n - 2, memo);
}
return memo[n];
}
そして、このメソッドを次のように実行します
int n = 5;
int[] memo = new int[n + 1];
Arrays.fill(memo, -1);
memoized(n, memo);
アルゴリズムはトップバリューから始まり、各ステップの最下部に移動してトップバリューを取得するため、このソリューションは依然としてトップダウンです。
一気飲み
しかし、問題は、最初のフィボナッチ数列のように、下から始めて、上に向かって歩けるかどうかです。このテクニックを使って書き直してみましょう
public int dp(int n) {
int[] output = new int[n + 1];
output[1] = 1;
output[2] = 1;
for (int i = 3; i <= n; i++) {
output[i] = output[i - 1] + output[i - 2];
}
return output[n];
}
次に、このアルゴリズムを調べると、実際には低い値から開始されてから、先頭に移動します。5番目のフィボナッチ数が必要な場合、実際には1番目、次に2番目、次に3番目の5番目の数まで計算します。この手法は、実際にはボトムアップ手法と呼ばれています。
最後の2つは、動的プログラミング要件を満たすアルゴリズムです。しかし、1つはトップダウンで、もう1つはボトムアップです。どちらのアルゴリズムも、空間と時間の複雑さが似ています。
動的プログラミングはしばしばメモ化と呼ばれます!
1.メモ化はトップダウン手法であり(与えられた問題を分解することで解決します)、動的プログラミングはボトムアップ手法です(些細な副問題から、与えられた問題に向かって解決し始めます)
2.DPは、ベースケースから開始してソリューションを見つけ、上に向かって進みます。DPはボトムアップで実行するため、すべてのサブ問題を解決します
必要なサブ問題のみを解決するメモ化とは異なり
DPには、指数時間ブルートフォースソリューションを多項式時間アルゴリズムに変換する可能性があります。
DPは反復的であるため、はるかに効率的かもしれません
反対に、メモ化は、再帰による(多くの場合、かなりの)オーバーヘッドを支払う必要があります。
より簡単にするために、メモ化はトップダウンアプローチを使用して問題を解決します。つまり、コア(メイン)の問題から始まり、サブ問題に分割して、これらのサブ問題を同様に解決します。このアプローチでは、同じサブ問題が複数回発生し、より多くのCPUサイクルを消費する可能性があるため、時間の複雑さが増します。一方、動的プログラミングでは、同じ副問題が複数回解決されることはありませんが、以前の結果がソリューションの最適化に使用されます。
トップダウンアプローチは、Subの問題を何度も呼び出すために再帰を使用するというだけです
が、ボトムアップアプローチは何も呼び出さずにシングルを使用するため、より効率的です。
以下は、トップダウンの編集距離問題のDPベースのソリューションです。ダイナミックプログラミングの世界を理解するのにも役立つことを願っています。
public int minDistance(String word1, String word2) {//Standard dynamic programming puzzle.
int m = word2.length();
int n = word1.length();
if(m == 0) // Cannot miss the corner cases !
return n;
if(n == 0)
return m;
int[][] DP = new int[n + 1][m + 1];
for(int j =1 ; j <= m; j++) {
DP[0][j] = j;
}
for(int i =1 ; i <= n; i++) {
DP[i][0] = i;
}
for(int i =1 ; i <= n; i++) {
for(int j =1 ; j <= m; j++) {
if(word1.charAt(i - 1) == word2.charAt(j - 1))
DP[i][j] = DP[i-1][j-1];
else
DP[i][j] = Math.min(Math.min(DP[i-1][j], DP[i][j-1]), DP[i-1][j-1]) + 1; // Main idea is this.
}
}
return DP[n][m];
}
あなたはあなたの家でその再帰的な実装を考えることができます。これまでにこのような問題を解決したことがない場合、それは非常に良い課題です。
トップダウン:現在までの計算値を追跡し、基本条件が満たされたときに結果を返します。
int n = 5;
fibTopDown(1, 1, 2, n);
private int fibTopDown(int i, int j, int count, int n) {
if (count > n) return 1;
if (count == n) return i + j;
return fibTopDown(j, i + j, count + 1, n);
}
ボトムアップ:現在の結果は、そのサブ問題の結果によって異なります。
int n = 5;
fibBottomUp(n);
private int fibBottomUp(int n) {
if (n <= 1) return 1;
return fibBottomUp(n - 1) + fibBottomUp(n - 2);
}