跳转链接
- 高精度计算入门与immense数据模型精讲(一)
- 高精度计算入门与immense数据模型精讲(二)
- 高精度计算入门与immense数据模型精讲(三)
- 高精度计算入门与immense数据模型精讲(五)
- 高精度计算入门与immense数据模型精讲(六)
- 高精度计算入门与immense数据模型精讲(七)
- 高精度计算入门与immense数据模型精讲(八)
- 高精度计算入门与immense数据模型精讲(九)
- immense大实数数据库
前言:从比较到运算,竖式思想的第一次完整落地
前面三篇我们搭建了存储模型、搞定了归一化规则、讲透了比较逻辑,完成了高精度库的全部基础准备。从本篇开始,我们正式进入运算模块。加减是所有竖式运算的起点,逻辑最直观,也最能体现「模拟人手计算」的核心思想。
很多人接触高精度都是从「大数加法」的算法题开始的,但真正写好支持小数的实数加减并不简单:小数点怎么对齐?进位如何跨整数与小数传递?借位不够怎么办?正负数混合运算如何统一逻辑?这些问题处理不好,代码就会充满分支,bug丛生。
本篇依然严格遵循「通用原理先行,代码实现对照」的教学节奏:先把十进制高精度加减的通用方法论讲透,从无符号整数到实数,再到带符号运算的统一处理;再对照immense的源码,逐行拆解Add、Less、Centre_AL三个核心函数,看它们如何把通用思想落地。
一、通用原理:高精度加减的核心方法论
1.1 本质:把小学竖式搬进代码
高精度加法和减法的底层逻辑,和我们小学学的手算完全一致。
人手算加法的标准步骤:
- 右对齐:个位对个位、十位对十位,保证相同权重的数位对齐
- 从低位算起:从最右侧的个位开始,逐位相加
- 满十进一:每一位相加超过10,就向高位进1
- 最高位进位:所有位算完还有进位,结果多一位
减法的步骤对称:
- 右对齐,从低位算起
- 不够减就向高位借1当10
- 必须保证被减数大于等于减数,否则交换位置,结果加负号
计算机做高精度加减,就是把这个过程用数组/字符串复刻出来。唯一的变量是存储顺序——存储方式不同,对齐和遍历的方向也不同。
1.2 无符号整数加法:通用算法
前提约定
两个非负整数,采用倒序存储(低位在下标0)。这是高精度整数最主流的存储方式。
标准算法步骤
- 以较长的数为基准,较短的数高位等效补零(循环超出长度按0处理)
- 初始化进位变量
carry = 0 - 从下标0开始正序遍历每一位:
- 当前位和 = 甲位数值 + 乙位数值 + 低位进位
- 当前位结果 = 和 % 10
- 新的进位 = 和 / 10
- 遍历结束后,如果
carry > 0,在结果末尾追加一位进位 - 结果天然为倒序,无需额外反转
时间复杂度
O(n),n 为较长数的位数。加法是线性时间运算,没有更快的通用算法。
教学要点:为什么倒序存储是加法的最佳选择?
因为最低位在下标0,两个数的最低位天然对齐,循环从0开始正序走即可;进位产生的新位直接追加到字符串末尾,均摊O(1)。如果是正序存储,不仅要从末尾往前遍历,进位还要插入到字符串头部,时间复杂度变成O(n),麻烦很多。
1.3 无符号整数减法:通用算法
减法比加法多一条铁律:必须保证被减数 ≥ 减数,否则会出现无限借位的逻辑错误。
标准算法步骤
- 前置判断:若被减数 < 减数,交换两者,标记结果为负
- 初始化借位变量
borrow = 0 - 从下标0开始正序遍历每一位:
- 被减数当前位先减去低位来的借位
- 如果被减数位 < 减数位:被减数位 += 10,新借位 = 1
- 否则:新借位 = 0
- 结果位 = 被减数位 - 减数位
- 遍历结束后,清理结果的前导零(倒序存储即清理末尾的零)
- 根据标记决定是否给结果加负号
重点:连续借位的处理
比如计算1000 - 1,需要从千位一路借位到个位,中间连续三位都是0。算法中的borrow变量会逐位传递,只要循环完整走完,就能自动处理连续借位,不需要额外写特殊分支。这是很多初学者容易卡壳的地方。
1.4 实数加减:小数点对齐是核心
从整数扩展到实数,唯一新增的复杂度就是小数点对齐。只有小数点对齐了,相同权重的数位才能正确相加减。
通用对齐方法
两个实数相加减,先统一小数位数:
- 小数位数少的,尾部补零,让两者小数长度相同
- 对齐之后,小数部分和整数部分就可以分别按整数加减处理
- 加法的进位会从小数部分传递到整数部分,减法的借位会从整数部分传递到小数部分
举个直观例子:12.34 + 5.678
- 统一小数位数:
12.340 + 5.678 - 小数部分相加:
340 + 678 = 1018,保留018,进位1传给整数 - 整数部分相加:
12 + 5 + 1 = 18 - 合并结果:
18.018
教学要点:实数加减的本质,就是「对齐小数点 → 当成大整数加减 → 还原小数点」。所有实数高精度加减,最终都会退化成整数加减。小数点对齐是实数库独有的额外工作量。
1.5 带符号运算:符号中心化统一处理
前面讲的都是无符号数的加减。但实际使用中数有正有负,加法和减法还可以互相转化。如果每种符号组合都写一套逻辑,代码会极其冗余,也容易出错。
工程上的通用最佳实践叫符号中心化:
所有带符号的加法和减法,都先取绝对值,转化成两个无符号数的运算;根据两个数的符号决定最终做加法还是减法;运算结束后再回填正确的符号。
具体来说,对于任意a op b(op 可以是 + 或 -):
- 先统一成
a + (±b)的形式(减法等价于加相反数) - 取两者绝对值 |a|、|b|
- 同号:结果 = 无符号加(|a|, |b|),符号与 a 相同
- 异号:用大的绝对值减小的绝对值,符号与绝对值大的一方相同
这样一来,无论正加正、正减负、负加正、负减负,全部复用同一套无符号 Add 和 Less 函数。外层只处理符号调度,内层只专心算数值。这就是「符号与数值分离」设计的最大价值。
1.6 必须覆盖的边界案例
写好加减运算,一定要用以下边界用例验证:
- 零加任何数 = 任何数
- 相等两数相减 = 0
- 全进位:
999 + 1 = 1000,位数增加 - 全借位:
1000 - 1 = 999,位数减少 - 小数跨整数进位:
0.9 + 0.2 = 1.1 - 跨小数点借位:
1.0 - 0.1 = 0.9 - 异号相加结果为零:
123 + (-123) = 0
这些案例覆盖了进位、借位、符号、零值所有关键路径,是检验加减实现正确性的试金石。
二、immense 实现对照:加减三函数逐行精讲
immense的加减体系严格遵循上述通用原理,采用经典的三层架构:
- 底层运算层:
Add(无符号实数加)、Less(无符号实数减) - 中层调度层:
Centre_AL(符号中心化调度) - 上层接口层:运算符重载(
+、-、+=、-=、自增自减)
我们从底层往上层逐段拆解。
2.1 Add 函数:无符号实数加法
完整源码:
immense Add(immense local,immense other){ std::string x=local.DCMp,y=other.DCMp; while(x.size()<y.size()) x.push_back('0'); while(y.size()<x.size()) y.push_back('0'); int carry=0,p,q; for(int i=(int)y.size()-1;i>=0;i--){ p=x[i]-'0'; q=y[i]-'0'; x[i]=(p+q+carry)%10+'0'; carry=(p+q+carry)/10; } local.DCMp=x; x=local.LTGp,y=other.LTGp; if(x.size()<y.size()) std::swap(x,y); int i; for(i=0;i<y.size();i++){ p=x[i]-'0'; q=y[i]-'0'; x[i]=(p+q+carry)%10+'0'; carry=(p+q+carry)/10; } while(i<x.size()&&carry){ int t=x[i]-'0'; x[i]=(t+carry)%10+'0'; carry=(t+carry)/10; ++i; } if(carry)x=x+'1'; local.LTGp=x; local.rationalize(); return local; }对照通用原理,拆解为三个阶段:
第一阶段:小数部分对齐与相加
std::string x=local.DCMp,y=other.DCMp; while(x.size()<y.size()) x.push_back('0'); while(y.size()<x.size()) y.push_back('0');先对小数部分补零对齐,短的尾部补零,让两者长度一致。完全对应「小数点对齐」的通用步骤。
for(int i=(int)y.size()-1;i>=0;i--){ p=x[i]-'0'; q=y[i]-'0'; x[i]=(p+q+carry)%10+'0'; carry=(p+q+carry)/10; }小数是正序存储,最低位在字符串末尾,所以从后往前遍历。逐位相加,记录进位。
这里产生的carry会从小数最低位一直传递到小数最高位(十分位),然后继续传递给整数部分,实现跨小数点的进位衔接。
第二阶段:整数部分相加
x=local.LTGp,y=other.LTGp; if(x.size()<y.size()) std::swap(x,y);整数部分是倒序存储,低位在下标0。先把长的放前面,保证循环基准统一。
for(i=0;i<y.size();i++){ p=x[i]-'0'; q=y[i]-'0'; x[i]=(p+q+carry)%10+'0'; carry=(p+q+carry)/10; }从下标0开始正序遍历,对应从低位到高位。这里的carry就是小数部分传过来的进位,天然衔接。完全匹配通用整数加法算法。
while(i<x.size()&&carry){ int t=x[i]-'0'; x[i]=(t+carry)%10+'0'; carry=(t+carry)/10; ++i; } if(carry)x=x+'1';短的数遍历完后,如果还有进位,就继续处理长数的剩余位。最后如果仍有进位,在字符串末尾追加一位。
对应通用算法的「剩余位处理 + 最终进位」两步。
第三阶段:归一化收尾
local.rationalize(); return local;运算完成后统一调用归一化,同步长度变量,清理可能产生的无效零。这是immense所有运算的标准收尾动作。
2.2 Less 函数:无符号实数减法
完整源码:
immense Less(immense local,immense other){ if(local==other)return immense(long(0)); if(local<other) return -Less(other,local); int carry=0,x=0,y=0; char ch; while((int)local.DCMp.size()<other.DCMn)local.DCMp.push_back('0'); while((int)other.DCMp.size()<local.DCMp.size()) other.DCMp.push_back('0'); for(int i=(int)other.DCMn-1;i>=0;i--){ x=local.DCMp[i]-'0'; y=other.DCMp[i]-'0'; local.DCMp[i]=(x-carry-y+10)%10+'0'; carry=(x-carry-y)<0; } int i=0; for(;i<other.LTGn;i++){ x=local.LTGp[i]-'0'; y=other.LTGp[i]-'0'; local.LTGp[i]=(x-carry-y+10)%10+'0'; carry=(x-carry-y)<0; } while(carry){ if(i>=(int)local.LTGp.size()) local.LTGp.push_back('0'); ch=static_cast<unsigned char>(local.LTGp[i])-1,carry=0; if(ch<'0') ch+=10; local.LTGp[i] = static_cast<char>(ch); ++i; } local.rationalize(); return local; }对照通用减法原理,分步拆解:
第一步:前置校验与交换保证
if(local==other)return immense(long(0)); if(local<other) return -Less(other,local);这是减法的标准前置处理:
- 两数相等直接返回0
- 若被减数小于减数,交换位置递归调用,结果取负
- 保证进入后续逻辑时,被减数一定 ≥ 减数,不会出现负结果
完全对应通用减法的前提保证。
第二步:小数部分对齐与相减
while((int)local.DCMp.size()<other.DCMn)local.DCMp.push_back('0'); while((int)other.DCMp.size()<local.DCMp.size()) other.DCMp.push_back('0');和加法一样,先补零对齐小数位数。
for(int i=(int)other.DCMn-1;i>=0;i--){ x=local.DCMp[i]-'0'; y=other.DCMp[i]-'0'; local.DCMp[i]=(x-carry-y+10)%10+'0'; carry=(x-carry-y)<0; }从小数末尾(最低位)开始往前减。carry在这里是借位标记,1表示需要借位,0表示不需要。
技巧:(x - carry - y + 10) % 10这个写法,无论够不够减都能算出正确的当前位,不用写 if-else 分支,简洁且不易出错。
第三步:整数部分相减
int i=0; for(;i<other.LTGn;i++){ x=local.LTGp[i]-'0'; y=other.LTGp[i]-'0'; local.LTGp[i]=(x-carry-y+10)%10+'0'; carry=(x-carry-y)<0; }整数倒序存储,从下标0(低位)开始正序遍历。借位从小数部分传过来,天然衔接。计算逻辑和小数部分完全一致。
第四步:连续借位处理
while(carry){ if(i>=(int)local.LTGp.size()) local.LTGp.push_back('0'); ch=static_cast<unsigned char>(local.LTGp[i])-1,carry=0; if(ch<'0') ch+=10; local.LTGp[i] = static_cast<char>(ch); ++i; }减数遍历完后,如果还有借位,就继续处理被减数的剩余高位。
这就是处理「连续借位」的逻辑,比如1000 - 1,借位会一路传递下去。因为前置已经保证了被减数更大,所以借位最终一定会清零,不会无限循环。
第五步:归一化收尾
减法一定会产生前导零(倒序存储即末尾的零),比如1000 - 1 = 0999,所以必须调用rationalize清理前导零,同步长度。
2.3 Centre_AL:符号中心化调度器
Centre_AL是加减运算的中枢,对应通用原理的「符号中心化」思想,负责把所有带符号运算调度到无符号的Add和Less上。
完整源码:
immense Centre_AL(immense x,bool AmS,immense y){ if(!AmS)return Centre_AL(x,true,-y); immense result; bool p=x.symbol,q=y.symbol; x.abs(),y.abs(); if(p){ if(q) result=Add(x,y); else result=Less(x,y); } else{ if(q) result=Less(y,x); else result=-(Add(x,y)); } return result; }参数说明
x:第一个操作数AmS:true表示加法,false表示减法y:第二个操作数
第一步:减法转加法
if(!AmS)return Centre_AL(x,true,-y);如果是减法,就转化成「x 加 -y」,递归调用加法版本。
这样所有运算最终都统一成加法形式,后面只需要处理同号、异号两种情况,分支数量减半。
第二步:符号与数值分离
bool p=x.symbol,q=y.symbol; x.abs(),y.abs();保存两个数的符号,然后取绝对值,后续只计算绝对值。
第三步:四分支调度
if(p){ if(q) result=Add(x,y); // 正 + 正 = 直接加 else result=Less(x,y); // 正 + 负 = 正减正 } else{ if(q) result=Less(y,x); // 负 + 正 = 正减负 else result=-(Add(x,y));// 负 + 负 = 相加取负 }四种符号组合,对应四种调度:
- 正+正:直接相加,结果为正
- 正+负:大数减小数,结果符号随大数
- 负+正:交换顺序,逻辑同上
- 负+负:绝对值相加,结果取负
Less函数内部已经处理了「谁大谁小」的问题,所以这里直接传参即可,无需额外判断大小。
2.4 运算符层:统一接口封装
上层的所有运算符全部调用Centre_AL,代码极其简洁:
inline immense immense::operator+(const immense other)const{ immense t=other; return Centre_AL(*this,true,t); } inline immense immense::operator-(const immense other)const{ immense t=other; return Centre_AL(*this,false,t); }复合赋值、自增自减也是同理复用:
inline void immense::operator+=(const immense other){ immense t=other; *this=Centre_AL(*this,true,t); } inline immense &immense::operator++(){ *this=Centre_AL(*this,true,immense("1")); return *this; }自增自减本质就是加/减1,完全复用同一套逻辑。
这种分层设计的优势非常明显:
- 底层
Add/Less只关心无符号运算,逻辑纯粹 - 中层
Centre_AL只关心符号调度,不碰具体运算 - 上层运算符只做接口包装,零逻辑冗余
- 出问题排查方便,改bug只改对应层
三、拓展:加减运算的性能与设计权衡
3.1 时间复杂度分析
加减运算都是线性时间 O(n),n 为总位数。
- 加法:一次遍历完成,常数因子很小
- 减法:同样一次遍历,但前置有一次比较判断大小,常数因子略大
在高精度运算体系里,加减是最快的基础运算,也是所有高级运算的基石——乘法、除法、开方、级数内部都会大量调用加减。
3.2 关键设计权衡点评
- 整数倒序 + 小数正序的不对称存储
- 优点:小数点平移方便,进位借位的传递方向与遍历方向天然匹配
- 缺点:整数和小数遍历方向相反,增加少量心智负担
- 综合来看,对于支持小数的实数库,是利大于弊的选择
- 值传递而非常量引用
Add和Less的参数都是值传递,函数内部直接修改副本返回。- 优点:代码书写直观,不用创建额外临时变量
- 缺点:会触发一次字符串拷贝
- 对于教学级、工具级库来说,可读性优先,这个取舍非常合理
- 中心化统一调度
用Centre_AL把所有加减统一调度,是非常经典的工程做法。虽然多了一层函数调用,但代码复用率极高,符号逻辑只写一次,大幅降低了出错概率。
3.3 自我验证练习
学到这里,你可以自己推演几个案例,对照代码逻辑走一遍,检验是否真正理解:
immense("0.9") + immense("0.2")→ 小数进位到整数,结果 1.1immense("1.0") - immense("0.1")→ 跨小数点借位,结果 0.9immense("999.99") + immense("0.01")→ 全进位,结果 1000.0immense("-123") + immense("45")→ 异号相减,结果 -78
如果都能正确推演,说明你已经完全掌握了高精度加减的核心逻辑。
四、本篇小结与下篇预告
4.1 核心知识点回顾
通用原理部分:
- 加减本质是模拟竖式计算,核心是进位和借位的传递处理
- 倒序存储的整数最适合加减,低位天然对齐,进位追加方便
- 实数加减的关键是小数点对齐,统一小数位数后转成整数加减
- 带符号运算的最佳实践是符号中心化:取绝对值 → 调度无符号运算 → 回填符号
- 减法必须保证被减数 ≥ 减数,否则交换位置结果取负
实现对照部分:
Add函数:对齐小数 → 小数带进位相加 → 整数带进位相加 → 最终进位处理 → 归一化Less函数:前置保证被减数大 → 对齐小数 → 小数借位相减 → 整数借位相减 → 连续借位处理 → 归一化Centre_AL:减法转加法 → 符号分离 → 四分支调度 → 返回带符号结果- 运算符层全部复用中心化函数,分层清晰,无逻辑冗余
4.2 下篇预告
加减是线性运算,乘法就不一样了。普通竖式乘法是 O(n²),对于大数来说性能很差。工业级高精度库都会用更快的乘法算法,比如Karatsuba 分治乘法,可以把时间复杂度降到 O(n^1.585)。
下一篇我们就来讲乘法:先讲普通竖式乘法的通用原理,再讲 Karatsuba 分治乘法的算法思想与复杂度推导;最后对照immense的源码,看它如何实现普通竖式与 Karatsuba 的自动切换,以及如何处理实数乘法的小数点位置。