1. 从“容器”到“集合”:为什么你需要了解C++的set

如果你刚开始接触C++,或者已经写过一些代码,用过 vector array ,那你对“容器”这个概念应该不陌生。它们就像一个个盒子,帮你把数据装起来,方便你存取。但当你需要处理一些特殊需求时,比如“自动去重”、“快速判断某个元素是否存在”、“按顺序遍历所有元素”,普通的“盒子”就显得有点力不从心了。这时候,你就需要认识一下C++标准库里的“集合”容器—— std::set

我第一次真正觉得 set 好用,是在处理一个用户标签系统的时候。后台会收到大量用户打上的标签,同一个用户可能重复提交相同的标签,我需要把这些标签整理成一个唯一的、有序的列表,方便后续的统计和展示。最开始我用的是 vector ,每次插入新标签前,都要遍历一遍整个列表,用 find 函数检查是否已经存在。当用户量上来,标签数量达到几万甚至几十万时,这个遍历操作就成了性能瓶颈,程序响应慢得让人抓狂。后来换成了 set ,代码简洁到只需要一句 tags.insert(newTag) ,不仅自动去重,而且内部元素始终是有序的,遍历输出时直接就是排好序的,性能更是提升了几个数量级。那一刻我才明白,选对工具,事半功倍。

简单来说, std::set 是一个关联容器,它存储的是唯一的“键”(key),并且这些键会按照特定的顺序(默认是升序)自动排列。它的底层通常由红黑树(一种自平衡的二叉查找树)实现,这保证了插入、删除和查找操作的平均时间复杂度都是O(log n),对于需要频繁进行“存在性检查”和“有序遍历”的场景,效率远高于顺序容器(如 vector list 的O(n)查找)。

这篇文章,我会从一个实际使用者的角度,带你彻底搞懂 set 。我们不只讲语法,更会深入它“为什么”要这么设计,以及在实际项目中“怎么用”才能避开那些常见的坑。无论你是正在准备面试,被“C++八股文”里关于STL的问题困扰,还是在实际开发中遇到了需要高效管理唯一集合的需求,这篇超详细的指南都能给你直接的帮助。

2. 核心特性与底层原理:它凭什么这么“聪明”?

在开始敲代码之前,我们必须先理解 set 的设计哲学。它不是一个简单的“数组”或“链表”,而是一个高度结构化、有自己规则的“智能集合”。它的核心特性决定了它的适用场景和性能表现。

2.1 三大核心特性:唯一、有序、不可直接修改

  1. 元素唯一性 :这是 set 最直观的特性。在同一个 set 中,不允许存在两个值相同的元素。当你尝试插入一个已经存在的值时,插入操作会被忽略(不会报错,但插入失败)。这省去了我们手动去重的麻烦。
  2. 自动排序 set 中的元素并非按照插入顺序存储,而是会根据元素的“比较规则”自动进行排序。默认情况下,它使用 std::less (即 < 运算符)进行升序排列。你也可以在定义 set 时,传入自定义的比较函数或函数对象来指定排序规则。
  3. 键即值,不可直接修改 :在 set 中,元素的值同时就是它的“键”(key)。这意味着你不能像修改 vector 里某个位置的值那样,直接通过迭代器去修改 set 中的元素(例如 *it = newValue; )。因为修改元素可能会破坏 set 内部赖以维持有序性的红黑树结构。如果你需要修改一个元素,通常的做法是先删除旧元素,再插入新元素。

2.2 底层数据结构:红黑树简析

为什么 set 能同时做到O(log n)的查找、插入、删除,并且保持有序?秘密就在于它的底层实现——红黑树(Red-Black Tree)。

