关于 js 中的浮点计算(转)

0.1 + 0.2 为什么不等于0.3 ?

简单来说就是0.1和0.2在32位和64位存储时都是无限循环小数,本身就是最后一位舍入的,是近似取值,相加之后结果又进行了一次舍入。所以就导致不等于0.3。解决办法可以分别变成整数进行计算。(0.1 * 1000 + 0.2 * 1000)/ 1000
转载: 原文链接:http://eux.baidu.com/blog/fe/%E5%85%B3%E4%BA%8Ejs%E4%B8%AD%E7%9A%84%E6%B5%AE%E7%82%B9%E8%BF%90%E7%AE%97

😄 阅读本文需要的:

  • 能将十进制的整数或小数换算成二进制且知道原理
  • 知道原码补码反码,且掌握二进制的加减
  • 有好奇心有耐心

前几天偶然跟人家聊到 javascript 有一个很好玩的事情, 0.1 + 0.2 = 0.30000000000000004。稍微有经验大概能反应出来这是存储时数据长度截取产生的原因,但是具体是计算机怎么计算的呢,自己也解释不清,于是带着好奇稍微探索了一下。 (ps:实际上并不是只有 javascript 存在这种问题,具体可以看看 http://0.30000000000000004.com/ 这个网站。)

浮点数在计算机中的存储

IEEE标准

首先科普一下 js 中使用的二进制浮点数算术标准 IEEE_754 他采用的存储格式为:

E =  (-1)^1 × M × 2^E
  • (-1)^s表示符号位,当s=0,V为正数;当s=1,V为负数。
  • M表示有效数字,大于等于1,小于2。
  • 2^E表示指数位。

举例来说,十进制的 5.0,写成二进制是 101.0,相当于 1.01×2^2。那么,按照上面 V 的格式,可以得出 s=0,M=1.01,E=2。 十进制的 -5.0,写成二进制是 -101.0,相当于 -1.01×2^2。那么,s = 1,M = 1.01,E = 2。

存储格式

对于32位的浮点数,最高的1位是符号位s,接着的8位是指数E,剩下的23位为有效数字M。

image

对于64位的浮点数,最高的1位是符号位S,接着的11位是指数E,剩下的52位为有效数字M。

image

这里只简单说一下第一种情况存储时的一些点:

尾数 M

IEEE 754规定,在 计算机内部保存M时,默认这个数的第一位总是1,因此可以被舍去,只保存后面的xxxxxx部分 。比如保存 1.01 的时候,只保存 01,等到读取的时候,再把第一位的 1 加上去。这样做的目的,是节省 1 位有效数字。以 32 位浮点数为例,留给 M 只有 23 位,将第一位的 1 舍去以后,等于可以保存 24位有效数字。

指数E

指数 E 的情况稍微复杂一点点。

首先,E为一个无符号整数)。这意味着,如果 E 为 8 位,它的取值范围为 0~255;如果 E 为 11 位,它的取值范围为 0~2047。但是,我们知道,科学计数法中的 E 是可以出现负数的,所以 IEEE 754规定,E的真实值必须再减去一个中间数,对于8位的E,这个中间数是127;对于11位的E,这个中间数是1023

比如,2^10 的 E 是10,当保存成 32 位浮点数时,必须保存成 10 + 127 = 137,即 10001001。如果要保存成 64 位浮点数的时候,就会保存成 10 + 1023 = 1033,即 10000001001。

然后,指数E还可以再分成三种情况:

image
  1. E 不全为 0 或不全为 1。这时,浮点数就采用上面的规则表示,即指数 E 的计算值减去 127(或1023),得到真实值,再将有效数字 M 前加上第一位的 1。
  2. E 全为 0。这时,浮点数的指数E等于 1-127(或者1-1023),有效数字M不再加上第一位的 1,而是还原为 0.xxxxxx 的小数。这样做是为了表示 ±0,以及接近于 0 的很小的数字。
  3. E 全为 1。这时,如果有效数字 M 全为0,表示±无穷大(正负取决于符号位s);如果有效数字M不全为0,表示这个数不是一个数(NaN)。

浮点数转换为二进制方法

