提问人:Joris Kinable 提问时间:5/17/2023 更新时间:5/17/2023 访问量:102
加减相同数字时的 Java 双精度错误
java double precision error when adding and subtracting the same number
问:
由于浮点数不准确,java 的 double 不能精确地表示每个十进制数,即引入了精度误差。 给定以下代码:
double d1=0.20976190476190476;
double d2=0.062142857142857146;
System.out.println("d1-d1= "+(d1-d1)); //Prints 0.0.
System.out.println("d1+d2-d2-d1= "+(d1+d2-d2-d1)); //Prints 2.7755575615628914E-17
我很好奇最后一行如何产生精度误差?我假设表达式的计算结果为:((d1+d2)-d2)-d1。因此,即使 d1、d2 或 (d1+d2) 没有精确表示,我也会假设精度误差会相互抵消吗?
答:
从 jshell(交互式 java shell): JShell> D1+D2-D2 6 美元 ==> 0.2097619047619048
它不等于 d1,因此您可以快速确认错误不会相互抵消。
浮点数奇怪行为的一个极端例子是将非常小的量添加到一个非常大的量上——这种添加甚至可能不会改变大值,因此通常 d1 + d2 可以等于 d1,即使 d2 不为零。
浮点数学很棘手,其中数字准确性非常重要,请确保您使用/理解手头的算法,使用专家编写的库,或使用 BigDecimal 或其他任意精度数字。
听起来你对浮点数学的工作原理感到困惑IEEE754(这是在 java 中的方式和行为 - 根据 IEEE754 规范)。那是。。不足为奇;总而言之,这是一个相当复杂的系统!double
float
有一些易于理解的心智模型应该会有所帮助。一旦你知道了这些,你应该能够直觉地回答你的问题。
请记住,计算机是十进制的
我有一个挑战要给你。拿起一张小纸片,用十进制记法写下 1 除以 5 的值。这应该很容易,你只需写下“0.2”就完成了。
现在把它翻过来,写下 1 除以 3。
你做不到。你可以试着靠近,根据你的纸的大小,你最终会得到“0.3333333333333333”,但随后你就用完了。不管你的论文有多大,考虑到你必须用十进制而不是分数写下来的限制,你无法完成这项工作。
那么 3 和 5 有什么特别之处呢?
诀窍是,5 是我们使用的十进制基数的公因数:5 可以被 10 整除,十进制系统是以 10 为基数。出于同样的原因,1/2 有效 (0.5),甚至 3/4 有效(当你将 4 分解为质因数时,它是 - 所有这些都可以被我们的基数 10 整除。这就是为什么 3/4 在十进制中完美地“工作”的原因)。但是 1/7 不起作用,就像 1/6 不起作用一样(6 分解为 - 3 不能干净地整除成 10,所以,你不能干净利落地写成十进制的 1/6)。2*2
2*3
不过,计算机以二进制计数,这立即导致了一个令人讨厌的惊喜:十进制中唯一的公因数是 2 本身。因此,IEEE754数学可以准确地表示例如 1/8 甚至 7/4096,但它非常有限。1/3、1/5、1/7、1/9,甚至 1/10 都不起作用。只有 2 的倍数“工作”,没有十进制错误。我们可以简单地观察到这一点:
double x = 0;
for (int i = 0; i < 10; i++) x += 0.1;
System.out.println(1.0 == x); // prints 'false'. What the....???
尝试相同的诡计,例如 而且你永远不会得到不稳定的结果,因为 0.25 在 IEEE754 中也运行良好。0.75
换句话说,“0.1”(1/10th)在我们的十进制系统中效果很好,但在二进制系统中,它与十进制中的1/3一样有问题:我们在纸上的“空间”用完了,所以计算机必须将其四舍五入。
数字线是有偏差的。
计算机是二进制野兽,而 CPU 喜欢固定宽度的概念——计算机只能存储 0 和 1,它们不能存储其他任何东西,甚至不能存储东西有多大,所以除非外部因素决定了东西有多大,否则计算机不知道去哪里看。在纸上写数字时,我们有数字 0 到 9,但我们使用的不止于此:我们使用点表示小数国家(例如 1.75),我们在页面上使用间距来表示事物的开始和结束位置。计算机没有这些,所以除非你想花费 0 和 1 来存储东西有多大,否则最简单的方法是规定数字是 64 位。现在我们知道从哪里开始和停止。
给定 64 位,您最多只能表示唯一数字。这是很多数字,但不是无限的。2^64
想象一条数字线,从负无穷大到正无穷大。这至少在理论上是能够代表的。0 和 1 之间有无限数量的数字,更不用说穿过该数字线了。double
尽管如此,其中 0.0000000000% 可以准确地用双倍表示。毕竟,我们的双精度最多只能代表 2^64 个数字,而我们的数字线有无限广阔的数字。
那么它是如何工作的呢?首先,它选择略少于 2^64 个唯一数字。想象一下,我们在数字线上扔了那么多飞镖。我们称这些为“祝福数字”——只能代表飞镖,不能代表任何其他数字。然后IEEE754做了一个非常简单的命令:任何数字(所以,任何输入,任何中间结果)总是默默地四舍五入到最近的飞镖。double
double
换句话说,如果我们调用将结果四舍五入到最近的飞镖的函数,则写入分解为 ,这将变成 .那是很多飞镖!!dart(x)
d1 + d2 - d2 - d1
(((d1 + d2) - d2) - d1)
dart(dart(dart(dart(d1) + dart(d2)) - dart(d2)) - dart(d1))
飞镖的分布并不均匀。事实上,一半的飞镖非常接近 0)。当你远离零时,任何两个飞镖之间的距离会越来越大。
在 2^53 时,2 个飞镖之间的距离开始超过 1.0。2^53?这是一个有福的数字。事实上,从 0 到 2^53 的所有整数都是有福的。但是 2^53 + 1?没有福气。让我们试试吧!
long a = 2L << 52;
sysout(a); // 9007199254740992
double b = Math.pow(2.0, 53);
sysout(b); // 9.007199254740992E15
sysout((long) b); // 9007199254740992
sysout(a == (long) b); // true
到目前为止,一切都很好 - 代码以指数表示法打印了大双打,但它是完全正确的,正如“将其投射回长”所示。但现在......System.out.println
double c = b + 1;
sysout(c); // 9.007199254740992E15
sysout(b == c); // true - wait what?
添加 1 没有任何作用。B 和 C 实际上仍然是相同的值。 与 b 相同,因为 2^53 确实是最接近计算 2^53+1 结果的飞镖。dart(b + 1)
double d = b - 1;
sysout(d); // 9.007199254740991E15
sysout(b == d); // false
但是下降一个确实很好。
解释您的问题
现在我们可以简单解释一下您的问题:
- 您的 2 个数字四舍五入到最接近的飞镖。没关系。
- 然后我们把飞镖和飞镖加在一起,这个数字并不完全适合飞镖(为什么会这样 - 它很少这样做)。因此,这也四舍五入到最接近的飞镖。
d1
d2
- 然后我们再次减去飞镖,然后飞镖这个结果。这并不能让我们得到相同的数字。这应该不会太令人惊讶 - 飞镖分布不均 - 将 d1 和 d2 相加会移动到数字线的“飞镖密度较低”区域。飞镖密度越低意味着准确性越低。这种回避到更高的数字(加上 d2,然后再次减去 - 在中间,我们处于飞镖密度较低的区域)让我们付出了代价。
d2
- 最后,你从你所拥有的东西中减去。鉴于我们现在越来越接近 0,这里有很多飞镖,所以飞镖四舍五入不会让我们回到 0,而是让我们到达一些非常接近 0 的飞镖。
d1
当然,从理论上讲,java 可能已经消失了:哦,嘿,,我可以摆脱 ,然后离开并摆脱它,这离开了 ,但这不是它的工作方式:java 规范清楚地说 和 是二进制运算符(不是任何数量的运算符),它们是从左到右解析的。Java 不是在白板上简化公式的数学家。Java 只是按照规范所说的去做。这是将其分解为 ,然后应用IEEE753(即四舍五入到所有事物的最近飞镖)。d1+d2-d2-d1
d2-d2
d1-d1
0
+
-
(((d1+d2)-d2)-d1)
评论