Ruby練習問題：教室予約システム（複数教室・分割割当対応）

### はじめに
この記事では、Ruby を使って「教室の予約システム」を実装します。
複数の教室を使って希望人数を収容するために、時間の重なりや定員制限を考慮したロジックを組み立てていきます。

このコードは、正直に言うと自分もスラスラとは書けませんでした。
でも、繰り返し読み解き、何も見ずに実装できるようになることを目指して練習しています。

Ruby の基本はわかっていても、設計力・論理的思考を磨きたい方にとっても実践的な題材になるはずです。


### 問題概要
あなたは学校の予約システムの開発を任されました。
このシステムでは、複数の教室を組み合わせて予約人数を満たすことが求められます。
各教室には定員があり、空いている時間帯かつ定員内であれば、複数の教室に分けて予約可能です。

### 仕様
- 教室は複数存在し、各教室には定員があります。
- 予約は次の情報を持ちます：
    - date：予約日（文字列 "2025-04-20" など）
    - start_time：開始時刻（文字列 "10:00" など）
    - end_time：終了時刻（文字列 "11:00" など）
    - people：予約人数（整数）

###  条件
- 教室は 収容人数の少ない順に 優先して利用されます（例：A → B → C）
- 予約の時間帯が他の予約と重なっている教室は使用できません
- 予約人数を満たすために、複数の教室を分割して組み合わせて使用可能
- すべての教室を使っても人数が足りない場合、予約は失敗
- 処理後、予約成功／失敗の結果を出力し、教室ごとの予約スケジュールも一覧表示

### 入力例
以下の教室と定員が設定されています：

```ruby
classroom_capacity = {
  "A" => 25,
  "B" => 20,
  "C" => 25
}
```
予約のリストは以下です：

```ruby
reservations = [
  { date: "2025-04-20", start_time: "10:00", end_time: "11:00", people: 25 },
  { date: "2025-04-20", start_time: "10:00", end_time: "11:00", people: 40 },
  { date: "2025-04-20", start_time: "10:00", end_time: "11:00", people: 10 },
  { date: "2025-04-20", start_time: "11:00", end_time: "12:00", people: 50 },
  { date: "2025-04-21", start_time: "09:00", end_time: "10:00", people: 70 },
  { date: "2025-04-21", start_time: "10:00", end_time: "11:00", people: 71 } 
]
```

### 出力例
```
予約成功: 2025-04-20 10:00〜11:00 → 教室A (25人)
予約成功: 2025-04-20 10:00〜11:00 → 教室B (20人) + 教室C (20人)
予約失敗: 教室が満室か、収容人数オーバーです
予約成功: 2025-04-20 11:00〜12:00 → 教室A (25人) + 教室B (20人) + 教室C (5人)
予約成功: 2025-04-21 09:00〜10:00 → 教室A (25人) + 教室B (20人) + 教室C (25人)
予約失敗: 教室が満室か、収容人数オーバーです

--- 教室別予約スケジュール ---

[教室A]
2025-04-20 10:00〜11:00 (25人)
2025-04-20 11:00〜12:00 (25人)
2025-04-21 09:00〜10:00 (25人)

[教室B]
2025-04-20 10:00〜11:00 (20人)
2025-04-20 11:00〜12:00 (20人)
2025-04-21 09:00〜10:00 (20人)

[教室C]
2025-04-20 10:00〜11:00 (20人)
2025-04-20 11:00〜12:00 (5人)
2025-04-21 09:00〜10:00 (25人)
```

### 回答