浮点数转换成二进制,我们要将整数部分和小数部分分开,大概就是整数部分采用除2取余倒叙记录,小数部分采用乘2取整顺序记录。具体例子和实现方式可自行搜索。

以 0.1 为例,0.1的二进制

    0.1
x     2
-------
    0.2     0
x     2
-------
    0.4     0
x     2
-------
    0.8     0
x     2
-------
    0.6     1
x     2
-------
    0.2     1
------- 又从0.2开始循环了

于是,我们得到了 0.1 的二进制表示,为 0.0001100110011(0011循环),即 1.100110011(0011) * 2^-4 在计算机中的存储表达里,符号 s=0,尾数 M = 1.100110011(0011),阶码 E = -4,实际存储为 -4+1023 = 1019 的二进制 1111111011。由于 javascript 是双精度的,所以 0.1 在计算机中存储格式为:

0  01111111011  1001100110011001100110011001100110011001100110011010
--------------------------------------------------------------------
s  exp(11位)     frac(52位,注意存储时候前面的1会舍去,最后一位进1)

同理,0.2 的二进制表示为 0.001100110011(0011循环),即 1.100110011(0011)*2^-3 在计算机中的存储表达里,符号 s=0,尾数 M = 1.100110011(0011),阶码 E = -3,实际存储为 -3+1023 = 1020 的二进制 1111111100。在计算机中存储格式为:

0  01111111100  1001100110011001100110011001100110011001100110011010
--------------------------------------------------------------------
s  exp(11位)     frac(52位,注意存储时候前面的1会舍去,最后一位进1)

运算

存储讲完,接下来就该聊下浮点数的加减运算了,它一般有6个步骤完成,先补充一下理论知识:

(1)对0InfinityNaN 操作数作检查,若有一个操作数为NaN则直接返回NaN;若有一个操作数为0则直接返回另一个操作数;若有一个操作数为Infinity,若另一个操作数也是Infinity且符号相同则返回第一个操作数;若另一个操作数也是Infinity且符号不同则返回NaN;若其他情况则返回Infinity。

image

(2)对阶:对阶是将两个进行运算的浮点数的阶码对齐的操作。因为只有使两浮点数的指数值部分相同,才能将相同的指数值作为公因数提出来,然后进行尾数的加减运算。具体方法为:求出两浮点数阶码的差,即⊿E=Ex-Ey,将小阶码加上⊿E,使之与大阶码相等,同时将小阶码对应的浮点数的尾数右移相应位数,以保证该浮点数的值不变。几点注意:

  • 对阶的原则是小阶对大阶。因为若大阶对小阶,则尾数的数值部分的高位需移出,而小阶对大阶移出的是尾数的数值部分的低位,这样损失的精度更小。
  • 采用补码表示的尾数右移时,符号位保持不变。
  • 由于尾数右移时是将最低位移出,会损失一定的精度,为减少误差,可先保留若干移出的位,供以后舍入处理用。

(3)尾数运算:主要为进行完成对阶后的尾数相加减的相关操作(包含隐藏位),采用双符号法判断是否溢出。

(4)结果规格化,主要分为三种 向右规格化:若上一步出现溢出,则尾数右移1位,阶码+1; 向左规格化:若上一步没有出现溢出,且数值域最高位与符号位数值相同,则尾数左移1位且阶码-1,直到数值域最高位为1为止。

(5)舍入处理:由于浮点数无法精确表示所有数值,因此在存储前必须对数值作舍入操作。具体有五种方式,这里我们只谈 IEEE 754 默认的舍入模式:就近舍入 Round to nearest, ties to even: 就是我们日常所说的四舍五入,当存在两个数一样接近时,取偶数值(如2.4舍入为2,2.6舍入为3;2.5舍入为2,1.5舍入为2)。 另外还有 round toward +∞,round toward -∞,round toward 0 这三种模式,有兴趣的可自行查相关资料。

(6)溢出判断:与定点数运算不同的是,浮点数的溢出是以其运算结果的阶码的值是否产生溢出来判断的。若阶码的值超过了阶码所能表示的最大正数,则为上溢,进一步,若此时浮点数为正数,则为正上溢,记为 +∞,若浮点数为负数,则为负上溢,记为-∞;若阶码的值超过了阶码所能表示的最小负数,则为下溢,进一步,若此时浮点数为正数,则为正下溢,若浮点数为负数,则为负下溢。正下溢和负下溢都作为 0 处理。

