2. 数值表示与精度基础

在讨论“计算精度与运算速度的权衡”之前,必须先回答一个更底层的问题:计算机用什么方式表示一个实数,这种表示方式本身会带来多大的误差?很多看似“算法不准”的现象,根源其实在数值表示环节就已经埋下。本节围绕定点数与浮点数两类表示方法,剖析它们的范围与精度特性,并追踪舍入误差、截断误差与累积误差的产生路径。

一、定点数:位置固定,精度均匀

定点数把小数点位置在二进制位串中固定下来。常见做法是采用 Q 格式,记作 Qm.n:用 m 位表示整数部分(含 1 位符号位),n 位表示小数部分,总位宽为 m+n。例如 Q1.15 使用 16 位,其中 1 位符号、15 位小数,可表示的范围约为 [-1, 1-2⁻¹⁵],最小分辨率(即最低有效位 LSB 的权重)为 2⁻¹⁵ ≈ 3.05×10⁻⁵。

定点数的关键特性是绝对精度均匀:无论数值落在 0.001 还是 0.9 附近,相邻可表示值之间的间隔都是同一个 LSB。这使得定点数非常适合表示物理量范围已知且固定的场景,例如音频采样、传感器读数、嵌入式控制中的系数。代价是动态范围窄:Q1.15 无法表示 2.0,一旦运算结果超出范围就会溢出回绕,产生完全错误的数值而非“略微不准”。

定点乘法需要特别处理位宽增长:两个 Q1.15 相乘得到 Q2.30 的中间结果,必须右移 15 位并做饱和或舍入才能回到 Q1.15,这一步正是定点误差的主要来源之一。

二、浮点数与 IEEE 754:范围宽,精度相对

IEEE 754 单精度(binary32)用 32 位组织为三部分:1 位符号 s、8 位阶码 e、23 位尾数 f,数值为 (-1)ˢ × 1.f × 2^(e-127)(规格化数)。双精度(binary64)则为 1+11+52 位。阶码让小数点“浮动”,因此浮点数拥有极宽的动态范围:单精度规格化数约覆盖 1.18×10⁻³⁸ 到 3.40×10³⁸。

但浮点数的精度是相对精度。规格化数的有效数字固定为 24 位(含隐含的 1),机器 epsilon 为 2⁻²³ ≈ 1.19×10⁻⁷,意味着任意数值的相对误差量级约为 10⁻⁷。数值越大,相邻可表示值的绝对间隔越大:在 1.0 附近间隔约 1.19×10⁻⁷,而在 10⁶ 附近间隔已达约 0.0625。这解释了一个经典现象:0.1 + 0.2 != 0.3,因为 0.1、0.2、0.3 在二进制下都是无限循环小数,存储时已被舍入。

判断两个浮点数是否相等,应比较差值是否小于设定容差,例如 fabs(a-b) < 1e-6(单精度)或 1e-12(双精度),而不是直接用 ==。

三、三类误差的来源与传播

舍入误差发生在“无法精确表示”时:把一个实数映射到最近的可表示值,误差不超过半个 LSB(就近舍入到偶数)。截断误差则来自主动丢弃低位,例如定点乘法右移时直接截掉低 15 位,误差恒偏向一个方向,长期累积会形成系统性偏差;改用“加半个 LSB 再截断”即可把偏差转为无偏的舍入。累积误差是前两者在迭代或求和中的放大:对 10⁶ 个量级相近的数做单精度累加,当累加和远大于单个加数时,小加数会被“吃掉”,结果可能偏离真值百分之几。缓解手段包括改用双精度累加、Kahan 补偿求和或分块求和。

数值表示结构与误差产生路径 定点数 Q1.15(16 位) 符号 1 小数部分 15 位(LSB = 2⁻¹⁵ ≈ 3.05×10⁻⁵) 范围 ≈ [-1, 1),绝对精度处处相同;超范围即溢出回绕 浮点数 binary32(32 位) s 1 阶码 e 8 尾数 f 23(隐含 1) 值 = (-1)ˢ × 1.f × 2^(e-127);相对精度约 10⁻⁷,数值越大绝对间隔越大 误差来源 舍入误差 ≤ 0.5 LSB,无偏 截断误差 丢弃低位,单向偏差 累积误差 迭代/求和放大,大数吃小数 对应缓解手段 • 舍入:就近舍入到偶数,避免直接截断 • 截断:加半个 LSB 再截断,把单向偏差转为无偏 • 累积:双精度累加、Kahan 补偿求和、分块求和 • 溢出:定点运算后做饱和处理,而非回绕

四、实验观察:同一算式,不同精度

下面用一段可复现的 C 代码观察精度差异。实验对象是调和级数部分和 S(n)=Σ1/k,分别用单精度、双精度和 Kahan 补偿求和计算 n=10⁶ 项,并与双精度结果对比。以下数值为按 IEEE 754 规则推算的典型示例结果,实际运行会因编译器和舍入模式略有差异。

#include <stdio.h>
int main(void) {
    const int N = 1000000;
    float  sf = 0.0f;
    double sd = 0.0, c = 0.0, sk = 0.0;
    for (int k = 1; k <= N; k++) {
        sf += 1.0f / k;                 /* 单精度直接累加 */
        sd += 1.0  / k;                 /* 双精度直接累加 */
        double y = 1.0 / k - c;         /* Kahan 补偿求和 */
        double t = sk + y;
        c = (t - sk) - y;
        sk = t;
    }
    printf("float  = %.9f\n", sf);      /* 示例约 14.392651557 */
    printf("double = %.9f\n", sd);      /* 示例约 14.392726723 */
    printf("kahan  = %.9f\n", sk);      /* 示例约 14.392726723 */
    return 0;
}

观察要点有三:其一,单精度结果与双精度相差约 7.5×10⁻⁵,相对误差约 5×10⁻⁶,明显大于单精度的机器 epsilon,说明误差已被累加放大;其二,Kahan 求和与双精度直接累加几乎一致,证明补偿项有效抑制了“大数吃小数”;其三,若把 N 增大到 10⁸,单精度结果会进一步偏离,甚至出现增长停滞——因为后续的 1/k 已小于当前累加和的半个 LSB,加进去等于没加。

五、选型判断:先看范围,再看精度

工程上可按以下顺序决策:第一步确认物理量的动态范围,若最大值与最小值之比超过约 10⁴ 且需要统一处理,优先浮点;若范围固定且窄(如归一化到 [-1,1] 的音频),定点更省资源且精度均匀。第二步估算允许误差:定点看绝对误差是否小于 1 个 LSB 的若干倍,浮点看相对误差是否小于 10⁻⁶(单精度)或 10⁻¹⁵(双精度)。第三步检查运算链长度:长迭代、大求和、病态矩阵求解应提升到双精度或引入补偿算法,而不是盲目相信单精度的“够用”。

不要用“单精度有 7 位有效数字”直接推断结果有 7 位可信数字。在减法相消(两个相近数相减)和长链累加中,有效数字会大幅损失,必须结合具体算式分析误差传播。