2011年5月24日火曜日

ナップサック問題とメモ化 in Elisp

有名なナップサック問題を Emacs Lisp で勉強である。

ナップサック問題とは「重さと価値がそれぞれ wi, vi, である品物から、重さの総和が W を超えないように選んだときの、価値の総和の最大値を求める」問題である。

素朴なバージョン

価値の最大値を求める関数を作る。入力は ((w1, v1) (w2, v2) ... (wn, vn)) なる形のリストと、総和 W の2つ。

(defun knapsack (lst w)
  (cond 
   ((null lst) 0)
   (t (if (> (caar lst) w) ;先頭の品物が入らない場合
          (knapsack (cdr lst) w)
        (max  ;入れる場合と入れない場合を両方とも試し、良い結果のほうを選ぶ
         (+ (cadar lst) (knapsack (cdr lst) (- w (caar lst))))
         (knapsack (cdr lst) w))))))
;; 確認
(knapsack '((10 7) (4 2) (5 9) (1 4) (7 9) (3 7) (6 4) (3 5)) 20)
=> 34

n個の品物それぞれについて、入れる/入れないの二通りを計算する考え方であり、最大計算量は O(2n) となる。

※確認には ナップサック問題とは? に載っている例を使っている。

※Emacs のバージョンは GNU Emacs 22.3.1

メモ化(memoization)を行うバージョン

上の関数 knapsack が再帰的に実行されるときの引数を分析すると、第一引数は長さnのリストから先頭の要素が一つずつ取り去られていくので合計 n種類になる。また、第二引数は初期値 W から何らかの整数を引いた値であるから、最大で W - 1 から 0 までの整数の数、つまりW種類になる。

第一引数が n 通りで第二引数が W 通りなので、引数の場合の数は結局のところ n * W 通り。メモ化を利用して n * W程度の領域に結果を保存すれば、最大計算量を 2n から n * W のオーダーに減らすことができるわけである。

次に、具体的にどうやってメモ化を行うかを考える。素朴な方法は、グローバル変数としてハッシュテーブルを定義しておき、knapsackの中でそれを参照するというもの。しかし、この方法は関数をグローバル変数に依存した形に書き換えなければならず、再利用やコピペがしにくくなる。

そこで、もう少し独立性を保てるよう、Yコンビネータを利用した方法を使う。この方法については、 さあ、Yコンビネータ(不動点演算子)を使おう! - よくわかりません を参考に、YコンビネータのコードはY combinator - Rosetta Code の中のCommon Lispバージョンを参考にするとよい(Elispでも動くように let ではなく lexical-let を使った形に書き換えるなどする)。

;; メモ化機能を組み込んだ Y です
(defun Y (f)
  (lexical-let ((_f f) (cache (make-hash-table :test #'equal)))
    ((lambda (x) (funcall x x))
     (lambda (y)
       (lexical-let ((_y y))
         (funcall _f (lambda (&rest args)
                       (if (gethash args cache)
                           (gethash args cache)
                         (setf (gethash args cache)
                               (apply (funcall _y _y) args))))))))))

;; Yコンビネータ向けに、ラムダ式を返す形
(defun knapsack-memo (f)
  (lexical-let ((_f f))
    (lambda (lst w)
      (cond 
       ((null lst) 0)
       (t (if (> (caar lst) w)
              (funcall _f (cdr lst) w)
            (max
             (+ (cadar lst) (funcall _f (cdr lst) (- w (caar lst))))
             (funcall _f (cdr lst) w))))))))

;; 確認
(funcall
 (Y 'knapsack-memo)
 '((10 7) (4 2) (5 9) (1 4) (7 9) (3 7) (6 4) (3 5)) 20)
=> 34

以下は、計算時間を比較した例。

;; 時間をざっと計る関数
(defun profile (func &rest args)
  (let ((start (current-time)))
    (apply func args)
    (time-to-seconds (time-subtract (current-time) start))))
=> profile

;; 重さと価値のリスト
(setq *w-v-list* '((10 7) (4 2) (5 9) (1 4) (7 9) (3 7) (6 4) (3 5) (3 5) (5 3) (1 2) (3 4) (1 5) (2 2) (4 5) (2 2) (4 5) (3 9) (1 5)))
=> ((10 7) (4 2) (5 9) (1 4) (7 9) (3 7) (6 4) (3 5) (3 5) (5 3) (1 2) (3 4) ...)

;; 素朴なバージョンは約 0.43 秒
(profile 'knapsack *w-v-list* 30)
=> 0.438918

;; メモ化バージョンは約 0.06 秒
(profile (Y 'knapsack-memo)  *w-v-list* 30)
=> 0.063991

n をもっと増やすと劇的に差が出るはず。ちなみに手軽に時間を計る関数は実行時間の測定 どう書く?org から流用したもの。

0 件のコメント:

コメントを投稿