CodeIQ:最寄りの素数

私自身が表題の問題を解いた時のプログラムについて解説します。
問題の詳細は「最寄りの素数」(CodeIQ)を参照してください。

問題の概要

次のように数字を並べます。
1251017
4361118
9871219
1615141320
2524232221

1以上1,000,000以下の素数でない値が入力されます。
入力された値から最も近い距離にある素数を表示してください。複数ある場合はコンマ区切りで昇順にソートして出力してください。答えには1,000,000より大きい数字が含まれる可能性があります。
距離は、マスの中心を結ぶ線分の長さです。マス目は正方形とします。

私のプログラム

Pythonで解答しています。

#!/usr/local/bin/python3
# -*- coding:utf-8 -*-

import sys
import math
import itertools
import fileinput

# 値から並びの最初の値を得る
def getColFirst(n):
	return n*n+1

# 値から並びの最後の値を得る
def getRowLast(n):
	return n*n+2*n+1

# 値から現在の座標を得る
def getRowCol(n):
	sq = math.ceil(math.sqrt(n))
	for i in range(sq):
		first = getColFirst(i)
		last = getRowLast(i)
		mid = (first + last) // 2
		if first <= n <= last:
			break

	if n == mid:
		return [i, i]
	elif n < mid:
		return [i-(mid-n), i]
	else:
		return [i, i-(n-mid)]

# 座標の値を得る
# @param pos 座標の配列[row, col]
def getValueOfPos(pos):
	i = max(pos)			# 現在の並び
	if pos[1] >= pos[0]:	# 縦の並びにある場合
		return getColFirst(i) + min(pos)
	else:
		return getColFirst(i) + (2*max(pos)-min(pos))

# 指定した座標(pos)からdだけはなれた場所の値のリストを返す
# @param pos 座標の配列[row, col]
# @param d 何マスはなれているところを調べるか
def getValueList(pos, d):
	st_r = pos[0] - d if (pos[0]-d) >= 0 else 0
	ed_r = pos[0] + d
	st_c = pos[1] - d if (pos[1]-d) >= 0 else 0
	ed_c = pos[1] + d

#	print("st_r=" + str(st_r) + " ed_r=" + str(ed_r) +", stc=" + str(st_c) + " ed_c=" + str(ed_c))

	ret = []
	for i in range(st_r, ed_r+1):
		if(isCorrectDistance(pos, [i,st_c], d)):
			ret.append(getValueOfPos([i,st_c]))
		if(isCorrectDistance(pos, [i,ed_c], d)):
			ret.append(getValueOfPos([i,ed_c]))
	for j in range(st_c+1, ed_c):
		if(isCorrectDistance(pos, [st_r,j], d)):
			ret.append(getValueOfPos([st_r,j]))
		if(isCorrectDistance(pos, [ed_r,j], d)):
			ret.append(getValueOfPos([ed_r,j]))

#	print("周囲:" + str(sorted(ret)))
	return ret

# targetが正しくbaseからdだけはなれたところにあるかチェックする
def isCorrectDistance(base, target, d):
	if (math.fabs(base[0]-target[0]) != d) and (math.fabs(base[1]-target[1]) != d):
		return False
	return True

# 素数かどうかを判定する
def isPrime(q):
	if q == 2: return True
	if q < 2 or q&1 == 0: return False
	return pow(2, q-1, q) == 1

# 距離の2乗を計算する
def squareDistance(center, pos):
	rd = center[0]-pos[0]	# 縦方向
	rc = center[1]-pos[1]	# 横方向
#	print("center:" + str(center) + " pos:" + str(pos) + " rd:" + str(rd) + " rc:" + str(rc))
	return rd*rd + rc*rc

# 現在のリストの中でposに最も近いもののリストを返す
# @param pos 中心座標
# @param lst 距離計測対象の座標リスト
# @return [距離, [素数の配列]]
def getNearList(pos, lst):
	if len(lst) == 0:
		return [2 ** 63 - 1, []]

	dist = []
	for i in lst:
		dist.append(squareDistance(pos, getRowCol(i)))

#	print("素数" + str(lst))
#	print("距離" + str(dist))

	# 距離の2乗の中で最も近い物だけを抽出する
	ret = []
	m = min(dist)
	for i,k in enumerate(dist):
		if k == m:
			ret.append(lst[i])

	return [m,ret]

# 最も近傍の素数を探す
def getNearlyPrimes(n):
	pos = getRowCol(n)	# 行、列の位置を求める

	# 1マスずつ拡大しながら周辺を探索して最も近い場所で見つかったものを返す
	ret = []
	i=1				# 中心から何マス離れるか
	m=2 ** 63 - 1	# 距離の最小値(ループ終了チェックに使う)
	while True:
		# 最も近いマスまでの距離が結果として得られたものの距離よりも大きくなったらやめる
		if (len(ret) != 0) and (i*i > m):
			break

		v = getValueList(pos, i)
		i+=1

		l = list(filter(isPrime, v))	# 1マスずつ広げながら素数を探す
		d, ln = getNearList(pos, l)		# 見つかった中で最も近傍のものを抽出する

		if len(ln) == 0:	#抽出したリストが空なら次
			continue
		elif len(ret) == 0:	#結果が空なら初めての要素なのでチェックなしに追加
			ret += ln
			m = d
		else:
			if m == d:	# 新規取得した要素が現在と同じ距離にあるなら追加する
				ret += ln
			elif d < m:	# 新規取得した要素の方が現在の結果より小さい場合、結果を作り直す
				ret = []
				ret += ln
				m = d

	return sorted(ret)

