1. 从一次面试翻车说起:两个值明明"相等",set却放着两个元素
先讲一个我面试别人时经常抛出的问题:一个std::set<std::string>里能不能同时存在"abc"和另一个"abc"?大多数人都能答对,不能。但当我追问"set是用什么判断两个值重复的"时,很多人的回答是operator==。继续追问"If the set is sorted,它凭什么用operator==就能高效查重",能答上来的就少了一半。如果再补一刀:"那std::set<double>里,1.0和1.00算不算重复?"基本就沉默了。
这个问题的核心,就是C++ STL里最容易被忽略、但几乎所有容器和算法背后都在用的一个概念:相等(equality)与等价(equivalence)。
很多人写了好几年C++,能熟练使用set、map、sort、lower_bound,但从来没想过:有序容器从不用operator==判断重复,它用的是比较器(默认是std::less,也就是<)推导出的"等价"关系。而这个设计不是偶然,它直接决定了STL的排序算法、二分查找、关联容器的行为,也决定了你为什么能在自定义类型上只写一个operator<就能放进set里跑得飞快。
这篇文章就把这件事彻底讲透:什么是等价、什么是相等、两者怎么用、在哪些场景下它们会分道扬镳、以及你写自定义类型进STL时最容易踩的坑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 严格弱排序是等价的真正地基,operator==只是选配
2.1 一个operator<如何定义"重复"
先看一个最直观的例子。
cpp复制#include <set>
#include <iostream>
struct Person {
std::string name;
int id;
};
bool operator<(const Person& a, const Person& b) {
return a.id < b.id;
}
int main() {
std::set<Person> people;
people.insert({"Alice", 1001});
people.insert({"Alice", 1001}); // id相同
people.insert({"Bob", 1001}); // id相同,name不同
std::cout << people.size() << std::endl; // 输出1
}
这里Person没有定义operator==,但set照样能判断两个元素"重复"。它是怎么做到的?
关键在于:std::set内部维护的是一个有序结构,插入新元素时,它会沿着搜索树走一遍,判断新元素应该落在哪个位置。判断的标准只有一个:comp(a, b)是否为true——即a是否排在b前面。
当插入一个新值x时,set会找到某个已存在的y,然后问两个问题:
comp(x, y)是否为 true?comp(y, x)是否为 true?
如果两个问题的答案都是false,说明x既不小于y,y也不小于x,这时候STL就认为x和y在排序意义上是"不可区分"的,也就是等价的。既然是等价,那就按重复处理。
这个逻辑写成伪代码就是:
cpp复制bool equivalent(const T& a, const T& b, Compare comp) {
return !comp(a, b) && !comp(b, a);
}
所以上面的例子里,第二个{"Alice", 1001}和第一个等价,插不进去;第三个{"Bob", 1001}虽然name不同,但比较器只看id,所以它和第一个也等价,同样插不进去。
2.2 为什么这里不能用operator==
有人会问:直接定义operator==,判断a == b为true就拒绝插入,不是更直观吗?
问题在于:**对一个有序容器来说,"判断相等"不是它的核心操作,"这也是STL设计者没有走的路。
3. 有序容器的三角关系:lower_bound、upper_bound与equal_range
当你真正理解了"等价"这个判定逻辑,再看有序容器提供的查询接口,很多困惑会迎刃而解。先看一个最常见的例子:
cpp复制#include <set>
#include <iostream>
int main() {
std::multiset<int> ms = {1, 2, 2, 2, 3, 4, 5};
auto lb = ms.lower_bound(2); // 第一个 >= 2 的元素
auto ub = ms.upper_bound(2); // 第一个 > 2 的元素
auto range = ms.equal_range(2);
std::cout << "lower_bound: " << *lb << std::endl; // 2
std::cout << "upper_bound: " << *ub << std::endl;
