文章

【信奥业余科普】C++ 的奇妙之旅 | 13:为什么 0.1+0.2≠0.3?——解密“爆int”溢出与浮点数精度的底层原理

在第 11 篇文章中,我们提到 intdouble 等数据类型本质上是向系统申请固定大小的内存空间。在第 12 篇文章中,我们看到整数除法(如 5 / 2)会舍弃小数部分,仅保留整数 2

这些现象的根本原因在于:计算机内部依靠晶体管的高低电平处理数据,只能理解由 01 组成的二进制。 今天,我们将探讨不同数据类型是如何在二进制架构中存储的,并解释为什么计算机在处理简单的小数运算(如 0.1 + 0.2)时会出现精度偏差。

写在前面的话:这是一系列专为对信奥(信息学奥赛)感兴趣的中小学生及家长朋友们准备的科普文章。笔者受自身学识所限,文中若存在不严谨之处,还望各位读者指正。

本系列文章往期回顾:

第二部分 【C++的奇妙之旅】


一、 二进制整数的存储与极限

我们声明变量 int a = 21; 时,系统会为其分配 4 个字节(Byte)的内存。

在计算机底层:

  • 比特(Bit) 是最小的存储单位,只能表示 01
  • 字节(Byte) 由 8 个比特组成。
  • 因此,一个 4 字节的 int 类型占据 32 个比特位(32 个 0 或 1)。

数据溢出(爆 int)的成因

由于容量固定为 32 位,int 能表示的数值存在上限。 此外,为了区分正负数,C++ 底层将 32 位中最左侧的一位用作 “符号位(Sign Bit)”(通常 0 代表正数,1 代表负数)。

真正用于存储数值本身的只剩下 31 位。 当这 31 位全为 1 时,达到 int 所能表示的最大正数,即 $2^{31}-1$,约等于 2147483647(常被称为 21 亿)。

如果计算过程超出了这个范围(例如 20亿 + 10亿),进位的数据会覆盖原本用于表示正负的符号位。这导致原本的正数在底层被错误解析为一个负数。

在信奥比赛中,这种现象被称为 “数据溢出(Overflow)”或俗称“爆 int”。为了避免这种情况,当预期中间计算结果会超过 21 亿时,应当使用占用 64 位的 long long 类型。

背景延展:为什么默认是 4 字节? 你可能会想,既然 4 字节容易引发溢出,为什么设计师不干脆把所有数字都默认分配极大的空间(比如 8 字节)? 这是出于存储空间分配与硬件处理效率的平衡。一方面,如果不分青红皂白地给哪怕很小的数字(比如年龄 12、月份 9)都分配 8 字节空间,当处理海量数据时,将造成惊人的内存浪费。另一方面,在很长一段历史时期内,主流计算机中央处理器(CPU)内部的运算通道与寄存器宽度恰好是 32 位。让 int 的大小(4 字节=32位)刚好吻合处理器的原生硬件吞吐标准,能最大程度地发挥处理器的物理运算性能。这种出于底层效率的精打细算,也是 C门类语言的核心哲学。

二、 浮点数的存储与精度丢失

与整数的直接二进制转换不同,计算机存储小数(浮点数,如 double 类型)时使用了类似科学记数法的设计。

以 64 位(8 字节)的 double 为例,其内部的 64 个比特位被精细划分为三个主要部分(这遵循一种名为 IEEE 754 的国际业界标准):

  1. 符号位(1 位):记录数字的正负。
  2. 指数位(11 位):记录放大或缩小的倍数层级(即 $2$ 的多少次方)。
  3. 尾数位(52 位):记录实际数字内容的有效数字序列。

实际原理举例(以十进制的 6.5 为例):

这种结构本质上是“科学记数法”在计算机内部的变体。要将十进制的简单小数 6.5 存入 double 内存中,底层会执行以下拆解与映射:

首先,将十进制 6.5 转化为二进制数:

  • 整数部分的 6 采用基础的“除 2 取余法”,转换为二进制的 110
  • 小数部分的 0.5 采用“乘 2 取整法”(即将小数不断乘以 2,依次提取每次结果的整数部分),计算过程为 $0.5 \times 2 = 1.0$。提取出整数的 1 后,剩余小数变为 0,计算结束。所以 0.5 在二进制下是 0.1
  • 两者拼合,十进制 6.5 在二进制下的形态就是 110.1

接着,机器将其转换为规范化的二进制科学记数法形式:小数点向左移动两位,使得最左侧只保留唯一的一个 1。变换后的数值成为了 $1.101 \times 2^2$。

最后,系统将这个分解成果塞入 double 的 64 位三段式格子体系中:

  • 符号位(1 位):因为 6.5 是正数,此格填入 0(如果原数是负数则这里填 1)。
  • 指数位(11 位):刚才算出的实际幂次指数是 2。在 IEEE 754 工业标准为了兼容各种正负次方的幂,设定了一个固定的偏移值 1023。所以向内存实际填入的偏移指数等于 $1023 + 2 = 1025$,它转换为 11 位的二进制就是 100 0000 0001
  • 尾数位(52 位):由于任何规范化后的二进制数最左侧固定都是 1,为了节约仅剩的一丝空间,底层规定干脆把那个最开始的 1 隐藏不存。因此系统将只截取小数点身后的核心比特流 101 存放进此区域,并在其后跟随填充巨量个 0 以补齐整整 52 个位置。

