9

从内存角度理解浮点数

 3 years ago
source link: https://www.vcjmhg.top/float_implication
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

在我们日常开发中我们经常会遇到比较浮点数大小的问题,一般来说我们不能直接像整型那样比较(如 a==b),因为浮点数在设计之初存储的数据就不是准确值,而是近似值。换句话说,浮点数的实际存储的值和实际值之间是有偏差的,在这个偏差之内我们认为是相等的,在偏差之外我们认为是不等。当然,只是简单这样说很是很多人会有疑问,下边我们从浮点数的硬件存储角度(内存角度),来对其实现过程进行分析,并且最终给出浮点数比较大小的可行性方法。好,闲话少叙,我们下边步入正题。

二进制表示小数为何会造成误差?

1.二进制表示法的局限性

理解浮点数的第一步是考虑含有小数的二进制数字,首先我们从熟悉的十进制开始分析,一般来说十进制常用如下形式来对小数进行表示:

dm​dm−1​dm−2​⋯d0​.d−1​d−2⋯d−n​

(形如 9876.54321))。

其中每一个十进制数di​的取值范围为 0-9,将其用数学累加式表示出来如下:
d=∑i=nm​10ixdi​

同理我们可以通过这种方法用二进制来表示一个小数:

b=∑i=nm​2ixbi​

例如十进制数 543​用二进制形式为:101.112​
也即 1∗22+0∗21+1∗20+1∗2−1+1∗2−2

上边的例子我们可以看到十进制数 543​可以被精确的用二进制形式表示出来,但是在实际存储的过程中,好多十进制小数(例如 1/3)是无法精确转化成二进制数的,也就是说二进制表示法,只能精确表示那些可以被写成b=∑i=nm​2ixbi​ 形式的小数。

2. 数据类型存储位数的限制

由于计算机内部内存资源是有限的,因此所有数据类型在设计之初都被设定的固定的存储位数。当然浮点数也不例外,这就导致有限小数可以通过二进制表示法精确表示但是由于存储位数的限制,在其精确表示的形式超过了计算机给定的位数时,计算机就会对其进行截断处理,从而导致内部存储数据的误差。

浮点数在内存中实际表示方法

根据 IEEE 标准浮点数应该用V=(−1)s×M×2E表示,其中:

  • 符号(s):当 s=0 时 V 为整数,当 s=1 时,V 为负数。
  • 尾数(M):又称为基数,是一个二进制小数
  • E(阶码):E 的作用是对尾数加权

将浮点数的位表示划分为三个字段,分别对这些字段编码可得:

  • 符号位 s:直接编码为一位的 s。
  • 阶码(E):表示为exp=ek−1​⋯e1​e0​编码阶码 E。
  • 尾数(M):表示为frac=fn−1​⋯f1​f0​

即在内存中浮点表示方法如下:

给定位表示,根据 exp 的值,被编码的值可以分为三种不同情况(最后一种有两种变体),下图说明了对单精度格式要求的三种情况。

  1. 规范化的值:

这是最常见的情况,当 exp 表示位(即ek−1​⋯e1​e0​)不全为 0 且不全为 1 时,此时就属于规范化的情况。在这种情况中,阶码值(exp)被解释为以偏执形式表示的有符号数,即阶码的值exp−Bias,其中exp=ek−1​⋯e1​e0​,Bias=2k−1−1,此时,产生了指数的取值范围−(2k−1−2)∼2k−1−1]),即对于单精度浮点型来说其取值范围为−126∼+127] (k=8),对于双精度浮点型来说就是−1022∼1023(k=11)。

[注]引入偏执数 Bias 的原因:

只有通过减少偏执数 Bias,才可以将原来阶码表示范围 0∼2k−1移动到−(2k−1−2)∼2k−1−1,从而使得阶码既可以表示正数也可以表示负数。

小数字段 frac 被解释为表示小数f,用二进制表示为 0.fn−1​⋯f1​f0​,也就是说二进制小数点在最高位有效位的左边。尾数(M)定义为M=1+f。这种方式叫做隐含以 1 开头的表达方式(implied leading 1),从而我们可以把 M 看做是一个二进制表示为 1.fn−1​⋯f1​f0​二进制表达式。通过这种以 1 开头的表示方式我们将 M 的范围变置1⩽M<2(假设没有溢出),这种表示方法是一种额外获得额外精度位的技巧。即既然第一位总是等于 1,那么我们就没有必要显示的表示出来。

  1. 非规范化的值

当阶码域全为 0 时,所表示的数就是非规范化形式。在这种情况下,阶码值被解释为E=1−Bias,而尾数的值被解释为M=f,此时 M 范围为 0⩽M<1,即在此种情况下小数字段不隐含以 1 开头。

[注]为何阶码值被表示为 1-bias 而不是规范形式 0-bias ?为何尾数被表示为 M=f,而不是规范形式下的 1+M?

改变阶码值表示形式主要是为了提供一种从规范化值平滑过渡到非规范化值的形式。因为非规范化主要有两个用途:首先,它提供了一种表示 0 的方法,因为使用规范化数我们必须使得M⩾1,此时根据公式V=(−1)s×M×2E,无论如何我们也无法表示 0。而通过非规范化形式我们可以将符号位 s,阶码值,尾数都置为 0 来表示 0

非规范化数的另一个功能是表示那些非常接近 0.0 的数,这些接近于 0 的数有一些特性我们称之为*逐渐溢出(gradual underflow),*其中这些数分布均匀的接近 0.0。

  1. 特殊值

最后一种情况指的是阶码值全为 1,此时,当小数域全为零时,得到的值我们表示为无穷,其中 s=0 时表示为正无穷,s=1 时表示为负无穷。当两个非常大的数相乘或者某个数除以 0 时,此时出现溢出现象,我们可以通过无穷来进行表示。当小数域不为零时,其表示 NaN,即 not a number。因为一些数的运算可能既不是实数也不是无穷,此时可以通过 NaN 来进行表示。

下图展示了假定的 8 位浮点格式的示例,其中有 k=4 的阶码位和 n=3 的小数位,偏执量Bias=24−1−1=7。图中针对不同的情况都举出了例子。

img

浮点数的大小比较方法

正如上边所说的浮点数在内存中表示方法限制了浮点数的范围和精度,在内存中只能近似的表示一个实小数,即内存中存储的数与实际上要表示的数之间存在误差,因此针对浮点数的比较我们不能简单的用><⩾⩽==来进行比较。例如下边的例子

1public static void main(String[] args) {
2		//注意float存储的只是一个近似值
3		float d1=423432423f;
4		float d2=d1+0.0001f;
5		System.out.println(d1==d2);
6}

其运行结果为:

img

因此浮点数在进行比较时,应该使用 BigDecimal 类来进行比较。即

1public static void main(String[] args) {
2		final float DELTA=0.000001f;
3		//注意float存储的只是一个近似值
4		float d1=423432423f;
5		float d2=d1+0.01f;
6		BigDecimal b1=BigDecimal.valueOf(d1);
7		BigDecimal b2=BigDecimal.valueOf(d2);
8		System.out.println(b1==b2);
9}

运行结果为:

img

或者我们也可以通过定义一个精度来进行比较,如果两个数的差值绝对值小于定义的精度我们便可以认为他们是相等的,否则我们认为这两个数是不相等的。

1public static void main(String[] args) {
2		final double DELTA=0.000001;
3		//注意float存储的只是一个近似值
4		double d1=423432423;
5		double d2=d1+0.01f;
6		System.out.println(Math.abs(d1-d2)<DELTA);
7}

运行结果为:

img


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK