浮点数运算

结论

  1. Java中单精度和双精度采用IEEE 754表示,能有效运算的范围大致是小数点后7位和15位
  2. 如果Java中默认的float和double不能满足你的精度要求,可以用BigDecimal,理论上它的精度只受限制与机器内存
  3. 如果BigDecimal仍无法满足需求,例如是无限循环小数的运算,可考虑设计分数系统保证计算的精度

1. IEEE 754 精度上限

编程语言中的浮点数一般都是 32 位的单精度浮点数 float 和 64 位的双精度浮点数 double,部分语言会使用 float32 或者 float64 区分这两种不同精度的浮点数。想要使用有限的位数表示全部的实数是不可能的,不用说无限长度的小数和无理数,因为长度的限制,有限小数在浮点数中都无法精确的表示。

img

  • 单精度浮点数 float 总共包含 32 位,其中 1 位表示符号、8 位表示指数,最后 23 位表示小数;
  • 双精度浮点数 double 总共包含 64 位,其中 1 位表示符号,11 位表示指数,最后 52 位表示小数;

我们以单精度浮点数 0.15625 为例:

img

通过上图中的公式 (sign * 2^{exp}* (1+fraction))可以将浮点数的二进制表示转换成十进制的小数。0.15625 虽然还可以用单精度的浮点数精确表示,但是 0.1 和 0.2 只能使用浮点数表示近似的值:

img

因为 0.2 和 0.1 只是指数稍有不同,所以上图中只展示了 0.1 对应的单精度浮点数,从上图的结果我们可以看出,0.1 和 0.2 在浮点数中只能用近似值来代替,精度十分有限,因为单精度浮点数的小数位为 23,双精度的小数位为 52,同时都隐式地包含首位的 1,所以它们的精度在十进制中分别是(log_{10}(2^{24})\approx 7.22) 和 (log_{10}(2^{53})\approx 15.95) 位。

因为 0.1 和 0.2 使用单精度浮点数表示的实际值为 0.100000001490116119384765625 和 0.200000002980232238769531257,所以它们在相加后就得到的结果与我们在一开始看到的非常相似:

img

上图只是使用单精度浮点数表示的数字,如果使用双精度浮点数,最终结果中的 3 和 4 之间会有更多的 0,但是小数出现的顺序是非常相似的。浮点数的运算法则相对来说比较复杂,感兴趣的读者可以自行搜索相关的资料,我们在这里不展开介绍了。

2. BigDecimal

为了解决浮点数的精度问题,一些编程语言引入了十进制的小数 Decimal。Decimal 在不同社区中都十分常见,如果编程语言没有原生支持 Decimal,我们在开源社区也一定能够找到使用特定语言实现的 Decimal 库。Java 通过 BigDecimal 提供了无限精度的小数,该类中包含三个关键的成员变量 intVal、scale 和 precision:

public class BigDecimal extends Number implements Comparable<BigDecimal> {
    private BigInteger intVal;
    private int scale;
    private int precision = 0;
    ...
}

当我们使用 BigDecimal 表示 1234.56 时,BigDecimal 中的三个字段会分别以下的内容:

  • intVal 中存储的是去掉小数点后的全部数字,即 123456
  • scale 中存储的是小数的位数,即 2
  • prevision 中存储的是全部的有效位数,小数点前 4 位,小数点后 2 位,即 6

img

BigDecimal 这种使用多个整数的方法避开了二进制无法准确表示部分十进制小数的问题,因为 BigInteger 可以使用数组表示任意长度的整数,所以如果机器的内存资源是无限的,BigDecimal 在理论上也可以表示无限精度的小数。

虽然部分编程语言实现了理论上无限精度的 BigDecimal,但是在实际应用中我们大多不需要无限的精度保证,C# 等编程语言通过 16 字节的 Decimal 提供的 28 ~ 29 位的精度,而在金融系统中使用 16 字节的 Decimal 一般就可以保证数据计算的准确性了。

有理数

使用 DecimalBigDecimal 虽然可以在很大程度上解决浮点数的精度问题,但是它们在遇到无限小数时仍然无能为力,使用十进制的小数永远无法准确地表示 1/3,无论使用多少位小数都无法避免精度的损失:

img

当我们遇到这种情况时,使用有理数(Rational)是解决类似问题的最好方法,使用分数可以准确的表示 1/101/51/3

这种解决精度问题的方法更接近原始的数学公式,分数的分子和分母是有理数结构体中的两个变量,多个分数的加减乘除操作与数学中对分数的计算没有任何区别,自然也就不会造成精度的损失,我们可以简单了解一下 Java 中有理数的实现7

public class Rational implements Comparable<Rational> {
    private int num;   // the numerator
    private int den;   // the denominator
    public double toDouble() {
        return (double) num / den;
    }
    ...
}

上述类中的 numden 分别表示分数的分子和分母,它提供的 toDouble 方法可以将当前有理数转换成浮点数,因为浮点数在软件工程中虽然更加常用,当我们需要严密的科学计算时,可以使用有理数完成绝大多数的计算,并在最后转换回浮点数以减少可能出现的误差。

然而需要注意的是,这种使用有理数计算的方式不仅在使用上相对比较麻烦,它在性能上也无法与浮点数进行比较,一次常见的加减法就需要使用几倍于浮点数操作的汇编指令,所以在非必要的场景中一定要尽量避免。

参考链接:

为什么 0.1 + 0.2 = 0.3

为什么 0.1 + 0.2 = 0.300000004

版权

评论