你可以把红黑树想象成一棵总是保持“相对平衡”的二叉树。它通过一套复杂的着色和旋转规则,确保从根节点到任意叶子节点的最长路径,不会超过最短路径的两倍。这种“平衡性”是高效操作的关键。

  • 查找 :从根节点开始,比较目标值与当前节点值,根据比较结果决定进入左子树或右子树,每次比较都能排除掉大约一半的节点,因此效率是O(log n)。
  • 插入 :先像查找一样找到合适的插入位置,插入新节点(初始为红色),然后通过一系列的颜色翻转和树旋转操作,修复可能被破坏的红黑树性质,维持平衡。这个过程也是O(log n)。
  • 删除 :过程更复杂一些,但核心思想类似,在删除节点后通过调整来维持树的平衡,复杂度同样是O(log n)。

正因为底层是树形结构, set 的迭代器进行 ++ 操作(中序遍历)时,得到的就是有序的序列。同时,这也解释了为什么 set 的元素不能直接修改——你无法保证修改后的新值还能放在树中正确的位置上,这会导致整棵树的结构错乱。

2.3 与其它容器的对比:什么时候该用set?

理解了原理,我们就能更理智地做技术选型。这里用一个表格来快速对比 set 和几个常用容器:

特性 std::set std::vector std::unordered_set std::multiset
元素是否唯一 否(允许重复)
内部顺序 有序(默认升序) 插入顺序 无序(基于哈希) 有序(默认升序)
底层数据结构 红黑树 动态数组 哈希表 红黑树
平均查找复杂度 O(log n) O(n) O(1) O(log n)
插入/删除平均复杂度 O(log n) 尾部O(1), 中间O(n) O(1) O(log n)
是否需要可哈希 否(需要可比较) 否(需要可比较)
典型使用场景 需要有序、唯一元素的集合,频繁查找 随机访问频繁,尾部增删多 只需快速判断存在性,不关心顺序 需要有序但允许重复的集合

选择指南

  • 当你需要 维护一个唯一且有序的集合 ,并且会频繁地进行查找、插入、删除操作时, set 是你的首选。例如,维护一个系统的在线用户ID列表(需要快速判断用户是否在线,并且可能按ID顺序进行某些操作)。
  • 如果你只关心元素 是否存在 ,完全 不关心顺序 ,并且你的元素类型提供了良好的哈希函数,那么 std::unordered_set (C++11引入)的平均O(1)操作会更快。
  • 如果你需要 允许重复元素 ,但同时要保持有序,那么应该选择 std::multiset
  • 如果你的操作以 随机访问 (通过下标)和 尾部插入 为主,那么 vector 仍然是效率最高的选择。

注意 set 的O(log n)复杂度是“平均”且“摊还”的。虽然它不如 unordered_set 的O(1)惊艳,但其有序性和稳定性(不受哈希冲突影响)在很多场景下是不可替代的。不要盲目追求O(1)而忽略了业务需求。

3. 从零开始使用set:完整API与实战示例

理论说再多,不如动手写一遍。这一部分,我们将像搭积木一样,从创建 set 对象开始,一步步掌握它的所有常用操作。我会用具体的代码示例和注释,让你看得明白,抄得放心。

3.1 基础操作:创建、插入与遍历

首先,要使用 set ,必须包含头文件 <set>

#include <iostream>
#include <set>
using namespace std;

int main() {
    // 1. 创建一个空的set,存储int类型,默认按升序排列
    set<int> mySet;

    // 2. 插入元素 - insert() 方法
    mySet.insert(3);
    mySet.insert(1);
    mySet.insert(4);
    mySet.insert(1); // 重复插入1,会被忽略
    mySet.insert(2);

    // 3. 遍历set - 迭代器
    cout << "Set elements (using iterator): ";
    for (set<int>::iterator it = mySet.begin(); it != mySet.end(); ++it) {
        cout << *it << " "; // 输出:1 2 3 4 (已自动排序)
    }
    cout << endl;

    // 4. 使用范围for循环 (C++11) - 更简洁
    cout << "Set elements (using range-for): ";
    for (int num : mySet) {
        cout << num << " ";
    }
    cout << endl;

    // 5. 获取元素个数 - size()
    cout << "Size of set: " << mySet.size() << endl; // 输出:4 (不是5,因为1重复了)

    // 6. 判断是否为空 - empty()
    set<int> emptySet;
    cout << "Is mySet empty? " << (mySet.empty() ? "Yes" : "No") << endl;
    cout << "Is emptySet empty? " << (emptySet.empty() ? "Yes" : "No") << endl;

    return 0;
}

