关于 C 语言浮点数 float 浮点数舍入错误问题

本贴最后更新于 824 天前,其中的信息可能已经沧海桑田

关于 C 语言浮点数 float 浮点数舍入错误问题

背景

《C Primer Plus》 3.4.6 float、double 和 long double 小节中浮点数舍入错误问题。

代码:

/* float_rounding_error.c -- 实验浮点数舍入错误问题 */
#include <stdio.h>

int main(void)
{
  float a, b;

  b = 2.0e20 + 1.0;
  a = b - 2.0e20;

  printf("%f \n", a);

  return 0;
}
``

输出如下:

0.000000  <-- Linux系统下的老式gcc
-13584010575872.000000  <-- Turbo C 1.5
4008175468544.000000  <-- XCode 4.5、Visual Studio 2012、当前版本的gcc

问题

  • 为什么输出结果不等于 1?

  • 需要具备哪些知识点才能理解这个问题出现的原因?

    1. IEEE 754 标准。
    2. C 标准 即 float.h 头文件。
    3. 保存浮点数的原理。

书中给出的解释:

得出这些奇怪答案的原因是,计算机缺少足够的小数位来完成正确的运算。2.0e20 是 2 后面有 20 个 0。如果把该数加 1,那么发生变化的是第 21 位。要正确运算,程序至少要储存 21 位数字。而 float 类型的数字通常只能储存按指数比例缩小或放大的 6 或 7 位有效数字。在这种情况下,计算结果一定是错误的。另一方面,如果把 2.0e20 改成 2.0e4,计算结果就没问题。因为 2.0e4 加 1 只需改变第 5 位上的数字,float 类型的精度足够进行这样的计算。

结论

  1. 因为保存单精度浮点数的内存大小为 32 位,其中表示有效数字的尾数部分为 23 个比特,最多表示 10 进制下的 7 位数。所以不管是 C 标准还是 IEEE 754 内的定义,都是只保证从第一个非 0 数字开始往后数 7 位数字的精度。例如:123.456789(只保证 1,2,3,4,5,6,7 的精度)
  2. C 标准定义 10 进制下的精度为 6 是因为计算机需要把数值转换成为 2 进制的数值保存,而 2 进制下的小数队列与 10 进制下的队列不是一一对应的。2 进制小数能保证完整表示从 0 到 9 的只有 10 进制下的小数部分第 6 位。

