Cookpad Code Puzzle for RubyKaigi 2022の解説(表ステージ)

技術部の遠藤(@mametter)です。RubyKaigiお疲れ様でした!

クックパッドはRubyKaigiで、Rubyを使ったパズルを出してました。この記事では、出題者が想定していた解き方を公開します。自力で遊びたい人は解いた後で読んでください。

Cookpad Code Puzzle for RubyKaigi 2022

どんなパズル?

あらかじめ定義された謎の関数の中身を当てるパズルです。適当な引数で呼び出してみて、結果を観察して、中身を想像します。あたりがついたら、同じ関数を定義してみて、テストをパスしたらクリア。

次のURLでブラウザでプレイできます。もう賞品はもらえませんが、解きたい人は今からでも挑戦してみてください。

ruby-puzzles-2022.cookpad.tech

以下、ネタバレで各問題を解説していきます。

1問目

あらかじめヒントが書かれています。

# You can call `func1`

p func1(0)  #=> 1
p func1(1)  #=> 2
p func1(2)  #=> 3

# Can you tell how `func1` is defined?
# Hint: def func1(n) = n + ???

# Define `answer1` that works like `func1`
def answer1(n)
  n
end

func1は引数に1足したものを返しているので、それをanswer1にそのまま実装するだけです。

def answer1(n)
  n + 1
end

これで実行ボタンを押したらクリア。

2問目

# Congrats! You've solved the first puzzle!
# Next, challenge func2!

p func2("Hello") # => ??? (press "Run Ruby" to see output)
p func2("world") # => ???

def answer2(str)
  str
end

とりあえず実行した結果。テストが失敗します。

"HELLO"
"WORLD"

--- testing answer2
answer2: Test failed

    func2("Bar") != answer2("Bar")

"Hello""HELLO"になっているということは、大文字に変換すればよさそうですね。「Ruby 大文字 変換」などと検索すればすぐに答えが見つかると思います。次が答え。

def answer2(str)
  str.upcase
end

3問目

# Next, challenge func3!

p func3(1)
p func3(2)
p func3(3)

def answer3(n)
end

とりあえず実行。

[0, 1]
[0, 1, 2]
[0, 1, 2, 3]

--- testing answer3
answer3: Test failed

    func3(376) != answer3(376)

[0, 1, ..., 引数] を返せばいいようです。いろいろなやり方がありますが、たとえば次が答え。

def answer3(n)
  (0..n).to_a
end

RubyKaigi中は、ここまで解いたら賞品としてキッチンクロスが進呈されていました。ここから先は趣味の問題なので、急激に難易度が上がります。

4問目

まずはそのまま呼び出します。

# This call raises an error!
# Try to find a correct way to call it.
func4
secret2.rb:2:in `func4': no block given (yield) (LocalJumpError)
    from code.rb:6:in `main'

この例外は、ブロックが渡されていないのにyieldしたら起きます。ということは、この関数にはブロックを渡す必要があります。適当なブロックを渡してみましょう。

func4 { nil }

今度はundefined method '+' for nil:NilClass (NoMethodError)になりました。nilに何かを足そうとしているようです。ということは、このブロックは整数か文字列あたりを返す必要があると推測できます。

p func4 { 1 } #=> 43

43が帰ってきました。他の値で試してみましょう。

p func4 { 1 } #=> 43
p func4 { 2 } #=> 44
p func4 { 3 } #=> 45

どうやら42を足した値を返しているようです。ということで、解答はこちら。

def answer4
  yield + 42
end

5問目

p func5(0) #=> 1
p func5(1) #=> 2
p func5(2) #=> 3

1を足すだけに見えるので、まずは試してみます。

def answer5(n)
  n + 1
end

実行すると、テストがwrong number of arguments (given 2, expected 1) (ArgumentError)といって失敗しました。引数1つを受け取る関数に引数2つを渡した、と言ってます。つまり、テストはanswer5に引数を2つ渡すことがあるようです。func5を2引数で呼んでみましょう。

