《计算机原理》复习之数据精度

/ 默认分类 / 0 条评论 / 788浏览

计算机数据精度(一)

数据精度的问题,在日常编码中是需要注意的,尤其是从事互联网金融相关的开发工作,这个系列的文章,我会重点复习总结下,当然这部分知识点,基本都是大学时期的学习课程,《计算机原理》中就有深入的分析。下面只是对于一些重点相关的知识点做一下总结,也算是复习了,如果需要更加深入的学习,还是建议大家阅读一下《计算机原理》。

一.进制转化

1.十进制整数转为二进制

对于十进制整数可以使用8421码来将十进制转为二进制.或者使用除2取余.

2.十进制小数转为二进制

首先需要明确一点就是并不是所有的十进制小数都能用二进制数精确表示的。其实就类似于在十进制中,小数1/3也无法精确表示,因为结果是无限循环小数,那么这种情况发生在计算机中,因为计算机存储数据的数据结构占用的空间大小是固定的有限制的,所以无限的小数就被截断了,所以就导致了精度丢失。

十进制  0.375
0.375 * 2 = 0.75  ———— 0 (0.75的整数部分为0)
0.75 * 2   = 1.5    ———— 1(1.5的整数部分为1)
0.5 * 2 = 1           ———— 1(1的整数部分为1,且没有余数)
则二进制小数位 0.011

十进制  0.2
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.001100110011001100110011........

二.定点数:

小数点位置是固定的,要么在最高位前面,要么在最低位后面,这里以java为例: Short num = 10;在java中Short占用2byte,即16bit,并且short是有符号短整形,所以num在计算机中就是:00000000 00001010,所以short所能表示的最大 数就是2^15-1,即01111111 11111111,所以对于short这样的有符号短整形,可以表示的范围就是-2^15-2^15-1,这里小数点的位置就定死在最低位的后面.所以short只能表示整数.

那么,如果想表示小数,如果也使用定点数表示,那么就有以下几种情况需要我们选择: 首先小数可以看做是这样的结构: 整数部分M.小数部分N,如果存储于一个16bit大小的数据结构中,那么小数点的位置就可能有下面三种划分模式:

三.浮点数:

上述方法就是定点数表示法,相对而言,还可以使用浮点数表示法,相同的位数,浮点数可以表示的数据范围更大,精度更大. 实际上,浮点数就是计算机世界(二进制)中的科学计数法(这里不严格按照科学计数法格式). 对于十进制来说:

8.25 = 8.25 * 10^0
或者
8.25 = 0.825 * 10^1

这里8.25我们称之为尾数M,3次幂就叫做指数或者阶数E,10叫做基数R. 所以可以用下面的通用格式表示

M*R^E

对于8.25 * 10^3,其实也可以表示成82.510^2,也可以表示成82510^1等等,所以小数点的位置是可变的,可变的基础可以看出来,只需要M和E的大小跟着做出变化即可. 将上面的8.25 * 10^3使用二进制进行表示,存储在计算机的数据结构中就可以让计算机使用浮点数来表示数据了,这样的好处很明显,计算机只需要存储M和E即可,而E是阶数,在固定的 位数下就可以实现很大的计数范围.

先将尾数8.25转为二进制:1000.01,再使用科学计数法表示就是:1.000012^3 那么这里使用二进制表示后,尾数就是00001(为什么不是1.00001?因为二进制中尾数一定是1开头,所以直接省略!这个省略的也叫做隐藏位,这样的话单精度float 23 位尾数可以表示了 24 位有效数字,双精度double 52 位尾数可以表示 53 位有效数字,这个后面会详细介绍),阶数就是3,基数就是2 上面得到的只是科学计数法转为二进制的形式,实际上计算机的数据是存储在内存上的,内存中都是连续的0或1,所以要怎样表示这样的浮点计数呢? 以java中的float(32bit)为例: 蓝色的部分表示符号位,绿色的部分表示阶数(指数),红色的部分表示尾数. 所以将10进制8.25转为了二进制1000.01,又转为二进制表示的科学计数法(阶数还是十进制):1.000012^3,再使用计算机数据结构(java中的float)存储,就是: 实际存储的阶数大小需要加上127,那么就是127+3=128+2=130,所以阶数就是10000010

为什么要加上127? 因为二进制阶数是无符号整数,在float中是8bit来表示的,所以是0~255范围内,但实际中我们需要有负指数,正负各取一半的范围,也就是-127~128,但是为了存储还是为无符号整数, 所以存储的时候都加上127(127也叫做中间数),这样就保证了实际存储的就是无符号整数了.同理对于下面的双精度数据double,因为阶数共占用11位,所以存入E时加上中间数1023,这样取值范围为 -1023 ~ 1024.

尾数是00001,所以实际的float中的尾数就是00001000000000000000000,所以组合在一起就是: 0 10000010 00001000000000000000000

