前言
这篇文章主要是对于大整数类的设计过程中,如何实现并改进长除法(模拟竖式法)的一个总结。
高精度除法的分类和比较
虽然有些文章在讨论大整数的除法运算时,喜欢分成高精度除以高精度和高精度除以低精度(短除法)两种情况并分开实现,但在本文中将不做这种区分。事实上,短除法就是长除法的一个缩略。
按照wiki百科,除法大致可以分为以下两类:
- 慢速算法:每步只能确定一位的算法。长除法属于此类,时间复杂度为O(N2)。
- 快速算法:每步可以确定多位的算法。牛顿迭代法属于此类,配合用FFT实现的乘法,时间复杂度为O(NlogN)。
一些讨论
这里讨论的是为了实现大整数类所必需的一些基础。厘清它们对提升算法的效率是有意义的。
1. 关于底层
标准C++的语法中基本的数据类型都带有基本的四则运算,这里的底层主要指硬件或者编译器。
要实现大整数的除法运算,至少需要用到加、减两种基本运算,乘、除和取余虽然不是必须的,但我们仍然假定底层(硬件或者软件模拟)已经实现,因为后面的优化将用到它们。
2. 关于数据结构
数据结构有多种实现方式,互有优劣。数组最简单,但长度固定,需要考虑溢出。STL的vector不用担心溢出,但不是线程安全的。链表线程安全,但操作复杂且效率低下。
本文中我们将使用数组,其中数组的低位存储数值的低位,数组的最高位存储数值的符号。
3. 关于数据类型
数据类型的选择,取决于存储效率和处理能力。用1个int型存储1个十进制有效数字显然是低效的,但用long型存储9个十进制有效数字也未必最佳(假如系统是16位的,则long型的四则运算本身就需要软件模拟)。有些类型的大小也依赖于编译器的实现,这也是在设计过程中需要考虑的。
本文先从1个char型存储1位十进制有效数字开始设计,暂将优化(压位)放到后面讨论。这样的优点是可以使用字符串进行存储,在输入输出上有所便宜。
长除法的实现
1. 除法的基本原理
本质上,慢速算法都是通过减法来实现除法。不考虑效率的话,下面的代码就可以实现除法。
Integer Integer::operator/(const Integer& itg)const{
//...
//prod=*this;
//div=itg;
result=zero;
while(!(prod<div)){
prod=prod-div;
result=result+one;
}
//...
}
显然,这个实现的时间复杂度是指数级的。现实中大概只有幼儿园的小朋友会使用。
2. 优化策略
如何高效率地完成减法,是优化的一个方向。直观的办法是通过减去除数的某个已知的倍数,来达到提高效率的目的。下面的代码是一个最简单的实现。
Integer Integer::operator/(const Integer& itg)const{
//...
//prod=*this;
//div=itg;
result=zero;
quot=one;
base=div;
while(!(prod<div)){
if(prod<base){
quot=one;
base=div;
}else{
prod=prod-base;
result=result+quot;
quot=quot+quot;
base=base+base;
}
}
//...
}
这个实现的时间复杂度已经达到O(N2),但无奈常数太大。因为每次采用的是翻倍,所以这里计算N是以2为底取的对数,而常用的笔算方法是以10为底取的对数。而且每次计算都要调用大整数的加减法,这也是一笔不小的开销。
3. 长除法的直减版本
下面的代码是采用移位策略的实现,已是相当接近于列竖式笔算的版本。
Integer Integer::operator/(const Integer& itg)const{
Integer result=0,prod,div,base;
int loop,offset,quot,reg,trans=0;
/*处理除0异常*/
prod=*this;
if(*(prod.p+Length-1)=='-') *(prod.p+Length-1)='+';
div=itg;
if(*(div.p+Length-1)=='-') *(div.p+Length-1)='+';
while(!(prod<div)){
base=div;
quot=1;
//确定移位的具体值
loop=prod.index-1;
offset=base.index-1;
while(*(prod.p+loop)==*(base.p+offset)){
if(offset>0){
loop--;
offset--;
}else break;
}
if(char2int(*(prod.p+loop))>=char2int(*(base.p+offset)))
offset=prod.index-base.index;
else
offset=prod.index-base.index-1;
//移位
if(offset>0){
for(loop=base.index-1;loop>=0;loop--)
*(base.p+loop+offset)=*(base.p+loop);
for(loop=0;loop<offset;loop++)
*(base.p+loop)=int2char(0);
base.index=base.index+offset;
quot=quot+offset;
}
if(prod.index>base.index) *(base.p+prod.index-1)=int2char(0);
//结果初始化,仅执行一次
if(quot>result.index){
for(loop=0;loop<quot;loop++)
*(result.p+loop)=int2char(0);
result.index=quot;
}
while(!(prod<base)){
//执行减法
for(loop=offset;loop<prod.index;loop++){
reg=char2int(*(prod.p+loop))-char2int(*(base.p+loop));
if(trans){
reg=reg-1;
trans=0;
}
if(reg<0){
reg=reg+Radix;
trans=1;
}
*(prod.p+loop)=int2char(reg);
}
//清除前导0
loop=prod.index-1;
while(loop>=0){
if(*(prod.p+loop)==int2char(0))
loop--;
else
break;
}
prod.index=loop+1;
if(prod.index==0) *(prod.p+Length-1)='0';
//商加1
*(result.p+quot-1)=int2char(char2int(*(result.p+quot-1))+1);
}
}
if(result.index!=0) *(result.p+Length-1)=sign2c(sign2i(*(p+Length-1))*sign2i(*(itg.p+Length-1)));
return result;
}
移位的本质是一种乘法,只是利用了数据结构的特点而得到了极大的便宜。
长除法的优化
仔细分析长除法的直减版本,可以发现以下两点:
- 存储效率太低,每个char至少能表达256个数值,但我们只使用了其中10个,这直接导致了处理能力的浪费。
- 为了确定每一位的数值,我们平均执行了5次减法(假定数值是随机的),但现实中我们在列竖式的时候绝对没有做这么多次减法。
第一个问题似乎容易解决,我们只要在选定的数据类型中使用尽可能大的进制(比如对char我们可以使用百进制)即可。对加、减、乘三种运算而言,这是正确的。但对除法,情况有所不同。
仍以char为例,当进制由10变成100的时候,存储长度可以缩短一半,在其他不变的情况下,时间复杂度的常数可以变为原本的1/4。但是为了确定每一位,平均执行减法的次数从5次变成了50次!实际的常数变化率为1/4*(50/5)=2.5!除法的运行时间反而变长了。
所以不管是为了压位,还是为了进一步减少减法提升效率,我们都需要试商。
1. 试商
如上文提及,我们在列竖式的时候总是能够快速的猜到每一位的最终值(或附近),我们是怎么做到的呢?下面的故事引自华罗庚的《天才与锻炼》:
有一天教授要给学生们出一道计算题。一位助手取来了题目,是一个871位数开97方,要求答案有9位有效数字。教授开始在黑板上抄这个数:456,378,192,765,431,892,634,578,932,246,653,811,594,667,891,992,354……当抄到二百多位后,教授的手已经发酸了。“唉!”他叹了一口气,把举着的手放下甩了一下。这时一位学生噗嗤一声笑了起来,对教授说,当您写出八位数字后,我已把答案算出来了,它是588,415,036。那位助手也跟着笑了,他说,本来后面这些数字是随便写的,它们并不影响答数。这时教授恍然大悟,“哈哈,我常给你们讲有效数字,现在我却把这个概念忘了。”
如引文所述,假如我们把大整数都用科学记数法表示,为了得到结果的最开始一位或几位有效数字,被除数和除数只需要几位有效数字就足够了。那么实际计算中到底需要几位呢?下面的不等式可以给出答案:
图中,X的整数部分即为当前位的最终值,P为进制。
由图可知,如果对被除数进行截断,对除数进行凑整(注:我们希望试商的结果不大于最终值,毕竟减过头就麻烦大了。 ),只要当除数的有效数字大于进制,就可以保证试商结果小于等于最终值,并且试商结果的误差就不大于1。
2. 长除法的试商版本
下面的代码是采用试商法的实现,相比于上面的直减版本,更接近列竖式的笔算。
Integer Integer::operator/(const Integer& itg)const{
Integer result(false),prod,div,base;
int loop,offset,quot,mult,reg,trans=0;
long lp,lb;
/*处理除0异常*/
prod=*this;
if(*(prod.p+Length-1)=='-') *(prod.p+Length-1)='+';
div=itg;
if(*(div.p+Length-1)=='-') *(div.p+Length-1)='+';
while(!(prod<div)){
base=div;
quot=1;
//确定移位的具体值
loop=prod.index-1;
offset=base.index-1;
while(*(prod.p+loop)==*(base.p+offset)){
if(offset>0){
loop--;
offset--;
}else break;
}
if(char2int(*(prod.p+loop))>=char2int(*(base.p+offset)))
offset=prod.index-base.index;
else
offset=prod.index-base.index-1;
//移位
if(offset>0){
for(loop=base.index-1;loop>=0;loop--)
*(base.p+loop+offset)=*(base.p+loop);
for(loop=0;loop<offset;loop++)
*(base.p+loop)=int2char(0);
base.index=base.index+offset;
quot=quot+offset;
}
if(prod.index>base.index) *(base.p+prod.index-1)=int2char(0);
//结果初始化,仅执行一次
if(quot>result.index){
for(loop=0;loop<quot;loop++)
*(result.p+loop)=int2char(0);
result.index=quot;
}
//试商
lp=char2int(*(prod.p+prod.index-1));
lb=char2int(*(base.p+prod.index-1));
for(loop=prod.index-2;loop>=(base.index-2)&&loop>=0;loop--)
{
lp=lp*Radix+char2int(*(prod.p+loop));
lb=lb*Radix+char2int(*(base.p+loop));
}
if(div.index>2) lb=lb+1;
mult=static_cast<int>(lp/lb);
while(!(prod<base)){
if(mult){//执行试商后的减法
for(loop=offset;loop<prod.index;loop++){
reg=char2int(*(prod.p+loop))-mult*char2int(*(base.p+loop));
if(trans){
reg=reg-trans;
trans=0;
}
if(reg<0){
if(reg%Radix){
trans=1-reg/Radix;
reg=reg%Radix+Radix;
}else{
trans=-reg/Radix;
reg=0;
}
}
*(prod.p+loop)=int2char(reg);
}
}else{//执行试商后的修正
for(loop=offset;loop<prod.index;loop++){
reg=char2int(*(prod.p+loop))-char2int(*(base.p+loop));
if(trans){
reg=reg-1;
trans=0;
}
if(reg<0){
trans=1;
reg=reg+Radix;
}
*(prod.p+loop)=int2char(reg);
}
}
//清除前导0
loop=prod.index-1;
while(loop>=0)
{
if(*(prod.p+loop)==int2char(0))
loop--;
else
break;
}
prod.index=loop+1;
if(prod.index==0) *(prod.p+Length-1)='0';
//商加上试商结果和修正
if(mult){
*(result.p+quot-1)=int2char(char2int(*(result.p+quot-1))+mult);
mult=0;
}else *(result.p+quot-1)=int2char(char2int(*(result.p+quot-1))+1);
}
}
if(result.index!=0) *(result.p+Length-1)=sign2c(sign2i(*(p+Length-1))*sign2i(*(itg.p+Length-1)));
return result;
}
可以看到,如果配合压位,短除法可以被长除法近乎完全涵盖,两者的效率差可以控制在一个相当有限的范围内。
3. 效率比较
下面的截图是直减版本(1.0.7)和试商版本(1.0.8)的效率比较。测试方法为对梅森数M61使用1亿以内的质数进行取余运算,看是否能整除(当然不可以,因为M61是质数。取这个数的原因是它在64位以内,可以用来和系统自身的除法进行效率比较 )。
可以看到试商法比直减法快了近2.5倍(=32.698/13.166)。
4. 压位
在有了试商以后,char就可以采用百进制,这将进一步提升大整数的效率。但更进一步使用更大的数据类型时就需要注意硬件和编译器的支持问题了。
事实上,在上面的试商版本中,在最差情况下被除数需要3个字节,除数需要2个字节以保证试商结果,故试商时采用long进行除法运算,虽然符合C++语言的规范,但这对于16位的系统并不友好。
另一方面,针对不同的硬件和编译器,int在2字节和4字节之间摇摆,long在4字节和8字节之间徘徊,这都会影响代码的使用(不过对于专业码农,这可能不算个问题吧 )。
对于一个32位系统,不考虑汇编指令级别的特殊支持,理论上压位的极限应该是16位,因为被除数和除数的错位将占用掉另一个16位。此时就需要考虑特殊的移位(因为此时只能使用小于进制的除数进行试商,而根据上面试商一节的讨论,试商的误差和除数的大小成反比,故尽可能扩大除数有助于降低误差,减少减法的次数),即低位的数据部分地移入高位,和高位扩大后的的剩余数据合并(简单理解就是有效数字被乘以了一个小于进制的系数,然后重新按照进制存储)。根据上面的讨论,可以知道只需要对大整数的前几位有效数字进行如此处理即可。针对这种情况,如何快速准确地找到要移位的具体值,会部分地影响效率。另一方面,因为除数小于进制,试商的误差将可能大于1,也会有额外的开销。但总体上还是利大于弊,效率还是会有提升。
其他
关于试商法的论述,还可以参考:
关于大整数类的完整实现,可以从C++大整数类高精度运算库下载,该版本使用short类型并采用万进制,32位(64位)系统中耗时约为long long的6(10)倍。