高效大整数运算库-------An Efficient Library for BigInteger

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/pp634077956/article/details/65445602

———-高效的高精度大整数库———-

————-本着锻炼一下编程和优化能力的目的,花了两个多星期完成了这个作品。具体的性能比较可以参考这个:性能比较

0. 其实在1年多前刚刚开始学习C++的时候也曾经做过一个用字符串来表示大整数的练习,但是运行速度很慢,所以我首先将原来的代码翻了出来看了看,首先把所有的参数改成const &的类型,当时写的时候还不知道引用….所以结果就快了几倍。然后发现了karatsuba算法实现错了,因为我用了4次乘法,这样计算出来的复杂度其实是O(n^2),原来第四次乘法完全可以用一次减法计算出来。经过修改以及debug之后,效率又提高了不少。然后我又针对代码进行了一些语言层面的优化,比如尽量减少拷贝,减少比较的次数….比1年多前的效率提高了不少,

1. 开始:

如果只是完成上面的成果,那么就在github项目的StringBigData里面了,一天就可以完成。但是这样是远远谈不上高效的,首先存在空间的浪费,1个char其实占8bit,但是我只用来表示0-9的字符,其次存在时间的低效,因为我们计算的时候将1个字符1个字符的计算,而且还要花时间在字符-整数的转换上面,所以常规的做法就是将多位整数保存成一个单位,在不超过int长度的情况下,这和每次计算一个1位的整数耗费的时间应该是一样的。

基于上面的考虑,我重新开始写代码,实现了以 108 为基的大整数类,中间重新实现了各个加减乘除,构造函数,拷贝控制,赋值等成员函数。这里花了3-4天的时间来做,因为算法的原理容易理解,但是实现的时候会出现很多的corner case大大延缓你的进度。其中重要的一点就是整数溢出的问题,这里不多谈,即使我已经提前注意到这个问题并且小心了,但是还是在这里出现了bug并且花了一下午才找到原因^~~~~^.其余各种情况就不说了,总之知易行难。

2. 瓶颈

在完成了基本的运算以后,算法的效率达到一个瓶颈,这时候就开始在茫茫网络上搜索需要的资料,不得不说这是一个比较辛苦的过程,因为有时候需要看别人的代码,有时候需要从别人的只言片语提取想要的信息。最终给我帮助的是这本书 <<MattersComputational>> ,里面几乎涵盖了各种常见的数值计算的算法,比起网上的资料详细且逻辑清晰。然后就是stack overflow里面的各种回答了。首先我完成了FFT算法,其实FFT的实现网上很多,原理我也清楚,毕竟本科通信出身。但是一开始我还是不懂怎么把FFT运用到大整数乘法上面来,最后经过仔细的推导公式才较为深刻的理解了频域和时域转换的一个应用,本科的学习还是纸上谈兵啊。

FFT的递归实现并不复杂,我自己也仿照着写了一个,基本思路就是将前半部分的结果作为FFT0,后半部分的结果作为FFT1,然后每次递归只传指针和步长(每次加倍)…..这里花了不少时间理清楚之间的逻辑关系,出错了调试起来也比较痛苦,因为是在频域上的计算,相对抽象。

接下来就是非递归的FFT实现,因为非递归肯定比递归的开销小,而且没有栈的限制,但是这个实现我一开始认为并不难,在没有查阅资料的情况下写了一个,但是怎么都调试不正确,最后发现是递推关系写错了,然而我并不能找到正确的递推关系,准确讲在第k次循环里面(此时已经分割2^k份),F[k]的系数对应的偶数和奇数项是什么?抱歉实在推导不出来,于是参考了wikipedia上面的butterfly方法,然后又找到了一个外国人fortran写的90年代的伪代码,终于弄明白了实现的原理,里面还是很有一些比较trick的东西,比如利用三角代换计算频率之类的,要搞懂花了我一下午研究代码。最后自己认真仿写也终于完成了这一部分,可以说FFT从开始查阅到非递归的完成花了大概3天半的时间,其中自己思考的非递归版本不正确也着实打击了我一次。最后还要注意FFT导致的误差,所以选择的基不能太大。但是整数太大也会导致误差,所以还要通过karatsuba算法来进行拆分配合FFT实现位数的突破。另外网上有些人认为在位数较小的时候karatsuba算法的速度超过FFT,其实几百位的乘法FFT几乎都是0ms了,所以可以说肯定是你的FFT没写好才会导致不如karatsuba快的。