为什么这里在存放尾数的时候,是将数据从高位存起,这是因为尾数是去除了隐藏数的,也就是去除了1,所以去除后的有效数字可能是0,如果是从低位开始存储,那么就无法区分尾数的起始位置了,所以从高位开始存储,到最后一个1终止,也就是尾数存储的起始位置了.

在java中进行验证(正数的符号位0省略了):

同理,如果是-8.25就是: 也就是符号位变成1

同理如果是double(java中是64bit),组成如下: 符号位 S 占 1 bit,指数 E 占 11 bit,尾数 M 占 52 bit,计算方式也就和上面一样.

Double类中doubleToRawLongBits,double类型占64位,而long类型也是占64位,两个类型在计算机中存储的机器码都是64位二进制,从0和1的角度来看,是没有任何区别的。区别在于,对应同一64位二进制机器码,两者只是解析方式不同。long:按整数类型来解析.double:按浮点类型来解析,按IEEE754标准,详细见IEEE 754浮点类型表示.

三.java中浮点数计算注意事项

浮点数值不适用于无法接受舍入误差的金融计算中,即:我们常说的丢失精度问题。这种舍入误差的主要原因是浮点数值采用二进制系统表示, 而在二进制系统中无法精确地表示分数 1/10。这就好像十进制无法精确地表示分数 1/3—样。针对十进制,1除以3是除不尽的。很好理解,因为我们一直接触的就是十进制,等于0.333333… 很好理解,在二进制中,小数部分转为二进制时也会出现小数循环的情况,比如0.2,表示为浮点数,那么按照上面说的,需要先转化为二进制

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(发生循环,所以会出现尾数是无限循环的二进制数,而float和double只能表示有限位数的尾数,所以肯定会出现精度丢失)

    @Test
    public void test4() {
        BigDecimal a10 = new BigDecimal(1.0);
        BigDecimal a09 = new BigDecimal(0.9);
        double d09 = 0.9d;
        System.out.println(a10);
        System.out.println(a09);
        System.out.println(d09);
        System.out.println(String.format("%.20f",a09));
        System.out.println(String.format("%.20f",d09));
    }
1
0.90000000000000002220446049250313080847263336181640625
0.9
0.90000000000000002220
0.90000000000000000000
    @Test
    public void test5() {
        double a04 = 0.4;
        double a4 = 4;
        double a2 = 2;
        double a03 = 0.3;

        //由于精度丢失导致的误差较小,这里忽略,忽略前的可以参考下面的BigDecimal运算
        System.out.println(a04/a4);
        //由于精度丢失导致的误差较小,这里忽略,忽略前的可以参考下面的BigDecimal运算
        System.out.println(a04/a2);
        //由于精度丢失导致的误差较大,这里不忽略
        System.out.println(a04-a03);

        System.out.println("===========");
        System.out.println(new BigDecimal(a04).divide(new BigDecimal(a4)));
        System.out.println(new BigDecimal(a04).divide(new BigDecimal(a2)));
        System.out.println(new BigDecimal(a04).subtract(new BigDecimal(a03)));
    }
0.1
0.2
0.10000000000000003
===========
0.1000000000000000055511151231257827021181583404541015625
0.200000000000000011102230246251565404236316680908203125
0.100000000000000033306690738754696212708950042724609375   1
0.3000000000000000166533453693773481063544750213623046875    1
0.600000000000000033306690738754696212708950042724609375   1
1.0777780000000000415472101167324581183493137359619140625  1
1.0777800000000000435473879178971401415765285491943359375  0

解释:java中直接定义的double数据变量,在输出的时候,java中会直接忽略这个变量的误差,按照程序中定义的数据来输出.但是,如果在程序中进行了浮点数运算,或者使用 了BigDecimal来表示浮点数,那么java会根据当前结果的误差大小来决定是否忽略当前误差的精度.


怎样保证浮点数计算不会损失精度? 在java中就是使用BigDecimal

对于下面的构造函数,BigDecimal不保证数据的精度不会丢失

public BigDecimal(double val)

比如:

System.out.print(new BigDecimal(0.9))        

结果是: 0.90000000000000002220446049250313080847263336181640625 基本原因在上面我们已经介绍了(具体的感兴趣的小伙伴自行研究源码哦,记得和大家分享分享),因为0.9转为二进制是无限小数,所以使用有限的位数表示一定会出现精度丢失.

所以如果希望程序准确表示自己需要的数据,那么需要使用,string参数的构造函数:

public BigDecimal(String val)

该方法内部逻辑较为复杂,但BigDecimal保证精度的解决思路其实极其的简单朴素:既然十进制整数在转化成二进制数时不会有精度问题,那么把十进制小数扩大N倍让它在整数的维度上进行计算,并保留相应的精度信息。

或者还可以使用下面的api:

    public static BigDecimal valueOf(double val) {
        return new BigDecimal(Double.toString(val));
    }