一堆理论扯完之后,我们来看 0.1 + 0.2 的运算过程。 原本的 0.1 和 0.2 的二进制表示

0.1 = 1.1001100110011001100110011001100110011001100110011010 * 2^-4
0.2 = 1.1001100110011001100110011001100110011001100110011010 * 2^-3

可以看到,0.1 的阶码为 -4,0.2 的阶码为 -3,依照小阶对大阶的原则,我们需要将 0.1 的阶码变为 -3,因此其尾数部分需要右移一位。对阶之后 0.1 的存储为:

0.1 = 0.11001100110011001100110011001100110011001100110011010 * 2^-3

想右移一位导致尾数需要进行阶段,因为最后一位刚好是0,所以这里直接舍弃,因此 0.1 对阶之后的存储为

0  01111111100  1100110011001100110011001100110011001100110011001101 
--------------------------------------------------------------------
s  exp(阶码-3)   frac

然后我们就可以愉快的进行相加这个运算步骤了:

   0  01111111100  1100110011001100110011001100110011001100110011001101
+  0  01111111100  1001100110011001100110011001100110011001100110011010

计算的话,手动画一画也没啥大问题,不过身为程序员就比较懒了,这里我们写一个二进制相加的方法:

const a = "1100110011001100110011001100110011001100110011001101".split('');
const b = "1001100110011001100110011001100110011001100110011010".split('');
const res = [];
let flag = 0;
for (let i = a.length - 1; i >= 0; i--) {
    const ai = parseInt(a[i]);
    const bi = parseInt(b[i]);
    const cur = (ai ^ bi) ^ flag; // 当前数值 
    flag = (ai + bi + flag) >= 2 ? 1 : 0; // 进位
    res.unshift(cur);
    if (i === 0 && flag === 1) {
        res.unshift(flag);
    }
}
console.log(res.join(''));
// 10110011001100110011001100110011001100110011001100111

得出:

   0  01111111100   1100110011001100110011001100110011001100110011001101
+  0  01111111100   1001100110011001100110011001100110011001100110011010
-------------------------------------------------------------------
=  0  01111111100  10110011001100110011001100110011001100110011001100111

可以看到发生了进位,此时尾数超过52,因此阶码部分加1(乘以2),即阶码由原来的 -3 变为 -2,所以阶码部分为 01111111101。而尾数部分右移一位(除以2),进行舍入(最后一位是1因此最低位进位),得到52位新的二进制表示为:

1011001100110011001100110011001100110011001100110011 11011001100110011001100110011001100110011001100110100~~~~

所以,最终的计算结果在计算机中的存储表达如下:

0  1111111101  1011001100110011001100110011001100110011001100110100

将其转换成十进制数为:

2^-2 + (1+(1*2^-1 + 0 * 2^-2+1*2^-3+1*2^-4+... ) = 0.3000000000000000444089209850062616169452667236328125

由于精度问题,只取到 0.30000000000000004。

然后看看 0.3.toString(2) 的结果 �

console.log(0.3.toString(2) );
// 0.010011001100110011001100110011001100110011001100110011

写在最后

好了,我们已经知道为什么 0.1 + 0.2 !== 0.3 的原因了,主要由于 0.1 和 0.2 转为二进制的时候为无限循环小数,而计算机的存储位置有限因此会做一定的截取舍入处理,再进行加减就有一定的误差了。

另外,由于js并没有特别区分整型和浮点型,实际上整型在 js 里面也是用浮点数的结构存储的,不过放在了尾数部分,以便于在计算过程总能随意自由切换。所以实际应用中,由于一些精度问题,比如后端数据库传来一个 ID 字段可能就会大于这个值,调用 JSON.parse 的时候就会丢失精度了,因此对于某些过大过小的数字需要用字符串存储。那要怎么在 js 中尽可能准确的计算出结果,以及怎么判断两个小数是否相等呢,敬请期待下回分解~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容