浮点数溢出问题解析

浮点数溢出

操作浮点数,经常会出现计算的结果并非数据意义上的结果: golang

go 复制代码
a := 8.0
b := 5.4
fmt.Print(a - b) // 2.5999999999999996

php

php 复制代码
$a = 6;
$b = 5.99;
echo $a - $b; // 0.0099999999999998

js

js 复制代码
let a = 8;
let b = 5.4;
a - b; //2.5999999999999996

这种情况被称为浮点数溢出浮点数的精度问题 ,那么这个溢出是怎么产生的呢?其中的根本原因是什么呢?这个需要从计算机如何存储、表示和计算浮点数的规则来探讨。 因为目前大部分语言都是使用IEEE 754浮点数国际标准定义,所以本文就基于这个规则来探讨。

浮点数

先了解下什么是浮点数,在计算机学中,浮点 是一种用来描述实数的近似值的表现法,使用这种表现法表示的数值,则称为浮点数。换句话说,浮点数就是计算机中用来表示实数的一种方式。

IEEE 754

IEEE 754规定了浮点数的运算标准。该标准的全称为IEEE二进制浮点数算术标准(ANSI/IEEE Std 754-1985) ,又称IEC 60559:1989,微处理器系统的二进制浮点数算术,是使用最广泛的浮点数运算准则。它定义了一套浮点数的标准,定义了浮点数的格式,规定了浮点数的表示方式(精度),指明了四种舍入规则和例外状况。

十进制to二进制

下面演示下怎么把28.35转换成二进制: 先把整数和小数拆开,整数部分是:28,小数部分是0.35 整数部分 整数转换成二进制就是将值除以2,得到余数和商,因为是除以2,所以余数只能是0或1,得到的商继续除以2,以此类推除到商为1为止,之后将余数倒序排列得到对应的二进制数

shell 复制代码
28/2    0     2^0
14/2    0     2^1
7/2     1     2^2
3/2     1     2^3
1       1     2^4

所以28的二进制是:0001 1100 小数部分 小数转成二进制,是将值乘2,取整数部分,因为是小数,所以整数位只会是1或0,剩下的小数部分继续乘以2,取整数部分,以此类推,直到小数部分为0或者得到一个无限循环的组合:

shell 复制代码
0.35 * 2    0.7     0
0.7 * 2     1.4     1
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.4 * 2     0.8     0
0.8 * 2     1.6     1
0.6 * 2     1.2     1
0.2 * 2     0.4     0   
0.4 * 2     0.8     0
0.8 * 2     1.6     1
0.6 * 2     1.2     1  // 0011会无限循环下去

所以 0.35的二进制是:0.0 1011 0011 0011 0011......

28.35 = 1 1100.0 1011 0011 0011 0011......

浮点数的存储

根据IEEE 754的标准,浮点数在内存中分为三段:

  • 符号位,使用s表示,0表示正,1表示负
  • 指数,使用e表示
  • 分数,使用f表示

单精度(32位)

  • 符号位 1位
  • 指数 8位
  • 分数 23位

双精度(64位)

  • 符号位 1位
  • 指数 11位
  • 分数 52位

二进制如何换算成浮点数

按照IEEE 754的标准,浮点数的实际值等于 符号位 乘以指数偏移值 再乘以分数值 ,可以这么表示: <math xmlns="http://www.w3.org/1998/Math/MathML"> v a l u e = s i g n ∗ e x p o n e n t ∗ f r a c t i o n value = sign * exponent * fraction </math>value=sign∗exponent∗fraction

将这个等式换成公式:

<math xmlns="http://www.w3.org/1998/Math/MathML"> v = ( − 1 ) s ∗ ( 1 + f ) ∗ 2 e v = (-1)^s * (1 + f) * 2^e </math>v=(−1)s∗(1+f)∗2e

为什么公式长这样子呢?我们继续了解。

科学计数法

上面的公式是使用了科学计数法来表示:

css 复制代码
科学记数法是一种记数的方法。把一个数表示成a与10的n次幂相乘的形式(1<=a<10,n为整数),这种记数法叫做**科学记数法**。

例如:19971400000000=1.99714×10^13。一般计算器或电脑会用E或e来表示10的幂,所以上面的等式也可以写成 <math xmlns="http://www.w3.org/1998/Math/MathML"> 1.99714 E 13 = 19971400000000 1.99714E13=19971400000000 </math>1.99714E13=19971400000000

在这个科学计数法的表示中:1.99714被称为尾数 ,13被称为 ,10则被称为基数

在二进制中也是一样的:110010011=1.10010011 * 2^8 科学计数法的三部分分别为:

  • 尾数: 1.10010011
  • 幂: 8
  • 基数: 2 (其实基数就是进制的基数)