插入操作的返回值 insert 方法有一个非常重要的返回值,它是一个 pair<iterator, bool>

  • first :是一个迭代器,指向被插入的元素(如果插入成功)或指向 set 中已存在的那个等值元素(如果插入失败)。
  • second :是一个 bool 值,表示插入是否成功( true 表示成功插入新元素, false 表示元素已存在)。

这个返回值在需要知道插入是否真正发生时非常有用。

auto result = mySet.insert(5);
if (result.second) {
    cout << "Element 5 inserted successfully." << endl;
} else {
    cout << "Element 5 already exists. Iterator points to: " << *(result.first) << endl;
}

3.2 查找与删除:精准操作集合元素

找到了、用完了,有时候也需要请出去。 set 提供了高效的查找和删除方法。

#include <iostream>
#include <set>
using namespace std;

int main() {
    set<int> mySet = {10, 20, 30, 40, 50}; // C++11 初始化列表

    // 1. 查找元素 - find()
    int target = 30;
    set<int>::iterator it = mySet.find(target);

    if (it != mySet.end()) { // 判断是否找到
        cout << "Found element: " << *it << endl;
    } else {
        cout << "Element " << target << " not found." << endl;
    }

    // 2. 统计元素个数 - count()
    // 对于set,返回值只能是0或1,因为元素唯一。
    // 对于multiset,返回值可能大于1。
    cout << "Count of 20: " << mySet.count(20) << endl; // 输出:1
    cout << "Count of 99: " << mySet.count(99) << endl; // 输出:0

    // 3. 删除元素 - erase()
    // 方法一:通过值删除
    size_t numRemoved = mySet.erase(20); // 返回删除的元素个数(对于set是0或1)
    cout << "Removed " << numRemoved << " instance(s) of 20." << endl;

    // 方法二:通过迭代器删除
    it = mySet.find(40);
    if (it != mySet.end()) {
        mySet.erase(it); // 更高效,因为省去了二次查找
    }

    // 方法三:删除一个区间 [first, last)
    // 例如,删除从30开始到末尾之前的所有元素
    it = mySet.find(30);
    if (it != mySet.end()) {
        // erase(it, mySet.end()) 会删除从it指向的元素开始,直到end()(不包括end())
        mySet.erase(it, mySet.end());
    }

    cout << "Set after deletions: ";
    for (int num : mySet) {
        cout << num << " "; // 输出:10 (只剩10了)
    }
    cout << endl;

    // 4. 清空整个set - clear()
    mySet.clear();
    cout << "Size after clear: " << mySet.size() << endl; // 输出:0

    return 0;
}

实操心得 :在删除已知迭代器位置的元素时, 优先使用通过迭代器删除的方式 erase(iterator) )。通过值删除( erase(value) )会先内部执行一次 find 操作,再执行删除。如果你已经通过 find 或其他方式获得了迭代器,直接用它删除能避免一次不必要的查找。

3.3 边界与范围操作:处理有序集合的利器

由于 set 是有序的,它提供了一些基于顺序的特殊查询方法,这在处理范围问题时非常高效。

#include <iostream>
#include <set>
using namespace std;

