BBP公式计算十万位十六进制圆周率(C语言)

BBP公式简介

贝利-波尔温-普劳夫公式(BBP 公式) 提供了一个计算圆周率π的第n位二进制数的算法。于1985年被提出。其衍生出的贝拉公式,是当前世界上计算机实现圆周率计算效率最高的公式之一。公式的形式如下。
\pi=\sum_{k=0}^{\infty}\left[\frac{1}{16^{k}}\left(\frac{4}{8 k+1}-\frac{2}{8 k+4}-\frac{1}{8 k+5}-\frac{1}{8 k+6}\right)\right]
公式的形式似乎不是很优美。但是摁一下计算器就可以发现,收敛的速度是很快的。注意到式子中16^k很适合计算机二进制,这也是我们快速求圆周率十六进制表示的关键。

为了求前面n位十六进制的圆周率,首先可以将式子两头乘以16^n,得到:
16^n\pi=\sum_{k=0}^{n}\left[\frac{4\cdot{16^{n-k}}}{8 k+1}-\frac{2\cdot{16^{n-k}}}{8 k+4}-\frac{1\cdot{16^{n-k}}}{8 k+5}-\frac{1\cdot{16^{n-k}}}{8 k+6}\right]\\+\sum_{k=n+1}^{\infty}\left[\frac{1}{16^{k-n}}\left(\frac{4}{8 k+1}-\frac{2}{8 k+4}-\frac{1}{8 k+5}-\frac{1}{8 k+6}\right)\right]

左边式子可以看做\pi的十六进制表示下,小数点向右侧位移了n次,整数部分,就是我们想要的答案。

可以看出,右边式子前面的项对于前面n位有决定性意义。而后面一项则有影响,但是影响不大。

如果将第一个求和项中的除法全部换成向下的整除\lfloor \frac{a}{b}\rfloor,并且省略掉第二个求和项。会对末尾的几位造成较大影响。因此一般会选择将实际取的n增大一些,例如我们今天要求n=100000,稍微安全点就应该取100008,多求八位,还不保险就取更大一些,然后得到答案时就取前面100000即可。

剩下的第一个求和项,实现起来就并不困难了。

Python 实现

在用C语言实现时,我们先用Python打一下草稿。

import time
f=open("/home/max/桌面/2.txt","w")
ans=0
v=100008
sta=time.time()
tmp=16**(v)
for k in range(v):
    a=tmp*4//(8*k+1)
    b=tmp*2//(8*k+4)
    c=tmp//(8*k+5)
    d=tmp//(8*k+6)
    ans+=a-b-c-d
    tmp//=16
ed=time.time()
print(ed-sta)
f.write(hex(ans)[2:-8])
f.close()

Python的优点是非常便于实现。自带的大整数实际表现也非常高效。

本机运行大概48.8s,就成功求出了答案。

C语言

C语言需要自己实现大整数的运算。但实际上,我们发现只有大整数相加,相减,和整除以一个很小的数字。而可以不用到大整数相乘相除。

为了高效性和便捷性,我们应该充分应用存储数组的每一比特。例如是unsigned类型存储,就应该是2^{32}进制。这样涉及到运算的位数就会尽量少,最后输出时,也只需要用printf\%x标识。

为了可能性的延拓,先定义好一些可替换的标识符和常量:

#define LIM 100008
#define ARRAY_SZ 12505  //>LIM*4/SZ_BASE_t

typedef unsigned int BASE_t;
typedef unsigned int ITER_t;
typedef unsigned long long DOUBLE_BASE_t;

const ITER_t SZ_BASE_t = sizeof(BASE_t) * 8;
const ITER_t ITER_END = (ITER_t) (-1);
const BASE_t var1[4] = {2, 1, 0, 0};
const BASE_t var2[4] = {1, 4, 5, 6};
const BASE_t BASE_INF = (BASE_t) (-1);

这里\text{LIM}代表了之前的n。根据\text{LIM}我们计算出一个大数存储所需的\text{ARRAY_SIZE}

下面是几个类型标识,\text{BASE_t}代表大数的存储类型,\text{ITER_t}是用于下标变量和长度变量的。\text{DOUBLE_BASE_t}代表了位长是\text{BASE_t}两倍的类型,做除法时会用到。

\text{var1}\text{var2}是BBP公式四项的参数。

下面定义\text{BitSet}结构体来记录我们的大数,其中\text{len}记录大整数最高位所在的位置,下标0\sim \text{len}记录了自低位到高位。以及\text{divd}记录除法的除数。

struct BitSet {
    BASE_t d[ARRAY_SZ];
    ITER_t len;
} tmp[4], ans;

BASE_t divd[4];

我们的主要逻辑如下:

