杂记 › 技术笔记 › 按每月 1.7 倍预估 18 个月,脚本和表格为什么给出两个数

按每月 1.7 倍预估 18 个月,脚本和表格为什么给出两个数

同事甩来一张预估表:首月 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。

第一个不相等的项号:Math.pow 对比逐项累乘,条越短说明分叉越早 4 r=1.01 a₁=1 4 r=1.05 a₁=1000 5 r=1.1 a₁=1 6 r=1.7 a₁=2 6 r=1.7 a₁=1 35 r=3 a₁=2

图 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 / 1840177.3841487649140177.384148764910
2 / 1.7 / 1003.168283922230802e+233.1682839222308025e+236.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。一栏一个读法,项数最容易吃暗亏。

排查顺序

  1. 先比 double 再比屏:1.70e-11 这种量级是固有误差,不是谁写错了。
  2. 再数下标:a_n 用 n-1,终值乘 n 次,隔着一整项。
  3. 看量级有没有过 1e15:过了就只剩 7 位有效数字,差 1.099e+10 也不显示。

那天是谁也没说服谁,直到备注里写清口径(18 项、闭式)、量级(40,177.3841487649)和一句「尾数差在小数第 15 位」才收场:浮点的事归浮点,口径的事归口径。

📎 相关文章

PNG 压了三遍只减 426 字节:无损压缩的真实边界 2026-10-22 WebP 转 PNG 后体积涨了几倍:什么时候必须转,怎么把涨幅压住 2026-10-22 截图缩到四分之一,小字糊成一团的三种原因 2026-10-22 同样裁 300×300,头像为什么总偏右下角半格 2026-10-22 接口 JSON 贴成 PHP 数组,哪些值会悄悄变形 2026-10-22