zoukankan      html  css  js  c++  java
  • 死磕浮点数——浮点数精度之谜

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

    浮点数运算精度丢失问题并不是js独有的!
    js浮点数的加减乘除运算都可能导致精度丢失问题!

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

    原码

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

    反码

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

    补码

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

    浮点数的表示方法

    国际标准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=-1

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

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

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

    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

    有人又要问了,哥,为啥子要有中间数?自己思考哈,弟你自己要学会成长,实在不行你也可以问你谷哥~

    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中运算器的设计
  • 相关阅读:
    String
    Xposed源码编译踩坑实录
    Hello 博客园
    HDU 1430 关系映射 + 打表 .
    HDU 1430 关系映射 + 打表 .
    hdu1043 经典的八数码问题 逆向bfs打表 + 逆序数
    hdu1043 经典的八数码问题 逆向bfs打表 + 逆序数
    hdu 1044 BFS(压缩图)+DFS
    hdu 1044 BFS(压缩图)+DFS
    hdu3338 最大流
  • 原文地址:https://www.cnblogs.com/CodeWorkerLiMing/p/12007372.html
Copyright © 2011-2022 走看看