js 小數(shù)的精度損失

參考如何理解double精度丟失問題?
比如1.1+0.1=1.2000000000000002,在java C++ OC等語言中出現(xiàn)是否原理相同?

根本原因在于,數(shù)學意義上的小數(shù)不是每個都能用二進制在有限位數(shù)內精確的表示。像 0.1,1.1 這樣的小數(shù)沒有精確的二進制表示,然后求和就不是1.2了。

一、二進制與十進制互相轉化
1.二進制如何表示0.1

我們知道 DEC(1) 就是 BIN(1),但是 DEC(0.1) 怎么轉換成二進制?對了!用除法:0.1 = 1 ÷ 10很簡單,二進制就是要算1 ÷ 1010我們回到小學的課堂,來列豎式吧:


image.png

相信上過小學的你一定會發(fā)現(xiàn),除不盡,除出了一個無限循環(huán)小數(shù):二進制的 0.0001100110011...

2.如果上面的除法有點復雜,也可以參考十進制小數(shù)如何轉換為二進制小數(shù)? - 知乎

采用"乘2取整,順序排列"法。具體做法如下:

  • 用2乘十進制小數(shù),可以得到積,將積的整數(shù)部分取出,再用2乘余下的小數(shù)部分,又得到一個積,再將積的整數(shù)部分取出,如此進行,直到積中的整數(shù)部分為零,或者整數(shù)部分為1,此時0或1為二進制的最后一位?;蛘哌_到所要求的精度為止。
  • 然后把取出的整數(shù)部分按順序排列起來,先取的整數(shù)作為二進制小數(shù)的高位有效位,后取的整數(shù)作為低位有效位。
如:0.625=(0.101)B   
0.625*2=1.25======取出整數(shù)部分1   
0.25*2=0.5========取出整數(shù)部分0   
0.5*2=1==========取出整數(shù)部分1   
再如:0.7=(0.1 0110 0110...)B   
0.7*2=1.4========取出整數(shù)部分1   

0.4*2=0.8========取出整數(shù)部分0   
0.8*2=1.6========取出整數(shù)部分1   
0.6*2=1.2========取出整數(shù)部分1   
0.2*2=0.4========取出整數(shù)部分0    

0.4*2=0.8========取出整數(shù)部分0   
0.8*2=1.6========取出整數(shù)部分1   
0.6*2=1.2========取出整數(shù)部分1   
0.2*2=0.4========取出整數(shù)部分0
再來試試上面的0.1吧
0.1*2=0.2========取出整數(shù)部分0

0.2*2=0.4========取出整數(shù)部分0
0.4*2=0.8========取出整數(shù)部分0
0.8*2=1.6========取出整數(shù)部分1
0.6*2=1.2========取出整數(shù)部分1

0.2*2....

很容易看到上面進入0.2之后,又開始了0011的循環(huán),所以最終結果就是0.0 0011 0011 0011...

3.參考二進制小數(shù)怎么轉化為十進制的?

和上面的過程很類似,比如二進制的0.1101如何轉化:
2-1+2-2+0-3+2-4=1/2+1/4+0/8+1/16=0.8125

二、舍入

我們得把 0.0001100110011... 放進一個 double「雙精度浮點數(shù)」里面

雙精度浮點數(shù)能表示多少精度呢?查看文檔會發(fā)現(xiàn):
半精度(16bit):11 位有效數(shù)字
單精度(32bit):24 位有效數(shù)字
雙精度(64bit):53 位有效數(shù)字
四精度(128bit):113 位有效數(shù)字

好吧,雙精度是 53 位有效數(shù)字

0.00011001100110011001100110011001100110011001100110011001 10011...

方便起見,我在第 53 個有效數(shù)字后面加了個空格。那么問題來了:十進制數(shù)我們可以四舍五入,二進制怎么辦?精神是一樣的:待處理部分有沒有達到前一位的一半,達到就進位,沒達到就舍去。(暫且當作 0 舍 1 入。)
那么我們的 0.1 在 double 中就是

