C语言——浮点数的前世今生

一、小数的二进制表示

前情提要:我们可以用32位(1个字)来表示整型integer、无符号整型unsigned integer、4个字符、32位机器中的指针

问题:我们该如何表示这些数:实数(有理数、无理数,如3.1415926),非常大的数(6.02×10^23),非常小的数(6.02×10^-34),特殊值(∞,Nan)

为什么我们不能表示分数?因为我们的位(bit)只能表示2的非负幂,最小的单位也是2^0=1,即任意不同的数的差值一定大于等于1,因此我们无法表示分数中小于0的部分。

想要解决这个问题,我们先来看看十进制的分数是怎么表示的:

我们发现十进制的小数部分其实就是10的负数次幂,那么如果我们把这个思路沿用到二进制呢?

关于如何把十进制有理数转换为二进制,请参考除二取余法乘二取整法

我们可以让二进制的小数部分依次表示2^-1、2^-1、2^-3......以此类推,但是你会注意到,有一些小数我们是可以精确表示的:0.5、0.25、0.125......而有些数我们是无法精确表示的,如:0.3、0.6、0.7......

总之,一旦我们确定二进制小数的精度(即保留多少位二进制小数),那么二进制小数部分的值,必然是这个最小精度的整数倍。 eg:我们把精度保留到小数点后第6位,即2^-6=1/64,那么该二进制小数部分的值必然是1/64的整数倍,任何不满足的小数我们都不能精确表示。

但即便如此,只要我们的精度足够小,那么对数据的表示就越准确,在大多数场景下已经可以满足需求。

二、IEEE754标准

我们有了小数的二进制表示方法,现在的问题是具体该如何存储二进制小数,以及一些特别大的数(6.02×10^23)、特别小的数(6.02×10^-34)我们该如何处理?于是,IEEE754标准应运而生:

简单来说,IEEE754标准把比特位分为三个部分:符号位、指数域、尾数域。以4个字节32位的float类型为例:

1位用来作为符号位(0表示正数、1表示负数),8位用来作指数域,23位用来作尾数域。其中符号位表示该浮点数的正负,指数域表示浮点数的数量级,尾数域用来确定浮点数的精度。

首先,我们要把浮点数用科学计数法表示,比如3.14=11.0010001111010111000010......=1.10010001111010111000010......×2^1。科学计数法中小数点的位置是浮动的,这也是浮点数其名称的由来。

指数域=指数+偏移量(biased notation),float类型的偏移量为2^7-1=127=01111111,3.14的指数为1,所以3.14的指数域为10000000。为什么要加一个偏移量呢?其实是为了方便对数值进行比较,在比较数值时,我们可以先比较他们的符号位,0>1,再比较他们的指数域即数量级,最后再比较他们的尾数域。所以指数域的8位是无符号类型。例如指数为-127时,指数域为00000000,指数为128时,指数域为11111111,这样我们正好就可以表示数量级范围为2^-127 ~ 2^128(相当于10^-38 ~ 10^38)之内的数了(其实并不完全是,请参考:四、特殊数值的表示),可表示数的范围一下子扩大了许多。

而1.10010001111010111000010中的小数部分10010001111010111000010就是3.14的尾数域,为什么舍去了个位上的1,是因为科学计数法的个位一定为1,因此省略,我们把这个被省略的1称为隐含"1"。 float类型的尾数域有23位,也就意味着精度为2^-23≈10^-6.92,即float类型可以保证10进制的小数点后6位,并且表示的误差不超过2^-23,一般情况这个精度已经很高了

综上,3.14的float类型的实际存储为0 10000000 10010001111010111000010

那我们来写一段程序验证一下:

ini 复制代码
#include <stdio.h>

union FloatIntUnion {
    float f;
    unsigned int x;
};

int main()
{
    union FloatIntUnion u;
    u.f = 3.14; // 设置要查看的浮点数

    // 获取浮点数的二进制表示
    unsigned int binary[32];
    for (int i = 0; i < 32; i++) {
        binary[i] = (u.x >> (31 - i)) & 1;
    }
    for (int i = 0; i < 32; i++) {
        printf("%u", binary[i]);
        if(i == 0 || i == 8)    printf(" ");
    }
    printf("\n");

    return 0;
}

