高精度黑科技——二进制分裂(Binary Splitting)

· · 算法·理论

什么是二进制分裂?

二进制分裂(Binary Splitting)可以十分快速地解决求一类无理数小数点后 n 位问题,这一类无理数的计算公式通常可以化成如下形式:

\sum^{\infin}_{i=1} \frac{P(i)}{R(i)} \prod^{i}_{j=1}\frac{R(j)}{Q(j)} 与其他在这方面的算法相比,该算法有以下优势: - **适用范围广**。我们可以用它轻松解决求 $\pi$,$e$ 和一系列著名的无理数小数点后 $n$ 位的问题。 - **更好理解**。理解这个做法,你只需要了解 $\sum$ 和 $\prod$ 的基础知识,拥有分式通分的能力以及拆式子的毅力。 - **相对好写**。(注意只是相对于其他这方面的算法,肯定没有暴力好写) - 为什么不用好写的暴力呢?自然是因为它**效率很高**。二进制分裂效率同样优于很多非暴力算法,是解决这一类问题最快的算法之一。 二进制分裂是 2024 年 5 月提出来的,在 [numberworld](https://www.numberworld.org/y-cruncher/internals/binary-splitting.html) 上面有原文,原文有二进制分裂的三种形式,这里主要介绍一般形式和其中一个特殊形式,同时原文作者也附有[更多用二进制分裂计算著名无理数的实例](https://www.numberworld.org/y-cruncher/internals/binary-splitting-library.html)。 ## 具体流程 我们直接讨论一般性问题的解法:计算无理数 $a$ 后 $m$ 位的值,公式如下所示: $$ a=\sum^{\infin}_{i=1} \frac{P(i)}{R(i)} \prod^{i}_{j=1}\frac{R(j)}{Q(j)} $$ ### 前置定义 我们先定义部分和 $S(l,r)$ 为: $$ S(l,r)=\sum^{r}_{i=l} \frac{P(i)}{R(i)} \prod^{i}_{j=l}\frac{R(j)}{Q(j)} $$ 考虑将 $S(l,r)$ 写成类似于 $\frac{A}{B}$ 的形式。 因此我们可以定义 $$ P(l,r)=\sum^{r}_{i=l} P(i) \cdot (\prod^{i-1}_{j=l}R(j)) \cdot (\prod^{r}_{j=i+1}Q(j)) $$ $$ Q(l,r)=\prod^{r}_{i=l}Q(i) $$ $$ R(l,r)=\prod^{r}_{i=l}R(i) $$ 因此我们便将 $P(l,r)$ 视作 $S(l,r)$ 的分子,$Q(l,r)$ 视作部分和的分母,$R(l,r)$ 为辅助计算的函数,即现在 $S(l,r)$ 可以表示为: $$ S(l,r)=\frac{P(l,r)}{Q(l,r)} $$ ::::info[如果你不知道为什么 $A(x)$ 为 $S(l,r)$ 的分子] 我们把 $S(l,r)$ 直接化成类似于 $\frac{A}{B}$: $$ \begin{aligned} S(l,r)&= \sum^{r}_{i=l} \frac{P(i)}{R(i)} \cdot \frac{\prod^{i}_{j=l}R(j)}{\prod^{i}_{j=l}Q(j)}\\ &=\sum^{r}_{i=l} \frac{P(i) \cdot \prod^{i-1}_{j=l}R(j)}{\prod^{i}_{j=l}Q(j)} \\ &=\frac{\sum^{r}_{i=l} P(i) \cdot (\prod^{i-1}_{j=l}R(j)) \cdot (\prod^{r}_{j=i+1}Q(j))}{\prod^{r}_{i=l}Q(i)}\\ \end{aligned} $$ 这样子一一对应上去,便能得出 $P(x)$ 的形式和含义。 :::: 假设我们要快速求 $S(1,r)$,那么我们现在就要考虑如何快速算出 $P(1 ,r),Q(1,r),R(1,r)$。 ### 递归过程 这里开始就是二进制分裂的核心部分,其思想是把整个求和区间掰成两半,分成 $[l,m]$ 和 $[m+1,r]$ 两部分递归处理,并将各部分乘积结果合并。 以下取 $m=\lfloor \frac{l+r}{2}\rfloor$。 那么每次递归,我们采取如下的合并式子计算即可: $$ \begin{cases} P(l,r)=P(l,m) \cdot Q(m+1,r)+R(l,m) \cdot P(m+1,r)\\ Q(l,r)=Q(l,m) \cdot Q(m+1,r)\\ R(l,r)=R(l,m) \cdot R(m+1,r) \end{cases} $$ ::::info[具体推导过程]{open} - 对于 $Q(l,r)$ 和 $R(l,r)$ 的合并,将掰开的两部分结果相乘即可。这没什么好说的。 $$ \begin{aligned} Q(l,r)&=\prod^{m}_{i=l}Q(i) \cdot \prod^{r}_{i=m+1}Q(i) \\ &=Q(l,m) \cdot Q(m+1,r) \end{aligned} $$ $$ \begin{aligned} R(l,r)&=\prod^{m}_{i=l}R(i) \cdot \prod^{r}_{i=m+1}R(i) \\ &=R(l,m) \cdot R(m+1,r) \end{aligned} $$ - 对于 $P(l,r)$,这一堆东西看起来不好直接得出合并的式子,考虑将 $S(l,r)$ 作如下拆分(注意细微差别): $$ \begin{aligned} S(l,r)&= \sum^{\color{red} m}_{i=l} \frac{P(i)}{R(i)} \prod^{i}_{j=l}\frac{R(j)}{Q(j)}+(\prod^{m}_{j=l}\frac{R(j)}{Q(j)}) \cdot (\sum^{\color{red} r}_{i=m+1} \frac{P(i)}{R(i)} \prod^{i}_{\color{red} j=m+1}\frac{R(j)}{Q(j)})\\ &=S(l,m)+(\prod^{m}_{j=l}\frac{R(j)}{Q(j)}) \cdot S(m+1,r)\\ &=S(l,m)+\frac{R(l,m)}{Q(l,m)} \cdot S(m+1,r) \\ &=\frac{P(l,m)}{Q(l,m)}+\frac{R(l,m)}{Q(l,m)} \cdot \frac{P(m+1,r)}{Q(m+1,r)} \\ &=\frac{P(l,m) \cdot Q(m+1,r)+R(l,m) \cdot P(m+1,r)}{Q(l,m)\cdot Q(m+1,r)}\\ &=\frac{P(l,m) \cdot Q(m+1,r)+R(l,m) \cdot P(m+1,r)}{Q(l,r)} \\ \end{aligned} $$ 由于我们知道 $S(l,r)=\frac{P(l,r)}{Q(l,r)}$,于是自然而然的得到了 $P(l,r)$ 的合并式子,即 $P(l,r)=P(l,m) \cdot Q(m+1,r)+R(l,m) \cdot P(m+1,r)$。 :::: ### 递归边界 很明显当 $l=r$ 时结束递归。 此时: $$ \begin{cases} P(l,r)=P(l) \\ Q(l,r)=Q(l) \\ R(l,r)=R(l) \end{cases} $$ 至此,基本流程就这么结束了,我们快速算出了 $P(1,r),Q(1,r),R(1,r)$ 的值,直接

S(l,r)=\frac{P(l,r)}{Q(l,r)}

## 代码框架 ~~人生苦短,我用 Python!~~ 这是一个用 Python 实现的代码框架。 ```python def P(x): # return val def Q(x): # return val def R(x): # return val # P(x),Q(x),R(x)为关于x的易于计算的函数。 def bs(l,r): if l==r: return (P(l),Q(l),R(l)) m=(a+b)//2 P1,Q1,R1=bs(l,m) P2,Q2,R2=bs(m+1,r) P=P1*Q2+P2*R1 Q=Q1*Q2 R=R1*R2 return (P,Q,R) # P1=P(l,m) # Q1=Q(l,m) # R1=R(l,m) # P2=P(m+1,r) # Q2=Q(m+1,r) # R2=R(m+1,r) # P,Q,R=(P(l,r),Q(l,r),R(l,r)) ``` ## 时间复杂度 假设我们要计算 $S(1,n)$。 那么,这个算法如果使用 FFT 实现高精度乘法,一般复杂度为 $O(n \log^3 n)$。 ::::info[分析与证明]{open} 递归次数显然是为 $O(\log n)$,递归到第 $k$ 层要合并 $2^k$ 个区间,计算三次乘法。合并一次区间时间复杂度为 $O(M(d))$,所以第 $k$ 层合并所有区间时间复杂度为 $O(2^k \cdot M(d))$,$d$ 为做高精度乘法时两个大数的位数。 具体分析 $O(M(d))$: - 使用 FFT 加速乘法,则单次高精度乘法时间开销 $M(d)=O(d \log d)$。 - 由于 $P(x),Q(x),R(x)$ 为有理函数,$x\leq n$ 时其值位数为 $O(\log n)$,区间长度 $L=\frac{n}{2^k}$,因此得大数位数 $d=O(L \log n)$。 最后得到合并单区间时间复杂度为: $$O(M(L \log n))=O(L \log n \cdot \log(L \log n))=O(L \log^2 n)=O(\frac{n}{2^k} \log^2 n)$$ (你要知道 $L \leq n$ 的时候,$\log(L \log n) \le \log(n \log n)$。) 于是显然第 $k$ 层合并所有区间时间复杂度为 $O(n \log^2 n)

最后得到这个算法的最终复杂度为 O(n \log^3 n)。 ::::

例题

1.计算 e

对应洛谷题目:https://www.luogu.com.cn/problem/P1729。

是不是感觉很容易了呢......

设我们计算 m 项级数的和后可以使 e 取到小数点后第 n 位,e_m=\sum^{m}_{i=0} \frac{1}{i!},来点简单的数学推导:

\begin{aligned} e_m &=1+\sum^{m}_{i=1} \frac{1}{i!} \\ &=1+\sum^{m}_{i=1}\frac{1}{1}\prod^{i}_{j=1}\frac{1}{j} \end{aligned}

不妨直接定义:

\begin{cases} S(l,r)=\sum^{r}_{i=l} \frac{1}{i!}\\ P(i)=1\\ Q(i)=i \\ R(i)=1 \end{cases}

直接按照上面的方式计算就完事了。

::::success[参考代码]

由于数据范围小,因此没有用 python 中自带 FFT 的 Decimal 库来加速运算,因为 FFT 常数不小会显得更慢。

代码没必要如此格式化,只是方便理解而已。

import sys,math
sys.set_int_max_str_digits(0)  # 注意到要计算小数点后10000位

def P(x):
    return 1
def Q(x):
    return x
def R(x):
    return 1
def bs(l,r):
    # 核心代码
    if l==r:
        return (P(l),Q(l),R(l))
    m=(l+r)//2
    P1,Q1,R1=bs(l,m)
    P2,Q2,R2=bs(m+1,r)
    QT=Q1*Q2
    RT=R1*R2
    PT=P1*Q2+R1*P2
    return (PT,QT,RT)
def main():
    n=int(input())
    # 多计算一些
    m=n+50

    PT,QT,RT=bs(1,m)

    fenzi=PT-QT
    fenmu=QT

    ansnum=math.floor((fenzi*(10**n))//fenmu)

    ansstr=str(ansnum)
    print("2.")

    for i in range(0,len(ansstr),50):
        l=ansstr[i:i+50]
        output=[l[k:k+10]for k in range(0,len(l),10)]
        print(' '.join(output))
if __name__=="__main__":
    main()

这里是提交记录,可见虽然用 python 实现,却一点也不虚。 ::::

::::info[思考:是否存在方法二?] 辅助函数 R(x) 都等于1了,那么 R(l,r) 不用说也等于 1,那么我们代入就能得到新的合并式子:

\begin{cases} P(l,r)=P(l,m) \cdot Q(m+1,r)+ P(m+1,r)\\ Q(l,r)=Q(l,m) \cdot Q(m+1,r)\\ \end{cases}

新的递推边界自然为

\begin{cases} P(l,r)=P(l) \\ Q(l,r)=Q(l) \\ \end{cases}

于是我们就得到了二进制分裂的一个特殊形式。

代码特别好写,在原通用形式上删掉有关 R(x) 的定义就可以了,这里只给出核心代码和提交记录,在数据范围小的情况下,这也并没有快多少。

def bs(l,r):
    if l==r:
        return (P(l),Q(l))
    m=(l+r)//2
    P1,Q1=bs(l,m)
    P2,Q2=bs(m+1,r)
    QT=Q1*Q2
    PT=P1*Q2+P2
    return (PT,QT)

这种特殊形式也是 numberworld 原文作者所采用的方法,不过只有计算 e\frac{1}{e} 才有用到。 ::::

2.计算 \pi

对应洛谷题目:https://www.luogu.com.cn/problem/P1727。

我们直接考虑使用 Chundnovsky 公式:

\begin{aligned} \frac{1}{\pi}&=12 \sum_{k=0}^{\infty}\frac{(-1)^k (6k)!}{(3k)! (k!)^3} \cdot \frac{13591409+545140134k}{640320^{3k+\frac{3}{2}}}\\ &=\frac{1}{426880\sqrt{10005}}\sum_{k=0}^{\infty}\frac{(-1)^k (6k)!}{(3k)! (k!)^3} \cdot \frac{13591409+545140134k}{640320^{3k}} \end{aligned}

由此得:

\pi=\frac{426880\sqrt{10005}}{\sum_{k=0}^{\infty}\frac{(-1)^k (6k)!}{(3k)! (k!)^3} \cdot \frac{13591409+545140134k}{640320^{3k}}}

有一种前面 1+1 后面造飞机的感觉。

看上去这么丑的一堆式子,直接计算肯定不行的,二进制分裂就派上用场了!

::::info[注]{open}

本例可能存在多种构造方式,这里主要介绍 numberworld 原文作者的构造方式,仅供参考。

::::

仍然设我们计算 m 项级数的和后可以使 \pi 取到小数点后第 n 位。

\pi_m=\frac{426880\sqrt{10005}}{\sum_{k=0}^{m}\frac{(-1)^k (6k)!}{(3k)! (k!)^3} \cdot \frac{13591409+545140134k}{640320^{3k}}}

那么

\begin{aligned} \pi_m&=\frac{426880\sqrt{10005}}{13591409+\sum_{k=1}^{m}\frac{(-1)^k (6k)!}{(3k)! (k!)^3} \cdot \frac{13591409+545140134k}{640320^{3k}}}\\ &=\frac{426880\sqrt{10005}}{13591409+\sum_{k=1}^{m}(13591409+545140134k)\cdot(-1)^k\cdot\frac{ (6k)!}{640320^{3k}(3k)!(k!)^3} } \end{aligned}

F(a) 等于右边带有阶乘的分式,即:

F(a)=\frac{(6a)!}{640320^{3a}(3a)!(a!)^3}

显然 F(0)=1

由于我们知道:

\begin{aligned} F(a)&=\prod^{a}_{j=1}\frac{F(j)}{F(j-1)}\\ \end{aligned}

于是我们直接大力硬算一波 \frac{F(a)}{F(a-1)},这个读者可以自己去算。结果是

\frac{F(a)}{F(a-1)}=\frac{(2a-1)(6a-1)(6a-5)}{10939058860032000a^3}

于是:

F(a)=\prod^{a}_{j=1}\frac{(2j-1)(6j-1)(6j-5)}{10939058860032000j^3}

反代回去(这里只拿分母出来看):

\begin{aligned} &13591409+\sum_{k=1}^{m}(13591409+545140134k)\cdot(-1)^k\cdot\frac{ (6k)!}{640320^{3k}(3k)!(k!)^3}\\ &=13591409+\sum_{k=1}^{m}(13591409+545140134k)\cdot(-1)^k\cdot F(k)\\ &=13591409+\sum_{k=1}^{m}(13591409+545140134k)\cdot(-1)^k\cdot \prod^{k}_{j=1}\frac{(2j-1)(6j-1)(6j-5)}{10939058860032000j^3}\\ &=13591409+\sum_{k=1}^{m}\frac{(13591409+545140134k)\cdot(-1)^k\cdot(2k-1)(6k-1)(6k-5)}{(2k-1)(6k-1)(6k-5)}\cdot \prod^{k}_{j=1}\frac{(2j-1)(6j-1)(6j-5)}{10939058860032000j^3}\\ \end{aligned}

终于化成了二进制分裂可以应用的形式!

现在就非常清晰了!

\begin{cases} S(1,m)=\sum_{k=1}^{m}\frac{(-1)^k (6k)!}{(3k)! (k!)^3} \cdot \frac{13591409+545140134k}{640320^{3k}}\\ P(a)=(13591409+545140134a)\cdot(-1)^a\cdot(2a-1)(6a-1)(6a-5) \\ Q(a)=10939058860032000a^3 \\ R(a)=(2a-1)(6a-1)(6a-5) \end{cases}

由此得到

\pi_m=\frac{426880\sqrt{10005}}{13591409+S(1,m)}

还没完!由于最后求出 S(1,m) 需要一次高精度除法,带回去又需要一次高精度除法,很慢,所以不妨直接代入 S(1,m)=\frac{P(1,m)}{Q(1,m)},得到:

\begin{aligned} \pi_m&=\frac{426880\sqrt{10005}}{13591409+\frac{P(1,m)}{Q(1,m)}}\\ &=\frac{426880\sqrt{10005}\cdot Q(1,m)}{13591409 \cdot Q(1,m)+P(1,m)} \end{aligned}

完成!

::::info[注]{open} numberworld 原文作者最后把分子的 \sqrt{10005} 移到分母去了,变成:

\pi_m=\frac{1}{\sqrt{10005}} \cdot \frac{4270934400 \cdot Q(1,m)}{13591409 \cdot Q(1,m)+P(1,m)}

计算效率上没什么差别。 :::: ::::warning[特别注意:对于 m 的取值]{open}

如果直接 m=n+kk 你可以自取正整数,为了保证精度用的。)会慢亿点,然而 Chundnovsky 公式每计算一项可以增加约 14.18 位精度,因此我们应取 m=\lfloor\frac{n}{14}\rfloor+k

这是写给那些像我一样写完后才发现这个事的小傻瓜的。 ::::

::::success[参考代码]

同样的,由于数据范围小,不用 Decimal。

import sys,math
sys.set_int_max_str_digits(0)  # 注意到要计算小数点后10000位

def P(x):
    p=-1
    if x%2==0:
        p=1
    return (13591409+545140134*x)*(6*x-5)*(6*x-1)*(2*x-1)*p
def Q(x):
    return 10939058860032000*x*x*x
def R(x):
    return (6*x-5)*(6*x-1)*(2*x-1)
def bs(l,r):
    # 核心代码
    if l==r:
        return (P(l),Q(l),R(l))
    m=(l+r)//2
    P1,Q1,R1=bs(l,m)
    P2,Q2,R2=bs(m+1,r)
    QT=Q1*Q2
    RT=R1*R2
    PT=P1*Q2+R1*P2

    return (PT,QT,RT)
def main():
    n=int(input())
    # 多计算一些
    m=n//14+2

    PT,QT,RT=bs(1,m)

    fenzi=QT

    fenmu=(PT+13591409*QT)

    ansnum=math.floor((426880*fenzi*math.isqrt(10005*10**(n+n)))//(fenmu))

    ansstr=str(ansnum)
    ansstr=ansstr[1:n+1]
    print("3.")

    for i in range(0,len(ansstr),50):
        l=ansstr[i:i+50]
        output=[l[k:k+10]for k in range(0,len(l),10)]
        print(' '.join(output))
if __name__=="__main__":
    main()

提交记录 ::::

这道重要的例题告诉了我们:

这也是解决一般二进制分裂问题所必须要注意的。

写在最后