動的計画法(ナップサック問題)
動的計画法とナップサック問題について解説します。
動的計画法とは
- 直接計算すると大きな時間がかかってしまう問題に対し、途中の計算結果をうまく再利用することで計算効率を上げる手法のこと。
- 「途中の計算結果を再利用」=「同じ計算をしない」ということ
- 難しいように見えて考え方自体は単純
- ICPC国内予選でもC問題~F問題くらいに何かしらの形で2,3題ほどでます
- 英語では「Dynamic Programming」と呼び、略して「DP」と呼ぶことが多いです。
- 動的計画法で効率的に解ける問題の一つに、ナップサック問題というものがあります。
ナップサック問題
ナップサック問題は、価値と重さが決まっている複数の品物を容量が一定のナップサックに詰め込むとき、ナップサックに詰め込める品物の価値の和の最大値は何であるか? という問題です。
具体的には、以下の図のようになります。ナップサックに書かれている「15kg」が容量で、周囲の品物(箱)に書かれている数字が価値と重さを表しています。
画像はWikipediaより改変(Dake, Keenan Pepper / CC BY-SA 2.5)
解法案
ナップサック問題についておおざっぱに方針を考えると、以下のような解法が思い浮かぶかもしれません。
- ある品物を入れるか入れないかの全パターンを試す(全探索)
- 何か優先順位をつけて、その順番でナップサックに入れていく(貪欲法)
全パターンを試せば、明らかに正しい答えを得ることができます。ただし、品物の数をnとすると計算量が\([O(2^n)]\)ほどあり、まともに計算できるのはnが25くらいまでのときのみです。
優先順位をつける方法なら、品物をソートして容量をオーバーしないようにしながら順に選んでいけるので、高速に計算することができます。
しかし実は、優先順位をつける方法ではどうやっても正しい解を得ることはできません。
上の図の例でいつくかの優先順位を使って最大値を求めたときに得られる解は、次のようになります。
- 価値が大きいもの優先: 15+4 = 19
- 重さが小さいもの優先: 2+3+1+4+8 = 18
- (価値÷重さ)が大きいもの優先: 2+8+3+4+1 = 18
- 全探索: 15+3+2 = 20
全探索で得られる20が正しい答えですが、他のやり方ではそれよりも小さな値になっています。
実は、ナップサック問題はNP困難な問題であり、ソートなどを用いて効率的に解くことは不可能だと考えられています。
深さ優先探索による実装
効率よい方法を考える前に、まずは全探索での実装を考えてみましょう。
全探索の方法として深さ優先探索(再帰関数)を使うことにします。
以下の例では、n
が品物の個数、W
がナップサックの容量、w[i]
とv[i]
がそれぞれi
番目の品物(品物i)の重さと価値を示します。
再帰関数をどう作るのかはやや悩ましいのですが、現在の注目している品物の番号と、現時点でのナップサックの残り容量を関数の引数にしてみるとうまくいきます。
再帰関数をrec(i, j)
とし、「品物i
~n-1
を重さj
のナップサックに入れたときの価値の最大値」を返す関数を表すことにします。
rec(0, W)
という関数呼び出しの動作を考えてみます。
このとき、品物0を入れるとすると、品物1以降の価値の最大値は「品物0が使えず、ナップサックの容量がw[0]
だけ減ったときの価値の最大値」と考えることができます。
すると、品物0を入れたときの価値の最大値はv[0]+rec(1, W-w[0])
と再帰の形で表せます。
同様に品物0を入れないとすると、ナップサックの容量は変化しないので最大値はrec(1, W)
と表せます。
rec(0, W)
全体の最大値はその2つの大きい方になります。
このような関数呼び出しをi
が1,2,3…と繰り返していけば、最終的な答えが得られそうです。
i
がn
(品物の数)以上のときのrec(i, j)
は明らかに0になる(使える品物が1つもない)ので、この再帰計算は無限ループにならず必ず終了します。
プログラムは次のようになります。
メモ化再帰による実装
上記のコードをよく解析すると、rec(i, j)
は再帰されているので指数回数分だけ呼び出されるが、iとjが同じであればrec(i, j)
の結果は常に一定であることがわかります(使える品物と使える容量が一定なら価値の和の最大値も一定となるため)。
そのため、rec(i, j)
の結果を配列に記憶しておけば、同じ計算をする手間が省けます。それを実装すると次のようになります。
単純な再帰関数に数行追加しただけですが、計算効率が大幅に向上しています。
メモ化をするとiとjの1つの組み合わせについて1回計算するだけで済むので、計算量はO(nW)になります。
単純な再帰では\([O(2^n)]\)だったので、Wが極端に大きくなければ圧倒的な効率の良さです。
一度計算した結果を再利用して計算効率を上げているので、この解法は動的計画法を用いた解法だと言えます。(厳密には上記のプログラムでやっていることはメモ化探索と呼ばれるもので、動的計画法とは区別されることがある。本質的には同じ)
漸化式を用いた実装
上記のメモ配列dp[i][j]
の変化をよく観察すると、iが大きくjが小さい順(使える品物と容量が少ない順)にメモの値が確定していくことがわかります。
これを考慮すると、メモ配列の中身を次のような漸化式として表現することができます。
$$[
dp[n][j] = 0 \\
dp[i][j] =
\begin{cases}
dp[i+1][j] & (j < w[i])\\
max\{dp[i+1][j],\ dp[i+1][j-w[i]] + v[i]\} & (j \ge w[i])
\end{cases}
]$$
この漸化式を使ってdp[i][j]
を順に求めていく操作をプログラムで実装すると、次のようになります。
再帰がなくなり、単純なforループで実装できています。
この方法も(狭義の)動的計画法であり、計算量はO(nW)となります。
両者のやり方の比較
基本的には大差はありません。
メモ化再帰の利点
- 問題を再帰で解くことができれば、そこからメモ化に移行しやすい
- 漸化式を作るより再帰を作るほうが考えやすいことが多い
漸化式の利点
- 再帰が不要な分効率がよく、コードも短くなる傾向がある
- コードが単純になる分、熟読してもそのプログラムが何をやっているのかわからなくなることも
- コメント書き残しておくこと大事です
- メモ化再帰では解けないような問題(確率計算など)でも解けることがある
コード
追記:string.h
のインクルード抜けを修正しました。
その他の動的計画法で解ける問題の一例
色々あります。講習会でもそのうち取り扱うかも。
- コイン両替問題
- 部分和問題
- 最長増加部分列
- 連鎖行列積
- 巡回セールスマン
- ビットDP(ビット演算を使って集合を数値で表して実行する動的計画法)を使う
演習
参考文献