CodeIQ:画像から連結成分の数を求めよう

私自身が表題の問題を解いた時のプログラムについて解説します。
問題の詳細は「画像から連結成分の数を求めよう」(CodeIQ)を参照してください。

問題の概要

問題を引用します。
あなたは食品メーカーに勤めており、画像認識で商品を検査することで業務を省力化するよう求められました。

画像認識はDeep Learningが得意とする分野ですが、まず「隗より始めよ」ということで、画像から物体の数を求めるプログラムの開発に着手することにしました。

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

標準入力から、ドット(.)とゼロ(0)の2文字で構成された文字列が同文字数複数行送られる。
入力は1文字を1ピクセル、1行分の文字列を横一列とした2値画像とみなす。
この時横軸、縦軸とも長さは4以上20以下とする。
ゼロ(0)が上下左右斜めの8方向に連結した領域(連結成分)の数を求め、その数を標準出力に返すこと。
なお領域外のピクセルは、ドット(.)とみなすこと。

以下、入力と出力例です。

入力出力
.0..
00..
....
...0
2
.0..
00..
..0.
...0
1

いかがでしょうか?
一見単純に見える連結成分の取得ですが、効率的に処理しようとすると奥が深いことに気づかされると思います。
それでは、是非挑戦してみてくださいね。

【問題】
標準入力から、ドット(.)とゼロ(0)の2文字で構成された文字列が同文字数複数行送られます。
この入力を2値画像とみなし、ゼロが上下左右斜めの8方向に連結した領域(連結成分)の数を求め、その数を標準出力に返してください。

私のプログラム

Rubyで解答しています。

#!/usr/bin/ruby

$Map = nil

def search(inputs, now, cnt)
	dir = [[-1,-1], [-1,0], [-1,1], [0,-1], [0,1], [1,-1], [1,0], [1,1]]

	queue = [now]
	while !queue.empty?
		pos = queue.pop

		for d in dir
			nxt = [pos[0] + d[0], pos[1] + d[1]]
			next if (nxt[0] < 0) || (nxt[1] < 0) || (nxt[0] >= inputs.size) || (nxt[1] >= inputs[0].size)

			next if inputs[nxt[0]][nxt[1]] == '.'

			if $Map[nxt[0]][nxt[1]] != 0 then next
			else
				$Map[nxt[0]][nxt[1]] = cnt
				queue.push(nxt)
			end
		end
	end
end

def solve(inputs)
	cnt = 0
	for r in 0...inputs.size
		for c in 0...inputs[0].size
			if inputs[r][c] == '0' then
				if $Map[r][c] == 0 then
					cnt += 1
					$Map[r][c] = cnt
					search(inputs, [r, c], cnt)
				end
			end
		end
	end
	return cnt
end

# main
inputs = []
while line = gets
	line.strip!
	inputs << line
end

$Map = Array.new(inputs.size){Array.new(inputs[0].size, 0)}
p solve(inputs)

解説

「連接成分」が何か知らなかったので調べました。
★★★ですがやや簡単な気がします。

考え方

ベースとなる考え方は次のようになります。
まず、入力値と同じサイズの記録領域を用意し、全域を0で初期化しておきます。
入力値の任意の座標を見たとき、それが0なら繋がっている部分を探します。この時、最初に見つけた0に繋がっている部分は記録領域に1と記録します。繋がっている部分を全て辿ったら次の0を探します。
次の0の場所の記録領域に1以上の値があったら探索済みなので無視します。
記録領域の値が0だったら、繋がっている部分を探索しながら2を記録領域に記録してゆきます。
カウントを3,4,...としながら全域をチェックし、終わった時のカウントが答えになります。

繋がっている部分の探索は経路探索と似たような手法になります。
0の隣接領域にある0の座標をキューに入れ、キューが空になるまでキューから値を取り出しては隣接座標をチェックすることを繰り返します。この時、記憶領域に1以上の値がある座標は探索済みなのでキューに入れないようにします。

main

入力値を1行ずつ配列inputsにとります。Rubyの文字列は[]で配列のようにアクセスできるので配列にする必要はありません。
$Mapは記録領域で、inputsと同じサイズの0埋めされた二次元配列として準備します。

solve(inputs)

入力値の最初から最後まで順に0の場所を探し、その場所の$Mapの値が0ならsearch()で連結成分を探します。
cntは連結成分の数でsearch()で探索が必要になる毎に+1します。これによって終了時のcntの値が答えになります。

search(inputs, now, cnt)

連結成分を探します。
inputsは入力値の配列です。
nowはスタート地点の座標です。
cntは$Mapに書き込む値です。

dirは隣接領域の相対座標です。

queueにスタート地点nowをセットし、queueが空になるまでループします。
dirの各方向をチェックし、inputsの座標が0でなければ無視します。領域外も無視します。
$Mapの当該座標が0以外なら探索済みなので無視します。
$Mapの当該座標が0なら$Mapの当該座標にcntをセットし、queueにも追加します。

queueが空になった時点で$Mapの連結成分の座標にcntの値が書き込まれた状態になります。

雑感

この問題はなかなか面白かったと思います。入力値の最大が20*20と小さいのでこれで十分ですがもっと巨大なデータの場合、これで十分なのかな?
ちなみに、連結成分の意味を知らなかったので調べたら、深さ優先探索云々という文言があったので深さ優先で探索しましたが、queueをpopするかshiftするだけの差なので幅優先でも同じ結果になるような気がします。