```ruby 
require "time"

# 教室定義（小さい順）
classroom_capacity = {
  "A" => 25,
  "B" => 20,
  "C" => 25
}

# テストデータ
reservations = [
  { date: "2025-04-20", start_time: "10:00", end_time: "11:00", people: 25 }, # A(25)
  { date: "2025-04-20", start_time: "10:00", end_time: "11:00", people: 40 }, # A(使えない), B(20), C(20)
  { date: "2025-04-20", start_time: "10:00", end_time: "11:00", people: 10 }, # 全教室重複→失敗
  { date: "2025-04-20", start_time: "11:00", end_time: "12:00", people: 50 }, # A(25) + B(20) + C(5)
  { date: "2025-04-21", start_time: "09:00", end_time: "10:00", people: 70 }, # A + B + Cぴったり
  { date: "2025-04-21", start_time: "10:00", end_time: "11:00", people: 71 }  # オーバー → 失敗
]

# 教室別の予約ログ
reservations_log = Hash.new { |h, k| h[k] = [] }

# 重複チェック
def overlaps?(new_res, existing_res)
  # 日付が違えば時間帯が重なることはないので、すぐに false（重なっていない）を返す。
  return false if new_res[:date] != existing_res[:date]

  new_start = Time.parse(new_res[:start_time])
  new_end = Time.parse(new_res[:end_time])
  existing_start = Time.parse(existing_res[:start_time])
  existing_end = Time.parse(existing_res[:end_time])

  # 重なっているときは true を返す
  !(new_end <= existing_start || existing_end <= new_start)
end

# 予約処理
reservations.each do |res|
  people_left = res[:people]
  reserved_rooms = []
  temp_log = []

  classroom_capacity.each do |room, cap|
    conflict = reservations_log[room].any? { |exist| overlaps?(res, exist) }
    next if conflict

    allocatable = [people_left, cap].min
    next if allocatable == 0

    temp_log << [room, res.merge(people: allocatable)]
    reserved_rooms << [room, allocatable]
    people_left -= allocatable
  end

  if people_left <= 0
    temp_log.each { |room, entry| reservations_log[room] << entry }
    rooms_output = reserved_rooms.map { |r, n| "教室#{r} (#{n}人)" }.join(" + ")
    puts "予約成功: #{res[:date]} #{res[:start_time]}〜#{res[:end_time]} → #{rooms_output}"
  else
    puts "予約失敗: 教室が満室か、収容人数オーバーです"
  end
end

# スケジュール出力
puts "\n--- 教室別予約スケジュール ---\n\n"

classroom_capacity.keys.each do |room|
  puts "[教室#{room}]"
  sorted = reservations_log[room].sort_by { |r| [r[:date], r[:start_time]] }

  if sorted.empty?
    puts "なし"
  else
    sorted.each do |r|
      puts "#{r[:date]} #{r[:start_time]}〜#{r[:end_time]} (#{r[:people]}人)"
    end
  end
  puts
end
```

## 解説

### 全体構成と思考の流れ
### この問題で実現したいことは？
1. 複数の教室を使って、希望人数を満たす予約処理
2. 重複（時間帯の重なり）がない教室を優先的に使う
3. なるべく定員が小さい順に割り当てる
4. 予約できたらログへ追加、できなければ失敗と出力
5. 最終的に教室別の予約状況を一覧表示

