一般情况下,对于比较两个整数的大小关系,我们通常使用简单的比较运算符进行比较即可得出结论;
考虑下面的程序:
#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。
使用任何关系运算符比较符点值都存在风险。这是因为浮点数并不精确,浮点操作数中的舍入误差可能会导致它们比预期略小或者略大。
也就是说,这导致它们脱离了关系运算符的作用域,在浮点数的比较时,不能使用这些关系运算符进行,或者说,不能完全的信任比较的结果。
浮点数的<和>
- 当对浮点数使用小于(<)、大于(>)、小于等于(<=)和大于等于(>=)运算符时,在大多数情况下(当操作数的值差异较大时),它们会产生可靠的结果。
“值差异较大” 的含义:
- 如果两个浮点数的数值差距大到超出了它们的精度限制(例如,
10
和10.0000000001
),比较结果通常是准确的。- 问题主要出现在两个浮点数非常接近(差值接近机器精度,比如
1.0000000001
和1.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.8f
和9.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 不再是绝对数,而是相对于a或b的大小。
让我们更详细地研究一下这个看起来疯狂的函数是如何工作的。
- 在 <= 运算符的左侧,
std::abs(a - b)
以正数形式告诉我们a和b之间的距离。 - 在 <= 运算符的右侧,我们需要计算我们愿意接受的“足够接近”的最大值。为此,算法选择a和b中较大的一个(作为数字总体大小的粗略指标),然后将其乘以 relEpsilon。
- 在此函数中,relEpsilon 代表百分比。例如,如果我们想说“足够接近”意味着a和b与a和b中较大者的误差在 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);
}
在此算法中,我们首先检查a和b在绝对值上是否接近,这处理a和b都接近于零的情况。应该将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)
应该足以处理大多数情况。