CodeIQ:縦線と横線でマス目を塗る

私自身が表題の問題を解いた時のプログラムについて解説します。
問題の詳細は「縦線と横線でマス目を塗る」(CodeIQ)を参照してください。

問題の概要

問題を引用します。
【概要】
マス目をルールに従って黒く塗っていきます。
黒く塗られたマス目の数を数えてください。

【詳細】
マス目を塗る方法は、以下の二通りあります(以下、座標系はyが大きい方が上、xが大きい方が右です):
方向を表す記号状況
V下から上に塗る(y座標が大きい方向に塗りすすめる)
H左から右に塗る(x座標が大きい方向に塗りすすめる)

【入出力】
入力は
H,1,2,8 V,4,1,8 H,1,7,5 V,7,7,2
のようになっています。

塗り方が、空白区切りで並んでいます。
塗り方は、
「方向を表す記号」「塗りはじめのマスのx座標」「塗りはじめのマスのy座標」「マスの数」が、コンマ区切りで並んでいます。

出力は、
21
のような感じです。
塗られている面積を10進数で普通に出力してください。

【例】
入力出力状況
H,1,2,8 V,4,1,8 H,1,7,5 V,7,7,2 21 Fig.1
V,3,9,4 H,0,4,13 H,4,10,2 V,8,2,11 29 Fig.2
V,8,7,2 H,8,10,6 V,9,9,7 H,10,14,4 H,11,0,3 21 Fig.3

【補足】
不正な入力に対処する必要はありません。
x座標・y座標 は、いずれも 0以上 一千万 以下の整数です。
長さ は、1以上 一千万 以下の整数です。
「塗り方」の個数は 十 個を超えることはありません。

私のプログラム

Rubyで解答しています。

#!/usr/bin/ruby

# 入力値をパースする
def parseLine(line)
	vs = []
	hs = []

	splited = line.split
	for s in splited
		prm = s.split(",").map{|a| a =~ /[0-9]+/ ? a.to_i: a}
		if prm[0] == "V" then vs << prm
		elsif prm[0] == "H" then hs << prm
		end
	end

	return vs, hs
end

# 同じ向きの2つの線ので繋げられるものを繋ぐ
# p1,p2はVの場合[y, len]、Hの場合[x, len]
def joinLine(p1, p2)
#	printf("p1=%s, p2=%s\n", p1.to_s, p2.to_s)	# DEBUG
	st = [p1[0], p2[0]].min
	ed = [p1[0]+p1[1], p2[0]+p2[1]].max

	len1 = p1[1] + p2[1]	# 実際の線の長さ
	len2 = ed - st			# 2つの線の両端の長さ
#	printf("len1=%d, len2=%d\n", len1, len2)	# DEBUG

	# 両端の長さが実際の線の長さより長ければ重なっていたりしない
	if len2 > len1 then return nil
	else return [st, len2]
	end
end

# Vでx座標が同じものを繋げられるなら繋げて一つのデータにする
def joinVsameX(org)
	vs = org.sort{|a, b| a[2] <=> b[2]}	# y座標の順にソート
#	printf("vs=%s\n", vs.to_s)	# DEBUG
	ret = []
	ret << vs.shift

	while !vs.empty?
		nv = vs.shift

		hold = nv
		for i in 0...ret.size
			joined = joinLine([ret[i][2], ret[i][3]], [nv[2], nv[3]])
			if joined != nil then
				ret[i] = [ret[i][0], ret[i][1], joined[0], joined[1]]
				hold = nil
				break
			end
		end

		if hold != nil then ret << hold end

#		printf("ret=%s\n", ret)	# DEBUG
	end

	return ret
end

# Vをx座標が同じもので分ける
def groupV(org)
	ret = {}

	for o in org
		x = o[1]
		if ret[x] == nil then ret[x] = [] end
		ret[x] << o
	end

	return ret
end

# Vでx座標が同じもので重なっているか繋がるものを繋げる
def joinV(vs)
	vg = groupV(vs)
	ret = []

	vg.each{|_, arr|
#		printf("target => %s\n", arr)	#DEBUG
		ret += joinVsameX(arr)
	}

	return ret
end

# Hでy座標が同じものを繋げられるなら繋げて一つのデータにする
def joinHsameY(org)
	hs = org.sort{|a, b| a[1] <=> b[1]}	# x座標の順にソート
#	printf("hs=%s\n", hs.to_s)	# DEBUG
	ret = []
	ret << hs.shift

	while !hs.empty?
		nh = hs.shift

		hold = nh
		for i in 0...ret.size
			joined = joinLine([ret[i][1], ret[i][3]], [nh[1], nh[3]])
			if joined != nil then
				ret[i] = [ret[i][0], joined[0], ret[i][2], joined[1]]
				hold = nil
				break
			end
		end

		if hold != nil then ret << hold end

#		printf("ret=%s\n", ret)	# DEBUG
	end

	return ret
end

# Hをy座標が同じもので分ける
def groupH(org)
	ret = {}

	for o in org
		y = o[2]
		if ret[y] == nil then ret[y] = [] end
		ret[y] << o
	end

	return ret
end

# Hでy座標が同じもので重なっているか繋がるものを繋げる
def joinH(hs)
	hg = groupH(hs)
	ret = []

	hg.each{|_, arr|
#		printf("target => %s\n", arr)	#DEBUG
		ret += joinHsameY(arr)
	}

	return ret
end

# 重複を含めて塗りつぶされた座標を数える
def countBlack(lines)
	ret = 0
	for l in lines
		ret += l[3]
	end
	return ret
end

