又见浮点数精度问题

 

 

今天看到一篇文章:http://younglab.blog.51cto.com/416652/241886,大概是说在使用Javascript进行下面的浮点数计算时出现了问题:
 
       obj.style.opacity =  (parseInt(obj.style.opacity *100) + 1)/100;
 
obj.style.opacity是一个浮点数,范围从0~1,初始值为0。这句代码每隔一小段时间执行一次,从而让目标由透明慢慢变为不透明(淡入效果)。
 
问题是,起初obj.style.opacity还能够按照预期的每次以0.01逐步增加,但增加到0.29时就一直保持不变了。
 
作者只是记录了这个问题,没有写出为什么。读完这篇博客后我的第一感觉是:
 
        这又是一个由于浮点数精度所引发的问题。
 
下面让我们来写一个小程序重现一下这个问题:
 
double opacity = 0;
for (int i = 0; i < 100; i++) {
     opacity = ((int) (opacity * 100 + 1)) / 100.0;

     System.out.println("opacity=" + opacity);
}

 

程序是用Java写的,共执行100次循环,采用了与那篇文章中相同的计算方法。正常情况下opacity会由0逐步增大到1。
 
程序输出如下:
 

opacity=0.01
opacity=0.02
opacity=0.03
opacity=0.04
opacity=0.05
opacity=0.06
(中间省略……)
opacity=0.27
opacity=0.28
opacity=0.29
opacity=0.29
opacity=0.29
……后面一直为0.29

 
可以发现,当opacity达到0.29后便不再增加了。由于Java和JS使用的是相同的浮点数格式,所以采用Java和JS结果都是相同的。
 
这里有一个细节需要注意:在这段程序中,除数必须写成100.0。这是由于在Java中有整数除法和浮点数除法两种不同的运算,如果写成100,那么被除数和除数将都是整数,Java就会按照整数除法来计算,就会导致每次计算的结果都是0(因为每次计算的结果都小于1,因此取整后就变为了0)。JS里没有这个问题,因为JS没有整数除法,所有除法都会当成浮点数除法来对待。
 

深入分析

现在我把上面那个程序做一点修改:

 

double opacity = 0;
for (int i = 0; i < 100; i++) {
     opacity = ((int) (opacity * 100 + 1)) / 100.0;

     System.out.println("opacity=" + new BigDecimal(opacity));
     System.out.println("opacity*100=" + new BigDecimal(opacity * 100));
     System.out.println("----------------------------");
}
 
因为Java在将浮点数转换为字符串时会做一些处理,让结果看起来更“美观”一些,但这样会让我们无法看清楚程序运行的真实情况。
 
在这个程序中我借助BigDecimal来显示浮点数在内存中的真正的样子。BigDecimal有一个以double数字为参数的构造方法,该方法会完整拷贝此double参数在内存中的位模式,它的toString( )方法也会严格按照实际的值进行转换,而不会为了“美观”而做任何处理。因此我们可以利用这种方法来看清一个double的“真面目”。
 
程序输出如下:
 
opacity=0.01000000000000000020816681711721685132943093776702880859375
opacity*100=1
----------------------------
opacity=0.0200000000000000004163336342344337026588618755340576171875
opacity*100=2
----------------------------
opacity=0.0299999999999999988897769753748434595763683319091796875
opacity*100=3
 
(中间省略……)
 
opacity=0.270000000000000017763568394002504646778106689453125
opacity*100=27
----------------------------
opacity=0.2800000000000000266453525910037569701671600341796875
opacity*100=28.000000000000003552713678800500929355621337890625
----------------------------
opacity=0.289999999999999980015985556747182272374629974365234375
opacity*100=28.999999999999996447286321199499070644378662109375
 
……后面一直重复相同的内容
 
可以发现,当opacity的值为0.29时,实际上在内存中的准确值是0.2899999……,所以乘以100变成28.99999……,这比29要稍微小那么一点点。但就是少了这一点点,当强制转换为整数后的结果却是28而不是期望的29。而这正是导致这个问题的原因所在。
 
从这个程序的运行结果中我们还可以观察到以下几个现象:
 
1. 每个中间结果例如0.01、0.02……等等,都无法用double类型精确表示
 
