カメとウサギで「輪」を見つける。フロイドの循環検出(追加メモリO(1))
自分で書いたプログラムが固まって、いつまで待っても返ってこない——その原因が無限ループだった、という経験はないでしょうか。やっかいなのは、次へ、次へ とデータをたどっていく類の処理が、輪にハマったときです。 たとえば、連結リストをたどるコードを書いたとします。本来は末尾(null)で終わるはずが、データが壊れて途中のノードが前のノードを指していたら、たどる処理は終点にたどり着けず、同じ環をぐるぐる回り続けます。連結リストに限らず、「ある値から次の値を計算して進む」「状態 A から次の状態へ遷移する」——こうした「次をたどる」ロジックを自分で書くとき、どこかで輪に入り込んでいないかは、コードを眺めただけでは分かりません。 この記事で扱うのは、そういう自分のコードに「隠れた輪」が無いかを、たどりながら確かめる方法です。具体的には、連結リストが壊れて循環していないかのチェック、配列の重複探し(配列を「次をたどる写像」とみなします)、あるいは自分の反復ロジックがループに落ちないかをテストで確かめたいとき——どれも同じ道具ひとつで済みます。 素朴にやるなら、「訪れた場所を全部メモ(集合)しておいて、すでに来たことのある場所に戻ったら輪あり」とすればいい。正しく動きますが、訪れた数だけメモリ($O(n)$)が要ります。 この記事の主役は、メモを一切持たず——変数2つ、追加メモリ $O(1)$——で輪を見つける、フロイドの循環検出(カメとウサギ)です。最後に、ブラウザだけで動くビジュアライザで実際に動かし、前回の玉突き方式 の無限ループにも当てはめてみます。 まず、輪のある列は「ρ(ロー)の形」になる 「現在の場所 → 次の場所」が毎回決まっている列をたどると、形は必ずρ(ギリシャ文字のロー)のようになります。輪に入るまでの一本道(しっぽ)と、ぐるぐる回る輪です。 輪の最後のノードの「次」は、輪の入口に戻ります。だから一度入ると二度と出られず、回り続ける。やりたいのは「輪があるか」、できれば「輪の入口はどこか」を知ることです。 カメとウサギ:ポインタ2つで追いつく フロイドの方法は、拍子抜けするほど単純です。同じスタートから、速さの違う2つのポインタを走らせます。 🐢 カメ:1歩ずつ進む 🐇 ウサギ:2歩ずつ進む 第1幕。 輪が無ければ、ウサギが先に終点(行き止まり)へ着きます。輪があれば、ウサギは輪の中をぐるぐる回り、いつか必ずカメに追いつきます。なぜなら、2匹が輪に入ったあとは、ウサギはカメに毎ステップ1つずつ近づくから。差が1ずつ縮むので、飛び越してすれ違うことはなく、必ずピッタリ一致します(差が1ずつしか減らない以上、輪の長さが偶数でも奇数でも必ず 0 を通ります)。一致したら、輪がある証拠です。 第2幕。 ここがエレガントなところ。出会った地点にカメを残し、もう1つのポインタを起点(スタート)に戻して、今度は両方とも1歩ずつ進めます。すると、ちょうど輪の入口で再会します。これで輪の始まりまで分かります。 function detectCycle(start, next){ let slow = start, fast = start; // 第1幕:出会うまで(出会わずに終点へ着けば輪は無い) do { slow = next(slow); // 1歩 fast = next(fast); if(fast === null) return null; // 2歩(1歩ごとに終点を確認) fast = next(fast); if(fast === null) return null; } while(slow !== fast); // 第2幕:起点と出会った地点から1歩ずつ → 入口で再会 let p = start; while(p !== slow){ p = next(p); slow = next(slow); } return p; // 輪の入口 } 使っているのは slow と fast(と p)という変数だけ。列がどれだけ長くても、メモリは増えません。これが $O(1)$ の意味です。 なぜ第2幕で入口に揃うのか 一本道の長さを $\mu$、輪の長さを $\lambda$ とします。第1幕でカメが進んだ距離を $d$ とすると、ウサギはその倍の $2d$ 進んで同じ場所にいるので、その差 $d$ は輪の長さ $\lambda$ の倍数です。つまりカメは「$\lambda$ の倍数ぶん」だけ余計に回った場所にいる。ここから、起点と出会い地点をそれぞれ1歩ずつ進めると、ちょうど $\mu$ 歩で両者が入口に重なります。証明は1行で、紙とペンでも追えます。 ...
