今回は強化学習や競技プログラミングで使用される高速化アルゴリズムの1つである動的計画法について解説していきます。動的計画法を使用して解ける問題には、最長部分列問題や編集距離などの問題がありますが今回は特に有名な問題であるフィボナッチ数列とナップサック問題について解説していきます。
動的計画法は計算コストを大幅に下げることのできる手法であるため、ぜひマスターしてください。
Contents
動的計画法(DP:Dynamic Programming)の概要
動的計画法とは
動的計画法は計算量が大きい問題を小さな問題に部分化し、それらを解くことで最終的な解を得る手法です。動的計画法最大の特徴はメモ化と呼ばれるもので、小さな問題を解いて得られた解を記録しておくことによって計算の重複をさけることができます。
フィボナッチ数列
フィボナッチ数列とは
フィボナッチ数列とは、のように各項が前の2つの項の和になる数列です。
漸化式では と表されます。
今回はフィボナッチ数列の第N項を求める方法について考えていきます。
フィボナッチ数列 DPなし
フィボナッチ数列の第N項を求める手段としてまずは動的計画法を使わない方法について解説します。
上記のコードではフィボナッチ数列の第10項を再帰関数を使って求めています。
この計算方法に大きな無駄が含まれています。どの部分が無駄かわかりますか?
実は上記の方法は同じフィボナッチ数を何度も計算してしまっています。
上記の図を見てください。これは今回の方法でF(10)を求めた場合の図です。F(10)を計算する際にF(8)は2回、F(7)は3回計算されていることがわかります。上記のコードの場合、F(9)を計算する際にF(8)を計算しているので、F(10)を求める場合に再び計算しなおす必要はないわけです。これが最初に言った計算の重複です。
フィボナッチ数列 DPあり
次にフィボナッチ数列を動的計画法使って求める方法について解説します。動的計画法では先ほどの重複をなくすためにある工夫を行います。それがメモ化と呼ばれる技術です。
動的計画法を使用したフィボナッチ数列を求めるコードは以下のようになります。
上記のコードも先ほど同様にフィボナッチ数列の第10項を求めています。先ほどのコードとの違いは計算したフィボナッチ数列の値をmemo配列に保存している点です。fib_DP関数ではF(n)を計算する前にmemo配列に答えが記録されていないかチェックを行います。もしすでに計算されている場合はmemo配列からF(n)の値を返すことによってF(n)の再計算を避けています。
この場合の計算は図のようになります。先ほどに比べて計算が大幅に減っていることがわかります。
これが動的計画法の特徴であるメモ化です。
F(10)を求める際にF(9)やF(8)などの部分問題を解く必要がありました。動的計画法ではそれらの部分問題の解をメモしておくことで計算の重複をさけているわけです。動的計画法の難しいポイントはどのように部分問題に分解するかという点です。
計算速度比較
実際にさまざまなNに対して動的計画法を使用する場合としない場合で実行時間を比較してみましょう。
- n=5 DPあり 0.000004 s DPなし 0.000004 s
- n=20 DPあり 0.000009 s DPなし 0.003807 s
- n=35 DPあり 0.000011 s DPなし 5.193535 s
上記の結果から分かるようにNが大きくなるにつれ、動的計画法を使用した場合とそうでない場合の実行時間の差が大きくなっていることがわかります。
このことから動的計画法が実行時間の高速化にどれほど重要かがわかると思います。
ナップサック問題
ナップサック問題とは
ナップサック問題とはナップサックの容量を超えない範囲で品物を詰む時にナップサック内の総価値を最大にする品物の組み合わせを求める問題です。今回は動的計画法を理解しやすいようにあえて無制限ナップサック問題を選んでいます。具体的には下記のような問題です。
この問題の特徴はソートなどの手法では効率的に解けない点です。仮に現時点の容量で詰め込めるうち価値が最大のものを詰め込むと(貪欲法)
価値 (ダイヤ 2個+鉛 1個) 20×2+3=43 容量 21×2+5=47 となる
現時点の容量で詰め込めるうち(価値/重さ)の値が最大の品物を詰め込むと(貪欲法)
金 18/20=0.9 ダイヤ 20/21=0.95 銅 10/12=0.83 プラチナ 13/15=0.86 鉛 3/5=0.6
価値 (金 2個+鉛 2個) 18×2+3×2=42 容量 20×2+5×2=50 となる
これらの方法は直感的には有効に思えるかもしれませんが、実は間違っています。今回の場合に最も総価値を高めることができるのは、下記の選び方です。
価値 (金 1個+プラチナ 2個) 18×1+13×2=44 容量 20+15×2=50 となります
実はこの問題は貪欲法やソートによる工夫などでは解くことができません。今回のケースでは容量が決まっているため、容量と価値の双方が関係してくるためです。
ナップサック DPなし
先ほどのナップサック問題をプログラムで解く方法について説明します。
ナップサック問題を解く方法としてまず思いつくのが全探索だと思います。再帰関数を使用して愚直に解くと下記のようなコードになります。
このコードはナップサックにいれる品物を愚直に全通り試しているので明らかに有用でない入れ方についても探索してしまっています。
例えば、銅を2つ入れるとダイヤを1つ入れるという2つの選択には明らかな優劣があります。銅は2つで価値 20 容量 24に対してダイヤ1つは価値 20 容量 21です。
この時点で銅を2つ入れる場合は考える必要がないのですが全探索なので銅2つを入れる場合についても探索してしまっています。また同じ組み合わせについても入れる順が違う場合にその都度計算してしまうため計算の重複が含まれてしまっています。
すべてのアイテムを愚直に試しているのでアイテムの数が増えると探索範囲が増え(枝が横に大きく)、容量が増えるほど再帰回数が多くなる(根が深くなる)。
品数や容量が大きいと実行できない程の計算量になってしまいます。
ナップサック DPあり
ナップサックサック問題で動的計画法を使用するためにこの問題をどのような部分問題に分解するとよいかわかりますか?。動的計画法の難しいポイントです。
今回の場合、容量に関する部分問題に分解します。具体的には容量が1の時や容量が10の時などの本来の問題よりも容量が小さい状態の総価値の最大値を求めることを部分問題とします。
具体的には以下のようになります。
ナップサックの最大容量がXの時の総価値の最大値をdp[X]とする。
例えば、X=5の時は鉛を1つ入れた総価値3が最大なのでdp[5]=3ということです。
次に考えるのは、より大きいXに対してdp[X]の値を求める方法です。
dp[X]までの計算が終了した時、dp[X+1]を求めることができれば任意のNについてもdp[N]が求められることがわかると思います。結論から言うとdp[X+1]は以下の式で求められます。
ここはすごく重要な部分なので少し詳しく解説していきます。
dp[x+1]において最大の総価値をとなる品物の組み合わせのサイズの合計がx以下である場合は、dp[x]の定義からdp[x+1]=dp[x]となることがわかると思います。ではそれ以外ではどのような場合が考えられるでしょうか。ずばり、ある品物をナップサックに入れた時サイズの総和がx+1になる場合です。
なぜなら入れた品物のサイズの総和がx以下の場合の総価値の最大値はdp[x]であるため、それを上回る場合は、ある品物を入れると容量がちょうどx+1になる場合だけだからです。
ただし、ある品物を入れて容量がx+1になったからといってそれがdp[x]より重くなることは保証されていません
。あくまで、容量がxに制限されていた状態では不可能であった品物の組み合わせという点に過ぎません。
では、ある品物をナップサックに入れた時サイズの総和がx+1になる場合の総価値の最大値はどのように計算するのでしょうか。
今回の場合は、ある品物をナップサックに入れた時サイズの総和がx+1かもしれない場合を求めることで解決します。
サイズの合計がx+1になる可能性がある場合は、ある品物を積んだ瞬間にサイズの合計がx+1になる場合なので、品物積む前の最大値にその品物の価値を足したものになるわけです。(ただし品物1を積む前の合計サイズがx+1-w1より小さい場合は、ちょうどサイズがx+1にならないわけですがその場合はdp[x]以下であることが保証されているため問題ありません)
このことからdp[x+1]の更新式は以下のようになることがわかります。
この方法を使って動的計画法を使用したコードは以下のようになります。
実行時間を比較すると動的計画法を使用しない場合よりも早いことがわかります。
DPを使用した場合、dp[x+1]の更新には既知の値しか使っていないため、人間でも可能なほど少ない計算で値を求めていることがわかると思います。DPの場合は容量や品物数が大きくなってもほとんど計算量は増えないわけです。このことからもアイテム数や容量が増えるほど実行時間の差は大きくなるためDPを使うことが重要であることがわかると思います。
計算速度比較
上記のコードは先ほどのナップサック問題のナップサック容量を増加させながらDPを使用しない方法とDPを使用する場合で実行時間を比較するコードです。結果は以下のようになります。
- 容量 50 DPなし 0.000576 秒 DPあり 0.000093 秒
- 容量 100 DPなし 0.401237 秒 DPあり 0.000206 秒
- 容量 130 DPなし 20.777770 秒 DPあり 0.000274 秒
容量が増加するとDPを使用しない場合は実行時間が指数関数的に増加していることがわかると思います。また容量だけでなく、品物の種類が増加するほどDPを使用しない場合は実行時間は指数的に増加していきます。DPの有効性がよく分かると思います。
さいごに
今回は動的計画法について説明しました。動的計画法はいっけん莫大な計算量がかかる問題を部分問題に分解し、部分問題の解をメモしておくことで計算の重複をさける手法でした。
実際にサンプルコードなどを実行していただいた方は実行時間の差を痛感したと思います。実は今回は動的計画法の中でもかなり入門的な問題を扱っているのでもっと動的計画法を練習したい方はぜひ様々な問題に挑戦してみてください。