3.从十进制到二进制

现在轮到除法了,除法的长除法最有名的是knuth在TAOCP里面提出的试猜法,而我自己最开始实现的是普通的模拟运算的办法,现在我想通过牛顿迭代的办法来使得除法能够达到和乘法一个级别,但是这样存在一个问题,牛顿迭代存在浮点数,但是我的大整数类只支持整数运算,应该怎样才能实现呢。我查询了很多的资料找到了解决的办法,其实和编译器对除法的优化非常相似,我们只需要找到一个 2k 使得 2k>AB ,那么 [AB]=(A2kB)>>k ,而对 2k 的除法可以用移位来代替。上面那个式子我证明了1个小时才写出来,有兴趣的自己算一算,这里就不说了。然而这里就遇到了新的问题,移位在十进制里面并不容易实现,计算复杂度会变得特别的高,这样就起不到效果了,所以我决定对整个项目进行重构,换成二进制的基来做计算。

换成二进制有几个好处,首先移位运算变得很快,其次加减都可以用位运算计算出来,最后还可以完美的利用一个int变量的空间。所以我选择了 232 作为基来进行计算,整个项目大的框架是不会发生变换的,但是很多细节都不一样了,这里也花了很多时间来调整程序。

二进制有好处就存在坏处,最不好的一点就是用户一般都是输入10进制的,那么我们就必须完成10进制到二进制的一个转换,这个问题看似很简单,但是一旦牵扯到效率就没有那么容易了。对等的还有从大整数类转换到10进制的表达形式,也是一个比较难的问题。

4. 重生之后

当我花了一整天的时间把所有的十进制都转换成二进制之后,实现除法就是比较容易的事情了,当然里面也有一些corner case和优化,比如最后的解可能在两个数值之间不停的跳转无法收敛,可以对开始几次运算进行truncate multiplication以加快运算速度等。

现在的问题已经完全聚焦到如何使得进制转换更加有效率了,对于几千位的十进制这不是一个问题,一个简洁但是优化过的程序可以使得完成3000位的十进制转换只需要20ms左右。利用了一些static数组,各种避免拷贝,预处理的小优化。但是对付几十万位的十进制这就太慢了,可以说复杂度成平方数量级上升。原因是传统的办法会模拟除2取余的这个办法。当数量级上涨以后,除2直到0会变得非常慢(试想对100000和100000000000000)进行除2取余的操作。

我通过查阅资料加上开动大脑,找到了一个办法来突破这个瓶颈.我们可以每次从最简单的情况考虑, a10=a<<3+a ,而计算一个很长的10进制又可以变成 a10+remain 的迭代过程。我们完全可以把10替换成10的任意幂来加快速度。所以利用加法,移位我们可以把10进制变成2进制,这里就取决于两点:(1)首先 a10k 在二进制里面怎么表示?(2)其次k该取多大?

5.探究

对于(1),我们完全可以采用原始的计算办法计算 10k 的二进制表示,然后把这个乘法变成移位和加法。这也是我最开始的实现。思路显然可行,结果也还可以。但是面对20万位以上的转换还是特别的慢,所以我又想到一个办法,我们每次计算 a10k 不去计算移位和加法了,而是直接把 10k 变成一个大整数类,然后直接相乘,由于我们高效的非递归FFT乘法,这个过程可能比起原来的方法要快很多,那么转换这样一个 10k 其实就变成了一个递归的问题。

对于(2),其实由于问题变成了一个递归的结构,那么我们可以不用固定k,而是每次初始化k,然后在递归的时候减半k,这样由于每次的k是固定的,那么它就形成了一个序列,我们完全可以进行空间换时间的思路,把 10k,10k2... 对应的BigInteger提前计算出来,这样我们每次都只需要计算10进制表达里面的那k个10进制数就好,经过实践发现,这样的办法是完全可以的。但是会造成启动程序的时候很慢(预处理),所以我选择吧这一部分的计算放在解析10进制的过程里面,遇到先查表,没有就计算,有就直接取出来,这样的结果是第一次会比较慢,后面的转换就相对快一点。

6. 再探究

同时我们还需要把BigInteger这个大整数类转换成字符转显示出来,最常见的需求就是10进制了,转换成2进制比较容易。普通的计算方法还是老问题简单但是很慢,我首先采取了wiki上的一种叫做dabble double的算法,用来计算2进制到10进制,虽然比起原始的乘2的做法要快一点,但是不足以应付大数量级的问题。

