カレンダー作成問題の解答

前回に引き続き、こちらの記事を見てみて、面白そうな問題がいくつかあったので、プログラミング初心者ではないですが解きました。元記事では、解答を見ないで解くこととなっていましたが、ある程度自分で考えた後に他の人の解答を見ました。それでもオリジナルなコードになったと思います。

blog.jnito.com

カレンダー作成問題

「たのしいRuby」に載っている、オーソドックスなカレンダー作成問題です。
DateクラスのAPIさえわかれば、あとは基礎的なプログラミング知識だけでコードが書けると思います。 Date クラスを使って、今月の1日と月末の日付と曜日を求め、次のような形式でカレンダーを表示させてください

      April 2013
 Su Mo Tu We Th Fr Sa
     1  2  3  4  5  6
  7  8  9 10 11 12 13
 14 15 16 17 18 19 20
 21 22 23 24 25 26 27
 28 29 30

解答

class Calendar
  def self.print_calendar(target_date = Date.today)
    start_of_month = Date.new(target_date.year, target_date.month, 1)
    end_of_month = start_of_month.next_month.prev_day
    cal_start = start_of_month.prev_day(start_of_month.wday)
    cal_end = end_of_month.next_day(6 - end_of_month.wday)

    # month year
    puts target_date.strftime('%B %Y').center(21)
    # week days header
    puts ' Su Mo Tu We Th Fr Sa'
    # days
    (cal_start..cal_end).each_slice(7) {|days|
      days.each {|day|
        if day.month == target_date.month
          print ' %2d' % day.day
        else
          print '   '
        end
      }
      puts
    }
  end
end

# 使い方
puts '--- this month ---'
Calendar.print_calendar
puts '--- 2013 04 ---'
Calendar.print_calendar(Date.new(2013, 4, 24))

解説

ブログに解答を載せるに際して、以下のような方針を立てました。

  • Railsで実装することを前提とする
    • HTMLのテーブルを使用する前提で実装する
    • フォーマットを(あまり)がんばらない

Railsで実装することを前提とする、とは、コンソール出力する用にコードを書くのではなく、Railsでhtmlを(erbなどで)出力する場合にこのように書く、という意味です。 特に今回はHTMLのテーブル形式で出力するとよさそうです。以下のテーブルを出力するイメージで実装を進めます。

f:id:yucatio:20190531085309p:plain

HTMLで出力を考えるとき、フォーマットはCSSで行うため、フォーマットについては最低限にします。

まずはクラスとメソッドを作成します。

class Calendar
  def self.print_calendar(target_date = Date.today)
    puts target_date
  end
end

# 使い方
puts '--- this month ---'
Calendar.print_calendar
puts '--- 2013 04 ---'
Calendar.print_calendar(Date.new(2013, 4, 24))

問題では今月のカレンダーを表示する、となっていましたが、メソッド内部に現在時刻が入ってしまうとバグに気付きにくくデバッグもしづらいので、対象とする日にちは引数で渡すことにします。デフォルトで引数を現在日時にすることにより問題の意図通りに動くようにします。

実行します。本日の日付と引数で渡した日付が表示されました。 this monthの方は現在の日付なので実行ごとに結果が変わります。

--- this month ---
2019-05-29
--- 2013 04 ---
2013-04-24

実装を進めます。まずは月の初めの日付と月の終わりの日付のオブジェクトを作成します。 月初めは、現在の年月の1日なので、Date(target_date.year, target_date.month, 1)で作成します。これをstart_of_monthという変数に格納します。 月終わりは、次の月の1日の、1日前なので、これをそのままプログラムに直します。start_of_monthを使用して、start_of_month.next_month.prev_dayでよさそうです。ここまでの実装は以下になります。

class Calendar
  def self.print_calendar(target_date = Date.today)
    start_of_month = Date.new(target_date.year, target_date.month, 1)  # 追加
    end_of_month = start_of_month.next_month.prev_day  # 追加
    puts start_of_month
    puts end_of_month
  end
end

# 使い方
puts '--- this month ---'
Calendar.print_calendar
puts '--- 2013 04 ---'
Calendar.print_calendar(Date.new(2013, 4, 24))

実行結果です。月の初めと終わりの日付が取得できました。

--- this month ---
2019-05-01
2019-05-31
--- 2013 04 ---
2013-04-01
2013-04-30

次に、曜日を求めます。曜日のぶん出力をずらすためです。Date#wdayが使用できます。一旦出力を確認しておきましょう。

class Calendar
  def self.print_calendar(target_date = Date.today)
    start_of_month = Date.new(target_date.year, target_date.month, 1)
    end_of_month = start_of_month.next_month.prev_day
    puts start_of_month.wday
    puts end_of_month.wday
  end
end

# 使い方
puts '--- this month ---'
Calendar.print_calendar
puts '--- 2013 04 ---'
Calendar.print_calendar(Date.new(2013, 4, 24))

実行結果です。曜日の対応は以下なので、曜日に対応する数値が取れました。

0 1 2 3 4 5 6
--- this month ---
3
5
--- 2013 04 ---
1
2

次に曜日のぶんだけ日付を右にずらします。今回は、"ずらす"というという発想より、カレンダー(テーブル)の最初の日付と最後の日付を求める、という発想で解決します。

カレンダーの左上と右下、この2つは(当月の日付でなければ)空白で出力しますが、ここに該当する前月と次月の日付を求めます。

f:id:yucatio:20190531091237p:plain

カレンダーの左上は、上で求めたstart_of_month.wdayぶんstart_of_monthから過去の日付です。 カレンダーの右下は、end_of_month.wdayから土曜までの日数を足せば良いので、6 - end_of_month.wdaぶん未来の日付がカレンダーの右下の日付になります。ここまでをコードにします。日付を足したり引いたりするにはDate#prev_dayDate#next_dayを使用します。

class Calendar
  def self.print_calendar(target_date = Date.today)
    start_of_month = Date.new(target_date.year, target_date.month, 1)
    end_of_month = start_of_month.next_month.prev_day
    cal_start = start_of_month.prev_day(start_of_month.wday)  # 追加
    cal_end = end_of_month.next_day(6 - end_of_month.wday)  # 追加
    puts cal_start
    puts cal_end
  end
end

puts '--- this month ---'
Calendar.print_calendar
puts '--- 2013 04 ---'
Calendar.print_calendar(Date.new(2013, 4, 24))

実行結果です。カレンダーの左上と右下の日付が出力されました。

--- this month ---
2019-04-28
2019-06-01
--- 2013 04 ---
2013-03-31
2013-05-04

ここまでで事前準備は終了なので、本番の出力部分を実装していきます。まずは April 2013 の部分を出力します。時間を文字列にフォーマットするには、Date#strftime( class Date (Ruby 2.6.0) ) を使用します。Aprilのような英語の月名表示は%B、年表示は%Yで表します。センタリングはString#center( class String (Ruby 2.6.0) ) で行います。ここまでのコードです。

class Calendar
  def self.print_calendar(target_date = Date.today)
    start_of_month = Date.new(target_date.year, target_date.month, 1)
    end_of_month = start_of_month.next_month.prev_day
    cal_start = start_of_month.prev_day(start_of_month.wday)
    cal_end = end_of_month.next_day(6 - end_of_month.wday)

    # month year
    puts target_date.strftime('%B %Y').center(21)  # 追加
  end
end

puts '--- this month ---'
Calendar.print_calendar
puts '--- 2013 04 ---'
Calendar.print_calendar(Date.new(2013, 4, 24))

実行結果です。月名と年がセンタリングして表示されました。

--- this month ---
      May 2019       
--- 2013 04 ---
     April 2013      

次に曜日のヘッダーSu Mo Tu We Th Fr Saを表示します。これはそのまま表示すれば良いでしょう。コードです。

class Calendar
  def self.print_calendar(target_date = Date.today)
    start_of_month = Date.new(target_date.year, target_date.month, 1)
    end_of_month = start_of_month.next_month.prev_day
    cal_start = start_of_month.prev_day(start_of_month.wday)
    cal_end = end_of_month.next_day(6 - end_of_month.wday)

    # month year
    puts target_date.strftime('%B %Y').center(21)
    # week days header
    puts ' Su Mo Tu We Th Fr Sa'  # 追加
  end
end

puts '--- this month ---'
Calendar.print_calendar
puts '--- 2013 04 ---'
Calendar.print_calendar(Date.new(2013, 4, 24))

実行結果です。曜日名が表示されました。

--- this month ---
      May 2019       
 Su Mo Tu We Th Fr Sa
--- 2013 04 ---
     April 2013      
 Su Mo Tu We Th Fr Sa

