浮点数的输入以及浮点数运算
写在前面
上一次我们讲解了IEEE的标准,还记得多少?
之前我提到过,有很多小数是二进制浮点数无法表示的,因此就难免会遇到舍入的问题.这一点其实在我们平时的计算当中会经常出现,就比如我们之前提到过的0.3,就无法使用浮点小数来准确表示.
我使用C#写了一个程序,打印出0.3的二进制表示,是这样的一个数字:0 01111101 00110011001100110011010.不信没关系,用我昨天说的那个公式计算一下啊.这个二进制数大概是多少,它的阶码在偏置之后的值为-2,它的尾数位在加1之后为1+1/8+1/16+1/128+1/256=1.19921875.后面还有有效位,不过我们只是大概的计算一下,不用很精确,结果大概是0.29多.(我说这个玩意就是想证明一件事,十进制的小数无法用二进制来准确表示).
如何把带小数的十进制转换成二进制数?
让大哥教你们一招好办法,怎么简单的转换,以及怎么判别能否用给定的位数精确表示一个10进制小数。
以0.635为例,这个数能用二进制小数表示么,能的话需要多少位呢。
因为0.635*256=162.56,注意小数部分不为0。所以8位二进制小数无法精确表示。再看0.635*65536=41615.36。小数部分仍不为0,所以16也不能精确表示。
到这里相信大家也看出点什么了,看一个十进制小数能否用给定的n位二进制小数表示,就用那个十进制乘以2的n次方,看小数部分是否为零,是的话就能用n位精确表示,否的话就不能。
其实楼上的52.63可以转换的,8位表示小数部分,把0.63*256也就是2的8次方得到161.23,23不要,把161化成二进制表示为10100001,再连上52的,就成了1010010.10100001,这里用8位表示小数是表示不尽的,因为0.63*256=161.23,小数部分不为零。
浮点数的舍入
在我们平时使用的十进制当中,我们一般会对一个无理数或者有位数限制的有理数进行舍入时,大部分时候会采取四舍五入的办法,这算是一种比较符合我们期望的方式.
不过针对浮点数来说,我们的舍入方式会更丰富一些.一共有四种方式,分别是向偶数舍入,向零舍入,向上舍入,向下舍入.
这四种方式不难理解,其中向偶数舍入就是向最靠近的偶数舍入,比如将1.5舍入为2,将0.1舍入为0.而向零舍入则是向靠近零的值舍入,比如将1.5舍入为1,将0.1舍入为0.对于向上摄入来说,则是往大了(也就是正无穷大)摄入的意思,比如将1.5舍入为2,将-1.5舍入为-1.而向下舍入则与向上舍入相反,是向较小的值(也就是负无穷大)舍入的意思.
这里需要所明一点,除了向偶数舍入除外,,其他三种方式都会有明确的边界.这里的含义是指这三种方式舍入后的值x与舍入之前的值x’会有一个明确的大小关系,比如对于向上舍入来说,则一定有x<=x’.对于向下舍入来说,一定有|x|>=|x’|.
对于向偶数舍入来讲,它最大的作用是在统计时使用.向偶数舍入可以让我们在统计时,将舍入产生的误差平均,从而尽可能的抵消.而其他三种方式在这一方面都是有缺陷的,向上和向下舍入很明显会造成指的偏大或者偏小.对于向零舍入来说,如果全是正数的时候会造成结果偏小,全是负数会造成结果偏大.
通常情况下我们采取的舍入规则是在原来的值是舍入值的中间值时,采取向偶数舍入,在二进制中,偶数我们认为是末尾为0的数.而倘若不是这种情况的话,则一般会有选择性的使用向上和向下舍入,但总会向最接近的值舍入.其实这正是IEEE才去的默认的舍入方式,因为这种舍入方式总是企图向最近的值舍入.
比如对于10.10011这个值来讲,当舍入到个位数时,会采取向上舍入,因此此时的值为11.当舍入到小数点后1位时,会采取向下舍入,因此此时的值为10.1.当舍入到这个小数点后4位时,由于此时的10.10011舍入值的中间值,因此采取想偶数舍入,此时舍入后的值为10.1010.
程序当中的浮点数舍入
之前讲解了一堆舍入的方式,最终我们得出一个结论,就是IEEE标准默认的舍入方式,是企图向最近的值舍入.
之前我们已经详细的解释了IEEE标准中默认的舍入方式.但是估计还有人不是很明白,没关系,咱们看看具体的案例.
在看案例之前,暂满需要先了解一些中间值的概念.中间值就是指的,比如1.1(二进制)这个数字,假设要舍入到个位,那么他就是一个中间值,因为它处于1(二进制)和10(二进制)的中间,在这个时候将会采用向偶数舍入的方式.
public class Main{
public static void main(String[] args){
System.out.println("舍入前: 10.10011111111111111111101");
System.out.print("舍入后:");
printFloatBinaryString(2.62499964237213134765625f);
System.out.println();
System.out.println("舍入前: 10.10011111111111111111111");
System.out.print("舍入后:");
printFloatBinaryString(2.62499988079071044921875f);
System.out.println();
System.out.println("舍入前: 10.10011111111111111111101011");
System.out.print("舍入后:");
printFloatBinaryString(2.62499968707561492919921875f);
System.out.println();
System.out.println("舍入前: 10.10011111111111111111100011");
System.out.print("舍入后:");
printFloatBinaryString(2.62499956786632537841796875f);
System.out.println();
System.out.println("舍入前: -10.10011111111111111111101");
System.out.print("舍入后:");
printFloatBinaryString(-2.62499964237213134765625f);
System.out.println();
System.out.println("舍入前: -10.10011111111111111111111");
System.out.print("舍入后:");
printFloatBinaryString(-2.62499988079071044921875f);
System.out.println();
System.out.println("舍入前: -10.10011111111111111111101011");
System.out.print("舍入后:");
printFloatBinaryString(-2.62499968707561492919921875f);
System.out.println();
System.out.println("舍入前: -10.10011111111111111111100011");
System.out.print("舍入后:");
printFloatBinaryString(-2.62499956786632537841796875f);
System.out.println();
}
public static void printFloatBinaryString(Float f){
char[] binaryChars = getBinaryChars(f);
for (int i = 0; i < binaryChars.length; i++) {
System.out.print(binaryChars[i]);
if (i == 0 || i == 8) {
System.out.print(" ");
}
}
System.out.println();
}
public static char[] getBinaryChars(Float f){
char[] result = new char[32];
char[] binaryChars = Integer.toBinaryString(Float.floatToIntBits(f)).toCharArray();
if (binaryChars.length < result.length) {
System.arraycopy(binaryChars, 0, result, result.length - binaryChars.length, binaryChars.length);
for (int i = 0; i < result.length - binaryChars.length; i++) {
result[i] = '0';
}
}else {
result = binaryChars;
}
return result;
}
}
代码从别人那里copy的,JAVA这么火,你不会不了解吧..反正我是不了解.
分析一下:上面一共有8次舍入,前四次是整数,后四次是负数.可以看出来对于负数来说,舍入后的位表示是一样的.只是最高位的符号位不同而已,因此这里就不再分析下面四个复数的舍入方式了,主要来看前四次舍入.
第一次和第二次对于末尾01和11的舍入,由于是中间只,因此全部采取向偶数舍入的方式,保证最低位为0.第三次由于比中间值大,,而数值又是整数,因此采取向上摄入的方式,第四次则比中间值小,数值同样也是整数,因此采取向下舍入的方式.
因为本屌最近在搞C#,使用了C#的代码演示了一遍,采用的是同样的舍入方式.
浮点数运算
在IEEE标准中,指定了关于浮点数的运算规则,就是我们将两个浮点数运算后的精确结果的舍入值,作为我们最终的运算结果.正是因为有了这一个特殊点,造成了浮点数运算中,很多运算不满足我们平时熟知的一些运算特性.
比如加法的结合律:a+b+c=a+(b+c),这是很简单的加法特性,但是浮点数不满足这一点,案例如下:
public static void main(String[] args){ System.out.println(1f + 10000000000f - 10000000000f); System.out.println(1f + (10000000000f - 10000000000f)); }
这一段代码会依次输出0.0和1.0,正是因为舍入而造成了这一误差.在第一条输出语句中,计算1f+1000000000f时,会将1这个有效数值舍入掉,而导致最终结果为0.0.而在第二个输出语句中10000000000f-10000000000f将先得到结果0.0,因此最终的结果为1.0,因此最终结果为1.0.
(JAVA代码在这里就证明了浮点数运算不满足结合律这一特性,我用了C#代码反而两个输出的结果一样呢?证明不了浮点数不满足结合律呢?我狠狠的给了自己一个大嘴巴子....)
有问题得解决啊,于是乎我开始了下面的扯犊子..
在使用JAVA的时候,
public class Test
{
public static void main(String[] args)
{
double x = 0.01;
double y = 0.09;
System.out.println(x + y);
}
}
为什么输出结果是0.09999999999999999而不是0.1啊?
奇怪的是当x,y改为float后,结果就等于0.1了,
更奇怪的是,如果把x,y分别改为float的0.01和0.04,在相加,结果居然是0.049999997,
这种浮点运算不精确的背后原理到底是什么呢?
可是我在使用C#的时候:
class Test
{
static void Main()
{
double x = 0.01;
double y = 0.09;
Console.WriteLine(x + y);
Console.ReadKey();
}
}
结果是0.1,为啥呢?
第一点,你要明确java里面32位的float对0.1在内存当中的表示是不精确的,不是你理解的那么100%精确,抛弃这个概念,产生这个误差的原因简单理解在32位的float的表达能力有限和计算机的二进制缘故吧,往下说也很复杂。
大体说下,float的0.1二进制形式是001111011 10011001100110011001101,根据符号位换算为10进制表达的值精确应该是这样计算 110011001100110011001101乘以2的负27次方,实际值是0.100000001490116119384765625
这样就产生了实际误差
这个误差对我们生活小打小闹没啥影响,但是对科学计算和银行这样的应用或者领域是致命的,因此要用Java银行以及科学计算会用java.math.BigDecimal提高精度,否则后果极其严重。
看一段C#代码:
float Fvar = 1f; double Dvar = (double)Fvar; Console.WriteLine("Float Var={0};Double Var={1}", Fvar, Dvar); float Flt = 0.9f; double Dbl = (double)Flt; Console.WriteLine("Float Var={0};Double Var={1}", Flt, Dbl);
即在C#中,当float转换为double时,若float数值不带小数,可得出正确值;但包含小数时,会得到一个近似的“超级”小数。
1、这是由于小数精度引起的,小数转换二进制是乘2取整的,但是对于那些永远也乘不完的数,比如你这个0.9,那么它会一直取下去知道位数满了。所以肯定是不准确的。
2、float转double需要补位,这样就有误差了。举个例子,float的精度可能为0.001,而double的精度可能为0.00001这样转换后可能会有0.001 - 0.00001的误差。
所以应该使用decimal
看代码:
float Fvar = 1f; double Dvar = (double)Fvar; Console.WriteLine("Float Var={0};Double Var={1}", Fvar, Dvar); float Flt = 0.9f; double Dbl = (double)Flt; decimal Dec = (decimal)Flt; Console.WriteLine("Float Var={0};Double Var={1};Decimal Var={2}", Flt, Dbl,Dec); Console.ReadLine();
其实相应的,浮点数的乘法也不满足结合律,就是说:a*b*c!=a*(b*c),同时也不满足分配律,即a*(b+c)!=a*b+a*c.浮点数失去了运算数的很多方面的特性,因此也导致很多优化手段无法进行,比如我们试图优化这样一段代码(以JAVA为例,因为JAVA的实现具有代表性).
/* 优化前 */
float x = a + b + c;
float y = b + c + d;
/* 优化后 */
float t = b + c;
float x = a + t;
float y = t + d;
对于优化前的代码来讲,进行了4次浮点运算,而优化后则是3次。然而这种优化是编译器无法进行的,因为可能会引入误差,比如就像前面的小例子中的结果0和1一样。编译器在此时一般是不敢进行优化的,试想一下,如果是银行系统的汇款或者收款等功能,如果编译器进行优化的话,很可能一不小心就把别人的钱给优化掉了。