js 小数的精度损失

参考如何理解double精度丢失问题?
比如1.1+0.1=1.2000000000000002,在java C++ OC等语言中出现是否原理相同?

根本原因在于,数学意义上的小数不是每个都能用二进制在有限位数内精确的表示。像 0.1,1.1 这样的小数没有精确的二进制表示,然后求和就不是1.2了。

一、二进制与十进制互相转化
1.二进制如何表示0.1

我们知道 DEC(1) 就是 BIN(1),但是 DEC(0.1) 怎么转换成二进制?对了!用除法:0.1 = 1 ÷ 10很简单,二进制就是要算1 ÷ 1010我们回到小学的课堂,来列竖式吧:


image.png

相信上过小学的你一定会发现,除不尽,除出了一个无限循环小数:二进制的 0.0001100110011...

2.如果上面的除法有点复杂,也可以参考十进制小数如何转换为二进制小数? - 知乎

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

  • 用2乘十进制小数,可以得到积,将积的整数部分取出,再用2乘余下的小数部分,又得到一个积,再将积的整数部分取出,如此进行,直到积中的整数部分为零,或者整数部分为1,此时0或1为二进制的最后一位。或者达到所要求的精度为止。
  • 然后把取出的整数部分按顺序排列起来,先取的整数作为二进制小数的高位有效位,后取的整数作为低位有效位。
如:0.625=(0.101)B   
0.625*2=1.25======取出整数部分1   
0.25*2=0.5========取出整数部分0   
0.5*2=1==========取出整数部分1   
再如:0.7=(0.1 0110 0110...)B   
0.7*2=1.4========取出整数部分1   

0.4*2=0.8========取出整数部分0   
0.8*2=1.6========取出整数部分1   
0.6*2=1.2========取出整数部分1   
0.2*2=0.4========取出整数部分0    

0.4*2=0.8========取出整数部分0   
0.8*2=1.6========取出整数部分1   
0.6*2=1.2========取出整数部分1   
0.2*2=0.4========取出整数部分0
再来试试上面的0.1吧
0.1*2=0.2========取出整数部分0

0.2*2=0.4========取出整数部分0
0.4*2=0.8========取出整数部分0
0.8*2=1.6========取出整数部分1
0.6*2=1.2========取出整数部分1

0.2*2....

很容易看到上面进入0.2之后,又开始了0011的循环,所以最终结果就是0.0 0011 0011 0011...

3.参考二进制小数怎么转化为十进制的?

和上面的过程很类似,比如二进制的0.1101如何转化:
2-1+2-2+0-3+2-4=1/2+1/4+0/8+1/16=0.8125

二、舍入

我们得把 0.0001100110011... 放进一个 double「双精度浮点数」里面

双精度浮点数能表示多少精度呢?查看文档会发现:
半精度(16bit):11 位有效数字
单精度(32bit):24 位有效数字
双精度(64bit):53 位有效数字
四精度(128bit):113 位有效数字

好吧,双精度是 53 位有效数字

0.00011001100110011001100110011001100110011001100110011001 10011...

