【Common Lisp】
素朴な実装をオラクルとして
効率版をテストする

  • 効率化したコードのバグを防ぐため、遅くても正しい素朴な実装をオラクルとして残し、累積和などの効率版と結果を突き合わせる手法を紹介します。
  • テストには Common Lisp の parachute ライブラリと macrolet を使い、境界条件を手動で選んだ固定ケースと、乱数で生成した大量のケースを同じテスト内に並べて実行します。
  • #+swank で囲むことで、テストコードは SLIME の REPL 上でのみ評価され、提出コードには含まれません。
  • テストが通ったあとは get-internal-real-time でベンチマークを取り、n を倍々に増やして実行時間の比から計算量を実測して確認します。

効率化したコードは間違えやすいです。
累積和のインデックスがずれたり、境界条件を見落としたり。
遅くても正しい素朴な実装をオラクルとして残しておいて、効率化したコードと突き合わせる方法を紹介します1

関連記事

1. 題材:区間和クエリ

長さ N の数列 A と、Q 個のクエリ (L, R) が与えられます。
各クエリに対して、A_L + A_{L+1} + … + A_R を求めてください。

制約は N, Q ≤ 10^5 程度を想定します。

題材:区間和クエリ 問題 数列 A(長さ N) 3 1 4 1 5 9 クエリ (L, R) が Q 個 A_L + … + A_R を求める N, Q ≤ 10⁵ 素朴な実装 毎回ループで合計 O(NQ) 遅いが正しい ← オラクルとして使う 累積和 前計算で O(1) 回答 O(N+Q) 速いが要検証 ← テスト対象

1.1. まず素朴な実装を書く

ループで区間を毎回足す O(NQ) の実装です。
遅いですが、正しさは明らかです2

(defun range-sum-naive (A L R)
  (loop for i from L to R
        sum (nth (1- i) A)))Code language: Lisp (lisp)

1.2. 累積和で効率化する

前計算で累積和を作っておけば、各クエリを O(1) で答えられます3

(defun build-prefix (A)
  (let* ((n (length A))
         (prefix (make-array (1+ n) :initial-element 0)))
    (loop for i from 1 to n
          do (setf (aref prefix i)
                   (+ (aref prefix (1- i))
                      (nth (1- i) A))))
    prefix))

(defun range-sum-prefix (prefix L R)
  (- (aref prefix R)
     (aref prefix (1- L))))Code language: Lisp (lisp)

インデックスの起算が 1 か 0 か、境界が (1- L)L か、こういった細かい部分でバグが入りやすいです。

2. macrolet でオラクルと突き合わせる

macrolet を使うと、同じ構造のテストケースを一行ずつ並べて書けます4

macrolet でオラクルと突き合わせる ① 固定ケース macrolet (tc 1 1) (tc 1 8) (tc 3 6) 境界条件を手動で選ぶ 1行追加で拡張 ② 突き合わせ parachute:is naive の結果 = prefix の結果 差分を自動検出 期待値と実際値を表示 ③ 提出安全 #+swank SLIME のみで評価 提出コードに テストコードは 含まれない 失敗時:期待値と実際値が並んで表示される 先頭・末尾・全体など境界ケースを優先して選ぶ

#+swank で囲むと、SLIME の REPL 上だけで評価され、提出コードには含まれません5