日にちを表示していきます。まず、カレンダーの左上の日付からカレンダーの右下の日付までの範囲オブジェクトを作成します。(cal_start..cal_end)です。これを7日ごとに分割します。Enumerable#each_slice( each_slice (Enumerable) - Rubyリファレンス )を使用します。ここまでの実装です。

class Calendar
  def self.print_calendar(target_date = Date.today)
    start_of_month = Date.new(target_date.year, target_date.month, 1)
    end_of_month = start_of_month.next_month.prev_day
    cal_start = start_of_month.prev_day(start_of_month.wday)
    cal_end = end_of_month.next_day(6 - end_of_month.wday)

    # month year
    puts target_date.strftime('%B %Y').center(21)
    # week days header
    puts ' Su Mo Tu We Th Fr Sa'
    # days
    (cal_start..cal_end).each_slice(7) {|days|  # 追加
      days  # 仮実装
    }
  end
end

puts '--- this month ---'
Calendar.print_calendar
puts '--- 2013 04 ---'
Calendar.print_calendar(Date.new(2013, 4, 24))

ここから各日付を出力します。

class Calendar
  def self.print_calendar(target_date = Date.today)
    start_of_month = Date.new(target_date.year, target_date.month, 1)
    end_of_month = start_of_month.next_month.prev_day
    cal_start = start_of_month.prev_day(start_of_month.wday)
    cal_end = end_of_month.next_day(6 - end_of_month.wday)

    # month year
    puts target_date.strftime('%B %Y').center(21)
    # week days header
    puts ' Su Mo Tu We Th Fr Sa'
    # days
    (cal_start..cal_end).each_slice(7) {|days|
      days.each {|day|  # 追加
        print day.day  # 仮実装
      }
      puts
    }
  end
end
puts '--- this month ---'
Calendar.print_calendar
puts '--- 2013 04 ---'
Calendar.print_calendar(Date.new(2013, 4, 24))

日付をフォーマットしていきます。フォーマットを、数値2桁にし、先頭に空白を入れます。 フォーマットには、String#%を使用します。 https://docs.ruby-lang.org/ja/latest/class/String.html#I_--25

先頭に空白1つ、数値2桁に揃えてフォーマットは、%2dで表します。

ここまでのコードです。

class Calendar
  def self.print_calendar(target_date = Date.today)
    start_of_month = Date.new(target_date.year, target_date.month, 1)
    end_of_month = start_of_month.next_month.prev_day
    cal_start = start_of_month.prev_day(start_of_month.wday)
    cal_end = end_of_month.next_day(6 - end_of_month.wday)

    # month year
    puts target_date.strftime('%B %Y').center(21)
    # week days header
    puts ' Su Mo Tu We Th Fr Sa'
    # days
    (cal_start..cal_end).each_slice(7) {|days|
      days.each {|day|
         print ' %2d' % day.day  # 変更
      }
      puts
    }
  end
end

puts '--- this month ---'
Calendar.print_calendar
puts '--- 2013 04 ---'
Calendar.print_calendar(Date.new(2013, 4, 24))

実行結果です。列が揃っています。

--- this month ---
      May 2019       
 Su Mo Tu We Th Fr Sa
 28 29 30  1  2  3  4
  5  6  7  8  9 10 11
 12 13 14 15 16 17 18
 19 20 21 22 23 24 25
 26 27 28 29 30 31  1
--- 2013 04 ---
     April 2013      
 Su Mo Tu We Th Fr Sa
 31  1  2  3  4  5  6
  7  8  9 10 11 12 13
 14 15 16 17 18 19 20
 21 22 23 24 25 26 27
 28 29 30  1  2  3  4

最後に、当月でない部分の日付は表示しないようにします。当月でない場合は空白3つを表示します。 当月かどうかの判定は、出力しようとしている日付と、引数で与えられた日付の月を比較します。

class Calendar
  def self.print_calendar(target_date = Date.today)
    start_of_month = Date.new(target_date.year, target_date.month, 1)
    end_of_month = start_of_month.next_month.prev_day
    cal_start = start_of_month.prev_day(start_of_month.wday)
    cal_end = end_of_month.next_day(6 - end_of_month.wday)

    # month year
    puts target_date.strftime('%B %Y').center(21)
    # week days header
    puts ' Su Mo Tu We Th Fr Sa'
    # days
    (cal_start..cal_end).each_slice(7) {|days|
      days.each {|day|
        if day.month == target_date.month  # if-else 追加
          print ' %2d' % day.day
        else
          print '   '
        end
      }
      puts
    }
  end
end

puts '--- this month ---'
Calendar.print_calendar
puts '--- 2013 04 ---'
Calendar.print_calendar(Date.new(2013, 4, 24))

実行結果です。問題で求められた形式のカレンダーを作成することができました。

--- this month ---
      May 2019       
 Su Mo Tu We Th Fr Sa
           1  2  3  4
  5  6  7  8  9 10 11
 12 13 14 15 16 17 18
 19 20 21 22 23 24 25
 26 27 28 29 30 31   
--- 2013 04 ---
     April 2013      
 Su Mo Tu We Th Fr Sa
     1  2  3  4  5  6
  7  8  9 10 11 12 13
 14 15 16 17 18 19 20
 21 22 23 24 25 26 27
 28 29 30            

あとがき

最初の日付をずらして空白を入れる実装に迷いましたが、 わりと分かりやすく書けたかと思います。

Railsで実装する時に、

  • 前後の月の日付を表示してほしい(薄い文字で)
  • "今日"をハイライトしてほしい

といった追加の要望にも簡単に応えられる実装にしました。

出題者の伊藤淳一さんの書籍はこちら↓


連続した文字を抽出する正規表現とガラケー文字入力問題(応用)の解答

前回の記事の問題を解いている過程で、"連続した数字を取り出さなければ"と思い、凝った正規表現を作ってしまったので、それを使える問題を作成しました。

★前回の記事

yucatio.hatenablog.com

こちらの記事のガラケー文字入力問題の応用編を作成しました。

blog.jnito.com

問題

英語のガラケーでは「2」キーを2回押すと「b」になり、「3」キーを3回押すと「f」になります。

文字を確定するには、'0'または別の数字を入れることで確定されます。

たとえば、このプログラムに対して"443355505556660"を入力すると、"hello"が返ってきます。


解答

class FeaturePhone
  BUTTONS = {
    '1' => ['.', ',', '!', '?', ' '],
    '2' => %w[a b c],
    '3' => %w[d e f],
    '4' => %w[g h i],
    '5' => %w[j k l],
    '6' => %w[m n o],
    '7' => %w[p q r s],
    '8' => %w[t u v],
    '9' => %w[w x y z]
  }.freeze

  def digits_to_alphabet(digits_line)
    digits_line.scan(/(([1-9])\2*)/).map {|digits, |
      button_values = BUTTONS[digits[0]]
      button_values[(digits.length - 1) % button_values.length]
    }.join
  end
end

テストコードです。

require 'rspec'
require File.expand_path(File.dirname(__FILE__) + '/../hello/feature_phone')

describe FeaturePhone do
  describe '#digits_to_alphabet_non_zero' do
    subject { phone.digits_to_alphabet(digits) }
    let(:phone){ FeaturePhone.new }

    context '2のボタンが1回押された場合' do
      let(:digits) { '20' }
      it { is_expected.to eq 'a'}
    end

    context '4, 3, 5, 6のボタンが2回か3回押され、毎回0で確定された場合' do
      let(:digits) { '440330555055506660' }
      it { is_expected.to eq 'hello'}
    end

    context '1, 4, 3, 5, 6, 7, 9のボタンが押され、途中に0がない場合' do
      let(:digits) { '4433555055566611011111090666077755531110' }
      it { is_expected.to eq 'hello, world!'}
    end

    context '0が複数回登場し、5のボタンが8回押されたケースが含まれる場合' do
      let(:digits) { '000555555550000330000444000080000200004440000' }
      it { is_expected.to eq 'keitai'}
    end

    context '0が複数回登場し、5のボタンが8回押されたケースが含まれる場合' do
      let(:digits) { '0005555555533444824440000' }
      it { is_expected.to eq 'keitai'}
    end
  end
end

解説

1から9の数字で同じ数字が連続している部分を抜き出します。[1-9]+では、443355などにマッチしてしまい、同じ数字の連続を取り出すことができません。

同じ数字の連続を取り出すのには、後方参照を使用します。後方参照は、カッコでキャプチャした部分を、\1の形式で正規表現中で参照できる機能です。同じ数字が2回連続で出現する正規表現([0-9])\1で表すことができます。今回は、1から9の数字の1回以上の連続を取り出したいので、正規表現([1-9])\1*となります。

String.scan( scan (String) - Rubyリファレンス )で正規表現にマッチする部部分を繰り返し取得します。