for (int k = 0; k < LIM; k++) {
        for (int i = 0; i < 4; i++) {
            divd[i] = (BASE_t) k << 3 | var2[i];
            set_bit(tmp + i, (ITER_t) (4 * (LIM - k) + var1[i]));
            divide(tmp + i, divd[i]);
            if (i)
                dec(tmp + i);
            else
                add(tmp + i);
        }
    }

外层循环BBP公式的项,内层循环代表一项的四个部分。\text{set_bit}函数为\text{tmp[i]}设置成分母(某一位是1,其余位都是0),接着做除法,最后是加法或者减法,将\text{tmp}的答案更新到\text{ans}中。

下面是重要的四个函数的实现:

void set_bit(struct BitSet *x, ITER_t pos) {
    memset(x->d, 0, sizeof(BASE_t) * (x->len + 1));
    x->len = pos / SZ_BASE_t;
    x->d[x->len] = (BASE_t) 1 << pos % SZ_BASE_t;
}

void divide(struct BitSet *x, BASE_t val) {
    BASE_t c = 0;
    DOUBLE_BASE_t temp;
    for (ITER_t i = x->len; i != ITER_END; i--) {
        temp = x->d[i] + ((DOUBLE_BASE_t) c << SZ_BASE_t);
        x->d[i] = (BASE_t) (temp / val);
        c = (BASE_t) (temp % val);
    }
    if (x->len >= 0 && !x->d[x->len])
        --x->len;
}

void add(struct BitSet *y) {
    if (y->len == ITER_END)
        return;
    BASE_t c = 0, t;
    ITER_t l = y->len;
    if (!ans.len)
        ans.len = l;
    ans.d[ans.len + 1] = 0;
    for (ITER_t i = 0; i <= l; i++) {
        t = c;
        c = (BASE_t) ((y->d[i] == BASE_INF && c) || BASE_INF - y->d[i] - c < ans.d[i]);
        ans.d[i] += y->d[i] + t;
    }
    while (c) {
        ++l;
        if (ans.d[l] != BASE_INF)
            ++ans.d[l], c = 0;
        else
            ans.d[l] = 0;
    }
}

void dec(struct BitSet *y) {
    if (y->len == ITER_END)
        return;
    BASE_t c = 0, t;
    ITER_t l = y->len;
    for (ITER_t i = 0; i <= l; i++) {
        t = c;
        c = (BASE_t) ((y->d[i] == BASE_INF && c) || ans.d[i] < y->d[i] + c);
        ans.d[i] -= y->d[i] + t;
    }
    while (c) {
        ++l;
        if (!ans.d[l])
            ans.d[l] = BASE_INF;
        else
            --ans.d[l], c = 0;
    }
}

这里不作过多解释。需要注意的是,这部分代码利用了一些鲁棒性不是很好的事实:\text{tmp}\text{ans}一开始是全部变量,初始为0,以及除了首次加法,\text{ans}之后的加减法不会出现进位和退位等等。我先实现完整的代码再做删减,这样可以尽量保证不会出错误,效率也会好些。

并行化

最近刚刚接触了基础openmp,这里可以给这个程序做个小小的并行化改动。

我们将每一项,当做一个任务,那么一共有\text{LIM}个任务,可以分给本机的四线程。另外,由于对于\text{ans}的修改不应冲突,所有用\text{critical}子句加以限制。另外要注意,线程A做了一次加法之后,一旦B,C,D都要做减法,那么可能导致\text{ans}出现暂时的减出负数的情况。这不是我们希望看到的,因此,我们需要把第一项k=0单独拎出来先做。

实现时发现一个有趣的事情。并行化之前和并行化之后,我用\text{time.h}中的\text{clock()}函数计时,结果之后反而变慢了一点。然而感官感觉时间缩短了不少。经过了解之后才知道\text{clock()}统计的是\text{CPU}运行时,而非程序运行时间。这里需要用\text{omp.h}中的\text{omp_get_wtime()}来计时。


总代码如下

#include <stdio.h>
#include <string.h>
#include <omp.h>

#define LIM 100008
#define ARRAY_SZ 12505  //LIM*4/SZ_BASE_t+5

typedef unsigned int BASE_t;
typedef unsigned int ITER_t;
typedef unsigned long long DOUBLE_BASE_t;

const ITER_t SZ_BASE_t = sizeof(BASE_t) * 8;
const ITER_t ITER_END = (ITER_t) (-1);
const BASE_t var1[4] = {2, 1, 0, 0};
const BASE_t var2[4] = {1, 4, 5, 6};
const BASE_t BASE_INF = (BASE_t) (-1);

struct BitSet {
    BASE_t d[ARRAY_SZ];
    ITER_t len;
} tmp[4], ans;

BASE_t divd[4];

