为什么说比较两个浮点数是否相等是不安全的?

在以前的时候,浮点计算使用的是软件方式完成的,效率比较低。现代CPU大多数都带有设计良好的浮点运算单元,这样以来浮点运算与整数运算之间的性能差异就变小了。
    虽然浮点运算在有了FPU之后逐渐流行起来,但是时至今日,仍然有很多程序员不了解浮点格式所固有的缺陷。从本质上讲,浮点运算只是实数运算的一个近似。如果一个软件工程师不了解这种近似所带来的问题,那这种不精确的表示法就会给其所编写的软件带来非常严重的问题。
    浮点数只能表示一个实数的近似值。原因是可能存在的实数有无穷多个,可以用数轴一一表示,但浮点表示法只有有限的位数。因此,浮点表示法仅能表示有限个不同的数。当某个浮点数格式无法精确表示某个实数时,作为替代只能选用一个最接近的,它能够精确表示的浮点数。整数是无法表示任何小数的,整数的另一个问题是它们只能表示范围在0~2^n-1或者-2^(n-1)~2^(n-1)-1的数。定点数倒是可以表示小数,但是代价是能表示的整数更少,这就是动态范围问题,不过已经被浮点格式解决了。
    考虑一个16位无符号定点格式,它使用8位作为小数部分,8位作为整数部分。而整数部分可以表示的范围是0~255,而小数部分可以表示0以及2^(-8)到1之间的小数,精度大约是2^(-8)。现在假定在一次运算中只需要两位来表示小数值0.0,0.25,0.5,0.75,不幸的是小数部分的其他六位不得不被浪费了。如果能够将这些位用于整数部分,把整数的范围从0~255扩展到0~16383岂不是很好?其实,这就是浮点表示法的基本原理。在浮点数中,小数点可以根据需要在各个位之间移动。因此,如果一个16位二进制数的小数部分只需要两位的精度,小数点可以浮动到第1位与第2位之间,允许该格式将第2位到第15位用作整数部分。为了支持浮点格式,在数值表示中需要指定一个额外的域用来指定小数点的位置。这个额外的域等效于使用科学记数法时的指数。
    为了表示实数,大多数浮点格式使用一些位来表示尾数,一小部分位来表示阶码。尾数是一个基数,通常落在一个有限范围内(0~1),而阶码是一个乘数,它作用到尾数之后,产生的值就超出了这个范围。将浮点数分成这两部分的结果就是,浮点数只能以一定数量的有效数字来表示数值。显而易见,如果最小的阶码与最大的阶码之间的差比尾数的有效数字大,那么该浮点表示法是无法精确表示该浮点格式可以表示的最小与最大值之间的所有整数的。
    为了更容易观察有限精度的算术影响,使用一个简化的十进制浮点格式。该浮点格式将提供一个具有三个有效数字的尾数与一个两位的十进制阶码。尾数与阶码都是有符号数,如下所示:
