CodeIQ:crontab書式を解釈しよう

私自身が表題の問題を解いた時のプログラムについて解説します。
問題の詳細は「crontab書式を解釈しよう」(CodeIQ)を参照してください。

問題の概要

問題を引用します。
あなたはとあるジョブ管理ツールの開発を任されており、定時実行のためにcrontab(Wikipediaへのリンク)の書式で記述されたスケジュールに対応することになりました。
そこで、まず与えられたスケジュールを正しく解釈できるかテストするためのプログラムを作ることにしました。

求められるプログラムの前提条件は、以下の通りとなります。

標準入力から、crontabの書式で記述されたスケジュールの文字列が1行送られる
文字列は、9文字以上80文字以下とし、フィールド間はスペースで区切られているものとする
文字列のうち、解釈の対象となるのは"時フィールド"のみとし、その他は無視するものとする
文字列に、コメントや、第6フィールド以降(コマンド記述)は含まれないものとする
文字列から、何時(0-23)に実行されるかを求め、数値が複数の場合はスペース区切りで標準出力に送ること
複数の値を指定する特殊記号として、アスタリスク (*) スラッシュ(/) ダッシュ (-) コンマ(,)を考慮すること
それぞれのルールはWikipedia上のcrontabの解説(リンク先)に従うこと
入出力例にはないが、範囲外の値(0未満または24以上)の指定がある場合も、無視すること
出力する数値に重複があった場合は、除外した上で出力すること
出力する数値は、小さい値から大きい値の順になるようソートすること

以下、入出力例になります。

入力出力
0 1,3,2 * * *1 2 3
0 5,5,6 * * *5 6
0 */6 * * *0 6 12 18
0 3-4,7-8 * * *3 4 7 8
0 1-10/5 * * *1 6

crontabの書式はスケジュールを記述する方法として、実際には様々なアプリケーションで採用されています。
またJenkinsのように同時に大量のジョブが走らないよう、書式を拡張してスケジューリングの自由度を強化しているケースもみられます。
今回の実装を通じて、意外と奥が深い存在であることに気付かされるのではないでしょうか?
是非挑戦してみてくださいね。

【問題】
標準入力から、crontabの書式で記述されたスケジュールの文字列が送られます。
この文字列を解釈し、何時に実行されるかを求め、その結果を標準出力に返してください。

私のプログラム

Rubyで解答しています。

#!/usr/bin/ruby

# 数値, *, 数値-数値の範囲に含まれる値の配列を返す
def getTimes(input)
	ret = []

	if input.include?("-") then
		md = input.match(/(-?[0-9]+)-(-?[0-9]*)/)
		st_ed = [md[1].to_i, md[2].to_i]
		st = st_ed.min
		ed = st_ed.max

		for i in 0..23
			ret << i if (st <= i) && (i <= ed)
		end
	elsif input == "*" then
		ret = (0..23).to_a
	elsif input =~ /[0-9]+/ then
		ret << input.to_i
	end

	return ret
end

# 問題を解く
def solve(hour)
	arr = hour.split(",")

	times = []

	# ","で要素を分割し、"/"の有無で分ける
	for a in arr
		if a.include?("/") then
			# "/"を含む場合は間隔ごとの値を選択する
			b = a.split("/")
			ret = getTimes(b[0])

			times << ret.shift
			for r in ret
				times << r if (r-times[0]) % b[1].to_i == 0
			end
		else
			times += getTimes(a)
		end
	end

	return times.uniq.sort
end

# main
while line = gets
	line.strip!
	if line.empty? then next end

	puts solve(line.split[1]).join(" ")
end

解説

文字列解析の問題です。
こう言うのは実際の業務でも結構あると思います。エラーチェックとか。

考え方

問題は"/"を含む場合だけなのでそれ以外を片付けます。
まず、コンマで分割し、数値ならそのまま、数値-数値ならその範囲、*なら0-23と扱えばOKです。
"/"を含む場合について、私は"/"の前を前述の通り処理し、そのリストから"/"後ろ間隔の値だけを選ぶと言うことにしました。

main

入力値から時間部分(ホワイトスペースで分割して1番目の要素)だけを取り出しsolve()に渡します。
結果は数値の配列なので","で連結した文字列を印字します。

solve(n)

引数を","で分割し、その要素ごとに処理します。

要素に"/"が含まれている場合、"/"でさらに分割し、"/"の前部分からgetTimes()でその範囲内の全ての値(例:1-7/2なら1-7に含まれる値である[1,2,3,4,5,6,7])を取得したのち最初の値から"/"の後間隔の値だけを選択して結果とします(38〜41行目)。
間隔ごとの値を選ぶのは2番目以降の値についてそれぞれから1番目の値を引き、それを"/"の後の値で割った場合に余り0になるもので選択できます。

要素に"/"が含まれていなければgetTimes()でその範囲内の全ての値を結果とします。

後は結果の重複をArray#uniqで除き、sortして返せばOKです。

getTimes(input)

数値か、数値-数値か、*かを判断して適切な数値の配列を返します。

数値の場合と*の場合は簡単で、数値はそのままの値1つだけを持つ配列、*は0〜23の値を持つ配列を返すだけです。

数値-数値は範囲外の値(負数と24以上)と5-1の様に大小逆になっている場合を考慮しています。
値の取り出しは正規表現で簡単にできます。
9〜11行目は大小逆になっている場合の対応で小さい値をst、大きい値をedに取る様、一旦配列にしてminとmaxで取得しています。
範囲内の値だけを選ぶのは頭悪そうですが0〜23の値のうちst以上ed以下のものを選ぶ様にすると範囲外の値が入り込まないので簡単です。

雑感

面倒な部分があるだけで簡単です。
ただ、問題文に範囲外の値を考慮しろとあるのにテストケースにはそう言うケースがなかったのはどうかと思います。