今となっては少々昔のことですが,AtCoder Beginner Contest 167のD問題で数列の循環検出が必要になった事がありました(問題の制約的にはダブリングでも解けますが).
数列の循環検出はナイーブにに実装すると,循環している部分に至るまでの長さを,循環の長さをとするとのメモリが必要になってしまいますが,これをで計算するアルゴリズムとして,フロイドの循環検出法が知られています. 中々巧妙なアルゴリズムなもので,これを実装して本当に循環検出ができているのか少々不安に思うところもありますし,この際Coqで正当性を保証してやりましょう.
フロイドの循環検出法
フロイドの循環検出法とは,で与えられる数列の循環を空間計算量で検出し,循環している部分に至るまでの長さと循環の長さを求めるアルゴリズムです.
正直Wikipediaより分かりやすく解説できる気がしませんが*1,具体的な手順を挙げると
- を覚えておくための変数とを覚えておくための変数を用意し,とを利用してとなるを線形探索
- を覚えておくための変数とを覚えておくための変数を用意し,とを利用してとなる最小のを線形探索
- を覚えておくための変数を用意し,を利用してとなる最小のを線形探索
となります.1. で速く動く変数と遅く動く変数を使うことから,「ウサギとカメのアルゴリズム」とも呼ばれているらしいです.
このアルゴリズムを循環検出アルゴリズムたらしめる重要な事実は,あらゆる循環の長さらしきもの——となる——は実際の循環の長さの整数倍となっていることです.それ故により,となるを線形探索することで循環している部分に至るまでの長さを求められ,それを足がかりとして循環の長さも求められるのです. となるを線形探索しているのは,単にとなるの一例を得たいからに過ぎませんが,循環している数列に対しては必ずとなるが存在することが保証されます.*2
Coqによる検証
このアルゴリズムはCoq上で検証する以前に,実装するだけでも大変です.例えばとなるを探索する処理を単純に実装すると
Fixpoint tortoise_and_hare m a_m a_2m := if a_m == a_2m then (m, a_m) else tortoise_and_hare (S m) (f a_m) (f (f a_2m)).
のような再帰関数を書くことになると思いますが,停止性が明らかではないとCoqに突っぱねられてしまいます. Coqの言い分ももっともで,となるが存在しなければこの関数は停止しませんから,そのようなが存在することを加味した型の関数にしなければなりませんね.
こういう停止性の怪しい再帰関数を3回も書かなければならないので,もう少し一般化して使い回しが効くような関数を定義してしまいましょう.
(* P(n)を満たす最小の自然数nを線形探索する *) Lemma nat_eps (P : nat -> Prop) (Hdec : forall n, { P n } + { ~ P n }) (Hex : exists n, P n) : { n | P n & forall n', P n' -> n <= n' }. Proof. refine (@Fix _ (fun n m => S m = n /\ (forall m, m < n -> ~ P m)) _ (fun n => (forall m, m < n -> ~ P m) -> { n | P n & forall n', P n' -> n <= n' }) (fun n eps HnP => match Hdec n with | left _ => exist2 _ _ n _ (fun n' => _) | right _ => let Hlb := fun m => _ in eps (S n) _ Hlb end) 0 _); eauto. Unshelve. - case Hex => n Hp m. remember (n - m) as d. generalize dependent m. induction d as [ | ? ] => m ?; constructor => ? [ ? Hnp ]; subst. + (have : (n < S m) by lia) => /Hnp. congruence. + apply /IHd. lia. - by case (le_lt_dec n n') => // Hlt /(HnP _ Hlt). - lia. - move => /le_S_n ?. case (Nat.eq_dec m n) => [ -> // | ? ]. apply /HnP. lia. Defined.
このnat_eps
なる関数は,ある自然数n
に対しては真になることが分かっていて,適当な自然数に対して真か偽か判別できるような述語P
を受け取って,P
が真になるような最小のn
を線形探索して返す関数です.肝心の処理は
Fixpoint nat_eps P n := if P n then n else nat_eps P (S n).
ぐらいの単純なことしかしていませんが,再帰の停止性をCoqにも分かりやすく伝えるために面倒な記述が増えています.
ではこのnat_eps
を使って,肝心のフロイドの循環検出アルゴリズムを実装していきましょう.
Section CycleDetection. Variable A : Set. Variable f : A -> A. Variable eq_dec : forall x y : A, { x = y } + { x <> y }. Definition eventually_periodic m0 l n : Prop := l > 0 /\ forall m, m0 <= m -> iter (l + m) f n = iter m f n. Theorem tortoise_and_hare v (Hperiod : exists m l, eventually_periodic m l v /\ forall m' l', eventually_periodic m' l' v -> m <= m' /\ l <= l') : {'(m, l) | eventually_periodic m l v /\ forall m' l', eventually_periodic m' l' v -> m <= m' /\ l <= l' }. Proof. refine (let (m, Hm, _) := (* ウサギとカメ *) nat_eps (fun m => @iter (S m + S m) _ f v = @iter (S m) _ f v) (fun m => eq_dec (@iter (S m + S m) _ f v) (@iter (S m) _ f v)) _ in let (mu, Hmu, Hmu') := (* ループの開始地点を見つける *) nat_eps (fun mu => @iter (S m + mu) _ f v = @iter mu _ f v) (fun mu => eq_dec (@iter (S m + mu) _ f v) (@iter mu _ f v)) _ in let (l, Hl, Hl') := (* ループの周期を測る *) nat_eps (fun l => @iter (S l + mu) _ f v = @iter mu _ f v) (fun l => eq_dec (@iter (S l + mu) _ f v) (@iter mu _ f v)) _ in exist _ (mu, S l) _);
eventually_periodic m l v
は,で与えられる数列が循環しており,循環に至るまでの長さがm
,循環の長さがl
であることを意味する3項関係です.ただし,我々が図で見てああ明らかに循環に至るまでの長さm
だな,循環の長さl
だなと思うものは,実際にはeventually_periodic m l v
となるような最小のm
,l
であることに注意が必要です.
フロイドの循環検出法は,何かしらの循環に至るまでの長さm
と循環の長さl
で循環している数列を受け取って,そのm
とl
を具体的に計算する関数tortoise_and_hare
として実装されています.型だけ見るとそのままm
とl
を返せば良いように見えますが,Coqの型システムではProp
型の型を持つ値をパターンマッチしてSet
型の型を持つ値は作れないので,何かしらのm
とl
で循環していることの情報は,単にアルゴリズムの停止性を保証することだけに使われます.
証明とプログラムを一緒に書くスタイルにしてしまったので,このまま正当性の証明も書く必要があります.
move: Hperiod => [ mu' [ l' [ [ Hpos Hperiod ] Hleast ] ] ]; eauto. { (* a_{m+1} = a_{2(m+1)}となるようなm>=0が存在することの証明 *) exists ((mu' + 2) * l' - 1). set j := (mu' + 2) * l' - 1. have->: S j + S j = (mu' + 2) * l' + S j by lia. elim: (mu' + 2) => [ // | ? ? /= ]. rewrite -plus_assoc Hperiod //. nia. } have : eventually_periodic mu (S l) v => [ | /Hleast [ ? Hlel ] ]. { split => [ | m0 ? ]; auto with arith. have->: m0 = (m0 - mu) + mu by lia. have->: S l + (m0 - mu + mu) = (m0 - mu) + (S l + mu) by lia. by rewrite !(@iter_plus _ _ _ _ (m0 - mu)) Hl. } have : eventually_periodic mu (S m) v => [ | /Hleast [ Hlemu Hlem ] ]. { split => [ | m0 ? ]; auto with arith. have->: m0 = (m0 - mu) + mu by lia. have->: S m + (m0 - mu + mu) = (m0 - mu) + (S m + mu) by lia. by rewrite !(@iter_plus _ _ _ _ (m0 - mu)) Hmu. } (* m+1が循環の長さl'で割り切れるかどうかで場合分け *) case (zerop (S m mod l')) => Hmod. - have : mu <= mu' => [ | /(le_antisym _ _ Hlemu) ? ]; subst. { apply /Hmu'. rewrite (Nat.div_mod (S m) l'). { lia. } rewrite Hmod -plus_n_O mult_comm. elim: (S m / l') => //= ? IH. by rewrite -plus_assoc iter_plus IH -iter_plus Hperiod. } have : S l <= l' => [ | /(le_antisym _ _ Hlel) <- // ]. { rewrite (S_pred_pos l') //. apply /le_n_S /Hl'. by rewrite -S_pred_pos // Hperiod. } - (* m+1が循環の長さl'で割り切れなかった場合,長さ(m+1)mod l'の循環が存在することになる これはl'が最も短い循環の長さであることに矛盾する *) have : forall i, i * l' <= S m -> iter (S m - i * l' + mu) f v = iter mu f v => [ | /(_ (S m / l')) ]. { elim => // i IH ?. rewrite -IH. { lia. } have->: S m - i * l' + mu = l' + (S m - S i * l' + mu) by lia. rewrite Hperiod //. lia. } rewrite (mult_comm _ l') -Nat.mod_eq => [ | Hperiod' ]. { lia. } have : eventually_periodic mu (S m mod l') v => [ | /Hleast [ ? /le_not_lt [ ] ] ]. { split => [ // | m0 ? ]. have->: m0 = (m0 - mu) + mu by lia. have->: S m mod l' + (m0 - mu + mu) = (m0 - mu) + (S m mod l' + mu) by lia. rewrite !(@iter_plus _ _ _ _ (m0 - mu)) Hperiod' //. apply /Nat.mul_div_le. lia. } apply /Nat.mod_upper_bound. lia. Defined. End CycleDetection.
線形探索が停止することを保証するためにとなるが少なくとも1つは存在することを証明している部分と,そのようなが循環の長さで割り切れることを証明する部分が少しややこしいですが,あとは前の節で書いたようなノリですね.
まとめ
フロイドの循環検出アルゴリズムをCoqで実装するとともに,その実装で循環に至るまでの長さと循環の長さを求められることも証明しました.今回Coqで書いたコードの全文はGistで見られます.
循環があればこのアルゴリズムで検出できることはTopProverの過去問で実は既に証明していたのですが,循環に至るまでの長さと循環の長さを具体的に求めることはできていませんでした.今回の記事ではとを計算する処理も含めて検証しています.
実は今回のフロイドの循環検出アルゴリズムの実装はやなどの値をメモ化しておらず,これをextractしようものなら甚だ非効率的なコードが出力されるのですが,実用に堪える実装とするのは今後の課題とします*3.