我这里采取的办法是这样的:(1)对大整数B计算 /10k mod10k ,这里只要用一次计算就可以得到两个结果,然后余数就显然是10进制下的表达形式的最后k位,那么商就变成了一个更小规模的转换问题。所以我实现之后的确可以处理10万位(10进制)的级别了,但是还可以更快,方法还是预处理,注意到我们除法里面用到 AB<=2k ,那么我们就可以提前计算出 D=B2k 这个D适用于任何固定的 B 和任意满足条件的A,所以 AD=A/b ,我们可以提前计算出来这个D,然后除法就被优化成了一次乘法和移位。这就是利用编译器优化除法的思路来做的。我们针对不同的长度的大整数类,我们的k也不应该完全相同,所以结果也应该是用一个表存起来。比如说现在这个大整数类只有3000位,我就不应该用 21000000 去计算它,而是最接近它的那个 2k 。所以这些结果也应该用一个表存起来。每次都进行一次顺序查找,找到最小满足条件的k,如果没有就进行一次除法并且保存结果。

注意上面的优化只针对固定的B,也就是我每次只除以 10m ,否则结果是不对的.

7. 优化:

(1)首先对+=,-=,*=,/=,>>=,<<=进行优化,不是使用 a=ab 这样的形式来进行计算的,而是直接在a本身上面进行修改,这样就少了一次拷贝。+=,-=速度有较明显的提高(40%左右),乘法没有测过,那么怎么能够同时把+=,+利用同一套框架写好呢?方法就是传入一个参数作为结果,抽象一个add函数执行具体的计算,由运算符决定将什么作为保存结果的参数传入add。(*this 或者新生成的一个BigInteger作为结果)。

(2)针对加法和减法,不使用最简单的办法来写,而是拆分成3个循环,减少inner loop里面判断的次数。

(3)尽量避免类型提升之类的事情发生,这样会减慢我们计算的速度。

(4)对于乘法,里面能用+=,-=,<<=之类的运算符号尽量使用。

(5)其他一些细枝末节的优化我也记不起来了……

8.测试,直接贴github上面的结果吧:

最终结果:
parsing:50 万位10进制 转 BigInteger 7.6s   
    +       :10万位加法 执行 10万次 4s                 1亿位加法,46ms

   -       :1亿位减法 41ms                 10万减法执行10万次,6s

   *       :50万位乘法,0.46s                 直接计算50000的阶乘,2.434s

   /       :40万位/20万位 10s

   toString: 转换为3万位10进制,0.336s,转换为21万位10进制,8.1s 
   最后发现BigInt之所以很快有部分原因是它很多地方都没有返回最终结果,
   而是依靠将结果写入参数,这和javad的BigInteger很相似,不能直接用+,/,*,/,%,<<,>>等符号,不是很方便,但是节约了一次拷贝。

9.总结:

2个星期大概写了2500行代码左右,其中很多代码重构了好几次之后又被删除了,最后只剩下1500行左右(不算测试),只能说想有一个架构优美,简洁易懂好较高性能的作品真的是比较困难的一件事。期间经历过很多bug的困扰和对算法理解上的困惑,遇到瓶颈时的尴尬。

如果把牛顿迭代换成Knuth的试商法会不会在某一个范围的数量级别更快一点?,因为对于几十万位的除法来说要迭代20次才能够收敛。而且BigInt这个开源项目也是用了试商法,速度很快。

另外我也尝试了把加法改为 Carry lookahead adder,但是这个基本上都是在硬件语言级别实现的,网上少有的一些c语言代码也仅仅是很粗浅的模拟罢了,我在大整数类的基础上实现了之后并没有任何性能的提高,而且代码还变得晦涩了很多,当然这有利于并行化,但是编译器能否利用好这个特点?还是我自己应该进行循环展开?由于时间和精力关系,我也没有探究了。

很多地方实现起来并没有写起来那么轻松,我也不可能把所有细节和api都说出来,那太琐碎了,有兴趣的可以看一看github上的源码。这个作品仅仅是练手之作,也没有什么实用的价值,所以工程上的考虑不多,但是可以给有兴趣研究的同学一点借鉴。如果有什么想法也可以和我交流。

猜你喜欢

转载自blog.csdn.net/pp634077956/article/details/65445602