【C++ STL篇(八)】set容器——零基础入门与核心用法精讲

C++ STL篇(八) ------ set 讲解

**  本篇文章将带你从零开始,一步步掌握 set的核心用法 。全程干货,坐稳发车~ ദ്ദി˶ー̀֊ー́ )✧**

文章目录

  • [C++ STL篇(八) ------ set 讲解](#C++ STL篇(八) —— set 讲解)
    • 1.序列式容器与关联式容器
      • [1.1 序列式容器](#1.1 序列式容器)
      • [1.2 关联式容器](#1.2 关联式容器)
    • [2. set 系列的初步认识](#2. set 系列的初步认识)
      • [2.1 参考资料](#2.1 参考资料)
      • [2.2 set 类的声明拆解](#2.2 set 类的声明拆解)
      • [2.3 构造与迭代器](#2.3 构造与迭代器)
      • [2.4 增删查核心操作](#2.4 增删查核心操作)
        • [2.4.1 插入 insert](#2.4.1 插入 insert)
        • [2.4.2 查找 find 和 count](#2.4.2 查找 find 和 count)
        • [2.4.3 删除 erase](#2.4.3 删除 erase)
        • [2.4.4 边界查找 lower_bound 与 upper_bound](#2.4.4 边界查找 lower_bound 与 upper_bound)
    • [3. 跟着代码彻底弄懂每个细节](#3. 跟着代码彻底弄懂每个细节)
      • [3.1 insert 与迭代器遍历](#3.1 insert 与迭代器遍历)
      • [3.2 find 和 erase 的正确使用](#3.2 find 和 erase 的正确使用)
      • [3.3 lower_bound 与 upper_bound 实现区间操作](#3.3 lower_bound 与 upper_bound 实现区间操作)
    • [4. multiset](#4. multiset)
    • [5. 实战演练](#5. 实战演练)
      • [5.1 两个数组的交集](#5.1 两个数组的交集)
      • [5.2 环形链表](#5.2 环形链表)
    • [6. 常见误区](#6. 常见误区)
      • [6.1 绝不能修改元素](#6.1 绝不能修改元素)
      • [6.2 迭代器失效](#6.2 迭代器失效)
      • [6.3 优先使用成员函数](#6.3 优先使用成员函数)
      • [6.4 为什么 set 没有下标操作?](#6.4 为什么 set 没有下标操作?)
    • 结语:

1.序列式容器与关联式容器

在深入 set 之前,我们先搞清楚 STL 容器大家族里的两大派系。

1.1 序列式容器

我们已经学过的 stringvectorlistdequearrayforward_list 等,都属于序列式容器 。你可以把它们想象成一排储物柜,每个元素按照插入的顺序占据一个位置。比如你依次存入 A、B、C,那它们在内存(或逻辑上)就按照 A->B->C 的顺序排列。即使你把 A 和 B 交换一下位置,容器本身并不会因此"坏掉",依然是一个合法的序列。所以,序列式容器中元素之间的关联很松散,主要靠存储位置来组织和访问

1.2 关联式容器

关联式容器 则完全不一样。它的逻辑结构通常是非线性的(比如树形结构),元素之间存在着某种"紧密的关联关系"------这种关系由 关键字(key) 来维持。如果你胡乱交换两个元素的位置,这个容器的结构就会被破坏因为它不是靠位置来记忆元素的,而是靠关键字和它们之间的排序规则。

常见的关联式容器分为两大类:

  • 有序关联容器map / set 系列,底层基于红黑树 (一种平衡二叉搜索树),元素按照键值自动排序
  • 无序关联容器unordered_map / unordered_set 系列,底层基于哈希表,元素位置由哈希值决定,不保证顺序。

本文的主角是 set 和它的兄弟 multiset ,它们都基于红黑树实现,专门用于"关键字就是值本身"的场景(即 key 搜索模型)。而 map 则是"关键字-值"对(key/value 模型)的场景。今天我们先彻底拿下 set


2. set 系列的初步认识

2.1 参考资料

关于 set 的官方参考可以查阅:

2.2 set 类的声明拆解

我们先来看一下 set 的类模板声明:

cpp 复制代码
template <
    class T,                          // 关键字类型
    class Compare = std::less<T>,     // 比较函数对象
    class Alloc = std::allocator<T>   // 空间配置器(内存管理)
> class set;

这三个模板参数分别代表了:

  1. T :你打算存储的元素的类型。在 set 里,元素的值就是它的键,因此也叫 key_typevalue_type,它们俩是同一回事。
  2. Compare :如何比较两个元素的大小。默认是 std::less<T>,也就是用 < 运算符(升序)。set 默认要求你的类型支持 < 比较。如果你想自定义排序规则,可以自己写一个仿函数(函数对象)传进来。
  3. Alloc:控制内存分配的方式,默认使用标准分配器。绝大多数情况下我们不需要动它。

关键点: set 的底层是红黑树(我们之后的文章中会对红黑树进行深入讲解),它具备以下核心特性:

  • 自动排序 :元素始终按 Compare 规则保持有序,默认是升序。
  • 唯一性:不允许有重复的关键字,同一个值只能出现一次。
  • 高效操作 :插入、删除、查找的平均时间复杂度都是 O(log N)
  • 迭代器中序遍历 :当你用迭代器遍历时,会得到一个有序序列

通常情况下,我们只需要传第一个参数,后两个保持默认就够用了。

2.3 构造与迭代器

set 提供了多种构造方法,我们可以重点关注以下几个最常用的:

cpp 复制代码
// 1. 默认构造:创建一个空 set
explicit set(const Compare& comp = Compare(),
             const Alloc& alloc = Alloc());

// 2. 迭代器区间构造:用一段 [first, last) 区间内的元素初始化 set
template <class InputIterator>
set(InputIterator first, InputIterator last,
    const Compare& comp = Compare(),
    const Alloc& = Alloc());

// 3. 拷贝构造
set(const set& x);

// 4. 初始化列表构造(C++11)
set(std::initializer_list<value_type> il,
    const Compare& comp = Compare(),
    const Alloc& alloc = Alloc());

例如:

cpp 复制代码
set<int> s1;                           // 空集合
set<int> s2 = {3, 1, 4, 1, 5};        // 初始化列表,注意重复的 1 只会保留一个
set<int> s3(s2.begin(), s2.end());     // 迭代器区间构造

迭代器 是访问 set 元素的核心工具。set 提供了双向迭代器,支持正向和反向遍历。

一个极为重要的特性:

set 的迭代器不允许修改元素的值 。也就是说,无论是 iterator 还是 const_iterator,你都不能通过 *it = ... 来赋值。尝试这样做会导致编译错误。为什么?因为 set 中的元素是自动排序的,如果你偷偷改了某个键的值,整个红黑树的结构就会被破坏,导致未定义行为。所以标准库直接将迭代器指向的元素视为常量,从根本上禁止修改。

由于支持迭代器,set 自然也支持范围 for 循环,遍历起来非常方便。


2.4 增删查核心操作

下面我们来看看 set 最常用的增删查接口。这些接口和 vectorlist 很像,但由于 set 的关联特性,参数和返回值会有一些不同。

2.4.1 插入 insert
cpp 复制代码
// 1. 插入单个值,返回一个 pair:first 是指向插入元素(或已存在元素)的迭代器,
//    second 是 bool,表示是否真的插入了新元素
pair<iterator, bool> insert(const value_type& val);

// 2. 插入初始化列表中的值(已存在的不会重复插入,也没有返回值告诉你哪些没插入)
void insert(std::initializer_list<value_type> il);

// 3. 插入一段迭代器区间
template <class InputIterator>
void insert(InputIterator first, InputIterator last);

单个插入的返回值非常有用:pair 的第一个成员是一个迭代器,指向刚插入的那个新元素 ,如果元素已经存在,就指向那个已存在的元素。第二个成员是 bool,表示是否插入成功。你可以用它来判断某个值是不是之前就已经存在。

2.4.2 查找 find 和 count
cpp 复制代码
// 在 set 中查找 val,返回指向该元素的迭代器,若没找到则返回 end()
iterator find(const value_type& val);
const_iterator find(const value_type& val) const;

// 返回 val 的元素个数,由于 set 不允许重复,结果只能是 0 或 1
size_type count(const value_type& val) const;

find 的复杂度是 O(log N) ,远比算法库里的通用 std::find(复杂度 O(N))高效。永远优先使用 set 自己的 find

count 对于 set 来说,通常用于检查某个元素是否存在:返回 1 表示存在,返回 0 表示不存在

2.4.3 删除 erase
cpp 复制代码
// 删除一个迭代器指向的元素,返回被删除元素的下一个迭代器(C++11起)
iterator erase(const_iterator position);

// 删除值为 val 的元素,返回实际删除的元素个数(0 或 1)
size_type erase(const value_type& val);

// 删除 [first, last) 区间的元素,返回 last
iterator erase(const_iterator first, const_iterator last);
  • 使用迭代器删除时,要确保迭代器有效,而且删除后该迭代器会失效,不能再使用。
  • 按值删除会返回删掉了几个,对于 set 来说就是 0 或 1(set 中的值不重复)。
2.4.4 边界查找 lower_bound 与 upper_bound

这两个函数在处理区间问题时特别有用:

cpp 复制代码
// 返回第一个 >= val 的位置的迭代器
iterator lower_bound(const value_type& val);
const_iterator lower_bound(const value_type& val) const;

// 返回第一个 > val 的位置的迭代器
iterator upper_bound(const value_type& val);
const_iterator upper_bound(const value_type& val) const;

lower_boundupper_bound 加起来可以精确划定一个范围:[lower_bound(a), upper_bound(b)) 包含所有满足 a ≤ x ≤ b 的元素。你可以利用这个区间进行批量删除或遍历。


3. 跟着代码彻底弄懂每个细节

下面我们结合具体的代码,一块一块地分析。

3.1 insert 与迭代器遍历

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

int main()
{
    // set<int> s;              // 默认升序(从小到大)
    set<int, greater<int>> s;   // 传入 greater<int>,改为降序(从大到小)

    s.insert(4);
    s.insert(8);
    s.insert(4);   // 重复的 4,不会插入成功
    s.insert(2);
    s.insert(9);

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

    // 插入初始化列表中的值,已经存在的值(如 8)插入失败,4 也不会再出现
    s.insert({6, 8, 7, 1, 5});
    for (auto e : s)
    {
        cout << e << " ";
    }
    cout << endl;

    // 存放字符串的 set,默认按照字符串的字典序(ASCII码)排序
    set<string> strset({"sort", "insert", "add"});
    for (auto& e : strset)
    {
        cout << e << " ";
    }
    cout << endl;

    return 0;
}

解析:

  • 降序排列set<int, greater<int>> s; 通过第二个模板参数把比较器换成了 greater<int>。原本用 less<int>,比较规则是"小于",换成 greater 后,比较规则变成了"大于",所以中序遍历结果是降序。输出时你会看到从大到小排列。
  • 重复插入s.insert(4); 执行两次,第二次插入时 4 已经存在,操作会失败。set 里仍然只有一个 4
  • 不能修改元素 :如果写*it = 1; ,编译器会直接报错,因为 set 的迭代器指向的是常量。这保护了底层的结构不被破坏。
  • 初始化列表插入s.insert({6,8,7,1,5}); 会把列表中的每个元素尝试插入。对于已经存在的元素(比如之前就有的 84),会直接跳过。最终容器里是所有出现过的唯一值,并按降序排列。
  • 字符串 setset<string> 默认按照字典序比较,所以输出顺序是 "add", "insert", "sort"(因为 'a' < 'i' < 's')。

3.2 find 和 erase 的正确使用

cpp 复制代码
int main()
{
    set<int> s = {3, 9, 2, 6, 8, 1, 4, 7};
    for (auto e : s) 
    {
        cout << e << " ";    // 输出:1 2 3 4 6 7 8 9 (自动升序)
    }
    cout << endl;

    // 删除最小的元素(s.begin() 指向最小)
    s.erase(s.begin());
    for (auto e : s) 
    {
        cout << e << " ";    // 2 3 4 6 7 8 9
    }
    cout << endl;

    // 方法1:直接按值删除 x
    int x;
    cin >> x;
    int num = s.erase(x);
    if (num == 0)
        cout << x << "不存在!" << endl;
    else
        cout << x << "删除成功!" << endl;

    // 方法2:先用 find 找到迭代器,再删除
    cin >> x;
    auto pos = s.find(x);
    if (pos != s.end())
    {
        s.erase(pos);     // 删除后 pos 失效
        cout << x << "删除成功!" << endl;
        // cout << *pos;  // 不能使用失效的迭代器!!
    }
    else {
        cout << x << "不存在" << endl;
    }

    // 算法库的 find(不推荐用于 set)
    auto pos1 = std::find(s.begin(), s.end(), x);   // O(N)
    auto pos2 = s.find(x);                           // O(logN)

    // 利用 count 快速判断元素是否存在
    cin >> x;
    if (s.count(x))
        cout << x << "存在!" << endl;
    else
        cout << x << "不存在!" << endl;

    return 0;
}

核心要点:

  • 删除 begin()s.erase(s.begin()) 会移除当前最小的元素。注意删除后,原来的 s.begin() 迭代器失效了,但 s 本身依然有效,下一次调用 begin() 会指向新的最小元素。
  • 按值删除 erase(x):返回 0 或 1,可以直接用来判断该值是否存在于集合中。这是一种既判断又删除的简洁写法。
  • 先 find 后 erasepos = s.find(x) 如果成功,pos 就是一个有效的迭代器。传给 erase(pos) 后,pos 立即失效 ,不能再解引用或递增。代码中注释掉的那行 cout << *pos; 一旦执行,行为是未定义的,可能导致崩溃或读取垃圾数据。
  • 效率差异std::find(s.begin(), s.end(), x)set 上执行时是暴力遍历,复杂度 O(N);而 s.find(x) 利用红黑树,复杂度 O(log N)。在数据量大时,两者差距惊人,一定要用成员函数 find
  • count 判段是否存在s.count(x) 要么是 0 要么是 1,非常适合做条件判断,比 find 略简洁一些,但如果你还需要拿到迭代器做进一步操作,find 更好。

3.3 lower_bound 与 upper_bound 实现区间操作

cpp 复制代码
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 << " ";       // 10 20 30 40 50 60 70 80 90
    }
    cout << endl;

    // 想要获取在 [30, 50] 范围内的所有元素
    auto itlow = myset.lower_bound(30);   // 返回第一个 >=30 的位置,即 30
    auto itup  = myset.upper_bound(50);   // 返回第一个 >50 的位置,即 60

    // 演示更宽泛的范围 [25, 55]
    itlow = myset.lower_bound(25);   // >=25,实际指向 30
    itup  = myset.upper_bound(55);   // >55,实际指向 60

    // 删除 [itlow, itup) 区间内的所有元素
    myset.erase(itlow, itup);
    for (auto e : myset) {
        cout << e << " ";       // 输出 10 20 60 70 80 90
    }
    cout << endl;

    return 0;
}

这段代码展示了如何删除一个闭区间内的所有元素。

  • 初始集合包含 10 的倍数:10, 20, 30, 40, 50, 60, 70, 80, 90。
  • lower_bound(25) 找到第一个 ≥ 25 的元素。由于 20 < 25,下一个是 30,所以返回指向 30 的迭代器。
  • upper_bound(55) 找到第一个 > 55 的元素。55 之后第一个是 60,所以返回指向 60 的迭代器。
  • 区间 [itlow, itup) 包含了 30, 40, 50。erase(itlow, itup) 会删除这三个元素,剩下 10, 20, 60, 70, 80, 90。

小技巧: 如果你想获取包含边界 [a, b] 的所有元素,就用 lower_bound(a)upper_bound(b),然后作用于 erase 或遍历。


4. multiset

multisetset 的用法几乎一模一样,唯一的本质区别就是:multiset 允许存在多个相同的关键字 ,也就是允许值冗余。这一差异导致了 insertfindcounterase 的行为有所不同。

cpp 复制代码
int main()
{
    // multiset 只排序,不去重
    multiset<int> s = {3, 9, 2, 6, 8, 1, 4, 7, 2, 6, 4, 4};
    auto it = s.begin();
    while (it != s.end()) 
    {
        cout << *it << " ";   // 输出:1 2 2 3 4 4 4 6 6 7 8 9
        ++it;
    }
    cout << endl;

    // find 查找 x:如果有多个,返回中序遍历的第一个 x
    int x;
    cin >> x;
    auto pos = s.find(x);
    while (pos != s.end() && *pos == x) 
    {
        cout << *pos << " ";
        ++pos;
    }
    cout << endl;

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

    // erase 按值删除:会把所有的 x 都删掉
    // 如果你只想删一个,需要用迭代器删除
    pos = s.find(x);
    while (pos != s.end() && *pos == x) 
    {
        pos = s.erase(pos);   // erase 返回下一个迭代器,所以可以安全地继续
    }
    cout << endl;

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

    return 0;
}

对比 set 逐一理解:

  • 构造和插入multiset<int> 可以接收重复值,构造后它会自动排序,但不会去重。输出中 4 出现了 3 次,26 各出现 2 次。
  • find 的行为s.find(x) 在有多个匹配时,返回的是中序遍历遇到的第一个 x。然后我们可以在循环里依次向后遍历,直到遇到的值不再是 x,这样就访问了所有等于 x 的元素。
  • count 的含义不同了 :在 multiset 里,count(x) 返回的是实际存在多少个 x,可能大于 1。
  • erase 按值删除十分危险s.erase(x)删除所有等于 x 的元素 !如果你只想删除其中一个,必须通过迭代器 s.erase(pos) 来精确删除某个位置。代码中使用了一个循环,每次 erase(pos) 返回被删除元素的下一个位置,将其赋给 pos 可以安全地继续向后清理所有 x

简单总结:

特性 set multiset
是否允许重复元素
insert 返回值 pair<iterator, bool> iterator(总成功)
find 返回唯一元素或 end() 返回中序第一个
count 0 或 1 实际个数
erase(val) 删除该值(0 或 1 个) 删除所有该值

5. 实战演练

5.1 两个数组的交集

传送门:两个数组的交集

思路拆解:

  1. 利用set完成 去重+排序
  2. 迭代器遍历两个有序集合,找交集
  • 两个迭代器都从头开始遍历两个有序集合;

  • 如果迭代器指向的值不相等:指向的值比较小的那个迭代器向后移动(有序序列越往右数越大,更小的数永远不可能匹配对面后方更大的数,直接舍弃、迭代器后移即可。)

  • 如果相等:说明这是交集元素,加入结果集,然后两个迭代器都向后移动(因为集合里没有重复元素,这个数不会再出现了)。

代码示列

cpp 复制代码
class Solution {
public:
    vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
        set<int> s1(nums1.begin(),nums1.end());
        set<int> s2(nums2.begin(),nums2.end());

        vector<int> v;
        auto it1 = s1.begin();
        auto it2 = s2.begin();
        while(it1 != s1.end() && it2 != s2.end())
        {
            if(*it1 < *it2)
            {
                ++it1;
            }
            else if(*it1 > *it2)
            {
                ++it2;
            }
            else{
                v.push_back(*it1);
                ++it1;
                ++it2;
            }
        }
        return v;
    }
};

这里再补充一个小问题:如果要找差集怎么办?

  1. 迭代器指向的值更小的就是差集,加入结果集,然后让值更小的迭代器向后移动。
  2. 如果两个迭代器相等 ,说明这个元素在两个集合里都存在,不属于差集,两个迭代器同时往后走。
  3. 其中一个走到尾部了,另一个没走完的值也是属于差集

5.2 环形链表

传送门:环形链表

思路拆解:

利用 set 存储已经访问过的节点指针,遍历链表时,第一次遇到重复访问的节点,就是环的入口。

  1. 创建一个集合,用来存储已经访问过的节点指针
  2. 遍历链表,直到节点为空(无环)或找到重复节点(有环)。
  3. 遍历结束没找到重复节点,说明链表无环,返回 nullptr

代码示例:

cpp 复制代码
class Solution {
public:
    ListNode *detectCycle(ListNode *head) {
        set<ListNode*> s;//用来存储已经访问过的节点
        ListNode* cur = head;
        while(cur)
        {
            //count 可以判断节点是否存在
            if(!s.count(cur))
            {
                s.insert(cur);
                cur = cur->next;
            }
            else  //找到了重复的节点
                return cur;
        }

        return nullptr;
    }
};

6. 常见误区

6.1 绝不能修改元素

*it = new_valueset 中是行不通的。如果你需要修改一个元素,只能先 erase 旧值,再 insert 新值。

6.2 迭代器失效

set 的插入操作不会使任何已有的迭代器失效;删除操作只会使指向被删除元素的迭代器失效,其它迭代器依然有效。这与 vector 有很大不同。代码中删除后继续使用 pos 是个常见的坑,务必重新赋值或提前备份。

6.3 优先使用成员函数

set 的成员函数 findlower_bound 等是专为树结构优化的,复杂度 O(log N)。不要图方便去用 <algorithm> 里全局的 std::find,那会变成线性遍历。

6.4 为什么 set 没有下标操作?

set 不是序列容器,元素的位置由排序规则决定,而不是线性索引。如果你需要"第 k 个元素",只能通过迭代器 + 循环,但那样做效率较低,通常说明 set 可能不是最适合你的结构。可以考虑在特定场景下使用 vector 并保持排序,或结合其它数据结构。


结语:

今天的内容到这里就结束了,希望你能有所收获~

干货整理到手抖,觉得有用的话,赏个三连回回血?__(:ᗤ」ㄥ)_ _

相关推荐
许长安1 小时前
Kafka 架构讲解:从提交日志到分区副本机制
c++·经验分享·笔记·分布式·架构·kafka
邪修king1 小时前
UE5 TA 核心修炼:材质与纹理艺术全解 —— 从 PBR 理论到工业级材质实战
c++·后端·游戏·ue5·材质
c#上位机1 小时前
C#项目中打包文件的三种方式
开发语言·c#
hehelm1 小时前
C++ 特殊类设计
开发语言·c++
吃好睡好便好1 小时前
在Matlab中绘制圆锥三维曲面图
开发语言·人工智能·学习·算法·matlab·信息可视化
摇滚侠1 小时前
并发编程 Java 面试题 真正的 offer 偏方 Java 基础 Java 高级
java·开发语言
z200509301 小时前
【linux学习】linux基本指令02
学习
JAVA学习通3 小时前
北京明光云振铎数据科技Java面经
java·开发语言·科技
星幻元宇VR3 小时前
VR心理健康学习机|沉浸式心理教育新模式
科技·学习·安全·vr·虚拟现实