走进浮点数:为什么1-0.9不等于0.1?

走进浮点数:为什么1-0.9不等于0.1?

1. 在计算机里,单精度的1-0.9等于多少?

按照正常计算的情况,1-0.9的结果肯定是0.1,答案毋庸置疑。但是在计算机里,很多小数并不能被精确表示,那么在单精度的情况下,1-0.9等于多少?结果是0.100000024

这里用Java执行一下命令。

public static void main(String[] args) {
   
 
    System.out.println(1.0f-0.9f);

}

//结果为0.100000024

“猜测”一下,单精度的1-0.9等于0.9-0.8吗?

public static void main(String[] args) {
   
    
    System.out.println((1.0f-0.9f)==(0.9f-0.8f));
    
}

//结果为false

输出0.9-0.8

public static void main(String[] args) {
   
    
    System.out.println(0.9f-0.8f);
    
}

//结果为0.099999964

可以看到,计算结果与预期存在明显的误差,那么这个误差是如何产生的呢?

2. 0与1

在正式介绍浮点数之前,先复习一下二进制,因为浮点数在计算机的存储和计算也是通过二进制的方式进行的。

简单来说,计算机就是一种电子设备,在此基础上的不管什么技术,本质上就是0与1的信号处理。信息存储与逻辑计算的元数据,只能是0和1 。它的进位规则是“逢二进一”,借位规则是“借一当二”。比如十进制的1在二进制中也是1,而十进制的2在二进制中就是10了,同理4==>100, 24==>11000,……

以十进制24为例,将其转化成二进制的方式,令24不断除以2,(第一次商12,余数为0),(第二次商6,余数为0),(第三次商3,余数为0),(第四次商1,余数为1),(第五次商0,余数为1),然后将余数反向排列就是24对应的二进制了,也就是11000

image-20201121180753916

二进制转化为十进制的方式,以11000为例,就是 2 4 + 2 3 2^4+2^3 24+23,结果为24

计算机中使用的存储计量单位,最基本的就是,即bit,简写为b。8个bit组成1个字节,即1个Byte,简写为B。1024个Byte,成为1KB;1024个KB记为1MB;1024个MB记作1GB,……

我们以一个字节为基本单位介绍二进制的计算,十进制对应的二进制表示为0000 0001。最左侧的值表示正负,0表示正,1表示负,那么-1可以表示为1000 0001,这就是二进制的原码

image-20201121183834707

二进制的计算涉及到三种编码方式:原码、反码和补码。

  • 原码:正数是数值本身,符号为0;负数是数值本身,符号位是1 。8位二进制数的表示范围是[-127,127]
  • 反码:正数是数值本身,符号为0;负数的数值是在正数表示的基础上按位取反,符号位是1 。8位二进制数的表示范围是[-127,127]
  • 补码:正数是数值本身,符号为0;负数的数值是在反码的基础上加1,符号位是1 。8位二进制数的表示范围是[-128,127](注意,补码所表示的范围比原码和反码大一点,稍后做解释)

示例:

十进制数值 原码 反码 补码
1 0000 0001 0000 0001 0000 0001
-1 1000 0001 1111 1110 1111 1111
2 0000 0010 0000 0010 0000 0010
-2 1000 0010 1111 1101 1111 1110

那么问题来了,既然原码更符合我们的认知,要反码和补码干什么用?

因为计算机的运算方式和人类的思维模式是不相同的,我们人可以轻易的分辨出一个数值是正数还是负数,而计算机如果将符号位与数值位分开计算,就需要作额外的判断,在一个比较复杂的程序中,这样的计算堆叠起来就是巨大的计算开销,显然是不合理的。

为了加速计算机的运算速度,需要将符号位一起参与计算,而如果使用原码计算,在一些情况下容易出现问题,以减法为例,减去一个值就是加上这个值的相反数,1-2=1+(-2)=-1,按照原码计算, [ 00000001 ] 原 + [ 10000010 ] 原 = [ 10000011 ] 原 = − 3 [0000 0001]_原+[1000 0010]_原=[1000 0011]_原=-3 [00000001]+[10000010]=[10000011]=3,这是不正确的,而如果使用反码进行计算, [ 00000001 ] 反 + [ 11111101 ] 反 = [ 11111110 ] 反 = − 1 [0000 0001]_反+[1111 1101]_反=[1111 1110]_反=-1 [00000001]+[11111101]=[11111110]=1,计算正确。

关于补码,再举一个例子,2+(-2)= [ 00000010 ] 反 + [ 11111101 ] 反 = [ 11111111 ] 反 = − 0 [0000 0010]_反+[1111 1101]_反=[1111 1111]_反=-0 [00000010]+[11111101]=[11111111]=0,按照正确的认知,0就是0,没有正负之分,这样计算显然存在问题。

随着编码的发展,补码应运而生了,同样的计算,2+(-2)= [ 00000010 ] 补 + [ 11111110 ] 补 = [ 00000000 ] 补 = 0 [0000 0010]_补+[1111 1110]_补=[0000 0000]_补=0 [00000010]+[11111110]=[00000000]=0,补码,解决了+0和-0的问题。