ここまでをコードにしていきます。

class FeaturePhone
  def digits_to_alphabet(digits_line)
    digits_line.scan(/([1-9])\1*/)
  end
end

入力が0005555555533444824440000の場合の実行結果です。連続した部分の先頭の文字列が取得できました。これはカッコでキャプチャした部分です。

[["5"], ["3"], ["4"], ["8"], ["2"], ["4"]]

マッチする部分全体を取得するため、キャプチャのカッコを追加します。後方参照の\1\2に変更します。

class FeaturePhone
  def digits_to_alphabet(digits_line)
    digits_line.scan(/(([1-9])\2*)/)
  end
end

入力が0005555555533444824440000の場合の実行結果です。連続した数字が取得できました。

[["55555555", "5"], ["33", "3"], ["444", "4"], ["8", "8"], ["2", "2"], ["444", "4"]]

連続した数字列と最初のの数字の2つをmapに渡します。 多重代入を使用して、配列の1番目と2番目をそれぞれ別の変数に代入します。

class FeaturePhone
  BUTTONS = {
    '1' => ['.', ',', '!', '?', ' '],
    '2' => %w[a b c],
    '3' => %w[d e f],
    '4' => %w[g h i],
    '5' => %w[j k l],
    '6' => %w[m n o],
    '7' => %w[p q r s],
    '8' => %w[t u v],
    '9' => %w[w x y z]
  }.freeze

  def digits_to_alphabet(digits_line)
    digits_line.scan(/(([1-9])\2*)/).map {|digits, digit|
      button_values = BUTTONS[digit]
      button_values[(digits.length - 1) % button_values.length]
    }.join
  end
end

そのほかの部分は前回の記事と一緒ですので、参考にどうぞ。

yucatio.hatenablog.com

出題者の伊藤淳一さんの書籍はこちら↓


ガラケー文字入力問題の解答

前回に引き続き、こちらの記事を見てみて、面白そうな問題がいくつかあったので、プログラミング初心者ではないですが解きました。

blog.jnito.com

ガラケー文字入力問題

英語のガラケーでは「2」キーを2回押すと「b」になり、「3」キーを3回押すと「f」になります。
ただし、この問題では特別ルールとして「0」で文字を確定させます。 たとえば、このプログラムに対して"440330555055506660"を入力すると、"hello"が返ってきます。

解答

class FeaturePhone
  BUTTONS = {
    '1' => ['.', ',', '!', '?', ' '],
    '2' => %w[a b c],
    '3' => %w[d e f],
    '4' => %w[g h i],
    '5' => %w[j k l],
    '6' => %w[m n o],
    '7' => %w[p q r s],
    '8' => %w[t u v],
    '9' => %w[w x y z]
  }.freeze

  def digits_to_alphabet(digits_line)
    digits_line.scan(/[1-9]+/).map {|digits|
      button_values = BUTTONS[digits[0]]
      button_values[(digits.length - 1) % button_values.length]
    }.join
  end
end

テストコード

describe FeaturePhone do
  describe '#digits_to_alphabet' do
    subject { phone.digits_to_alphabet(digits) }
    let(:phone){ FeaturePhone.new }

    context '2のボタンが1回押された場合' do
      let(:digits) { '20' }
      it { is_expected.to eq 'a'}
    end
    context '4, 3, 5, 6のボタンが2回か3回押された場合' do
      let(:digits) { '440330555055506660' }
      it { is_expected.to eq 'hello'}
    end
    context '1, 4, 3, 5, 6, 7, 9のボタンが1回から5回押された場合' do
      let(:digits) { '44033055505550666011011111090666077705550301110' }
      it { is_expected.to eq 'hello, world!'}
    end
    context '0が複数回登場し、5のボタンが8回押されたケースが含まれる場合' do
      let(:digits) { '000555555550000330000444000080000200004440000' }
      it { is_expected.to eq 'keitai'}
    end
  end
end

出題元のサイト ( Keitai Message | Aizu Online Judge )では、はじめに入力数とその数ぶんの入力列が与えられるという使用ですが、その部分は実装していません。

解説

始めに、クラスとメソッドと引数名を定義します。ガラケーは英語では"feature phone"というようです。これをクラス名にします。

メソッド名は数字の列をアルファベットに直すという意味で、digits_to_alphabetにしました。引数は、数字の列ということで、digits_lineにしました。

class FeaturePhone
  def digits_to_alphabet(digits_line)
    0
  end
end

テストを書きます。

require 'rspec'
require File.expand_path(File.dirname(__FILE__) + '/../feature_phone')

describe FeaturePhone do
  describe '#digits_to_alphabet' do
    subject { phone.digits_to_alphabet(digits_line) }
    let(:phone){ FeaturePhone.new }

    context '2のボタンが1回押された場合' do
      let(:digits_line) { '20' }
      it { is_expected.to eq 'a'}
    end
  end
end

テストを実行して落ちるのを確認します。

expected: "a"
     got: 0
# 中略
1 example, 1 failure, 0 passed

では、実装を進めていきます。

まず、0以外の数字が連続している部分を抜き出すことを考えてみます。今回は正規表現を使います。

0以外の数値は、正規表現[1-9]と書けます。正確にいうと、[1-9]は、1から9のうちの1文字を表します。1から9の文字の1回以上の繰り返しは、[1-9]+と書けます。この正規表現3555888888881144などにマッチします。同じ数宇の繰り返し以外にもマッチしますが、今回は、0と0で挟まれた部分は同じ数字になっているので、こちらの正規表現でも問題ないでしょう。同じ数字の繰り返しを表現したい場合はこちらの記事を参照してください。

yucatio.hatenablog.com

さて、アルファベットに変換する部分の繰り返しは[1-9]+で表現されることがわかったので、今度はマッチした部分を抜き出します。 RubyString#scan( scan (String) - Rubyリファレンス )メソッドを使えばよさそうです。このメソッドは、引数に与えられた正規表現にマッチする部分を繰り返し取り出します。

ここまでコードです。

class FeaturePhone
  def digits_to_alphabet(digits_line)
    digits_line.scan(/[1-9]+/)
  end
end

テスト結果です。

expected: "a"
     got: ["2"]
# 中略
1 example, 1 failure, 0 passed

配列の要素が1つだと動作が見せにくいので、ここでテストケースを追加しておきます。

describe FeaturePhone do
  describe '#digits_line' do
    subject { phone.digits_line(digits_line) }
    let(:phone){ FeaturePhone.new }

    context '2のボタンが1回押された場合' do
      let(:numbers) { '20' }
      it { is_expected.to eq 'a'}
    end
    context '4, 3, 5, 6のボタンが2回か3回押された場合' do
      let(:digits_line) { '440330555055506660' }
      it { is_expected.to eq 'hello'}
    end
  end
end

テストを実行すると、新しいテストも失敗しているのが分かります。ただ、連続する数値が配列の各要素にすることができています。

expected: "hello"
     got: ["44", "33", "555", "555", "666"]
# 中略
2 examples, 2 failures, 0 passed

この配列の各要素をそれぞれアルファベットに変換しましょう。

 ["44", "33", "555", "555", "666"]
    ↓     ↓      ↓      ↓      ↓
 [ "h",  "e",   "l",   "l",   "o"]

このような変換はmapを使うのでした。一旦mapをつなげてみましょう。中の実装は仮です。

class FeaturePhone
  def digits_to_alphabet(digits_line)
    digits_line.scan(/[1-9]+/).map {|digits|
      digits  # 仮実装
    }
  end
end

テストを実行して、前回と同じ出力になる事を確認してください。

次はどのように"555"を"l"に変換するか考えます。まず、どのボタンが何回押されたかを考えることにしましょう。"555"の場合は"5"のボタンが"3"回というふうにです。 どのボタンが押されたか、は1番目の文字を見ればよさそうです。1番目の文字は、配列と同じように[0]で取得することができるので、今回の場合はdigits[0]で取得することができます。また、何回押されたかは、String#lengthで取得できるので、上記のコードではdigits.lengthで取得することができます。

class FeaturePhone
  def digits_to_alphabet(digits_line)
    digits_line.scan(/[1-9]+/).map {|digits|
      digit = digits[0]
      length = digits.length
    }
  end
end

ここでガラケーのボタンを定義しましょう。キーを数字(文字列型)にして、値をアルファベットの配列にしました。ボタンは定数として定義しました。 1に対応するアルファベット(というか記号)はスペースを含むため、角カッコ[]を使用して定義していますが、それ以外は、パーセント記法(%w)を使って見やすくしています。最後にfreezeをつけて変更不能にしました。

