浮動小数点数オタクがAtCoder Beginner Contest 284のD問題をガチで解説してみる
こんにちは、浮動小数点数オタクのmod_poppoです。
昨日開催されたABC284のD問題でsqrtがどうのこうのという声がツイッターで観測されたので、ガチで考察してみます。
問題文(引用)
まず最初に問題文を引用しておきます。
問題文
正整数
が与えられます。 N は、2つの相異なる素数 N , p を用いて q と表せることがわかっています。 N=p^2q
, p を求めてください。 q
個のテストケースが与えられるので、それぞれについて答えを求めてください。 T 制約
- 入力は全て整数
1\le T\le 10 1\le N\le 9\times 10^{18} は、2つの相異なる素数 N , p を用いて q と表せる N=p^2q
2023が
方針
まず、
(試し割りの際はあらかじめ素数を列挙しておいて素数だけで割るのが効率的ですが、速度を気にしないなら全ての整数で割っても良いでしょう。)
試し割りによって見つかった
整数除算は問題ないですが、平方根の計算は問題です。安易に浮動小数点数のsqrt関数を使ってしまうと誤差が心配です。
この平方根の計算をどうするか?というのがこの記事の主題です。
平方根の計算以外の部分をC言語で実装すると次のようになります:
#include <inttypes.h>
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
// 入力:n <= 4.5*10^18, nは平方数
int64_t isqrt(int64_t n)
{
// どう実装する?
}
struct result {
int64_t p, q;
};
// 入力:N <= 9*10^18
struct result solve(int64_t N)
{
// 2080083^3 < 9*10^18 < 2080084^3
for (int64_t a = 2; a <= 2080083; ++a) {
if (N % a == 0) {
int64_t b = N / a;
if (b % a == 0) {
// a = p
return (struct result){.p = a, .q = b / a};
} else {
// a = q
return (struct result){.p = isqrt(b), .q = a};
}
}
}
abort();
}
int main()
{
int T;
scanf("%d", &T);
for (int i = 0; i < T; ++i) {
int64_t N;
scanf("%" SCNd64, &N);
struct result r = solve(N);
printf("%" PRId64 " %" PRId64 "\n", r.p, r.q);
}
}
解法1:整数演算で済ませる
浮動小数点数の計算が心配なら、全部整数演算で片付ければ良いのです。そう、伝家の宝刀†二分探索†を使わせていただきますぞ!
// 入力:n <= 4.5*10^18, nは平方数
int64_t isqrt(int64_t n)
{
// 4.5*10^18 < 2^62
// 真の答えは 2 <= _ < 2^31 の範囲にある
int64_t low = 2, high = INT64_C(1) << 31;
while (low < high) {
int64_t mid = (low + high) / 2;
int64_t mid2 = mid * mid; // 最初の上界を2^31程度に抑えているのでここではオーバーフローしない
if (mid2 < n) {
low = mid;
} else if (mid2 == n) {
return mid;
} else {
high = mid;
}
}
return low;
}
解法2:80ビットのlong doubleを使う
x86のGCCでは long double
が80ビットあって、仮数部も64ビットあります。なので、64ビット整数を正確に表現できます。もちろん、四倍精度が使えるならそれでも構いません。
C言語では long double
に対応する sqrtl
があり、これは正確な(真の値に最も近い浮動小数点数を返す)ことが期待されるのでうまくいきます(x87にはFSQRTという命令があるのでそれを使っていることが期待できます)。
#include <assert.h>
#include <float.h>
#include <math.h>
static_assert(LDBL_MANT_DIG >= 64, "not enough precision");
// 入力:n <= 4.5*10^18, nは平方数
int64_t isqrt(int64_t n)
{
return (int64_t)sqrtl((long double)n);
}
この方法は long double
が64ビットな環境(MSVCとかApple Silicon Macとか)や long double
を提供していないプログラミング言語からは使えないのがネックです。
解法3:sqrtとroundを使う
double
で正確に表現できない可能性がある)とは言っても、平方根を取ってしまえば
何が言いたいかというと、sqrt(N/a)
は整数round
を適用してやれば正しい答えが得られるだろう、ということです。
// 入力:n <= 4.5*10^18, nは平方数
int64_t isqrt(int64_t n)
{
return llround(sqrt((double)n)); // llroundはroundの結果をlong longで返す関数
}
この解法はACしますが、「単にテストケースが弱い」のではなく、「本当に正しい」ことを証明してみましょう。
倍精度浮動小数点数で表現できる実数のなす集合を
定理1.実数
ただし、
【追記】この定理についてはより詳しい記事を書きました:浮動小数点数の丸めの相対誤差を計算機イプシロンで評価する
C言語の sqrt
は大抵の処理系ではIEEE 754に準拠、つまり「真の平方根に最も近い浮動小数点数を返す」ようになっています(正確に言うならC処理系がC言語の規格のAnnex Fに準拠する場合これが成り立つ)。
なので、証明したいのは
となります。
まず
となり、それぞれ平方根を取ると
となります。これを浮動小数点数に丸めると
となり、左と右を評価すると
となります。
あとは
を示せば十分です。
前半について。実数
と評価できます。あとは
後半について。実数
と評価でき、あとは
よって、「倍精度浮動小数点数でsqrtしてからroundする解法」が正しいことが証明できました。
解法4:sqrtと切り捨てを使う
C系の言語では浮動小数点数を整数にキャストする際は0方向への切り捨てが行われます。解法3で round
の代わりにキャストしてしまうとどうなるでしょうか?
// 入力:n <= 4.5*10^18, nは平方数
int64_t isqrt(int64_t n)
{
return (int64_t)sqrt((double)n);
}
実はこれもACしますが、これで問題ないことを証明するのはかなり厄介です。
とりあえずこの方法が問題ないことを実験的に確認してみましょう:
#include <inttypes.h>
#include <math.h>
#include <stdio.h>
int main()
{
// 3037000499^2 < 2^63-1 < 3037000500^2
for (int64_t i = 0; i <= 3037000499; ++i) {
int64_t n = i * i;
int64_t j = (int64_t)sqrt((double)n); // sqrtの結果を切り捨てる
if (i != j) {
printf("%" PRId64 "\n", i);
return 0;
}
}
puts("Done");
}
このプログラムが Done
を出力すれば反例はありません。(このプログラムを float
/ sqrtf
で実行すると普通に反例が出てきます。
では証明の時間です。
実数
すると
となります。右辺は
を示せば十分です。
実数
ここで
と評価できます。
よって、「倍精度浮動小数点数でsqrtしてから切り捨てる解法」が正しいことが証明できました。
雑感
浮動小数点数の誤差評価は面倒なのでなるべくやりたくないですね。特に切り捨ての解法の正当性の証明は面倒なので、浮動小数点数を整数に変換する際は安易なキャストではなく round
関数を使うようにしましょう。浮動小数点数を使わないやり方ができれば一番良いです。
安易にキャスト(切り捨て)して通ってしまう問題はあまり教育的ではない気もする。
Discussion
二分探索の他にも、整数上でニュートン・ラフソン法を使って平方根を計算する事もできます。