同事丟來一張預估表:首月 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 位」才收場:浮點的事歸浮點,口徑的事歸口徑。