class FeaturePhone
  BUTTONS = {
    '1' => ['.', ',', '!', '?', ' '],
    '2' => %w[a b c],
    '3' => %w[d e f],
    '4' => %w[g h i],
    '5' => %w[j k l],
    '6' => %w[m n o],
    '7' => %w[p q r s],
    '8' => %w[t u v],
    '9' => %w[w x y z]
  }.freeze

  def digits_to_alphabet(digits_line)
    digits_line.scan(/[1-9]+/).map {|digits|
      digit = digits[0]
      length = digits.length
    }
  end
end

まずは押されたボタンから、対応するアルファベットの配列を取得しましょう。

class FeaturePhone
  BUTTONS = {
    '1' => ['.', ',', '!', '?', ' '],
    '2' => %w[a b c],
    '3' => %w[d e f],
    '4' => %w[g h i],
    '5' => %w[j k l],
    '6' => %w[m n o],
    '7' => %w[p q r s],
    '8' => %w[t u v],
    '9' => %w[w x y z]
  }.freeze

  def digits_to_alphabet(digits_line)
    digits_line.scan(/[1-9]+/).map {|digits|
      digit = digits[0]
      length = digits.length
      button_values = BUTTONS[digit]  # 追加
    }
  end
end

テストを実行します。

expected: "hello"
     got: [["g", "h", "i"], ["d", "e", "f"], ["j", "k", "l"], ["j", "k", "l"], ["m", "n", "o"]]

だんだん完成に近づいてきましたね。

配列の中から1つアルファベットを選択すればよさそうです。選択にはlengthを使います。配列の添字は0から始まるので、数字列の長さ(length)から1を引く必要があります。

class FeaturePhone
  BUTTONS = {
    # 中略
  }.freeze

  def digits_to_alphabet(digits_line)
    digits_line.scan(/[1-9]+/).map {|digits|
      digit = digits[0]
      length = digits.length
      button_values = BUTTONS[digit]
      button_values[digits.length - 1]  # 追加
    }
  end
end

テスト結果です。

expected: "hello"
     got: ["h", "e", "l", "l", "o"]

あとは各配列の要素を結合すれば良いですね。Array.join( join (Array) - Rubyリファレンス )が使えます。

class FeaturePhone
  BUTTONS = {
    # 中略
  }.freeze

  def digits_to_alphabet(digits_line)
    digits_line.scan(/[1-9]+/).map {|digits|
      digit = digits[0]
      length = digits.length
      button_values = BUTTONS[digit]
      button_values[digits.length - 1]
    }.join  # 追加
  end
end

テスト結果です。

2 examples, 0 failures, 2 passed

テストがパスしました。

しかしここで終わりではありません。テストケースを追加します。

describe FeaturePhone do
  describe '#digits_to_alphabet' do
    subject { phone.digits_to_alphabet(digits) }
    let(:phone){ FeaturePhone.new }

   # 中略
   # 追加ここから
    context '1, 4, 3, 5, 6, 7, 9のボタンが1回から5回押された場合' do
      let(:digits) { '44033055505550666011011111090666077705550301110' }
      it { is_expected.to eq 'hello, world!'}
    end
    context '0が複数回登場し、5のボタンが8回押されたケースが含まれる場合' do
      let(:digits) { '000555555550000330000444000080000200004440000' }
      it { is_expected.to eq 'keitai'}
    end
    # 追加ここまで
  end
end

テストを実行します。

expected: "keitai"
     got: "eitai"

最後のテストが失敗してしまいました。5のボタンが8回押されているので、配列の範囲外を参照してしまいました。配列の長さより大きい回数押された場合は、ループするのでした。コードを変更します。button_values.lengthの剰余を取ればよいです。

def digits_to_alphabet(digits_line)
  BUTTONS = {
    # 中略
  }.freeze

  digits_line.scan(/[1-9]+/).map {|digits|
    digit = digits[0]
    length = digits.length
    button_values = BUTTONS[digit]
    button_values[(digits.length - 1) % button_values.length]  # 変更
  }.join
end

テストを実行します。

4 examples, 0 failures, 4 passed

テストが通りました。digitとlengthはいちいち変数に入れるまでもないと思ったので最終的には冒頭のコードとしました。

あとがき

はじめ、同じ数字の連続したものを抽出しようとして試行錯誤しようとして時間を無駄にしてしまいました。同じ数の連続のことは以下の記事にしました。

yucatio.hatenablog.com

プログラミングっぽい問題だったのでブログに書くのに緊張しましたが、シンプルに書けたと思います。

出題者の伊藤淳一さんの書籍はこちら↓


電話帳作成問題の解答

前回に引き続き、こちらの記事を見てみて、面白そうな問題がいくつかあったので、プログラミング初心者ではないですが解きました。

blog.jnito.com

電話帳作成問題

カタカナ文字列の配列を渡すと、ア段の音別にグループ分けした配列を返すプログラムを作成する問題です。

# INPUT
['キシモト', 'イトウ', 'ババ', 'カネダ', 'ワダ', 'ハマダ']

# OUTPUT 
[ ['ア', ['イトウ']], ['カ', ['カネダ', 'キシモト']], ['ハ', ['ハマダ', 'ババ']], ['ワ', ['ワダ']] ]

詳しい仕様はこちらにあります。

https://github.com/JunichiIto/name-index

解答

class NameIndex
  NAME_INDEX_HASH = {
      '' => %w(ア イ ウ エ オ ヴ),
      '' => %w(カ キ ク ケ コ ガ ギ グ ゲ ゴ),
      '' => %w(サ シ ス セ ソ ザ ジ ズ ゼ ゾ),
      '' => %w(タ チ ツ テ ト ダ ヂ ヅ デ ド),
      '' => %w(ナ ニ ヌ ネ ノ),
      '' => %w(ハ ヒ フ ヘ ホ バ ビ ブ ベ ボ パ ピ プ ペ ポ),
      '' => %w(マ ミ ム メ モ),
      '' => %w(ヤ ユ ヨ),
      '' => %w(ラ リ ル レ ロ),
      '' => %w(ワ ヲ ン)
  }.freeze

  def self.create_index(names)
    names.sort.group_by {|name|
      NAME_INDEX_HASH.find(['Others']) {|_, value| value.include?(name[0])}.first
    }.to_a
  end
end

テストです。

describe NameIndex do
  describe '#create_index' do
    specify {expect(NameIndex.create_index([])).to eq []}
    specify {expect(NameIndex.create_index(['キシモト', 'イトウ', 'ババ', 'カネダ', 'ワダ', 'ハマダ'])).to eq [['', ['イトウ']], ['', ['カネダ', 'キシモト']], ['', ['ハマダ', 'ババ']], ['', ['ワダ']]]}
    specify {expect(NameIndex.create_index(['サトウ', 'スズキ', 'タカハシ', 'イケガミ', 'アラキ', 'デグチ', 'ヌマタ'])).to eq [['', ['アラキ', 'イケガミ']], ['', ['サトウ', 'スズキ']], ['', ['タカハシ', 'デグチ']], ['', ['ヌマタ']]]}
  end
end

元記事では、解答を見ないで解くこととなっていましたが、ある程度自分で考えた後に解答を見てから解きました。それでもオリジナルなコードになったと思います。

解説

問題を見たときに以下の4つを考えました。

  • 名前の先頭のカタカナを取り出す
  • カタカナが属する行のア段のカタカナを求める(ただし濁音、半濁音は清音にする)(例: キ → カ, バ →ハ)
  • ア段のカタカナでグループ化する
  • ソートする

これらをクリアにしていけば解けそうです。

  • 名前の先頭のカタカナを取り出すにはname[0]のように、[0]を使用します。
  • カタカナが属する行のア段のカタカナを求める を行う標準のメソッドは存在しないので、自身で実装する必要があります。
  • ア段のカタカナでグループ化する はArray.group_by( group_by (Enumerable) - Rubyリファレンス )を使用します。
  • ソートする はArray.sort( sort (Enumerable) - Rubyリファレンス )が使用できます。

まず先頭のカタカナを取り出すところまで実装しましょう。

class NameIndex
  def self.create_index(names)
    names.map { |name|
      name[0]
    }
  end
end

テストは一旦以下の2ケースのみ書いておくことにします。また、以下実行結果として書くのは2つ目のテストケースについてです。

describe NameIndex do
  describe '#create_index' do
    specify { expect(NameIndex.create_index([])).to eq [] }
    specify { expect(NameIndex.create_index(['キシモト', 'イトウ', 'ババ', 'カネダ', 'ワダ', 'ハマダ'])).to eq [ ['', ['イトウ']], ['', ['カネダ', 'キシモト']], ['', ['ハマダ', 'ババ']], ['', ['ワダ']] ] }
  end
end

実行結果です。先頭の文字列を取り出すことができました。

["", "", "", "", "", ""]

