浮点数精度丢失问题

C#中的浮点数,分单精度(float)和双精度(double):

floatSystem.Single 的别名,介于 -3.402823e38 和 +3.402823e38 之间的32位数字,符合二进制浮点算法的 IEC 60559:1989 (IEEE 754) 标准;

doubleSystem.Double 的别名,介于 -1.79769313486232e308 和 +1.79769313486232e308 之间的64位数字,符合二进制浮点算法的 IEC 60559:1989 (IEEE 754) 标准;

我们知道,计算机只认识 0 和 1,所以数值都是以二进制的方式储存在内存中的。

(对于人脑和计算机哪个聪明,个人更倾向于选择人脑,计算机只是计算得快,而且不厌其烦而已!)

所以要知道数值在内存中是如何储存的,需先将数值转为二进制(这里指在范围内的数值)

根据 IEEE 754 标准,任意一个二进制浮点数 V 均可表示为:V = (-1 ^ s) * M * (2 ^ e)

其中 s ∈ {0, 1};M ∈ [1, 2);e 表示偏移指数。

以 198903.19(10) 为例,先转成二进制的数值为:110000100011110111.0011000010100011(2)(截取 16 位小数),采用科学记数法等于 1.100001000111101110011000010100011 * (2 ^ 17)(整数位是 1),即 198903.19(10) = (-1 ^ 0) * 1.100001000111101110011000010100011 * (2 ^ 17)。

整数部分可采用 "除2取余法",小数部分可采用 "乘2取整法"。

从结果可以看出,小数部分 0.19 转为二进制后,小数位数超过 16 位(我已经手算到小数点后 32 位都还没算完,其实这个位数是无穷尽的)

由于无法得到完全正确的数值,这里就引申出浮点数精度丢失的问题:

/* 程序段1 */
float num_a = 198903.19f;
float num_b = num_a / 2;
Console.WriteLine(num_a);
Console.WriteLine(num_b);

这段程序代码,我们预想中正确的结果应该是:198903.19 和 99451.595。

但结果居然是!!!原因下面将讲到 ...

这里介绍另一种转小数部分的方法,有兴趣可以看下:

假如结果要求精确到 N 位小数,那么只需要将小数部分乘以 2 的 N 次方(例如 N = 16,0.19 * (2 ^ 16),得到 12451.84)。

取整数部分(12451),按整数的方法转为二进制,得到 11000010100011,不足 N 位在高位用 0 补足。

结果 0.19 精确到 16 位后,用二进制表示为 0.0011000010100011。

可以看出,若是小数部分乘以 2 的 N 次方后,可以得到一个整数,那么这个小数可以用二进制精确表示,否则则不可以。

(原理很简单,根据二进制小数位转十进制的方法,反推回去就可以得到这个结果)

在内存中,float 和 double 的储存格式是一致的,只是占用的空间大小不同。

float 总共占用 32 位:

从左往右,第 1 位是符号位,占 1 位;第 2-9 位是指数位,占 8 位;第 10-32 位是尾数位,占 23 位。

double 总共占用 64 位,从左往右第 1 位也是符号位,占 1 位;第 2-12 位是指数位,占 11 位;第 13-64 位是尾数位,占 52 位。

其中,符号位(即上文的 s,下同),0 代表正数,1 代表负数。

对于 float,8位指数位的值范围为 0-255(10),由于指数(即上文的 e,下同)可正可负,而指数位的值是一个无符号整数。根据标准规定,储存时采用偏移值(偏移值为127)的方法,储存值为指数 + 127。例如 0111 0011(2) 表示指数 -12(10)((-12) + 127 = 115),1000 1011(2) 表示指数 12(10)(12+ 127 = 139)

{

  另外,IEEE 754 规定(同样适用于 double):

  当指数全为 0 时,如果尾数全为 0,表示 ±0(正负取决于符号位),如果尾数不全为 0,计算时指数等于 -126,尾数不加上第一位的1,而是还原为 0.xxxxxx 的小数,表示更接近 0 的小数;

  当指数全为 1 时,如果尾数全为 0,表示 ±无穷大(正负取决于符号位),如果尾数不全为 0,表示这不是一个数(NaN)。

  资料来自非规约形式的浮点数

}

同样的,对于 double,11位指数位,储存时采用的偏移值为 1023。

尾数位,由于所有数值均可以转换成 1.xxx * (2 ^ N)(此处暂时忽略精度问题),所以尾数部分只保存小数部分(最高位的 1 不存入内存,提高 1 个位的精度)

以 float 198903.19 为例,二进制为 1.100001000111101110011000010100011 * (2 ^ 17);

数值为正数,符号位是 0;

指数是 17,保存为 144(17 + 127 = 144),即 10010000(共 8 位,不足 8 位在高位用 0 补足)

小数位是 10000100011110111001100(截取 23 位)

最终得到:01001000 01000010 00111101 11001100,按字节倒序顺序,转为十六进制就是:CC 3D 42 48

float f_num = 198903.19f;
var f_bytes = BitConverter.GetBytes(f_num);
Console.WriteLine("float: 198903.19");
Console.WriteLine(BitConverter.ToString(f_bytes));
Console.WriteLine(string.Join(" ", f_bytes.Select(i => Convert.ToString(i, 2).PadLeft(8, '0'))));

同样的格式,double 198903.19 最终得到:01000001 00001000 01000111 10111001 10000101 00011110 10111000 01010010(最后两位结果为什么是 10 而不是 01,请参考浮点数的舍入,按字节倒序顺序,转为十六进制就是:52 B8 1E 85 B9 47 08 41

double d_num = 198903.19d;
var d_bytes = BitConverter.GetBytes(d_num);
Console.WriteLine("double: 198903.19");
Console.WriteLine(BitConverter.ToString(d_bytes));
Console.WriteLine(string.Join(" ", d_bytes.Select(i => Convert.ToString(i, 2).PadLeft(8, '0'))));

回到精度丢失的问题,由于小数位无法算尽,内存用截取精度的方式储存了转换后的二进制,这导致保存的结果并非是完全正确的数值。

看回 程序段1 的例子,

num_a 在内存中其实是保存为:01001000 01000010 00111101 11001100,换算成十进制就是:198903.1875;

num_b 在内存中其实是保存为:01000111 11000010 00111101 11001100,换算成十进制就是:99451.59375;

先看 num_b,由于 num_a 在内存中储存的值已经是不正确的,那么再利用其进行计算,得到的结果 99.9% 也会是不正确的。所以 num_b 的结果并不是我们想要的 99451.595。

然后为什么 198903.1875 会变成 198903.2,而 99451.59375 会变成 99451.595 呢?我们知道,内存中确实是储存了 198903.1875 和 99451.59375 这两个值,那么就只有可能是在输出的时候做了变动。其实这是微软做的小把戏,我们有句俗话说"以毒攻毒",大概就这个意思,既然储存的已经是不正确的数值,那么在输出的时候,会智能地猜测判断原先正确的数值是什么,然后输出猜测的那个值,说不定就真的猜中了呢!

(之前看过的一篇文章写的,忘了地址,大概就这个意思。如果不是因为这个原因,大家就当饭后娱乐吧。)

如果有写错的地方,请帮忙指正。谢谢 ...

原文地址:https://www.cnblogs.com/SugarLSG/p/3534248.html