通过这种将数据强行切割分离后存储,读取时再自动组装拼接的工程机制,计算机得以使用同一套 64 位空间,去表示各种庞大天体或是微小细胞的复杂数值。 然而,这种拼接折中结合二进制小数自身的转化特性,带来了另一个无法避免的核心痛点——精度丢失(Precision Loss)

无限循环小数的截断

在十进制中,像 1 / 3 这样的分数无法被精确表示,会成为无限循环小数 0.3333...。 同理,在二进制系统中,许多十进制下看似简单的小数(例如 0.1),如果使用前面介绍的“乘 2 取整法”去进行转换,就会陷入一个永远无法让小数归零的数学循环:

  • $0.1 \times 2 = 0.2$ (提取整数 0,保留小数 0.2 继续算)
  • $0.2 \times 2 = 0.4$ (提取整数 0,保留小数 0.4 继续算)
  • $0.4 \times 2 = 0.8$ (提取整数 0,保留小数 0.8 继续算)
  • $0.8 \times 2 = 1.6$ (提取整数 1,保留小数 0.6 继续算)
  • $0.6 \times 2 = 1.2$ (提取整数 1,保留小数 0.2 —— 注意,此时剩余小数又变回了 0.2 !)
  • $0.2 \times 2 = 0.4$ (提取整数 0,数学计算进入无休止的设计死循环…)

因此,表面上看似短小干净的十进制 0.1,在转换至二进制体系时,实际上变成了一串无限绵延的循环小数 0.00011001100110011...

由于 double 的尾数部分长度固定,计算机无法存储无限位数的二进制数。当达到存储极限时,硬件会直接截断多余的部分。 因此,计算机中存储的 0.1 实际上是一个受到截断后、存在微小偏差的近似值。

由于基础数值已经存在微小误差,当进行算术运算时,误差通常会被进一步显现。例如执行以下代码:

1
2
3
4
5
6
double num1 = 0.1;
double num2 = 0.2;
double result = num1 + num2;

// 在大多数系统中,result 的实际值并不严格等于 0.3。
// 而是类似于 0.30000000000000004,出现细微偏差。

三、 实战:如何安全地判断浮点数相等

由于浮点数存在截断误差,在编程中直接使用双等号(==)来判断两个 double 是否完全相等是不安全的。

例如以下尝试(判断语法将在下一篇文章中详述):

1
2
3
4
5
6
7
double a = 0.1 * 3;     // 常识理论为 0.3
double b = 0.3;

// 如果使用普通的严格判断:
if (a == b) {           // 极大概率执行结果为 false,条件不成立
    std::cout << "两者完全相等!" << std::endl;
}

因为经过运算后,两者在底层的二进制截断残留值可能存在微小差异,严格匹配 64 位数据时会判定为不相等。

标准的处理方案:引入容差(Epsilon)

在算法编写中,处理浮点数比较的标准方法是引入一个极小的容差变量(Epsilon 简称 eps。 我们不再要求两个浮点数值严格相同,而是计算两者的绝对差值。只要差值小于这个极小的阈值(例如 $10^{-9}$ 即 1e-9),我们就认为它们在逻辑上是相等的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
#include <cmath> // 引入数学库,以便使用求绝对值的 fabs() 函数

int main() {
    double a = 0.1 * 3;
    double b = 0.3;

    // 设定容差阈值 eps,1e-9 代表 0.000000001
    double eps = 1e-9;

    // 计算两者的差值绝对值,并判断是否小于容差
    if (std::fabs(a - b) < eps) {
        std::cout << "在容错范围内,a 和 b 相等!" << std::endl;
    } else {
        std::cout << "a 和 b 存在实质性差异!" << std::endl;
    }

    return 0;
}

结语

在编写算法时,理解底层数据存储规律十分必要。这要求我们在编码前合理预估数据规模,通过选择 intlong long 来规避整型溢出;同时也要求我们在处理小数运算时,了解浮点数精度丢失的客观物理特性,使用容差来进行安全比较。

你在上方的示例代码中可能已经注意到,我们使用了一种名为 if () { ... } else { ... } 的结构。这种语法打破了程序默认单向顺序向下执行的规则,使得程序能够根据计算结果产生分支并做出不同的处理。

在下一篇文章中,我们将正式介绍控制流程语句,详细拆解能让程序具备基础决策能力的工具——if-else 条件判断语句,及其背后的运行原理。

所有代码已上传至Github:https://github.com/lihongzheshuai/yummy-code

GESP 学习专题站:GESP WIKI

"luogu-"系列题目可在洛谷题库进行在线评测。

"bcqm-"系列题目可在编程启蒙题库进行在线评测。

欢迎加入Java、C++、Python技术交流QQ群(982860385),大佬免费带队,有问必答

欢迎加入C++ GESP/CSP认证学习QQ频道,考试资源总结汇总

欢迎加入C++ GESP/CSP学习交流QQ群(688906745),考试认证学员交流,互帮互助

GESP/CSP 认证学习微信公众号
GESP/CSP 认证学习微信公众号
本文由作者按照 CC BY-NC-SA 4.0 进行授权