C++ STL之set/multiset详解:从使用到底层,再到面试八股

C++ STL之set/multiset详解:从使用到底层,再到面试八股

本文面向面试和日常开发,先讲调用,再讲原理,最后给口语化面试答案。


一、用法速查

1.1 头文件与初始化

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

int main() {
    // 默认构造(升序)
    set<int> s1;

    // 初始化列表(C++11)
    set<int> s2{3, 1, 4, 1, 5, 9};
    for (int x : s2) cout << x << " ";   // 1 3 4 5 9(自动去重+排序)
    cout << "\n";

    // 拷贝构造
    set<int> s3(s2);
    set<int> s4 = s2;

    // 迭代器区间构造
    vector<int> v{2, 3, 1, 2};
    set<int> s5(v.begin(), v.end());      // 1 2 3

    // 降序
    set<int, greater<int>> s6{3, 1, 4};
    for (int x : s6) cout << x << " ";    // 4 3 1
    cout << "\n";

    // 自定义比较器(lambda + C++17 CTAD)
    auto cmp = [](int a, int b) { return a > b; };
    set<int, decltype(cmp)> s7(cmp);
    s7.insert({1, 2, 3});
    for (int x : s7) cout << x << " ";    // 3 2 1
    cout << "\n";
}

1.2 插入:insert() 返回值

insert() 返回 pair<iterator, bool>——这是 set 区别于其他容器的标志性特征。second 指示是否真正插入了新元素。

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

int main() {
    set<int> s{1, 2, 3};

    auto [it, inserted] = s.insert(2);   // C++17 结构化绑定
    cout << inserted << "\n";             // 0,插入失败(已存在)
    cout << *it << "\n";                  // 2,指向已有元素

    auto [it2, inserted2] = s.insert(0);
    cout << inserted2 << "\n";            // 1,插入成功
    cout << *it2 << "\n";                 // 0

    // multiset 没有 pair<iterator,bool>——总是成功
    multiset<int> ms{1, 2, 2, 3};
    auto mit = ms.insert(2);              // 返回迭代器,无 bool
    cout << *mit << "\n";                 // 2
}

1.3 删除

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

void print(const set<int>& s) {
    for (int x : s) cout << x << " ";
    cout << "\n";
}

int main() {
    set<int> s{1, 2, 3, 4, 5};

    s.erase(3);                          // 按值删除,返回删除个数(0或1)
    print(s);                            // 1 2 4 5

    auto it = s.find(2);
    s.erase(it);                         // 按迭代器删除
    print(s);                            // 1 4 5

    // 区间删除 [first, last)
    auto first = s.lower_bound(1);
    auto last  = s.upper_bound(4);
    s.erase(first, last);
    print(s);                            // 5

    // multiset 注意:erase(val) 会删除所有相同值的元素
    multiset<int> ms{1, 2, 2, 2, 3};
    ms.erase(2);                         // 删掉所有 2
    for (int x : ms) cout << x << " ";   // 1 3
    cout << "\n";

    // 只删一个:用迭代器
    multiset<int> ms2{1, 2, 2, 2, 3};
    ms2.erase(ms2.find(2));
    for (int x : ms2) cout << x << " ";  // 1 2 2 3
    cout << "\n";
}

1.4 查找

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

int main() {
    set<int> s{10, 20, 30, 40, 50};

    // find() ——返回迭代器
    auto it = s.find(20);
    if (it != s.end())
        cout << "found: " << *it << "\n";  // found: 20

    // count() ——set 中返回 0 或 1
    cout << s.count(20) << "\n";            // 1
    cout << s.count(99) << "\n";            // 0

    // contains() ——C++20,语义最清晰
    if (s.contains(30))
        cout << "30 exists\n";

    // multiset 中 count 可能 > 1
    multiset<int> ms{1, 2, 2, 2, 3};
    cout << ms.count(2) << "\n";            // 3
}

1.5 边界查询:lower_bound / upper_bound / equal_range

这是有序关联容器独有的优势,unordered_set 做不到。

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

int main() {
    set<int> s{10, 20, 30, 40, 50};

    // lower_bound(val):第一个 >= val 的元素
    auto lb = s.lower_bound(25);
    cout << *lb << "\n";                    // 30

    // upper_bound(val):第一个 > val 的元素
    auto ub = s.upper_bound(30);
    cout << *ub << "\n";                    // 40

    // equal_range(val):返回 [lb, ub) 区间
    auto [lo, hi] = s.equal_range(30);
    cout << *lo << " " << *hi << "\n";      // 30 40

    // 区间查询:找出所有值在 [25, 45] 之间的元素
    auto start = s.lower_bound(25);
    auto end   = s.upper_bound(45);
    for (auto it = start; it != end; ++it)
        cout << *it << " ";                 // 30 40 50
    cout << "\n";

    // multiset 中 equal_range 可能返回多元素区间
    multiset<int> ms{1, 2, 2, 2, 3, 4};
    auto [mlo, mhi] = ms.equal_range(2);
    for (auto it = mlo; it != mhi; ++it)
        cout << *it << " ";                 // 2 2 2
    cout << "\n";
}

1.6 遍历与有序性证明

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

int main() {
    set<int> s{5, 1, 4, 2, 3};

    // 迭代器遍历——结果始终升序(中序遍历)
    for (auto it = s.begin(); it != s.end(); ++it)
        cout << *it << " ";                 // 1 2 3 4 5
    cout << "\n";

    // 范围 for
    for (int x : s) cout << x << " ";       // 1 2 3 4 5
    cout << "\n";

    // 反向遍历
    for (auto it = s.rbegin(); it != s.rend(); ++it)
        cout << *it << " ";                 // 5 4 3 2 1
    cout << "\n";
}

1.7 常用函数速查

方法含义复杂度
s.insert(val)插入,返回 pair<iterator,bool>O(logN)
s.erase(val)按值删除,返回删除个数O(logN)
s.erase(iter)按迭代器删除O(logN) 均摊 O(1)
s.find(val)查找,返回迭代器O(logN)
s.count(val)set 返回 0/1,multiset 返回实际个数O(logN)
s.contains(val)C++20,存在返回 trueO(logN)
s.lower_bound(val)第一个 ≥ val 的迭代器O(logN)
s.upper_bound(val)第一个 > val 的迭代器O(logN)
s.equal_range(val)返回 [lower_bound, upper_bound)O(logN)
s.size()元素个数O(1)
s.empty()判空O(1)
s.clear()清空所有元素O(N)

1.8 set vs multiset 对比

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

int main() {
    // set:唯一 key
    set<int> s{1, 2, 2, 3};
    cout << s.size() << "\n";              // 3(去重了)

    // multiset:允许重复 key
    multiset<int> ms{1, 2, 2, 3};
    cout << ms.size() << "\n";             // 4(保留重复)

    // insert 返回值不同
    auto [it, ok] = s.insert(2);           // ok == false
    auto mit = ms.insert(2);               // 直接返回迭代器,无 bool
    cout << *mit << "\n";                  // 2

    // erase(val) 行为不同
    ms.erase(2);                           // 删掉所有 2
    cout << ms.size() << "\n";             // 2(只剩 1 和 3)

    // equal_range 在 multiset 中更实用
    multiset<int> ms2{1, 2, 2, 2, 3, 4};
    auto [lo, hi] = ms2.equal_range(2);
    cout << distance(lo, hi) << "\n";      // 3(三个 2)
}

二、底层原理

2.1 红黑树:从 map 到 set 是同一棵树的复用

setmap 的底层是同一套红黑树模板——gcc 中叫 _Rb_treeset<K> 实际上就是一个 _Rb_tree<K, K, _Identity<K>, Compare>,而 map<K,V>_Rb_tree<K, pair<const K, V>, _Select1st<pair<const K,V>>, Compare>

两者的差异只体现在节点存储的数据类型上:set 的节点只存 key,map 的节点存 pair<const K, V>。树的搜索、插入、旋转逻辑完全共用同一份代码。

红黑树为 set 带来的三个核心特性:

  1. 有序遍历 O(N):中序遍历左→根→右得到升序序列
  2. 查找/插入/删除 O(logN):树高 2log₂(n+1),不需要遍历所有元素
  3. 迭代器不因插入失效:指针连接的结构,插入新节点不影响已有节点的地址

2.2 红黑树五条性质

红黑树不是一棵任意的二叉搜索树,它靠五条规则来保证平衡性:

  1. 每个节点要么红色要么黑色
  2. 根节点永远是黑色
  3. 每个叶子节点(NIL)是黑色
  4. 红色节点的子节点必须是黑色(不能有连续红色)
  5. 从任一节点到其所有后代叶子节点的路径上,黑色节点数量相同

这五条规则保证了最长路径不超过最短路径的两倍。证明:最短路径是全黑路径,最长路径是红黑交替路径(规则 4),且黑色数量相同(规则 5),所以最长路径 ≤ 2 × 最短路径。

2.3 插入平衡的旋转机制(简略)

插入新节点默认是红色(避免立刻打破规则 5)。如果父节点也是红色(打破规则 4),就需要修复:

  • 情况 1:叔叔节点是红色——变色即可,把父节点和叔叔节点变黑、祖父节点变红,问题向上传递
  • 情况 2:叔叔节点是黑色,且新节点、父节点、祖父节点呈"之"字形(LR 或 RL)——先左旋或右旋变成直线型,再按情况 3 处理
  • 情况 3:叔叔节点是黑色,且呈直线型(LL 或 RR)——对祖父节点做一次旋转,然后变色

