浮点数精度之谜

  话要从业务代码里的bug提及,大体过程是前端运算 2.07-1 以后结果倒是1.0699999999999998,老司机们都知道是浮点数运算的精度丢失致使的,在查看了下具体代码,果真处理不当。所以我深究一番,并诞生了此文。此处重点强调两个认识误区:javascript

  • 浮点数运算精度丢失问题并非js独有的!
  • js浮点数的加减乘除运算均可能致使精度丢失问题!

首先不得不说说浮点数的表示方法,任何数在计算机面前都会被处理成二进制,而数字的二进制表示主要有原码、反码、补码。(有点熟悉对不对?哥就是来给你补计算机组成原理的,坏笑~)前端

原码

原码是计算机中对数字的二进制的定点表示方法,最高位表示符号位,其他位表示数值位。优势显而易见,简单直观;缺点也很明显,不能直接参与运算,可能会报错,如11+(-11) => 10010110 => -22,结果居然不等于0。(卧槽,瞎搞啊~,觉得我没上过学?)因此,原码符号位不能直接参与运算。说到这,给你们个思考题,8位有符号的原码表示范围是多少?本身思考哈~java

反码

正数的反码和其原码同样;负数的反码,符号位为1,数值部分按原码取反。例如 [+7]原 = 00000111,[+7]反 = 00000111; [-7]原 = 10000111,[-7]反 = 11111000。ui

补码

正数的补码和其原码同样;负数的补码为其反码加1。例如 [+7]原 = 00000111,[+7]反 = 00000111,[+7]补 = 00000111; [-7]原 = 10000111,[-7]反 = 11111000,[-7]补 = 11111001。
说到这,你也许会问,哥你这都是讲的整数啊,没说到浮点数啊。别急,弟继续往下看~spa

浮点数的表示方法

国际标准IEEE 754规定,任意一个二进制浮点数V均可以表示成下列形式:设计


  1. (-1)^s 表示符号位,当s=0,V为整数;s=1,V为负数;
  2. M 表示有效数字,1≤M<2;
  3. 2^E 表示指数位

举个小栗子🌰:
-0.5 => -0.1[二进制]
   => -1.0 * 2^-1
   => (-1)^1 * 1.0 * 2^-1
   => s=1,M=1.0,E=-13d

IEEE 754又规定了,浮点数分单精度双精度之分:code

  • 32位的单精度浮点数,最高1位是符号位s,接着的8位是指数E,剩下的23位是有效数字M
  • 64位的双精度浮点数,最高1位是符号位s,接着的11位是指数E,剩下的52位为有效数字M

对于有效数字M和指数E,这个IEEE 754还规定了:cdn

  1. 有效数字M
    (1)1≤M<2,也即M能够写成1.xxxxx的形式,其中xxxxx表小数部分
    (2)计算机内部保存M时,默认这个数第一位老是1,因此舍去。只保存后面的xxxxx部分,节省一位有效数字
  2. 指数E(阶码)
    (1)E为无符号整数。E为8位,范围是0~255;E为11位,范围是0~2047
    (2)由于科学计数法中的E是能够出现负数的,因此IEEE 754规定E的真实值必须再减去一个中间数(偏移值),127或1023

有人又要问了,哥,为啥子要有中间数?本身思考哈,弟你本身要学会成长,实在不行你也能够问你谷哥~blog

Attention! 精华部分来了~

浮点数加法

浮点数的加法运算(不要问哥为啥只讲加法~)分为下面几个步骤:

  • 对阶
  • 位数求和
  • 规格化
  • 舍入
  • 校验判断

(1)对阶
顾名思义就是对齐阶码,使两数的小数点位置对齐,小阶向大阶对齐;
(2)尾数求和
对阶完对尾数求和
(3)规格化
尾数必须规格化成1.M的形式
(4)舍入
在规格化时会损失精度,因此用舍入来提升精度,经常使用的有0舍1入法,置1法
(5)校验判断
最后一步是校验结果是否溢出。若阶码上溢则置为溢出,下溢则置为机器零

实例计算(以单精度为例)

0.2 => 1/8 + 1/16 + 1/128 +... => 1.100110011001100...*2^-3 =>

0(符号位) 01111100 (指数位) (1) 10011001100110011001100(尾数位)

0.4 => 1/4 + 1/8 +1/64 +... => 1.100110011001100...*2^-2 =>

0(符号位) 01111101 (指数位) (1) 10011001100110011001100(尾数位)

这里,细心的同窗可能会发现指数位为什么是01111100,不是应该是-3,这是由于-3加上了中间值127等于124;因此反算的时候,要用计算值减去中间值获得真正的指数值。
(1)对阶
根据小阶对大阶原则,0.2的阶码向0.4阶码对齐,即0.4的阶码不做调整,0.2的阶码对齐,且尾数作右移处理:
0.2 => 0 01111101 (0)11001100110011001100110
0.4 => 0 01111101 (1)10011001100110011001100
(2)尾数求和
(0)11001100110011001100110 + (1)10011001100110011001100 => (10)01100110011001100110010
(3)尾数规格化
0 01111101 (10)01100110011001100110010 => 0 01111110 (1)00110011001100110011001

⚠️ 最后的0被移出去了,这就是偏差产生的根源!

(4)舍入
(5)校验判断
0.2 + 0.4 => 0 01111110 (1)00110011001100110011001 => 1.1999999285/2 => 0.5999999643 (并不等于0.6)

最后发现计算结果果真出现偏差,由于在尾数规格化的步骤中可能产生移位偏差,看来要想精确运算,不能直接操做浮点数运算啊!最保险的方法是在运算过程当中,将浮点数处理成整数进行运算:

/** * [scaleNum 经过操做其字符串将一个浮点数放大或缩小] * @param {number} num 要放缩的浮点数 * @param {number} pos 小数点移动位数 * pos大于0为放大,小于0为缩小;不传则默认将其变成整数 * @return {number} 放缩后的数 */
function scaleNum(num, pos) {
    if (num === 0 || pos === 0) {
        return num;
    }

    let parts = num.toString().split('.');
    const intLen = parts[0].length;
    const decimalLen = parts[1] ? parts[1].length : 0;

    // 默认将其变成整数,放大倍数为原来小数位数
    if (pos === undefined) {
        return parseFloat(parts[0] + parts[1]);
    } else if (pos > 0) {
        // 放大
        let zeros = pos - decimalLen;
        while (zeros > 0) {
            zeros -= 1;
            parts.push(0);
        }
    } else {
        // 缩小
        let zeros = Math.abs(pos) - intLen;
        while (zeros > 0) {
            zeros -= 1;
            parts.unshift(0);
        }
    }

    const idx = intLen + pos;
    parts = parts.join('').split('');
    parts.splice(idx > 0 ? idx : 0, 0, '.');

    return parseFloat(parts.join(''));
}复制代码

有不少同窗将浮点数扩大成整数,直接乘以10^N,其实这也会可能致使偏差,例如 0.57*100 => 56.99999999999999;另外除法运算也可能致使偏差,5.7/10 => 0.5700000000000001;记住,包含浮点数的加减乘除均可能致使计算偏差。

Q&A:

  1. 8位有符号的原码表示范围是多少?
    A:111111111 ~ 01111111 => -127 ~ +127
  2. 阶码运算为啥要有中间数?A:指数能够为正数,也能够为负数。为了计算机处理数据的方便,就是但愿在加法运算中将减法运算一并处理了,因此处理了负指数的状况,加上中间值来简化CPU中运算器的设计
相关文章
相关标签/搜索