### コード解説：ブロックごとに丁寧に
#### 1. 教室の定義と予約データ
```ruby
# 教室定義（小さい順）
classroom_capacity = {
  "A" => 25,
  "B" => 20,
  "C" => 25
}

# テストデータ
reservations = [
  { date: "2025-04-20", start_time: "10:00", end_time: "11:00", people: 25 }, # A(25)
  { date: "2025-04-20", start_time: "10:00", end_time: "11:00", people: 40 }, # A(使えない), B(20), C(20)
  { date: "2025-04-20", start_time: "10:00", end_time: "11:00", people: 10 }, # 全教室重複→失敗
  { date: "2025-04-20", start_time: "11:00", end_time: "12:00", people: 50 }, # A(25) + B(20) + C(5)
  { date: "2025-04-21", start_time: "09:00", end_time: "10:00", people: 70 }, # A + B + Cぴったり
  { date: "2025-04-21", start_time: "10:00", end_time: "11:00", people: 71 }  # オーバー → 失敗
]

# 教室別の予約ログ (キーがなかったら、自動で空の配列を作って入れてくれる親切ハッシュ)
reservations_log = Hash.new { |h, k| h[k] = [] }
```
**解説**
- classroom_capacity: 教室ごとの定員（ここでは3教室）
- reservations_log: 教室ごとの予約履歴を保持するハッシュ
    - Hash.new { |h, k| h[k] = [] } は、教室名ごとに空配列を初期化できるテク
    - 通常、Rubyのハッシュで存在しないキーを参照すると nil が返ってきます。でも、Hash.new { |h, k| h[k] = [] } のようにブロックを渡すと、キーがまだ存在していないときに自動で初期値（ここでは空の配列）を作って代入してくれるようになります。
        イメージでいうと：
        ```ruby
        reservations_log["A"] # まだ何も入ってない → 自動で [] を代入
        ```
        なので、次のようにしてもエラーにならないし、ちゃんと動く：
        
        ```ruby
        reservations_log["A"] << { 日時と人数の予約データ }
        ```
- `|h, k| h[k] = []` の意味
    - `h`: このハッシュ（reservations_log）自身
    - `k`: 存在しなかったキー（例: `"A"` や `"B"`）
    - 「`h` の中の `k` に `[]` を代入する」、すなわち「まだ存在していなかったら空の配列を入れる」ということ。
     

#### 2. 重複チェックのヘルパー関数
```ruby
# 新しい予約と既存の予約が同じ日で、かつ時間帯が重なっているかを判定する。重なっているときは true を返す。
# new_res は新しく予約しようとしているデータ、existing_res は既に予約されているデータ
def overlaps?(new_res, existing_res)
  # 日付が違えば時間帯が重なることはないので、すぐに false（重なっていない）を返す。
  return false if new_res[:date] != existing_res[:date]

  new_start = Time.parse(new_res[:start_time])
  new_end = Time.parse(new_res[:end_time])
  existing_start = Time.parse(existing_res[:start_time])
  existing_end = Time.parse(existing_res[:end_time])

  !(new_end <= existing_start || existing_end <= new_start)
end
```
**思考のポイント**
- 同じ日の予約だけを比較
- Rubyの Time.parse を使って時間比較を行う
- 時間の重なり判定の定番パターン
→ "終了時間 <= 開始時間" または "既存の終了 <= 新規の開始" → どちらかなら重ならない！

**✅ 重ならないパターン（false）**
```makefile
# パターン①：新しい予約が前に終わってる（new_end <= existing_start）

new:   ┌──────┐
     10:00  11:00

exist:           ┌──────┐
               12:00  13:00

# パターン②：新しい予約が後から始まる（existing_end <= new_start）

exist: ┌──────┐
      9:00  10:00

new:           ┌──────┐
             10:30  11:30
```

**❌ 重なるパターン（true）**
```makefile
# パターン①：完全に中に入ってる

exist: ┌──────────────┐
        9:00       12:00

new:       ┌──────┐
         10:00  11:00


# パターン②：少しだけかぶってる（前）

exist:     ┌──────┐
         10:00  11:00

new:   ┌──────┐
     9:30   10:30

# パターン③：少しだけかぶってる（後）

exist: ┌──────┐
      9:30  10:30

new:       ┌──────┐
         10:00  11:00

# パターン④：完全にかぶってる

exist: ┌──────┐
     10:00  11:00

new:   ┌──────┐
    10:00   11:00
```


#### 3. メイン処理：予約を教室に振り分ける
```ruby 
# 予約管理
reservations.each do |res|
  p res # {:date=>"2025-04-20", :start_time=>"10:00", :end_time=>"11:00", :people=>25}

  # 残りの予約人数（最初は希望人数そのまま）
  p people_left = res[:people] # => 25
  
  # 成功時の出力用に記録 例: [["A", 25], ["B", 20], ["C", 25]]
  reserved_rooms = []
  
  # 実際に予約ログへ追加する前に、一時保存しておく
  # 例: [["A", {:date=>"2025-04-20", :start_time=>"11:00", :end_time=>"12:00", :people=>25}], ["B", {:date=>"2025-04-20", :start_time=>"11:00", :end_time=>"12:00", :people=>20}]]
  temp_log = []
```