p func5(0, 0) #=> 0
p func5(1, 1) #=> 2
p func5(2, 2) #=> 4
p func5(3, 3) #=> 6

どうも、第2引数がないときは「第1引数 + 1」を返し、あるときは「第1引数 + 第2引数」を返していると想像できます。ということで答えはこちら。

def answer5(a, b = 1)
  a + b
end

ちなみにMethod#parametersを使うと、func5がどういう引数を受け取るかがわかります。

p method(:func5).parameters #=> [[:req, :augend], [:opt, :addend]]

必須引数augend(足される数)とオプション引数addend(足す数)を受け取るとわかりますね。ヒントの文にあるCheck the "parameters"はそういう意味でした。

6問目

p func6(1)   #=> 1
p func6(12)  #=> 3
p func6(123) #=> 6

どうやら各桁を足し合わせている(数字和、digit sum)と気づきます。そこで次を試します。

def answer6(n)
  n.digits.sum
end

しかしテストが通りません。

--- testing answer6
answer6: Test failed

    func6([**REDACTED**]) != answer6([**REDACTED**])

しかも、どういう引数でテストが失敗したのかが検閲(REDACTED)されています。いじわるですね。

しょうがないので、自分でテストを書いてみましょう。

100.times do |n|
  if func6(n) != answer6(n)
    puts "func6(#{n})=#{func6(n)}, answer6(#{n}) = #{answer6(n)}"
  end
end

すると、いろいろ失敗例が見つかります。

func6(19)=1, answer6(19) = 10
func6(28)=1, answer6(28) = 10
func6(29)=2, answer6(29) = 11
func6(37)=1, answer6(37) = 10
func6(38)=2, answer6(38) = 11
func6(39)=3, answer6(39) = 12
...

これを眺めると、また「各桁を足し合わせている」が見えてきます。つまり、1桁になるまでこれを繰り返すのではと推測できます(数字根、digits rootといいます)。よって次が答え。

def answer6(n)
  n = n.digits.sum
  if n < 10
    n
  else
    answer6(n)
  end
end

7問目

p func7(0) #=> 1
p func7(1) #=> 2
p func7(2) #=> 3

また1を足すだけに見えます。試してみましょう。

def answer7(n)
  n + 1
end

しかしこれはよくわからない例外でテスト失敗します。

ヒントに「Try to pass non-Integer!」と書いてあるので、適当に文字列を渡してみましょう。

func7("A") #=> "B"

文字列もひとつ進みました。わかる人はこれでわかると思いますが、わからなかったら更に別のオブジェクト、たとえば浮動小数を渡してみましょう。

func7(1.0) #=> undefined method `succ' for 1.0:Float (NoMethodError)

succを呼び出そうとしていることがわかりました。ややマイナーなメソッドですが、Integer#succは1足した数を返し、String#succはひとつ進めた文字列を返します("A"→"B"→"C"→...→"Y"→"Z"→"AA"→"AB"→...。Excelのカラム番号っぽい)。

ということで答えはこちら。

def answer7(n)
  n.succ
end

8問目

p func8(0)  #=> 0
p func8(1)  #=> 1
p func8(2)  #=> 1

これではなにもわからないので、サンプルを増やします。

p func8(3)  #=> 2
p func8(4)  #=> 1
p func8(5)  #=> 2
p func8(6)  #=> 2
p func8(7)  #=> 3
p func8(8)  #=> 1
p func8(9)  #=> 2
p func8(10) #=> 2
p func8(11) #=> 3
p func8(12) #=> 2
p func8(13) #=> 3
p func8(14) #=> 3
p func8(15) #=> 4
p func8(16) #=> 1

わかる人はここでわかるかもしれません。1、2、4、8、16、……のときに1を返しているのが特徴的です。

ヒントに%bと書いてあります。これはStringのフォーマット指定子で、2進数表記という意味です。試してみましょう。