0.00011001100110011001100110011001100110011001100110011001 10011...
0.00011001100110011001100110011001100110011001100110011010

而 1.1 就是

1.0001100110011001100110011001100110011001100110011001 10011...
1.0001100110011001100110011001100110011001100110011010
三、加法

這個很簡單,1.1 + 0.1 就是

1.0001100110011001100110011001100110011001100110011010
+ 
0.00011001100110011001100110011001100110011001100110011010
------------------------------------------------------------
1.00110011001100110011001100110011001100110011001100111010

因為結果仍然是 double,需要再做一次保留 53 位有效數(shù)字和舍入:

1.0011001100110011001100110011001100110011001100110011 1010
1.0011001100110011001100110011001100110011001100110100
四、結果

好了,終于可以回到十進制的世界了,我們把最終結果轉換回來:
1.0011001100110011001100110011001100110011001100110100

得到十進制的:
1.20000000000000018

一般的輸出函數(shù),在輸出浮點數(shù)時,都會限制顯示的有效數(shù)字,即會再做一次四舍五入。題目中的 1.2000000000000002 是這個結果在顯示時四舍五入后的結果。

1.20000000000000018
1.2000000000000002

正經答題:1.2000000000000002 的原理上面已經一步步分析了。至于各個語言之間的差異,答案是可能會有,比如可能因為選擇的舍入規(guī)則的不同可能導致的結果的不同;甚至有可能某個語言里的浮點數(shù)壓根不是 IEEE 754 的浮點數(shù),而是以字符串方式保存的,所以可能沒有誤差。

以下參考JavaScript 精度丟失問題

// 1. 兩數(shù)相加
// 0.1 + 0.2 = 0.30000000000000004
// 0.7 + 0.1 = 0.7999999999999999
// 0.2 + 0.4 = 0.6000000000000001
// 2.22 + 0.1 = 2.3200000000000003

// 2. 兩數(shù)相減
// 1.5 - 1.2 = 0.30000000000000004
// 0.3 - 0.2 = 0.09999999999999998

// 3. 兩數(shù)相乘
// 19.9 * 100 = 1989.9999999999998
// 19.9 * 10 * 10 = 1990
// 1306377.64 * 100 = 130637763.99999999
// 1306377.64 * 10 * 10 = 130637763.99999999
// 0.7 * 180 = 125.99999999999999

// 4. 不一樣的數(shù)卻相等
// 1000000000000000128 === 1000000000000000129

計算機的底層實現(xiàn)就無法完全精確表示一個無限循環(huán)的數(shù),而且能夠存儲的位數(shù)也是有限制的,所以在計算過程中只能舍去多余的部分,得到一個盡可能接近真實值的數(shù)字表示,于是造成了這種計算誤差。

比如在 JavaScript 中計算0.1 + 0.2時,十進制的0.1和0.2都會被轉換成二進制,但二進制并不能完全精確表示轉換結果,因為結果是無限循環(huán)的。

// 百度進制轉換工具
0.1 -> 0.0001100110011001...
0.2 -> 0.0011001100110011...

根據 MDN這段關于Number的描述 可以得知,JavaScript 里的數(shù)字是采用 IEEE 754 標準的 64 位雙精度浮點數(shù)。該規(guī)范定義了浮點數(shù)的格式,最大最小范圍,以及超過范圍的舍入方式等規(guī)范。所以只要不超過這個范圍,就不會存在舍去,也就不會存在精度問題了。比如:

// Number.MAX_SAFE_INTEGER 是 JavaScript 里能表示的最大的數(shù)了,
//超出了這個范圍就不能保證計算的準確性了
var num = Number.MAX_SAFE_INTEGER;
num + 1 === num +2 // = true