# 結果出力
def printResult(ret):
	s = ""
	for i, k in enumerate(ret):
		s += str(k)
		if i != len(ret)-1:
			s += ","
	print(s)

#==============================================================================
# main
#------------------------------------------------------------------------------
if __name__ == "__main__":
	for line in fileinput.input():
		if not line.strip():
			continue

		n = int(line)
		ret = getNearlyPrimes(n)
		printResult(ret)

解説

この問題はかなり難しいと思います。しかし、問題はわかりやすいですし、数学的にもそんなに難しくはありません。

基本構想

私の基本構想は次のとおりです。
  1. 入力値の座標を特定する
  2. その座標から周囲1マスずつ範囲を広げながら素数を探す
プログラムの本体はgetNearyPrimes()で、上記の手順に従って処理しています。
114行目のgetRowCOl()で座標を求め、120〜142行目のループで素数を探しています。

getRowCol()

入力値から値の座標(0始まり)を求めます。例えば入力値が6なら(Y=1,x=2)を求めるわけです。
まず、入力値がどの並び(6の場合は5,6,7,8,9)にあるかを特定します。
入力値が存在する並びはその最小値と最大値がわかれば求めることができます。行番号・列番号n(n>=0)とした場合、並びの最初の値はn2+1、最後の値はn2+2n+1、でgetColFirst()、getRowLast()で求めます。
入力値が含まれる並びの最大値は√入力値の端数切り上げの2乗なので0<= i <(√入力値の端数切り上げ)の範囲で探索し、nがgetColFirst(i)、getRowLast(i)の間にあるiを探せば行番号、列番号の一方が決まります。
実際のコードは効率が悪く、検索するにしても逆順に検索すべきです(多分、math.ceil(math.sqrt(n))-1で求まると思います)。

並びが決まったらそれを元に座標を求めます。
i番目の並びにあったとして、並びの真ん中の値だと(i,i)です。
真ん中より小さければYの値が変化し、大きければXの値が変化します。
これを27〜32行目で計算して返します。

本来ならタプルで返すべきですが、この時点ではまだPythonに慣れておらず配列を使っています。

素数を探す

座標が決まったら周囲を1マスずつ拡大しながら素数を探索します。120〜142行目のループがその処理です。
ただし、気をつけなければいけない点があります。私の方法では探索は正方形で広がってゆきますが、距離の定義はマスの中心と中心を結ぶ長さなので、同一周回で見つかった素数が全て同じ距離にあるわけではありません。場合によっては探索で見つかった素数が正方形の隅にあった場合、次の周回以降で見つかった素数の方が近くにある可能性もあります。
そのため、ループは素数が1以上見つかっており、探索回数(=半径)が見つかった素数までの距離以下である限り探索を継続します。122〜123行目の処理がこの判断でmには見つかった素数までの距離の2乗が入っています。探索回数(=半径)の2乗がmより大きくなったらより近傍にある素数が見つかる可能性がないので処理をやめます。2乗を使っているのはそちらの方が計算が楽で整数で比較できるためです。

ループではさらに次の手順で処理を行っています。
  1. 入力値からi離れた場所の値を列挙する
  2. その値から素数を探す
  3. その中から最も近傍の値だけを抽出する
近傍の素数が抽出できたら結果をソートして返します。

範囲内の値を列挙する

ループ処理中に探索範囲内の値を列挙する処理を行っているのがgetValueList()です。
47〜51行目で探索の範囲を決めています。st_?は開始点、ed_?は終了点、rは行方向、cは列方向になります。

中でisCorrectDistance()で距離をチェックしていますが、これは47〜51行目で探索範囲が0以上になるようにしているため、探索すべき距離よりも内側を調べる可能性があるためです。
でも、よく考えるとこのチェックはなくても問題ありませんでした。

素数判定

getValueList()で列挙した値に対して、isPrime()で素数判定を行い、素数であった値だけの集合を作ります。

実はこの処理は正しくありません。
素数判定のためにエラトステネスの篩を実装するのが普通と思いますが、もっと楽ちんな方法はないかと思って検索し、あまり内容を読まずに真似した関数(フェルマーテスト)です。
これは偶に素数でない値を素数として判定してしまいます。今回のプログラムがこれで全てのテストケースをパスしたのはラッキーでした。

最も近傍の値を抽出する

getNearList()で素数のリストの中で最も近い場所にあるものを探します。
この関数も本当は(距離, [素数リスト])のタプルを返すべきですが、このプログラムを書いた時点ではまだPythonに慣れていないので配列を返しています。

93〜94行目は処理するものがなかった場合のリターンです。距離はとても大きな値を返して結果が更新されないようにしています。
97〜98行目で距離のリストを作っています。squareDistance()が入力値の座標から、素数の座標までの2乗を計算する関数です。この関数が返す値の平方根をとると距離になりますが浮動小数点になってしまうのと、2乗したままでも結果は同じなので2乗した値のまま扱います。
105行目で最短距離を求めます。そして、106〜108行目で最短距離に等しい距離にある素数だけを集めています。
結果として、中心から素数までの距離と素数リストを返します。

この関数の戻り値を受け取ったgetNearlyPrimes()では、既存の結果の距離と戻り値の距離を比較し、同じならリストに追加し、既存の結果よりも近傍のリストが帰ってきたらリストを置き換えます。

雑感

かなり難しい問題ではあったのですが、やればなんとかなると言う感じだったのでなかなか楽しめました。
しかし、素数判定が完全ではないことに気づいたのは別の時に素数判定のルーチンを流用しようとした時でした。その時には調べ直して冷や汗をかきました。