CodeIQ:バスの料金を計算しよう(ややリアル編)

私自身が表題の問題を解いた時のプログラムについて解説します。
問題の詳細は「バスの料金を計算しよう(ややリアル編)」(CodeIQ)を参照してください。

問題の概要

運賃は次のように定義されます。
幼児 子供 大人
記号 I C A
料金 後述 大人の半額
(10円未満切り上げ)
標準料金
幼児については同行の大人1人につき、2名まで無料。
無料にならなかった幼児は子供と同額。

年齢区分とは別に、料金区分があります。
料金区分は以下の通りです。
通常 定期券あり 特別割引
記号 n p x
料金 年齢区分の通り 無料 通常の乗客の 56%
(10円未満の端数は切り上げ)

年齢区分と料金区分が決まると運賃が(後述の一日乗車券の件を除けば)決められるようになります。
年齢区分の記号と料金区分の記号をApの様に並べて、これを「乗客記号」と呼びます。

【1日乗車券】
前述のルールとは別に一日乗車券があります。
一日乗車券の値段は、標準料金910円の場合に発生する運賃と同額です。具体的には下表のとおりです。

乗客記号 An Ax Cn In Cx Ix
一日乗車券の値段 910円 510円 460円 460円 260円 260円
※定期券を持っている場合は一日乗車券を買う必要がないので表には含まれません。
一日乗車券を購入すると、その日はバス乗り放題となります。
一日乗車券を購入したほうが安くなる場合は必ず一日乗車券を使って下さい。

【入力と出力】
入力は「210:Ap,Cn,In,Ix,Ix」、「310,350,330:Ap,Cn,In,Ix,Ix」のような形式で与えられます。
コロンの前が標準料金のリスト、コロンの後が同乗者の年齢区分のリストです。
同乗者のバス代の合計を出力してください。

私のプログラム

Pythonで解答しています。

import fileinput
import math

#特別料金を計算する
def calcSpecialPrice(i):
	return 10 * math.ceil(i*56/1000)