次にア段の音に変換していきます。範囲オブジェクトを使用する方法もありますが、 50音表の順番で並んでいるわけではないので、注意して使用する必要があります。( 片仮名 (Unicodeのブロック) - Wikipedia ) (カキクケコガギグゲゴと並ぶわけでなく、カガキギクグケゲコゴと並んでいます。)

今回はわかりやすさのため、名前の先頭に使用できるカタカナを全て書き出しました。(ヲ ンから始まる名前があるかどうか微妙ですが)

どのカタカナがどのア段のカタカナに対応するか求めます。まず全てのカタカナをキー、対応するア段のカタカナを値とする方法が思いつきます。

NAME_INDEX_HASH = {'' => '', '' => '', '' => '', ..., }

これは確実な方法ですが、コードが長くなるため今回は採用しませんでした。

代わりに、ア段のカタカナをキー、そのア段のカタカナが含むカタカナを値とするハッシュを定義しました。この場合だとハッシュの逆引きが必要なのですが、コードがわかりやすくなります。

  NAME_INDEX_HASH = {
      '' => %w(ア イ ウ エ オ ヴ),
      '' => %w(カ キ ク ケ コ ガ ギ グ ゲ ゴ),
      '' => %w(サ シ ス セ ソ ザ ジ ズ ゼ ゾ),
      '' => %w(タ チ ツ テ ト ダ ヂ ヅ デ ド),
      '' => %w(ナ ニ ヌ ネ ノ),
      '' => %w(ハ ヒ フ ヘ ホ バ ビ ブ ベ ボ パ ピ プ ペ ポ),
      '' => %w(マ ミ ム メ モ),
      '' => %w(ヤ ユ ヨ),
      '' => %w(ラ リ ル レ ロ),
      '' => %w(ワ ヲ ン)
  }.freeze

ス→サのような変換を行うには、"ス"が含まれる配列を見つけ、それのキーを取得することで実現できます。 Enumerable#find( find, detect (Enumerable) - Rubyリファレンス ) を使用すると、ブロックが最初に真になったときのキーと値を返します。名前の始めの文字が見つからなかったときの挙動は特に問題には示されていなかったので、"Other"というキーを返すようにしました。

class NameIndex
  NAME_INDEX_HASH = {
      '' => %w(ア イ ウ エ オ ヴ),
      '' => %w(カ キ ク ケ コ ガ ギ グ ゲ ゴ),
      '' => %w(サ シ ス セ ソ ザ ジ ズ ゼ ゾ),
      '' => %w(タ チ ツ テ ト ダ ヂ ヅ デ ド),
      '' => %w(ナ ニ ヌ ネ ノ),
      '' => %w(ハ ヒ フ ヘ ホ バ ビ ブ ベ ボ パ ピ プ ペ ポ),
      '' => %w(マ ミ ム メ モ),
      '' => %w(ヤ ユ ヨ),
      '' => %w(ラ リ ル レ ロ),
      '' => %w(ワ ヲ ン)
  }.freeze

  def self.create_index(names)
    names.map { |name|
      NAME_INDEX_HASH.find(['Others']) {|_, value| value.include?(name[0])}  # 変更
    }
  end
end

実行結果です。名前の最初のカタカナが含まれている配列と対応するキーが取り出せています。

[
  ["", ["", "", "", "", "", "", "", "", "", ""]],
  ["", ["", "", "", "", "", ""]],
  ["", ["", "", "", "", "", "", "", "", "", "", "", "", "", "", ""]],
  ["", ["", "", "", "", "", "", "", "", "", ""]],
  ["", ["", "", ""]],
  ["", ["", "", "", "", "", "", "", "", "", "", "", "", "", "", ""]]
]

必要なのはキーだけなんで、配列の最初の要素を取り出します。Array#first ( first (Array) - Rubyリファレンス )を使用します。

class NameIndex
  NAME_INDEX_HASH = {
      '' => %w(ア イ ウ エ オ ヴ),
      '' => %w(カ キ ク ケ コ ガ ギ グ ゲ ゴ),
      '' => %w(サ シ ス セ ソ ザ ジ ズ ゼ ゾ),
      '' => %w(タ チ ツ テ ト ダ ヂ ヅ デ ド),
      '' => %w(ナ ニ ヌ ネ ノ),
      '' => %w(ハ ヒ フ ヘ ホ バ ビ ブ ベ ボ パ ピ プ ペ ポ),
      '' => %w(マ ミ ム メ モ),
      '' => %w(ヤ ユ ヨ),
      '' => %w(ラ リ ル レ ロ),
      '' => %w(ワ ヲ ン)
  }.freeze

  def self.create_index(names)
    names.map { |name|
      NAME_INDEX_HASH.find(['Others']) {|_, value| value.include?(name[0])}.first  # 変更 
    }
  end
end

実行結果です。ア段のカタカナを取り出すことができました。

["", "", "", "", "", ""]

次に、ア段のカタカナごとに名前をグループ化します。Array#group_byを使うのでした。

class NameIndex
  # 中略

  def self.create_index(names)
    names.group_by { |name|  # 変更
      NAME_INDEX_HASH.find(['Others']) {|_, value| value.include?(name[0])}.first 
    }
  end
end

実行結果です。ア段ごとにグループ化されました。

{
  ""=>["キシモト", "カネダ"],
  ""=>["イトウ"],
  ""=>["ババ", "ハマダ"],
  ""=>["ワダ"]
}

個人的には、データ形式は上記のように{ア段のカタカナ => 含まれる名前の配列}がよいと思うのですが、問題ではア段のカタカナを0番目の要素、名前の配列を1番目の要素とする配列の配列を作成することになっているので変換します。この変換はHash#to_a( to_a (Hash) - Rubyリファレンス )で行うことができます。

class NameIndex
  # 中略

  def self.create_index(names)
    names.group_by { |name|
      NAME_INDEX_HASH.find(['Others']) {|_, value| value.include?(name[0])}.first
    }.to_a  # 変更
  end
end

実行結果です。配列に変更できました。

[
  ["", ["キシモト", "カネダ"]],
  ["", ["イトウ"]],
  ["", ["ババ", "ハマダ"]],
  ["", ["ワダ"]]
]

最後にソートをかけます。ア段のカタカナ、それに対応する名前の配列それぞれにソートをかける必要があります。今回は最後にソートかけるのではなく、最初にソートをかけるように変更します。それによって自然に最終的な配列もソートされます。

1つ気にしておくべきことは、Rubyの(というよりほぼ全てのプログラミング言語での)ソートは文字コード順に並ぶということです。例えば、['ゴトウ', 'コニシ', 'コバヤシ']は、国語辞典ではこの順に並びますが、Rubyでは['コニシ', 'コバヤシ', 'ゴトウ']の順に並びます。もし国語辞典と同じ順番で並べたい場合はArray#sortに比較を行うブロックを渡し独自のソートを行う必要があります。 今回は問題文中の例として示されているケース(['ハ', ['ハマダ', 'ババ']])から、文字コード順で並び替えることが類推できます(あと初心者向けと銘打っていることからも)。コードは以下になります。

class NameIndex
  # 中略

  def self.create_index(names)
    names.sort.group_by { |name|  # 変更
      NAME_INDEX_HASH.find(['Others']) {|_, value| value.include?(name[0])}.first 
    }.to_a
  end
end

実行結果です。期待通りの結果が得られました。

[
  ["", ["イトウ"]],
  ["", ["カネダ", "キシモト"]],
  ["", ["ハマダ", "ババ"]],
  ["", ["ワダ"]]
]

あとがき

こちらも意外と考えることの多い問題でした。自分が初心者の時に解けただろうか。全てのことを一度にやろうとするのではなく、問題を細かいステップに分けるのがカギなのですが、初心者の頃は全てのことを一度にやりがちでした。

名前の先頭のカタカナからア段のカタカナを求める部分がキモなのですが、その部分は今回はわりと読みやすいコードになったかとは思います。

テストが書きにくかったです。数ケースはコードが書いた人が書けばよいと思いますが、チームで働いてる場合はテストケースは他の人が追加で書いた方がよい気がします(特に今回の場合は)。考慮漏れが発生しやすいプログラミング問題だと感じました。

テストケースの名字は名字由来netを参考にしました。

出題者の伊藤淳一さんの書籍はこちら↓


国民の祝日.csv パースプログラムの解答

前回に引き続き、こちらの記事を見てみて、面白そうな問題がいくつかあったので、プログラミング初心者ではないですが解きました。

blog.jnito.com

国民の祝日.csv パースプログラム

その昔、「国民の祝日.csv」という扱いづらいCSVが話題になっていました。
具体的にはこんなCSVファイルです↓

