今回はpaiza リアルイベント問題セット 「本の整理」 (paizaランク A 相当)を解説します。
順番がバラバラになっている本を指示されたルールに従って並び替えた時の入れ替え回数を答える問題です。
本の整理 (paizaランク A 相当)
※ paiza リアルイベント問題セット より
問題
あなたはパイザ図書館で働く図書館員です。
パイザ図書館には N 冊の蔵書があります。この蔵書はすべて 1 段からなる本棚で管理されています。そして、それぞれの本には 1 から N までの相異なる整数の ID がついています。本棚の本は ID 順に並んでいます。
しかし、ある日、あなたが蔵書の点検をしていると、本棚の本がバラバラに並べられていることに気づきました。
そこで、あなたは、次のルールに従って本を並び替えることにしました。
次の手順を N 回繰り返す。
1. i (1 ≦ i ≦ N) 回目の手順では、まず本棚の左から i 番目の位置に立つ。
2. 左から i 番目の位置にある本から、本棚の右端にある本までのうち、最も ID が小さい本の位置を歩きながら見つける。この位置を j とする。
3. i = j なら、何もしない。 i ≠ j なら、i 番目の本と j 番目の本を入れ替える。
本を取り出して入れ替えるのには時間がかかります。そこで、このルールに従って本を整理するときに、何回本を入れ替える必要があるのかを計算してください。
例)
5 冊の本が、本棚に次の順番で並んでいたとします。ただし、数字はそれぞれの本の ID を表します。
5 4 3 2 1
このとき、ルールに従うと、次のように本が整理されます。
i = 1 のとき (1 番目の本と 5 番目の本の位置が入れ替わる)
1 4 3 2 5
i = 2 のとき (2 番目の本と 4 番目の本の位置が入れ替わる)
1 2 3 4 5
i = 3 のとき
1 2 3 4 5
i = 4 のとき
1 2 3 4 5
i = 5 のとき
1 2 3 4 5
本の交換は i = 1 のときと i = 2 のときのみ起こっているので、このときの出力は 2 となります。
入力される値
入力は以下のフォーマットで与えられます。
N
a_1 a_2 ... a_N
- 1 行目に本の冊数を表す整数 N が与えられます。
- 2 行目に、 1 番目の本の ID を表す整数 a_1、 2 番目の本の ID を表す整数 a_2、 ...、 N 番目の本の ID を表す整数 a_N がこの順に半角スペース区切りで与えられます。
- 入力は合計で 2 行となり、末尾に改行が 1 つ入ります。
入力値最終行の末尾に改行が1つ入ります。
期待する出力
本の交換をする回数を整数で出力してください。
条件
すべてのテストケースにおいて、以下の条件をみたします。
- 1 ≦ N ≦ 100,000
- 1 ≦ a_i ≦ N (1 ≦ i ≦ N)
- 各 j, k (1 ≦ j, k ≦ N) について j ≠ k のとき a_j ≠ a_k
- 入力例1
- 5
5 4 3 2 1
- 出力例1
- 2
- 入力例2
- 10
8 7 9 1 5 6 2 10 4 3
- 出力例2
- 6
攻略ポイント
ポイント
- 問題文の条件から、効率よく該当IDの本を検索する方法を考える
- 本の数 = 棚の数 = N (空になる棚はない)
- 本のIDは 1 ~ N の連番
- 1 ≦ N ≦ 100,000
問題を解く流れ
入出力例をコピペしてヒアドキュメントで変数に代入し、データを確認します。正しく受け取れていれば確認用コードは削除します。
INPUT1 = <<~"EOS" 5 5 4 3 2 1 EOS OUTPUT1 = <<~"EOS" 2 EOS INPUT2 = <<~"EOS" 10 8 7 9 1 5 6 2 10 4 3 EOS OUTPUT2 = <<~"EOS" 6 EOS # 確認用コード p INPUT1 # > "5\n5 4 3 2 1\n" p OUTPUT1 # > "2\n" p INPUT2 # > "10\n8 7 9 1 5 6 2 10 4 3\n" p OUTPUT2 # > "6\n"
よくある失敗実装
せっかく罠がある問題なので、やりがちな失敗をしてみたいと思います。
失敗実装をsolve1
メソッドとし、変数の下に定義します。
solve1 処理の流れ
- 入力データ受け取り
- 棚の数=本の数 : 整数n
- 棚に入っている本のID: 整数の配列a
- 交換回数count = 0 で初期化する
- 配列a[i] (0 ≦ i ≦ n-2)の順番で整数a[i](本のID)を取り出す
- s_id = i + 1
※ s_id: i番目の棚に収めるべき本のID - a[i] == s_id なら i のループをスキップ(next)する
- a[j] (i+1 ≦ j ≦ n-1)の順番で整数a[j](本のID)を取り出す
- a[j] == s_id なら
- 棚a[i]にある本のIDと 棚a[j] にある本のIDを入れ替える
- 交換回数count を +1 する
- j のループを抜ける(break)
- a[j] == s_id なら
- s_id = i + 1
- 交換回数count を出力する
以上を実装すると、例えばINPUT2 の処理は下図のようになります。
では、以上の処理をsolve1メソッドに書いていきます。(今回は動作確認用コードも入れます)
def solve1(input_lines) n, *a = input_lines.split.map(&:to_i) # 【確認用コード ここから】 search = 0 # 【確認用コード ここまで】 count = 0 # 棚の左端から右に向かって調査していく 0.upto(n - 2) do |i| # 【確認用コード ここから】 search += 1 p a # 【確認用コード ここまで】 # index: i の棚には 本ID: i + 1 の本を置く s_id = i + 1 # 調査中の棚にある本のIDが正しければ次の棚を調べる next if a[i] == s_id # 調査中の棚より右側から index: i の棚に置く本を探す (i + 1).upto(n - 1) do |j| #【確認用コード ここから】 search += 1 #【確認用コード ここまで】 # index: i に置く本 a[j] を発見したとき if a[j] == s_id # a[i] にある本と a[j] にある本を入れ替え a[i], a[j] = a[j], a[i] # カウントアップして j のループを抜ける count += 1 break end end end # 【確認用コード ここから】 p a puts "search: #{search}" # 【確認用コード ここまで】 count end # 確認用コード p solve1(INPUT1) # > [5, 4, 3, 2, 1] # > [1, 4, 3, 2, 5] # > [1, 2, 3, 4, 5] # > [1, 2, 3, 4, 5] # > [1, 2, 3, 4, 5] # > search: 10 # > 2 p solve1(INPUT2) # > [8, 7, 9, 1, 5, 6, 2, 10, 4, 3] # > [1, 7, 9, 8, 5, 6, 2, 10, 4, 3] # > [1, 2, 9, 8, 5, 6, 7, 10, 4, 3] # > [1, 2, 3, 8, 5, 6, 7, 10, 4, 9] # > [1, 2, 3, 4, 5, 6, 7, 10, 8, 9] # > [1, 2, 3, 4, 5, 6, 7, 10, 8, 9] # > [1, 2, 3, 4, 5, 6, 7, 10, 8, 9] # > [1, 2, 3, 4, 5, 6, 7, 10, 8, 9] # > [1, 2, 3, 4, 5, 6, 7, 8, 10, 9] # > [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] # > search: 31 # > 6
正しく計算出来ているようなので、確認用コードを削除し、標準入力からのデータ入力、交換回数countを出力するようにコードを変更して提出してみます。
solve1 提出コード
def solve1(input_lines) n, *a = input_lines.split.map(&:to_i) count = 0 # 棚の左端から右に向かって調査していく 0.upto(n - 2) do |i| # index: i の棚には 本ID: i + 1 の本を置く s_id = i + 1 # 調査中の棚にある本が正しければ次の棚を調べる next if a[i] == s_id # 調査中の棚より右側から index: i に置く本を探す (i + 1).upto(n - 1) do |j| # index: i に置く本 a[j] を発見したとき if a[j] == s_id # a[i] にある本と a[j] にある本を入れ替え a[i], a[j] = a[j], a[i] # カウントアップして j のループを抜ける count += 1 break end end end count end puts solve1(STDIN.read)
提出結果
最後のテスト10が通らず・・・残念!
ということで、solve1メソッドでテスト10が通らなかった原因を確認してみましょう。
INPUT1(n = 5, a = [5, 4, 3, 2, 1])のようなカウントダウンの数列の桁を増やしながら入力データとして与えて、処理時間を測ってみます。
def solve1(input_lines) n, *a = input_lines.split.map(&:to_i) count = 0 # 棚の左端から右に向かって調査していく 0.upto(n - 2) do |i| # index: i の棚には 本ID: i + 1 の本を置く s_id = i + 1 # 調査中の棚にある本が正しければ次の棚を調べる next if a[i] == s_id # 調査中の棚より右側から index: i に置く本を探す (i + 1).upto(n - 1) do |j| # index: i に置く本 a[j] を発見したとき if a[j] == s_id # a[i] にある本と a[j] にある本を入れ替え a[i], a[j] = a[j], a[i] # カウントアップして j のループを抜ける count += 1 break end end end count end # 配列サイズが大きいときの処理時間 [5, 10, 100, 1_000, 10_000, 100_000].each do |n| input = ([n] + n.downto(1).to_a).join(" ") start = Time.now puts "n = #{n.to_s.rjust(6)}" puts "交換回数 #{solve1(input).to_s.rjust(5)} 処理時間: #{Time.now - start}" puts "---" end # > n = 5 # > 交換回数 2 処理時間: 7.91e-05 # > --- # > n = 10 # > 交換回数 5 処理時間: 2.67e-05 # > --- # > n = 100 # > 交換回数 50 処理時間: 0.0002558 # > --- # > n = 1000 # > 交換回数 500 処理時間: 0.0195869 # > --- # > n = 10000 # > 交換回数 5000 処理時間: 1.892705 # > --- # > n = 100000 # > 交換回数 50000 処理時間: 190.5272883 # > ---
データ数nと処理時間の関係を見てみると、 nの桁が1個増えるごとに処理時間の桁が2個増えていることが分かります。
最後の n = 100,000 の時は処理時間に190秒かかっていますが、判定は16秒で打ち切られるのでタイムオーバーが発生してしまうというわけですね。
本の場所を1発で検索する
失敗した実装では、調査対象の棚を調べてそこから右に順番に探索していましたが、本のIDは 1〜n までの重複なしであることから、本の位置を保持する配列を用意することで、ループなしで任意の本の位置を取得することが出来ます。
solve2 処理の流れ
- 入力データ受け取り
- 棚の数=本の数 : 整数n
- 棚に入っている本のID: 整数の配列a
- 要素数n の 配列b を用意する。
- b[a[i] - 1] に インデックスi を格納する。
※ 本ID: i の場所を取得するには b[i - 1] を参照する - 交換回数count = 0 で初期化する
- 配列a[i] (0 ≦ i ≦ n-2)の順番で整数a[i](本のID)を取り出す
- s_id = i + 1
※ s_id: i番目の棚に収めるべき本のID - a[i] == s_id なら i のループをスキップ(next) する
- tmp_p = b[i]
※ 現在の棚に置くべき本がある場所を格納
- tmp_i = a[i] - 1
※ 現在の棚に置かれている本の位置情報を格納する配列bのindexを格納 - a[i] と a[tmp_p] を入れ替える(本のID情報)
- b[i] と b[tmp_i] を入れ替える(本の位置情報)
- 交換回数count を +1 する
- s_id = i + 1
- 交換回数count を出力する
文字で説明しても分かりづらいですね。
今回もINPUT2 の処理を図にしてみました。
では、以上の処理をsolve2メソッド
に書いていきます。
今回はINPUT1, INPUT2, n = [5, 100, 1000, 10000, 100000]
まで一気に確認します。
def solve2(input_lines) n, *a = input_lines.split.map(&:to_i) # b[index(本ID - 1)] に 現在位置を格納 b = Array.new(n) a.each_with_index { |ai, i| b[ai - 1] = i } count = 0 # 棚の左端から右に向かって調査していく a.each_with_index do |ai, i| # ai: 現在の本ID, i+1: 入るべき本ID # 調査中の棚にある本が正しければ次の棚を調べる next if ai == i + 1 # tmp_p: 正しい本がある場所 tmp_p = b[i] # tmp_i: 調査中の棚に置かれている本のindex tmp_i = a[i] - 1 # 本の情報を入れ替える a[i], a[tmp_p] = a[tmp_p], a[i] b[i], b[tmp_i] = b[tmp_i], b[i] count += 1 end count end # 確認用コード p solve2(INPUT1) # > 2 p solve2(INPUT2) # > 6 # 配列サイズが大きいときの処理時間 [5, 10, 100, 1_000, 10_000, 100_000].each do |n| input = ([n] + n.downto(1).to_a).join(" ") start = Time.now puts "n = #{n.to_s.rjust(6)}" puts "交換回数 #{solve2(input).to_s.rjust(5)} 処理時間: #{Time.now - start}" puts "---" end # > n = 5 # > 交換回数 2 処理時間: 1.4e-05 # > --- # > n = 10 # > 交換回数 5 処理時間: 2.2e-05 # > --- # > n = 100 # > 交換回数 50 処理時間: 6.7e-05 # > --- # > n = 1000 # > 交換回数 500 処理時間: 0.001765 # > --- # > n = 10000 # > 交換回数 5000 処理時間: 0.007277 # > --- # > n = 100000 # > 交換回数 50000 処理時間: 0.062295 # > ---
n = 100,000 でも 0.06秒で解けているので問題なさそうです!
確認用コードを標準入力からのデータ受け取りに変更、標準出力をpメソッド
からputsメソッド
に変更して、動作確認をしたら提出します。(STDIN.readの入力終了は Ctrl+D
又は Ctrl+Z
)
解答コード
def solve2(input_lines) n, *a = input_lines.split.map(&:to_i) # b[index(本ID - 1)] に 現在位置を格納 b = Array.new(n) a.each_with_index { |ai, i| b[ai - 1] = i } count = 0 # 棚の左端から右に向かって調査していく a.each_with_index do |ai, i| # ai: 現在の本ID, i+1: 入るべき本ID # 調査中の棚にある本が正しければ次の棚を調べる next if ai == i + 1 # tmp_p: 正しい本がある場所 tmp_p = b[i] # tmp_i: 調査中の棚に置かれている本のindex tmp_i = a[i] - 1 # 本の情報を入れ替える a[i], a[tmp_p] = a[tmp_p], a[i] b[i], b[tmp_i] = b[tmp_i], b[i] count += 1 end # 交換回数 count を文字列に変換し末尾に改行を追加 count.to_s << "\n" end puts solve2(STDIN.read)
他の解答例
ハッシュを使って解くこともできます。
def solve3(input_lines) n, *a = input_lines.split.map(&:to_i) # key: 本のID-1, value: 今ある場所 b = a.each_with_index.to_h count = 0 # 棚の左端から s_id: 1 ~ s_id: n として # s_id と 本ID が揃うように並べ替える 0.upto(n - 1) do |i| # s_id: 棚ID s_id = i + 1 # 調査中の棚にある本が正しければ次の棚を調べる next if a[i] == s_id # tmp_p: 正しい本がある場所 tmp_p = b[s_id] # tmp_i: 調査中の棚に置かれている本のID tmp_i = a[i] # 本の情報を入れ替える a[i], a[tmp_p] = a[tmp_p], a[i] b[s_id], b[tmp_i] = b[tmp_i], b[s_id] count += 1 end count.to_s << "\n" end puts solve3(STDIN.read)
今回のまとめ
多重ループで考えると分かりやすい問題もありますが、要素数が増えると計算量が一気に増加しますので注意しましょう!
paiza Aランク問題あたりからは答えが合っていても(工夫なしの)多重ループで実装すると時間切れになるケースも出てきます。
問題の条件を確認して、超ざっくりで良いので計算量をつかんでおきましょう。