使用双精度时,为什么不(x /(y * z))与(x / y / z)相同?
这部分是学术性的,就我的目的而言,我只需将它四舍五入到小数点后两位; 但我很想知道结果会产生两个略有不同的结果。
这是我写的测试,以缩小到最简单的实现:
@Test public void shouldEqual() { double expected = 450.00d / (7d * 60); // 1.0714285714285714 double actual = 450.00d / 7d / 60; // 1.0714285714285716 assertThat(actual).isEqualTo(expected); }
但它输出失败了:
org.junit.ComparisonFailure: Expected :1.0714285714285714 Actual :1.0714285714285716
任何人都可以详细解释在引擎盖下发生的事情导致1.000000000000000 X
的值不同吗?
我在答案中寻找的一些要点是:精度丢失在哪里? 首选哪种方法,为什么? 哪个是正确的? (在纯数学中,两者都不对。也许两者都错了?)这些算术运算有更好的解决方案或方法吗?
我看到一堆问题告诉你如何解决这个问题,但没有一个真正解释发生了什么的问题,除了“浮点舍入错误是坏的,m’kay?” 那么让我来看看吧。 我首先要指出,这个答案中没有任何内容特定于Java 。 舍入误差是数字的任何固定精度表示所固有的问题,因此您在C中会遇到相同的问题。
十进制数据类型中的舍入错误
作为一个简化的例子,假设我们有某种本机使用无符号十进制数据类型的计算机,我们称之为float6d
。 数据类型的长度为6位:4个专用于尾数,2个专用于指数。 例如,数字3.142可以表示为
3.142 x 10^0
将以6位数字存储为
503142
前两位是指数加50,后四位是尾数。 该数据类型可以表示0.001 x 10^-50
至9.999 x 10^+49
任何数字。
实际上,那不是真的。 它不能存储任何数字。 如果你想代表3.141592怎么办? 还是3.1412034? 还是3.141488906? 幸运的是,数据类型不能存储超过四位数的精度,因此编译器必须对具有更多数字的任何内容进行舍入以适应数据类型的约束。 如果你写
float6d x = 3.141592; float6d y = 3.1412034; float6d z = 3.141488906;
然后编译器将这三个值中的每一个转换为相同的内部表示, 3.142 x 10^0
(记住,存储为503142
),这样x == y == z
将保持为真。
关键是有一整个实数范围都映射到相同的基础数字序列(或实际计算机中的位)。 具体地,满足3.1415 <= x <= 3.1425
(假设半偶数舍入)的任何x
被转换为表示503142
以存储在存储器中。
每次程序在内存中存储浮点值时都会发生这种舍入。 第一次发生的是你在源代码中写一个常量,正如我上面用x
, y
和z
所做的那样。 每当您执行算术运算时,它会再次发生,这会增加超出数据类型所代表的精度位数。 这些效果中的任何一个都称为舍入误差 。 有几种不同的方式可以发生:
-
加法和减法:如果您添加的其中一个值与另一个值具有不同的指数,您将获得额外的精度数字,如果有足够的数字,则需要删除最不重要的数字。 例如,2.718和121.0都是可以在
float6d
数据类型中精确表示的值。 但是如果你尝试将它们加在一起:1.210 x 10^2 + 0.02718 x 10^2 ------------------- 1.23718 x 10^2
它会四舍五入到
1.237 x 10^2
或123.7,精确到两位数。 -
乘法:结果中的位数大约是两个操作数中位数的总和。 如果您的操作数已经有许多有效数字,这将产生一些舍入误差。 例如,121 x 2.718给你
1.210 x 10^2 x 0.02718 x 10^2 ------------------- 3.28878 x 10^2
四舍五入到
3.289 x 10^2
,或328.9,再次降低两位数的精度。但是,要记住,如果您的操作数是“漂亮”数字而没有很多有效数字,那么浮点格式可能完全代表结果,因此您不必处理舍入误差。 例如,2.3 x 140给出
1.40 x 10^2 x 0.23 x 10^2 ------------------- 3.22 x 10^2
没有出现问题。
-
分部:这是事情变得混乱的地方。 除非你除以的数字碰巧是基数的幂(在这种情况下,除法只是数字移位,或二进制位移),除法几乎总会导致一些舍入误差。 举一个例子,取两个非常简单的数字,3和7,除以它们,你得到
3. x 10^0 / 7. x 10^0 ---------------------------- 0.428571428571... x 10^0
可以表示为
float6d
的最接近此数字的值是4.286 x 10^-1
或0.4286,这与确切的结果明显不同。
正如我们将在下一节中看到的那样,舍入引入的错误会随着您执行的每个操作而增加。 因此, 如果您正在处理“好”数字,如您的示例所示,通常最好尽可能晚地执行除法运算,因为这些操作最有可能将舍入错误引入您之前不存在的程序中。
对舍入误差的分析
一般来说,如果你不能假设你的数字是“好的”,那么舍入误差可以是正数也可以是负数,并且很难根据操作来预测它将走向哪个方向。 这取决于所涉及的具体价值。 看看这个2.718 z
的舍入误差图作为2.718 z
的函数(仍然使用float6d
数据类型):
实际上,当您使用使用数据类型的完整精度的值时,通常更容易将舍入错误视为随机错误。 查看该图,您可能会猜测误差的大小取决于操作结果的数量级。 在这种特殊情况下,当z
的数量级为10 -1时 , 2.718 z
也大约为10 -1 ,因此它将是0.XXXX
forms的0.XXXX
。 最大舍入误差是最后一位精度的一半; 在这种情况下,“精度的最后一位”是指0.0001,因此舍入误差在-0.00005和+0.00005之间变化。 在2.718 z
跳到下一个数量级(即1 / 2.718 = 0.3679)的点上,您可以看到舍入误差也会跳跃一个数量级。
您可以使用众所周知的错误分析技术来分析某个幅度的随机(或不可预测)错误如何影响您的结果。 具体来说,对于乘法或除法,结果中的“平均”相对误差可以通过在每个操作数的正交中加上相对误差来近似 - 也就是说,将它们平方,加上它们,然后取平方根。 使用我们的float6d
数据类型,相对误差在0.0005(对于像0.101这样的值)和0.00005(对于像0.995这样的值)之间变化。
让我们将0.0001作为值x
和y
的相对误差的粗略平均值。 然后给出x * y
或x / y
的相对误差
sqrt(0.0001^2 + 0.0001^2) = 0.0001414
这是sqrt(2)
的因子大于每个单独值中的相对误差。
在组合操作时,您可以多次应用此公式,每次浮点运算一次。 因此,例如,对于z / (x * y)
, z / (x * y)
的相对误差平均为0.0001414(在此十进制示例中),然后z / (x * y)
的相对误差为
sqrt(0.0001^2 + 0.0001414^2) = 0.0001732
请注意,平均相对误差随着每个操作而增加,特别是作为乘法和除法的平方根。
类似地,对于z / x * y
, z / x * y
的平均相对误差为0.0001414, z / x * y
的相对误差为
sqrt(0.0001414^2 + 0.0001^2) = 0.0001732
所以,同样,在这种情况下。 这意味着对于任意值,平均而言,这两个表达式引入了大致相同的错误 。 (从理论上讲,就是这样。我看到这些操作在实践中表现得非常不同,但这是另一个故事。)
血淋淋的细节
您可能对您在问题中提出的具体计算感到好奇,而不仅仅是平均值。 对于那个分析,让我们切换到二进制算术的真实世界。 大多数系统和语言中的浮点数使用IEEE标准754表示。 对于64位数字, 格式指定52位专用于尾数,11位指定指数,1位指示符号。 换句话说,当写入基数2时,浮点数是表单的值
1.1100000000000000000000000000000000000000000000000000 x 2^00000000010 52 bits 11 bits
前导1
未明确存储,并构成第53位。 此外,您应该注意,存储以表示指数的11位实际上是实指数加上1023.例如,此特定值为7,即1.75 x 2 2 。 尾数是1.75,二进制,或1.11
,指数是1023 + 2 = 1025二进制,或10000000001
,所以存储在内存中的内容是
01000000000111100000000000000000000000000000000000000000000000000 ^ ^ exponent mantissa
但这并不重要。
你的例子也涉及450,
1.1100001000000000000000000000000000000000000000000000 x 2^00000001000
和60,
1.1110000000000000000000000000000000000000000000000000 x 2^00000000101
您可以使用此转换器或互联网上的任何其他值来使用这些值。
当你计算第一个表达式450/(7*60)
,处理器首先进行乘法,得到420,或
1.1010010000000000000000000000000000000000000000000000 x 2^00000001000
然后它将450除以420.这产生15/14,即
1.0001001001001001001001001001001001001001001001001001001001001001001001...
在二进制。 现在, Java语言规范说明了这一点
不精确的结果必须四舍五入到最接近无限精确结果的可表示值; 如果两个最接近的可表示值相等,则选择具有最低有效位0的那个。 这是IEEE 754标准的默认舍入模式,称为舍入到最近。
64位IEEE 754格式的最接近的可表示值为15/14
1.0001001001001001001001001001001001001001001001001001 x 2^00000000000
大约是1.0714285714285714
。 (更确切地说,这是唯一指定此特定二进制表示的最不精确的十进制值。)
另一方面,如果先计算450/7,结果为64.2857142857 ...,或者是二进制,
1000000.01001001001001001001001001001001001001001001001001001001001001001...
最近的可表示值是
1.0000000100100100100100100100100100100100100100100101 x 2^00000000110
这是64.28571428571429180465 ...请注意由于舍入误差导致的二进制尾数的最后一位数(与精确值相比)的变化。 将此除以60可以得到你
1.000100100100100100100100100100100100100100100100100110011001100110011...
看结尾:模式不同! 它是重复的0011
,而不是在另一种情况下重复001
。 最接近的可表示值是
1.0001001001001001001001001001001001001001001001001010 x 2^00000000000
这与最后两位中的其他操作顺序不同:它们是10
而不是01
。 十进制当量是1.0714285714285716。
如果您查看确切的二进制值,则应该清楚导致此差异的特定舍入:
1.0001001001001001001001001001001001001001001001001001001001001001001001... 1.0001001001001001001001001001001001001001001001001001100110011001100110... ^ last bit of mantissa
在这种情况下,前者的结果(数字15/14)恰好是精确值的最准确表示。 这是一个如何离开分部直到最终使您受益的一个例子。 但同样,只要您使用的值不使用数据类型的完整精度,此规则就会成立。 一旦开始使用不精确(舍入)值,您就不再通过先进行乘法来保护自己免受进一步的舍入误差。
它与double
类型的实现方式以及浮点类型与其他更简单的数字类型不能提供相同的精度保证这一事实有关。 虽然以下答案更具体地说是总和,但它也通过解释如何在浮点数学运算中无法保证无限精度来回答您的问题: 为什么更改总和顺序会返回不同的结果? 。 基本上,如果不指定可接受的误差范围,就不应该尝试确定浮点值的相等性。 Google的Guava库包含DoubleMath.fuzzyEquals(double, double, double)
以确定一定精度内两个double
值的相等性。 如果你想了解浮点相等的细节, 这个网站是非常有用的 ; 同一站点也解释了浮点舍入错误 。 总之:由于操作顺序的计算之间的舍入不同,计算的预期值和实际值会有所不同。
让我们简化一下。 你想知道的是为什么450d / 420
和450d / 7 / 60
(特别是)会给出不同的结果。
让我们看看如何在IEE双精度浮点格式中执行除法。 在不深入实现细节的情况下,它基本上对符号位进行XOR
,从被除数的指数中减去除数的指数,除以尾数,并对结果进行归一化。
首先,我们应该以正确的格式表示我们的数字:
450 is 0 10000000111 1100001000000000000000000000000000000000000000000000 420 is 0 10000000111 1010010000000000000000000000000000000000000000000000 7 is 0 10000000001 1100000000000000000000000000000000000000000000000000 60 is 0 10000000100 1110000000000000000000000000000000000000000000000000
让我们先将450
除以420
首先是符号位,它是0
( 0 xor 0 == 0
)。
然后是指数。 10000000111b - 10000000111b + 1023 == 10000000111b - 10000000111b + 01111111111b == 01111111111b
看起来很好,现在是尾数:
1.1100001000000000000000000000000000000000000000000000 / 1.1010010000000000000000000000000000000000000000000000 == 1.1100001 / 1.101001
。 有几种不同的方法可以做到这一点,我稍后会谈谈它们。 结果是1.0(001)
(您可以here
validation)。
现在我们应该将结果标准化。 让我们看看守卫,圆形和粘性位值:
0001001001001001001001001001001001001001001001001001 0 0 1
保护位为0,我们不进行任何舍入。 结果是,二进制:
0 01111111111 0001001001001001001001001001001001001001001001001001
以十进制表示为1.0714285714285714
。
现在让我们通过类比将450
除以7
。
符号位= 0
指数= 10000000111b - 10000000001b + 01111111111b == -01111111001b + 01111111111b + 01111111111b == 10000000101b
尾数= 1.1100001 / 1.11 == 1.00000(001)
四舍五入:
0000000100100100100100100100100100100100100100100100 1 0 0
保护位置位,圆形和粘滞位不设置。 我们四舍五入到最近(IEEE的默认模式),并且我们正处于可以舍入的两个可能值之间。 当lsb为0
,我们加1
。 这给了我们圆形的尾数:
0000000100100100100100100100100100100100100100100101
结果是
0 10000000101 0000000100100100100100100100100100100100100100100101
以十进制表示为64.28571428571429
。
现在我们将它除以60
……但你已经知道我们已经失去了一些精确度。 将450
除以420
并不需要四舍五入,但在这里,我们必须至少对结果进行一次舍入。 但是,为了完整起见,让我们完成这项工作:
将64.28571428571429
除以60
符号位= 0
指数= 10000000101b - 10000000100b + 01111111111b == 01111111110b
尾数= 1.0000000100100100100100100100100100100100100100100101 / 1.111 == 0.10001001001001001001001001001001001001001001001001001100110011
轮换:
0.1000100100100100100100100100100100100100100100100100 1 1 0 0 1.0001001001001001001001001001001001001001001001001001 1 0 0
与前一种情况一样,我们得到尾数: 0001001001001001001001001001001001001001001001001010
。
当我们移动1
,我们将其添加到指数中,得到
指数= 01111111111b
所以,结果是:
0 01111111111 0001001001001001001001001001001001001001001001001010
以十进制表示为1.0714285714285716
。
Tl;博士 :
第一师给了我们:
0 01111111111 0001001001001001001001001001001001001001001001001001
最后一个部门给了我们:
0 01111111111 0001001001001001001001001001001001001001001001001010
差异只在最后2位,但我们可能会失去更多 – 毕竟,为了得到第二个结果,我们不得不绕两次而不是没有!
现在,关于尾数分裂 。 浮点除法以两种主要方式实现。
IEEE长除法规定的方式( 这里有一些很好的例子;它基本上是常规的长除法,但是用二进制而不是十进制),而且它很慢。 这就是你的电脑所做的。
还有一个更快,但收益更少的选项,乘以逆。 首先,找到除数的倒数,然后进行乘法运算。
那是因为双重划分经常会导致精度下降。 所述损失可以根据划分的顺序而变化。
除以7d
,实际结果已经失去了一些精度。 然后只有你将错误的结果除以60
。
当你除以7d * 60
,你只需要使用一次除法,因此只会失去一次精度。
请注意,双倍乘法有时也会失败,但这种情况并不常见。
当然,操作的顺序与双打不精确的事实混合在一起:
450.00d / (7d * 60) --> a = 7d * 60 --> result = 450.00d / a
VS
450.00d / 7d / 60 --> a = 450.00d /7d --> result = a / 60