但是需要注意,这里使用的是Double.toString,这个方法只会保留double的有效位数,超过了double可以表示的位数的部分会被自动去除. double有效位数可以参考这篇文档 所以如果需要按照我们需要的精度进行精确的计算,最好使用上面的String参数的构造函数。

上面我们已经介绍了,计算机中定点数和浮点数的存储原理,以及java中浮点数计算为什么会发生精度丢失,以及解决办法,下面我总结下一些常见的场景和解决办法:

案例:

float a = 2.0f - 1.9f;
float b = 1.8f - 1.7f;
System.out.println(a);// 0.100000024
System.out.println(b);// 0.099999905
System.out.println(a == b);// false

可以看到这里对于浮点数基本数据类型直接使用==进行比较,和预期的情况有锁不同。下面提供解决方法: 首先,如果不希望使用BigDecimal,那么可以这样进行判断:

        float limitDiff = 1e-6f;
        float a = 2.0f - 1.9f;
        float b = 1.8f - 1.7f;
        boolean numIsEquals = Math.abs(a - b) < limitDiff;
        if(numIsEquals){
            System.out.println("相等,当前差值为:"+Math.abs(a - b));
        }else{
            System.out.println("不等,当前差值为:"+Math.abs(a - b));
        }

或者使用BigDecimal进行运算操作,这样可以避免精度丢失(字符串参数构造):

 @Test
    public void test9(){
        BigDecimal b20 = new BigDecimal(2.0f);
        BigDecimal b19 = new BigDecimal(1.9f);
        BigDecimal b18 = new BigDecimal(1.8f);
        BigDecimal b17 = new BigDecimal(1.7f);

        BigDecimal subtract1 = b20.subtract(b19);
        BigDecimal subtract2 = b18.subtract(b17);
        //结果为1  所以subtract1大于subtract2,因为这里用的是浮点型参数的构造方法
        System.out.println(subtract1.compareTo(subtract2));
    }

    @Test
    public void test10(){
        BigDecimal b20 = new BigDecimal("2.0");
        BigDecimal b19 = new BigDecimal("1.9");
        BigDecimal b18 = new BigDecimal("1.8");
        BigDecimal b17 = new BigDecimal("1.7");

        BigDecimal subtract1 = b20.subtract(b19);
        BigDecimal subtract2 = b18.subtract(b17);
        //结果为0  所以subtract1等于subtract2,因为这里用的是字符类型的构造函数,直接保留原始数据和原始精度,转为长整型进行计算
        System.out.println(subtract1.compareTo(subtract2));
    }

注意:compareTo方法的比较会忽略数据的精度,比如2.0和2.00使用compareTo比较的结果是相等的,但是如果使用equals方法(BigDecimal中重写的)比较则结果是不相等,因为 equals会将精度也作为比较条件。

综上:浮点数没有办法用二进制精确表示,因此存在精度丢失的风险。Java 提供了BigDecimal 来操作浮点数,保证了数据的运算精度。

关于小数位数保留:

Math.round对浮点数进行四舍五入操作,返回相应长度的整形数据。

    @Test
    public void test12121(){
        System.out.println(Math.round(8.1));//8
        System.out.println(Math.round(8.2));//8
        System.out.println(Math.round(8.4));//9
        System.out.println(Math.round(8.5));//9
        System.out.println(Math.round(8.6));//9
        System.out.println(Math.round(-8.6));//-9
        System.out.println(Math.round(-8.5));//-8
        System.out.println(Math.round(-8.4));//-8
    }

和Math.round类似的,还有 Math.floor():向负无穷方向取,说白了也就是取最近的较小的整数 Math.ceil():向正无穷方向取,说白了也就是取最近的较大的整数

而所谓的四舍五入的Math.round,其实就是将原数加上0.5然后再对加后的数据进行Math.floor() 比如:

Math.round(-8.6)==>Math.floor(-8.6+0.5)==>Math.floor(-8.1)==>-9
Math.round(-8.5)==>Math.floor(-8.5+0.5)==>Math.floor(-8.0)==>-8
Math.round(-8.4)==>Math.floor(-8.4+0.5)==>Math.floor(-7.9)==>-8
//保留两位小数
public void test12121112(){

    BigDecimal bigDecimal = new BigDecimal(1.4562244);
    BigDecimal bigDecimal1 = bigDecimal.setScale(2, RoundingMode.HALF_UP);
    System.out.println(bigDecimal1);//1.46

    BigDecimal bigDecimal2 = new BigDecimal(1.4552244);
    BigDecimal bigDecimal3 = bigDecimal2.setScale(2, RoundingMode.HALF_UP);
    System.out.println(bigDecimal3);//1.46

    BigDecimal bigDecimal4 = new BigDecimal(1.4542244);
    BigDecimal bigDecimal5 = bigDecimal4.setScale(2, RoundingMode.HALF_UP);
    System.out.println(bigDecimal5);//1.45

}