平成28年(2016年),,平成29年(2017年),,平成30年(2018年),
名称,月日,名称,月日,名称,月日
元日,2016/1/1,元日,2017/1/1,元日,2018/1/1
成人の日,2016/1/11,成人の日,2017/1/9,成人の日,2018/1/8
建国記念の日,2016/2/11,建国記念の日,2017/2/11,建国記念の日,2018/2/11
春分の日,2016/3/20,春分の日,2017/3/20,春分の日,2018/3/21
# 中略
文化の日,2016/11/3,文化の日,2017/11/3,文化の日,2018/11/3
勤労感謝の日,2016/11/23,勤労感謝の日,2017/11/23,勤労感謝の日,2018/11/23
天皇誕生日,2016/12/23,天皇誕生日,2017/12/23,天皇誕生日,2018/12/23
,,,,,
月日は表示するアプリケーションによって形式が異なる場合があります。,,,,,

これをいい感じにパースして、以下のようなデータ構造(Rubyのハッシュオブジェクト)に変換しよう、というプログラミング問題です。

{
  2016 => {
    # 実際のキーは文字列ではなくDateオブジェクト
    '2016/01/01' => '元日',
    '2016/01/11' => '成人の日',
    # ...
    '2016/11/23' => '勤労感謝の日',
    '2016/12/23' => '天皇誕生日',
  },
  2017 => {
    '2017/01/01' => '元日',
    '2017/01/09' => '成人の日',
    # ...
    '2017/11/23' => '勤労感謝の日',
    '2017/12/23' => '天皇誕生日',
  },
  2018 => {
    '2018/01/01' => '元日',
    '2018/01/08' => '成人の日',
    # ...
    '2018/11/23' => '勤労感謝の日',
    '2018/12/23' => '天皇誕生日',
  },
}

解答

class SyukujitsuParser
  CSV_PATH = File.expand_path('../../resource/syukujitsu.csv', __FILE__)

  def self.parse(csv_path = CSV_PATH)
    self.new.parse(csv_path)
  end

  def parse(csv_path)
    IO.readlines(csv_path, chomp: true, encoding: 'Shift_JIS:UTF-8')
          .join(',')
          .scan(%r!([^,]+),([0-9]{4})/([0-9]{1,2})/([0-9]{1,2})!)
          .group_by{|_, year| year.to_i}
          .map { |year, holiday_arr|
            holiday_hash = holiday_arr.map {|holiday, *date|
              [Date.new(*date.map(&:to_i)), holiday]
            }.to_h
            [year, holiday_hash]
          }.to_h
  end
end

RSpecは本家のgithubに記載されているので割愛します。 https://github.com/JunichiIto/parse-syukujitsu/blob/master/test/syukujitsu_parser_test.rb

解説

RubyCSVファイルを読み込むには、CSVクラスを使用するのが定石のようですが、CSVクラスを知らなかったので、通常のファイルとして読み込みました。

まず、どのように祝日名と日付部分を取り出すか考えます。公式ドキュメントのString#scan( scan (String) - Rubyリファレンス )の例に以下のようなものがあるので、同様の処理を行います。

s = "Hokkaido:Sapporo, Aomori:Aomori, Iwate:Morioka"
p s.scan(/(\w+):(\w+)/)
# => [["Hokkaido", "Sapporo"], ["Aomori", "Aomori"], ["Iwate", "Morioka"]]

祝日名と日付の部分は、

(平仮名か漢字),(年/月/日)

というペアになっており、上記のscanの例と同じようになっています。

正規表現にしていきます。まず、(平仮名か漢字)の部分ですが、これを満たす正規表現が無くはないようですが、一筋縄ではいかないようなので今回は別の方法にします。カンマより前のすべての(カンマでない)文字を取れば良いので、[^,]+という正規表現で表します。

日付部分の正規表現

[0-9]{4}/[0-9]{1,2}/[0-9]{1,2}

にします。ここまでで、マッチさせる正規表現は以下のようになります。

([^,]),([0-9]{4}/[0-9]{1,2}/[0-9]{1,2})

先々の過程で、日付の年月日が一つの変数に入っているよりも、年・月・日で別々の方が扱いやすいので、カッコでのキャプチャの位置を変更します。

([^,]+),([0-9]{4})/([0-9]{1,2})/([0-9]{1,2})

それでは、ファイルを読み込んでString#scanで一致した部分を抜き出していきます。 ファイルは1行ごとに読むことが多いのですが、今回は一度にすべての行を読み込みます。IO.readlinesは指定されたファイルを全て読み込んで、その各行を要素としてもつ配列を返すメソッドです。 ( singleton method IO.readlines (Ruby 2.6.0) )

class SyukujitsuParser
  CSV_PATH = File.expand_path('../../resource/syukujitsu.csv', __FILE__)

  def self.parse(csv_path = CSV_PATH)
    self.new.parse(csv_path)
  end

  def parse(csv_path)
    IO.readlines(csv_path, chomp: true, encoding: "Shift_JIS:UTF-8")
  end
end

readlinesの引数のchomp: trueは各行の末尾の改行を取り除くオプションです。今回の場合、指定しなくても出力は変わらないのですが、あると将来的なバグの温床になるので取り除いておきます。読み込むCSVファイルのエンコーディングがShift-JIS、内部的にはUTFで扱いたいので、encoding: "Shift_JIS:UTF-8"を指定しています。

実行結果です。

[
  "平成28年(2016年),,平成29年(2017年),,平成30年(2018年),", 
  "名称,月日,名称,月日,名称,月日", 
  "元日,2016/1/1,元日,2017/1/1,元日,2018/1/1", 
  "成人の日,2016/1/11,成人の日,2017/1/9,成人の日,2018/1/8", 
  "建国記念の日,2016/2/11,建国記念の日,2017/2/11,建国記念の日,2018/2/11", 
  "春分の日,2016/3/20,春分の日,2017/3/20,春分の日,2018/3/21", 
  "昭和の日,2016/4/29,昭和の日,2017/4/29,昭和の日,2018/4/29", 
  # 中略
  "秋分の日,2016/9/22,秋分の日,2017/9/23,秋分の日,2018/9/23", 
  "体育の日,2016/10/10,体育の日,2017/10/9,体育の日,2018/10/8", 
  "文化の日,2016/11/3,文化の日,2017/11/3,文化の日,2018/11/3", 
  "勤労感謝の日,2016/11/23,勤労感謝の日,2017/11/23,勤労感謝の日,2018/11/23", 
  "天皇誕生日,2016/12/23,天皇誕生日,2017/12/23,天皇誕生日,2018/12/23", 
  ",,,,,", 
  "月日は表示するアプリケーションによって形式が異なる場合があります。,,,,,"
]

この各配列の要素に対してscanをかけてもよいのですが、今回は簡単のためすべての行をカンマで結合してからscanをかけます。CSVの各行を1行にまとめるのは普通はやらないのですが、今回は元となるデータの形式がイマイチなのでこのようなことをしてもしょうがないという気持ちです。配列の各要素をjoinで連結します。引数にカンマを与えて、カンマ区切りで結合します。結果的に1行のCSVが出力されます。

class SyukujitsuParser
  # 中略

  def parse(csv_path)
    IO.readlines(csv_path, chomp: true, encoding: "Shift_JIS:UTF-8")
          .join(',')  # 追加
  end
end

実行結果です。1行にまとまりました。

"平成28年(2016年),,平成29年(2017年),,平成30年(2018年),,名称,月日,名称,月日,名称,月日,元日,2016/1/1,元日,2017/1/1,元日,2018/1/1,成人の日,2016/1/11,成人の日,2017/1/9,成人の日,2018/1/8,建国記念の日,2016/2/11,建国記念の日,2017/2/11,建国記念の日,2018/2/11,春分の日,2016/3/20,春分の日,2017/3/20,春分の日,2018/3/21,昭和の日,2016/4/29,昭和の日,2017/4/29,昭和の日,2018/4/29,(中略)2018/9/17,秋分の日,2016/9/22,秋分の日,2017/9/23,秋分の日,2018/9/23,体育の日,2016/10/10,体育の日,2017/10/9,体育の日,2018/10/8,文化の日,2016/11/3,文化の日,2017/11/3,文化の日,2018/11/3,勤労感謝の日,2016/11/23,勤労感謝の日,2017/11/23,勤労感謝の日,2018/11/23,天皇誕生日,2016/12/23,天皇誕生日,2017/12/23,天皇誕生日,2018/12/23,,,,,,,月日は表示するアプリケーションによって形式が異なる場合があります。,,,,,"

これに対して、上で作成した正規表現で祝日名と日付を抽出します。scanの引数には正規表現オブジェクトを渡します。今回は%rを使用して正規表現オブジェクトを作成します。マッチしたい部分に記号がたくさん含まれるので!正規表現を囲みます。