#### 4. 教室を1つずつ見て、予約できるか判断
```ruby 
 classroom_capacity.each do |room, cap|
    # その教室の予約ログに、今回の予約と時間が重なる予約が1つでもあるかをチェック
    conflict = reservations_log[room].any? do |exist|
      puts "[DEBUG] チェック中：#{room} → #{exist.inspect}"
      overlaps?(res, exist)
    end

    # 重なっていたら、この教室はスキップ
    next if conflict

    # その教室に割り当て可能な人数（残り人数と教室の定員の小さい方）
    allocatable = [people_left, cap].min

    # 割り当て可能人数が0なら、この教室もスキップ
    next if allocatable == 0

    # この教室に予約を一時的に記録（実際に確定するのは後）
    # mergeでpeopleを教室に割り当てる人数に上書きする (ハッシュ.merge)
    p "temp_log"
    p temp_log << [room, res.merge(people: allocatable)]


    # 教室と人数の組を予約済みリストに追加（出力用）
    p "reserved_rooms"
    p reserved_rooms << [room, allocatable]

    # 割り当てた分だけ残りの人数を減らす 例: 46
    p "people_left"
    p people_left -= allocatable
  end
```
**思考ポイント**
- 教室ごとに、既存の予約と重ならないかチェック
- people_left と教室のキャパ cap の小さい方だけ割り当て
- この段階では予約ログに追加せず、一時的に保留しておくのが肝！


#### 5. 予約が成功か失敗かを判断し、ログに反映
```ruby 
if people_left <= 0
    # 一時的に記録しておいた予約情報 temp_log を、実際の予約ログ reservations_log に書き込む。
    temp_log.each { |room, entry| reservations_log[room] << entry }

    # 予約に使われた教室と人数の情報を文字列に整形して出力用にする。
    # 例：[["A", 25], ["B", 20]] → "教室A (25人) + 教室B (20人)"
    rooms_output = reserved_rooms.map { |r, n| "教室#{r} (#{n}人)" }.join(" + ")
    puts "予約成功: #{res[:date]} #{res[:start_time]}~#{res[:end_time]} → #{rooms_output}"
  else
    puts "予約失敗: 教室が満室か、収容人数オーバーです"
  end
end
```
**思考の流れ**
- people_left == 0 → すべての人数を割り当てられた！
    - ⇒ 一時ログを本物のログへ追加
- 満たせなかったら puts だけして、ログには何も追加しない！

これが最も重要な修正ポイントでした。


#### 6. 最後に予約スケジュールを教室別に出力
```ruby 
puts "\n--- 教室別予約スケジュール ---\n\n"

classroom_capacity.keys.each do |room|
  puts "[教室#{room}]"
  sorted = reservations_log[room].sort_by { |r| [r[:date], r[:start_time]] }

  if sorted.empty?
    puts "なし"
  else
    sorted.each do |r|
      puts "#{r[:date]} #{r[:start_time]}〜#{r[:end_time]} (#{r[:people]}人)"
    end
  end
  puts
end
```
**解説**
- 各教室ごとの予約を日付順＋開始時刻順でソートして見やすく出力


### 終わりに
株式会社シンシアでは、実務未経験のエンジニアの方や学生エンジニアインターンを採用し一緒に働いています。
※ シンシアにおける働き方の様子はこちら

https://www.wantedly.com/companies/xincere-inc/stories

弊社には年間100人程度の実務未経験の方に応募いただき、技術面接を実施しております。
この記事が少しでも学びになったという方は、ぜひ wantedly のストーリーもご覧いただけるととても嬉しいです！