本章ではリスト処理を行う関数を再帰を使って書く練習をしながら,関数の処理時間の見積もり方と最適なコードのへの書き換え方を学ぶ.本ページではまず第5話と6話の間に挿入されたLispのコンパイル方法と,第6話で説明されている関数のトレースの仕方についてまとめ,その後,処理時間の見積もり方と,効率的なコードへの書き換えについてまとめる.
Lispのコンパイル方法
REPLで作業をしている時,定義済みの関数や外部ファイルに書かれたプログラムをコンパイルする方法についてまとめる.
関数ごとのコンパイル
foo
という名の関数を定義したら,REPLで(compile 'foo)
を実行すると関数単位でコンパイルされる.コンパイルされている関数とされていない関数が混在しても問題なく動作する.当然コンパイル済みの関数は早く動く.
(defun foo ....)
(compile 'foo)
slimeを使っている場合,「C-c C-c」でカーソル位置のトップレベルをcomplileする.この場合出力はない.
ファイル単位でのコンパイル
ファイルの内容をすべてコンパイルするにはファイル名を指定して(compile-file "foo.lisp")
でコンパイルした後,拡張子を省略したファイル名でプログラムをロードする.
(compile-file "foo.lisp")
(load "foo")
slimeでは「C-c C-k」でバッファ全体(ファイル)をcompileする.
逆アセンブル
コンパイラが変換した機械語は,disassemble
関数で逆アセンブルすることができる.
(defun foo .....)
(compile 'foo)
(disassemble 'foo)
slimeでは定義した関数のシンボル上で「C-c M-d (slime-disassemble-symbol)」を実行すると,一時バッファに逆アセンブルした結果が表示される.
関数処理のトレース
trace
関数を使うと関数に入る時と出る時の情報が表示される.組込み関数をtrace
の引数に渡すとエラーが出るが,自分で定義した関数なら問題ない.
> (defun length$ (lst)
(length-loop lst 0))
> (defun length-loop (lst count)
(if (null lst) count
(length-loop (cdr lst) (+ count 1))))
> (trace length$) ; length$関数のトレース開始
> (length$ '(a b c))
0: (LENGTH$ (A B C))
0: LENGTH$ returned 3
3
> (trace length-loop) ; length-loop関数のトレース追加
> (length$ '(a b c))
0: (LENGTH$ (A B C))
1: (LENGTH-LOOP (A B C) 0)
2: (LENGTH-LOOP (B C) 1)
3: (LENGTH-LOOP (C) 2)
4: (LENGTH-LOOP NIL 3)
4: LENGTH-LOOP returned 3
3: LENGTH-LOOP returned 3
2: LENGTH-LOOP returned 3
1: LENGTH-LOOP returned 3
0: LENGTH$ returned 3
3
> (untrace) ; 全関数のトレース終了.
T
計算時間の見積もり方
オーダー記法と同様,データのサイズに対しどれ位のオーダーで処理時間が増加していくかに興味があるため,以下の方法で見積もる.
- データの長さ
n
に比例しない一定時間処理は,何ステップかかっても1と数える. car
,car
,cons
,null
,+
,-
,*
,/
等の基本関数は一定時間で実行できると考える.- 長さ
n
のデータ(リスト)に比例してかかる計算時間はn
と数える. - 同様に長さ
n^2
のデータ(リスト)に比例してかかる計算時間はn^2
で数える.
例)リストの長さを求めるlength$
関数の計算時間の見積もり.
(defun length$ (lst)
(length-loop lst 0))
(defun length-loop (lst count)
(if (null lst) count
(length-loop (cdr lst) (+ count 1))))
- 2行目の
length-loop
の呼び出し,4行目のnull
判断,最終行のlength-loop
に渡す引数準備計算は全てリストの長さに依存しないので一定時間と考え,計算時間は合わせて1とカウントする. - リスト
lst
のcdr
部分を渡して再帰処理しているlength-loop
は引数の長さに依存して処理が決まる.リストの長さn
に依存して決まるlength-loop
の計算時間を\(T(n)\)で表すと,一定時間の処理と合わせて,\(T(n) = 1 + T(n-1)\)で表せる.\(T(0)\)はnull
テストだけで処理時間は1であることを考慮すると以下を得る.
length$
の一定時間処理と合わせても定数処理時間は1とカウントするので,length$
の処理時間は\(1+n\)である.
定数時間はいくら合わせても1とする理由は,データ数(リスト長)が増加して行った時,どれくらいのオーダーで処理時間が増えるかに興味があるからだ.結局,length$
関数はリスト長n
に比例して計算時間が増加していくことを示している.
append
の処理時間
append
の処理時間を考えるためにappend$
を実装してみる.
(defun append$ (lst1 lst2)
(if (null lst1) lst2
(cons (car lst1) (append$ (cdr lst1) lst2))))
lst1
の長さn
だけappend$
が再帰的に呼び出されるのでappend$
の処理時間はn
の関数になる.これを\(T(n)\)とおくと,2行目のif
のテストと3行目の(cons (car lst1)
部分は一定時間なので1,再帰呼び出しは\(T(n-1)\)となるので,\(T(n)\)は以下を満たす.
\[ \begin{align} T(n) = 1 + T(n-1) \end{align} \]
上式右辺に再帰的に\(T(0)\)まで代入し,\(T(0) = 1\)を用いて解くか,以下のように解けば,\(T(n) = 1 + n\)を得る.
効率的なreverse
の実装.
リストの要素を逆順に並び替えるには,先頭要素を取り出して,残りの要素を再帰的にreverse
で並び替えて,その後ろに最初に取り出した先頭要素を加えれば良い.この時,append
で両者をつなげるか,cons
で繋げるかで実行時間が変わってくる.
先ほど計算したようにappend
の計算時間は要素の長さn
に比例するが,cons
なら一定時間で処理できる.
まず,悪い例を挙げて,実際に計算時間を求めてみる.
(defun reverse1 (lst)
(if (null lst) nil
(append (reverse1 (cdr lst)) (list (car lst)))))
append
は第1引数の長さに比例するのでn-1
の時間がかかる.また,append
に渡す第1引数の準備でreverse1
自身がn-1
の長さで呼び出される.他の処理は全て一定時間なので1とする.以上を合わせると,reverse1
の処理時間\(T(n)\)は以下を満たす.
従ってreverse1
を用いた実装では\(n^2\)に比例した時間がかかる.
次は良い例. append
ではなく一定時間でリスト操作するcons
を使う.
(defun reverse2 (let)
(reverse-loop lst nil))
(defun reverse-loop (lst reversed)
(if (null lst) reversed
(reverse-loop (cdr lst)
(cons (car lst) reversed))))
この場合の処理時間は,\(T(n) = 1 + T(n-1)\)になるので,append
の処理時間の計算と同じで,\(T(n) = 1 + n\)となる.
計算効率に関する教訓
- リストの長さ
n
に比例する関数を入れ子にしない! append
よりcons
やlist
を使う!
eq
, eql
, equal
の違い
包含関係でいくと\(\text{eq}\subset\text{eql}\subset\text{equal}\)で,eq
で真を返す関係は他の2つでも真となり,eql
で真となる関係はequal
でも真となる.すなわち,左から「等しさ」基準が厳しい順に並んでいると考えてよい.
eq
—メモリ上で等しいオブジェクトならT
を返す.シンボルが同じかどうかをテストする時はこれを使う.Javaオブジェクトの==
比較と同じ.eql
—アトムの値が等しければT
を返す.数値の場合は型も同じでなければ等しいとはみなされない. Javaのequals()
と似ている.equal
—リストの見かけが同じならばT
を返す.
シンボルが同じなら同じメモリ上のアドレスを指すので,当然シンボルの値も等しいし,見かけも等しいので,eq
でT
ならeql
でもequal
でもT
である.
ちなみに=
は数値が等しいかどうかを判定するときに使う.==
じゃない点に注意!
述語関数
`真偽を返す述語関数が幾つか紹介された.
atom
consp
listp
symbolp
numberp
integerp
null
連想配列
「car
がキーでcdr
が値とみなしたリスト」から成るリストを連想配列(association list, alist)と呼ぶ.
(assoc キー alist)
で連想配列alis
の先頭(左)から探して最初に見つかるキーの要素を返す.
sublis
関数
(sublis alist lst)
でlst
内のアトムを連想配列内の値と置き換える.
CL-USER> (sublis '((first-name . 太朗)
(last-name . 鈴木)
(telno . 03-432-323))
'((last-name first-name) tel telno))
((鈴木 太朗) TEL |03-432-323|)