void set_bit(struct BitSet *x, ITER_t pos) {
    memset(x->d, 0, sizeof(BASE_t) * (x->len + 1));
    x->len = pos / SZ_BASE_t;
    x->d[x->len] = (BASE_t) 1 << pos % SZ_BASE_t;
}

void divide(struct BitSet *x, BASE_t val) {
    BASE_t c = 0;
    DOUBLE_BASE_t temp;
    for (ITER_t i = x->len; i != ITER_END; i--) {
        temp = x->d[i] + ((DOUBLE_BASE_t) c << SZ_BASE_t);
        x->d[i] = (BASE_t) (temp / val);
        c = (BASE_t) (temp % val);
    }
    if (x->len >= 0 && !x->d[x->len])
        --x->len;
}

void add(struct BitSet *y) {
    if (y->len == ITER_END)
        return;
    BASE_t c = 0, t;
    ITER_t l = y->len;
    if (!ans.len)
        ans.len = l;
    ans.d[ans.len + 1] = 0;
    for (ITER_t i = 0; i <= l; i++) {
        t = c;
        c = (BASE_t) ((y->d[i] == BASE_INF && c) || BASE_INF - y->d[i] - c < ans.d[i]);
        ans.d[i] += y->d[i] + t;
    }
    while (c) {
        ++l;
        if (ans.d[l] != BASE_INF)
            ++ans.d[l], c = 0;
        else
            ans.d[l] = 0;
    }
}

void dec(struct BitSet *y) {
    if (y->len == ITER_END)
        return;
    BASE_t c = 0, t;
    ITER_t l = y->len;
    for (ITER_t i = 0; i <= l; i++) {
        t = c;
        c = (BASE_t) ((y->d[i] == BASE_INF && c) || ans.d[i] < y->d[i] + c);
        ans.d[i] -= y->d[i] + t;
    }
    while (c) {
        ++l;
        if (!ans.d[l])
            ans.d[l] = BASE_INF;
        else
            --ans.d[l], c = 0;
    }
}

int main() {
    FILE *fo = fopen("/home/max/桌面/1.txt", "w");
    double sta = omp_get_wtime();
    for (int i = 0; i < 4; i++) {
        divd[i] = (BASE_t) var2[i];
        set_bit(tmp + i, (ITER_t) (4 * LIM + var1[i]));
        divide(tmp + i, divd[i]);
        if (i)
            dec(tmp + i);
        else
            add(tmp + i);
    }
#pragma omp parallel for firstprivate(tmp, divd)
    for (int k = 1; k < LIM; k++) {
        for (int i = 0; i < 4; i++) {
            divd[i] = (BASE_t) k << 3 | var2[i];
            set_bit(tmp + i, (ITER_t) (4 * (LIM - k) + var1[i]));
            divide(tmp + i, divd[i]);
#pragma omp critical(the_ans)
            if (i)
                dec(tmp + i);
            else
                add(tmp + i);
        }
    }
    printf("%.10lf\n", omp_get_wtime() - sta);
    fprintf(fo, "%x", ans.d[ans.len]);
    for (ITER_t i = ans.len - 1; i; i--)
        fprintf(fo, "%08x", ans.d[i]);
    return 0;
}

测试效果

CPU 酷睿i5双核四线程,运行环境为Clion,编译器为gcc 7.5.0,Release模式,大致相当于gcc -fopenmp -O3 -o main.out main.c

并行前效率为32s左右,胜过Python。其中占用比较多的是\text{divide}函数,占用大概十分之九的时间。原因在于取模和除法运算都比较耗时。

并行后在16s16.5s之间,提升大约一倍。

将基础类型换成\text{unsigned long long},\text{unsigned __int128},用时会略有增长。换成\text{unsigned short},\text{unsigned int},会出现分母溢出的情况(k较大时大于65536),减小\text{LIM}再观察,逊于前两者。因此64位机子,还是用\text{unsigned int}比较合适。

一些说明和潜在的优化

  1. 并行并不是很彻底。这里计算机分配任务可能不均匀。另外虽然\text{add/dec}用时只占有十分之一,但是\text{critical}下造成的阻塞,估计也会增长时间1s左右。可以有其他的并行方法,另外也可以最后进行答案的聚合,避开\text{critical}
  2. 除法和取模是同一种运算,或许可以用汇编优化,同时得到商和余数。
  3. 这里只是求出了十六进制。如果要转换成十进制,需要乘以10^n除以16^n,再从十六进制转换为十进制,过程麻烦,而且效率是O(n^2)的。十六进制转换为十进制暂时想不出非常高效率且方便的做法。留给大家思考好了。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 200,176评论 5 469
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,190评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 147,232评论 0 332
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,953评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,879评论 5 360
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,177评论 1 277
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,626评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,295评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,436评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,365评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,414评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,096评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,685评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,771评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,987评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,438评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,032评论 2 341

推荐阅读更多精彩内容