写这一篇的由头,是我上周被一个高精度算法的问题缠到了深夜。某个内部财务引擎在处理月均分摊时,用double累加了两千多笔金额,最终结果总是差个几分钱。一开始我以为是业务逻辑有误,后来定位到问题出在“浮点数底层的精度表达”上——那一刻我才意识到,光会调库不行,真要解决极端场景下的精度问题,还是得回到高精度算法的底层实现去挖根因。这篇算是把这段时间的踩坑、选型和实测心得整理出来,也帮自己留一份完整的复盘。
这篇文章会从存储设计聊到加减乘除、除法和开方、超越函数,再到精度验证和工程落地。适合正在实现或优化高精度数值库的开发者,也适合那些只知道调BigDecimal、但一直想知道“底层到底发生了什么”的同学。如果你只是想快速调库,那直接看结论也行;但如果你想搞懂为什么高精度算法慢、为什么有时算出来还是“看似精准实则不对”,这篇应该能给你一个比较完整的视角。
1. 我们为什么绕不开“高精度”:从浮点误差的一地鸡毛说起
1.1 一个让我决定重写底层计算的真实场景
那个财务引擎的核心逻辑不复杂:把一笔总金额按权重分摊到多个账户,然后四舍五入到分。比如 1000.01 元分摊到 3 个月,期望结果是每月 333.336666……元,保留两位小数后可能是 333.34、333.33、333.34。问题在于,分摊过程中每一笔都要先取整,下次计算又拿取整后的值继续,误差会像滚雪球一样累积。当时我用double跑了一遍,累计下来最后一个月总是对不上账。
你可能要说,这不是很简单吗,最后一个月用“总额减前面所有月份”不就行了?这话没错,但分摊场景往往还有权重、百分比、税费叠加,甚至后续要追溯历史记录,每到一处都会做一次舍入。只要底层数值表达不精确,各种边界组合一定会漏出几分钱。这件事让我重新审视了一个老生常谈的真相:0.1 + 0.2在二进制浮点里就是不等于0.3,只不过大多数场景舍入后看起来一样,但一旦累积次数多、操作复杂,问题就藏不住了。
1.2 浮点数的“精度幻觉”:double 只有 53 位尾数
要理解高精度算法的必要性,得先搞清楚浮点数的精度边界。IEEE 754 的double本质上是一个二进制科学计数法:(-1)^s × m × 2^e,其中尾数 m 只有 53 位有效二进制位,折算成十进制约 15 到 17 位有效数字。
你可以把浮点数想成一把“量杯”——它能量的范围很广,从极小到极大都能表示,但刻度是有限且不均匀的。数值越大,相邻两个可表示数之间的间隔越大。在千万亿这个量级上,两个相邻double之间的差距可能比一个普通小数本身还大。所以当你把一个小金额累加到很大的累计值里,那个小金额可能直接被“吞”掉了。
这还不是最致命的。更致命的是浮点运算的误差是相对的,不是绝对的。换言之,误差大小跟数值量级成正比。一旦你把一堆不同量级的数据混在一起运算,错误会迅速传染到低位数字。很多高精度库存在的意义,并不是“魔法般消除误差”,而是把每一步中间结果都保留足够多的有效数字,把舍入操作推迟到最后一次完成。
1.3 什么时候真的需要高精度,什么时候只是自我感动
代码写得越久越会发现,并不是所有场景都要上高精度。我自己定了个判断标准:
- 必须用高精度:金融对账、货币分摊、大整数加密运算、高精度科学常数计算、几何算法中的面积与交点判定、需要精确回溯的计算审计。
- 没必要用高精度:物理引擎里的坐标变换、3D 渲染矩阵、大多数机器学习推理。这些场景本身带有物理噪声或统计噪声,多算几位没有实际意义,反而会把性能拖垮。
高精度算法的核心价值,是在“需要精确可复现”的场景中提供一个可控的误差下限。真正的高手从不盲目追求“越精确越好”,而是先明确业务到底需要多少位有效数字,再去选择对应的算法和优化策略。这也是我后来重新审视底层实现时最深的体会。
2. 高精度数的存储设计:所有算法跑的“地基”
2.1 基数选择:我为什么最终选了 10^9 而不是 10^18
实现高精度算法的第一步不是写加法,而是决定“一个数内部到底怎么存”。我见过不少业余实现用string存十进制字符,每位数字单独处理,写起来确实简单,但性能基本没救。标准做法是分段存储:把一个长数字切成一截一截,每截存在一个整数单元里,所有运算都基于这些单元进行。
这里有个关键参数叫“基数”(BASE)。我最终选了BASE = 10^9,也就是每 9 位十进制数字放进一个uint32_t。为什么不是更大的10^18?因为两个数相乘时,单次乘法结果已经是(10^9)^2 = 10^18,这刚好接近 64 位无符号整数的上限2^64 ≈ 1.84×10^19。如果基数再翻倍到10^18,一次乘法的单步结果就直接溢出uint64_t,只能引入更复杂的处理方式,得不偿失。
从输入输出来看,10^9还有个天然优势:十进制字符串可以按 9 位一组直接切分,转内部数组几乎不需要额外的进制转换开销。相比之下,如果选二进制基(比如2^26),加减法可能更快,但每次打印成十进制都要做高精度除法,这个成本在某些应用里反而会吃掉所有优化收益。
我整理了个对比表:
| 基数方案 | 空间利用率 | 乘法溢出风险 | 十进制转换成本 | 典型使用场景 |
|---|---|---|---|---|
| BASE=10^4 | 较低 | 很低 | 低 | 教学实现、小规模计算 |
| BASE=10^9 | 高 | 单次乘法可控 | 低 | 通用高精度库首选 |
| BASE=2^26 | 高 | 需要特殊处理 | 高 | 偏二进制密集计算的场景 |
空间利用率也很直观:用10^9,一个uint32_t能表示 9 位十进制数,存储浪费很少。而10^4一个单元只能表示 4 位,同样位数下数组长度多出一倍多,内存和循环开销都会变大。
2.2 一个高精度数的完整形态:符号、长度、动态数组与“归一化”
我最终采用的内部结构,核心就四个部分:
struct BigNum { int sign; // +1 或 -1,0 恒为正 int len; // 当前有效单元数,0 表示值为 0 std::vector<uint32_t> d; // d[0] 是最低位,d[len-1] 是最高位 };这里最反直觉的一点是低位放在数组前面。很多人一开始习惯把最高位放前面,因为打印方便。但加减法、乘法的进位都是从低位向高位传播的,如果最低位在数组末尾,每次进位都可能要整体挪动数组。让最低位在d[0],进位向高位扩展时只需要push_back,操作成本低很多。
与此配套的是一个“归一化”要求:任何时刻,一个数的最高有效位不能是 0,除非这个数本身就是 0;0 的符号恒定为正。这个约定看似简单,却是后面所有算法的“质量门”。我见过太多高精度库的 bug,源头都是某一步运算后没有清理前导零,结果在比较大小、除法试商时莫名其妙多出一堆问题。
2.3 想清楚“不变量”,后面能省一大半 debug 时间
如果你只是给学生写个演示程序,不变量可能无所谓。但如果你想写一个能长期维护、支持多算法协同的高精度底层库,必须在设计阶段就把不变量定下来,并在每个关键函数的前后都加上断言检查。
我给自己定义了三条硬性不变量:
- 数组
d中每个元素都满足0 <= d[i] < BASE。 - 若
len > 0,则d[len-1] != 0;若值为 0,则len == 0 && sign == 1。 - 任何运算函数返回前必须调用内部
normalize(),确保上述规则成立。
这些不变量听起来像“洁癖”,但高精度算法最怕的就是“每个函数都差不多对,合起来全错”。比如减法借位后某一位可能变成负数,如果不在函数末尾统一修复,下一个函数拿到的就是非法状态。有了断言,你能第一时间定位是哪个函数破坏了规则,而不是等最后结果不对了再从头查。就我个人的经验,这一步省掉的排查时间至少是开发总时间的三分之一。
3. 加减法的底层实现:进位传播才是性能分水岭
3.1 最简单的逐位加减也有讲究:对齐与借位
加法是所有高精度算法里最基础、也最容易写对的部分。核心思想就是模拟竖式运算:从低位开始,按位相加,处理进位。两个数长度不一样,短的数高位自动补 0。
减法稍微麻烦一点,因为涉及符号。常规做法是:先比较两个数的绝对值大小,让“大的减小的”,结果符号再单独判断。否则如果直接用小减大,每一百个位都会出现借位地狱,代码很容易写混。后来我意识到,这个“先比较、后运算、再定号”的流程,其实和补码思想有点像,只不过我们用的是原码表示,所以必须在小数点层面处理符号。
3.2 进位/借位的传播:最坏情况是连续 9 变 0
进位传播最坏的情况是什么?999999999 + 1会触发连续九位进位,一路把最高位从 9 变成 0、再在最前面多加一个 1。很多初学实现会写成“先加当前位,如果有进位就循环判断下一位”,这在最坏情况下会退化成两层嵌套,性能极差。
正确的做法是一次循环内用进位变量打穿整个数组。加法的核心代码我写出来大概长这样:
int addCore(const uint32_t* a, int na, const uint32_t* b, int nb, uint32_t* out) { uint64_t carry = 0; int n = (na > nb) ? na : nb; for (int i = 0; i < n; i++) { uint64_t av = (i < na) ? a[i] : 0; uint64_t bv = (i < nb) ? b[i] : 0; uint64_t s = av + bv + carry; out[i] = (uint32_t)(s % BASE); carry = s / BASE; } if (carry) out[n++] = (uint32_t)carry; return n; }关键点在于把carry当成循环里持续存在的“状态”,而不是每发现一次进位就重新处理。这样整个加法只需要扫描一遍数组,时间复杂度稳定在 O(n)。减法也是同一个思路,只是把carry换成borrow,每一位计算av - bv - borrow,小于 0 就加回 BASE 并置借位。
3.3 原地运算与内存复用:减少对象创建的微优化
高精度数的加法如果每次返回一个新对象,确实很直观,但性能会很难看。尤其当你要对一个上万位的数字做连续一万次细微调整时,每次临时创建大数组、最后再释放,光是内存分配的开销就能让算法慢上一个数量级。
我在性能优化阶段把部分接口改成了“原地运算”风格,比如addInPlace、subInPlace。这种接口允许调用方复用已有的缓冲区,修改只发生在这块内存上。对底层实现来说,这带来的收益不止是少了malloc/free,还让 CPU 缓存更容易命中,因为参与运算的数据都集中在同一段内存里。
踩过的坑是:原地运算一旦涉及别名(alias),比如a.addInPlace(a)这种“自己加自己”,就要特别小心,不能先修改了a再拿它当原始输入。解决方式是在函数开头检查输入输出是否指向同一对象,或者干脆在文档里写明“调用方禁止传入同一对象”。大多数高精度库会选择保守做法:原地函数内部先做一层隔离,或者要求调用方显式使用临时变量。
3.4 比较器设计不能省:排序/去重/除法都靠它
很多人觉得比较大小很简单,直接先比长度再比内容不就行了?但加上符号之后,问题就来了。-1000是负数,长度是 4,-999长度是 3,如果直接“先比长度”,会错误地认为-1000 < -999,这当然没错,但如果你用“长度更长就更大”的逻辑,就会得出反直觉的错误结论。
正确的比较逻辑是:
- 先比符号,正数大于零,零大于负数。
- 同为正数,长度更长的更大。
- 同为负数,长度更长的反而更小。
- 长度相同,从最高位往低位逐个比较,符号不同则方向相反。
这个函数看起来普通,却是高精度库里被调用最频繁的基础函数之一。排序、去重、找极值、除法试商全都依赖它。我有一个建议:比较函数不要图省事,先把符号判断写清楚,再单独写一个“绝对值比较”的内部函数复用。这样逻辑清晰,后续出 bug 的概率也小很多。
4. 乘法的底层实现:从 O(n^2) 到分治与 FFT 的工程选择
4.1 把乘法看成数字卷积
乘法是所有高精度算法里最值得钻研的部分,因为它直接决定了大数据场景下的性能瓶颈。小学竖式乘法的本质其实是卷积:a的第i位和b的第j位相乘,贡献到结果的第i+j位。写成公式就是:
res[i+j] += a[i] * b[j]一个常见的实现误区是:在每一层内循环里立刻处理进位,导致循环体内有大量的条件分支和取模运算,限制编译器自动向量化。更高效的做法是分两步:
- 第一步:用两层循环把所有乘积累加到一个临时数组里,先不管进位。
- 第二步:从低位向高位统一处理一次进位。
这样第二个循环里的进位传播是线性的,整个算法结构也更清晰。当然,这么做的前提是临时数组的元素类型要够宽,否则累加时可能溢出。这部分细节我后面会提。
4.2 朴素乘法的性能边界:别过早优化
朴素乘法的时间复杂度是O(n*m),两个数位数相差很大时,可以固定把外层循环放在位数较短的那个数上,以减少内层访问次数。
我在一个 1000 位 × 1000 位的乘法测试里,用最普通的三重循环实现,耗时大约是几十毫秒级别。这个量级对大多数场景来说完全够用。可一旦把位数提升到 1 万位以上,O(n^2)的增长曲线就开始让人坐不住了——一万位乘法比一千位不是慢了 10 倍,而是慢了 100 倍。这时候就得开始考虑分治算法。
很多人一上来就冲着最复杂的状态去,觉得必须写 FFT。但我的经验是:先确认自己的数据规模边界。如果业务里最多只会处理几百到几千位的数字,朴素乘法优化得好,加上良好的内存局部性,已经可以交出不错的成绩单;过早引入复杂算法反而增加维护成本和潜在 bug。
4.3 Karatsuba 分治:用三次乘法替代四次乘法
Karatsuba 的想法非常精彩:把一个数拆成高低两半,a = a1 * B^k + a0,b = b1 * B^k + b0。理论上需要做四次子乘法(a1*b1、a1*b0、a0*b1、a0*b0),但 Karatsuba 巧妙利用了恒等式:
mid = (a1 + a0) * (b1 + b0) result = a1*b1 * B^(2k) + (mid - a1*b1 - a0*b0) * B^k + a0*b0这样只需要做三次子乘法。虽然递归划分后总的复杂度从 O(n^2) 降到了 O(n^log2(3)) ≈ O(n^1.585),但要注意的是,这里的“常数项”比朴素乘法大很多。所以实际使用时必须设定一个递归阈值:当子问题的位数小于某个数时,直接改用朴素乘法。我的经验是,阈值在 32 到 64 位之间比较常见,具体要通过本机测试来确定。
Karatsuba 的工程实现里有几个容易踩的坑:
- 拆出来的“半长”不一定是整数个基础单元,边界要对齐。
mid - a1*b1 - a0*b0可能出现负数,需要处理带符号的加减。- 递归会创建大量临时对象,如果内存分配控制不好,性能提升会被分配成本吃回去。
我在 Demo 实现里把阈值设成 64,实测下来当两个数都在 2000 位左右时,Karatsuba 比朴素乘法快了不少;但如果数字只有几百位,反而更慢。工程上,永远不要迷信某个算法一定更好,阈值测试和数据说话才是准确的。
4.4 为什么我没有继续写 FFT:精度和复杂度都要权衡
还有一个绕不开的话题是 FFT(快速傅里叶变换),它能把大整数乘法降到 O(n log n)。但我在这次实现里没有真正把 FFT 写进正式代码,原因很实际:
FFT 本质依赖浮点数运算。高精度算法要保证结果每一位都准确,而浮点 FFT 会引入中间舍入误差。位数越大,FFT 内部的浮点误差越可能污染高精度结果。虽然可以通过使用高精度浮点或数论变换(NTT)解决,但那意味着还要处理模素数、原根、逆变换等一整套数学工具,复杂度上升不少。
我的取舍是:数据规模没有到万位以上,Karatsuba 已经足够撑住日常场景。等真正需要万位以上乘法时,再考虑引入 NTT,而不是冒着精度风险硬上 FFT。任何算法选型都要结合实际数据规模,这比盲目追求理论复杂度更可靠。
5. 除法与开方:最容易卡住的“非线性”底层操作
5.1 竖式长除法:从笔试算法到逐位试商
乘法、加法这些运算都有清晰的“模板”可以套,但除法就没那么好对付了。最直接的高精度除法是模拟竖式长除:一位一位地从被除数里取数字,尝试估商,乘回去做减法,如果得到的余数大于除数,说明商估小了,需要回退修正。
竖式长除法的复杂度是 O(n*m),其中 n 是被除数位数、m 是除数位数。如果除数很短而被除数很长,它还算能跑;但如果是两个长度都很长的大数相除,性能就非常难受。另一个隐蔽问题是“试商可能差一”:由于我们的进制是10^9,单步试商结果可能略大于真实商,必须通过一次乘法回验来决定是否减一。很多初版实现的精度 bug 都出现在这个回验环节。
5.2 牛顿迭代法把除法变成乘法
工程上更常见的做法是把除法转化为牛顿迭代求倒数。目标q = b / a可以分成两步:先用牛顿法求出y = 1/a的高精度近似值,再乘上b,最后修正余数。
牛顿迭代求倒数的公式非常优雅:
y_{k+1} = y_k * (2 - a * y_k)这个迭代是二次收敛的:每迭代一轮,有效位数大约翻倍。比如从 16 位有效位开始,几轮之后就能逼近数百位。实现时要注意一个关键优化:每轮迭代的精度不需要一开始就拉满。如果目标精度是 1000 位,你可以让第 1 轮只算 32 位,第 2 轮算 64 位,第 3 轮算 128 位,这样逐轮提升,而不是每一轮都按最终精度去算。否则牛顿法里反复的乘法操作会白白浪费大量算力。
用牛顿法得到y ≈ 1/a后,r = b - q*a的余数不一定落在合法的0 <= r < a区间内,这时需要根据余数的符号做最后一次修正。永远不要跳过这个修正步骤——牛顿法逼近的是数学上的商,但高精度运算要求的是贴合整数除法的精确商和余数。
5.3 平方根:从二分法到单步牛顿修正
平方根比除法再难一档。我见过有些实现用二分法来找大整数平方根,每次缩小区间都要做一次乘法比较,复杂度有点惨,而且不容易收敛到“恰好合适”的边界。
牛顿法求平方根的迭代式是:
x_{k+1} = (x_k + a / x_k) / 2每轮迭代需要一次高精度除法,代价也不低,但收敛速度比二分法快得多。实际中我会先用双精度近似的floor(sqrt(a))作为初始值,然后迭代两至三轮。最后一步非常关键:由于中间有舍入,必须要做一次“边界修正”:如果x^2 > a,让x减 1;如果(x+1)^2 <= a,让x加 1。这样才能保证x = floor(sqrt(a))这个整数语义绝对正确。
这个单步修正其实和除法余数修正同理。不要相信迭代到一定程度就自动正确——高精度整数运算的各个边界,必须靠显式检查来保证。
5.4 停止条件:什么时候才算足够准
写迭代类算法最怕的是“永远差一点”的错觉。我给自己定了一个清晰标准:
- 设置目标精度
prec,也就是最终需要的十进制有效位数。 - 在迭代过程中,每轮记录修正值的变化量。
- 当修正量已经小于当前精度的 1 个 ulp(最小精度单位),就停止。
另一个实操技巧是引入“保护位”。比如目标精度是 200 位,内部计算时统一用prec + 16位来算,最后输出时再舍入。这多出来的 16 位就像安全垫,能吸收迭代过程中的中间误差,避免最终舍入到目标精度时出现“差一位”的尴尬情况。这个思路在实现超越函数时尤其重要。
6. 超越函数与数值稳定性:高精度不仅仅是更多位数
6.1 直接泰勒展开为什么会“崩”
不少初学者会想:既然有了高精度加减乘除,那么sin(x)、e^x、ln(x)直接套泰勒级数不就行了?理论上确实可以,但一旦你把一个很大的数带进去,立马就会发现灾难现场。
以e^x为例,如果x = 1000,泰勒展开需要算到第几百项,而且中间项的数值极大、之后又回落。如果没有足够多的保护位数,中间过程的舍入误差会直接毁掉最终结果。更麻烦的是三角函数,泰勒级数里各项正负交替,当x很大时,相邻项几乎相等且符号相反,这种灾难性抵消会把有效位数瞬间清零。参数约化因此不是可选项,而是必须项。
6.2 参数约化:把大头剥掉,让剩给小函数
处理e^x的标准办法是先用ln(2)做一次分解:
x = k * ln(2) + r e^x = 2^k * e^r其中r被约化到很小的范围,比如[0, ln2),在这个范围内e^r的泰勒级数收敛得非常快,几项就有很高的精度;2^k的内部实现只需要做一次高精度的左移或乘以二倍,成本很低。
ln(x)类似,先把x表示成m * 2^k,其中m约化到接近 1 的区间,然后ln(x) = ln(m) + k * ln(2)。因为m接近 1,ln(m)的泰勒展开收敛速度极快。
这里面最容易被忽略的是ln(2)和π这类常数本身必须用高精度保存。如果直接用double的M_LN2转成高精度,那你定义的“高精度”上限就只剩十几位了。正确的做法是高精度库内置这些常数到足够多的有效位,并且在用到时只做常量引用,而不是每次重新计算或从低精度类型转换。
6.3 三角函数的周期性规约:一个被精度细节支配的操作
计算sin(x)时,最理想的做法是先把x对2π取模,规约到[0, 2π),再进一步根据对称性把角度压到[0, π/4],最后用泰勒展开。这个思路本身没问题,坑在于取模操作的精度损耗。
我踩过一次很惨的坑:用一个double精度的π常量去对一个大整数x做周期性规约。当x是1e20量级时,x本身是很精确的,但2π只有约 15 位有效数字,两者相乘再取整时,误差会被放大到不可接受的程度。算出来的三角函数值连前几位都是错的。
解决方法是使用高精度的π,位数至少要比目标精度多出一部分,然后在高精度域内完成除法取模。即便如此,当x特别巨大时,常规规约依然会很吃力,需要更精细的算法处理。这也是为什么专用数学库实现三角函数时,会花很大精力处理“大数参数规约”这个环节。
6.4 数值稳定性:用误差追踪来监督每一步
超越函数部分是“高精度最容易翻车”的重灾区。后来的经验告诉我,与其等最终结果不对再来排查,不如在开发时直接加一层“误差追踪”:
- 每轮迭代结束时计算两次迭代结果之间的相对变化量。
- 如果变化量没有持续下降,而是震荡,基本可以断定当前保护位不足或约化步骤有误。
- 用双精度
double跑同一个表达式作为“低精度对照组”,观察高精度结果的低位趋势是否与对照一致。
这套方法在实际调试中非常高效。它能帮你在复杂函数尚未完全实现时,先定位出哪些步骤引入了误差,而不是到最后输出一个错误数字再回头逐行检查。
7. 精度自检与交叉验证:不能只靠“看上去对”
7.1 误差度量:绝对误差、相对误差与 ULP
高精度库写完之后,怎么证明它是对的?粗浅的做法是挑几个例子看输出和前几位对不对,但这远远不够。我习惯用三个指标来度量:
| 指标 | 定义 | 使用场景 |
|---|---|---|
| 绝对误差 | 计算结果 - 精确值 | 小范围运算、定点数校验 |
| 相对误差 | 绝对误差 / 精确值 | 变幅较大的通用场景 |
| ULP 误差 | 结果与精确值之间相隔多少个最小精度单位 | 高精度舍入语义的核心指标 |
对高精度算法来说,ULP 误差尤其重要。因为高精度库的承诺往往是“对任意输入,结果都应该是正确舍入”——也就是误差不超过 0.5 ULP。如果你的实现每次都有好几个 ULP 的误差,业务上可能一时看不出问题,但累积起来就会复现出对账不平、多项式求根漂移这类疑难杂症。
7.2 对照测试:和慢但正确的参考实现比
一个非常实用的策略是:在开发阶段先写一个“超慢但绝对正确”的参考实现,用最简单直接的字符串模拟手工计算来做加法、乘法、除法。然后让快速版实现和参考版同时跑大量随机输入,逐位比较输出。
for 每个测试用例: ref = reference_addition(a, b) fast = fast_addition(a, b) assert fast == ref这个参考实现不必追求性能,它只需要保证逻辑正确性。等快速版通过对照测试后,再逐步把优化选项打开,每开一层优化就重新跑一遍全套对照。这套流程看起来笨,但比任何形式化证明都来得可靠。
构造测试用例时,不能只用均匀随机数。还要刻意生成:
- 全 9 的数字,比如
9999999999999999 - 高低位长度相差 1 的数
- 正零、负零、极小值
- 大量的“边界数字”,如
BASE-1、BASE附近的值。
这些边界用例最容易暴露进位、借位、归一化的问题。
7.3 不变量测试与代数恒等测试
数值对照测试能验证“结果对不对”,但不一定能帮你快速定位“哪个环节错了”。我更依赖一组代数恒等测试来做回归:
- 加法逆元:
a + b - b == a - 减法符号:
a - b == -(b - a) - 乘法交换:
a * b == b * a - 乘法结合:
(a * b) * c == a * (b * c) - 除法余数定理:
a == b * q + r且0 <= r < |b|
这些测试写得快,跑得快,发现问题时也能迅速缩小范围。特别是除法余数定理,几乎是我每改一次除法实现都会跑一遍的必备项,它能同时验证商和余数的一致性。
7.4 性能回归门槛:精度上去了,算法不能变慢太多
正确性过关之后,性能是另一道门槛。我给核心运算设计了简单基准:分别测 100 位、1000 位、10000 位的加减乘除耗时,并约定一个阈值。任何改动如果导致某个指标退化超过 30%,就必须重新审视。
这里分享一个我真实踩过的性能坑:最初实现牛顿除法时,我在每一轮迭代里都保持最终精度去乘,结果 1000 位除法的耗时异常难堪。改成“逐轮增加精度”的阶梯式迭代后,性能提升了近一个数量级。这种问题只看正确性测试永远发现不了,只有放在性能回归里才会被摆到台面上。
8. 工程落地中的那些坑与心得:从 Demo 到可用代码
8.1 拷贝、移动与内存管理:高精度对象必须轻
如果你只是写几个测试函数,按值传参没问题。但在一个正经项目里,高精度对象动不动就是几千字节的数组,每次按值传递都会发生一次深拷贝,这是最隐蔽的性能黑洞。
我在代码里刻意使用了引用传参,并为大对象定义了移动语义,避免不必要的底层数组复制。对于需要反复申请临时缓冲区的内部操作,也尽量使用了对象池或复用已有缓存。这一层优化不改变算法复杂度,但对整体性能影响非常大,几乎是“立竿见影”。
8.2 精度上下文:是全局状态还是显式参数?
一个容易忽略的设计决定是:精度参数到底放在哪里?早期我把“当前全局精度”做成一个静态变量,后来多线程测试时直接翻车——一个线程在调整精度,另一个线程正在计算,结果互相污染。
最终我采取的原则是:
- 函数内部计算使用的临时精度,作为参数显式传入。
- 对外输出的格式精度,由调用方的请求决定。
- 线程间共享的常数(如
π、ln2),只允许只读访问,且只初始化一次。
这样设计虽然让接口多了几个参数,但换来的是确定性和可复现性。对高精度库来说,消除“隐藏状态”比接口好看更重要。
8.3 安全发布高精度常数:π、ln2 的并发处理
在高精度算法里,常数常常是被频繁使用的。如果你用懒加载方式初始化π,并且允许多线程同时触发初始化,就会遇到数据竞争这类鬼问题。稳妥做法是:
- 用只初始化一次的原语(比如
std::call_once)来准备常数。 - 常数一旦生成,就只做读取,绝不允许后续线程修改。
- 如果两个算法需要不同精度的同一常数,宁可同时维护两份不同精度的不可变对象,也不要图省事共享一个可变对象。
8.4 我的不变量断言清单(可直接抄)
最后分享一份我在正式代码里使用的断言清单,几乎适合所有高精度实现:
- 数组中每个元素都满足
0 <= d[i] < BASE。 - 若
len == 0,则sign == +1。 - 若
len > 0,则最高位d[len-1] != 0。 len <= d.size(),且闲置元素不影响值。- 任何运算函数返回前,对象必须经过
normalize()。
这些断言在 Debug 模式下全量开启,Release 模式下可以关闭。它们不能提高性能,但能在开发期帮你拦截大量低级错误,往往一次编译就能发现之前写错的借位逻辑或漏掉的归一化。
真要说这次重写的收获,我觉得最核心的一条是:高精度算法的难点从来不只是“把每一位算对”,而是建立一套不变量与验证体系,让所有复杂算法都在一个可靠的地基上生长。如果你也想从头实现自己的高精度库,我建议别急着写最炫的 FFT 或最高阶的牛顿法,先把存储结构、进位逻辑、归一化规则打扎实,再一层层往上叠算法。一段实践证明,最容易让自己崩溃的,不是数学推导,而是那些藏在边界条件下、分散在各个函数里的隐式假设。最后再分享一个小技巧:始终保留一份慢但正确的参考实现,它对排错的价值,远超过它在性能上的“拖后腿”。