動的プログラミングの謎を解く

動的プログラミングアルゴリズムを構築およびコーディングする方法

インタビューのコーディングの準備中に聞いたことがあるかもしれません。アルゴリズムコースで苦労したかもしれません。自分でコーディングする方法を学ぼうとしていて、途中でどこかで動的プログラミングを理解することが重要だと言われたのかもしれません。アルゴリズムを記述するために動的プログラミング(DP)を使用することは、恐れられるほど重要です。

そして、それから縮小する人々を誰が責めることができますか?動的プログラミングは、教訓がないため、威圧的に見えます。多くのチュートリアルでは、プロセスではなくアルゴリズムを説明する結果に焦点を当てて、アルゴリズムを見つけます。これは、理解ではなく暗記を促します。

今年のアルゴリズムクラスでは、動的プログラミングを必要とする問題を解決するための独自のプロセスをまとめました。その一部は、私のアルゴリズム教授(多くの功績が当然です!)、および動的プログラミングアルゴリズムの私自身の分析からの一部です。

しかし、プロセスを共有する前に、基本から始めましょう。とにかく、動的プログラミングとは何ですか?

動的プログラミングの定義

動的プログラミングとは、最適化問題をより単純な副問題に分解し、各副問題が1回だけ解決されるように各副問題にソリューションを保存することです。

正直に言うと、この定義は、サブ問題の例を見るまで完全に意味をなさないかもしれません。大丈夫です、次のセクションで説明します。

私が伝えたいのは、DPは最適化問題、特定の制約が与えられた最大または最小の解を求める問題のための有用な技術であるということです。これにより、アルゴリズムの解決または近似に使用されるほとんどの技術とは言えない正確さと効率が保証されます。これだけでもDPは特別です。

次の2つのセクションでは、サブ問題とは何かを説明した後、動的プログラミングでソリューションの保存(メモ化と呼ばれる手法)が重要な理由を説明します。

サブ問題のサブ問題のサブ問題

副問題は、元の問題の小さなバージョンです。実際、サブ問題は、多くの場合、元の問題の修正版のように見えます。正しく定式化されていれば、元の問題の解決策を得るために、サブ問題は互いに積み重なります。

これがどのように機能するかをよりよく理解するために、動的プログラミングの問題の例で副次的な問題を見つけましょう。

1950年代に戻って、IBM-650コンピューターで作業しているふりをします。あなたはこれが何を意味するか知っています—パンチカード!あなたの仕事は、1日、IBM-650を男性または女性にすることです。実行する自然数nのパンチカードが与えられます。各パンチカードiは、所定の開始時刻s_iに実行され、所定の終了時刻f_iに実行を停止する必要があります。 IBM-650で一度に実行できるパンチカードは1つだけです。各パンチカードには、会社にとっての重要度に基づいて、関連する値v_iもあります。

問題:IBM-650の担当者として、実行されるすべてのパンチカードの合計値を最大化するパンチカードの最適なスケジュールを決定する必要があります。

この記事ではこの例を詳細に説明するため、ここではその副次的な問題についてのみ説明します。

副問題:パンチカードが開始時間でソートされるような、パンチカードiからnの最大値スケジュール。

副問題が元の問題をソリューションを構築するコンポーネントに分解する方法に注目してください。サブ問題を使用すると、n-1からnのパンチカードの最大値スケジュールを検索でき、n-2からnのパンチカードの最大値スケジュールを検索できます。すべてのサブ問題の解決策を見つけることで、元の問題自体に取り組むことができます:パンチカード1からnの最大値スケジュール。副問題は元の問題のように見えるため、副問題を使用して元の問題を解決できます。

動的プログラミングでは、各副問題を解決した後、それをメモまたは保存する必要があります。次のセクションでその理由を見つけましょう。

フィボナッチ数によるメモ化の動機付け

任意の数のフィボナッチ値を計算するアルゴリズムを実装するように指示されたら、どうしますか?私が知っているほとんどの人は、Pythonで次のような再帰アルゴリズムを選択します。

def fibonacciVal(n):
  n == 0の場合:
    0を返す
  elif n == 1:
    1を返す
  その他:
    return fibonacciVal(n-1)+ fibonacciVal(n-2)

このアルゴリズムは目的を達成しますが、多大なコストがかかります。たとえば、n = 5(F(5)と略す)を解くためにこのアルゴリズムが計算する必要があるものを見てみましょう。

