このページではCodeIQ*1で結城浩先生から出題されたマヨイドーロ問題について解説しています。

マヨイドーロ

幾つか手で動かしてみる

  • 問題ではNは「N回まで反転して良い」ということになっている
  • 簡単のために「N回ちょうど」反転する方法がいくつあるか、数えてみる

N=0のとき

  • この時は反転できないので、そのままZから出て行く方法しか無い
  • 1通りのみ
  • X→B→C→Z

N=1のとき

  • Bで反転する経路と、Cで反転する経路の2通りある
  • X→B→A→Y
  • X→B→C→B→A→Y

N=2のとき

  • 以下の3通り
  • X→B→A→B→C→Z
  • X→B→C→B→C→Z
  • X→B→C→B→A→B→C→Z

N=3のとき

  • 以下の5通り
  • X→B→A→B→A→Y
  • X→B→C→B→A→B→A→Y
  • X→B→A→B→C→B→A→Y
  • X→B→C→B→C→B→A→Y
  • X→B→C→B→A→B→C→B→A→Y

まとめてみる

  • 当然、奇数回反転するとZから、偶数回反転するとYから出る
  • そこで、pをZからN回ちょうど反転して出る経路の数、qをYからN回ちょうど反転して出る経路の数といて表にしてみる
    Npq
    010
    102
    230
    305
  • 詳細は結城先生の解説に書かれているので割愛するが、
  • F(N)=F(N-2)+F(N-1)という形で表すことができ、これはフィボナッチ数列を表す
  • ただし、フィボナッチ数列はF_{3}=2なのに対し、今回の反転はN=1の時に経路数が2である
  • なので、N回ちょうど反転するときの経路数はF_{N+2}で表される
  • さて、あとは、フィボナッチ数列を求める実装だけである

単純な実装

  • 教科書に出てきそうな、再帰の参考例
    1. unsigned long long simpleFibonacciNumber(int n)
    2. {
    3. unsigned long long retValue = 0;
    4. switch(n)
    5. {
    6. case 0:
    7. retValue = 0;
    8. break;
    9. case 1:
    10. case 2:
    11. retValue = 1;
    12. break;
    13. default:
    14. retValue = simpleFibonacciNumber(n-1)+simpleFibonacciNumber(n-2);
    15. break;
    16. }
    17. return retValue;
    18. }
  • このsimpleFibonacciNumber関数は、nを渡すと、n番目のフィボナッチ数を返してくれる
  • 問題では、n<Nとなるような、偶数のnの、フィボナッチ数を足し合わせれば良い

単純な実装の限界

  • しかし、この実装方法だと、計算時間が指数時間で増加し、爆発する
  • 後述するが、フィボナッチ数は指数関数的に増え、そのフィボナッチ数を計算するための時間が単純に足しあわされていくので、計算時間が到底間に合わなくなる
  • 今回の自動採点は、1秒以内に計算が終了しないと、不正解扱いになってしまうため、この実装方法では間に合わない

一般項を用いた実装

  • 行列のべき乗とフィボナッチ数列でも説明したが、フィボナッチ数は以下の一般項で表されることが知られている
  • p_n=\frac{1}{\sqrt{5}}\left\{\left(\frac{1+\sqrt{5}}{2}\right)^n-\left(\frac{1-\sqrt{5}}{2}\right)^n\right\}
  • で、前述の単純な再帰の実装では計算時間がフィボナッチ数同様に足し算の形で増えていく
  • なので、フィボナッチ数と比例して増えることになり、計算時間はO(c^N)で増える
  • ここで一般項を用いることで再帰しない実装方法を試す
  • 以下が実装例
  • \sqrt{5}\frac{1}{\sqrt{5}}などの定数は事前に計算して固定値で記入しておいた。
  1. #include <cmath>
  2. const double positivleHalfRoot5 = 1.618033988749894848204586834365638117720309179805762862135448;
  3. const double negativeHalfRoot5 = -0.61803398874989484820458683436563811772030917980576286213544;
  4. const double inverseRoot5 = 0.447213595499957939281834733746255247088123671922305144854179;
  5.  
  6. unsigned long long powerComputationFibonacciNumber(int n)
  7. {
  8. double retValue;
  9. retValue  = pow(positivleHalfRoot5, n);
  10. retValue -= pow(negativeHalfRoot5, n);
  11. retValue *= inverseRoot5;
  12. return (unsigned long long)(retValue + 0.5f);
  13. }
  • なんとも単純
  • これだと計算時間はべき乗の分だけですむ
  • return するときに0.5f足し算しているのは、四捨五入するための計算
  • これにより、丸め誤差を四捨五入して整数に変換する

一般項を用いた実装の限界

  • 一般項を用いた実装の場合、ネックはdoubleの分解能にある
  • Wikipedia*4を見るとわかるが、一般的な(IEEE754で定義された)double 型の仮数部は53bit分の分解能を持つ
  • 53\log_{10}(2)\approx 15.9545897701であり、10進数でだいたい16桁ぐらいから丸め誤差が整数として現れ始める