#+swank
(progn
  (ql:quickload :parachute)
  (let* ((A '(3 1 4 1 5 9 2 6)))
    (parachute:define-test range-sum-test
      (macrolet ((tc (l r)
                   `(parachute:is =
                      (range-sum-naive A ,l ,r)
                      (range-sum-prefix (build-prefix A) ,l ,r))))
        (tc 1 1)
        (tc 1 8)
        (tc 3 6)
        (tc 4 4)
        (tc 1 3)
        (tc 6 8))))
  (parachute:test 'range-sum-test))Code language: Lisp (lisp)

テストケースを追加するのは (tc L R) を一行足すだけです。
失敗すると、期待値と実際の値が並んで表示されるので、どこがずれているかすぐわかります6

2.1. テストを活用するコツ

素朴な実装で怪しいケースを先に調べておくと、テストケースの選び方が自然と決まります。
たとえば累積和なら、L = R = 1(先頭の一要素)、L = 1, R = N(全体)、右端や左端が絡むケースあたりを押さえておくだけで、インデックスのずれはほぼ捕まえられます。

効率化した実装がテストを通ったら、素朴な実装はそのまま残しておいて構いません。
#+swank で囲んであるので、提出コードには含まれないからです。

3. ランダム入力で大量に突き合わせる

境界条件は手で選びますが、それ以外は乱数で大量に試す方が見落としを減らせます7

ランダム入力で大量に突き合わせる 乱数で数列・クエリを生成 loop repeat 100 で実行 全通過 → 安心して提出 計算量を実測 実装 ratio 素朴版 ≈ 4(O(n²)) 累積和版 ≈ 2(O(n)) n を倍々に増やして ratio を確認 repeat を増やすと安定する 失敗時は print で a / l / r を出力して再現ケースを固定ケースに追加 手動ケース(macrolet)+ランダムの両輪でカバー率を上げる

まず、ランダムな数列とクエリを作る関数を用意します。

(defun make-random-input (n &key (max-val 100))
  (loop repeat n collect (1+ (random max-val))))

(defun make-random-query (n)
  (let* ((l (1+ (random n)))
         (r (+ l (random (- n l -1)))))
    (cons l r)))Code language: Lisp (lisp)

macrolet は意図して選んだ固定ケースに使い、ランダムなケースは loop で回します。
同じ define-test の中に並べて書けます。

#+swank
(progn
  (ql:quickload :parachute)
  (let* ((A '(3 1 4 1 5 9 2 6)))
    (parachute:define-test range-sum-test
      (macrolet ((tc (l r)
                   `(parachute:is =
                      (range-sum-naive A ,l ,r)
                      (range-sum-prefix (build-prefix A) ,l ,r))))
        (tc 1 1)
        (tc 1 8)
        (tc 3 6)
        (tc 4 4)
        (tc 1 3)
        (tc 6 8))
      (loop repeat 100
            for a = (make-random-input 50)
            for (l . r) = (make-random-query (length a))
            do (parachute:is =
                 (range-sum-naive a l r)
                 (range-sum-prefix (build-prefix a) l r)))))
  (parachute:test 'range-sum-test))Code language: Lisp (lisp)

100回試して全部通れば、かなり安心できます。
失敗したときは期待値と実際の値が表示されますが、どの入力で失敗したかはわかりません。
print を挟んで alr を出力しながら再実行すると、再現ケースを固定ケースに追加できます。

3.1. time で計算量を実測する

テストが通ったら、本当に速くなっているかを確認します。
n を倍々に増やして実行時間の比を見れば、O(n) なら比が約 2 倍、O(n²) なら約 4 倍に近づきます8

(defun make-bench (f)
  (lambda (n)
    (let* ((a (make-random-input n))
           (prefix (build-prefix a)))
      (loop repeat n
            for (l . r) = (make-random-query n)
            do (funcall f a prefix l r)))))

(defun calc-order (bench &key (base 500) (steps 4) (repeat 3))
  (loop for i from 0 below steps
        for n = (* base (expt 2 i))
        for times = (loop repeat repeat
                          collect (let ((start (get-internal-real-time)))
                                    (funcall bench n)
                                    (- (get-internal-real-time) start)))
        for avg = (/ (reduce #'+ times) (float repeat))
        collect (cons n avg) into results
        finally
           (loop for ((n1 . t1) (n2 . t2)) on results
                 while t2
                 do (format t "n=~a -> n=~a  ratio: ~,2f~%"
                            n1 n2 (/ t2 t1)))))Code language: Lisp (lisp)

呼び出しはこうなります。

#+swank
(progn
  (calc-order
   (make-bench (lambda (a _prefix l r)
                 (range-sum-naive a l r))))
  (calc-order
   (make-bench (lambda (_a prefix l r)
                 (range-sum-prefix prefix l r)))))Code language: Lisp (lisp)

素朴な実装は ratio が 4 に近く、累積和版は 2 に近い値になるはずです。
GC のタイミングで多少ぶれるので、repeat を増やすと安定します。

  1. テストオラクルとは、プログラムの出力が正しいかどうかを判定する別の仕組みのことです。素朴な実装(ブルートフォース)をオラクルとして使う手法は、競技プログラミングで広く使われています。 – Test oracle: A useful tool for competitive programmers
  2. この実装の計算量は O(NQ) です。N, Q がともに 10^5 の場合、最大で 10^10 回の演算が必要になり、制限時間内に終わりません。
  3. 累積和(prefix sum)は、前計算に O(N) かかりますが、クエリの応答は O(1) になります。全体の計算量は O(N + Q) です。 – 累積和 – Wikipedia
  4. macrolet はローカルスコープのマクロを定義する特殊形式です。let と同様にスコープが限定されますが、コンパイル時に展開される点が異なります。ここではテストケースの引数パターンを短く書くために使っています。 – The Common Lisp Cookbook – Macros
  5. SWANK は SLIME のバックエンドサーバです。SLIME 接続中はこのパッケージがロードされているため、(find-package :swank) で SLIME 上かどうかを判定できます。#+swank はその判定を利用したリーダーマクロです。 – 【Common Lisp】コードの修正で自動テストするには?(Parachute)
  6. Parachute の is(is 比較関数 期待値 実測値) の順に引数を取ります。失敗時は「実測値 evaluated to X when Y was expected」という形式で表示されます。 – shinmera/parachute
  7. 素朴な実装をオラクルとして乱数入力で大量テストする手法は、プロパティベーステストと呼ばれます。手動でテストケースを選ぶより、入力空間を広く探索できます。 – Property-based Testing and Test Oracles
  8. この手法はアルゴリズムの経験的な計算量確認によく使われます。get-internal-real-time の返す値は internal-time-units-per-second で割ると秒単位になります。SBCL では通常ミリ秒単位です。 – The Common Lisp Cookbook – Dates and Times