另外,补码的诞生,增大了二进制编码所能表示的范围,在8位二进制编码中,补码可以表示到-128,其对应的补码为 [ 10000000 ] 补 [1000 0000]_补 [10000000](补充:补码的补码就等于原码)

3. 浮点数

浮点数不同于整数,不可以像上面介绍的那样进行存储与计算,而是分成了符号、指数和有效数字分别表示。当前业界流行的浮点数标准是IEEE754,该标准规定了4种浮点数类型:单精度、双精度、延伸单精度、延伸双精度,前两种是最常用的,而单精度与双精度的区别只是位数不同而已,下面一起复习单精度浮点数

单精度被分配了4个字节,占32位,具体格式如下图所示:

image-20201122001036786

通常将内存地址低端的位写在最右边,称作“最低有效位”,代表最小的比特,改变时对整体影响最小。单精度浮点数的表示格式如上图所示,符号代表了数值的正负;浮点数的表示依赖于科学记数法,指数位就表示了规格化之后数值的指数,这一块占了8位,在二进制中称作“阶码位”;最后的有效数字在二进制中称作为“尾数”。

  1. 符号位

在二进制最高位分配了1位表示浮点数的符号,0表示正数,1表示负数

  1. 阶码位

在符号位右侧分配了8位用来存储指数,IEEE754标准规定了阶码位存储的是指数对应的移码,而不是原码或者补码。移码的几何意义是把真值映射到了一个正数域,在比较真值大小时,只要将高位对齐后逐个比较即可。

定义真值为 e e e,阶码为 E E E,IEEE754标准规定的偏移量为 2 n − 1 − 1 2^{n-1}-1 2n11 n n n是阶码的位数,这里 n = 8 n=8 n=8。那么有 E = e + ( 2 n − 1 − 1 ) E=e+(2^{n-1}-1) E=e+(2n11)。关于偏移量,前面介绍到,8位二进制能表示到的范围是[-128,127],将其平移到正数域,每个值需要加上128,得到的范围是[0,255],而计算机规定阶码全为0或者全为1的两个值会被当做特殊值进行处理,全为0时认为是机器零(小到精度达不到的值认为是0,与值0不同,值0表示一个点,机器零表示一个区域),全1则认为是无穷大。去掉两个特殊值,范围变成了[1, 254],而偏移量取 2 n − 1 − 1 2^{n-1}-1 2n11的话,指数的范围就是[-126, 127]。

  1. 尾数位

最右侧的23位用来存储有效数字,根据科学记数法,由有效数字和指数组成数值代表了最终浮点数的大小。在科学记数法中要求小数点前的数值范围是[1,10),在二进制中,这个范围就是[1,2),而为了节省存储空间,将规格化之后形成的类似1.xyz的首位1省略,因此23位的区域表示了24位的二进制数值,也因此这一区域被称为尾数。

三个区域有着各自的职责,我们可以将其简化,如下图所示。

image-20201122005439751

数值的计算公式如下:
X = ( − 1 ) S × ( 1. M ) × 2 E − 127 X=(-1)^S\times (1.M)\times 2^{E-127} X=(1)S×(1.M)×2E127
以数值16为例,8位二进制原码表示为0001 0000,而浮点数表示为0100-0001-1000-0000-0000-0000-0000-0000,我们计算一下,最高位0,表示正数;100-0001-1转化为十进制为131,131-127=4, 2 4 = 16 2^4=16 24=16;尾数为全为0,即 1 × 2 4 = 16 1\times2^4=16 1×24=16

数值1,对应浮点数表示为0011-1111-1000-0000-0000-0000-0000-0000,最高为0,表示正数;011-1111-1转化为十进制为127, 2 127 − 127 = 1 2^{127-127}=1 2127127=1;尾数全为0,即 1 × 1 = 1 1\times1=1 1×1=1

上面两个数值使用浮点数正好可以精确表示,但是对于大多数的值来说,有限位的值无法精确表示。