實際工作中我們也用不到這么大的數(shù)或者是很小的數(shù),也應該盡量把這種對精度要求高的計算交給后端去計算,因為后端有成熟的庫來解決這個計算問題。前端雖然也有類似的庫,但是前端引入一個這樣的庫代價太大了。

排除直接使用的數(shù)太大或太小超出范圍,出現(xiàn)這種問題的情況基本是浮點數(shù)的小數(shù)部分在轉成二進制時丟失了精度,所以我們可以將小數(shù)部分也轉換成整數(shù)后再計算。網上很多帖子,比如 [ JS 基礎 ] JS 浮點數(shù)四則運算精度丟失問題 (3)
,貼出的解決方案就是這種:

var num1 = 0.1
var num2 = 0.2
(num1 * 10 + num2 * 10) / 10 // = 0.3

但是這樣轉換整數(shù)的方式也是一種浮點數(shù)計算,在轉換的過程中就可能存在精度問題,比如:

1306377.64 * 10 // = 13063776.399999999
1306377.64 * 100 // = 130637763.99999999
var num1 = 2.22;
var num2 = 0.1;
(num1 * 10 + num2 * 10) / 10 // = 2.3200000000000003

所以不要直接通過計算將小數(shù)轉換成整數(shù),我們可以通過字符串操作,移動小數(shù)點的位置來轉換成整數(shù),最后再同樣通過字符串操作轉換回小數(shù):

/**
 * 通過字符串操作將一個數(shù)放大或縮小指定倍數(shù)
 * @num 被轉換的數(shù)
 * @m   放大或縮小的倍數(shù),為正表示小數(shù)點向右移動,表示放大;為負反之
 */
function numScale(num, m) {
  // 拆分整數(shù)、小數(shù)部分
  var parts = num.toString().split('.');
  // 原始值的整數(shù)位數(shù)
  const integerLen = parts[0].length;
  // 原始值的小數(shù)位數(shù)
  const decimalLen = parts[1] ? parts[1].length : 0;
  
  // 放大,當放大的倍數(shù)比原來的小數(shù)位大時,需要在數(shù)字后面補零
  if (m > 0) {
    // 補多少個零:m - 原始值的小數(shù)位數(shù)
    let zeros = m - decimalLen;
    while (zeros > 0) {
      zeros -= 1;
      parts.push(0);
    }
  // 縮小,當縮小的倍數(shù)比原來的整數(shù)位大時,需要在數(shù)字前面補零
  } else {
    // 補多少個零:m - 原始值的整數(shù)位數(shù)
    let zeros = Math.abs(m) - integerLen;
    while (zeros > 0) {
      zeros -= 1;
      parts.unshift(0);
    }
  }

  // 小數(shù)點位置,也是整數(shù)的位數(shù): 
  //    放大:原始值的整數(shù)位數(shù) + 放大的倍數(shù)
  //    縮?。涸贾档恼麛?shù)位數(shù) - 縮小的倍數(shù)
  var index = integerLen + m;
  // 將每一位都拆到數(shù)組里,方便插入小數(shù)點
  parts = parts.join('').split('');
  // 當為縮小時,因為可能會補零,所以使用原始值的整數(shù)位數(shù)
  // 計算出的小數(shù)點位置可能為負,這個負數(shù)應該正好是補零的
  // 個數(shù),所以小數(shù)點位置應該為 0
  parts.splice(index > 0 ? index : 0, 0, '.');

  return parseFloat(parts.join(''));
}

測試用例:

describe('accAdd', function() {
  it('(0.1, 0.2) = 0.3', function() {
    assert.strictEqual(0.3, _.accAdd(0.1, 0.2))
  })
  it('(2.22, 0.1) = 2.32', function() {
    assert.strictEqual(2.32, _.accAdd(2.22, 0.1))
  })
  it('(11, 11) = 22', function() {
    assert.strictEqual(22, _.accAdd(11, 11))
  })
})
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

友情鏈接更多精彩內容