int main() {
    set<int> mySet = {10, 20, 30, 40, 50, 60, 70};

    // 1. 获取边界迭代器
    if (!mySet.empty()) {
        cout << "Smallest element: " << *mySet.begin() << endl;   // 输出:10
        cout << "Largest element: " << *mySet.rbegin() << endl;   // 输出:70 (反向迭代器)
    }

    // 2. 上下界查找 - lower_bound & upper_bound
    // 假设我们想找到所有 >= 35 且 < 65 的元素
    int lowerVal = 35;
    int upperVal = 65;

    // lower_bound(val): 返回第一个 >= val 的元素的迭代器
    set<int>::iterator lowIt = mySet.lower_bound(lowerVal);
    // upper_bound(val): 返回第一个 > val 的元素的迭代器
    set<int>::iterator upIt = mySet.upper_bound(upperVal);

    cout << "Elements in range [" << lowerVal << ", " << upperVal << "): ";
    for (auto it = lowIt; it != upIt; ++it) {
        cout << *it << " "; // 输出:40 50 60
    }
    cout << endl;

    // 3. 等值范围 - equal_range
    // 对于set,因为元素唯一,这个范围最多包含一个元素。
    // 对于multiset更有用,可以一次性获取所有等值元素的范围。
    auto range = mySet.equal_range(50); // 返回一个pair<iterator, iterator>
    if (range.first != range.second) {
        cout << "Found element 50." << endl;
        // range.first 是 lower_bound(50) 的结果
        // range.second 是 upper_bound(50) 的结果
    }

    // 一个更直观的例子:删除某个范围内的所有元素
    // 删除所有值在 [20, 50] 之间的元素
    auto start = mySet.lower_bound(20); // 第一个 >=20 的,即20本身
    auto end = mySet.upper_bound(50);   // 第一个 >50 的,即60
    mySet.erase(start, end);

    cout << "Set after erasing [20, 50]: ";
    for (int num : mySet) {
        cout << num << " "; // 输出:10 60 70
    }
    cout << endl;

    return 0;
}

lower_bound upper_bound 是处理有序区间的核心工具。它们之所以高效(O(log n)),是因为底层红黑树支持快速的二分查找,而不是像在 vector 中那样需要线性扫描。

4. 进阶与定制:让set适应你的复杂数据类型

到目前为止,我们用的都是 int 这种内置类型。但实际项目中,我们更常需要存储自定义的结构体或类对象。这时, set 的“排序”特性就带来了挑战:它怎么知道两个自定义对象谁大谁小呢?

4.1 存储自定义类型:定义比较规则

set 默认使用 std::less ,即 < 运算符来比较元素。对于自定义类型,你有两种方式来提供比较规则:

方法一:重载 < 运算符 这是最直接的方法。在你的类或结构体内部,重载小于运算符。

#include <iostream>
#include <set>
#include <string>
using namespace std;

class Person {
public:
    string name;
    int age;

    Person(string n, int a) : name(n), age(a) {}

    // 重载 < 运算符
    // 这里我们定义按年龄升序排序。如果年龄相同,再按名字升序排序。
    bool operator<(const Person& other) const {
        if (age != other.age) {
            return age < other.age;
        }
        return name < other.name;
    }

    // 为了方便打印,重载 << 运算符
    friend ostream& operator<<(ostream& os, const Person& p) {
        os << p.name << "(" << p.age << ")";
        return os;
    }
};

int main() {
    set<Person> personSet;

    personSet.insert(Person("Alice", 30));
    personSet.insert(Person("Bob", 25));
    personSet.insert(Person("Charlie", 30)); // 年龄与Alice相同,比较名字
    personSet.insert(Person("Alice", 30));   // 与第一个Alice完全相同,不会被插入

    cout << "Persons in set (sorted by age, then name):" << endl;
    for (const auto& p : personSet) {
        cout << p << endl;
    }
    // 输出:
    // Bob(25)
    // Alice(30)
    // Charlie(30)
    return 0;
}

方法二:提供自定义比较函数对象(仿函数) 这种方法更灵活,尤其是当你不想或不能修改原有类的定义时,或者你需要多种不同的排序方式。

#include <iostream>
#include <set>
#include <string>
using namespace std;

struct Person {
    string name;
    int age;
    // 注意:这里没有重载 < 运算符
};

// 自定义比较器:按姓名升序排序
struct CompareByName {
    bool operator()(const Person& a, const Person& b) const {
        return a.name < b.name;
    }
};

int main() {
    // 在定义set时,将比较器类型作为第二个模板参数传入
    set<Person, CompareByName> personSetByName;

    personSetByName.insert({"Alice", 30});
    personSetByName.insert({"Bob", 25});
    personSetByName.insert({"Charlie", 35});

    cout << "Persons sorted by name:" << endl;
    for (const auto& p : personSetByName) {
        cout << p.name << " - " << p.age << endl;
    }
    // 输出:
    // Alice - 30
    // Bob - 25
    // Charlie - 35

    // 你甚至可以定义另一个按年龄降序的set
    struct CompareByAgeDesc {
        bool operator()(const Person& a, const Person& b) const {
            return a.age > b.age; // 注意这里是 >,实现降序
        }
    };
    set<Person, CompareByAgeDesc> personSetByAgeDesc;
    personSetByAgeDesc.insert({"Alice", 30});
    personSetByAgeDesc.insert({"Bob", 25});
    personSetByAgeDesc.insert({"Charlie", 35});

    cout << "\nPersons sorted by age (descending):" << endl;
    for (const auto& p : personSetByAgeDesc) {
        cout << p.name << " - " << p.age << endl;
    }
    // 输出:
    // Charlie - 35
    // Alice - 30
    // Bob - 25
    return 0;
}

重要提醒 :自定义的比较规则必须满足 严格弱序 (Strict Weak Ordering)。简单来说,它需要满足:

  1. 非自反性: comp(a, a) 必须为 false
  2. 非对称性:如果 comp(a, b) true ,则 comp(b, a) 必须为 false
  3. 可传递性:如果 comp(a, b) true comp(b, c) true ,则 comp(a, c) 必须为 true
  4. 等价的可传递性:如果 !comp(a, b) && !comp(b, a) (即a和b“等价”),并且 !comp(b, c) && !comp(c, b) ,那么必须有 !comp(a, c) && !comp(c, a)

不满足严格弱序的比较规则会导致 set 行为未定义,通常会在运行时崩溃。最常见的错误是在比较浮点数时直接使用 < (因为NaN不满足任何比较关系),或者在多字段比较时逻辑写错。务必小心。

4.2 性能考量与迭代器失效

虽然 set 的操作大多是O(log n),但在某些特定场景下,性能仍有差异,并且迭代器的稳定性也需要关注。

  • 插入性能 :在已知位置附近插入(通过 hint 迭代器)可以提升效率。 insert 方法有一个重载版本: iterator insert (iterator position, const value_type& val) 。如果你能提供一个指向插入位置“附近”的正确迭代器(例如,通过 lower_bound 获得),插入操作可能接近常数时间。

    set<int> s = {10, 30, 50};
    auto hint = s.lower_bound(25); // 指向30
    s.insert(hint, 20); // 在30之前插入20,效率可能更高
    
  • 查找性能 find 是O(log n)。如果你的操作模式是“先查找,如果不存在则插入”,那么使用 insert 的返回值( pair<iterator, bool> )是更高效的做法,因为它将查找和插入合并为一次树操作。

    // 低效做法:
    if (mySet.find(value) == mySet.end()) {
        mySet.insert(value);
    }
    // 高效做法:
    mySet.insert(value); // insert本身会检查是否存在
    
  • 迭代器失效 set 的迭代器在插入和删除操作中表现出很强的稳定性。

    • 插入 :不会使任何现有迭代器失效。
    • 删除 :只有指向被删除元素的迭代器会失效,其他迭代器仍然有效。 这与 vector (插入/删除可能导致所有迭代器失效)和 deque (在中间插入/删除会使所有迭代器失效)形成鲜明对比。这使得在遍历 set 的同时安全地删除某些元素成为可能(但需要小心处理迭代器)。
    set<int> s = {1, 2, 3, 4, 5, 6};
    for (auto it = s.begin(); it != s.end(); /* 注意这里不递增 */) {
        if (*it % 2 == 0) { // 删除所有偶数
            it = s.erase(it); // erase(it) 返回被删除元素的下一个元素的迭代器
        } else {
            ++it;
        }
    }
    // s 现在包含 {1, 3, 5}
    

    关键技巧 :在循环中删除元素时,利用 erase 的返回值来更新迭代器,这是安全且标准的做法。