class SyukujitsuParser
  # 中略

  def parse(csv_path)
    IO.readlines(csv_path, chomp: true, encoding: "Shift_JIS:UTF-8")
          .join(',')
          .scan(%r!([^,]+),([0-9]{4})/([0-9]{1,2})/([0-9]{1,2})!)  # 追加
  end
end

実行結果です。[祝日名, 年, 月, 日]の配列が作成されました。

[
  ["元日", "2016", "1", "1"], 
  ["元日", "2017", "1", "1"], 
  ["元日", "2018", "1", "1"], 
  ["成人の日", "2016", "1", "11"], 
  ["成人の日", "2017", "1", "9"], 
  ["成人の日", "2018", "1", "8"], 
  ["建国記念の日", "2016", "2", "11"], 
  ["建国記念の日", "2017", "2", "11"], 
  # 中略
  ["文化の日", "2017", "11", "3"], 
  ["文化の日", "2018", "11", "3"], 
  ["勤労感謝の日", "2016", "11", "23"], 
  ["勤労感謝の日", "2017", "11", "23"], 
  ["勤労感謝の日", "2018", "11", "23"], 
  ["天皇誕生日", "2016", "12", "23"], 
  ["天皇誕生日", "2017", "12", "23"], 
  ["天皇誕生日", "2018", "12", "23"]
]

ここから年でグルーピングしてみましょう。group_byを使えば簡単です。年は配列の2番目なので、それを整数に変換したものでグルーピングします。

class SyukujitsuParser
  # 中略

  def parse(csv_path)
    IO.readlines(csv_path, chomp: true, encoding: 'Shift_JIS:UTF-8')
          .join(',')
          .scan(%r!([^,]+),([0-9]{4})/([0-9]{1,2})/([0-9]{1,2})!)
          .group_by{|_, year| year.to_i}  # 追加
  end
end

実行結果です。

{
  2016=>[
    ["元日", "2016", "1", "1"], 
    ["成人の日", "2016", "1", "11"],
    # 中略
    ["勤労感謝の日", "2016", "11", "23"], 
    ["天皇誕生日", "2016", "12", "23"]
  ], 
  2017=>[
    ["元日", "2017", "1", "1"], 
    ["成人の日", "2017", "1", "9"], 
    # 中略
    ["勤労感謝の日", "2017", "11", "23"], 
    ["天皇誕生日", "2017", "12", "23"]
  ], 
  2018=>[
    ["元日", "2018", "1", "1"], ["成人の日", "2018", "1", "8"], 
    ["建国記念の日", "2018", "2", "11"],
    # 中略
    ["勤労感謝の日", "2018", "11", "23"],
    ["天皇誕生日", "2018", "12", "23"]
  ]
}

だいぶ完成に近くなってきました。完成形と違うところは、ハッシュの各値が配列になっていることです。

まず、ハッシュの値(value)を変換する方法として、以下のイディオムがあります。

hash.map {|key, value|
  [key, convert(value)]
}.to_h

こちらを適用します。中の実装は仮のものにしておきます。

class SyukujitsuParser
  # 中略

  def parse(csv_path)
    IO.readlines(csv_path, chomp: true, encoding: 'Shift_JIS:UTF-8')
          .join(',')
          .scan(%r!([^,]+),([0-9]{4})/([0-9]{1,2})/([0-9]{1,2})!)
          .group_by{|_, year| year.to_i}
          .map { |year, holiday_arr|  # 追加
            [year, holiday_arr]  # 仮実装
          }.to_h  # 追加
  end
end

実行すると1つ前で実行したのと同じものになります。

次に

[
  ["元日", "2016", "1", "1"], 
  ["成人の日", "2016", "1", "11"],
  # 中略
  ["勤労感謝の日", "2016", "11", "23"], 
  ["天皇誕生日", "2016", "12", "23"]
]

{
  Date.parse('2016/01/01') => '元日',
  Date.parse('2016/01/11') => '成人の日',
  # 中略
  Date.parse('2016/11/23') => '勤労感謝の日',
  Date.parse('2016/12/23') => '天皇誕生日',
}

