動的計画法について

数列の漸化式、たとえば階乗・フィボナッチ数(後述)などは、その定義 に自分自身がでてきます。このようなものを再帰的定義 といいますが、それらを解くときには再帰関数 を使うことで問題は 簡単に記述できるのは当然です。典型的な再帰問題である階乗(!)
…を知らない人がいるかもしれないので、念のために;

(自然数の)階乗とは、 1からその数までを順番にかけたもの で、普通「!」 で表します。例えば5の階乗 (5!) は、

5! = 1 x 2 x 3 x 4 x 5
   = 120
となります。これをプログラムで計算させる場合、繰り返し構文でもいいので すが、上で説明している再帰的な定義を使うと、
1! = 1 ←定義
2! = 2 x 1! =  2 x 1 = 2 
3! = 3 x 2! = 3 x 2 = 6 
4! = 4 x 3! = 4 x 6 = 24 
5! = 5 x 4! = 5 x 24 = 120 
...
のようになり、結局、下のようにかける、ということです。
の計算を再帰で書くと、「にぱも」(疑 似日本語プログラミング環境)では以下のようになります。
# 再帰関数定義の例
#
関数カイジョウを引数はアで定義する
 もしアが1以下ならば
  1を返す
 でなければ
  アアをアとする
  アアから1を引く
  アに関数カイジョウを引数はアアで呼び出したものを掛ける
  アを返す
ヒキに数値入力する
関数カイジョウを引数はヒキで呼び出したものを出力する
このように関数カイジョウの定義の中にカイジョウ自体が出てくるもの が 再帰関数 です。ちなみに階乗の計算は以下に埋めこんである JavaScript でも確認できます。
しかし、再帰関数は処理効率という意味ではなかなか厳しいものがあります。 これは、関数の中で関数を繰り返し呼び出すときの計算コストが実 は効いているというわけで、特に大きな数に対して処理すると、関数呼び出し が指数関数的に増大して非常に重い処理になります。
返り値用の領域をスタックに用意して、レジスタをスタックに退避して、ロー カル変数もスタックに積んで…みたいなことなのですが、詳細はここでは略し ます。
そのような時に、ここで説明する動的計画法 (Dynamic Programing, DP)の考えかたを取り入れると、効率よく解ける場合があります。
DP 自体は本来オペレーションズ・リサーチ (Operations Research, OR) の分 野などでよく扱われた、再帰とは直接関係ないものです。最適化問題を複数の 部分問題に分割して解く際に、そこまでに求められている以上の最適解が求め られないような部分問題を切り捨てながら解いていく手法です。
動的計画法を単純に説明すると、処理内容が小さな計算の繰り返しで あるとき、既に計算した値はどこか(多くの場合は配列)に保存し ておいて、いつでも再利用できるようにして重複計算を避けようとい う考え方です。もちろんこの手法は万能ではなく、以下の性質をもつ問題に適 応するのがいいとされています。 つまり、問題全体の規模(場合の数?)nは大きいが、部分問題の規模xの取り うる範囲がそれほど大きくないときに効果が大きいことが期待できるというわ けです。逆に、nが小さく、xの範囲がとてつもなく大きいときは、やめたほう が(=再帰で解いた方が)いいと思われます。

例題:フィボナッチ数

概念的な説明では分かりにくいので、例でみていきます。
整数nにたいして(とりあえず0≤n<100としておきます)これ の「フィボナッチ数」 を求めなさい、という問題を考えます。

フィボナッチ数とは、最初の2つの数は1、3つ目以降は、直前の2つの数の和、 というものです。具体的には以下のようなものです。

1, 1, 2, 3, 5, 8, 13, 21, ...

再帰プログラムの例

まず再帰でプログラムを考えます。「フィボ」という関数をつくって、再帰処 理をさせてみます。 関数フィボを引数はアで定義する  もしアが二以下ならば   一を返す  じゃなければ   イをアとする   イから一を引く   ウをアとする   ウから二を引く   エを関数フィボを引数はイで呼び出したものとする   エに関数フィボを引数はウで呼び出したものを足す   エを返す 関数フィボを引数は20で呼び出したものを出力する この関数の中では、 という処理になっています(ほぼそのままプログラムとして書けていることに 注意?!)

動的計画法の例

再帰プログラムの無駄なところはどこかというと、[引数-1] や[引数-2] で呼び出すところの計算が、小さい数のところでは何回も呼び出されて いるという点です。そこで、動的計画法を使って書き換えると以下のようにな ります。 # 動的計画法利用の関数定義の例 # 関数フィボナを引数はアで定義する  0以上ア以下の配列をフィに入れる  もしアが2以下ならば   1を返す  じゃなければ   フィの0番目を1とする   フィの1番目を1とする   2からアまでのそれぞれをアアとして繰り返す    イをアアとする    イから1を引く    ウをアアとする    ウから2を引く    エをフィのイ番目とする    エにフィのウ番目を足す    フィのアア番目をエとする  フィのアア番目を返す 関数フィボナを引数は70で呼び出したものを出力する

再帰から動的計画法への書き換え

末尾再帰 (tail recursion, 再帰呼び出しがそのサブルーチンの最 後にあること)が繰り返しで書き換えられることはよく知られてい ます。前掲の通り階乗は以下のようになります。 関数カイジョウを引数はアで定義する  もしアが1以下ならば   1を返す  でなければ   アアをアとする   アアから1を引く   アに関数カイジョウを引数はアアで呼び出したものを掛ける   アを返す ヒキに数値入力する 関数カイジョウを引数はヒキで呼び出したものを出力する これは以下のように書いても計算することが出来ます。 関数カイジョウを引数はアで定義する  カイを1とする  2からアまでのそれぞれについて繰り返す   カイにそれを掛ける  カイを返す ヒキに数値入力する 関数カイジョウを引数はヒキで呼び出したものを出力する この場合、後者のほうが速いです。
本質ではないですが、「にぱも」プログラムでは、直前に示された変数(こ の場合は、ループ変数)に名前をつけずに「それ」で使えていることに注意 してください(?!)
同様に、再帰法から動的計画法へ機械的に 書き換えることができることがあります。例えば、フィボナッチ数のプログラ ムで、終了条件
 もしアが二以下ならば
  一を返す
は、初期化の部分
  フィの0番目を1とする
  フィの1番目を1とする
に対応していますし、また、再帰呼び出し
  エを関数フィボを引数はイで呼び出したものとする
  エに関数フィボを引数はウで呼び出したものを足す
は、(既に計算された)小さなデータを織り込む部分
   エをフィのイ番目とする
   エにフィのウ番目を足す
に対応しているのがわかるでしょう。ですので、動的計画法でプログラムを書 くのは慣れてなくて難しいかもしれませんが、そんなときは、まずは再帰で記 述して、それが動的計画法で書き直せるかを考えてもいいかもしれません。
ただ、メモリ効率という意味では再帰のほうがいい場合もある(そうじゃない 場合もある)ということも、頭の片隅においておくといいかもしれません。言 い換えると、 CPU 効率とメモリ効率の トレードオフ(両天秤にかける) 、という感じでしょうか?
問題によっては劇的に処理速度が速くなりますので、覚えておくと(プログラ マとして)幸せになれるときがきっとくるでしょう。
講義用スタイル
印刷用スタイル