5. 实战场景与避坑指南

了解了所有API之后,我们来看看 set 在真实项目中是如何大显身手的,以及有哪些“坑”需要提前避开。

5.1 典型应用场景剖析

场景一:维护唯一且有序的ID集合 这是 set 最经典的应用。比如在游戏服务器中,维护所有在线玩家的ID,需要快速检查某个玩家是否在线,并且有时需要按ID顺序进行批量操作(如广播消息给前N个玩家)。

set<uint64_t> onlinePlayerIds;
// 玩家登录
onlinePlayerIds.insert(playerId);
// 玩家退出
onlinePlayerIds.erase(playerId);
// 快速检查是否在线
bool isOnline = (onlinePlayerIds.find(playerId) != onlinePlayerIds.end());
// 按顺序处理前100名玩家
int count = 0;
for (auto id : onlinePlayerIds) {
    doSomething(id);
    if (++count >= 100) break;
}

场景二:求交集、并集、差集 利用 set 的有序特性,可以高效地实现集合运算。标准库提供了 std::set_intersection std::set_union std::set_difference 等算法,它们要求输入范围是有序的。

#include <iostream>
#include <set>
#include <algorithm>
#include <iterator>
using namespace std;

int main() {
    set<int> A = {1, 2, 3, 4, 5};
    set<int> B = {3, 4, 5, 6, 7};
    set<int> C;

    // 求交集 A ∩ B
    set_intersection(A.begin(), A.end(),
                     B.begin(), B.end(),
                     inserter(C, C.begin())); // 使用inserter迭代器插入结果
    cout << "Intersection: ";
    for (int x : C) cout << x << " "; // 输出:3 4 5
    cout << endl;

    C.clear();
    // 求并集 A ∪ B
    set_union(A.begin(), A.end(),
              B.begin(), B.end(),
              inserter(C, C.begin()));
    cout << "Union: ";
    for (int x : C) cout << x << " "; // 输出:1 2 3 4 5 6 7
    cout << endl;

    return 0;
}

场景三:作为字典的“键”的集合 当你有一个 std::map<Key, Value> ,有时你需要获取所有键(key)的集合。虽然 map 本身提供了迭代器,但如果你需要一个独立、有序且唯一的键集合,可以将其键插入到一个 set 中。

map<string, int> studentScores = {{"Alice", 90}, {"Bob", 85}, {"Charlie", 92}};
set<string> studentNames;
for (const auto& pair : studentScores) {
    studentNames.insert(pair.first);
}
// 现在 studentNames 是一个有序的学生姓名集合

5.2 常见“坑”与解决方案

坑一:误用 [] 运算符 std::map std::unordered_map 支持通过 [] 运算符来访问或插入元素,但 set 没有这个运算符!因为 set 只有键,没有键值对,通过键来索引值没有意义。试图使用 mySet[5] 会导致编译错误。

正确做法 :插入用 insert() ,查找用 find() count()

坑二:试图修改 set 中的元素 这是新手常犯的错误。由于 set 元素的常量性( const ),通过迭代器获取的是 const 引用,不能修改。

set<int> s = {1, 2, 3};
auto it = s.find(2);
// *it = 4; // 错误!编译不通过,不能修改set中的元素

正确做法 :如果需要修改,必须先删除旧元素,再插入新元素。但要注意,这可能会使指向旧元素的迭代器失效。

auto it = s.find(2);
if (it != s.end()) {
    int newValue = 4;
    s.erase(it); // 删除旧元素
    s.insert(newValue); // 插入新元素
}

坑三:自定义比较规则不满足严格弱序 这是最隐蔽也最危险的坑。比如,你想按字符串长度排序,但如果两个字符串长度相同,你希望它们“相等”从而只保留一个。错误的写法:

// 错误的比较器:不满足严格弱序
struct BadComparator {
    bool operator()(const string& a, const string& b) const {
        return a.length() < b.length(); // 只比较长度
    }
};
set<string, BadComparator> badSet;
badSet.insert("apple");
badSet.insert("banana"); // 长度都是5,根据比较器,它们“等价”,所以不会被插入?
// 问题在于,对于set,“等价”(!comp(a,b) && !comp(b,a))意味着“相同”,但“apple”和“banana”内容不同。
// 这会导致未定义行为,程序可能崩溃或产生奇怪的结果。

正确做法 :当主要比较条件相等时,必须提供一个次要比较条件来建立全序,确保任意两个不同的元素都能比出大小。

struct GoodComparator {
    bool operator()(const string& a, const string& b) const {
        if (a.length() != b.length()) {
            return a.length() < b.length();
        }
        return a < b; // 长度相同时,按字典序比较
    }
};
set<string, GoodComparator> goodSet;
goodSet.insert("apple");
goodSet.insert("banana"); // 现在两者都会存在,且按字典序排列

坑四:忽略 insert 的返回值导致低效操作 在需要知道元素是否是新插入的场景下,忽略 insert 的返回值意味着你可能需要额外调用一次 find

// 低效
if (mySet.find(value) == mySet.end()) {
    mySet.insert(value);
    // 处理新插入的情况
}
// 高效
auto result = mySet.insert(value);
if (result.second) {
    // 处理新插入的情况,result.first 是指向新元素的迭代器
}

坑五:在循环中删除元素时迭代器处理不当 这是很多容器的通病,但在 set 中,由于迭代器相对稳定,正确的处理模式是利用 erase 的返回值。

set<int> s = {1, 2, 3, 4, 5};
// 错误做法(未定义行为)
for (auto it = s.begin(); it != s.end(); ++it) {
    if (*it % 2 == 0) {
        s.erase(it); // it 失效了,下一次循环的 ++it 行为未定义!
    }
}
// 正确做法
for (auto it = s.begin(); it != s.end(); ) {
    if (*it % 2 == 0) {
        it = s.erase(it); // erase 返回下一个有效迭代器
    } else {
        ++it;
    }
}

6. 性能测试与选型思考

理论上的复杂度是O(log n),但实际表现如何?我们通过一个简单的测试来感受一下 set unordered_set vector 在查找操作上的性能差异。这个测试虽然不严谨,但能给我们一个直观的印象。

#include <iostream>
#include <set>
#include <unordered_set>
#include <vector>
#include <algorithm>
#include <chrono>
#include <random>
using namespace std;
using namespace std::chrono;

int main() {
    const int N = 100000; // 元素数量
    const int M = 10000;  // 查找次数

    // 生成随机数
    vector<int> data(N);
    random_device rd;
    mt19937 gen(rd());
    uniform_int_distribution<> dis(1, N * 10);
    for (int i = 0; i < N; ++i) {
        data[i] = dis(gen);
    }

    // 准备容器
    set<int> s;
    unordered_set<int> us;
    vector<int> v;

    // 插入数据
    for (int num : data) {
        s.insert(num);
        us.insert(num);
        v.push_back(num);
    }
    // 对vector排序,以便使用binary_search
    sort(v.begin(), v.end());

    // 生成待查找的随机数(一半存在,一半不存在)
    vector<int> searchKeys(M);
    for (int i = 0; i < M; ++i) {
        if (i % 2 == 0) {
            searchKeys[i] = data[dis(gen) % N]; // 存在的数
        } else {
            searchKeys[i] = dis(gen) + N * 10; // 可能不存在的数
        }
    }

    // 测试 set 查找
    auto start = high_resolution_clock::now();
    int foundCount = 0;
    for (int key : searchKeys) {
        if (s.find(key) != s.end()) {
            foundCount++;
        }
    }
    auto end = high_resolution_clock::now();
    auto duration_set = duration_cast<microseconds>(end - start);
    cout << "set find() time: " << duration_set.count() << " us, found: " << foundCount << endl;

    // 测试 unordered_set 查找
    start = high_resolution_clock::now();
    foundCount = 0;
    for (int key : searchKeys) {
        if (us.find(key) != us.end()) {
            foundCount++;
        }
    }
    end = high_resolution_clock::now();
    auto duration_uset = duration_cast<microseconds>(end - start);
    cout << "unordered_set find() time: " << duration_uset.count() << " us, found: " << foundCount << endl;

    // 测试 vector 的 binary_search
    start = high_resolution_clock::now();
    foundCount = 0;
    for (int key : searchKeys) {
        if (binary_search(v.begin(), v.end(), key)) {
            foundCount++;
        }
    }
    end = high_resolution_clock::now();
    auto duration_vec = duration_cast<microseconds>(end - start);
    cout << "vector binary_search() time: " << duration_vec.count() << " us, found: " << foundCount << endl;

    return 0;
}

