同事甩来一张预估表:首月 2,之后每月乘 1.7,一共 18 个月,问累计多少。她的表是「上一期 × 1.7」逐行推下来再求和,我的脚本套闭式 a₁ × (1 - r^n) / (1 - r)。两个数并排放,尾数不一样:闭式给 40177.38414876491,她的表给 40177.384148764904,差在小数第 15 位,两个人都咬定自己没错。
要判断谁错了,得先有一个不参与吵架的答案。把 1.7 写成分数 17/10,用 BigInt 做有理数运算,18 项的精确和是 40,177.384148764929,两个 double 离它都是 1.70e-11 这个量级。谁都没算错;把「两个 double 尾数不同」当成「有人算错」,是这场争论里最贵的一步。
差异分三层
- 公式层:闭式与逐项累加在这组参数上差 0,两条路给的是同一个 double 40177.38414876491。
- 算法层:末项按
a × Math.pow(r, n - 1)单点算,列表第 i 项按a × Math.pow(r, i)逐项独立算,全程没有t = t * r那种累乘;而表格的口径恰恰是累乘,两条路从第 6 项起就不保证同尾数。 - 显示层:结果区的格式化分四档,小数最多留 10 位,绝对值过 1e15 只剩 7 位有效数字,那点 ulp 级分叉肉眼看不见。
从第 6 项起分家
第一层近似在数字进输入框之前就发生了:双精度里 1.7 的真实值是 1.69999999999999995559,比十进制的 1.7 小 4.44e-17。Math.pow 是一次库函数调用,递推是逐项乘过去、每乘一次舍入一次。r=1.7、a₁=2 时两者第一个不相等的项是第 6 项:pow 给 28.397139999999997,累乘给 28.397139999999993,差 3.55e-15,约合 4.44e-16 的一个 ulp。
图 1:六个(首项,公比)组合下,pow 与累乘第一个不相等的项号。
分叉的早晚和涨得快慢不是一回事:r=1.01 在第 4 项分家,r=1.1 在第 5 项,整数公比 r=3 撑到第 35 项才不相等,r=0.5 这种减半口径扫过 100 项一次都没分叉。
什么时候开始看得见
ulp 级分叉爬不进报表,格式化那一步就把它抹平了:0.30000000000000004 显示成 0.3,9.825999999999999 显示成 9.826。断点也常被记晚一个量级:1e21 确实输出 1.000000e+21,但切换发生在 1e15——999999999999999 还是千分位,1e15 已经是 1.000000e+15,往后只剩 7 位有效数字,123456789012345678 变成 1.234568e+17,与真值相差 -1.099e+10。
| a₁ / r / n | 闭式 double | 逐项累加 double | 差 |
|---|---|---|---|
| 2 / 1.7 / 18 | 40177.38414876491 | 40177.38414876491 | 0 |
| 2 / 1.7 / 100 | 3.168283922230802e+23 | 3.1682839222308025e+23 | 6.71e+7 |
项数一多就是这个局面:两条路差到 6.71e+7,两边的显示串还是同一个 3.168284e+23。争谁算错了,问的已经不是数学而是格式化。
比浮点更常见的错:项数口径
这组预估里有三个数很像,彼此却不通用:第 18 项的末项是 16,544.8052377267,前 18 项的和是 40,177.3841487649,而乘满 18 次得到的 28,126.1689041354 是第 19 项——通项下标写成 n-1 就是差一处的根源。反推公比走开方不走搜索:把 40235.4285714286 当末项喂进去会得到 1.7912313617;同样 2 到 4,374,项数填 8 得 r=3,填 9 得 2.6150566286。
公比贴近 1 是另一种风险:分支判定是严格相等 r === 1,输入 0.9999999999999999 也落回闭式,在 (1 - r) 上相消;a₁=1、n=1000 时给出整齐的 1,000,精确值其实是 0。
复核的现场
这些数不是我手推的:把等比数列计算这一页的函数原样抽出来跑(等比数列计算),它自己写出的字符串就是证据——2 / 1.7 / 18 给 40,177.3841487649,过程行印成「求和:S18 = 2 × (1 - 1.718) / (1 - 1.7) = 40,177.3841487649」,上标被拼成平排两段数字,读成 1.718 不奇怪。它一次只吃三个数,吐末项、和、逐项列表,不解方程也不对表格。
两处会骗人:列表最多渲染 500 项,n=600 时补一行「... 共 600 项,仅显示前 500 项 ...」,S_n 却是全部 600 项的和,拿屏上的列表去加对不上;项数过 1000 只弹「项数过多,请控制在 1000 以内」就返回,结果区不清空,留着上一次的数。上限之内照样溢出:a₁=2、r=3 时和在第 646 项就变 +∞,末项要到第 647 项,那一屏是 1.107234e+308 配 +∞;a₁=1、r=2 的末项要到第 1025 项,1.7 倍那个口径的和要到第 1336 项。
拿去讲增长率的话,我会把同一份参数在复利口径上再走一遍(复利计算器),因为终值 P × (1 + i)^n 天然比 a_n 多一项;复利和单利为什么年限一长就差很多,也是从同一个错位切进去的。
三个当场被问到的
Q:表格和脚本,信哪个?
A:先看量级。几十项以内差的是 ulp 那一档,n=18 时两路差 0;项数上百,差就成了实打实的绝对量。要定案,把公比当分数算出的 40,177.384148764929 才能抄进备注。
Q:粘出来的数列小数位为什么那么多?
A:复制走的是逐项 pow 的原始 double,用「, 」拼接,不过格式化:2, 3.4, 5.779999999999999, 9.825999999999999, 16.704199999999997;屏上那个 9.826 是格式化之后的,再算用前者。
Q:项数那栏填 1.9 或 1e3 会怎样?
A:都读成 1。项数用 parseInt,首项和公比用 parseFloat,同一个 1e3 在后者是 1000。一栏一个读法,项数最容易吃暗亏。
排查顺序
- 先比 double 再比屏:1.70e-11 这种量级是固有误差,不是谁写错了。
- 再数下标:a_n 用 n-1,终值乘 n 次,隔着一整项。
- 看量级有没有过 1e15:过了就只剩 7 位有效数字,差 1.099e+10 也不显示。
那天是谁也没说服谁,直到备注里写清口径(18 项、闭式)、量级(40,177.3841487649)和一句「尾数差在小数第 15 位」才收场:浮点的事归浮点,口径的事归口径。