動的計画法

動的計画法 (Dynamic Programming)

講義資料

例題紹介

例題と課題は別々になっています。 例題の方は、正解プログラム例まで紹介しますが、課題の方は、ヒント+入出力まわりのプログラムだけ配ります。

例題ですが、ACM ICPC という国際大学対向プログラミングコンテストの2007年アジア予選東京大会の問題C (pdf) です。

プログラムおよびサンプルデータは、dp directory 以下に配置してあります。git を更新してください。

  • backgammon.c
  • サンプルデータ(入力、正解):sample.in, sample.ans
  • 確認用データ(入力、正解): C.in, C.ans

問題文が英語なので、簡単に要約すると、対象とするのは バックギャモンを単純化したゲーム。

  • 左端からスタート、右端がゴール
    • スタート:0, ゴール: N, 制約: 5 ≤ N ≤ 100
  • サイコロを振って、目の数だけ進む
    • ゴールを超えたら、その分戻る
    • L: 1回休み
    • B: スタートに戻る
  • 質問: T 回以内にゴールにたどり着く確率は? (1 ≤ T ≤ 100)

minimalBackgammon.gif

試行錯誤

とりあえず、少し考えてみましょう。 最初は、必ずスタートにいて、1/6 の確率で、位置: 1-6 に移動するわけです。 但し、L についたら一回休みだし、B についたら、スタートに戻るんですが。

さて、仮に、サイコロのパターンを全部試したらどうなるでしょう?

  • サイコロを 10 回振ったときのパターン: 6の10乗~60Mぐらい。
  • サイコロを 20 回振ったときのパターン: 6の20乗~3600ペタぐらい?
  • サイコロ 100回なんて、気が遠くなる。。。

てな具合です。真面目にやると大変な回数ですね。 まあ、サンプリングで近似的に割合を求めるって方法(モンテカルロ法)もありますが、今回は、現み使いを求めてみましょう。

組み合わせを全て求めると上記のように発散してしまうわけですが、幅優先探索の時みたいに、探索枝の合流をうまく処理すれば、無駄な計算をしなくても済む気がします。

解法

今回、f(k,p) を、k 回目のサイコロを振ったあと、位置 p に到着する確率とします。 “B” のマスに入ったときは、位置 0 に到着することにしておきます。

このとき、f(k, p) は、 f(k-1, ?) および f(k-2, ?) が分かっていれば、求められるはずです。

解法

以下のプログラムは、f(k-2, ?)f(k-1, ?) の結果を次段に反映させていれば、f(k, p)を求められるはずって感じのプログラムのイメージです。 まあ、L に入ったときや、B に入ったときの処理がちょっと面倒ですけど、まあ解けそうな気がしますよね?

int solve() {
    for(/* 0 回目から t 回目まで*/) {
        for(/* 位置 0 から位置 n-1 まで*/) {
            for(/* サイコロ 1, .., 6 */) {
                int nextPos; /* 次の位置は分かる */
                int nextTurn; /* 次のターンもわかる */
                /* f(nextTurn, nextPos) を更新 */
            }
        } 
    }
}

プログラム解説

プログラムbackgammon.cの解説を簡単にしておきます。

  • board: 盤面情報

    • board[0]: スタート, board[n]: ゴール, n 番要素までアクセスするので注意
    • state_t: W(空白), L(一回休み), B(振り出しに戻る)を表す enum 型
  • double solve(int n, int t)

    • 皆さんに実装してほしい関数
    • 与えられた board の状態と、n: 盤面サイズ情報, t: ターン数に対して、解を求める。
  • double prob[MAX_T+2][MAX_N+1]: 解説図中の f(k, p) を格納するための配列

  • 実際のsolve()関数の中身も、上の疑似コードとおなじですよね。

動的計画法

動的計画法 (Dynamic Programming, DP) は、部分問題から順に解き、その結果を用いてより大規模な問題を解く手法です。

実は、前回おこなったダイクストラ法も、DP の一種です。波紋を広げる感じで、部分問題から大規模な問題に進めていくと。

あるいは、フィボナッチ関数 fib(n) = fib(n-1)+fib(n-2) を、loop を使って解くのも、DP 的解法といえます。

感じとしては、「ある種の漸化式」をつくって、順番に解くって感じでしょうか。 結果として、プログラムは単純なループ構造になることも多いです。 ただ、「なにを部分問題とするか」がポイントで、少し頭を使いますが。

これとは別に、プログラミングとしては、 部分問題の結果を記録するためのデータ構造のことも考えなくてはです。 上の課題では、単純な配列で OK ですが、場合によってはハッシュや2分探索木を使いたくなるかもしれませんね。

計算量と量感覚

今回の課題の分析

今回の解法の計算量について考えましょう。

まあ、3重ループを書いた訳で、

  • 各サイコロのターン: O(n) (n 盤面サイズ、サイコロの目は 6 と定数)
  • ターンの数: t
  • 時間計算量: O(n*t)
  • 空間計算量: n x t の配列を使った場合は、O(n*t)
    • 全ターン覚えずに、直前2ターンだけ覚えた場合 O(n)

てな感じです。

5≤ n ≤ 100, 1≤ t ≤ 100 です。

  • 時間計算量は、深さ優先なら「6100なんて、ありえない!」けど、DP なら 100x100 程度で、なんてことないぜ!
  • 配列サイズは n x t の配列つかっても 100x100 程度だから、節約せずに2次元配列使おうぜ!

とか思えるでしょうか?

現実計算機のスペック

さっきの計算量が、現実、どれぐらいの計算時間になるか、少し考えましょう。

  • CPU: 数 GHz
    • 1秒間に数 G 回の命令をこなす (1命令 1nano sec 以下)
    • 1000 命令かかる処理でも 数M 回こなせる。
    • 今回:100x100=10K なんて、なんてことないぜ。
  • メモリ
    • 主メモリ数 GB は当たり前(でも、OS などにもメモリ領域のこさないとだけど)
    • キャッシュサイズは 数 MB 以下なので、容量が小さいほうが嬉しいのは確か。
    • ハードディスクは数 TB オーダー(ファイルやデータベース)
    • 今回: 100x100=10K個の要素, 1要素 4byte でも 40KB, なんてことないぜ。

てな感じです。

量感覚イメージ

おまけで、量感覚をいくつか紹介しておきます。

  • 累乗: 210~K, 210~M, 230~G
  • 階乗: 5! = 120, 10! ~ 3.6M ぐらい, 13! > 4G
  • 32 bit 符号付整数で表現できる値: -2G ~ 2G
  • 時間
    • CPU: 1秒間に数 G回の演算が可能
    • 1分:60秒, 1時間: 3.6K秒, 1日:86.4K
    • 1M秒: 11日半ぐらい
    • 意外と 1秒は長くて、1 日はさほどでもないというか。