在我的测试环境(10万数据,1万次查找)下,结果大致如下(具体数值因机器而异,但关系稳定):

  • unordered_set :最快,通常在几百微秒级别。哈希表的O(1)查找名不虚传。
  • set :次之,通常在几千微秒级别。O(log n)的查找在数据量大时依然高效。
  • vector + binary_search :与 set 处于同一数量级,有时甚至略快,因为数组连续内存访问对CPU缓存更友好。 但是 vector 的插入和删除成本远高于 set

这个测试告诉我们:

  1. 如果只需要极快的查找,且不关心顺序 unordered_set 是王者。
  2. 如果需要有序性,或者需要频繁的插入删除 set 是平衡的选择。
  3. 如果数据基本固定,只需要一次性排序后频繁查找 ,排序后的 vector 配合 binary_search 可能是性能最好、内存最紧凑的方案,但它失去了动态插入删除的高效性。

选择容器,永远是在 功能需求 (是否需要有序、唯一)、 操作类型 (查找、插入、删除的频率)和 性能表现 之间做权衡。没有最好的容器,只有最适合当前场景的容器。

7. 延伸阅读:与set相关的其他容器

掌握了 set ,理解它的“兄弟姐妹”就很容易了。它们共享相似的设计理念和接口。

  • std::multiset :允许存储重复元素的“集合”。它的 insert 操作总是成功。 find 会返回指向第一个匹配元素的迭代器, count 会返回该元素出现的次数。 equal_range 方法在这里特别有用,可以获取所有重复元素的范围。
  • std::unordered_set (C++11):基于哈希表的集合,提供平均O(1)的查找、插入和删除。它不保证元素顺序。使用它需要你的元素类型有可用的哈希函数(标准类型如 int string 已提供,自定义类型需要特化 std::hash 或提供自定义哈希函数)。
  • std::map / std::multimap :如果你需要存储键值对(key-value pairs),而不是单独的键,那么应该使用 map (键唯一)或 multimap (键可重复)。它们的底层也是红黑树,保持了键的有序性。
  • std::unordered_map (C++11):基于哈希表的键值对容器,提供平均O(1)的访问。

当你需要 set 的功能但发现自定义类型的哈希函数比比较函数更容易实现时,或者当你完全不需要顺序时, unordered_set 是你的下一个学习目标。

回过头看, set 就像是一个严谨的图书管理员。它不允许两本完全相同的书(唯一性),并且总是按照特定的编号规则(比较器)把书整理得井井有条(有序性)。当你需要找一本书时,它不会从第一本开始漫无目的地找,而是根据编号规则快速定位到大概区域(二分查找)。这个特性使得它在处理需要“唯一标识”和“快速检索”的场景时,成为了一个不可或缺的工具。理解它的原理和脾气,你就能在合适的场合放心地使用它,避免很多不必要的性能损耗和逻辑错误。

更多推荐