ズンドコキヨシ問題をやってみる

沖縄チームのキリです。FizzBuzz問題というものをご存知でしょうか?1から順にカウントを増やしていき、3の倍数のときにはFizz、5の倍数のときにはBuzz、3と5の倍数のときにはFizzBuzz、それ以外のときには数字をそのまま出力する、という問題で、私の調べた限りだと2007年にImran Ghoryという方が投稿した以下の記事が発端となったものです。

これを受けて以下の記事が投稿されます。

http://weblog.raganwald.com/2007/01/dont-overthink-fizzbuzz.html

これらの記事の内容を意訳すると

プログラマー募集すると立派な履歴書を持って達者に語れる一見優秀そうな人たちが集まるが、その実力はコードを書かせてみるまでわからない。そして、コードを書くことに苦労している人たちは大きい問題や実装方法に悩んでいるのではない。(ループ、分岐、出力のような)もっと些細な問題で詰まっているのだ。

そういう人たちを見分ける手段としてFizz-Buzz問題を作成した。優秀なプログラマーなら2~3分もあれば解けるはずだが、実際にやらせてみるとほとんどのプログラマーが10分かかっても解くことができない。

といった内容です。読んだプログラマーたちが「よしきた」と色んな言語で大喜利やコードゴルフ状態の大盛り上がりをしたそうな…

ズンドコキヨシ問題

さて本題ですが、以前Twitterで『学校の課題で自作関数作れってのがあったから無限に「ズン」と「ドコ」をランダムで出力してその並びが「ズン、ズン、ズン、ズン、ドコ」になったら「キ・ヨ・シ!」と出力して終了する関数作ったら満点もらった』という内容のツイートがバズり、一部界隈でズンドコキヨシ問題やズンドコチェックと呼ばれ色んなコードが投稿される祭り状態になりました。これらの実装はqiitaのズンドコキヨシまとめという記事にまとめられています。

https://qiita.com/shunsugai@github/items/971a15461de29563bf90

ちょっとしたコード練習、脳トレ代わりにFizzBuzzは聞いたことあるよ~って方にも今使っている言語やこれから学びたい言語で、是非挑戦してみてください。(まとめるまでもないですが一応)仕様をまとめると以下の通りです。

  • 「ズン」「ドコ」のどちらかをランダムに出力する
  • 「ズンズンズンズンドコ」の並びが現れるまで無限に繰り返す
  • 「キ・ヨ・シ!」を出力して実行を終了する

せっかくなので私がPHPで実装したコードを紹介します。

ベース

$zun = 0;

while (1) {
    $val = rand(0, 1);
    if ($val) {
        echo "ドコ";
        if ($zun >= 4) {
            break;
        }
        $zun = 0;
        continue;
    }
    echo "ズン";
    $zun++;
}

echo "キヨシ!\n";

while文と「ズン」のカウントで実装したパターンです。ズンが出る度にカウントが増え、ドコが出たときにカウントが4以上なら終了条件、4未満ならカウントをリセットしています。ズンとドコの判定は小賢しいですがboolに変換されたとき0ならfalse、1ならtrueとなることを利用して比較を省略しています。(実務では分かりにくいので$val === 1と比較しましょう)

再帰関数とpreg_match

function zundoko($str = ""): string
{
    if (preg_match('/(ズン){4,}ドコ$/u', $str) === 1) {
        return $str . "キヨシ!\n";
    }
    $str .= mt_rand(0, 1) ? "ドコ" : "ズン";
    return zundoko($str);
}

echo zundoko();

ループ部分を再帰処理にしてpreg_matchで並びを見つけるパターンです。同じ文字列の繰り返しや文末のマッチングは正規表現で簡単にできます。ズンとドコの判定も三項演算子でもっとシンプルになっています。ちなみにPHP7.1.0からrand()がmt_rand()と同じ乱数生成器を使うようになっているのでrand()の方が遅い!というような状態は解消されています。行末マッチングはパフォーマンス的には文字列が長くなるとあまり良い実装ではないかと思います。実際に計測はしていませんが文字列の後ろ8文字をmb_substr等の関数で切り出したほうが速い気がします。

ジェネレーター

function zundoko()
{
    $zun = 0;
    while (1) {
        $n = rand(0, 1);
        yield $n ? "ドコ" : "ズン";
        if ($zun >= 4 && $n) {
            break;
        }
        $zun = $n ? 0 : $zun + 1;
    }
    yield "キヨシ!\n";
}

foreach (zundoko() as $v) {
    echo $v;
}

ジェネレーターの使い所がわからなくて実務で使うことがないのでここで使いました。ベースの処理とほぼ同じですがカウンターのリセット、加算も三項演算子を使ってコンパクトに書いています。ズンとドコをランダムにyieldしつつ連続したズンをカウントし、カウンターが4以上かつドコのときにループを抜けキヨシをyieldします。ジェネレーターのメリットは文字の連結とは違いメモリに保持し続けないので終了条件にヒットする確率が低くてもメモリ不足になりにくいです。でもやっぱり普段の開発では使い所がわからないです…

コンパクト

$str = "";
while (preg_match("/(ズン){4,}ドコ$/u", $str) !== 1) {
    $str .= rand(0, 1) ? "ドコ" : "ズン";
}

echo "{$str}キヨシ!\n";

なるべくコンパクトかつ読みやすいコードを意識したパターンです。文字連結とpreg_matchの行末マッチなのでパフォーマンス的にはよろしくないですが実務的かな?という気はします。ちなみにpreg_matchを使う時はマッチしなかった場合は0、エラーの場合falseを返す仕様のため、厳密な比較を使うべきだそうです。

おわりに

実行結果は同じでも色々なアプローチ方法を覚えておくとその場でベターなやり方を見つけたりリファクタリングの際に役立つと思います。このような単純な問題を色んな手段で実装するトレーニングをすることで楽しく基礎力を身につけられる気がするので、他にも面白そうな問題があったら挑戦してみたいです。ちなみに「ドドスコスコスコ」を3回繰り返したのちに「ラブ注入」と出力して終了するドドスコ問題という発展形もあり、内容的には変わりませんが終了条件にヒットする確率が非常に低くなるのでメモリ不足になることもあります。メモリを意識してこちらに挑戦してみるのも勉強になっていいかもしれません。

おまけ

冒頭のFizzBuzz問題をやってみたコードもおいておきます

for ($i = 1; $i <= 100; $i++) {
    $fizz = $i % 3 === 0;
    $buzz = $i % 5 === 0;
    if ($fizz && $buzz) {
        $str = 'FizzBuzz';
    } elseif ($fizz) {
        $str = 'Fizz';
    } elseif ($buzz) {
        $str = 'Buzz';
    } else {
        $str = $i;
    }
    echo "{$str}\n";
}