C++ STL 之 set 详解:从底层红黑树到实际应用

1. 从序列式容器到关联式容器

在 STL 的庞大体系中,我们最先接触的往往是 vectorlistdequearray 以及 string 这类容器。它们有一个共同的特征:数据在逻辑结构上呈现为一条线 。 比如一个 vector<int> 存放了 10, 20, 30, 40,每个元素凭借其存储位置就能确定彼此的前后关系;即使我们交换其中两个元素的位置,比如变成 10, 30, 20, 40,它仍然是一个有效的线性结构。 这类容器被 STL 归类为序列式容器(Sequence Container),它们最关心的问题是"元素放在哪里",也就是位置关系。

但在真实的软件开发中,我们经常遇到另一类需求:判断一个数字是否曾经出现过、统计一篇英文文章中每个单词出现的频率、或者根据学生编号快速查找对应的成绩。在这些场景里,我们并不在意数据在内存中如何排列,而是更关心数据之间是否存在某种映射关系 ------比如"编号 1001 对应成绩 95"。这种"键值对"式的关联关系,是序列式容器难以高效表达的。为此,STL 专门提供了另一大类容器------关联式容器(Associative Container) ,其中最为核心的就是 mapset 以及它们的无序版本 unordered_mapunordered_set。前两者(map / set)的底层实现是一棵红黑树,后两者则基于哈希表。本章我们先深入探讨 set,因为它是理解"键值搜索"场景的基石,而 map 则是在 set 的基础上扩展出了"键值对"的映射关系。


2. set 的基本概念与核心特性

set 是 STL 中一种非常纯粹的关联式容器,它的设计目标就是存储一组互不相同的元素,并且能够按照某种顺序自动维护这些元素 。这种"自动排序"加上"元素唯一"的特性,使得 set 在很多需要去重和有序遍历的场景中显得格外顺手。

举个例子,如果我们向一个 set<int> 中依次插入 101020,那么最终容器里只会保留一个 10 和一个 20------第二次插入的 10 会被忽略。与此同时,当我们用迭代器遍历这个 set 时,输出的顺序一定是 10, 20,而不是插入时的先后顺序。这种有序性并非巧合,而是直接源于 set 的底层数据结构------红黑树 。红黑树本质是一棵平衡的二叉搜索树,它满足"左子树所有节点的值小于根节点,右子树所有节点的值大于根节点"的性质。当我们对这样一棵树进行中序遍历(左 → 根 → 右)时,得到的结果自然就是升序序列。因此,set 的迭代器遍历是有序的,而且增删查操作都能在 O(logN) 的时间复杂度内完成,这比线性容器的 O(N) 查找要高效得多。


3. 为什么底层选择红黑树而非其他结构?

这是一个非常经典的问题,也是很多初学者容易困惑的地方。既然 set 只需要存储唯一元素并支持快速查找,为什么不用哈希表(平均 O(1) 查找)?又为什么不用 vector 或链表?

我们来逐一比较:vector 的查找需要遍历整个数组,复杂度为 O(N);如果要在中间插入元素,还需要移动后续所有元素,代价很高。 链表虽然插入和删除很快(O(1)),但查找依然是 O(N),因为链表没有随机访问能力,也无法利用有序性进行二分查找。哈希表的确能提供平均 O(1) 的查找速度,但它有一个致命的弱点------无法保证元素的有序性 。例如,向哈希表中插入 528,遍历时得到的顺序可能是 852,这完全取决于哈希函数和冲突解决策略。

set 的设计目标之一就是"有序遍历",因此哈希表并不符合要求。红黑树恰好在这三者之间取得了完美的平衡:它既保证了 O(logN) 的查找、插入和删除效率,又天然维持了元素的有序性,同时它的树高被严格控制在 logN 级别,避免了二叉搜索树在最坏情况下退化为链表的窘境。正是这些综合优势,让 STL 最终选择红黑树作为 setmap 的底层实现。

红黑树

功能 复杂度
查找 O(logN)
插入 O(logN)
删除 O(logN)
有序遍历 支持

4. set 的模板参数解析

翻开 set 的声明,我们会看到这样的模板定义:

cpp 复制代码
template < class T,
           class Compare = less<T>,
           class Alloc = allocator<T> >
class set;

虽然有三个模板参数,但绝大多数情况下我们只需要关注前两个。第一个参数 T 非常直观------它表示 set 中存储的元素类型,比如 set<int> 就是存放整数的集合。

第二个参数 Compare 才是理解 set 排序机制的关键。它默认是 less<T>,这是一个函数对象(仿函数),其内部重载了 operator(),默认行为是使用 < 运算符比较两个 T 类型的值。红黑树在构建和插入节点时,正是依靠这个比较器来决定新节点应该放在左子树还是右子树。当使用 less<T> 时,树的结构遵循"左小右大",中序遍历得到升序;如果我们换成 greater<T>,比较逻辑变成 a > b,树的结构就会逆转,中序遍历自然得到降序。

所以,想要让 set 降序排列,只需这样声明:

cpp 复制代码
set<int, greater<int>> s;

至于第三个参数 Alloc,它通常用于定制内存分配策略,在一般应用中极少改动,我们暂且略过。理解 Compare 的作用,其实就是在理解红黑树的比较规则是如何影响整个容器的行为,这也为你后续学习自定义类型如何放入 set(需要重载 < 运算符或提供自定义仿函数)打下了基础。


五、set 的构造与迭代器:遍历有序,但只读

5.1 set 的构造方式

set 的构造方式和我们之前学过的 vectorlist 非常相似,STL 容器在设计上保持了高度的一致性,这让我们学习新容器的成本大大降低。

最常用的是默认构造 ,创建一个空的 set,后续再通过 insert 填入数据:

复制代码
set<int> s;

迭代器区间构造 则体现了 STL 容器之间通过迭代器解耦连接的设计思想。你可以从一个 vector 中取出一段迭代器范围,直接用来构造一个 set,从而实现"去重 + 排序"一步到位:

cpp 复制代码
vector<int> v = {1, 2, 3, 4, 2, 3};
set<int> s(v.begin(), v.end());

vector 提供 begin()end()set 的构造函数接收两个迭代器作为 firstlast,逐个插入元素。至于这组迭代器来自 vectorlist 还是原生数组,set 并不关心------这就是迭代器作为"容器之间的通用桥梁"的意义。

C++11 之后还支持了初始化列表构造,写法更加简洁直观:

cpp 复制代码
set<int> s = {5, 2, 8, 2, 1};

这里插入了两次 2,但最终 s 中只会保留一个 2,因为 set 的核心性质就是键值唯一。遍历这个 s,你会看到 1 2 5 8------已经自动排好序了。

课件中还提到了拷贝构造,用法跟其他容器完全一样,这里不再赘述。

5.2 迭代器遍历与中序

set 支持正向迭代器和反向迭代器,这意味着你可以用 begin()/end() 正向遍历,也可以用 rbegin()/rend() 反向遍历。同时,支持迭代器也就意味着支持范围 for 循环:

cpp 复制代码
set<int> s = {5, 2, 8, 1};

for (auto it = s.begin(); it != s.end(); ++it) {
    cout << *it << " ";
}
// 输出:1 2 5 8

关键问题来了:为什么输出是 1 2 5 8,而不是插入顺序 5 2 8 1

答案藏在红黑树的结构里。当我们依次插入 5、2、8、1 时,红黑树内部会按照二叉搜索树的规则组织节点:

  • 插入 5 作为根节点

  • 插入 2,比 5 小,放到左子树

  • 插入 8,比 5 大,放到右子树

  • 插入 1,比 5 小,往左走到 2,比 2 小,放到 2 的左子树

最终树的结构大致如下(忽略红黑树的颜色平衡细节):

复制代码
        5
       / \
      2   8
     /
    1

当我们用迭代器遍历 set 时,底层走的是红黑树的中序遍历 ------先左子树,再根节点,最后右子树。所以遍历顺序是:1 → 2 → 5 → 8,天然升序。

课件里有一句话非常精炼地概括了这一点:

set 底层用红黑树实现,迭代器遍历走搜索树中序,因此元素有序。

5.3 迭代器为什么不能修改数据?

这是一个极其重要且容易踩坑的问题。我们来看这段代码:

cpp 复制代码
set<int> s = {1, 2, 3};
auto it = s.begin();
*it = 10;   // 编译报错

编译器会直接报错,提示无法给常量赋值。为什么 set 的迭代器不允许修改元素?

根本原因在于:修改 set 中的元素,本质上就是在修改红黑树节点的键值(key)。而红黑树的整个结构------哪个节点在左子树、哪个在右子树------完全依赖于这些键值的大小关系。 一旦你随意修改了某个节点的值,整棵树的二叉搜索性质就可能被破坏。

举个具体的例子。假设红黑树目前的结构是:

复制代码
        5
       / \
      3   8

如果我们把根节点 5 修改成 100,树就变成了:

复制代码
        100
       /    \
      3      8

现在问题来了:100 的左子树里放着 38,它们都比 100 小,这符合"左子树 < 根节点"的规则,看起来似乎没问题。但你再想一下------节点 8 原本在 5 的右子树,现在却成了 100 的左子树,而 100 的右子树是空的。这种结构混乱会导致后续所有的查找、插入、删除操作全部失效。

为了避免这种灾难,STL 的设计者直接把 set 的迭代器设计成了只读模式。无论是 iterator 还是 const_iterator,解引用后得到的都是 const T& 引用,从语法层面彻底禁止了修改。

这里可以提前跟 map 做一个对比,帮助你建立清晰的概念边界:

  • set 中只存键(key),修改键会破坏树结构,所以键不可改

  • map 中存的是键值对(key-value),修改值(value)不影响树结构,所以值可以改,但键(key)同样不可改

这个区别会在后面讲 map 时反复体现,现在先留个印象。

六、插入操作:insert 的返回值为什么是 pair?

6.1 基本插入行为

set 的插入接口很直观,支持单个元素插入、初始化列表插入、以及迭代器区间插入:

对于单个元素的 insert,由于 set 保证了键值唯一,第二次插入相同的值会被静默忽略。比如上面的代码中,28 在初始化列表插入时已经存在于 set 中,所以实际插入的只有 39

把插入和遍历放在一起演示:

cpp 复制代码
cpp

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

int main() {
    // 去重 + 升序排序
    set<int> s;
    s.insert(5);
    s.insert(2);
    s.insert(7);
    s.insert(5);   // 重复,插入失败

    auto it = s.begin();
    while (it != s.end()) {
        // *it = 1;   // 编译错误:不能给常量赋值
        cout << *it << " ";
        ++it;
    }
    cout << endl;

    // 插入 initializer_list,已存在的值插入失败
    s.insert({2, 8, 3, 9});
    for (auto e : s) {
        cout << e << " ";
    }
    cout << endl;

    set<string> strset = {"sort", "insert", "add"};
    for (auto& e : strset) {
        cout << e << " ";   // 按 ASCII 码字典序输出
    }
    cout << endl;

    return 0;
}

运行结果:

复制代码
2 5 7
2 3 5 7 8 9
add insert sort

这个样例同时演示了三个关键点:去重(重复的 5 被忽略)、排序(升序输出)、以及迭代器只读(注释掉的赋值语句编译失败) 。最后对 string 类型的 set 进行遍历,输出按字典序排列,这验证了 set 的比较器默认使用 < 来比较元素。

6.2 insert 的返回值

接下来是重点:insert 单个元素的返回值类型是 pair<iterator, bool>。很多初学者第一次看到这个时会困惑------为什么不直接返回 bool 表示成功或失败?

我们来看两种场景。

场景一:插入成功

set<int> s;

auto ret = s.insert(10);

此时 ret.first 是指向新插入元素 10 的迭代器,ret.second 是 true。

场景二:插入失败(元素已存在)

复制代码
s.insert(10);        // 第一次插入,成功
auto ret = s.insert(10);   // 第二次插入,失败

此时 ret.first 指向已经存在于 set 中的那个 10,ret.second 是 false。

所以 pair<iterator, bool> 的设计意义在于:一次插入操作,同时告诉你两个信息------元素在哪里(迭代器),以及插入是否成功(bool)。如果插入失败,你还可以通过返回的迭代器拿到已存在的那个元素,做进一步处理。这种"状态 + 结果"打包返回的设计,在 STL 中屡见不鲜。

使用时可以这样判断:x'x

cpp 复制代码
auto ret = s.insert(10);
if (ret.second) {
    cout << "插入成功,元素位于:" << *ret.first << endl;
} else {
    cout << "插入失败,元素已存在:" << *ret.first << endl;
}

课件中把这一点放在了 mapoperator[] 实现中重点展开,因为 map[] 正是利用 insert 的这个特性来实现"查找 + 插入 + 修改"三合一的。我们现在先把 set 的接口吃透,后面讲 map 的时候就能顺理成章地理解。

七、查找操作:优先用容器自己的 find

7.1 set::find 与算法库 std::find

set 提供了 find 成员函数,用于快速查找某个键是否存在:

cpp 复制代码
set<int> s = {4, 2, 7, 8, 5, 9};
auto pos = s.find(7);
if (pos != s.end()) {
    cout << "找到了:" << *pos << endl;
}

set::find 利用红黑树的搜索特性,时间复杂度是 O(log N)。

但很多初学者容易犯一个错误------使用算法库中的 std::find

cpp 复制代码
#include <algorithm>
auto pos = find(s.begin(), s.end(), 7);

这个 find 是泛型算法,它不知道底层是红黑树,只能从 beginend 一个一个地遍历比较,时间复杂度是 O(N)。

两种写法:

cpp 复制代码
// 算法库的查找 O(N)
auto pos1 = find(s.begin(), s.end(), x);

// set 自身实现的查找 O(logN)
auto pos2 = s.find(x);

务必记住:对于关联式容器,永远优先使用容器自带的 find 成员函数。 这是性能和正确性兼得的最佳实践。

7.2 count 也可以用来查找

set 还提供了 count 成员函数,返回某个值的个数。由于 set 不允许重复,返回值只能是 0 或 1,所以它也可以用来判断元素是否存在:

cpp 复制代码
if (s.count(x)) {
    cout << x << " 存在" << endl;
} else {
    cout << x << " 不存在" << endl;
}

count 的时间复杂度也是 O(log N),用法比 find 更简洁------如果你只需要知道"在不在",而不需要获取迭代器做后续操作,用 count 更省事。

样例完整展示了 finderasecount 的配合使用:

cpp 复制代码
#include <iostream>
#include <set>
using namespace std;

int main() {
    set<int> s = {4, 2, 7, 2, 8, 5, 9};
    for (auto e : s) {
        cout << e << " ";
    }
    cout << endl;

    // 删除最小值
    s.erase(s.begin());
    for (auto e : s) {
        cout << e << " ";
    }
    cout << endl;

    // 直接删除指定值 x
    int x;
    cin >> x;
    int num = s.erase(x);
    if (num == 0) {
        cout << x << "不存在!" << endl;
    }

    for (auto e : s) {
        cout << e << " ";
    }
    cout << endl;

    // 先用 find 查找,再用迭代器删除
    cin >> x;
    auto pos = s.find(x);
    if (pos != s.end()) {
        s.erase(pos);
    } else {
        cout << x << "不存在!" << endl;
    }

    for (auto e : s) {
        cout << e << " ";
    }
    cout << endl;

    // 利用 count 间接实现快速查找
    cin >> x;
    if (s.count(x)) {
        cout << x << "在!" << endl;
    } else {
        cout << x << "不存在!" << endl;
    }

    return 0;
}

这个样例覆盖了三种删除方式(删除迭代器位置、删除指定值、删除区间)和两种查找方式(findcount),建议你自己运行一遍,观察每一步的输出,对 set 的行为建立直观感受。

八、区间查找:lower_bound 与 upper_bound

8.1 这两个接口是做什么的?

lower_boundupper_boundset 提供的两个区间查找接口,它们经常配合使用来处理一段连续的有序区间。

  • lower_bound(val) :返回第一个 大于等于 val 的元素的迭代器

  • upper_bound(val) :返回第一个 大于 val 的元素的迭代器

两者结合,可以精确锁定一个左闭右开的区间 [lower_bound(val1), upper_bound(val2))

8.2 典型使用场景:删除一个值区间

课件中有一个非常经典的例子。假设 set 中存放了 10, 20, 30, 40, 50, 60, 70, 80, 90,现在要删除所有在 [30, 60] 闭区间内的元素------也就是删掉 30, 40, 50, 60

如果用遍历加判断的方式,代码啰嗦且效率低。用 lower_boundupper_bound 可以一行定位区间:

cpp 复制代码
#include <iostream>
#include <set>
using namespace std;

int main() {
    set<int> myset;
    for (int i = 1; i < 10; i++) {
        myset.insert(i * 10);   // 10 20 30 40 50 60 70 80 90
    }

    for (auto e : myset) {
        cout << e << " ";
    }
    cout << endl;

    // lower_bound(30) 返回第一个 >= 30 的位置 → 指向 30
    auto itlow = myset.lower_bound(30);
    // upper_bound(60) 返回第一个 > 60 的位置 → 指向 70
    auto itup = myset.upper_bound(60);

    // 删除 [itlow, itup) 区间,即 30 40 50 60
    myset.erase(itlow, itup);

    for (auto e : myset) {
        cout << e << " ";
    }
    cout << endl;

    return 0;
}

运行结果:

10 20 30 40 50 60 70 80 90 10 20 70 80 90

这里的关键在于:erase 接收的是左闭右开的迭代器区间 [first, last)lower_bound(30) 恰好指向 30(包含),upper_bound(60) 指向 70(不包含 70,但 60 在区间内)。所以 erase(itlow, itup) 正好删掉了 30, 40, 50, 60

如果需求改成删除 (30, 60) 开区间(不包含 30 和 60),那就用 upper_bound(30) 作为起点,lower_bound(60) 作为终点------起点不包含 30,终点包含 60 但 lower_bound(60) 指向 60 本身,区间 [40, 60) 就只包含 40 和 50。灵活运用这两个接口,可以精确控制区间边界。

这两个函数的底层也是红黑树查找,时间复杂度 O(log N),比从头遍历要高效得多。

九、multiset:允许重复的 set

multisetset 几乎一模一样,唯一的区别是:multiset 允许键值冗余,即多个相同的元素可以共存

这个看似微小的差异,导致了一系列行为上的不同,需要你特别留意。

课件中给出了一个完整的 multiset 使用样例:

cpp 复制代码
#include <iostream>
#include <set>
using namespace std;

int main() {
    // multiset 排序但不去重
    multiset<int> s = {4, 2, 7, 2, 4, 8, 4, 5, 4, 9};

    auto it = s.begin();
    while (it != s.end()) {
        cout << *it << " ";
        ++it;
    }
    cout << endl;

    // find 查找中序的第一个
    int x;
    cin >> x;
    auto pos = s.find(x);
    while (pos != s.end() && *pos == x) {
        cout << *pos << " ";
        ++pos;
    }
    cout << endl;

    // count 返回实际个数
    cout << s.count(x) << endl;

    // erase 按值删除会删除所有匹配的元素
    s.erase(x);
    for (auto e : s) {
        cout << e << " ";
    }
    cout << endl;

    return 0;
}

我们来逐一拆解这些差异:

第一,insert 永远成功。multiset 中插入一个已经存在的值,不会像 set 那样被忽略,而是会新增一个副本。所以 multiset 中的元素是排序的,但不去重。

第二,find 返回中序的第一个匹配位置。 当有多个相同的值时,find 返回的是中序遍历中第一个等于该值的位置。如果你想遍历所有相同的值,需要从 find 返回的位置开始往后走,直到遇到不同的值为止(如样例中的 while 循环所示)。

第三,count 返回实际个数。setcount 只能返回 0 或 1,但在 multiset 中它会返回匹配元素的实际数量。

第四,erase 按值删除会删除所有匹配的元素。 s.erase(x) 会把所有值为 x 的元素全部删掉,返回删除的个数。这一点和 set 的"最多删一个"完全不同。

multiset 的使用场景是:你需要保留所有数据(不去重),同时又希望它们始终保持有序。比如记录一组考试成绩,允许并列分数存在,但需要按分数从低到高遍历。

十、两道力扣题:set 如何让复杂问题变得简单

最后用两道力扣题展示了 set 的实战价值,我们简要拆解一下,让你感受"用对工具"的力量。

两个数组的交集

题目要求返回两个数组的交集,且结果中每个元素只能出现一次。

set 的解法极其简洁:

cpp 复制代码
cpp

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

int main() {
    // multiset 排序但不去重
    multiset<int> s = {4, 2, 7, 2, 4, 8, 4, 5, 4, 9};

    auto it = s.begin();
    while (it != s.end()) {
        cout << *it << " ";
        ++it;
    }
    cout << endl;

    // find 查找中序的第一个
    int x;
    cin >> x;
    auto pos = s.find(x);
    while (pos != s.end() && *pos == x) {
        cout << *pos << " ";
        ++pos;
    }
    cout << endl;

    // count 返回实际个数
    cout << s.count(x) << endl;

    // erase 按值删除会删除所有匹配的元素
    s.erase(x);
    for (auto e : s) {
        cout << e << " ";
    }
    cout << endl;

    return 0;
}
复制代码

两个 set 各自完成了"去重 + 排序",然后双指针同步遍历,值相等就是交集。这个解法的优雅之处在于:你不用手动排序、不用手动去重、不用考虑重复元素会多次加入结果------set 把底层脏活累活全包了。

142. 环形链表 II

这道题的常规解法需要快慢指针加数学推导,证明过程比较绕。用 set 来解,思路直接降维到"记录已访问节点":

cpp 复制代码
class Solution {
public:
    ListNode* detectCycle(ListNode* head) {
        set<ListNode*> s;
        ListNode* cur = head;
        while (cur) {
            auto ret = s.insert(cur);
            if (!ret.second) {
                return cur;   // 插入失败,说明 cur 之前访问过,这就是环的入口
            }
            cur = cur->next;
        }
        return nullptr;
    }
};

遍历链表,把每个节点的地址存入 set。如果某个节点已经存在,说明链表有环,而且这个节点就是环的入口。这就是 set 的"去重 + 快速查找"能力在算法题中的降维打击------把复杂问题变成了"查重"问题。

十一、小结

这一部分我们完整覆盖了 set 的接口使用层面:构造方式、迭代器遍历(以及为什么不能修改)、插入(重点理解 pair<iterator, bool> 的设计意图)、查找(优先用容器自带的 find)、删除(三种形式的适用场景)、以及 lower_bound/upper_bound 配合处理区间的技巧。最后通过 multiset 的对比和两道力扣题,帮你建立起"什么场景用什么工具"的判断力。

理解 set 的接口设计,本质上是在理解一个原则:底层数据结构(红黑树)的特性,决定了上层接口的行为边界。 红黑树有序,所以 set 有序;红黑树靠比较规则维护结构,所以 set 的键不能改;红黑树查找 O(log N),所以 set::findstd::find 快得多。把这条线索理清,set 就不再是一个需要死记硬背接口的容器,而是一个你可以自如运用的工具。

相关推荐
520拼好饭被践踏2 小时前
JAVA+Agent学习day26
java·开发语言·数据结构·学习·agent
liulilittle2 小时前
无锁多生产者多消费者队列的工程实现剖析——算法、内存模型与性能
算法·多线程·并发·无锁·mpmc·lock-free
郝学胜-神的一滴2 小时前
力扣 692:巧用小顶堆高效求解前K个高频单词
java·数据结构·python·程序人生·算法·leetcode·职场和发展
157092511342 小时前
优先队列:从Java源码到实战
开发语言·python
科学实验家2 小时前
贪心算法:分饼干
算法·贪心算法
誰能久伴不乏2 小时前
深入解析 C++ STL 底层原理:从设计哲学到内存收割机
c++·架构·stl
额,不知道写啥。3 小时前
题解:P16710 愿望
数据结构·算法
jinyishu_3 小时前
C++继承与多态进阶
开发语言·c++·程序人生
程序猿编码3 小时前
用 C++ 从零写一个能训练的 GPT:标量自动微分 + Arena 内存池
开发语言·c++·gpt·transformer·模型推理