(+-)[].[][]e(+-)[][]
上面的表示法可以近似表示0.00到9.99*10^99之间的所有值。但是很显然这种格式无法精确表示这个范围内的所有的数,那需要100位的精度。为了表示9876543210这样的数,就不得不用9.88*10^9来近似表示这个数。
    使用尾数加阶码的一大优点是,这样的浮点数格式可以表示很宽广范围内的数。但是,这种方案也有一个很微妙的缺点:使用浮点格式能够表示的数的个数比整数格式要少。这是因为,在浮点格式中,同一个数可以有多种表示方法。例如,1.00e+1与0.10e+2就是同一个数的两种不同的表示。由于不同的表示的个数是有限的,那么只要一个数可以有两种表示,就意味着这种格式能够表示的数少了一个。
    另外,作为科学记数法的一种,浮点格式在一定程度上使得算术运算复杂化了。在将两个使用科学记数法的数相加或相减的时候,首先必须调整这两个数,使它们的阶码相等。例如,在将1.23e1与4.56e0相加时,可以将4.56e0转换为0.456e1在进行相加产生的结果是1.686e1,但是,这个结果不能用我们上面的格式的三个有效数字来表示,因此必须进行处理,要么四舍五入要么舍位,将结果处理到三个有效数字。四舍五入一般会产生最准确的结果,因此将结果进行四舍五入得到1.69e1。可以看到,精度的缺乏会影响结果的准确性或者说计算的正确性。在这个例子中我们可以对结果进行四舍五入,因为在计算过程中我们保留了4个有效数字。如果所做的浮点计算在计算过程中被限制在三个有效数字,那么计算中就不得不将较小的哪个数的最后一个数字舍位,结果是1.68e1,这就更不准确了。因此,为了提高浮点运算的正确性,有必要在计算过程中使用额外的有效数字。这些额外的有效数字被称为保护位。在进行一长串运算时,保护位极大地提高了准确性。
    除非你非常在意计算的准确性,否则一次计算过程中损失的精度还不算太严重。但是,如果计算出的数是一系列浮点运算的结果,误差是会积累的,而且会极大地影响计算结果。例如,假定要将1.23e3与1.00e0相加。在将它们相加之前需要进行调整,使得阶码相等,这得到了1.23e3+0.001e3。而这两个数的和,即使是使用四舍五入,也是1.23e3,这也许看起来非常合理,毕竟如果只有三个有效数字,加上一个很小的数不应该影响结果。但是,如果要将1.00e0加到1.23e3十次。第一次将1.00e0加1.23e3得到1.23e3。同样,第二次,第三次,第四次...以及第十次将1.00e0加1.23e3得到的都是一样的结果。但是,如果我们将1.00e0累加十次,然后将结果(1.00e1)加到1.23e3上,最终结果就是不一样的1.24e3了。这就是有限精度运算必须了解的一点:计算顺序可以影响结果的准确性。
    在对数量级(阶码的大小)接近的数进行相加或者相减时,结果可能会更理想一些。如果进行的是一系列涉及加法与减法的计算,那么你应该尽量将运算分组,分组原则是先对数量级接近的数进行相加或相减,再对数量级相差较大的数进行运算。加法与减法的另一个问题是可能会出现假精度问题。假设计算的是1.23e0-1.22e0,结果是0.01e0。尽管这个结果数学上等价于1.00e-2,但是从后一种形式我们可以看出,后两位数字都正好是0。遗憾的是,我们的计算结果只有一个有效数字,就在百分之一的位置。事实上,有些FPU或者浮点软件包可能会在低端位插入一些随机的数字。这就是有限精度运算的第二个重要规则:在对符号相同的两个数做减法或者符号不同的两个数做加法时,结果的精度可能比所用的浮点格式所能支持的精度要小。
    乘法与除法没有这些问题,因为在运算之前不需要调整阶码。需要做的就是将阶码相加并将尾数相乘(或者将阶码相减并将尾数相除)。只做乘法与除法不会产生特别差的结果,但是,它们会加剧操作数中已经存在的精度错误。例如,如果本应该作的是将1.24e0乘2,但是作了1.23e0乘2,那么结果就变得更加的不精确了。这引出了有限精度运算的第三个重要规则:在进行一系列涉及到加法,减法,乘法,与除法的计算时,尽量先做乘法与除法。
    通常,通过使用标准的代数变换,可以将一个算式重新安排,让它首先进行乘法和除法运算。例如需要计算:x * ( y + z )这里先将y和z相加,然后将得到的和与x相乘。但是,如果先把该算式变换为如下形式,结果的精度会稍高一些:x * y + x * z这样就可以先进行乘法操作。
    同样的,乘法和除法也有自身的问题。当对两个非常大或者非常小的数做乘法时,很有可能会出现溢出或下溢。同样,在将一个很小的数除以一个很大的数,或者将一个很大的数除以一个很小的数时,也会出现这种情况。这就是在做乘法或除法时应该尽量遵循的第四条规则:在对一组数做乘法或除法时,尽量对数量级相对一样的数做乘法与除法。
    浮点数的比较运算是很不安全的。由于任何浮点数运算都是不精确的,绝对不要比较两个浮点数看它们是否相等。使用二进制浮点数格式,产生数学意义上同样结果的不同运算所产生的结果值可能会在最低有效位上有所不同。例如:1.31e0 + 1.69e0应该得到3.00e0,同样,1.50e0 + 1.50e0也应该得到3.00e0。但是,如果你比较(1.31e0 + 1.69e0)与(1.50e0 + 1.50e0),你可能会发现它们的和并不相等。判断两个数相等在且仅在两个操作数的所有位相同的情况下才会得到肯定的答案。由于两个看起来等效的浮点运算不是必定会产生完全相等的结果,直接做是否相等的比较可能会产生错误的结果,即使数学上这种比较是完全正确的。
    比较浮点数是否相等的标准方法是首先确定在比较中允许的误差,然后看一个数是否在另一个数的此误差范围内,可以采用如下方式:
if ( ( value1 >= ( value2 - error ) ) && ( value1 <= ( value2 + error ) ) ) {}
而更高级的方法如下:
if ( abs( value1 - value2 ) <= error ) {}
挑选误差值的时候一定要小心,它一定要比计算中所会出现的最大误差要大一点点。具体的值取决于特定的浮点数格式以及比较数的数量级,最后一个规则是:在比较两个浮点数是否相等时,要做的是计算这两个数的差,看它是否小于某个很小的误差值。
    其实使用浮点数还有其他一些问题,总之浮点数运算不同于实数运算,如果不认真处理有限精度运算中的不准确性,它就会给你带来麻烦的,以后有时间再说说IEEE浮点数格式吧。

你可能感兴趣的:(算法学习)