比如0.9,阶码位可以给出 2 − 1 2^{-1} 21(即0.5), 2 0 2^0 20 2 − 2 2^{-2} 22无法通过乘以1.x得到0.9;对于尾数位,只要精确表示0.8,那么整体就可以精确表示0.9,但是有限的二进制位没有办法精确到0.8。0.9对应的浮点数二进制为0011-1111-0110-0110-0110-0110-0110-0110,没有精确表示0.9,那么回到开头的问题,在计算机中,1-0.9也就并不精确等于0.1,具体结果在后面进行计算。(补充,二进制小数转化为十进制,小数点后一位表示 2 − 1 2^{-1} 21,依次累加,如 1.00000101 = 1 + 2 − 6 + 2 − 8 1.00000101=1+2^{-6}+2^{-8} 1.00000101=1+26+28

思考一下,能够由这样的方式精确表示的两个相邻的值相差多少?指数的范围是[-126, 127],指数最小为 2 − 126 2^{-126} 2126;两个相邻的值尾数位相差 2 − 23 2^{-23} 223(尾数位的最后一个值加1,在十进制中就是加 2 − 23 2^{-23} 223),那么两个相邻的值相差 2 − 126 × 2 − 23 = 2 − 149 2^{-126}\times2^{-23}=2^{-149} 2126×223=2149。那么单精度可以表示的最大值是多少?指数取最大,也就是 2 127 2^{127} 2127,约等于 1.7 × 1 0 38 1.7\times10^{38} 1.7×1038;尾数位取最大,即各个位都为1,所表示的1.11··11近似认为是一个无限接近于2的值,因此可表示的最大值为 2 × 1.7 × 1 0 38 = 3.4 × 1 0 38 2\times1.7\times10^{38}=3.4\times10^{38} 2×1.7×1038=3.4×1038 。最小正数值呢?同理,可以取到的最小正数值为 1.0 × 2 − 126 1.0\times2^{-126} 1.0×2126,这里有个概念,称为渐进式下溢出,也就是两个值相差应该是均匀的;而现在最小值与0的差值 1.0 × 2 − 126 1.0\times2^{-126} 1.0×2126相比最小值与次小值的差值 2 − 149 2^{-149} 2149相差了 2 23 2^{23} 223倍这样的数量级,可以说是非常突然的下溢出到0,这种情况被称为突然式下溢出 。IEEE754标准规定采用渐进式下溢出,也就是与0的差值也为 2 − 149 2^{-149} 2149,约等于 1.4 × 1 0 − 45 1.4\times10^{-45} 1.4×1045

4. 加减运算

对两个采用科学记数法表示的值进行加减运算,首先需要进行操作确保指数一样,再将有效值按照正常的数进行加减运算。

  1. 零值检测

规定阶码和尾数全为0表示的值就是0,如果两个值其中一个为0可以直接得出结果。

  1. 对阶

两个值的阶码相同时,表示小数点对齐了。如果不相同,则需要移动尾数来改变阶码,尾数向右移动一位,则阶码值加1,反之减1。思考一下,对比左移和右移,都有可能将部分二进制位挤出,导致误差,但左移一位误差在 2 − 1 2^{-1} 21级,右移一位误差在 2 − 23 2^{-23} 223级,明显右移带来的误差更小,因此标准规定对阶操作只能右移。

  1. 尾数求和

当对阶完成后,尾数按位相加即可完成求和(负数需要转化为补码进行运算)

  1. 规格化

将结果转化为前面介绍的规范的形式的过程就称作规格化。尾数位向右移动称为右归,反之称为左归

  1. 结果舍入

对接操作和规格化操作都有可能造成精度损失,为了减少这样的损失,先将移出的这部分数据保存起来,称为保护位,在规范化后再根据保护位进行舍入处理。

了解了二进制浮点数的加减运算,再回到开头的问题,单精度的1-0.9=?

  • 1.0的二进制为0011-1111-1000-0000-0000-0000-0000-0000
  • -0.9的二进制为1011-1111-0110-0110-0110-0110-0110-0110

为方便计算,将三个区域的数值分割开来

浮点数 符号 阶码 尾数(加入隐藏值1) 尾数补码(加入隐藏值)
1.0 0 127 1000-0000-0000-0000-0000-0000 1000-0000-0000-0000-0000-0000
-0.9 1 126 1110-0110-0110-0110-0110-0110 0001-1001-1001-1001-1001-1001
  1. 对阶

1.0阶码为127,-0.9阶码为126,标准规定只能右移,因此-0.9的阶码变为127,尾数补码右移后在最高位补1,变为1000-1100-1100-1100-1100-1100,舍掉了最后一个1,实际上作结果舍人时可以把这个1补回来,为方便计算,直接补回这个1,也就变成了1000-1100-1100-1100-1100-1101

  1. 尾数求和

image-20201122113629603

  1. 规格化

上一步计算出的值并不符合要求,尾数的最高为必须为1,因此需要将尾数位左移4位,对应的阶码减去4 。规格化后的符号为0,阶码为123(对应的二进制为1111011),尾数去掉隐藏值为100-1100-1100-1100-1101 。三部分组合起来就是1-0.9的结果,转化为十进制就为0.100000024

5. 绝对精度

如果要求绝对精度呢?比如在金融领域,一点点精度的缺失可能带来巨额的财产损失,这个时候推荐使用Decimal类型进行表示。

public static void main(String[] args) {
   

    BigDecimal a = new BigDecimal("1.0");
    BigDecimal b = new BigDecimal("0.9");

    BigDecimal c = a.subtract(b);
    System.out.println(c);

}

//结果为0.1

6. 参考

  • Java开发手册
  • 百度百科
「点点赞赏,手留余香」

    还没有人赞赏,快来当第一个赞赏的人吧!
0 条回复 A 作者 M 管理员
    所有的伟大,都源于一个勇敢的开始!
欢迎您,新朋友,感谢参与互动!欢迎您 {{author}},您在本站有{{commentsCount}}条评论