方便起见,我在第 53 个有效数字后面加了个空格。那么问题来了:十进制数我们可以四舍五入,二进制怎么办?精神是一样的:待处理部分有没有达到前一位的一半,达到就进位,没达到就舍去。(暂且当作 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 位有效数字和舍入:

1.0011001100110011001100110011001100110011001100110011 1010
1.0011001100110011001100110011001100110011001100110100
四、结果

好了,终于可以回到十进制的世界了,我们把最终结果转换回来:
1.0011001100110011001100110011001100110011001100110100

得到十进制的:
1.20000000000000018

一般的输出函数,在输出浮点数时,都会限制显示的有效数字,即会再做一次四舍五入。题目中的 1.2000000000000002 是这个结果在显示时四舍五入后的结果。

1.20000000000000018
1.2000000000000002

正经答题:1.2000000000000002 的原理上面已经一步步分析了。至于各个语言之间的差异,答案是可能会有,比如可能因为选择的舍入规则的不同可能导致的结果的不同;甚至有可能某个语言里的浮点数压根不是 IEEE 754 的浮点数,而是以字符串方式保存的,所以可能没有误差。

以下参考JavaScript 精度丢失问题

// 1. 两数相加
// 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. 两数相减
// 1.5 - 1.2 = 0.30000000000000004
// 0.3 - 0.2 = 0.09999999999999998

// 3. 两数相乘
// 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. 不一样的数却相等
// 1000000000000000128 === 1000000000000000129

计算机的底层实现就无法完全精确表示一个无限循环的数,而且能够存储的位数也是有限制的,所以在计算过程中只能舍去多余的部分,得到一个尽可能接近真实值的数字表示,于是造成了这种计算误差。

比如在 JavaScript 中计算0.1 + 0.2时,十进制的0.1和0.2都会被转换成二进制,但二进制并不能完全精确表示转换结果,因为结果是无限循环的。

// 百度进制转换工具
0.1 -> 0.0001100110011001...
0.2 -> 0.0011001100110011...

根据 MDN这段关于Number的描述 可以得知,JavaScript 里的数字是采用 IEEE 754 标准的 64 位双精度浮点数。该规范定义了浮点数的格式,最大最小范围,以及超过范围的舍入方式等规范。所以只要不超过这个范围,就不会存在舍去,也就不会存在精度问题了。比如:

// Number.MAX_SAFE_INTEGER 是 JavaScript 里能表示的最大的数了,
//超出了这个范围就不能保证计算的准确性了
var num = Number.MAX_SAFE_INTEGER;
num + 1 === num +2 // = true

实际工作中我们也用不到这么大的数或者是很小的数,也应该尽量把这种对精度要求高的计算交给后端去计算,因为后端有成熟的库来解决这个计算问题。前端虽然也有类似的库,但是前端引入一个这样的库代价太大了。

排除直接使用的数太大或太小超出范围,出现这种问题的情况基本是浮点数的小数部分在转成二进制时丢失了精度,所以我们可以将小数部分也转换成整数后再计算。网上很多帖子,比如 [ JS 基础 ] JS 浮点数四则运算精度丢失问题 (3)
,贴出的解决方案就是这种:

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

但是这样转换整数的方式也是一种浮点数计算,在转换的过程中就可能存在精度问题,比如:

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

所以不要直接通过计算将小数转换成整数,我们可以通过字符串操作,移动小数点的位置来转换成整数,最后再同样通过字符串操作转换回小数:

/**
 * 通过字符串操作将一个数放大或缩小指定倍数
 * @num 被转换的数
 * @m   放大或缩小的倍数,为正表示小数点向右移动,表示放大;为负反之
 */
function numScale(num, m) {
  // 拆分整数、小数部分
  var parts = num.toString().split('.');
  // 原始值的整数位数
  const integerLen = parts[0].length;
  // 原始值的小数位数
  const decimalLen = parts[1] ? parts[1].length : 0;
  
  // 放大,当放大的倍数比原来的小数位大时,需要在数字后面补零
  if (m > 0) {
    // 补多少个零:m - 原始值的小数位数
    let zeros = m - decimalLen;
    while (zeros > 0) {
      zeros -= 1;
      parts.push(0);
    }
  // 缩小,当缩小的倍数比原来的整数位大时,需要在数字前面补零
  } else {
    // 补多少个零:m - 原始值的整数位数
    let zeros = Math.abs(m) - integerLen;
    while (zeros > 0) {
      zeros -= 1;
      parts.unshift(0);
    }
  }

  // 小数点位置,也是整数的位数: 
  //    放大:原始值的整数位数 + 放大的倍数
  //    缩小:原始值的整数位数 - 缩小的倍数
  var index = integerLen + m;
  // 将每一位都拆到数组里,方便插入小数点
  parts = parts.join('').split('');
  // 当为缩小时,因为可能会补零,所以使用原始值的整数位数
  // 计算出的小数点位置可能为负,这个负数应该正好是补零的
  // 个数,所以小数点位置应该为 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))
  })
})
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,519评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,842评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,544评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,742评论 1 271
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,646评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,027评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,513评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,169评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,324评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,268评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,299评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,996评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,591评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,667评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,911评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,288评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,871评论 2 341