删除也类似,但更复杂。关键认知:红黑树的插入最多需要 2 次旋转,删除最多需要 3 次旋转,这就是它比 AVL 树写性能更好的原因(AVL 最坏需要 O(logN) 次旋转)。

2.4 红黑树插入平衡流程(Mermaid)

插入新节点

父节点是红色?

无需修复完成

叔父节点是红色?

父节点变黑
叔父节点变黑
祖父节点变红

祖父节点设为当前
继续向上检查

构成之字形?

对父节点旋转
变成直线形

对祖父节点旋转

父节点变黑
祖父节点变红

完成

2.5 lower_bound / upper_bound / equal_range 精确语义

这三个函数是面试中容易混淆的点,理解它们的精确语义才能正确使用:

  • lower_bound(val):返回第一个 >= val 的元素的迭代器。如果所有元素都 < val,返回 end()
  • upper_bound(val):返回第一个 > val 的元素的迭代器。如果所有元素都 <= val,返回 end()
  • equal_range(val):返回 pair(lower_bound(val), upper_bound(val)),即一个左闭右开区间 [lb, ub)

对于 set,由于元素唯一,equal_range 返回的区间长度要么是 0(不存在),要么是 1(存在)。但在 multiset 中,这个区间可能包含多个相同值的元素——这也是 equal_range 最有价值的场景。

// 区间查询:找出所有 > 20 且 < 50 的元素
auto lo = s.lower_bound(20);  // 第一个 ≥20 的元素
auto hi = s.upper_bound(49);  // 第一个 >49 的元素(即 ≥50 的元素)
for (auto it = lo; it != hi; ++it) { /* 遍历 20 ≤ x ≤ 49 */ }

2.6 extract(C++17)与 merge(C++17)

extract —— 零拷贝节点句柄

C++17 引入的 extract 可以从 set 中"拔"出一个节点,得到一个独立于树的节点句柄(node handle)。节点句柄中的 key 不再是 const 的,你可以修改它然后重新插入——这是修改 set 中 key 的标准做法

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

int main() {
    set<int> s{1, 2, 3, 4, 5};

    // extract 按值
    auto nh = s.extract(3);               // 拔出 key=3 的节点
    nh.value() = 99;                      // 修改 key(节点已脱离树,不再 const)
    s.insert(move(nh));                   // 重新插入

    for (int x : s) cout << x << " ";     // 1 2 4 5 99
    cout << "\n";

    // extract 按迭代器
    auto it = s.find(4);
    auto nh2 = s.extract(it);
    nh2.value() = 100;
    s.insert(move(nh2));

    // extract 不触发任何内存分配或拷贝——只是指针操作
}
merge —— 合并两个 set

merge 把另一个 set(或 multiset)中的元素转移到当前 set,源容器中的成功转移的元素会被删除。当遇到重复 key 时,该元素留在源容器中不被转移。

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

int main() {
    set<int> s1{1, 2, 3};
    set<int> s2{3, 4, 5};

    s1.merge(s2);                         // 把 s2 的元素转移到 s1

    for (int x : s1) cout << x << " ";    // 1 2 3 4 5
    cout << "\n";
    for (int x : s2) cout << x << " ";    // 3(冲突的 3 留在 s2)
    cout << "\n";
}

2.7 为什么 set 的迭代器不失效?

这是红黑树结构带来的天然优势。set 的每个节点是以指针连接的树节点,插一个新节点只是在树上挂一片新的叶子——已有的节点地址不会改变。这与 vector 连续存储、扩容导致全部迭代器失效形成鲜明对比。

具体来说:

  • 插入新元素:所有已有迭代器保持有效
  • 删除某个元素:只有被删除元素的迭代器失效,其他全部有效
  • 不触发任何 rehash 或内存重分配——红黑树永远在它已经分配的内存上调整指针

2.8 节点内存结构

set 的红黑树节点和 map 同源,只不过不存 value,只存 key:

┌─────────────────────────────────────────────────┐
│  _Rb_tree_node                                   │
│  ┌─────────┬─────────┬───────────┬──────┬───────┐│
│  │ parent* │ left*   │ right*    │ color│ Key   ││
│  │ (父节点) │ (左子)   │ (右子)     │(红/黑)│ 数据  ││
│  └─────────┴─────────┴───────────┴──────┴───────┘│
└─────────────────────────────────────────────────┘

x64 下三个指针各 8 字节,颜色枚举 4 字节(对齐后 8 字节),加上 key 本身。set<int> 一个节点约 32 字节,有效载荷 4 字节,空间利用率约 12.5%。


三、面试题 + 口语化答案

Q1:红黑树和 AVL 树比,为什么 STL 选红黑树?

