高精度计算pi [P1727]
TBB_Nozomi · · 题解
blog:Masof TBB
前记:本题是目前洛谷所有含有“高精度”的 tag 中唯一一个含有高精度圆周率计算的题目。如果对本题做出扩展,也可以将本题当作高精度反三角函数的题目来做。因此我就来这个题下手了(
在本文中,本文出示本题解答的四种方法,并简要证明其它题解中出现但尚未证明的一些恒等式,帮助各位读者理解。在不另外加以说明的情况下,本题默认
0. 打表
前人之述备矣。如果你想用打表来求解此题,唯一需要注意的问题是将内容格式化为程序所能接受的形式。N[Pi, 10001]
即可得到满足题目需求的圆周率小数点后 10000 位的全文本。
1. 级数与迭代
前置知识:一个你觉得好用的好计算的
和
称为 Leibniz-Gregory 级数。这一式子本质上来源于反正切函数
然后将
从
我们取其中一个公式作为例子。由
哪怕仅仅只是从数列的项的大小来看,我们也能预感到这个级数的收敛速度应该远快于最开始的那个例子。事实上,取
在一些人题解中,提到了下面这个公式
其中
上面的公式都有一个共同点:他们的数列的通项形式上都相对的简单,而且普遍的能使用前一项去推出后一项。利用这一特点,我们可以调整项的计算方式,来得到更加简洁,且精确度更高计算过程。以上面的 (Euler-T) 的式子为例,假设我们已经确定使用前
由这个式子,我们从
时间复杂度:设所需的精度为
部分代码样例:(使用
LFloat _pi(void) {
static LFloat inner_pi= 3;
static int pi_precsion= 0;
if(_LFloat_prec < pi_precsion) { //缓存pi的计算结果,若精度满足要求则直接输出
LFloat ret = inner_pi;
ret.sho(); return ret;
}
pi_precsion= _LFloat_prec;
if(_LFloat_prec > 1000) return inner_pi = 4 * arctan(1);
static LInt numerator= 1, denominator= 1; // 缓存上次计算时的分子和分母
static LInt iter_factorial= 1;
static int done_iter_times= 0;
int new_iter_times= 6.09832411290916 + 13.28771237954945 *_LFloat_prec;
LInt delta_numerator=0, delta_denominator= 1, delta_iter_factorial= 1;
for(int k= new_iter_times; k > done_iter_times; --k) { // 计算新增加的项的部分的分子和分母
delta_numerator+= delta_denominator;
delta_numerator*= k;
delta_denominator*= 2*k+1;
delta_iter_factorial*= k;
}
numerator= numerator * delta_denominator + iter_factorial * delta_numerator;
denominator*= delta_denominator; // 计算通分后的新的分子和分母
iter_factorial*= delta_iter_factorial;
return Div_LInt(numerator, denominator) * 2;
}
2. 高精度反正切函数
前置知识:基本的微积分知识,时间复杂度为
要实现高精度的反正切函数,最简单的方法先将
首先我们需要对
由此可以得到
我们将相邻的正负符号相反的两项看作一组项,现在我们仅仅只取
如果我们选取
这意味着我们能估计的相对精度
相比于这题所需要的精度而言,
利用正切函数的倍角公式,我们可以得到
其中当 exp
函数一样,拥有一个等比例的放缩函数,且每放缩一次所需的计算量为
另一方面,放缩以后利用相邻项之间的递推,每计算一项就会进行一次高精度乘法。不妨设最后我们将
部分代码样例如下。经测试,在 -O2
情况下可以至多 2.96s 的时间通过此题。
LFloat arctan(const LFloat& x) {
if(x.isNaN()||x.zero()) return x;
if(x.isinf() && x.negative()) return arctan(-1)*2;
if(x.isinf() && x.positive()) return arctan(1)*2;
if(x<0) return -arctan(-x);
if(x>1) return arctan((sqrt(x*x+1)-1)/x)*2; // 规约x到[0,1]中
struct{
LFloat operator()(const LFloat& t) {return t / (sqrt(t*t+1)+1);}
} scale_func; // 放缩函数
int precision= _LFloat_prec * 4;
int bound= int(std::sqrt(0.2006866637759875 * precision / 16)); //bound= Sqrt(2 Lg 2*p/3)/16
LFloat B= pow10<LFloat>(-bound);
int n= precision/bound +1; //expansion terms count
int k= 0; //scale times
_LFloat_prec= (precision + Log_2(precision) + 3.322*bound)/4 + 1;
LFloat x_scaled= x;
x_scaled.sho();
while(x_scaled > B) { //scaling
x_scaled= scale_func(x_scaled);
++k;
}
LFloat y_scaled= 0, x2= x_scaled * x_scaled;
for(int i= 4*n-1; i>=1; i-=2) y_scaled= -x2*y_scaled + LFloat(1.0)/i; // 求较小的x的近似值
y_scaled*= x_scaled;
LFloat& y= y_scaled;
for(int i= 0; i<k; ++i) y= y*2;
_LFloat_prec= precision/4;
y.sho();
return y;
}
2.5. 欧拉变换与超几何函数
阅读前提醒:本部分用于加速反正切函数的计算,但是不能改善其时间复杂度。同时,本部分也会介绍广受好评的公式
前置知识:第2节前置知识;组合数相关知识或生成函数。可选其它补充知识:超几何函数(《具体数学》)。相关的资料可以参考 Wolfram Mathworld 的 Euler Transform。
在继续前进之前,我们先来了解一个恒等式。假设
则
后面的级数将与前一个级数收敛到同一个数,且收敛速度更快。这被称作欧拉改进收敛法,也被称为欧拉变换。这里用生成函数简单证明一下这个式子的收敛性:在定义
其中
接下来我们看看这个恒等式能用在什么地方。仔细一看,
我们再来看一下这个恒等式能有什么用途。观察
其中,打括号的幂指数部分表示上升幂(不知道为什么洛谷突然不支持 \overline
指令了)
。特别的,有
不加证明的,这里给出一个超几何变换(一个恒等式):
这个变换公式被称为反射定律,也被称为欧拉超几何变换。当
这个式子已经变成了一个计算量常数相对小的正项级数了,可以跑出一个相当不错的时间。按照第2节内的方法调整放缩和展开系数,可以达到常数比较小的
部分代码如下。在开启 -O2
选项的时候成功跑进了 1s 内,不开启时最大的点也只有 4.90s。
LFloat arctan2(const LFloat& x) {
if(x.isNaN()||x.zero()) return x;
if(x.isinf() && x.negative()) return arctan(-1)*2;
if(x.isinf() && x.positive()) return arctan(1)*2;
if(x<0) return -arctan(-x);
if(x>1) return arctan((sqrt(x*x+1)-1)/x)*2;
struct{
LFloat operator()(const LFloat& t) {return t / (sqrt(t*t+1)+1);}
} scale_func;
int precision= _LFloat_prec * 4;
int n= std::ceil(std::sqrt(precision/2)*12) + 1;
int bound= std::ceil(1.0*precision/2/n) + 1; //expansion terms count
LFloat B= pow10<LFloat>(-bound);
int k= 0; //scale times
_LFloat_prec= (precision + Log_2(precision) + 3.322*bound)/4 + 1;
LFloat x_scaled= x;
x_scaled.sho();
while(x_scaled > B) { //scaling
x_scaled= scale_func(x_scaled);
++k;
}
LFloat z= 1 / (1 + x_scaled * x_scaled);
LFloat y_scaled= 1, t= 1-z;
for(int i= n; i>= 1; --i) y_scaled= y_scaled*t*2*i/(2*i+1) + 1;
y_scaled*= x_scaled * z;
LFloat& y= y_scaled;
for(int i= 0; i<k; ++i) y= y*2;
_LFloat_prec= precision/4;
y.sho();
return y;
}
3. AGM 方法
前置知识:《理性愉悦——高精度数值计算》。
参考论文:Eugene Salamin 于 1976 年发表于 Mathematics of Computation 上的论文 Computation of pi Using Arithmetic-Geometric Mean。
本来这篇题解到这里就该结束了,但没想到课件的后面提出另一个作为目前计算初等函数时间复杂度最低的方法:AGM 方法。囿于这部分相关的数学知识不熟悉,本题解无法提供该方法正确性的证明。
下面简单的介绍一下这个方法。假设给出两个正数
这两个序列的取值分别为上一项的两个数的算数平均值和几何平均值序列。容易证明这两个序列一个单调递增一个单调递减,且对应项的差会趋近于
这个
可以发现
时间复杂度:每迭代一项需要做一次高精度加法、一次高精度乘法和一次高精度开平方根,也就是
部分代码如下。在不开启 -O2
的情况下,最慢的点需要 1.2s,若开启则可以加速到 300ms 以内。
LFloat agm_pi(void) {
int precision= tbb::LFloat::precision() * 4;
tbb::LFloat::precision(std::ceil((precision+Log_2(precision))/4.0)); // 调整过程精度
LFloat a= 1, b= 1/sqrt(LFloat(2));
int n= Log_2(precision);
LFloat S= pow(b-a, 2);
for(int i=1; i<=n; ++i) {
LFloat an= (a+b)/2, bn= sqrt(a*b);
S+= (1<<i)*pow(bn-an, 2);
a= an, b= bn;
}
LFloat ans= (4*a*a)/(1-S);
tbb::LFloat::precision(precision/4);
ans.sho();
return ans;
}