- Common Lisp で AtCoder ABC086C を解いていたところ、時間制限を超えてしまいました。
- 原因は
nthによるリストのインデックスアクセスで、毎回先頭からたどるため全体の計算量が O(N²) になっていました。 loop for rest onを使ってポインタを順に送る方式に変えることで、計算量を O(N) に改善できました。
1. 問題のおさらい
AtCoder Beginners Selection の ABC086C(Traveling)を Common Lisp で解いていたら、時間制限を超えてしまい、なかなかクリアできませんでした。
ABC086C(Traveling)は、2次元平面上の移動が実行可能かを判定する問題です1。
時刻 0 に (0,0) を出発し、N 個のチェックポイント (t_i, x_i, y_i) を順番に通過できるかを確かめます。
1.1. 最初のコード
隣接するチェックポイント間で移動できる条件は、そんなに複雑ではありません。
- マンハッタン距離
distが時間差dt以下であること、 - そして
dt - distが偶数であること。
余った時間を往復運動で使いつぶすためです2。
(defun distance (pos1 pos2)
(let ((x1 (first pos1))
(y1 (second pos1))
(x2 (first pos2))
(y2 (second pos2)))
(+ (abs (- x1 x2)) (abs (- y1 y2)))))
(defun movable-p (pos1 pos2 dt)
(and (>= dt (distance pos1 pos2))
(zerop (mod (- dt (distance pos1 pos2)) 2))))
(let* ((n (read))
(time-points (append
'((0 0 0))
(loop for i from 1 to n
collect (list (read) (read) (read))))))
(format t (if
(loop for i from 1 to n
always (movable-p
(cdr (nth (- i 1) time-points))
(cdr (nth i time-points))
(- (car (nth i time-points))
(car (nth (- i 1) time-points)))))
"Yes"
"No")))Code language: Lisp (lisp)

しかし、このコードには、2か所の問題点がありました。
- メインループが
nthでインデックスアクセスしていることです。 movable-pの中でdistanceを2回呼んでいることと、
2. nthは常に先頭からたどる
Common Lisp のリストはコンスセルを連結した片方向連結リストなので3、nth は先頭から順にたどるので時間がかかります4。
つまり、(nth i list) の計算量は O(i) で、ループ全体では O(N²) になります。
N が最大 10^5 なので、最悪 10^10 ステップに近い操作が行われ、時間がかかってしまいます。
加えて、(nth (- i 1) ...) と (nth i ...) はどちらも先頭から探し直しているのも非効率でした。
直前のステップで参照した要素を、毎回ゼロから追いかけ直しているわけですから。
2.1. loop for rest on list
そこで、loopでリストを順繰りに進んでいくようにしました。
loop for rest on list はリストの各コンスセルを rest に束縛しながら進みます5。
たとえば、(loop for x on '(a b c d)) を使うと、次の順で x を束縛します。
(a b c d)
(b c d)
(c d)
(d)Code language: Lisp (lisp)
毎回先頭からたどり直すのではなく、前回の続きのポインタをそのまま使うので、全体の計算量が O(N) になります。
(loop for rest on time-points
while (cdr rest)
always (let ((prev (car rest))
(now (cadr rest)))
(movable-p
(cdr prev)
(cdr now)
(- (car now) (car prev)))))Code language: Lisp (lisp)
while (cdr rest) を入れることで、末尾要素の単独処理を防いでいます6。
隣接する2要素は (car rest) と (cadr rest) で取りました。
3. distance の二重計算
もう一つの問題は、不要な再計算です。
movable-p では distance を2回呼んでいました。and の短絡評価があるので最初の条件を通過しなければ2回目は呼ばれませんが7、条件を通過したときには同じ計算をもう一度やっています。
そこで、dist を let で一度だけ計算して束縛します8。
(defun movable-p (pos1 pos2 dt)
(let ((dist (distance pos1 pos2)))
(and (>= dt dist)
(zerop (mod (- dt dist) 2)))))Code language: Lisp (lisp)
小さな計算なのでたいしたことないかな、と思ったのですが、扱うデータサイズが大きくなると、やはり無視できないみたいです。
4. 修正のまとめ
(defun distance (pos1 pos2)
(let ((x1 (first pos1))
(y1 (second pos1))
(x2 (first pos2))
(y2 (second pos2)))
(+ (abs (- x1 x2)) (abs (- y1 y2))) ))
(defun movable-p (pos1 pos2 dt)
(let ((dist (distance pos1 pos2)))
(and (>= dt dist)
(zerop (mod (- dt dist) 2)))))
(let* ((n (read))
(time-points (append
'((0 0 0))
(loop for i from 1 to n
collect (list (read) (read) (read))))))
(format t (if
(loop for rest on time-points
while (cdr rest)
always (let ((prev (car rest))
(now (cadr rest)))
(movable-p
(cdr prev)
(cdr now)
(- (car now) (car prev)))))
"Yes"
"No")))

| 問題点 | 原因 | 修正 |
|---|---|---|
| O(N²) のループ | nth による毎回の線形探索 | loop for rest on でポインタを送る |
distance の二重計算 | and の条件式で2回呼び出し | let で結果を束縛してから再利用 |
連結リストをインデックスで扱うと、配列と同じ感覚で書いても計算量が跳ね上がります。loop for i from 0 に慣れていると見落としやすいポイントで、リストをたどるときは on を使って構造に沿って動かすのが Common Lisp らしい書き方です。
- この問題は AtCoder Regular Contest 089 の A 問題として出題されたものが、AtCoder Beginners Selection にも収録されています。 – AtCoder Beginners Selection
- マンハッタン距離とは、2点間の水平・垂直方向の移動量の和で定義される距離です。座標 (x1, y1) と (x2, y2) のマンハッタン距離は |x1-x2| + |y1-y2| になります。直線距離(ユークリッド距離)とは異なり、格子状の移動しかできない場合に適した距離の測り方です。 – Manhattan distance – NIST
- コンスセルは car と cdr の2要素を持つメモリオブジェクトです。リストは複数のコンスセルを cdr でつないだ構造になっています。car が現在の要素を指し、cdr が次のコンスセルを指します。最後のセルの cdr は nil になります。 – cons – Wikipedia
- Common Lisp HyperSpec によると、
nthは(car (nthcdr n list))と定義されており、n 番目の要素に到達するまで先頭から cdr を n 回たどります。配列のようなランダムアクセスは行われません。 – CLHS: Accessor NTH for var on list構文は、varをリストの連続するサブリスト(tail)に順番に束縛します。for var in listがリストの各要素を束縛するのに対し、onはコンスセルごとにポインタを進めます。 – Common Lisp the Language 2nd ed., §26.6 Iteration Control(cdr rest)がnilになるのはリストの末尾セルに達したときです。while (cdr rest)を省くと、末尾の要素だけがrestに残った状態でもalways節が実行され、存在しない(cadr rest)がnilになるためロジックが崩れます。- Common Lisp の
andは短絡評価を行い、左から順に評価して最初にnilを返したフォームの時点で評価を停止します。これは Ada のand thenや Pascal のcandに相当する動作です。 – Common Lisp the Language 2nd ed., §6.4 letは新しいレキシカルスコープを作り、指定した変数を並列に束縛します。let*は順番に束縛する点が異なり、後の束縛で前の変数を参照できます。どちらもローカル変数を導入する Common Lisp の基本フォームです。 – Common Lisp HyperSpec – Special Operator LET, LET*