に変換します。配列からハッシュを作成するには、Array.to_hを使用します。( instance method Array#to_h (Ruby 2.6.0) ) このメソッドは[key, value]のペアの配列を、{key => value}のハッシュに変換します。

to_hで変換する前の配列は

[
  [Date.parse('2016/01/01'), '元日'],
  [Date.parse('2016/01/11'), '成人の日'],
  # 中略
  [Date.parse('2016/11/23'), '勤労感謝の日'],
  [Date.parse('2016/12/23'), '天皇誕生日'],
]

となっている必要があります。

年ごとの配列に対してmapを使用します。mapで受け取る引数は、["元日", "2016", "1", "1"]の形式です。多重代入を使用して、holiday, *date = ["元日", "2016", "1", "1"]のように受け取っています。 この場合だと、holiday = "元日", date = ["2016", "1", "1"]のように代入されます。

dateをDate( class Date (Ruby 2.6.0) ) オブジェクトに変換します。Date.newで作成するので、 はじめにdateの各日付を数値に変換します(date.map(&:to_i))これをDate.newの引数にします。配列展開を使います。Date.new(*[2016, 1, 1])と書くと、Date.new(2016, 1, 1)と書いたのと同じになります。

class SyukujitsuParser
  # 中略

  def parse(csv_path)
    IO.readlines(csv_path, chomp: true, encoding: 'Shift_JIS:UTF-8')
          .join(',')
          .scan(%r!([^,]+),([0-9]{4})/([0-9]{1,2})/([0-9]{1,2})!)
          .group_by{|_, year| year.to_i}
          .map { |year, holiday_arr|
            holiday_hash = holiday_arr.map {|holiday, *date|  # 追加
              [Date.new(*date.map(&:to_i)), holiday]  # 追加
            }.to_h  # 追加
            [year, holiday_hash]  # 変更
          }.to_h
  end
end

これで完成です。書いている最中はプログラムの意図を理解していますが、1週間くらいすると各変数やメソッドの使用意図を忘れそうなので、適宜コメントを入れた方がよさそうです。

あとがき

元のCSVがひどいのでコードも力技になったなあと思います。

初心者向けの問題と書かれていながら、なかなか考えることの多い問題でした。

★前回の記事

yucatio.hatenablog.com

出題者の伊藤淳一さんの書籍はこちら↓


ボーナスドリンク問題の解答

こちらの記事を見てみて、面白そうな問題がいくつかあったので、プログラミング初心者ではないですが解きました。

blog.jnito.com

ボーナスドリンク問題

「ある駄菓子屋で飲み物を買うと、空き瓶3本で新しい飲み物を1本プレゼントしてくれる。最初に100本購入した場合、トータルで何本飲めるか」

解答

コード

class BonusDrink
  def self.total_count_for(amount)
    return 0 if amount.zero?

    last_amount = (amount - 1) % 2 + 1
    (3 * amount - last_amount) / 2
  end
end

テストコードはこちら。

require 'rspec'
require File.expand_path(File.dirname(__FILE__) + '/../bonus_drink')

describe BonusDrink do
  specify { expect(BonusDrink.total_count_for(0)).to eq 0 }
  specify { expect(BonusDrink.total_count_for(1)).to eq 1 }
  specify { expect(BonusDrink.total_count_for(2)).to eq 2 }
  specify { expect(BonusDrink.total_count_for(3)).to eq 4 }
  specify { expect(BonusDrink.total_count_for(11)).to eq 16 }
end

解説

最初にN本買ってきたときに、最終的に飲める本数をf(N)とします。

f(1) = 1
f(2) = 2
f(3) = 4
f(11) = 16

です。

例として問題にある通り、今回はN=100の場合を考えます。

f(100) = 最初に100本買ってきたときに最終的に飲める本数

まず、100本の中身が入っているジュースがあります。

f:id:yucatio:20190509234327p:plain

3本飲んでください。

f:id:yucatio:20190509234406p:plain

さっそく3本だけ取り替えに行きましょう。1本もらいました。

手元にあるジュースの本数を確認しましょう。 残ってる97本と取り替えた1本で98本ですね。 98本で飲める量はf(98)で表されるのでした。

f:id:yucatio:20190509234512p:plain

すでに3本のジュースを飲んでいるので、 100本のジュースで飲める量は、

f(100) = 3 + f(98)

で表すことができます。

同様に98本のうち3本を飲んで新しいジュース1本と交換します。

f:id:yucatio:20190509234751p:plain

手元には96本のジュースがあります。すでに6本のジュースを飲んでいるので100本で最終的に飲める本数は以下の式で表すことができます。

f(100) = 3 + 3 + f(96)

お気付きのように、ジュースを3本飲むごとに手持ちのジュースが2本減ります。

繰り返していきましょう。

f(100) = 3 + 3 + 3 + f(94)
f(100) = 3 + 3 + 3 + 3 + f(92)
f(100) = 3 + 3 + 3 + 3 + 3 + f(90)
# 中略
f(100) = 3 + 3 + 3 + 3 + 3 + ... + 3 + 3 + 3 + f(6)
f(100) = 3 + 3 + 3 + 3 + 3 + ... + 3 + 3 + 3 + 3 + f(4)
f(100) = 3 + 3 + 3 + 3 + 3 + ... + 3 + 3 + 3 + 3 + 3 + f(2)

ここで手持ちのジュースが2本になってしまいました。 最後にこれを飲みましょう。

f(100) = 3 + 3 + 3 + 3 + 3 + ... + 3 + 3 + 3 + 3 + 3 + 2

では、上の式で3を何個足したか数えましょう。 100から2まで、2ずつ引いていったので、

(100 - 2) / 2 = 49

49個の3を足しています。

なので、上記の式は以下のように書き換えられます。

f(100) = 3 * 49 + 2
       = 149

最初に100本買った場合に最終的に飲める本数は149本であることがわかりました。(プログラムで算出するはずなのに、算数的に解いてしまった)

本数がNの時は上記と同様に

f(N) = 3 * (N-2)/2 + 2

となります。しかし、2本ずつ引いていって最後に2本になるのは偶数の時のみです。 奇数の場合を考えてみましょう。例として97本の場合を考えます。

考え方は同様で、3本飲むと、2本手持ちが減ります。これを数式にしていきます。

f(97) = 3 + f(95)
f(97) = 3 + 3 + f(91)
f(97) = 3 + 3 + 3 + f(89)
f(97) = 3 + 3 + 3 + 3 + f(87)
# 中略
f(97) = 3 + 3 + 3 + 3 + 3 + ... + 3 + 3 + 3 + f(5)
f(97) = 3 + 3 + 3 + 3 + 3 + ... + 3 + 3 + 3 + 3 + f(3)
f(97) = 3 + 3 + 3 + 3 + 3 + ... + 3 + 3 + 3 + 3 + 3 + f(1)

このようになり、最後に手持ちの1本を飲んで終わりです。

f(97) = 3 + 3 + 3 + 3 + 3 + ... + 3 + 3 + 3 + 3 + 3 + 1

97から1まで2ずつ引いたので、足した3の数は、

(97 - 1)/2 = 48

48個の3を足したので、

f(97) = 3 * 48 + 1
      = 145

最初に97本買った場合に最終的に飲める本数は145本であることがわかりました。

本数がNかつNが奇数の時、最終的に飲めるジュースの本数は

f(N) = 3 * (N-1)/2 + 1

になります。

ここまでをまとめると、

f(N) = 3 * (N-2)/2 + 2 (Nが偶数のとき)
       3 * (N-1)/2 + 1 (Nが奇数のとき)

です。

コードにしてみましょう。メソッドの最初にamountが0の時に0を返す処理を書いています。 amount % 2はamountを2で割ったときの余りという意味です。

class BonusDrink
  def self.total_count_for(amount)
    return 0 if amount.zero?

    case amount % 2
    when 0
      3 * (amount - 2) / 2 + 2
    when 1
      3 * (amount - 1) / 2 + 1
    end
  end
end

2つの式は似ているので、工夫すればまとめられそうです。 2つの式の違いは、amountから引いている数と最後に足している数です。この数は最後に手元に残る瓶の本数でした。

奇数と偶数で値が異なるので、

last_amount = amount % 2 === 0 ? 2 : 1
3 * (amount - last_amount) / 2 + last_amount

のようなコードを書きたくなりますが、あまりよい方法とは言えません。"3本で1本プレゼント"が"4本で1本プレゼント"になったときに対応できなくなってしまいます。

基本的には剰余(割り算の余り)を考えればよいのですが、割り切れる時には2にします。

last_amount = amount % 2 === 0 ? 2 : amount % 2
3 * (amount - last_amount) / 2 + last_amount

3項演算子を使わなくても、一旦1を引いて剰余を求めてから1を足すと同様の結果が得られます。

last_amount = (amount - 1 ) % 2 + 1
3 * (amount - last_amount) / 2 + last_amount

式を整理して、最終的にこちらのコードになります。

class BonusDrink
  def self.total_count_for(amount)
    return 0 if amount.zero?

    last_amount = (amount - 1 ) % 2 + 1
    (3 * amount - last_amount) / 2
  end
end

完成です!

あとがき

最初に再帰アルゴリズムを思いついたのですが、最終的に四則演算と剰余だけで求めることができました。 試行錯誤できて楽しい問題でした。

ちなみに5本で1本交換の場合は、以下のようになります。

class BonusDrink
  def self.total_count_for(amount)
    return 0 if amount.zero?

    last_amount = (amount - 1 ) % 4 + 1
    (5 * amount - last_amount) / 4
  end
end

算数としてはこれで答えは出るのですが、プログラミングとしては整除を使用すればもう少し短く書けるかもと思いました。

こちらもどうぞ

yucatio.hatenablog.com

[商品価格に関しましては、リンクが作成された時点と現時点で情報が変更されている場合がございます。]

面白くて眠れなくなる数学 (PHP文庫) [ 桜井進 ]
価格:691円(税込、送料無料) (2019/2/8時点)


JavaScriptのnew Array(n)をmapしたいとき fillをはさむ理由

経緯

配列をオブジェクトで初期化したい場合、

new Array(3).fill({foo: "ふう", bar:"ばあ"})

というコードだと、全てのインデックスが同じオブジェクトを指してしまうので、 調べたら

new Array(3).fill().map(() => ({foo: "ふう", bar: "ばあ"}))

という方法が出てきたので試したらうまく行きました。

疑問

このfill()要らなくない?new Array(3).map(() => ({foo: "ふう", bar: "ばあ"})) って書けばよさそうな気がします。

答え

fill()は必要。

理由はここに書いてありますが、英語なので日本語で&自分で実行しながら書いていきます。

itnext.io

まず、JavaScriptの配列は、実質的には数値をキーとしたオブジェクトです。

例えば、以下のように配列を初期化します。

const array = ['りんご', 'みかん', 'バナナ']

console.log("array", array)

これを実行すると以下のようになります。

f:id:yucatio:20190407105938p:plain

{
  0: "りんご"
,
  1: "みかん"
,
  2: "バナナ"
,
  length: 3

}

上記配列はこのオブジェクトを宣言したのと同じになります。

さて、今度は今度はArrayのコンストラクタを使った場合をみてみます。

const array = new Array(3)

console.log("array", array)

実行結果です。

f:id:yucatio:20190407110103p:plain

{
  length: 3

}

この場合はlengthのみ含まれているオブジェクトになっています。数値のキーはありません。

コンストラクタで作成された配列に対して、map()を呼び出してみます。

const array = new Array(3).map(() => ({foo: "ふう", bar: "ばあ"}))

console.log("array", array)

実行結果です。

f:id:yucatio:20190407110103p:plain

{
  length: 3

}

何も設定されていません。mapに渡したコールバックが呼ばれていないことがわかります。

公式ドキュメントのArray.prototype.map()のページには、下記のように書いてあります。

callback は、値が代入されている配列のインデックスに対してのみ呼び出されます(undefined が代入されているものも含みます)。すでに削除されたインデックスや、まだ値が代入されていないインデックスに対しては呼び出されません。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/map

つまり、new Array(3)だけした状態でmapを呼び出しても、インデックス(オブジェクトのキー)がないので1度もコールバックが呼ばれないのです。 この挙動はforEachやreduce、filterなども同様です。

そこで、先人の教えの通り、一旦fill()します

const array = new Array(3).fill()

console.log("array", array)

実行結果です。

f:id:yucatio:20190407110128p:plain

{
  0: undefined
,
  1: undefined,

  2: undefined,
  length: 3
}

インデックスのキーが作成されました。fillに引数を渡していないので、各値はundefinedです。

この状態でmap()をすれば、各キーに対してコールバックが呼ばれそうです。

const array = new Array(3).fill().map(() => ({foo: "ふう", bar: "ばあ"}))

console.log("array", array)

実行結果です。

f:id:yucatio:20190407110144p:plain

{
  0: {foo: "ふう", bar: "ばあ"},
  1: {foo: "ふう", bar: "ばあ"},
  2: {foo: "ふう", bar: "ばあ"},
  length: 3
}

配列に初期値を設定できました。

あとがき

オブジェクトで配列の初期化をする方法は複数ありますが、どれも初心者にとって直感的ではないのでなんとかならないかなと思います。