奇怪,看上去基本没问题,但是为什么最后一位是1而不是推算出的0呢?其实,浮点数类型在存储的时候还会进行一次舍入,例如3.14的二进制小数如果多保留几位小数11.001000111101011100001010001,发现尾数被舍弃的部分大于一半,因此选择进位(参考五、1.舍入模式),这就是为什么实际的3.14存储为0 10000000 10010001111010111000011

我们可以换一个数验证一下: 2.56同理推算出其二进制存储为0 10000000 01000111101011100001010001,尾数被舍弃的部分不到一半,因此不进位,结果为0 10000000 01000111101011100001010

ini 复制代码
#include <stdio.h>

union FloatIntUnion {
    float f;
    unsigned int x;
};

int main()
{
    union FloatIntUnion u;
    u.f = 2.56; // 设置要查看的浮点数

    // 获取浮点数的二进制表示
    unsigned int binary[32];
    for (int i = 0; i < 32; i++) {
        binary[i] = (u.x >> (31 - i)) & 1;
    }
    for (int i = 0; i < 32; i++) {
        printf("%u", binary[i]);
        if(i == 0 || i == 8)    printf(" ");
    }
    printf("\n");

    return 0;
}

验证成功

三、关于double

double的原理与float相同,只不过double为8个字节64位,划分为1个符号位、11位指数域、52位尾数域,因此,double表示的范围比float更大,精度更准。

四、特殊数值的表示

以下都按float举例

1.+0与-0

由于隐含"1"的存在,即使指数域和尾数域同时为0,也无法表示0,而是1×2^-127≠0。 因此,为了表示0,我们规定指数域和尾数域同时为0时就表示0而不表示1×2^-127,这是一种规范。

由于符号位的存在,我们可以发现0有两种:+0(0,00000000,00000000000000000000000)、-0(1,00000000,00000000000000000000000)

2.+∞、-∞与NAN

为了表示无穷大以及NAN(非数值,not a number),我们规定指数域最大即为11111111,并且尾数域为0时,表示无穷大,正负取决于符号位。而当指数域为11111111,尾数域不为0时,将这些数定义为NAN,NAN表示计算中的错误或不确定结果。例如,当试图执行一个非法的数学操作时,比如0除以0,结果将是NaN。这种情况下,NaN提供了一种方式来指示出现了错误。对NaN进行某些数学运算(如加减乘除)的结果通常也是NaN。这种定义使得在处理数据时可以更容易地处理特殊情况。

3.非规范化浮点数

这时我们还有一个问题:我们无法表示非常接近于0的数值,因为当指数域为0尾数域不为0时,由于隐含"1"的存在,这些数都必定大于等于1×2^-127,而无法表示非常接近于0的部分。

如图,a=1×2^-127,b=(1.000......0001)×2^-127=(1+2^-23)×2^-127=1×2^-127+2^-150, b-a=2^-150,而a-0=2^-127>>2^-150,由此形成了一个big gap。

为了解决这个问题,我们规定指数域为0而尾数域不为0的数不包含隐含"1",并且这些数的指数即使为0,但不再表示2^-127而是2^-126,这是为了与指数域为1尾数域为0的数1×2^-126接近。我们把这些数称为非规范化的浮点数,而指数域位于[1 ~ 254](即真实指数在-126 ~ 127之间)的数称为规范化的浮点数。

现在的gap是怎样的呢:

------绝对值最小的非规范化浮点数: a = 2^-149

------绝对值最大的非规范化浮点数: b = (2^-126 -- 2^-149)

------绝对值最小的规范化浮点数: c = 2^-126

c-b=2^-149, 非规范浮点数之间的精度=2^-149,a-0=2^-149

这样gap的设置就合理多了

下图为浮点数类型按指数域的分类:

五、浮点数的限制

1.三种特殊情况:溢出、下溢与舍入

(1)数值绝对值过大超出指数域的范围(abs(x)> 2^128):溢出

(2)数值绝对值过小超出指数域的范围(abs(x)< 2^-149):下溢

