从绝对误差到相对误差:C++ 浮点数比较详解

image-20250112124528155

一般情况下,对于比较两个整数的大小关系,我们通常使用简单的比较运算符进行比较即可得出结论;

考虑下面的程序:

#include <iostream>
#include<cmath>
#include <algorithm>

int main()
{
    int a {1};
    int b {2};
    
    std::cout << std::boolalpha;
    std::cout << (a > b) << '\n';
    
    return 0;
}

浮点数比较存在的问题

执行上面的代码将会打印false,这是正确的,因为1确实小于2,没毛病,但对于浮点数类型的比较,这样的方法可能会出现问题。

考虑下面程序:

#include <iostream>

int main()
{
    constexpr double d1 {100.0 - 99.99};
    constexpr double d2 {10.0 - 9.99};
    
    if (d1 == d2)
        std::cout << "d1 == d2" <<'\n';
    else if (d1 > d2)
        std::cout << "d1 > d2" <<'\n';
    else
        std::cout <<"d1 < d2" << '\n';
    
    return 0;
}

数学上,这里变量d1,d2的值都应为0.01.但是程序打印了一个意料之外的结果: d1 > d2;

为什么呢?

如果你启用调试模式查看d1,d1的值,你可能会看到:

  • d1 = 0.010000000000005116
  • d2 = 0.0099999999999997868。
  • 两个数字都接近 0.01,但 d1 又略大于0.01,d2 略小于0.01。

使用任何关系运算符比较符点值都存在风险。这是因为浮点数并不精确,浮点操作数中的舍入误差可能会导致它们比预期略小或者略大。

也就是说,这导致它们脱离了关系运算符的作用域,在浮点数的比较时,不能使用这些关系运算符进行,或者说,不能完全的信任比较的结果。


浮点数的<和>

  1. 当对浮点数使用小于(<)、大于(>)、小于等于(<=)和大于等于(>=)运算符时,在大多数情况下(当操作数的值差异较大时),它们会产生可靠的结果。

“值差异较大” 的含义

  • 如果两个浮点数的数值差距大到超出了它们的精度限制(例如,1010.0000000001),比较结果通常是准确的。
  • 问题主要出现在两个浮点数非常接近(差值接近机器精度,比如 1.00000000011.0)时,这时浮点精度可能会导致不可靠的比较结果。

但是,如果操作数几乎相同,则这些运算符应被视为不可靠。例如,在上面的示例中, d1 > d2恰好产生true ,但如果数值误差朝不同的方向变化,也可能很容易产生false

如果在操作数值非常接近时得到错误答案是可以接受的,那么使用这些运算符也是可以接受的。这是一个取决于具体应用。

例如,考虑一个游戏(如《太空入侵者》),你需要判断两个移动的物体(如导弹和外星人)是否相交或碰撞。如果这些物体彼此相距较远,这些运算符将返回正确的结果。