F(5)
                    / \
                   / \
                  / \
               F(4)F(3)
            / \ / \
          F(3)F(2)F(2)F(1)
         / \ / \ / \
       F(2)F(1)F(1)F(0)F(1)F(0)
       / \
     F(1)F(0)

上記のツリーは、n = 5のフィボナッチ値を見つけるために実行する必要のある各計算を表しています。n= 2の副問題が3回解決されることに注意してください。比較的小さな例(n = 5)の場合、これは多くの繰り返しの無駄な計算です!

n = 2のフィボナッチ値を3回計算する代わりに、1回計算してその値を保存し、n = 2が発生するたびに保存されたフィボナッチ値にアクセスするアルゴリズムを作成したらどうなるでしょうか?それがまさにメモ化の機能です。

これを念頭に置いて、私はフィボナッチ値の問題に対する動的プログラミングソリューションを作成しました。

def fibonacciVal(n):
  メモ= [0] *(n + 1)
  メモ[0]、メモ[1] = 0、1
  範囲(2、n + 1)のiの場合:
    memo [i] = memo [i-1] + memo [i-2]
  メモを返す[n]

戻り値の解がメモ化配列memo []からどのように得られるかに注目してください。メモ化配列はforループによって繰り返し埋められます。 「繰り返し」とは、memo [2]が計算され、memo [3]、memo [4]、…、およびmemo [n]の前に格納されることを意味します。 memo []はこの順序で入力されるため、各サブ問題(n = 3)の解は、これらの値が既に格納されているため、その前のサブ問題(n = 2およびn = 1)以前のmemo []。

メモ化は再計算を行わないことを意味し、より効率的なアルゴリズムになります。したがって、メモ化は動的プログラミングが効率的であることを保証しますが、最適なものを見つけるために動的プログラムがあらゆる可能性を通過することを保証する適切な副問題を選択しています。

メモ化と副次的な問題に対処したので、動的プログラミングプロセスを学習します。バックル。

私の動的プログラミングプロセス

ステップ1:言葉で副問題を特定します。

多くの場合、プログラマは手元の問題について批判的に考える前にコードを書くことになります。良くない。キーボードに触れる前に脳を発火させるための戦略の1つは、元の問題で特定した副問題を説明するために、英語などの言葉を使用することです。

動的プログラミングを必要とする問題を解決する場合は、紙を手に取り、この問題を解決するために必要な情報について考えてください。これを念頭に置いて副問題を書きます。

たとえば、パンチカードの問題では、サブ問題を「パンチカードが開始時間でソートされるように、パンチカードi〜nの最大値スケジュール」と書くことができると述べました。パンチカードが開始時間でソートされるように、パンチカード1〜nの最大値スケジュールを決定するには、次のサブ問題に対する答えを見つける必要があります。

  • パンチカードが開始時間でソートされるような、パンチカードn-1からnの最大値スケジュール
  • パンチカードが開始時間でソートされるような、パンチカードn-2からnの最大値スケジュール
  • パンチカードが開始時間でソートされるような、パンチカードn-3からnの最大値スケジュール
  • (エトセトラ)
  • パンチカードが開始時間でソートされるような、パンチカード2からnの最大値スケジュール

目前の問題を解決するために以前の副問題に基づいた副問題を特定できれば、あなたは正しい軌道に乗っています。

ステップ2:副問題を数学的決定の繰り返しとして書き出す。

言葉で副問題を特定したら、数学的に書きます。どうして?まあ、あなたが見つけた数学的再発、または繰り返された決定は、最終的にあなたがコードに入れるものです。さらに、サブ問題を書き出すと、ステップ1の単語でサブ問題を数学的に検証します。数学でステップ1のサブ問題をエンコードすることが難しい場合、それは間違ったサブ問題である可能性があります。

再発を探そうとするたびに、私は自分自身に尋ねる2つの質問があります。

  • すべてのステップでどのような決定を下しますか?
  • 私のアルゴリズムがステップiにある場合、ステップi + 1で何をするかを決定するにはどのような情報が必要ですか? (そして時々:私のアルゴリズムがステップiにある場合、ステップi-1で何をするかを決定するためにどのような情報が必要でしたか?)

パンチカードの問題に戻り、これらの質問をしてみましょう。

すべてのステップでどのような決定を下しますか?前述のように、パンチカードは開始時間でソートされていると仮定します。これまでのスケジュールと互換性のある各パンチカード(開始時刻は現在実行中のパンチカードの終了時刻より後)ごとに、アルゴリズムは、パンチカードを実行するかしないかの2つのオプションから選択する必要があります。

