はじめに
競プロはじめたての初心者です。
言語はRubyをよく使っています。
昔、どうしても解くことができなかった問題を、最近遂に解くことができたので、シェアしたいと思います。
※ 著作権の問題上、問題は少し変えています
正解のコードを紹介する前に、私がつまずいたときの考え方とコードを先にシェアしています。
これは、初心者プログラマの考え方を知りたいという熟練者の方の手助けになれば良いなと考えたからです。
少々長くなってしまっているので、答えのみ知りたい方は、「私がつまずいた点」の部分をスキップして読んでもらえばと思います。
サイコロの問題
問題は以下の通りです。
- 上記の展開図からなるサイコロがある
- このサイコロを、ある方向にある回数転がす
- 最後の上面の数字を出力せよ
入力
入力は、「N,S,E,W」のどれかなる、一つ以上の文字列である。
尚、「N,S,E,W」はそれぞれ上図の矢印方向にサイコロを1回転させることを意味する。
NW
出力
入力された文字列の長さ分、サイコロを回転させたときの、上面の数字を出力する。
3
私がつまずいた点
私がなぜ最初にこの問題を解けなかったのか、つまずいた点を紹介します。
初心者はこういうところを難しいと感じているということが伝わればいいなと思います笑
私は、この問題を見た際、サイコロが縦方向と横方向に回転することから、縦方向の数値からなる配列と、横方向の数値からなる配列を作れば良いと考えました。
# サイコロが縦に転がった際の数値の配列
tate = [1,2,6,5]
# サイコロが横に転がった際の数値の配列
yoko = [4,6,3,1]
そして、サイコロがN方向に転がったときは、縦の配列の最初の要素(tate[0]
)を、縦配列の最後(tate[-1]
)に持ってこれば良いと考えました。
この移動を行なった後の、配列の一番最初の要素が、サイコロの上面の数値になります。
サイコロがS方向に転がった際は、この逆の作業を行います。すなわち、縦配列の最後(tate[-1]
)を、縦配列の最初(tate[0]
)に持ってきます。
# N方向への回転
# 配列の最初の値を、配列の最後に持ってくる
tate << tate.shift
# S方向への回転
# 配列の最後の値を、配列の最初に持ってくる
tate.unshift(tate.pop)
これで、縦方向にサイコロが回転した場合に、配列の一番目の数値を見れば、サイコロの上面の数値がわかるようになりました。
横方向にサイコロが回転した際も、縦方向に回転した場合と考え方は全く同じです。
# E方向への回転
# 配列の最初の値を、配列の最後に持ってくる
yoko << yoko.shift
# W方向への回転
# 配列の最後の値を、配列の最初に持ってくる
yoko.unshift(yoko.pop)
しかし、このコードでは動きません
すでにお気付きの方もいらっしゃるかもしれませんが、このコードは間違っています。
さて、どこが間違っているでしょう。
サイコロの縦回転と横回転が繰り返されたケースを考えるとわかります。
実はこのコード、次の場合にのみ正しく動きます。
- サイコロが縦回転しかしない場合
- サイコロが横回転しかしない場合
なぜこうなってしまうのでしょう?
サイコロがN方向に1回、E方向に2回、回転した場合を考えてみましょう。
まず、N方向に1回回転すると、サイコロ上面は、2になります。
次に、E方向に2回回転すると、サイコロ上面は、5になります。
しかし、上記のコードでこれを実行すると、6が出力されます。
おかしいですね。
私のコードがうまくいかない理由は、縦方向、もしくは横方向への回転により、縦配列、横配列の値が変化するということを無視しているコードになっているからです。
サイコロがN方向に1回、回転した場合、横方向の配列は次のように変わります。
# yoko = [4,6,3,1] であったが、
# N方向に1回転した場合は、次のような配列になっていないといけない。
yoko = [4,5,3,2]
このように、縦方向への回転と横方向への回転が組み合わさると、縦配列、横配列の値を動的に変化させないといけないことがわかりました。
最初に問題を解いたときも、ここまではたどり着けたのですが、結局どうすれば良いのかわからず寝ました笑
解答コード in Ruby
つい最近、同じ問題と出会い解法が閃きました(成長の証だと信じたい笑)
そのコードがこちらです。
# サイコロを用意
dice = [*1..6]
# 回転方向(N,S,E,W)の入力結果を配列に
commands = gets.chomp!.split("")
# N方向への回転
def turn_north(dice)
[dice[1], dice[5], dice[2], dice[3], dice[0], dice[4]]
end
# S方向への回転
def turn_south(dice)
[dice[5], dice[0], dice[2], dice[3], dice[4], dice[1]]
end
# E方向への回転
def turn_east(dice)
[dice[3], dice[1], dice[0], dice[5], dice[4], dice[2]]
end
# W方向への回転
def turn_west(dice)
[dice[2], dice[1], dice[5], dice[0], dice[4], dice[3]]
end
commands.each do |c|
case c
when "N"
dice = turn_north($dice)
when "S"
dice = turn_south($dice)
when "W"
dice = turn_west($dice)
when "E"
dice = turn_east($dice)
end
end
# サイコロ上面の数値を出力
puts $dice.first
サイコロを回転させるメソッドのロジックがかなりわかりにくいと思うので、説明します。
まず、サイコロの縦方向ごと、横方向ごとに配列を用意するという考え方を捨てました。
理由は上記の通りです。
発想を変えて、サイコロが回転したときに、上記の展開図の位置にどの数値が入るかを考えました。
今回の場合だと、N方向に1回転した場合、展開図の1のところに数値2がきます。
そして、展開図の2のところには、数値6がきます。
このように、展開図の場所にどの数値が入るかを、1から6まで考えます。
すると、次のような結果になります。
N方向に1回転した場合
- 展開図の1に、数値2
- 展開図の2に、数値6
- 展開図の3に、数値3
- 展開図の4に、数値4
- 展開図の5に、数値1
- 展開図の6に、数値5
この、動きをコードで再現したのが、turn_north(dice)
メソッドです。
このメソッドは、引数でサイコロ(今の展開図の数字順に、サイコロの数値が並んでいる配列)を受け取ります。
def turn_north(dice)
[dice[1], dice[5], dice[2], dice[3], dice[0], dice[4]]
end
そして、引数で受け取った配列に対して、N方向に一回転した際に、サイコロの数値と展開図の位置の変化を戻り値として返しています。
このコードは、N方向に回転した際、展開図の位置に対しサイコロの数値がどのように変化するかという性質を表したものです。
そのため、例えサイコロが途中で横方向に回転しようと、この性質が変わることはありません。
このように考えることによって、正しい出力を出せるコードが出来上がりました。
[追記]
サイコロを転がすメソッドの部分は、Array#values_at
メソッドを使えば、よりエレガントに記述できます。
例えば、turn_north(dice)
メソッドは次のように書き換えられます。
# 元のコード
def turn_north(dice)
[dice[1], dice[5], dice[2], dice[3], dice[0], dice[4]]
end
# Array#values_atを用いたコード
def turn_north(dice)
dice.values_at(1, 5, 2, 3, 0, 4)
end
詳しくは、こちらを参照ください
https://docs.ruby-lang.org/ja/2.7.0/method/Array/i/values_at.html
※ @scivola さん、ご指摘ありがとうございます!
最後に
今回私が書いたコードは、非常にわかりにくいコードのような気がします。
特に、サイコロが回転したときの処理は、このように長々と説明しないと理解されないと思い、もっと綺麗なコードがあるのではないかと思います。
もし、この記事を読んだ方で、もっと良い方法があるという方は、コメントご教授下さい。