# 子供料金を計算する
def CalcChildPrice(lst):
	ret = []
	for i in lst:
		n = 10 * math.ceil((i // 10) / 2)
		ret.append(n)

	return ret

# 入力文字列をパースする
def parseLine(str):
	ret = []
	price, passenger = str.split(":")
	ret.append(passenger.split(","))
	ret.append(list(map(int, price.split(","))))
	ret.append(CalcChildPrice(ret[1]))
	return ret

# 乗客の大人、子供、幼児の数を数える
def countPassenger(lst):
	p = {"An":0, "Ax":0, "Ap":0, "Cn":0, "Cx":0, "Cp":0, "In":0, "Ix":0, "Ip":0}
	for k,v in p.items():
		p[k] = len(list(filter(lambda a:a==k, lst)))
	return p

# 1日乗車券を考慮して大人、子供、幼児の値段表を計算する
def calcPriceDic(ap, cp):
	price_dic = {
		"An": sum(ap),
		"Ax": sum(list(map(calcSpecialPrice, ap))),
		"Ap": 0,
		"Cn": sum(cp),
		"Cx": sum(list(map(calcSpecialPrice, cp))),
		"Cp": 0,
		"In": sum(cp),
		"Ix": sum(list(map(calcSpecialPrice, cp))),
		"Ip": 0,
	}

	oneday_pass = {
		"An": 910,
		"Ax": 510,
		"Ap": 0,
		"Cn": 460,
		"Cx": 260,
		"Cp": 0,
		"In": 460,
		"Ix": 260,
		"Ip": 0,
	}

	for k,v in price_dic.items():
		if price_dic[k] > oneday_pass[k]:
			price_dic[k] = oneday_pass[k]

	return price_dic

# 金額計算
# p 乗客を数えたもの
# ap 大人の金額
# cp 子供の金額
def calcTotal(p, ap, cp):
	price_dic = calcPriceDic(ap, cp)

	# 通常料金の幼児を相殺する
	cnt_i =  p["In"]
	cnt_a = 2*(p["An"] + p["Ax"] + p["Ap"])

	if (cnt_i - cnt_a) < 0:
		cnt_i = p["In"] + p["Ix"]
		p["In"] = 0

		if (cnt_i - cnt_a)<0:
			p["Ix"] = 0
		else:
			p["Ix"] = cnt_i - cnt_a
	else:
		p["In"] = (cnt_i - cnt_a)

	ret = 0
	for k, v in p.items():
		ret += v * price_dic[k]

	return ret

#==============================================================================
# main
#------------------------------------------------------------------------------

for line in fileinput.input():
	if not line.strip():
		continue

	PassengerList, PliceListA, PliceListC = parseLine(line.strip())
	PassengerCount = countPassenger(PassengerList)
	total = calcTotal(PassengerCount, PliceListA, PliceListC)
	print(total)

解説

初級編の問題があるのでそちらをやっていれば基本的な処理の手順は同じです。ただし、条件がかなりややこしくなっています。これをいかにシンプルに扱えるかがポイントになります。
もう一点、浮動小数点の誤差もポイントになります。

入力のパースと子供料金の計算

parseLine()がパース処理、CalcChildPrice()が子供料金の計算です。
やっていることは初級編と同じです。
ポイントとしてはAn、Ax、Apのように年齢区分と料金区分をひとまとめにして別物として扱ってしまうことでしょう。A(大人)のグループがあってその中にn、x、pが含まれるような扱いよりも計算時の処理がシンプルになります。もっと種別(n、x、pのシリーズ)が多い場合はクラス化して扱うことを検討しますが、それでも別物として扱うことには違いがありません。

countPassenger()

乗員のリスト(文字のリスト)をAn、Ax、Ap、・・・ごとの人数のディクリョナリに変換します。

calcPriceDic()

1日乗車券のことを考慮して、入力に対応する区分ごとの料金表を計算します。
引数のapは入力値をパースして作った大人の標準料金のリスト、cpは子供の標準料金のリストです。これを合計すればそれぞれの標準料金になります。
35〜45行目で料金表を作っています。この中で使用しているcalcSpecialPrice()は特別割引を計算する関数です。この計算は浮動小数点の誤差を考慮しなければなりません。私はそれで1回リジェクトされています。最初の計算式は次の通りでした。
 10 * math.ceil((i // 10) * 0.56)

この式では浮動小数点誤差が出るパターンがあって実際の値よりもごくわずかだけ大きな値になります。そのため、ceil()の結果が1大きくなってしまいます。

47〜57行目の値は1日パスの料金です。
59〜61行目の処理は計算した料金と1日パスの料金を比較し、1日パスの値段が安ければそちらに変更するという処理をして、入力値に対する値段表を作成します。

料金計算

calcTotal()で最終的な計算をします。
基本的に初級編と同じなのですが、幼児の人数を大人と相殺する処理が面倒くさくなっています。値段を安くするため、標準料金>特別料金の順で相殺しなければなりません。その処理を73〜85行目でやっています。
大人の数はAn、Ax、Apの合計ですので、その2倍が相殺可能な幼児の人数です。幼児の数はまずInだけを考慮し、Inが大人の数×2以上ならInから大人の数×2を引いて終わりです。
Inが大人の数×2より少ない場合、まずInを0にし相殺されなかった大人の数をIxから引くことになりますが、コードではちょっと工夫しています。Inの数(p["In"])は0にするのですが、幼児の数InとIxを合計してから大人の数×2と比較しています。式を変形しただけですがこちらの方が少し単純な処理になります。

後は初級編と同じように区分ごとの人数と料金を掛け合わせて合計すれば終わりです。

雑感

実際の業務にありそうな問題とのことでしたが、確かに条件によってこういうこまごまとした処理が必要なことはよくあります。こういう時は前準備でデータを整えて可能な限り単純なループで処理できるようにするのがポイントと思っています。
このプログラムの場合は最終的な計算は88〜89行目のループですが非常に単純です。もし、ここに条件文が含まれていたりするとデバッグが大変ですし、修正も大変です。この問題程度の複雑さなら大したことはないかもしれませんが、実際の業務で作るプログラムの場合、何か修正したら別のところに問題が出ることにすらなり得ます。