著者: 池上 大介 <ikegami at madscientist.jp>
ウバと言えば、スリランカ産紅茶のなかでも深いコクと独特の苦みが 知られる世界3大銘茶のひとつである。 しかし、パズル好きプログラマにとってのウバとは Problem Set Archiveを言う。
ウバのサイトを初めて訪れた方は、画面右側のリンクを読んでいただきたい。
ウバは、プログラムを書いて解くパズル/問題を集めたサイトである。 パズルは答えがわかってしまうと陳腐化してしまうが、ウバでは 多くのボランティアスタッフに支えられて、 プログラマを楽しませる新しい問題が投稿され続けている。
ウバの特徴は、問題の数や面白さ、新鮮さだけではなく、 プログラムを投稿して腕試しできることにある。 投稿されたプログラムはウバ側で実際に実行され(!)、 制限時間内に仕様通りの答えを返すかどうかを検査される。 そこで、単に答えを正確に出すだけでなく、スピードの早さや、 計算に必要なメモリーの少なさ、移植性も試されることになる。
しかし、残念なことに、投稿できるプログラムは C/C++/Pascal/Java で書かれたものに限られる。ウバに登録された優れた問題たちが この 4 つの言語ユーザにしか知られないのは大変残念なので 不祥、筆を取った次第である。
前章に書いたように、Ruby で書いたプログラムをウバで自動検査してもらう ことはできない。しかし、我々には
がある。そこで、(厳密ではないが)、 ウバの要求「正確に速く動け」に近いテストを我々の手もとでも出来るのだ。 問題を解くときは、紳士的に振る舞い、満足するまで追求することを誓おう。
(出題者: 匿名 (University of Valladolid))
ウバの記念すべき第 1 問目は 3n + 1 問題である。
この問題は Collatz 予想など、さまざまな別名で呼ばれ、古くから知られている難予想である。http://mathworld.wolfram.com/CollatzProblem.html に詳しい。
ウバで提示されている問題の説明に入る前に、この問題の背景についてざっと眺めよう。次のコードを見てほしい。
collatz_len の引数には正の整数を入れることにする。引数が正の整数であるかどうかの検査は 簡単であるが省略した。(n が Integer であるかどうか、および n > 0 かどうかを最初に確かめればよい) collatz_len の while ループの中で n は次のように変化する:
ループ中で n が 1 になったらループから脱出する。 たとえば、n = 6 のときは
となる。変数 len はこの列の長さを表しており (この場合は 9) 、collatz_len は len、つまり n に対するこの操作の列の長さを返す。
collatz_len がどんな n に対しても必ず停止するか? (つまり操作を繰り返していつか n = 1 になるか?) は今のところ誰にもわかっていない。
次の仕様を満たすプログラムを書け。 ただし、「n のサイクル長」は、上記説明で用いたメソッド collatz_len の返り値 len を 指すものとする。 (Hint: なぜサイクルと呼ぶんだろう?)
次の Unit Test に合格しなさい
ウバのオリジナルの問題では、入力のサイズは
となっている。
あなたの書いたプログラムはこれだけ大きな入力を与えても、満足の行く時間で答えが求まるだろうか。 求まらなかったとしたら、それは何が原因で、何を改良すればいいだろう?
読者の中には、
のようなプログラムを作れるのではないか、と想像したことが ある人もいるだろう。しかし、残念ながらこのようなプログラムを 書くことは難しいし、仮に書けたとしても、same?(f, g) が止まらないような (f, g) が存在することが Turing 機械の議論から導ける。 (興味のある人は調べてみよう。)
では、全ての f と g について same? を作るのではなく、
というプログラムなら作れるだろうか。そこで、 3 n + 1 問題について考えてみる。
collatz は while と if と簡単な四則演算のみから成り立っている。 そこで、
は成り立つだろうか?ということを考えてみよう。 しかし、3 n + 1 問題は(前に述べたように)昔から知られた数学上の難問であり、 same?(collatz, one) が true または false を返したということは、 この難問の証明を与えたことに他ならない。 仮にアルゴリズムが簡単であっても 二つのプログラムの同一性を調べるのは難しいのである。
もし、あなたが仕事でプログラムを書いていて、 上司から「簡単なプログラムのはずなのに、仕様を満たしているかどうかがどうしてわからないのかね?」 と怒られたときには、 「作ったプログラムが仕様を満たしているかどうかを確かめることは、 仕様が簡単かどうか、あるいは、プログラムが簡単であるかどうかとは無関係なんですよ」 と答えることができるだろう。
同じ様な発想で、プログラムが停止するかどうかの判定が難しいことも わかる。
collatz が全ての n に対して計算が止まるためには、 while ループを脱出しなければならない。つまり、いつかは n が 1 に ならなければならない。しかし、どんな入力 n に対しても collatz が止まるかどうかを調べることは、結局、難問 3n+1 問題の 証明を与えることに他ならない。
もし、あなたが仕事でプログラムを書いていて、 上司から「簡単なプログラムのはずなのに、どうして停止するかどうかがわからないのかね?」 と怒られたときには、 「作ったプログラムが停止するかどうかを確かめることは、プログラムが 簡単であるかどうかとは無関係なんですよ」 と答えることができるだろう。
停止性判定の難しさはTuring 機械を用いることにより精密に議論できる。 http://en.wikipedia.org/wiki/Halting_problem
(注意: この節を読むとパズルを解く際の試行錯誤の楽しみが損なわれるおそれがある)
ruby 1.8 では、再帰関数のパフォーマンスは再帰を用いない関数と比べると 悪いようだ。階乗を計算する 2 つの関数について、実験してみた結果は以下の通り:
こういう時に再帰関数の効率をあげるテクニックとして、 「末尾再帰(tail recursion)」という方法がある。 末尾再帰とは一言で言うと帰納段階に現れる return 文を工夫して 自分自身の呼び出しにすることである。
末尾再帰を用いると、用いないときよりも大きな数について計算できるが、 やはり再帰の回数には限界があり、SystemStackError になってしまう。
今回のパズル 3n+1 問題も、問題が再帰的なので 再帰的にプログラムを書く方が素直に書ける(と私は思う)のだが、 スタックが溢れるために n が大きくなると実行できなくなってしまう。
ruby に対する末尾再帰の最適化は ruby そのものをもてあそぶ人の 興味の対象になっており、今後のバージョンによっては速度の改善や SystemStackError が起きにくくなるような改善が施されるかもしれない。 (と期待する)
ちなみに python 2.3 で末尾再帰版 fact を計算したところ、 n = 900 までは計算でき、 n = 1000 でスタックが溢れた。
再帰関数を効率良く計算できる関数型言語では、 n = 10000 でも計算できる。 (オブジェクト指向言語とは言語仕様と実装が大きく異なるので、この比較は公平ではない。)
なお、テストに用いた環境は
であり、別の環境では結果が異なってくるだろう。
ウバに登録されている約 2000 問のプログラムパズルがあなたの 挑戦を待ち受けている。
筆者は、Tanaka 氏の日記 経由でウバの 面白さを知った。
Ruby プログラミング入門 (原信一郎 著) p. 202 「オブジェクト指向フィボナッチ数列!?」より、 フィボナッチ数列を計算する例の 3n+1 版。
これは Ruby のオブジェクト指向言語の特徴(特異メソッド)を使った面白いコードで、 同じ計算を繰り返さない工夫がされている。 しかし、再帰的なプログラムなので、大きな n に対して計算するとスタック が溢れるという問題がある。
この CollatzLen について少し補足しよう。CollatzLen の目的は、 計算した結果をできるだけ覚えておいて、再度計算することを防ぐというもの である。 たとえば、3n+1 問題で n = 4 について
の計算に着目すると、n=6 についての計算の途中で
4 が再び現れて、その列の続きは n = 4 の計算と同じことになる。 そこで、n を key、 n に対するサイクル長を value とするような Hash を作り、 key に対する value が得られたら計算を止めることにすれば、 同じ計算をしなくて済む分効率が上がるはずだ。(その代わりにメモリを必要とする)
CollatzLen は定数で Hash である。CollatzLen の key は 3n+1 問題の n で、 CollatzLen の value は key n に対するサイクル長を指すものとしている。 CollatzLen は最初は {1 => 1} なのだが、CollatzLen[n] を評価する際に CollatzLen[n] が nil のときに限り CollatzLen[n] を過去に行った計算 CollatzLen[m] を使うようにしている。
今回提出したパズルでプログラムの効率を上げ、かつ大きな数字に対しても cmaxlen を計算するためには、
について取り組む必要がある。