CodeIQ:回文数の中央値

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

問題の概要

問題を引用します。
【概要】
数の範囲を指定します。
その範囲内にある回文数( seeWikipedia - 回文数) の、真ん中の値を計算してください。

例えば下表の通りです:
値の範囲回文数真ん中の値
99〜12099, 101, 111101
200〜232202, 212, 222, 232212, 222

【入出力】
入力は
99,120
のようになっています。
下限と上限がコンマ区切りで並んでいます。
下限以上で、上限以下の回文数の真ん中の値を求めてください。

出力も普通に10進数です。
範囲内にある回文数が奇数個の場合には、中央の値はひとつに決まるのでそれを。

偶数個の場合にはぴったり中央の値はありませんので、中央付近の2つの値をコンマ区切りで昇順に出力してください。

ただし、範囲内に回文数がひとつもない場合は
-
を出力してください。

【例】
入力 範囲内の回文数 状況
99,120 99, 101, 111 101
200,232 202, 212, 222, 232 212,222
123,124   -
999,1221 999, 1001, 1111, 1221 1001,1111
1234,1400 1331 1331

【補足】
不正な入力に対処する必要はありません。
0 < 下限 < 上限 ≦ 一千兆 です。

私のプログラム

Rubyで解答しています。

#!/usr/bin/ruby

# 桁数    0   一  十   百   千     万   十万    百万   千万     一億    十億      百億     千億       一兆       十兆       百兆
Counts = [0, 10, 19, 109, 199, 1099, 1999, 10999, 19999, 109999, 199999, 1099999, 1999999, 10999999, 19999999, 109999999]

# 回分数かどうかチェックする
def isKaibun(n)
	s = n.to_s
	r  = s.size-1

	for i in 0..(r/2)
#		printf("%s, %s\n", s[i], s[r-i])
		return false if s[i] != s[r-i]
	end
	return true
end

# r桁のn番目の回分数を求める
def getKaibun(n, r)
	return n-1 if r == 1

	s = (n-1).to_s

	if (r % 2) == 0 then s += s.reverse
	else s += s[0..-2].reverse
	end

	while s.size < r
		s = '0' + s + '0'
	end

	return s.to_i + 10**(r-1) + 1
end

# 引数以下の最大の回分数がその桁の何番目かを返す
# 0は1番目
def getIndexOfKaibun(n)
	r = n.to_s.size
	return n+1 if r == 1

	base = 10**(r-1) + 1
	ret = (n - base) / (10**(r/2))

	loop{
		v = getKaibun(ret+1, r)
#		printf("n=%d, v=%d, ret=%d\n", n, v, ret)
		if v <= n then break
		else ret -= 1
		end
	}

	return ret + 1
end

# 何桁の何番目の回分数かを求める
def search(idx)
	for i in 1...Counts.size
		return [i, idx-Counts[i-1]] if (Counts[i-1] < idx) && (idx <= Counts[i])
	end
end

# 問題を解く
def solve(mn, mx)
	mx = 999999999999999 if mx > 999999999999999

	# 入力値の範囲にある回分数の最小と最大のインデックスを決める
	r_min = mn.to_s.size
	i_min_of_r = getIndexOfKaibun(mn)

	r_max = mx.to_s.size
	i_max_of_r = getIndexOfKaibun(mx)

	# 全体で何番目か?
	i_min = Counts[r_min-1] + i_min_of_r
	i_max = Counts[r_max-1] + i_max_of_r

	# 範囲内に値が無い
	return "-" if i_max < i_min

#	printf("i_min_of_r=%d, i_max_of_r=%d, i_min=%d, i_max=%d\n", i_min_of_r, i_max_of_r, i_min, i_max)
#	p getKaibun(i_min_of_r, r_min)
#	p getKaibun(i_max_of_r, r_max)

	# 範囲内の個数を求める
	if isKaibun(mn) then
		# mnが回分数の場合、mnが範囲内に含まれる
		cnt = i_max - i_min + 1
		if (cnt) % 2 == 1 then target = [(i_max + i_min)/2]
		else target = [(i_max + i_min)/2, (i_max + i_min)/2+1]
		end
#		printf("mn=Kaibun: cnt=%d target=%s\n", cnt, target.to_s)
	else
		# mnが回分数でない場合、mnは範囲に含まれない
		cnt = i_max - i_min
		if (cnt) % 2 == 1 then target = [(i_max + i_min)/2+1]
		else target = [(i_max + i_min)/2, (i_max + i_min)/2+1]
		end
#		printf("mn!=Kaibun: cnt=%d target=%s\n", cnt, target.to_s)
	end


	ret = []
	for t in target
		r, pos = search(t)
#		printf("r=%d, pos=%s\n", r, pos)
		ret << getKaibun(pos, r)
	end

	if (ret.size == 2) && ((ret[0] < mn) || (mx < ret[1])) then return '-'
	elsif (ret.size == 1) && ((ret[0] < mn) || (mx < ret[0])) then return '-'
	else return ret.join(',')
	end
end

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

	mn, mx = line.split(',').map{|a| a.to_i}

	puts solve(mn, mx)
end

解説

★★★の問題です。かなり難しいと思います。

考え方

まず、入力値の最大が1千兆と非常に大きいため、回分数を列挙してその真ん中を表示するという単純な方法は使えません。
なので、何番目の回分数が答えかを調べ、その番号の回分数をピンポイントで求めて返す必要があります。

まず、x番目の回分数を求める方法です。
桁ごとに区切って考えるとその桁のx番目の回分数は意外と簡単にわかります。
回分数をnとします。
一桁の場合はn+1になります(0〜9は1始まりで1番目〜10番目)。
二桁以上の場合、(x-1)を文字列と見て反転したものを後ろに連結します。奇数桁の場合、反転前の一番下の数(反転後の一番上の数)を除いたものを連結します。
それに10桁数-1+1を加えたものがx番目の回分数です。