p "%b" % 0 #=> "0"
p "%b" % 1 #=> "1"
p "%b" % 2 #=> "10"
p "%b" % 3 #=> "11"
p "%b" % 4 #=> "100"
p "%b" % 5 #=> "101"
p "%b" % 6 #=> "110"
p "%b" % 7 #=> "111"
p "%b" % 8 #=> "1000"

func8の返り値と合わせて、グッと見つめてみます。すると、2進数表記したときの1の数だとわかります。わかってください。この問題はもうちょっとヒントあったほうがよかったかもですね。とにかく答えはこちら。

def answer8(n)
  ("%b" % n).count("1")
end

別解としては、n.to_s(2).count("1") など。ちなみにこの演算にはpopcount(population count)という名前があります。

9問目

p func9("foo") #=> "foo"
p func9("bar") #=> "bar"
p func9("baz") #=> "baz"

引数をそのまま返す関数でしょうか? そんなに簡単なわけはありません。

ヒントにPass a spy (or mock) object to func9と書いてあります。spyやmockとは、主にテストで使われる言葉で、呼ばれたメソッドを記録するダミーオブジェクトを指します。Rubyではとても簡単にspyオブジェクトが定義できます。

class Spy
  def method_missing(name, *args)
    puts "#{ name } is called with #{ args }"
  end
end

このインスタンスをfunc9に渡してみましょう。

func9(Spy.new)
#=> gsub is called with ["u-g0t-me", "yikes"]

もう何が行われているかわかりましたね。これが答えです。

def answer9(s)
  s.gsub("u-g0t-me", "yikes")
end

10問目

1回実行すると、次のようになります。

p func10  #=> 1
p func10  #=> 2
p func10  #=> 3

この関数は呼ぶたびに違う値を返しているので、状態を保存していることがわかります。グローバル変数で模倣してみましょう。

$counter = 3
def answer10
  $counter += 1
end

$counterの初期値をうまく合わせないと、func10 != answer10というにべもないテスト失敗になります。冪等でない関数はたちが悪いですね。うまくいくと、wrong number of arguments (given 1, expected 0) (ArgumentError)と出るので、実はfunc10はオプション引数を受け取ることがわかります。

試行錯誤すると、次のような挙動に気づくと思います。

p func10       #=> 31
p func10       #=> 32
p func10       #=> 33
p func10(true) #=> 0
p func10       #=> 1
p func10       #=> 2
p func10       #=> 3

つまり、引数に真の値が与えられたときは状態を0に戻すようです。ということで次が答え。

func10(true)

$counter = 0
def answer10(reset = false)
  if reset
    $counter = 0
  else
    $counter += 1
  end
end

func10(true)を呼んで状態をリセットするのがコツです。

別解として、func10の状態をそのまま返す手があります。p global_variablesを使うと、$__func10_counterといういかにも怪しいグローバル変数が発見できます。これをそのまま返してもテストはパスします。

def answer10(*)
  $__func10_counter
end

global_variablesを知ってる人向けの裏技というつもりだったのですが、この方法で解いた人の方が多かったかもしれません。

一旦まとめ

func10を解くと、"Congraturations! You've completed all our puzzles!"と出ます。お疲れ様でした。

しかし、このパズルにはまだ隠しステージがあります。p methodsを見てみましょう。

p methods
#=> [:func1, :func2, :func3, :func4, :func5, :unlock_all_puzzles, :func6, :func7, :func8, :func9, :func10, :func11, :func12, :func13, :func14, :func15, :func16, :func17, :func18, :func19, :func20, ...]

実はfunc20まで用意されていることがわかりますね。

挑戦の仕方はいままでと同じです。func11を適当に呼んで中身を推測し、def answer11; ...; endを定義して解答です。問題文はありませんので、全部自力になります。なお、10問目まではある程度Rubyを知っていればわかるように配慮しましたが、11問目からはだいぶ理不尽な問題も混ざっています。

この記事はずいぶん長くなってしまったので、func11以降の解答はまた来週にします(楽しみにしてた人ごめんね)。func11以降に気づいてなかった人はぜひ今からでも挑戦してみてください。