也就是基于不同的进制,科学计数法可以这么描述: 把一个数表示成a与进制的基数的n次幂相乘的形式,并满足以下条件:

  • n为整数
  • 1 <= a <进制基数
  • a为实数

也就是说,在二进制中,有效数可以是正数或者是负数,但是它必选大于等于1并且小于2,所以二进制的科学计数法中有效数字的整数部分一定是1,所以在浮点数存储中,分数f在计算的时候会默认+1,这个1不需要存储。

在这种情况下在IEEE 754中为了跟科学计数法区分,会将f称为有效数 ( <math xmlns="http://www.w3.org/1998/Math/MathML"> f = a − 1 f = a - 1 </math>f=a−1);

你也许会问,为什么整数部分一定是1呢?如果是一个整数为0的数字呢?例如:0.24 整数部分为什么是1? 首先0.24的二进制是 0.00111101011100001010 向前移三位就变成:1.11101011100001010 * 10^-3 所以整数部分依然是1,指数为-3

单精度浮点数的换算

下面我们以单精度浮点数为例来看下浮点数的实际值是怎么计算的。 首先请出我们的公式: <math xmlns="http://www.w3.org/1998/Math/MathML"> v = ( − 1 ) s ∗ ( 1 + f ) ∗ 2 e v = (-1)^s * (1 + f) * 2^e </math>v=(−1)s∗(1+f)∗2e

我们已经知道单精度浮点数据的存储方式:

符号位S: <math xmlns="http://www.w3.org/1998/Math/MathML"> ( − 1 ) s (-1)^s </math>(−1)s 这个很好理解,s为0表示正数,1表示负数,正好对应 <math xmlns="http://www.w3.org/1998/Math/MathML"> − 1 0 = 1 -1^0=1 </math>−10=1, <math xmlns="http://www.w3.org/1998/Math/MathML"> − 1 1 = − 1 -1^1 = -1 </math>−11=−1

指数偏移值e: 为什么叫指数偏移量呢?在浮点数表示法里面,它表示指数域的编码值,等于指数的实际值加上某个固定的值。

在32位的浮点数中,指数域为8个bit,取值范围为 <math xmlns="http://www.w3.org/1998/Math/MathML"> [ 0 , 255 ] [0, 255] </math>[0,255],但是我们上面也讲到了指数是可能出现负,所以指数的取值范围应该是 <math xmlns="http://www.w3.org/1998/Math/MathML"> [ − 127 , 128 ] [-127, 128] </math>[−127,128],那么问题来了,指数域其实是没有符号位的,也就是没办法直接表示负,所以IEEE 754在计算的时候会对指数做处理,就是"加上某个固定值",在单精度里面是:-127(也就是取值范围的最小值) 所以上面的公式可以变形为: <math xmlns="http://www.w3.org/1998/Math/MathML"> v = ( − 1 ) s ∗ ( 1 + f ) ∗ 2 ( e − 127 ) v = (-1)^s * (1 + f) * 2^{(e - 127)} </math>v=(−1)s∗(1+f)∗2(e−127)

分数f 这个也比较好理解,就是存在0-22位的一个23bit的二进制值。但是为什么要称之为"分数",叫其他名字不行吗(有效数就挺不错)。这个分数其实表示的是一个偏移量。分数部分的取值区间为 <math xmlns="http://www.w3.org/1998/Math/MathML"> [ 0 , 2 23 ] [0,2^{23}] </math>[0,223],对于任何一个在这个区间的值f都会有一个M,使得 <math xmlns="http://www.w3.org/1998/Math/MathML"> f = M 2 23 f=\frac M {2^{23}} </math>f=223M成立,也就是说f表示的是 <math xmlns="http://www.w3.org/1998/Math/MathML"> M 2 23 \frac M {2^{23}} </math>223M这个分数。

那么M又是什么? 对于任何一个数x,都可以找到一个数n,使得 <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 n < = x < = 2 n + 1 2^n<=x<=2^{n+1} </math>2n<=x<=2n+1成立,我们将 <math xmlns="http://www.w3.org/1998/Math/MathML"> [ 2 n , 2 n + 1 ] [2^n, 2^{n+1}] </math>[2n,2n+1]划分成 <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 23 2^{23} </math>223(8388608)等份,M则表示从 <math xmlns="http://www.w3.org/1998/Math/MathML"> [ 2 n , x ] [2^n, x] </math>[2n,x]需要占用多少等份,n则是这个偏移量的指数,即 <math xmlns="http://www.w3.org/1998/Math/MathML"> n = e − 127 n = e - 127 </math>n=e−127

上面的公式可以变形为:

<math xmlns="http://www.w3.org/1998/Math/MathML"> v = ( − 1 ) s ∗ ( 1 + M 2 23 ) ∗ 2 ( e − 127 ) v = (-1)^s * (1 + \frac M {2^{23}}) * 2^{(e - 127)} </math>v=(−1)s∗(1+223M)∗2(e−127)

举个栗子 我们来看看2.25怎么用上面的公式来表示:

  • 正数,s为0
  • <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 1 < = 2.25 < = 2 2 2^1 <= 2.25 <= 2^2 </math>21<=2.25<=22,n为1,那么e就是 <math xmlns="http://www.w3.org/1998/Math/MathML"> 1 + 127 = 128 1 + 127 = 128 </math>1+127=128
  • <math xmlns="http://www.w3.org/1998/Math/MathML"> 2.25 − 2 4 − 2 = 0.125 \frac {2.25-2} {4-2} = 0.125 </math>4−22.25−2=0.125 f为 0.125, M为 <math xmlns="http://www.w3.org/1998/Math/MathML"> 0.125 ∗ 2 23 = 1048576 0.125 * 2^{23}=1048576 </math>0.125∗223=1048576

将这是三个部分转为二进制,2.25的二进制表示如下:

shell 复制代码
0  10000000 0010000000000000000000

双精度浮点数的计算方式也是一样的:

<math xmlns="http://www.w3.org/1998/Math/MathML"> v = ( − 1 ) s ∗ ( 1 + f ) ∗ 2 ( e − 1023 ) v = (-1)^s * (1 + f) * 2^{(e - 1023)} </math>v=(−1)s∗(1+f)∗2(e−1023)

精度问题的产生

看看我们一开始的示例:

php 复制代码
$a = 6;
$b = 5.99;
echo $a - $b; // 0.0099999999999998

先将a和b转成二进制浮点数 6的二进制是0110,换成浮点数的三段表示为:

  • s:0
  • e:129
  • f:10000000

最终可表示为:

shell 复制代码
0 10000001 10000000000000000000000

5.99的二进制是101.1111110101110000101000111101011100001010001111011,换成浮点数的三段表示为:

  • s:1
  • e:129
  • f:0111111101...

最终可表示为:

shell 复制代码
0 10000001 01111111010111000010100

这时候精度问题就已经出现了,我们把5.99分解一下:

<math xmlns="http://www.w3.org/1998/Math/MathML"> 2 2 < = 5.99 < = 2 3 2^2 <=5.99<=2^3 </math>22<=5.99<=23,n为2,所以e为129。

<math xmlns="http://www.w3.org/1998/Math/MathML"> M = 5.99 − 4 8 − 4 ∗ 2 23 = 4173332.48 M=\frac {5.99-4} {8-4} * 2^{23} = 4173332.48 </math>M=8−45.99−4∗223=4173332.48

M值必须为整数,四舍五入得到M为4173332,把结果替换进公式得到:

<math xmlns="http://www.w3.org/1998/Math/MathML"> ( − 1 ) 0 ∗ ( 1 + 4173332 2 23 ) ∗ 2 ( 129 − 127 ) = − 1 ∗ ( 1 + 0.497499942779541 ) ∗ 4 = − 5.989999771118164 (-1)^0 * (1+ {\frac {4173332} {2^{23}}}) * 2^{(129 - 127)} = -1 * (1 + 0.497499942779541) * 4 = -5.989999771118164 </math>(−1)0∗(1+2234173332)∗2(129−127)=−1∗(1+0.497499942779541)∗4=−5.989999771118164

因为有了四舍五入所以出现了精度问题。

当然,我们也可以从另一个角度来理解这个问题,f为0.4795,转成二进制得到存入内存中的值为:

shell 复制代码
0111101011000000100000110001001001101110100101111001

存储空间是有限的,所以只能存入前面的23位,后面的则会被截取,截取的这一部分会导致精度问题,因为没办法准确的还原为-5.99

浮点数的运算

浮点数二进制表示如下:

6: [0] [1000 0001] [1000 0000 0000 0000 0000 000]

5.99: [0] [1000 0001] [0111 1111 0101 1100 0010 100]

相减的时候幂是不会变的,但是有效数的整数位现在变成0了(因为相减互相抵消了),这时候计算出来的结果为: [0] [1000 0001] [0000 0000 1010 0011 1101 000] 这时候,这个浮点数被称为"非正规浮点数"(为了降低复杂度,IEEE 754规定有效数的整数位必须为1,不符合条件的为"非正规",出现不正规的情况会调整指数来完成正规化)于是结果会变成: 向前移动9位,指数位 <math xmlns="http://www.w3.org/1998/Math/MathML"> − 9 + 2 = − 7 -9+2=-7 </math>−9+2=−7 所以 <math xmlns="http://www.w3.org/1998/Math/MathML"> e = 120 − 127 e = 120 - 127 </math>e=120−127 [0] [0111 100] [010 0011 1101 0110 0000 0000] 把结果转成10进制:

<math xmlns="http://www.w3.org/1998/Math/MathML"> ( − 1 ) 0 ∗ ( 1 + 2348544 2 23 ) ∗ 2 2 = (-1)^0 * (1 + {\frac {2348544} {2^{23}}}) * 2^2 = </math>(−1)0∗(1+2232348544)∗22= <math xmlns="http://www.w3.org/1998/Math/MathML"> 1.27996826171875 ∗ 2 − 7 = 1.27996826171875 * 2^{-7} = </math>1.27996826171875∗2−7= <math xmlns="http://www.w3.org/1998/Math/MathML"> 0.009999752044677734 0.009999752044677734 </math>0.009999752044677734

这样精度问题就出现了。

浮点数的数值区间

上面我们已经知道了,32位和64位的浮点数的指数位8位和11位,所以很多人都认为它们的取数区间为: <math xmlns="http://www.w3.org/1998/Math/MathML"> [ 2 − 127 , 2 128 ] [2^{-127}, 2^{128}] </math>[2−127,2128]和 <math xmlns="http://www.w3.org/1998/Math/MathML"> [ 2 − 1023 , 2 1024 ] [2^{-1023}, 2^{1024}] </math>[2−1023,21024],但是其实并不然。浮点数是存在特殊值的。

特殊值

  • 如果指数是0并且尾数的小数部分是0,这个数±0(和符号位相关)
  • 如果指数 = <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 e − 1 {2^{e}-1} </math>2e−1,并且尾数的小数部分是0,这个数是±∞(同样和符号位相关)
  • 如果指数 = <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 e − 1 {2^{e}-1} </math>2e−1,并且尾数的小数部分非0,这个数表示为非数(NaN)。

也就是说当e全部为0或者全部为1的时候为特殊情况,所以真实的取数区间必须去掉0和255,也就是-127和128,所以浮点数的取数范围为: <math xmlns="http://www.w3.org/1998/Math/MathML"> [ 2 − 126 , 2 127 ] [2^{-126}, 2^{127}] </math>[2−126,2127]和 <math xmlns="http://www.w3.org/1998/Math/MathML"> [ 2 − 1022 , 2 1023 ] [2^{-1022}, 2^{1023}] </math>[2−1022,21023],所以会出现极限值不存在的情况。

避免精度问题

精度问题看起来是无可避免的,那么我们要怎么处理呢?如果业务场景对精度要求不高,那就简单的四舍五入就能满足(其实就是忽略精度问题)。如果有精度要求,比较常用的办法就是避免小数运算,将小数转为整数来运算,例如对数字很敏感的金额计算,一般会将金额的单位转成分或者厘,以前做金融产品的时候金额都是以分为单位,最后前端展示的时候再除100。另外就是可以借助一些第三方库来处理,这些第三方库会使用其他进制来进行运算,尽可能的避免精度问题。

总结

浮点数的精度问题(溢出问题更好理解),其实就是出现在存储上面,实际值在转成二进制之后,因为存储空间的限制,无法将二进制数完整的存入(有些值会出现无限循环的情况),导致了偏差,进而出现精度问题。在浮点数运算过程中只要计算数或者结果出现无法完整存储的情况就会出现精度问题。

参考资料: en.wikipedia.org/wiki/IEEE_7... baike.baidu.com/item/IEEE%2...

相关推荐
IceTeapoy1 天前
【基础概念】蒙特卡洛算法
数学·算法
八一考研数学竞赛2 天前
第十七届全国大学生数学竞赛初赛模拟试题
学习·数学·latex·全国大学生数学竞赛
窗户9 天前
有限Abel群的结构(2)
python·数学·抽象代数
MPCTHU11 天前
机器学习的数学基础:线性模型
数学·机器学习
夏旭泽13 天前
计算机组成原理-总线
计算机组成原理
Thanks_ks14 天前
计算机组成原理核心剖析:CPU、存储、I/O 与总线系统全解
计算机组成原理·计算机技术·存储系统·cpu 结构·i/o 设备·总线系统·硬件原理
闻缺陷则喜何志丹14 天前
【分治法 容斥原理 矩阵快速幂】P6692 出生点|普及+
c++·线性代数·数学·洛谷·容斥原理·分治法·矩阵快速幂
takagi桑咩15 天前
插值法求解非线性方程
数学
MPCTHU15 天前
机器学习的数学基础:决策树
数学·机器学习
MPCTHU15 天前
机器学习的数学基础:假设检验
数学·机器学习