“核心原因是写性能。AVL 树更严格平衡(左右子树高度差不超过 1),查找比红黑树略快,但插入和删除需要更多旋转——AVL 最坏 O(logN) 次旋转,红黑树插入最多 2 次旋转、删除最多 3 次。对于 STL 这种通用容器,插入删除和查找一样频繁,红黑树的写性能优势是决定性的。另外红黑树的高度是 2log₂(n+1),查找 O(logN) 和 AVL 的 1.44log₂(n) 差距在数据量不大时几乎没区别。面试官问这个,你从’旋转次数’和’高度常数因子’两个角度答就行。”

Q2:set 和 unordered_set 怎么选?

“看需求。需要有序遍历、范围查询、或者对迭代器稳定性有要求,选 set。只做纯粹的增删查、不关心顺序,选 unordered_set。但注意两点:第一,数据量小的时候(< 100 个),set 的 O(logN) 未必比 unordered_set 的 O(1) 差,因为哈希表有建表开销。第二,unordered_set 在哈希冲突严重时退化成 O(N),而且 rehash 会让所有迭代器失效——如果你在遍历过程中插入元素,用 set 更安全。一句话:有顺序需求或有迭代器稳定性要求,set;其他情况看场景选。”

Q3:insert() 的返回值怎么用?C++17 结构化绑定怎么写?

insert() 返回 pair<iterator, bool>——second 表示是否真正插入了新元素,first 指向已存在或刚插入的元素。C++17 结构化绑定可以这样写:auto [it, inserted] = s.insert(val); if (!inserted) cout << *it << "already exists";。这在去重场景特别有用——你可以同时知道元素是否存在、以及获取它的迭代器,不需要先 findinsert,省了一次 O(logN) 查找。”

Q4:equal_range 的典型用途是什么?

equal_range 最常见的场景有两个。第一,在 multiset 中找到所有相同值的元素——equal_range 返回的区间正好包含所有重复元素,用 distance(lo, hi) 可以知道有多少个。第二,做区间查询——比如找出所有 score 在 60 到 80 之间的元素,用 lower_bound(60)upper_bound(80) 组合。面试官如果问’怎么找出 set 中某个范围内的所有元素’,你的答案就是 lower_bound + upper_bound。”

Q5:为什么 set 的 key 不可修改?

“因为修改 key 会破坏红黑树的有序性。set 的底层是红黑树——左子树都小于父节点,右子树都大于父节点。如果你拿到一个迭代器,然后改了 key,整棵树可能立即不再是合法的二叉搜索树。所以 set 的迭代器解引用返回的是 const T&,禁止通过迭代器修改元素。老办法是先删后插,C++17 之后用 extract——把节点拔出来,key 就不受 const 限制了,改完再塞回去。”

Q6:extract 的原理和场景是什么?

extract 的原理是把红黑树上的一个节点摘下来,返回一个节点句柄。摘下来的节点完全脱离了树——key 不再 const,不会触发任何构造函数或析构函数,纯粹是指针操作。最典型的场景就是修改 set 中的 keyauto nh = s.extract(old_val); nh.value() = new_val; s.insert(move(nh))。比先 erase 再 insert 快在哪?省了一次元素析构和一次构造,对大对象有明显优势。另外 extract 也可以用来零拷贝地在两个 set 之间转移元素——node handle 的移动构造是 O(1) 的。”

Q7:set 和 multiset 在迭代器和删除行为上有什么差异?

“两个核心差异。第一,insert() 的返回值不同——set 返回 pair<iterator, bool> 告诉你是否重复,multiset 直接返回迭代器(因为总是成功插入)。第二,erase(val) 的行为不同——set 删除最多一个元素,multiset 会删除所有等于 val 的元素。如果想在 multiset 中只删一个,必须用迭代器版本:ms.erase(ms.find(val))。还有一个差异体现在 count()——set 中 count 返回值只能是 0 或 1,multiset 返回实际出现次数。equal_range 也一样:set 中区间长度 ≤ 1,multiset 中可能很长。”

Q8:merge 操作的时间复杂度是多少?

merge 的复杂度是 N * log(size() + N),其中 N 是源容器的大小。注意 merge 不是把源容器的元素一个个 insert——它利用红黑树的节点句柄特性,直接把源容器的节点挂接到目标树上,避免了元素级别的拷贝。对于冲突的 key,对应的节点留在源容器不被转移。所以 merge 的代价主要来自在目标树上查找插入位置,每个节点 O(log(total)),但没有元素构造或析构的开销。”


一句话总结:set 和 map 共用同一棵红黑树模板,区别仅在于节点存储的数据类型——理解 insert() 的 pair 返回值、lower_bound/upper_bound 的精确语义、以及 extract/merge 在 C++17 中的零拷贝能力,就是掌握了 set 的全部面试考点。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Ricky_Theseus

感谢大家,祝您生活愉快

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值