この動的なプログラムは、親しい友人のハムレットのように、各ステップで2つのオプションから選択します!

私のアルゴリズムがステップiにある場合、ステップi + 1で何をするかを決定するにはどのような情報が必要ですか? 2つのオプションを決定するには、アルゴリズムは次の互換性のあるパンチカードを順番に知る必要があります。所定のパンチカードpの次の互換性のあるパンチカードは、s_q(パンチカードqの所定の開始時間)がf_p(パンチカードpの所定の終了時間)の後に発生し、s_qとf_pの差が最小になるパンチカードqです。数学者の話を捨てて、次の互換性のあるパンチカードは、現在のパンチカードの実行が終了した後、最も早い開始時間を持つものです。

私のアルゴリズムがステップiにある場合、ステップi-1で何をするかを決定するためにどのような情報が必要でしたか?アルゴリズムは、パンチカードi-1を実行するかどうかを決定するために、パンチカードiからnに対して行われた将来の決定について知る必要があります。

これらの質問に答えたので、おそらくあなたは頭の中で繰り返し数学的な決定を下し始めたのでしょう。そうでない場合でも大丈夫です。より動的なプログラミングの問題にさらされると、繰り返しを簡単に書くことができます。

さらに苦労することなく、ここに繰り返しがあります。

OPT(i)= max(v_i + OPT(next [i])、OPT(i + 1))

この数学的な再発には、特にこれまで書いたことのない人のために、ある程度の説明が必要です。 OPT(i)を使用して、パンチカードが開始時間でソートされるように、パンチカードi〜nの最大値スケジュールを表します。おなじみですね。 OPT(•)は、ステップ1の副問題です。

OPT(i)の値を決定するために、2つのオプションを検討します。目標を達成するために、これらのオプションの最大値を使用します。すべてのパンチカードの最大値スケジュールです。ステップiで最大の結果が得られるオプションを選択したら、その値をOPT(i)としてメモします。

パンチカードiを実行するかどうかの2つのオプションは、数学的に次のように表されます。

v_i + OPT(next [i])

この句は、パンチカードiを実行する決定を表します。パンチカードiの実行から得られた値をOPT(next [i])に追加します。ここで、next [i]はパンチカードiに続く次の互換性のあるパンチカードを表します。 OPT(next [i])は、パンチカードが開始時間でソートされるように、パンチカードnext [i]〜nの最大値スケジュールを提供します。これらの2つの値を加算すると、パンチカードiからnの最大値スケジュールが作成され、パンチカードiが実行された場合、パンチカードは開始時間でソートされます。

OPT(i + 1)

逆に、この句は、パンチカードiを実行しないという決定を表します。パンチカードiが実行されない場合、その値は得られません。 OPT(i + 1)は、パンチカードが開始時間でソートされるように、パンチカードi + 1からnの最大値スケジュールを提供します。したがって、OPT(i + 1)は、パンチカードiが実行されていない場合、パンチカードが開始時間でソートされるように、パンチカードiからnの最大値スケジュールを提供します。

このようにして、パンチカード問題の各ステップで行われた決定は数学的にエンコードされ、ステップ1の副問題を反映します。

ステップ3:ステップ1と2を使用して元の問題を解決します。

ステップ1では、パンチカード問題の副問題を言葉で書き留めました。ステップ2では、これらの副問題に対応する繰り返しの数学的決定を書き留めました。この情報で元の問題をどのように解決できますか?

OPT(1)

とても簡単です。ステップ1で見つかったサブ問題は、パンチカードが開始時間でソートされるように、パンチカードiからnの最大値スケジュールであるため、パンチカード1からnの最大値スケジュールとして元の問題の解決策を書き出すことができます。パンチカードが開始時間でソートされるように。ステップ1と2は密接に関連しているため、元の問題はOPT(1)と書くこともできます。

ステップ4:メモ化配列の次元と、配列の方向を決定します。

ステップ3は一見シンプルに見えましたか?確かにそうです。 OPT(2)、OPT(next [1])などに依存している場合、OPT(1)は動的プログラムの解決策になると考えるかもしれません。

OPT(1)がOPT(2)のソリューションに依存していることに気付くのは正しいことです。これは、ステップ2から直接続きます。

OPT(1)= max(v_1 + OPT(next [1])、OPT(2))

しかし、これは壊滅的な問題ではありません。フィボナッチのメモ化の例を振り返ってください。 n = 5のフィボナッチ値を見つけるには、アルゴリズムは、n = 4、n = 3、n = 2、n = 1、およびn = 0のフィボナッチ値が既にメモされているという事実に依存します。メモ化テーブルに正しい順序で入力すると、他の副問題へのOPT(1)の依存は大したことではありません。