(3)数值的尾数部分超出了尾数限制,导致发生舍入。而最终结果可能与预期结果有所不同,这可能会产生一些意想不到的结果,特别是在高精度要求的计算中。

diff 复制代码
eg:假设我们有两个单精度浮点数,每个尾数有 23 位,符号位占 1 位,指数占 8 位。

现在考虑一个简单的浮点数加法:0.1 + 0.2。在十进制中,这个结果是 0.3,但是在二进制中,这个结果是一个无限循环小数。因此,在单精度浮点数的尾数范围内,计算的结果将被截断。

假设单精度浮点数的尾数只能容纳 4 位。0.1 和 0.2 的二进制表示如下:

-   0.1 的近似二进制表示:0.00011001100110011001101...
-   0.2 的近似二进制表示:0.00110011001100110011010...

在尾数只能容纳 4 位的情况下,浮点数的计算结果可能如下:

```
markdownCopy code
  0.0001 (0.1 的近似二进制表示)
+ 0.0011 (0.2 的近似二进制表示)
--------
  0.0100
```

在这个例子中,结果被截断,只保留了 0.0100,这相当于十进制中的 0.25,而不是预期的 0.3。这就是由于尾数位数限制而导致的舍入误差,使得最终结果与预期不同。

常见的舍入模式:

  • 舍入到最接近,连接到偶数:四舍五入到最接近的值;如果数字落在中间,则会四舍五入到最接近的具有偶数最低有效数字的值。
  • 朝+∞方向舍入:会将结果朝正无限大的方向舍入。
  • 朝-∞方向舍入:会将结果朝负无限大的方向舍入。
  • 朝0方向舍入:会将结果朝0的方向舍入。

其中,"舍入到最接近,连到偶数"是二进制浮点数的默认舍入模式。

对于不同的数量级,浮点数可以表示的最小单位的绝对值不同,数量级越大,最小单位越大,绝对误差就越大(相对误差不变)

把浮点数可以精确表示的数分配在数轴上,越靠近0的地方数越密集

(4)浮点数的加法不满足结合律(当数值差异较大时)

small + small + big ≠ small + big + small

这是因为精度有限,计算时需要先对齐指数,从而导致small数的关键数据都被舍弃了,导致计算结果失真

ps:乘法则没有这种缺陷,只要不发生溢出/下溢的话

让我们写段程序验证一下:

arduino 复制代码
#include <stdio.h>
#include <math.h>

int main()
{
    float big = pow(2, 60);
    float tiny = pow(2, -15);
    float bigneg = -big;
    if((big + tiny + bigneg) != (big + bigneg + tiny))
        printf("plus not equal\n");
    else
        printf("plus equal\n");
    if((big * tiny * bigneg) != (big * bigneg * tiny))
        printf("multiply not equal\n");
    else
        printf("multiply equal\n");
    return 0;
}

验证成功

相关推荐
阿巴~阿巴~26 分钟前
多源 BFS 算法详解:从原理到实现,高效解决多源最短路问题
开发语言·数据结构·c++·算法·宽度优先
CoderCodingNo1 小时前
【GESP】C++二级真题 luogu-b3924, [GESP202312 二级] 小杨的H字矩阵
java·c++·矩阵
刃神太酷啦2 小时前
堆和priority_queue
数据结构·c++·蓝桥杯c++组
Heris992 小时前
2.22 c++练习【operator运算符重载、封装消息队列、封装信号灯集】
开发语言·c++
----云烟----2 小时前
C/C++ 中 volatile 关键字详解
c语言·开发语言·c++
ChoSeitaku3 小时前
12.重复内容去重|添加日志|部署服务到Linux上(C++)
linux·c++·windows
挣扎与觉醒中的技术人3 小时前
网络安全入门持续学习与进阶路径(一)
网络·c++·学习·程序人生·安全·web安全
OTWOL4 小时前
【C++编程入门基础(一)】
c++·算法
宇寒风暖5 小时前
侯捷 C++ 课程学习笔记:内存管理与工具应用
c++·笔记·学习
Smile丶凉轩5 小时前
数据库面试知识点总结
数据库·c++·mysql