解析

  • 2.0e20 是 2 后面有 20 个 0。如果把该数加 1,那么发生变化的是第 21 位。要正确运算,程序至少要储存 21 位数字。

    2.0e20 + 1 = 2.00000000000000000001e20(要这么保存才能保持精度)

  • 而 float 类型的数字通常只能储存按指数比例缩小或放大的 6 或 7 位有效数字。

    有效数字(维基百科):

    有效数字指科学计算中用以表示一定长度浮点数精度的那些数字。一般指一个用小数形式表示的浮点数中,从第一个非零的数字算起的所有数字,因此,1.24 和 0.00124 的有效数字都有 3 位。并且在取有效数字时一般会遵循四舍五入的进位规则。例如取 1.23456789 为三位有效数字后的数值将会是 1.23,而取四位有效数字后的数值将会是 1.235。

    为什么是 6 或 7 位?

    先看看 C 语言采用的 IEEE 754 标准

    IEEE 二进制浮点数算术标准(IEEE 754):

    Generalfloatingpointfrac.png
    二进制浮点数是以符号数值表示法的格式存储——最高有效位被指定为符号位(sign bit);“指数部分”,即次高有效的 e 个比特,存储指数部分;最后剩下的 f 个低有效位的比特,存储“有效数”(significand)的小数部分(在非规约形式下整数部分默认为 0,其他情况下一律默认为 1)。

    float 类型也就是单精度二进制小数,使用 32 个比特存储。

    S Exp Fraction
    1 8 23 位长
    31 30 至 23 偏正值(实际的指数大小 +127) 22 至 0 位编号(从右边开始为 0)

    S 为符号位,Exp 为指数字,Fraction 为有效数字。 指数部分即使用所谓的偏正值形式表示,偏正值为实际的指数大小与一个固定值(32 位的情况是 127)的和。采用这种方式表示的目的是简化比较。因为,指数的值可能为正也可能为负,如果采用补码表示的话,全体符号位 S 和 Exp 自身的符号位将导致不能简单的进行大小比较。正因为如此,指数部分通常采用一个无符号的正数值存储。单精度的指数部分是 −126~+127 加上偏移值 127,指数值的大小从 1~254(0 和 255 是特殊值)。浮点小数计算时,指数值减去偏正值将是实际的指数大小。

    尾数部分占 23 个比特(其实位数隐含了整数部分 1,这 23 位是小数部分。但这里可以先忽略)。需要知道 23 位比特所能表达的最大值在 10 进制下至少需要几位数。

    • 可以根据公式 N=b^n-1(求在 b 进制下 n 个位数所能表达的最大数值)得出 2^23-1=8388607
    • 再根据 n=log(b)N (求在 b 进制下表达数值 N 至少需要的位数)得出 log8388607≈6.9 向上取整得 7 位。23 位比特所能表达的最大值在 10 进制下至少需要 7 位数。

    结论:

    也就是说 23 位比特所能表达的最大数也就只能到 7 位。这正好就能解释“float 类型的数字通常只能储存按指数比例缩小或放大的 6 或 7 位有效数字”。

    验证:

    /* significant_figures.c -- 单精度浮点类型的有效数字 */
    #include <stdio.h>
    
    int main(void)
    {
      float a, b, c, d, e, f;
    
      a = 1234567;
      b = 1234567.89;
      c = 1234.56789;
      d = 123456789.7654321;
      e = 1.1234567;
      f = 0.123456;
    
      printf("a: %f \n", a);
      printf("b: %f \n", b);
      printf("c: %f \n", c);
      printf("d: %f \n", d);
      printf("e: %f \n", e);
      printf("f: %f \n", f);
    
      return 0;
    }
    

    输出如下:

    a: 1234567.000000
    b: 1234567.875000 
    c: 1234.567871
    d: 123456792.000000
    e: 1.123457
    f: 0.123456
    

    可以看到在有效数字超出 7 位以后就失去了精度。

    为什么默认只展示小数点后 6 位?

    C 标准定义 FLT_DIG 10 进制的精度位数 为 6,也就是小数点后 6 位

    C 标准函数库中的头文件 float.h

    /* float_header_file.c -- float.h是C标准函数库中的头文件 */
    #include <stdio.h>
    #include <float.h>
    
    int main(void)
    {
      printf("The precision of float = %d\n", FLT_DIG );
      return 0;
    }
    

    输出如下:

    The precision of float = 6
    

    那为什么会定义为小数点后 6 位而不是 7 位或 8 位呢?

    浮点数的精度
    原因在于二进制小数与十进制小数没有完全一一对应的关系,二进制小数相比十进制小数来说,是离散而不是连续的,我们来看看下面这些数字:

    二进制小数 十进制小数
    2^-23 1.00000011920928955078125
    2^-22 1.0000002384185791015625
    2^-21 1.000000476837158203125
    2^-20 1.00000095367431640625
    2^-19 1.0000019073486328125
    2^-18 1.000003814697265625

    这里只需要关注 F,上面列出了 1.xxx 这类浮点数中的 6 个最小的二进制小数,及其对应的十进制数。可以看到使用二进制所能表示的最小小数是 1.00000011920928955078125,其次是 1.0000002384185791015625,这两个数之间是有间隔的,如果想用二进制小数来表示 8 位有效数(只算小数部分,小数点前面的 1 是隐藏的默认值)1.00000002、1.00000003、1.00000004......这些数是无法办到的,而 7 位有效数 1.0000001 可以用 2-23 来表示,1.0000002 可以用 2-22 来表示,1.0000003 可以用 2-23+2-22 来表示。从这个角度来看,float 型所能精确表示的位数只有 7 位,7 位之后的数虽然也是精确表示的,但却无法表示任意一个想表示的数值。
    但还是有一些例外的,比如说 7 位有效数 1.0000006 这个数就无法用 F 表示,这也表明二进制小数对于十进制小数来说相当于是离散的,刚好凑不出 1.0000006 这个数,从这点来看 float 型所能精确表示的位数只有 6 位。因此 float 型的有效位数是 6-7 位,但这个说法应该不是非常准确,准确来说应该是 6 位,C 语言的头文件中规定也是 6 位。对于一个很大的数,例如 1234567890,它是由于指数 E 系数而被放大了的,但它的有效位仍然是 F 所能表示的 6~7 位有效数字。1234567890 用 float 表示后为 1234567936,只有高 7 位是有效位,后 3 位是无效的。int 型可以准确的表示 1234567890,而 float 浮点数则只能近似的表示 1234567890,精度问题决定了 float 型无法取代 int 型。

    结论:

    从上文中可知,二进制存储的 23 位小数部分,在 10 进制数的小数部分中能完整且精准表示的只能到第 6 位。我想这就是 C 标准定义 10 进制的精度位数为 6 的原因。

  • C

    C 语言是一门通用计算机编程语言,应用广泛。C 语言的设计目标是提供一种能以简易的方式编译、处理低级存储器、产生少量的机器码以及不需要任何运行环境支持便能运行的编程语言。

    85 引用 • 165 回帖 • 2 关注
  • 计算机基础理论
    2 引用 • 1 回帖
1 操作
Doss 在 2022-08-20 03:56:06 更新了该帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...