2. 即使本身无法精确表示,但在0.28之前,opacity*100的结果却都是精确的
 
3. 在无法精确表示的数中,有些比真实值略大,而有些却比真实值略小。如果是前者,当截断小数位转成整型时得到的结果是“正确”的;但如果是后者则会得到错误的结果。例如0.28*100转成整型为28,而0.29*100转成整型不是29而是28。

如何改正 

经过前面的分析,现在我们已经弄明白了问题产生的原因,那么该如何修正它呢?
 
之前的代码之所以无法正确运行,其根本原因在于一个double类型的数字强制转换为整型时会发生截断,这会导致小数部分全部丢失,然而计算的中间结果中有一些要比期望的整数值略小,截断小数位以后得到的是比期望值小1的值。
 
因此我们可以从以下两个方面着手修正此问题:一是从代码中去除强制转换操作;或者,保证截断之前的中间结果一定是比期望值略大的。
 

方法1. 去除强制转换

程序的目的是让opacity的值每次增加0.01,那么就只需要每次加上0.01就好了,完全不需要绕圈子。如下:

 

double opacity = 0;
while (opacity < 1) {
     opacity += 0.01;
     System.out.println("opacity=" + opacity);
}
 
这个程序简单、直接,而且没有任何问题。我个人推荐这个方法。该程序输出如下:
 

opacity=0.01
opacity=0.02
opacity=0.03
opacity=0.04
opacity=0.05
opacity=0.060000000000000005

(中间省略……)

opacity=0.9800000000000006
opacity=0.9900000000000007
opacity=1.0000000000000007

 

方法2. 保证截断之前的中间结果略大于期望值

既然原程序的问题发生在截断时,那么只要保证截断发生之前,中间结果的值略大于期望值,就能保证程序的正确性。例如如果要让截断后的结果为29,只要保证截断前的值在[29, 30)这个范围内即可。
 
如何做到这一点呢?
 
由于我们可以肯定在这个问题中,opacity*100的结果是非常接近我们所期望的整数的,只是由于double类型的精度限制而比期望的整数略大或略小而已,其误差一定非常非常小。
 
所以我们可以修改这句代码:
 
        opacity = ((int) (opacity * 100 + 1)) / 100.0;
 
不是给opacity * 100加上1,而是加一个更大一些的数,例如1.5,变为:
 
        opacity = ((int) (opacity * 100 + 1.5)) / 100.0;
 
如果我们期望的值是29,那么修改后的中间结果一定是在29.5附近,这样就能保证截断后的值一定是29了。程序如下:
 
double opacity = 0;
for (int i = 0; i < 100; i++) {
     opacity = ((int) (opacity * 100 + 1.5)) / 100.0;

     System.out.println("opacity=" + opacity);
}

 

输出为:
 

opacity=0.01
opacity=0.02
opacity=0.03
opacity=0.04
opacity=0.05
opacity=0.06

(中间省略……)

opacity=0.96
opacity=0.97
opacity=0.98
opacity=0.99
opacity=1.0

 
可以看到结果是正确的。
 

总结

只要稍有经验的程序员都知道浮点数不能直接进行相等比较,但是像这篇文章中所碰到的问题可能并不那么常见,因此有时不容易意识到发生了问题。
 
每个程序员都应该知道计算机中是采用近似值来保存浮点数的,当进行浮点数相关的计算时,需要时刻提防由于精度问题所导致的误差,并注意避免那些会影响到结果正确性的误差(所谓正确性,就是误差超出了所允许的最大范围)。
 
 
 

附:

 
下面这个网页列举了历史上的一些由于计算问题引起的软件灾难,其中一例是1996年欧洲航天局的Ariane 5火箭发射失败事件,该火箭发射后仅40秒即发生爆炸,导致发射基地的2名法国士兵死亡,并导致历时近10年、耗资达70亿美元的航天计划严重受挫。事后调查报告显示问题的原因出在火箭的惯性参考系的软件系统中,其中有一个地方是将水平方位的64位浮点数转换为一个16位的整数,当浮点数的值超过32767时,转换就会失败(即转换的结果是错误的),从而导致了悲剧的发生。
 

 

 

 

posted @ 2015-05-24 00:15  Antineutrino  阅读(3815)  评论(2编辑  收藏  举报