n73747576777879
べき乗で求めた結果8065155330493931304969544928657211148507797805034164546229067065527939700884756894439432379146414472334024676218
足し算で求めた結果8065155330493931304969544928657211148507797805034164546229067075527939700884757894439432379146414472334024676221
桁数(10進数)15桁16桁16桁16桁16桁16桁17桁
ここまで計算結果OK丸め誤差が整数として現れる桁が上がる度に
丸め誤差がどんどん大きくなる
  • C++の規約にはないものの、コンパイラ次第では128bit/80bit長の浮動小数点型がある
    • Visual Studio 2012。無し。long doubleも8バイト長
    • gcc。long doubleは12バイト長。でも実体は80bit。指数部15bit、仮数部64bit。
  • 80bitのfloatを使ったとしても仮数部は64bitしかなく、64\log_{10}(2)\approx 19.2659197225であり、10進数で20桁ぐらいから丸め誤差が整数として現れはじめる
  • どうやっても浮動小数点ではテストケースをパスできない

計算速度と精度を考慮した実装

  • 計算速度で言えば、べき乗を利用した実装は十分だったが、問題は精度
  • そこで、再度整数型に戻り、整数型でフィボナッチ数を計算してみる
  • 単純な足し算をする実装も、方法を変えればそこそこのスピードで計算できる
  • 既に計算されたフィボナッチ数を再帰で再度計算していたのが処理時間が遅い原因
  • そこで1度計算されたフィボナッチ数を配列に保存するように実装してみる
  • 以下実装
    1. #include <vector>
    2. std::vector<unsigned long long> fibonacci;
    3. unsigned long long simpleFibonacciNumber2(int i)
    4. {
    5. if(fibonacci.empty())
    6. {
    7. fibonacci.push_back(0);
    8. }
    9. if(fibonacci.size() == 1)
    10. {
    11. fibonacci.push_back(1);
    12. }
    13. if(fibonacci.size() == 2)
    14. {
    15. fibonacci.push_back(1);
    16. }
    17. if(i <= 0)
    18. {
    19. return 0;
    20. }
    21. if((unsigned int)i < fibonacci.size())
    22. {
    23. return fibonacci[i];
    24. }
    25. for(unsigned int j = fibonacci.size()-1;j < (unsigned int)i;j++)
    26. {
    27. unsigned long long p = fibonacci[fibonacci.size()-1];
    28. unsigned long long q = fibonacci[fibonacci.size()-2];
    29. fibonacci.push_back(p+q);
    30. }
    31. return fibonacci[i];
    32. }
  • この実装方法だと、計算した分をどんどん足しあわせて配列を長くしていく
  • 29行目の push_back で配列の後方にどんどんフィボナッチ数を追加していく
  • 単純な再帰実装だと計算時間はO(c^N)だが、これだとO(N)で済む。
  • 計算時間的にはこれでも一瞬で終わる
  • が、再度オーバーフローの問題が現れる。

計算速度と精度を考慮した実装での限界

  • 整数型に戻しても、64bitの壁が存在する
N   フィボナッチ数       フィボナッチ数の16進数表現
89  1779979416004714189  0x18B3C1D91E77DECD
90  2880067194370816120  0x27F80DDAA1BA7878
91  4660046610375530309  0x40ABCFB3C0325745
92  7540113804746346429  0x68A3DD8E61ECCFBD
93  12200160415121876738 0xA94FAD42221F2702 ←ここでMSBが埋まる(=64bitすべて使って表現が必要になる)
94  1293530146158671551  0x111F38AD0840BF6BF ←オーバーフローが発生
  • テストケースでは最大N=2015の結果を求められる
  • というわけで、残念ながら、どうしても多倍長整数の実装が必要になる
  • 今回はC++で挑戦したが、他の言語だと扱える最大整数はどうなのだろう?

多倍長整数の実装の前に

  • 実装しかけて、前回のCodeIQ、サルベジオン問題で四則演算のごく一部だけ実装したlargeNumberクラスを思い出す
  • 今回はフィボナッチ数でどうせ足し算しか使わないので、流用することに。
  • 実装コードは長いのでこちらで公開

感想

  • 面白かった!
  • 今回は、
    • フィボナッチ数の一般項
    • 多倍長整数型の実装
  • と、過去の自分のWikiを再度活躍させる場面が2箇所もあり、CodeIQをやってて良かったなぁ、と思った。
  • 結城先生の模範解答ではRubyを使ってらして、Rubyでは自動的に多倍長整数が実装されているので、もしかしたら多倍長整数の実装は想定してなかった?
  • 以前のサルベジオン問題でもその当たりは解説で触れられてないので、もしかしたら想定されてなかったのかも。
  • 自前実装はいいアイディアでは無かったかも知れないけれど(車輪の再発明的に)、前回の自分の実装を再利用できたのは良かった。
  • とうとう結城先生の問題でも自動採点のテストケース、つまり計算速度/計算量の制約が出てきて、エンジニアとして腕の見せ所が出てきたのは嬉しい。

*1  CodeIQ|ITエンジニアのための実務スキル評価サービス, 2015-12-06閲覧
*2  結城浩の「マヨイドーロ問題」 | CodeIQ, 2015-12-03公開, 2015-12-06閲覧
*3  CodeIQの問題に挑戦しよう!, 2015-12-06閲覧
*4  倍精度浮動小数点数 - Wikipedia, 2015-08-24 08:10版, 2015-12-06閲覧

トップ   編集 凍結 差分 バックアップ 添付 複製 名前変更 リロード   新規 一覧 単語検索 最終更新   ヘルプ   最終更新のRSS
Last-modified: 2015-12-17 (木) 12:33:38 (1373d)