# 重複箇所を数える
def countCrossed(vs, hs)
	ret = 0
	for v in vs
		y_min = v[2]
		y_max = v[2] + v[3] - 1

		for h in hs
			x_min = h[1]
			x_max = h[1] + h[3] - 1
			if ((y_min <= h[2]) && (h[2] <= y_max)) && ((x_min <= v[1]) && (v[1] <= x_max))then ret += 1 end
		end
	end
	return ret
end

# 問題を解く
def solve(line)
	vs, hs = parseLine(line)
	jv = joinV(vs)
	jh = joinH(hs)

	ret = countBlack(jv) + countBlack(jh) - countCrossed(jv, jh)
	return ret
end

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

	p solve(line)
end

解説

見た目よりも難しいと思います。ただ、頑張ればできるタイプの問題です。

考え方

考え方の前に問題で明示的に説明されていい引っ掛けポイント思うところです。
入力値のH同士、V同士も重なることがあります。これはHとVの重なる場所を数えるよりもかなり厄介で、これをなんとかするのがこの問題の主旨かもしれません。

私の考え方は単純です。
  • 入力値をHとVに分ける。
  • H同士、V同士で繋がるものを繋げてしまう。
  • マス目を数える。
です。
要するにH同士、V同士で繋げられるものを繋げてしまって1つの入力値と見なせば、VとHの交差しか考慮する必要がなくなるので簡単だというわけです。

main

入力値を solve()に渡します。
solve()が結果を返すのでそれを印字して終わります。

solve(line)

答えを計算します。
parseLine()で入力値をHとVに分け、数値部分は数値変換した配列にします。
joinV()、joinH()で繋げられるものを繋げてしまいます。
countBlock()でHとVそれぞれが塗りつぶすマスを数えて足し合わせ、countCrossed()で重なった部分の数を数えて引きます。これが答えになります。

parseLine(line)

どうこういうことはありません。
入力値の内、数値部分は数値に変換して配列にし、最初の値がHかVかで振り分けます。

joinV(vs), joinH(hs)

joinV()はV、joinH()はHで連結可能なものを繋げて一つの値にしてしまいます。
例えば、["V",1,1,2]、["V",1,4,2]、["V",1,3,3]があったら["v",1,1,5]という一つの値にしてしまうわけです。
縦横が違うだけなので縦側だけを説明します。

まずgroupV()でx座標が同じものごとに分けます(Hの場合はgroupH()でy座標が同じもので分けます)。結果は{座標=>[入力値, …]}の連想配列になります。
これを同じ座標のものごとにjoinVsameX()(Hの場合はjoinHsameY())で連結し、parseLine()の結果と同じ形式の配列にして返します。

groupV(org)、groupH(org)

groupV()はx座標が同じもの、groupH()はy座標が同じものでグループ化し、{座標=>[入力値, …]}の連想配列にします。
単純に入力値の当該座標の値を見て廉造配列に変換する処理を行うだけです。

joinVsameX(org)、joinHsameY(org)

joinVsameX(org)はV、joinHsameY(org)はHのデータを対象とするだけで同様の処理です。以下はjoinVsameX(org)だけを説明します。

引数はx座標を同じくする入力値の配列です。
まず、y座標でソートし変数vsに結果を保持します(38行目)。ソートしておかないと["V",1,1,2]、["V",1,4,2]、["V",1,3,3]の様に["V",1,1,2]、["V",1,4,2]の間が繋がっていなくて、その後の値によって繋がることを考慮しなければならず面倒です。

retは結果の配列で、初期値はvsの先頭要素(つまり、y座標が一番小さいもの)です(40〜41行目)。

vsが空になるまでループします(43〜54行目)(※ 多分ループする必要はなくretの最後の要素だけを試せば良いはずです)。
vsから先頭要素を取り出し変数nvに保持します。
連結できなかった場合のフラグを兼ねて変数holdをnvに設定します。
retの先頭から順にnvを連結可能かテストし(48行目)、可能なら連結します(49〜53行目)。vsはソートされているため間を繋ぐパターンを考慮する必要がないので繋がるものを見つけたら(49行目)、ret中の当該要素を連結したデータに書き換え(50行目)、hold=nilにしてループを終了します。
holdがnilでなかったら連結できるものがなかったことになるので、holdの値をretに追加します。

retには繋げられるものを繋げたデータが入っているのでそれを返します。

joinLine(p1, p2)

p1とp2、2つの線を繋げます。繋げられない場合はnilを返し、繋げられた場合は[繋げた後の開始座標, 繋げた後の長さ]を返します。
p1,p2はコメントにある通り、Vの場合[y座標, 長さ]、Hの場合[x座標, 長さ]です。

繋げられるかどうかは次の様に判断します。
(A) p1とp2の開始座標の小さい方から、p1とp2の終了座標の大きい方までの長さを求めます(23〜24、27行目)。
(B) p1とp2の長さの合計を求めます(28行目)。
AがB以下なら重なっているか繋がります。その場合は開始座標はp1とp2の座標のうち小さい方、長さはAになりますのでそれを返します(32行目)。そうでなければ繋がらないのでnilを返します(31行目)。

雑感

線をわざわざ繋げないで重なった部分だけをカウントするうまい方法がありそうな気もします。
話は変わりますが、「〜〜H()」、「〜〜V()」の関数はだいたい同じ処理なのでまとめようと思えばまとめられます(引数を増やしてx座標かy座標の要素番号を渡せば良い)。ただ、わかりやすさにはあまり寄与しないと思うのです。あとで見たときに「この引数はなんだっけ?」と一旦考えないといけないのが面倒というか。
でも、3つ以上になったらさすがにまとめてしまうので、やはりまとめるべきだったかもしれません。