但如果两个物体非常接近,你可能会得到任意一种答案。在这种情况下,即使结果是错误的,也可能根本不会被注意到(例如看起来像是一次“擦肩而过”或“险些命中“。 这对于当前游戏来说是可以被接受的误差。


浮点数中的== 和!=

相等运算符(== !=)要麻烦得多。考虑==,它仅当其操作数完全相等时才返回true

因为即使是最小的舍入误差也会导致两个浮点数不相等,因此当可能期望返回 true 时,== 很有可能返回 false。运算符!= 也有同样的问题。

#include <iostream>

int main()
{
    std::cout << std::boolalpha << (0.3 == 0.2 + 0.1); // prints false

    return 0;
}

因此,通常应避免将这些运算符与浮点操作数一起使用。

上述情况有一个显著的例外:将浮点字面值与同类型变量进行比较是安全的,前提是该变量用同类型的字面值初始化,并且字面值中的有效数字不超过该类型的最小精度。

double a = 0.1; // 用字面值 0.1 初始化
if (a == 0.1) { // 比较安全
    // 这个比较是可靠的
}

Float 的最小精度为 6 位有效数字,double 的最小精度为 15 位有效数字。

我们也可以比较一个使用字面值初始化的 const constexpr 浮点变量,而不是直接比较字面值。

constexpr double gravity { 9.8 };
if (gravity == 9.8)

其次,比较不同类型的符点字面值通常是不可靠的,例如比较9.8f9.8将返回false


浮点数的比较

基于上面的内容,既然使用比较运算符对浮点数进行比较通常是不可靠的,那么我们如何合理的比较两个浮点数是否相等呢?

结果前面内容的分析的铺垫,一种可行的方法是使用一个函数来查看两个比较的浮点数是否 几乎相同。如果他们足够接近,那么我们称他们相等,用于表示 足够接近的值传统上称为epsilon,它通常被定义为一个小的正数,例如:0.00000001,使用科学计数法也可以写成1e-8

基于此,考虑下面自己尝试写出来的一个比较函数:

#include <cmath> // 用于 std::abs()

// absEpsilon 是一个绝对值
bool approximatelyEqualAbs(double a, double b, double absEpsilon)
{
    // 如果 a 和 b 之间的距离小于或等于 absEpsilon,那么 a 和 b 被认为“足够接近”
    return std::abs(a - b) <= absEpsilon;
}

std::abs() <cmath> 头文件中的一个函数,用于返回其参数的绝对值。因此,std::abs(a - b) <= absEpsilon 检查 a b 之间的距离是否小于或等于传入的 absEpsilon 值,该值表示“足够接近”的范围。如果 a 和 b 足够接近,该函数返回 true,表示它们相等。否则,返回 false

虽然这个函数可以工作,但并不是一个理想的方案。

  • epsilon 值为 0.00001,对于接近 1.0 的输入是合适的;

  • 对于接近 0.0000001 的输入来说太大了;

  • 而对于像 10,000 这样的输入来说又太小了。

#include <cmath>
#include <iostream>

// 仅基于绝对误差判断两个浮点数是否“足够接近”
bool approximatelyEqualAbs(double a, double b, double absEpsilon)
{
    return std::abs(a - b) <= absEpsilon;
}

int main()
{
    double a = 1.0;
    double b = 1.00001;
    double absEpsilon = 0.00001;

    std::cout << std::boolalpha;
    std::cout << "Are a and b close enough? " << approximatelyEqualAbs(a, b, absEpsilon) << '\n';

    // 小数值测试
    double c = 0.0000001;
    double d = 0.0000002;
    std::cout << "Are c and d close enough? " << approximatelyEqualAbs(c, d, absEpsilon) << '\n';

    // 大数值测试
    double e = 10000.0;
    double f = 10001.0;
    std::cout << "Are e and f close enough? " << approximatelyEqualAbs(e, f, absEpsilon) << '\n';

    return 0;
}

这意味着每次我们调用这个函数时,我们都必须选择一个适合我们输入的 epsilon。如果我们知道我们必须根据输入的大小按比例缩放 epsilon,我们不妨修改函数来为我们做到这一点。

著名计算机科学家Donald Knuth在他的著作《计算机编程的艺术,第二卷:半数值算法》(Addison-Wesley,1969)中提出了以下方法:

#include <algorithm> // for std::max
#include <cmath>     // for std::abs

bool approximatelyEqualRel(double a, double b, double relEpsilon)
{
	return (std::abs(a - b) <= (std::max(std::abs(a), std::abs(b)) * relEpsilon));
}

在这种情况下,epsilon 不再是绝对数,而是相对于ab的大小。

让我们更详细地研究一下这个看起来疯狂的函数是如何工作的。

  • 在 <= 运算符的左侧, std::abs(a - b)以正数形式告诉我们ab之间的距离。
  • 在 <= 运算符的右侧,我们需要计算我们愿意接受的“足够接近”的最大值。为此,算法选择ab中较大的一个(作为数字总体大小的粗略指标),然后将其乘以 relEpsilon。
  • 在此函数中,relEpsilon 代表百分比。例如,如果我们想说“足够接近”意味着abab中较大者的误差在 1% 以内,则我们传入 relEpsilon 0.01 (1% = 1/100 = 0.01)。
  • relEpsilon 的值可以调整为最适合具体情况的值(例如,epsilon 0.002 表示误差在 0.2% 以内)。

要执行不等式 (!=) 而不是相等,只需调用此函数并使用逻辑 NOT 运算符 (!) 翻转结果:

if (!approximatelyEqualRel(a, b, 0.001))
 std::cout << a << " is not equal to " << b << '\n';

请注意,虽然 approxEqualRel() 函数适用于大多数情况,但它并不完美,特别是当数字接近零时:

#include <algorithm> // 用于 std::max
#include <cmath>     // 用于 std::abs
#include <iostream>

// 如果 a 和 b 之间的差值在 epsilon 百分比以内(以 a 和 b 中较大的值为基准),则返回 true
bool approximatelyEqualRel(double a, double b, double relEpsilon)
{
	return (std::abs(a - b) <= (std::max(std::abs(a), std::abs(b)) * relEpsilon));
}

int main()
{
    // a 非常接近 1.0,但有舍入误差
    constexpr double a{ 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 };

    constexpr double relEps { 1e-8 };
    constexpr double absEps { 1e-12 };

    std::cout << std::boolalpha; // 以 true 或 false 打印,而不是 1 或 0

    // 首先,将 a(几乎是 1.0)与 1.0 进行比较
    std::cout << approximatelyEqualRel(a, 1.0, relEps) << '\n';

    // 其次,将 a-1.0(几乎是 0.0)与 0.0 进行比较
    std::cout << approximatelyEqualRel(a-1.0, 0.0, relEps) << '\n';

    return 0;
}

true

false

第二次调用时未按照预期执行,数学结果接近于零。

避免这种情况的一种方法是同时使用绝对误差和相对误差,考虑下面程序:

// 如果 a 和 b 之间的差值小于或等于 absEpsilon,或相对误差在 a 和 b 中较大的值的 relEpsilon 百分比以内,则返回 true
bool approximatelyEqualAbsRel(double a, double b, double absEpsilon, double relEpsilon)
{
    // 检查数值是否非常接近 —— 在比较接近零的数值时需要此检查。
    if (std::abs(a - b) <= absEpsilon)
        return true;

    // 否则,使用 Knuth 的算法进行相对误差比较
    return approximatelyEqualRel(a, b, relEpsilon);
}

在此算法中,我们首先检查ab在绝对值上是否接近,这处理ab都接近于零的情况。应该将absEpsilon参数设置为非常小的值(例如1e-12)。如果失败,那么我们就回到 Knuth 算法,使用相对 epsilon。

下面是结合两种方法的代码示例:

#include <algorithm> // 用于 std::max
#include <cmath>     // 用于 std::abs
#include <iostream>

// 如果 a 和 b 之间的差值在 epsilon 百分比以内(以 a 和 b 中较大的值为基准),则返回 true
bool approximatelyEqualRel(double a, double b, double relEpsilon)
{
	return (std::abs(a - b) <= (std::max(std::abs(a), std::abs(b)) * relEpsilon));
}

// 如果 a 和 b 之间的差值小于或等于 absEpsilon,或相对误差在 a 和 b 中较大的值的 relEpsilon 百分比以内,则返回 true
bool approximatelyEqualAbsRel(double a, double b, double absEpsilon, double relEpsilon)
{
    // 检查数值是否非常接近 —— 在比较接近零的数值时需要此检查。
    if (std::abs(a - b) <= absEpsilon)
        return true;

    // 否则,使用 Knuth 的算法进行相对误差比较
    return approximatelyEqualRel(a, b, relEpsilon);
}

int main()
{
    // a 非常接近 1.0,但有舍入误差
    constexpr double a{ 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 };

    constexpr double relEps { 1e-8 };
    constexpr double absEps { 1e-12 };

    std::cout << std::boolalpha; // 打印 true 或 false,而不是 1 或 0

    // 首先,将 a(几乎是 1.0)与 1.0 进行比较
    std::cout << approximatelyEqualRel(a, 1.0, relEps) << '\n';

    // 其次,将 a-1.0(几乎是 0.0)与 0.0 进行比较
    std::cout << approximatelyEqualRel(a-1.0, 0.0, relEps) << '\n';

    // 使用绝对误差加相对误差的方法比较 "几乎 1.0" 和 1.0
    std::cout << approximatelyEqualAbsRel(a, 1.0, absEps, relEps) << '\n';

    // 使用绝对误差加相对误差的方法比较 "几乎 0.0" 和 0.0
    std::cout << approximatelyEqualAbsRel(a-1.0, 0.0, absEps, relEps) << '\n';

    return 0;
}

true

false

true

true

到此为止,关于浮点数的比较的问题讨论差不多结束了,浮点数的比较问题是一个相对困难的话题,目前并没有适合所有情况的完美方案,但是absEqualAbsRel() 函数(absEpsilon 为 1e-12,relEpsilon 为 1e-8)应该足以处理大多数情况。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Xayla

轻轻一点,暖心房

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值