前言
之前有一位学长在上班的时候出现了这样一个Bug:死活算不出正确数据 于是拿测试单元试了一下,发现关键问题在于… 小数!
先来看看这段代码
下面的代码是我用Python复现的测试逻辑:
a = 0.6b = 0.7print (a+b == 1.3)
# False是的,你没看错,0.6+0.7 不等于 1.3 也就是说,问题肯定出现在小数上面。 那么我们要把代码的Bug找出来,就必须要了解小数在计算机当中,是怎么表示的。
小数是怎么表示的
各位试想一下,如果你想要设计一个小数的存储格式,你会怎么想? 我们可以先回想一下在十进制下,我们是如何表示一个小数的: (−1)c × n × 10y 例如0.1,我们就可以表示成: (−1)0 × 1 × 10−1 那么现在我们就可以了解到,我们只需要存储三个数值就可以表示一个小数了。也就是c、n和y。 但是我们的电脑使用的是二进制,为了方便电脑处理数据,我们得将最后指数10y中的10换成2。也就是下面的公式: (−1)c × n × 2y 例如0.5,我们就可以写成(−1)0 × 1 × 2−1 因此我们现在就可以设计一个自己的小数储存方式。我们拿一块大小为32位的地方,其中c占一位,n占10位,y占21位。 那么我们可以算出c为0或1;n这里我们规定为一个无符号数,那么他的范围就是0到210;y这里我们定义为一个有符号数,范围即为-220 ~ 2^20-1。 但是我们也不难发现,我们可以表示出一个很小的数,但是我们不能表示出一个高精度的值。为了方便理解,我们先用十进制来代替:我们现在需要表示0.11…11(这里总共有19个1),我们想要表达这个式子,用刚才的公式就是: (−1)0 × 1..1 × 10−20 这里我们不难发现,如果我们去表示这个式子,我们是表示不出来的。因为我们的n只能到10位,多了就不行了。但是我们又想去表示这个式子,怎么办? 对了,科学计数法! 我们完全可以把小数用小数表示: (−1)c × n.m × 10y 例如1.5就可以表示成(−1)0 × 1.5 × 100。但是这时中间的n.n,我们就可以采用我们刚才用来表示小数的式子:n × 10y 来进行了。 那么,我们现在就可以这么设计了。我们将刚才的式子转为二进制:(−1)c × n.m × 2y。 我们还能优化吗?可以的。 如果我们的y为0时,此时值是直接表示成n.m的,但是这里的n.m我们也可以表示为0.nm*2^1这个形式,这时我们完全可以把前面的n直接融入到m当中,这样就可以将精度再往后一位。 那么n.m这个小数我们不妨令他在1到2之间。既然他在1到2之间,那么我们不妨直接转为1+0.m,这样我们就可以少存一个n了。 现在,我们来看看那些科学家是如何规定的:
Float 是如何表示的
在 IEEE 754 标准下,Float的公式是这样的:
(−1)sign × (1 + fraction) × 2(exponent − 127)
这里的sign为之前我们提到的表示正负号的东西,fraction 这里是之前提到的 m,剩下的 (exponent − 127) 就相当于是 y。
而在具体的储存上,我们将32位分为三个区域来分别储存这三个值:
借用一下Wiki的图
其中:
- sign (正负号) 占 1 位
- exponent (指数) 占 8 位
- fraction (尾数)占 23 位
但是这里就有一个疑问了,为什么要减去127?
对于一个有符号数的运算而言,我们还得用额外的逻辑来处理符号位。例如当我们进行两个小数的比较时,我们可以先对正负号进行比较,然后再比较他们的指数,此时如果是有符号数的话,还得去处理符号位的问题,而如果是无符号数,我们就可以直接去比较,不需要进行其他的处理即可了解他们的大小。
我们现在可以通过Float的定义来得到这几个特殊值:
| 形式 | 指数 | 小数部分 |
|---|---|---|
| 零 | 0 | 0 |
| 非规范形式 | 0 | 大于0小于1 |
| 规范形式 | 1到2^e−2 | 大于等于1小于2 |
| 无穷 | 2^e−1 | 0 |
| NaN | 2^e−1 | 非0 |
NOTE
这里的NaN即为Not a Number(不是一个数)
当然,如果你还想要更高的精度,还可以把空间放大到64位。此时这个类型就叫Double(双精度)。Double具体的分配如下:
sign(正负号) 占 1 bitexponent(指数) 占 11 bitfraction(尾数)占 52 bit 但是,正如十进制无法精准的表达1/3这个值,只能无限的趋近于一样。对于2进制而言,也永远无法表达0.1这种值。所以这也就是为什么前面的代码会出现问题——因为人类用的和计算机的不是一个进制。 那么如果要设计一个可以规避这个问题的结构,你会怎么设计呢?
让计算机迁就于我们
之前我们想着是让计算机算的快一些,所以才使用二进制来表示。但是我们现在的应用场景是——要求能在人们日常使用的时候不会遇到这个问题,而对于算法的性能要求并没那么高。既然前面的问题根源在于进制,那我直接换成十进制,这样不就行了? 所以我们也可以直接使用十进制来表达这个小数,也就是我们最开始列出来的这个式子: (−1)c × n × 10y 我们在这里还是跟前面的一样,存入这三个值即可。
TIP
但是,正如前面一样,十进制的存储方式依然不能解决这个趋近的问题。 那么我们再想想,还有没有一种方法可以准确的表达有理小数?
有的,分数!
我们现在只需要使用这样的公式,对于任意的有理小数,我们可以有这个公式:$\dfrac{a}{b}$。同样的,我们只需要存入两个数即可。这就是目前julia的解决办法。
最后,让我们来对比一下
| 维度 | Float(32bit) | Double(64bit) | Decimal |
|---|---|---|---|
| 精度范围 | 7位有效数字 | 15-17位有效数字 | 28-29位精确小数 |
| 内存占用 | 4字节 | 8字节 | 可变(通常16+字节) |
| 运算速度 | 纳秒级(硬件加速) | 微秒级 | 毫秒级(软件模拟) |
| 适用场景 | GPU计算/嵌入式 | 通用科学计算 | 财务/货币计算 |
End
对于小数的储存我们先讲到这里。下一期我们将讲讲大佬是如何利用float的特性来简化算法的。
文后注解 [1]:Float和Double的相关标准,请看https://zh.wikipedia.org/wiki/IEEE_754 [2]:如果看不懂,也可以去看一下这篇文章:http://kaito-kidd.com/2018/08/08/computer-system-float-point/ [3]: 对于最后面的分数这个概念,可以去看一下这篇文章:https://draveness.me/whys-the-design-decimal-and-rational/。 [4]:或者直接去看Julia的文档:https://docs.julialang.org/en/v1/manual/complex-and-rational-numbers/ 文本 | 李嘉俊
图片 | 部分来源于网络
审核 | 李嘉俊
终审 | 赵莎