メモ表を埋める正しい方向をどのように識別できますか?パンチカードの問題では、OPT(1)がOPT(2)およびOPT(next [1])のソリューションに依存しており、パンチカード2およびnext [1]がソートによりパンチカード1の後に開始時間を持っていることがわかっているため、メモ表をOPT(n)からOPT(1)に記入する必要があると推測できます。

このメモ化配列の次元をどのように決定しますか?トリックは次のとおりです。配列の次元は、OPT(•)が依存する変数の数とサイズに等しくなります。パンチカードの問題では、OPT(i)があります。これは、OPT(•)がパンチカード番号を表す変数iのみに依存することを意味します。これは、メモ化配列が1次元であり、合計パンチカードがn個あるため、そのサイズがnになることを示しています。

n = 5であることがわかっている場合、メモ化配列は次のようになります。

メモ= [OPT(1)、OPT(2)、OPT(3)、OPT(4)、OPT(5)]

ただし、多くのプログラミング言語では0から配列のインデックス付けが開始されるため、このメモ化配列を作成して、インデックスがパンチカード番号と一致するようにすると便利な場合があります。

メモ= [0、OPT(1)、OPT(2)、OPT(3)、OPT(4)、OPT(5)]

ステップ5:コーディングしてください!

動的プログラムをコーディングするには、ステップ2〜4をまとめます。動的プログラムを作成するために必要な唯一の新しい情報はベースケースです。これは、アルゴリズムをいじくり回すときに見つけることができます。

パンチカード問題の動的プログラムは、次のようになります。

def punchcardSchedule(n、values、next):
 #メモ化配列の初期化-ステップ4
  メモ= [0] *(n + 1)
  
 #ベースケースを設定する
  メモ[n] =値[n]
  
 #メモ表をnから1に作成-ステップ2
  range(n-1、0、-1)のiの場合:
    memo [i] = max(v_i + memo [next [i]]、memo [i + 1])
 
 #元の問題の解決策を返すOPT(1)-ステップ3
  メモを返す[1]

最初の動的プログラムの作成おめでとうございます!足が濡れたので、別の種類の動的プログラムを紹介します。

選択のパラドックス:複数オプションDPの例

DPとは関係ありませんが、マルチオプションの決定がいかに悲惨かを正確に描写しています。

前のダイナミックプログラミングの例では、パンチカードを実行するかしないかという2つのオプションの決定がありましたが、問題によっては、各ステップで決定する前に複数のオプションを考慮する必要があります。

新しい例の時間。

友情のブレスレットをn人の顧客に販売していると仮定すると、その製品の価値は単調に増加します。つまり、顧客jが顧客iの後に来る場合、製品の価格は{p_1、…、p_n}であり、p_i≤p_jになります。これらのn人の顧客の値は{v_1、…、v_n}です。特定の顧客iは、p_i≤v_iの場合にのみ、価格p_iで友情ブレスレットを購入します。それ以外の場合、その顧客から得られる収益は0です。価格は自然数であると仮定します。

問題:フレンドシップブレスレットの販売から最大限の収益を確保できる価格のセットを見つける必要があります。

手順1と2のソリューションを見る前に、この問題にどのように対処するかについて少し考えてください。

ステップ1:言葉で副問題を特定します。

サブ問題:顧客i-1の価格がqに設定された、顧客iからnから得られる最大収益。

顧客1〜nの最大収益を決定するには、次の副問題に対する答えを見つける必要があることを認識して、この副問題を見つけました。

  • 顧客n-2の価格がqに設定されるような、顧客n-1からnから得られる最大収益。
  • 顧客n-3の価格がqに設定された、顧客n-2からnから得られる最大収益。
  • (エトセトラ)

サブ問題に2番目の変数qを導入したことに注意してください。これは、各サブ問題を解決するために、そのサブ問題の前に顧客に設定した価格を知る必要があるためです。変数qは価格セットの単調な性質を保証し、変数iは現在の顧客を追跡します。

ステップ2:副問題を数学的決定の繰り返しとして書き出す。

再発を探そうとするたびに、私は自分自身に尋ねる2つの質問があります。

  • すべてのステップでどのような決定を下しますか?
  • 私のアルゴリズムがステップiにある場合、ステップi + 1で何をするかを決定するにはどのような情報が必要ですか? (そして時々:私のアルゴリズムがステップiにある場合、ステップi-1で何をするかを決定するにはどのような情報が必要ですか?)

友情のブレスレットの問題に戻って、これらの質問をしてみましょう。

すべてのステップでどのような決定を下しますか?現在の顧客に友情ブレスレットを販売する価格を決めます。価格は自然数でなければならないため、顧客iの価格をq(顧客i-1の価格セット)からv_i(顧客iがフレンドシップブレスレットを購入する最大価格)の範囲で設定する必要があることを知っています。

私のアルゴリズムがステップiにある場合、ステップi + 1で何をするかを決定するにはどのような情報が必要ですか?私のアルゴリズムは、顧客i + 1の価格を設定する自然数を決定するために、顧客iの価格セットと顧客i + 1の値を知る必要があります。

この知識があれば、繰り返しを数学的に書き出すことができます。

OPT(i、q)= max〜([Revenue(v_i、a)+ OPT(i + 1、a)])
max〜がq≤a≤v_iの範囲ですべてのaの最大値を見つけるように

繰り返しますが、この数学的再発には説明が必要です。顧客i-1の価格はqであるため、顧客iの場合、価格aは整数qのままであるか、q + 1とv_iの間の整数に変更されます。総収益を見つけるために、顧客iの価格がaに設定されるように、顧客iからの収益を顧客i + 1からnから取得した最大収益に追加します。

言い換えると、総収益を最大化するには、アルゴリズムは、qとv_iの間のすべての可能な価格をチェックすることにより、顧客iの最適な価格を見つける必要があります。 v_i≤qの場合、価格aはqのままでなければなりません。

他のステップはどうですか?

手順1と2を実行することは、動的プログラミングの最も難しい部分です。演習として、理解を確認するために、ステップ3、4、および5を自分で行うことをお勧めします。

動的プログラムのランタイム分析

アルゴリズムの作成の楽しい部分であるランタイム分析について説明します。このディスカッションでは、big-O表記を使用します。 big-Oにまだ慣れていない場合は、ここで読んでください。

一般に、動的プログラムのランタイムは次の機能で構成されています。

  • 前処理
  • forループの実行回数
  • ループの繰り返しで1回実行するのにどれだけの時間がかかるか
  • 後処理

全体として、ランタイムは次の形式を取ります。

前処理+ループ*繰り返し+後処理

パンチカードの問題のランタイム分析を実行して、動的プログラムのbig-Oに精通しましょう。パンチカード問題の動的プログラムは次のとおりです。

def punchcardSchedule(n、values、next):
 #メモ化配列の初期化-ステップ4
  メモ= [0] *(n + 1)
  
 #ベースケースを設定する
  メモ[n] =値[n]
  
 #メモ表をnから1に作成-ステップ2
  range(n-1、0、-1)のiの場合:
    memo [i] = max(v_i + memo [next [i]]、memo [i + 1])
 
 #元の問題の解決策を返すOPT(1)-ステップ3
  メモを返す[1]

ランタイムを分析しましょう:

  • 前処理:ここでは、これはメモ配列の構築を意味します。に)。
  • forループの実行回数:O(n)。
  • 1回のループ反復で繰り返しが実行されるのにかかる時間:繰り返しは、各繰り返しで2つのオプションを決定するため、実行に一定の時間がかかります。 O(1)。
  • 後処理:なし! O(1)。

パンチカード問題の動的プログラムの全体的な実行時間は、O(n)O(n)* O(1)+ O(1)、または簡略化した形式ではO(n)です。

できたね!

それだけです-ダイナミックプログラミングウィザードに一歩近づいたのです!

マーガレットハミルトン:私たちの歴史の中で多くのプログラミングウィザードの1つ!

最後の知恵:動的プログラミングの練習を続けます。これらのアルゴリズムがどれほどイライラしているように見えても、動的プログラムを繰り返し記述すると、副問題と再発がより自然に発生します。試してみるための古典的な動的プログラミングの問題のクラウドソーシングリストを以下に示します。

だからそこに出て、あなたのインタビュー、クラス、そしてあなたの(もちろん)あなたの新たな動的プログラミングの知識を身につけてください!

この投稿を校正してくれたSteven Bennett、Claire Durand、およびPrithaj Nathに感謝します。 Hartline教授に感謝します。ダイナミックプログラミングに非常に興奮し、それについて詳しく説明しました。

あなたが読んだものをお楽しみください?この作品を好み​​、共有して愛を広めましょう。考えや質問がありますか? Twitterまたは以下のコメントで私に連絡してください。