What is comparison?
这段文字是从计算机科学、编译器设计或系统优化的角度来定义和评价“比较(comparison)”这个操作:
比较操作在编程中极为常见,存在于:
if
, switch
)for
, while
)尽管比较看起来是个简单操作,但在高性能场景中:
比较操作往往决定程序的行为:
从理论角度来说,比较操作其实是一个 数学关系判断:
a == b
a < b
, a >= b
“比较”是计算中无处不在、不可或缺、但又代价高昂的操作,其本质是对等价或序关系的查询。
它是优化的核心点,尤其在高性能编程(如向量化、GPU、并行化)和编译器设计中,如何处理比较决定了程序性能与行为的关键路径。
一个等价关系(记作 ~)是在一个集合上的二元关系,满足以下三个基本性质:
a
:这三个条件一起,就定义了一个弱等价关系(weak equivalence)。
除了基本的等价关系,**“相等(=)”**在程序里通常还意味着 更强的等价性,叫作 一致性(congruence),它除了满足上面三条,还要求:
f()
:int x = 5;
int y = 5;
// 如果 x == y,并且是“可替代”的等价,
// 那么 x + 1 == y + 1 是合法的优化
性质 | 描述 |
---|---|
自反性 | a ~ a |
对称性 | a ~ b → b ~ a |
传递性 | a ~ b 且 b ~ c → a ~ c |
可替代性 | a = b → f(a) = f(b)(等价在任意上下文中成立) |
下面为你详细解释这些概念:
序关系是用来对元素“排序”的一种二元关系(通常记作 <
)。我们关心的排序可以是:
一个严格偏序关系 <
必须满足以下 3 个性质:
性质 | 含义 |
---|---|
Irreflexive(反自反性) | 不存在元素 a 使得 a < a。 |
Asymmetric(非对称性) | 如果 a < b,那么绝不可能 b < a。 |
Transitive(传递性) | 如果 a < b 且 b < c,那么一定有 a < c。 |
集合 {1, 2, 3}
中的 <
就是严格偏序:
它是在严格偏序的基础上,增加了“可比较性”的某种弱形式:
~
表示),这些等价类之间是有序的。性质 | 含义 |
---|---|
Ordered partitions | 如果 a < b,则对于任何 c,不是 a < c,就是 c < b(或两者都) |
用途: | |
标准库排序函数(如 std::sort )要求的比较函数必须满足严格弱序(例如小于 < )。 |
这是最强的序关系 —— 所有元素都可以被比较。
性质 | 含义 |
---|---|
Trichotomy(三分律) | 对于任意 a 和 b,恰好有以下三个关系之一成立: |
特性 | Partial Order | Weak Order | Total Order |
---|---|---|---|
自反性 | |||
非对称性 | |||
传递性 | |||
三分律 | |||
可比较性 | (部分可比) | (类间可比) | (全部可比) |
应用场景 | 所需关系类型 |
---|---|
std::sort() 的比较函数 |
Strict Weak Order |
数据结构中的优先级(如 heap) | Partial or Total Order |
排序网络或拓扑排序 | Partial Order |
编译器依赖图分析、任务调度 | Partial Order |
解释:
拓扑排序是用于有向无环图(DAG)中的节点排序,表示“先做谁,后做谁”的依赖关系。
需要的顺序类型:
示例:
Task A < Task B < Task C
But Task D is unrelated (not comparable)
解释:
标准排序算法(如
std::sort
)依赖比较器(如<
)来确定元素之间的顺序。
需要的顺序类型:
特点:
解释:
用于搜索结构(如二分查找、平衡树、哈希树等)时,需要所有元素可以比较出大小先后。
需要的顺序类型:
解释:
Memoization(记忆化)会缓存之前计算过的输入与结果。
需要的顺序类型:
具体要求:
解释:
将问题空间(如一个数组、输入域)划分为子区域(如分区处理、并行计算),具体做法依赖于元素类型。
需要的顺序类型:
应用场景 | 所需比较关系类型 |
---|---|
拓扑排序(Topological Sort) | Strict Partial Order |
一般排序(std::sort) | Strict Weak Order |
索引结构(二叉树、B树) | Strict Total Order |
记忆化缓存(Memoization) | Equality + Weak Order |
计算域分区(Partitioning) | Type-specific(依类型而定) |
原因: 算法往往基于某种假设(如弱序、等价关系、总序等),一旦比较操作不满足这些假设,就可能导致算法行为不正确或不确定。
浮点数中的 NaN 不满足常规的比较规则:
1 < 3 true
1 < NaN false
NaN < 3 false
NaN == NaN false
由于 NaN
和任何数比较都不成立,因此 operator<
不满足弱序(weak order)所需的条件,导致排序逻辑出错。
为能用于排序算法(如 std::sort
),比较函数 <
必须满足严格弱序(strict weak ordering):
a < a
a < b
且 b < c
,则 a < c
!(a < b)
且 !(b < a)
,则 a 和 b 处于同一等价类NaN
无法满足这些,因为:!(NaN < x)
对所有 x 成立!(x < NaN)
也对所有 x 成立NaN != x
和 NaN != NaN
⇒ 它无法构成等价类,也不等于任何值你给的几组可能的排序结果都不同:
-NaN, -1.0, +0.0, +1.0, +NaN
+NaN, -1.0, +0.0, +1.0, -NaN
-1.0, -NaN, +0.0, +NaN, +1.0
...
说明排序不具备稳定性、确定性、可重复性,因为 NaN 与其它值“不可比”。
#include
#include
#include
#include
int main() {
std::vector<double> v = { -1.0, std::nan(""), 0.0, 1.0, std::nan("") };
std::sort(v.begin(), v.end());
for (double d : v) {
if (std::isnan(d))
std::cout << "NaN ";
else
std::cout << d << " ";
}
}
可能输出结果会因编译器或排序策略而异。
import math
arr = [float('-nan'), -1.0, 0.0, 1.0, float('nan')]
arr.sort()
print(arr)
也会得到不确定排序,比如
[nan, -1.0, 0.0, 1.0, nan]
,具体位置不一定相同。
std::sort(vec.begin(), vec.end(), [](double a, double b) {
if (std::isnan(a)) return false;
if (std::isnan(b)) return true;
return a < b;
});
项目 | 是否满足 |
---|---|
1 < NaN 、NaN < 3 |
|
构成弱序(weak order) | |
NaN == NaN | |
导致排序不稳定 | |
推荐处理方式 | 自定义比较器或剔除 NaN |
+0.0
和 -0.0
是两个不同的位模式+0.0 == -0.0
是 true1.0 / +0.0 == +∞
1.0 / -0.0 == -∞
==
不是真正的等价(congruence)?a == b
,则对所有函数 f
,应有 f(a) == f(b)
+0.0 == -0.0 // true
1.0 / +0.0 == +∞ //
1.0 / -0.0 == -∞ //
+0.0 == -0.0
,但它们不能替代使用 → 不满足 substitutability==
不是一个等价关系(不是 congruence)你举例的排序结果如:
-1.0, -0.0, -0.0, +0.0, +1.0
-1.0, -0.0, +0.0, -0.0, +1.0
-1.0, +0.0, -0.0, ...
说明即使 -0.0 == +0.0
,排序时仍可能因符号差异不稳定,导致多种有效但不一致的排序顺序。
#include
#include
#include
int main() {
std::vector<double> values = { -1.0, -0.0, +0.0, +1.0 };
std::sort(values.begin(), values.end());
for (double x : values) {
if (std::signbit(x))
std::cout << "-0.0 ";
else if (x == 0.0)
std::cout << "+0.0 ";
else
std::cout << x << " ";
}
std::cout << "\n";
}
输出可能是:
-1.0 -0.0 +0.0 +1.0
但顺序不保证,除非你加上自定义比较器。
bool less_with_sign(double a, double b) {
if (a == b)
return std::signbit(a) && !std::signbit(b); // -0.0 comes before +0.0
return a < b;
}
观察项 | 是否满足 |
---|---|
-0.0 == +0.0 |
true |
1 / -0.0 != 1 / +0.0 |
true |
== 提供 substitutability |
不满足 |
== 是数学意义上的等价关系? |
不是 congruence |
会影响排序稳定性? | 是 |
推荐的排序方式? | 使用自定义 comparator |
-0.0
和 +0.0
)时,为什么常见的比较(如 ==
)在**“记忆化(memoization)”场景下是危险的**,其核心在于——==
不具备“可替代性(substitutability)”,所以不能被当作数学意义上的等价关系(congruence)来使用。Memoization(记忆化) 是一种将函数调用结果缓存起来以避免重复计算的技术。
典型逻辑如下:
std::unordered_map<double, double> cache;
double compute(double x) {
if (cache.find(x) != cache.end())
return cache[x];
double result = 1.0 / x;
cache[x] = result;
return result;
}
-0.0 == +0.0
,但值不同?在 IEEE 754 中:
-0.0 == +0.0 // true
1.0 / -0.0 == -∞ // true
1.0 / +0.0 == +∞ // true
所以你这段话的含义是:
-0.0, +0.0, -0.0 →
1/x
→ -Inf, +Inf, -Inf
但是如果用
==
匹配缓存,会变成:
-0.0, +0.0, -0.0 → 结果全部缓存为第一次结果(可能是 -Inf)
或者全部为 +Inf,取决于先缓存哪个!
这就是违背了**“相等值可替代”**原则:如果 a == b
,则 f(a) == f(b)
应该成立。但这里却:
-0.0 == +0.0
f(-0.0) != f(+0.0)
所以 ==
不是一个等价关系,不能用于 memoization 的 key!
你可以使用:
std::bit_cast
或 memcmp
进行 bit-level 比较#include
#include
#include
struct FloatBitsHash {
std::size_t operator()(double x) const {
return std::bit_cast<std::size_t>(x);
}
};
struct FloatBitsEqual {
bool operator()(double a, double b) const {
return std::bit_cast<std::size_t>(a) == std::bit_cast<std::size_t>(b);
}
};
std::unordered_map<double, double, FloatBitsHash, FloatBitsEqual> cache;
这样 -0.0
和 +0.0
将会被区分缓存,确保 memoization
正确性。
项目 | 解释 |
---|---|
-0.0 == +0.0 |
是 true,但只表示“数值上相等” |
1/-0.0 != 1/+0.0 |
行为不同(-∞ vs +∞) |
不能用于 memoization key | 因为不满足 substitutability,不是真正的“等价” |
正确的比较方法 | 使用 bit-level 比较(如 std::bit_cast )以区分符号 |
结论 | == 在浮点上下文中不是数学意义上的 congruence,因此有风险 |
记忆化是一种缓存机制,通过“输入 → 输出”映射避免重复计算。
但对浮点类型(如 double
),当输入是 NaN
时,memoization
会失效!
在 IEEE 754 浮点规范中:
double a = std::nan("");
a == a; // false
在使用 std::unordered_map
做 memoization 时,哈希表使用 operator==
作为默认比较函数。
因为:
NaN != NaN
➡ 每一个 NaN 都无法与已有 NaN 匹配
➡ 所以每次新 NaN 输入都 认为是“新的”请求
➡ 所有 NaN 请求都 无法命中缓存,造成:
#include
#include
#include
std::unordered_map<double, double> cache;
double compute(double x) {
if (cache.find(x) != cache.end()) {
std::cout << "Cache hit\n";
return cache[x];
}
std::cout << "Cache miss\n";
double result = 1.0 / x;
cache[x] = result;
return result;
}
int main() {
double nan1 = std::nan("1");
double nan2 = std::nan("2");
compute(nan1); // miss
compute(nan1); // miss again!
compute(nan2); // also a miss!
}
即使 nan1
是同一个变量,两次调用也会是“miss”。
#include
struct FloatHash {
std::size_t operator()(double x) const {
return std::bit_cast<std::size_t>(x);
}
};
struct FloatEqual {
bool operator()(double a, double b) const {
return std::bit_cast<std::size_t>(a) == std::bit_cast<std::size_t>(b);
}
};
std::unordered_map<double, double, FloatHash, FloatEqual> cache;
这样做的好处:
+0.0
和 -0.0
被区分NaN
和 NaN
可以匹配(如果 bit pattern 一样)问题点 | 原因 | 后果 | 解决方式 |
---|---|---|---|
NaN != NaN |
不满足 reflexivity | 无法命中缓存 | Bit-level 比较方式 |
== 不是等价关系(equiv) |
缺乏 substitutability | 导致缓存系统逻辑错误 | 使用自定义 == 或 bit pattern |
多个 NaN 被分别缓存 | NaN 无法彼此相等 | 内存持续增长,计算无法复用 | 自定义 hash 和 equal |
在 IEEE 754 双精度浮点数(double
) 中:
2^52 - 1 ≈ 4.5 × 10^15 ≈ 4.5 quadrillion
再考虑符号位(正/负 NaN),总数达到 约 9 quadrillion(千兆)NaN 值。
所以你看到的「8 quadrillion NaNs」是合理估计。
IEEE 754 要求:
运算中传递下来的 NaN,不改变其 payload(有效载荷)。
举例:
double nan1 = std::nan("1");
double nan2 = std::nan("2");
std::vector<double> v = { nan1, 0.0, nan2 };
for (double x : v) {
std::cout << 1.0 / x << "\n";
}
结果:
1.0 / nan1
→ nan1
1.0 / 0.0
→ +Inf
1.0 / nan2
→ nan2
排序策略 | 行为说明 |
---|---|
default | 通常实现无法处理 NaN,可能把它们移动或混乱处理。示例:NaN1, NaN2, +Inf, NaN2(注意 NaN2 重复) |
weak order | 所有 NaN 被视为“等价”,会全部归类为 NaN1(如:NaN1, NaN1, +Inf, NaN1) |
total order | 明确保留每个 NaN identity,遵循完整比较规则(如:NaN1, NaN2, +Inf, NaN2) |
问题点 | 原因 | 后果 |
---|---|---|
存在海量 NaN 值 | NaN 的尾数部分可编码大量值 | NaN identity 会影响排序、比较、缓存行为 |
IEEE 保留 NaN identity | 为保留调试信息、错误传播标识 | 在排序或映射操作中行为难预测 |
NaN 不可比较 | NaN == NaN 为 false |
无法用于哈希、等价判断、memo key |
默认排序无法稳定处理 NaN | 排序算法假定 weak/total order | 导致结果不一致或重复 |
bitwise
比较(例如 memcmp()
)或强制统一所有 NaN(canonical NaN)。std::nan("")
)或返回默认数值。具体理解如下:
<
总是定义良好)浮点数不是唯一“坑”——程序员对比较的设计、平台的实现,以及类型本身的定义,都可能导致比较不满足全序,进而影响算法正确性。
<
、==
)来做比较,NaN
情况,或用户自定义类型没有严格定义比较规则。问题在于算法设计和运算符设计之间的脱节。算法默认“信任”运算符符合数学性质,但现实中并非如此。需要更灵活和明确的比较接口设计,或者在算法层面引入更健壮的比较策略。
代码1:
bool operator>=(T a, T b) {
return !(a < b);
}
operator<
,认为 a >= b
等价于 “不是 a < b
”。T
仅是部分有序(partial order)时,比如浮点数(float)中存在 NaN,operator<
不能形成全序。float
中的 NaN,a < b
和 a >= b
都可能为 false
,导致逻辑错误。bool operator>=(T a, T b) {
return a > b || a == b;
}
operator>
和 operator==
来实现“a >= b
”。operator>
和 operator==
仍然可以更可靠地区分情况。a == b
是 false
,但是不会错误地判断 a >= b
。operator<
的逻辑(!(a < b)
)不总是正确的。operator>
和 operator==
来确保比较符合实际语义。实现方式 | 适用范围 | 是否可靠 | 备注 | ||
---|---|---|---|---|---|
return !(a < b); |
全序 | 是 | 对全序关系有效 | ||
`return a > b | a == b;` | 部分有序 | 更加健壮 | 适用于包含 NaN 的浮点数等 |
std::less
来驱动标准库算法。std::sort
、std::set
等)明确提供符合预期的比较函数,保证算法行为正确。double
实现的六个布尔比较函数示例,分别体现了部分、有序(弱)、全序以及相应的等价性判断。重点处理 NaN 和 ±0.0 等特殊情况:#include // std::isnan, std::signbit
// 判断两个数是否都是零(包含正负零)
bool both_zero(double a, double b) {
return a == 0.0 && b == 0.0;
}
// 1. 部分序(partial order)小于
// 如果任一参数是NaN,则返回false(表示不可比较)
// 否则返回 d < f
bool partial_less(double d, double f) {
if (std::isnan(d) || std::isnan(f)) {
return false; // 含NaN时不可比较
}
return d < f;
}
// 2. 弱序(weak order)小于
// 将所有NaN视为相等,不认为NaN小于或大于其他数
// ±0.0被视为相等,不存在大小关系
bool weak_less(double d, double f) {
bool d_nan = std::isnan(d);
bool f_nan = std::isnan(f);
if (d_nan && f_nan) return false; // NaN间相等
if (d_nan) return false; // NaN不小于任何数
if (f_nan) return true; // 任何数小于NaN
if (both_zero(d, f)) return false; // ±0.0视为相等
return d < f;
}
// 3. 全序(total order)小于
// 定义对所有浮点数(含NaN和±0.0)的完整排序关系
// NaN被视为大于所有非NaN数,区分±0.0(-0.0 < +0.0)
bool total_less(double d, double f) {
if (std::isnan(d)) {
if (std::isnan(f)) {
// 简单处理,认为所有NaN相等
return false;
}
return false; // NaN > 非NaN
}
if (std::isnan(f)) {
return true; // 非NaN < NaN
}
// 对±0.0区分符号,-0.0被认为小于+0.0
if (both_zero(d, f)) {
bool d_neg = std::signbit(d);
bool f_neg = std::signbit(f);
return d_neg && !f_neg;
}
return d < f;
}
// 4. 部分无序判断(partial unordered)
// 如果任一参数为NaN,则认为两者不可比较(无序)
bool partial_unordered(double d, double f) {
return std::isnan(d) || std::isnan(f);
}
// 5. 弱等价关系(weak equivalence)
// NaN之间相等,±0.0视为相等,其他按==判断
bool weak_equivalence(double d, double f) {
bool d_nan = std::isnan(d);
bool f_nan = std::isnan(f);
if (d_nan && f_nan) return true; // NaN等价
if (d_nan || f_nan) return false; // NaN与非NaN不等价
if (both_zero(d, f)) return true; // ±0.0等价
return d == f;
}
// 6. 全等价(total equality)
// 严格比较位级相等,但区分±0.0符号
// NaN统一视为相等(也可扩展为比较NaN payload)
bool total_equal(double d, double f) {
if (std::isnan(d) && std::isnan(f)) {
return true; // 简单处理,所有NaN相等
}
if (both_zero(d, f)) {
// ±0.0只有符号相同时才等价
return std::signbit(d) == std::signbit(f);
}
return d == f;
}
partial_less
:对NaN和非比较情况返回false,不满足全序。
weak_less
:将所有NaN视为相等(非排序),同时把±0.0视为相等。
total_less
:强制对所有浮点数(包括NaN和±0.0)排序,保证全序。
partial_unordered
:判断两个数是否无法比较(包含NaN)。
weak_equivalence
:弱等价关系,NaN都视为相等,±0.0相等。
total_equal
:严格等价,包括区分±0.0,NaN则默认相等(也可扩展为按payload区分)。
保持一致性
在定义比较和等价关系时,应保持逻辑一致性,避免混淆。
用“小于”定义等价
等价关系(a~b)可以用“小于”操作符来定义:
a~b 当且仅当 不是 a < b 且 不是 b < a
跨层级保持一致
比如,全序(total_less)成立时,弱序(weak_less)也应成立,保证层级间关系不冲突。
与操作符保持一致
用户自定义的 operator< 应该与 weak_less 保持一致,避免在不同代码路径出现冲突。
遵循标准
例如,遵守 IEEE 754 的 totalOrder 规范,保证浮点数比较的标准化和可预测性。
这些原则有助于确保比较函数的正确性、稳定性以及算法行为的预期。
<
运算符只保证部分序性质,不能处理 NaN 等特殊情况。weak_less
函数示例代码:
bool weak_less(double d, double e) {
if (d < e) return true; // 常见情况直接比较
if (e >= d) return false; // 排除另一种常见情况
// 处理其它特殊情况
}
if (s < t) { ... }
else if (s > t) { ... }
else { ... }
但如果用字符串比较函数:int cmp = s.compare(t);
if (cmp < 0) { ... }
else if (cmp > 0) { ... }
else { ... }
partial_ordering
: { less, unordered, greater }weak_ordering
: { less, equivalent, greater }total_ordering
: { less, equal, greater }partial_order(const T&, const T&)
weak_order(const T&, const T&)
total_order(const T&, const T&)