例えば3桁で13番目の回分数を考えます。
13-1=12
'12'+'1'='121' …… 奇数桁なので2を除いて連結
121+103-1+1=222

実際に数えてみると101,111,121,131,141,151,161,171,181,191,202,212,222で合っています。

これを逆に考えるとある回分数がその桁の何番目の回分数かがわかります。
ここから入力値以下の最大の回分数がその桁の何番目かを求める方法を考えます。
入力値の上半分(奇数桁の場合は真ん中を含む)をmとします。
mを文字列として考え、それを反転したもの(奇数桁の場合は一番下の桁を除く)を連結し回分数にします。
これが入力値より大きい場合、m-1で同じことをします。それでも大きければm-2、m-3、…、と入力値以下になるまでやれば入力値以下の最大の回分数がその桁の何番目かがわかります。

例えば120で考えて見ます。
'120' → '12'(上半分)
'12' → '121'(反転した数を連結)
121 > 120なので、12-1=11
'11' → '111'
111 <= 120なのでこれが120以下で最大の回分数。
111 - (102+1) = 10
10 / 101 = 1(上の結果を半分(切り上げ)の桁数にすると0始まりで何番目かがわかる)
1+1 = 2(0を1番目とするので1加える)
となります。
確かに3桁の回分数は101,111,121なので120以下で最大の回分数は111で2番目ということがわかります。

各桁の回分数が最初から何個目までかはwikipediaの記事から[10, 19, 109, 199, 1099, 1999, 10999, …]という数列なので、これと合わせると最初から何番目かがわかります。
111なら2桁までの回分数の数19+2で21番目です。

以上から、この問題は次のように解くことができます。
入力値から下限と上限以下で最大の回分数が何番目かを求める。
その平均(割り切れない場合は平均の前後)が求める回分数の最初からの順番。
そこから何桁の何番目かを求める。
何桁の何番目かがわかったらその回分数を求める。

main

入力値を数値にし、それぞれをsolve()に渡し、結果を印字します。
定数Countsは各桁までに何個の回分数があるかの数列で、0桁目からになっているので先頭の値は0になっています。

solve(mn, mx)

問題を解きます。
64行目でmxを999,999,999,999,999に丸めています。入力値の最大は1,000,000,000,000,000ですがこれ以下の最大の回分数は999,999,999,999,999なので丸めてしまいます(手抜き)。

67〜68行目で下限以下で最大の回分数がその桁の何番目か、70〜71行目で上限以下で最大の回分数がその桁の何番目かを求めます。

74,75行目で下限と上限がそれぞれ全体の何番目かを求めます。

78行目は上限以下の最大の回分数が下限以下の最大の回分数より小さい場合、相当するものはないのでそのチェックです。

85〜99行目で中央値を求めます。
下限の値が回分数の場合、その場所を探索範囲に含めないと1子少なくなってしまうのでチェックし、個数を調整します。
下限以下と上限以下の回分数の個数が決まったらその平均が求める回分数の先頭からの番号になるのでそれを求めます。

102行目のretは答えを保持する配列です。
103〜107行目で答えを求めます。
108行目で先頭からの番号を何桁の何番目かに変換し、109行目でその場所の回分数を求めます。

109〜112行目で答えの印字ですが、ここでも不正値のチェックをしています。答えの小さい方がmn未満や大きい方がmxより大きい場合は答えとしておかしいので'-'を返し、それ以外なら','で連結した文字列を返します。

isKaibun(n)

nが回分数かどうかをチェックします。
nを文字列にして両端から比較し、真ん中まで同じなら回分数、どこかで違えば回分数ではありません。

getKaibun(n, r)

r桁のn番目(1始まり)の回分数を求めます。
1桁の場合はn-1になります。
2桁以上の場合は考え方に書いた通り、nを文字列としてnにnを反転した数を連結したものを求めます。
引数nは数値なので桁数が合うまで左右を0埋めします(右側だけ埋めれば良いがチェックが楽なので左右埋めています)。
得られた文字列に10r-1+1を加えると求める値になります。

getIndexOfKaibun(n)

引数n以下の最大の回分数がその桁の何番目か(1始まり)を求めます。
考え方に書いた通りの実装で、nの上半分とそれを反転した値を文字列として連結した値を作り、それがnより大きければnの上半分を1ずつ減らしながら求める値を探します。

search(idx)

引数idxは全体の何番目かを示す値で、それを該当する桁の何番目かに変換します。
Countsの要素と比較していって、当てはまる場所になったら一つ下の桁までの総数を引けば求める値になります。
桁数はsearch()を呼ぶ側でわかっているので返しません。

雑感

非常に難しい問題でした。
入力値からそれ以下の回分数が何番目かを求めて、下限と上限の真ん中の番号の回分数を求めれば良いというのはすぐに思いついてのですが、何番目の回分数かを求める、何番目という情報から該当する回分数を求める方法がわからず苦労しました("回分数 性質"で検索しましたが有用な情報を見つけられなかった)。
ブレークスルーは回分数を作る方法を考えていた時にn番目はnとnを反転した文字列を連結して10…01を足せば良いことに気づいたことで、これによって何番目の回分数かを求めるのも容易にできるようになったことでした(最初はもっとゴチャゴチャした方法でやっていた)。
この出題者の問題は例が詳しいので正答率が高い(挑戦者数とバッジ獲得数が近い)のですが、私が正解した時点で50%くらいしかない状態で、かなり難しい問題だったようです。