CSP-S 提高级课程中心 / C++进阶与STL / 第7讲
CSP-S 提高级 · C++进阶与STL · s2-04

set 与 map 家族

两大基于红黑树的有序容器,把查找、去重、计数、离散化全部压到 O(log n)——提高组最常用的「万能工具箱」。

4 个容器 set/multiset/map/multimap O(log n) 全部核心操作 4 个递进案例 2 处交互动画 12 道洛谷原题
本讲 · 第 7/52 讲set 与 map 家族
模块 01

情景导航

从一个真实竞赛场景出发,理解「有序 + 快速查找」为什么值得专门造一种容器。

故事机房的点名册

期末考试周,王老师的信息学集训队要每天点名。队里有 10 万名学员,每人有一个 6 位学号。

第一天,助教 A 说:「把所有报到的人写进一个数组里吧。」于是写下了 int a[100005];。王老师问:「小 P 来了吗?」助教只能从头到尾扫一遍,最坏要看 10 万次。

第二天,有人重复签了两次到,数组里就出现了两个相同的学号。王老师问:「今天实到多少人?」助教又得先排序、再去重,折腾半天。

第三天,王老师改问:「今天实到次数 ≥ 3 次的有几个人?」、「把学号从小到大打印出来」。助教终于崩溃了。

转机:王老师笑着说,C++ 标准库早就替我们准备好了一本「自动排好序、自动去重、随手翻到任意一页」的点名册——它叫 set;还有一本「按学号查成绩」的字典——它叫 map。

数组擅长「按下标访问」,set / map 擅长「按值查找」——这是整个提高组最基础的一次思维转变。

💡 为什么数组不够用

数组的问题:查找某个值是否存在要 O(n);插入删除要搬动后面的元素,也是 O(n)。当操作次数达到 10⁵ 量级时,O(n²) 直接超时。

set / map 的答案:底层是平衡二叉搜索树(GCC 里实现为红黑树),树高恒为 O(log n)。查找、插入、删除全部 O(log n),并且中序遍历天然有序。

代价:常数比数组大(每个结点要存三个指针 + 颜色位),随机访问不支持(没有 s[5] 这种写法)。所以选型时先问自己:我需要的是「按下标取」还是「按值查」?

①生活类比
📚

词典 = set

词条自动按拼音排序,同一个词只出现一次;翻开任意位置都是 O(log n)。

→ set 有序 + 去重
📞

通讯录 = map

用「姓名」当键,翻到就能拿到「电话号码」这个值;键不重复。

→ map<K,V>
🛒

超市小票 = multiset

同一件商品买两份就会出现两行,重复是允许的,但顺序依然整齐。

→ multiset 允许重复
🗂️

同名学生档案 = multimap

同名的人可以有多个,一个「键」对应多份「档案」,不能再用方括号取。

→ multimap
②核心定义

set<T>(集合):元素类型为 T 的有序容器,自动去重 + 自动升序。底层红黑树,操作复杂度 O(log n)。

multiset<T>(多重集合):与 set 完全相同,唯一区别是允许重复元素。这也带来了 erase 的经典陷阱。

map<K,V>(映射):以 K 为键、V 为值的容器,按键升序排列,键唯一。支持 m[k] 直接访问。

multimap<K,V>(多重映射):键可以重复的映射,不支持 operator[],必须用 equal_range 取到一段区间。

红黑树(Red-Black Tree):一种自平衡二叉搜索树,通过旋转与染色保证树高 ≤ 2log₂(n+1),从而让所有操作稳定在 O(log n)。

📌 本讲核心知识地图

板块核心内容对应大纲
set 基础有序性 + 去重,声明与遍历方式集合(set)
set 成员函数insert / erase / find / lower_bound / upper_bound / count集合(set)
multiset允许重复;erase(值) 删全部,erase(迭代器) 删一个多重集合(multiset)
map 基础键值对、operator[] 的自动插入陷阱、at() 与 find()映射(map)
map 应用频次统计、散列映射、离散化、前缀和计数映射(map)
multimap一键多值、equal_range 区间遍历多重映射(multimap)
红黑树直觉平衡树高 → O(log n),常数与内存代价算法与数据结构基础
③核心口诀
🔑 选容器

按下标取用数组,按值查找用 set;
要配「键→值」查字典,map 出场最省心;
允许重复 multiset,一键多值 multimap。

🔑 防陷阱

multiset 删一个:先 find 拿迭代器,再 erase 这个迭代器;
map 别乱用 []:查在不在用 count / find,否则会凭空插入一堆 0!

模块 02

知识探究

把四个容器的接口、复杂度、易错点逐个拆开,再用四个案例把知识用起来。

1set 基础与有序性 核心

set 内部维护一棵平衡二叉搜索树,中序遍历就是升序序列。因此 set 天生具备两个性质:

1
🔤
自动排序

插入顺序随便,遍历时一定是从小到大。

2
🚫
自动去重

重复插入同一个值,第二次不产生任何效果。

3
⚡
O(log n)

插入 / 删除 / 查找都是树高量级。

C++set 的声明、插入与三种遍历方式
#include <bits/stdc++.h>
using namespace std;

int main() {
    set<int> s;
    s.insert(5);
    s.insert(3);
    s.insert(8);
    s.insert(3);          // 重复插入,被忽略
    s.insert(1);

    cout << "size = " << s.size() << "\n";   // 4

    // 方式一:范围 for(最推荐,C++11)
    for (int x : s) cout << x << " ";
    cout << "\n";                        // 1 3 5 8

    // 方式二:迭代器(要删除元素时只能用这种)
    for (auto it = s.begin(); it != s.end(); ++it)
        cout << *it << " ";
    cout << "\n";

    // 方式三:反向迭代器,从大到小
    for (auto it = s.rbegin(); it != s.rend(); ++it)
        cout << *it << " ";
    cout << "\n";                        // 8 5 3 1

    cout << "min = " << *s.begin() << ", max = " << *s.rbegin() << "\n";
    return 0;
}
运行结果
size = 4
1 3 5 8
1 3 5 8
8 5 3 1
min = 1, max = 8

🔍 代码逐段解析 · set 的声明与三种遍历

声明与插入(第 5~10 行) —— set<int> s; 建一棵空红黑树;连续 5 次 insert,其中第二次插入 3 是重复值,会被静默丢弃。

size 输出(第 12 行) —— 打印 size() 得 4:去重后只剩 1、3、5、8 四个元素。

方式一:范围 for(第 15~16 行) —— for (int x : s) 按中序遍历,天然升序,是最推荐的写法。

方式二:迭代器(第 19~21 行) —— begin() 到 end() 前进;需要边遍历边 erase 时只能用这种形式。

方式三:反向迭代器(第 24~26 行) —— rbegin() 指向最大值,倒着走到 rend(),得到降序序列 8 5 3 1。

min/max(第 28 行) —— *s.begin() 取最小、*s.rbegin() 取最大,都是 O(1)。

易错点 —— ① 对空 set 执行 *s.begin() 是解引用 end(),未定义行为;② size() 是无符号数,s.size()-1 在空集时会溢出成巨大正数。

📐 关键点拆解

① 插入的返回值:insert 返回 pair<iterator, bool>。若元素已存在,second 为 false,可以用来判断「是否是第一次出现」。

② min / max:set 里最小元素是 *s.begin(),最大元素是 *s.rbegin()(反向迭代器的第一个)。这是一行代码求双端最值的关键。

③ 不能随机访问:s[2] 是语法错误!要取第 k 小只能迭代前进 k 步,O(k)。所以 set 不适合「查第 k 小」这类题目。

④ size() 返回无符号数:s.size() - 1 在 s 为空时会变成一个巨大的正数,比较时务必先转 (int)。

操作用法时间复杂度备注
insert(v)插入值 vO(log n)返回 pair<it, bool>
erase(v)删除所有等于 v 的元素O(log n + cnt)set 中 cnt 恒为 0 或 1
erase(it)删除迭代器 it 指向的元素均摊 O(1)返回下一个迭代器(C++11)
find(v)返回指向 v 的迭代器O(log n)不存在返回 end()
count(v)返回 v 的个数O(log n)set 中只能是 0 或 1
lower_bound(v)第一个 ≥ v 的位置O(log n)不存在返回 end()
upper_bound(v)第一个 > v 的位置O(log n)常用于「前驱/后继」
clear() / empty()清空 / 判空O(n) / O(1)判空用 empty() 不要用 size()==0
2set 六大成员函数精讲 高频

这六个函数几乎覆盖了 set 在竞赛中的全部用法,务必背到能盲写。

C++insert / erase / find / lower_bound / upper_bound / count
#include <bits/stdc++.h>
using namespace std;

int main() {
    set<int> s = {2, 4, 6, 8, 10};

    // ① insert 的返回值:判断是不是新元素
    auto r1 = s.insert(6);    // 已存在
    auto r2 = s.insert(7);    // 新元素
    cout << "insert(6) 成功? " << r1.second
         << ", insert(7) 成功? " << r2.second << "\n";

    // ② count:判断存在性(最简洁)
    cout << "count(4) = " << s.count(4) << "\n";   // 1
    cout << "count(5) = " << s.count(5) << "\n";   // 0

    // ③ find:拿到迭代器才能取「前驱 / 后继」
    auto it = s.find(6);
    if (it != s.end()) {
        cout << "找到 " << *it;
        if (it != s.begin()) { auto p = it; --p; cout << ",前驱 = " << *p; }
        auto nx = it; ++nx;
        if (nx != s.end()) cout << ",后继 = " << *nx;
        cout << "\n";
    }

    // ④ lower_bound / upper_bound
    cout << "lower_bound(6) = " << *s.lower_bound(6) << "\n";  // 6
    cout << "upper_bound(6) = " << *s.upper_bound(6) << "\n";  // 7
    cout << "lower_bound(5) = " << *s.lower_bound(5) << "\n";  // 6

    // ⑤ erase 迭代器:O(1),且返回下一个位置
    s.erase(s.find(6));

    // ⑥ erase 值:删除所有等于该值的元素
    s.erase(8);

    for (int x : s) cout << x << " ";
    cout << "\n";
    return 0;
}
运行结果
insert(6) 成功? 0, insert(7) 成功? 1
count(4) = 1
count(5) = 0
找到 6,前驱 = 4,后继 = 7
lower_bound(6) = 6
upper_bound(6) = 7
lower_bound(5) = 6
2 4 7 10
📌 lower_bound(v)
  • 含义:第一个 ≥ v 的元素
  • 用途:找「不小于 v 的最小值」
  • 经典题:营业额统计求前驱
  • 不存在时返回 end(),必须先判空
📌 upper_bound(v)
  • 含义:第一个 > v 的元素
  • 用途:找「大于 v 的最小值」
  • 技巧:--upper_bound(v) = 第一个 ≤ v 的元素
  • 配合 lower_bound 可数出 v 的个数
🔑 前驱后继速记

• ≤ v 的最大值(前驱)→ auto it = s.upper_bound(v); if (it != s.begin()) pre = *(--it);
• ≥ v 的最小值(后继)→ auto it = s.lower_bound(v); if (it != s.end()) suf = *it;
• 记忆:upper 往前退一个才是「不大于」,这一步忘退是最高频错误。

🎬动画演示:set 插入时元素怎么「找到自己的位置」

下面按顺序插入 5, 3, 8, 3, 1, 7。注意每次插入都要从根往下比较,就像在 BST 里找家。

🎬 set<int> 插入全过程
1集合为空,插入 5 → 5 直接成为根:[5]
2插入 3:3 < 5,去左子树 → 左子树为空 → 挂上。[3, 5]
3插入 8:8 > 5,去右子树 → 右子树为空 → 挂上。[3, 5, 8]
4插入 3:3 < 5 往左,3 == 3 找到同值结点 → set 直接丢弃本次插入(insert().second == false)
5插入 1:1 < 5 左,1 < 3 左,左空 → 挂上。[1, 3, 5, 8]
6插入 7:7 > 5 右,7 < 8 左 → 挂上。[1, 3, 5, 7, 8]
7若此时树偏高(比如连续插入 1,2,3,4,5),红黑树会通过旋转 + 染色把树高压到 O(log n),保证后续操作依然快
8最终中序遍历:1 3 5 7 8 —— 无论插入顺序如何,输出永远升序 ✅
📎 结论:set 的「有序」不是排序出来的,而是结构本身带来的;「去重」是插入时比较到相等就停下带来的。

再用格子图直观感受一下最终的有序序列(高亮格子表示当前被访问的位置):

1
3
5
7
8
当前比较位置已就位的元素
3multiset 与 erase 的经典陷阱 易错

multiset 与 set 只有一个差别:允许重复元素。但正是这个差别,让 erase 的行为完全不同。

❌ erase(v) —— 删掉所有等于 v 的

multiset<int> ms = {1,2,2,2,3};

ms.erase(2); → 结果是 {1,3}

等价于 ms.erase(ms.lower_bound(2), ms.upper_bound(2));,删掉的是整个相等区间。

✅ erase(迭代器) —— 只删一个

multiset<int> ms = {1,2,2,2,3};

ms.erase(ms.find(2)); → 结果是 {1,2,2,3}

传入迭代器时,只删除这一个结点。

C++multiset 的两个 erase 天差地别
#include <bits/stdc++.h>
using namespace std;

void show(multiset<int>& ms, string name) {
    cout << name << " : ";
    for (int x : ms) cout << x << " ";
    cout << " (size=" << ms.size() << ")\n";
}

int main() {
    multiset<int> ms = {1, 2, 2, 2, 3};
    show(ms, "初始");

    // 正确做法:删一个 2
    ms.erase(ms.find(2));
    show(ms, "erase(find(2)) 之后");

    // 危险做法:删所有 2
    ms.erase(2);
    show(ms, "erase(2) 之后");

    // count 在 multiset 里返回真实个数(这才是它该用的地方)
    ms.insert(9); ms.insert(9); ms.insert(9);
    cout << "count(9) = " << ms.count(9) << "\n";   // 3
    show(ms, "插入三个 9 之后");

    // 范围遍历:一次拿到所有等于 9 的元素
    auto rg = ms.equal_range(9);
    for (auto it = rg.first; it != rg.second; ++it) cout << *it << " ";
    cout << "\n";
    return 0;
}
运行结果
初始 : 1 2 2 2 3  (size=5)
erase(find(2)) 之后 : 1 2 2 3  (size=4)
erase(2) 之后 : 1 3  (size=2)
count(9) = 3
插入三个 9 之后 : 1 3 9 9 9  (size=5)
9 9 9

🔍 代码逐段解析 · multiset 的两种 erase

show 函数(第 5~9 行) —— 范围 for 遍历 multiset 自动升序输出,同时打印 size() 方便观察删除效果。

初始化(第 13 行) —— {1,2,2,2,3},multiset 保留全部三个 2,size=5。

erase(find(2))(第 16 行) —— 先 find 拿到第一个 2 的迭代器再删除,只删一个 → {1,2,2,3},size=4。这是安全写法。

erase(2)(第 20 行) —— 按值删除整个相等区间,把剩下的两个 2 一并删掉 → {1,3},size=2。这是危险写法。

count(9)(第 25~27 行) —— multiset 里 count 返回真实个数 3;这正是 count 在 multiset 中该用的地方。

equal_range(第 31~34 行) —— 返回 pair<it,it>,左闭右开区间内恰好是所有 9,遍历输出 9 9 9。

易错点 —— ① 想删一个却写 erase(值),数据会被误删;② find 返回的迭代器必须先判断 != end() 再 erase。

💡 为什么 set 里不会踩这个坑

因为 set 中任何值的出现次数最多为 1,「删所有」和「删一个」效果完全一样。所以这个坑只在 multiset / multimap 里出现。

考场自检口诀:看到 multiset 里写 erase(v),立刻问自己一句——「我是想删一个,还是想全删?」

4map 与 operator[] 的自动插入陷阱 高频易错

map 存储的是键值对 pair<const K, V>,按键 K 升序排列。它把「查找」和「取数据」合二为一,是提高组用得最多的容器之一。

⚠️ 头号陷阱:m[k] 会「凭空创造」元素

当你写 m[k] 而 k 不存在时,map 会立刻插入一个键为 k、值为 V 默认值(int 为 0)的元素,然后返回它的引用。

这在「计数」场景是福利(m[k]++ 一行搞定),但在「只查询」场景就是灾难——你的 map 会不断膨胀,size() 变大,还可能把不该算的键算进去。

只查不改的三种安全写法:m.count(k)(返回 0/1)、m.find(k) != m.end()、m.at(k)(不存在抛异常,一般不用于竞赛)。

C++operator[] 的陷阱现场
#include <bits/stdc++.h>
using namespace std;

int main() {
    map<string, int> score;
    score["Alice"] = 95;
    score["Bob"]   = 88;
    cout << "size = " << score.size() << "\n";      // 2

    // ❌ 危险:只是想查一下 Carol 有没有分数
    if (score["Carol"] > 0) { /* ... */ }
    cout << "危险写法后 size = " << score.size() << "\n";  // 3!Carol 被凭空插入了

    // ✅ 正确:用 count 判断存在性
    cout << "count(\"Dave\") = " << score.count("Dave") << "\n";
    cout << "安全写法后 size = " << score.size() << "\n";  // 仍然是 3

    // 遍历时注意:it->first 是键,it->second 是值
    for (auto& kv : score)
        cout << kv.first << " -> " << kv.second << "\n";

    // 删除:erase(键) 和 erase(迭代器) 在 map 里等价(键唯一)
    score.erase("Carol");
    cout << "删除 Carol 后 size = " << score.size() << "\n";
    return 0;
}
运行结果
size = 2
危险写法后 size = 3
count("Dave") = 0
安全写法后 size = 3
Alice -> 95
Bob -> 88
Carol -> 0
删除 Carol 后 size = 2

🔍 代码逐段解析 · operator[] 的陷阱现场

建表(第 6~7 行) —— score["Alice"]=95 是「写」语义:键不存在就先插入再赋值,正是我们想要的。

危险查询(第 11 行) —— if (score["Carol"] > 0) 只是「想看看」,但 [] 会先把 Carol 以默认值 0 插入,size 从 2 变 3。

安全查询(第 15~16 行) —— count("Dave") 只查找不插入,size 保持 3;这是只读场景的标准写法。

遍历(第 20~21 行) —— kv.first 是键(const,不可改)、kv.second 是值;按键升序输出 Alice、Bob、Carol。

删除(第 24~25 行) —— map 键唯一,erase("Carol") 删键值对,size 回到 2;与 multiset 不同,这里按值删和按迭代器删效果一致。

易错点 —— ① 判断「在不在」绝不能用 [],会让 map 膨胀;② 遍历中 kv.first 是 const 引用,试图赋值编译不过。

5map 的两大经典用法:计数与散列 核心
📊 用法一:频次统计
  • 核心是一行:cnt[x]++;
  • 键不存在时自动建 0 再自增,正是我们要的
  • 缺点:把 [] 用在「只读」处会误建元素
  • 典型题:词频统计、众数、色子点数计数
🔢 用法二:大值域散列(离散化)
  • 值域到 10⁹ 甚至 string 时数组开不下
  • map 扮演「无限大数组」的角色
  • 复杂度 O(log n) 而非 O(1),常数更大
  • 只关心存在性时用 set 更省内存
C++map 计数 + 离散化编号(竞赛常用套路)
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n; cin >> n;
    map<long long, int> cnt;      // 值 -> 出现次数
    for (int i = 0; i < n; i++) {
        long long x; cin >> x;
        cnt[x]++;                    // 一行完成「建键 + 计数」
    }

    // 自动按值升序输出(因为 map 按键有序)
    for (auto& kv : cnt)
        cout << kv.first << " 出现 " << kv.second << " 次\n";

    // 离散化:把大值域映射成 1..m 的小编号
    int id = 0;
    map<long long, int> disc;
    for (auto& kv : cnt) disc[kv.first] = ++id;

    cout << "离散化后:\n";
    for (auto& kv : disc)
        cout << "值 " << kv.first << " -> 编号 " << kv.second << "\n";
    return 0;
}
运行结果(输入 8 个数:5 3 5 1000000000 3 5 7 7)
3 出现 2 次
5 出现 3 次
7 出现 2 次
1000000000 出现 1 次
离散化后:
值 3 -> 编号 1
值 5 -> 编号 2
值 7 -> 编号 3
值 1000000000 -> 编号 4
🎬 map 计数 + 离散化动画(输入 5 3 5 1000000000 3 5 7 7)
1读入 5:键不存在 → 先建 cnt[5]=0 再自增 → {5:1}
2读入 3:同上 → {3:1, 5:1}(map 内部按键有序)
3读入 5:键已存在,直接 +1 → {3:1, 5:2},不产生新键
4读入 1000000000:值域太大数组开不下,map 当「无限大数组」用 → {3:1, 5:2, 1000000000:1}
5继续读入 3 5 7 7 → 最终 {3:2, 5:3, 7:2, 1000000000:1}
6离散化:按 map 升序遍历,依次编号 3→1、5→2、7→3、1000000000→4,大值域压成连续小编号
7总复杂度 O(n log m):8 个数最多 17 层比较,n=10⁵ 时约 1.7×10⁶ 次,毫无压力
📎 编号顺序 = 值的升序,这是「保序离散化」;离散化后的编号可以直接当桶下标、线段树下标用。

📐 复杂度与选型

map 的复杂度:每次 [] / find / insert 都是 O(log m),m 为不同键的个数。总复杂度 O(n log m)。

n = 10⁵ 时:10⁵ × 17 ≈ 1.7×10⁶ 次比较,完全跑得动。

n = 10⁶ 时:常数偏大(红黑树结点是堆分配的,cache 不友好),建议改用 unordered_map(哈希,平均 O(1))。

需要有序输出时只能老老实实用 map;只统计个数时优先 unordered_map。

6multimap 与四种容器选型

multimap = 键可以重复的 map。它没有 operator[](因为一个键对应多个值,不知道该返回哪个),插入要用 insert(make_pair(k, v)) 或 emplace(k, v),取值要用 equal_range。

C++multimap:一个班级名册(同名同姓会出现多次)
#include <bits/stdc++.h>
using namespace std;

int main() {
    multimap<string, int> mm;            // 姓名 -> 成绩(可以重名)
    mm.insert({"Zhang", 92});
    mm.insert({"Li",    85});
    mm.insert({"Zhang", 78});      // 又一个 Zhang
    mm.insert({"Wang",  90});
    mm.insert({"Zhang", 100});

    // 所有同名的人会被排在一起,且内部按插入顺序稳定排列
    for (auto& kv : mm)
        cout << kv.first << " : " << kv.second << "\n";

    // 取某个键的全部值:equal_range 返回 [first, second)
    cout << "--- 查 Zhang 的所有成绩 ---\n";
    auto rg = mm.equal_range("Zhang");
    int sum = 0, k = 0;
    for (auto it = rg.first; it != rg.second; ++it) {
        cout << it->second << " ";
        sum += it->second; k++;
    }
    cout << "\n共 " << k << " 人,平均 " << sum / k << " 分\n";
    return 0;
}
运行结果
Li : 85
Wang : 90
Zhang : 92
Zhang : 78
Zhang : 100
--- 查 Zhang 的所有成绩 ---
92 78 100 
共 3 人,平均 90 分

🔍 代码逐段解析 · multimap 班级名册

插入(第 6~11 行) —— multimap 没有 [],只能 insert({键, 值});三个 Zhang 各自成一条记录。

整体遍历(第 14~15 行) —— 按键升序排列,Zhang 的三条记录相邻;同键之间保持插入顺序。

equal_range(第 19 行) —— 返回 pair<it,it>,[rg.first, rg.second) 恰好覆盖所有键为 Zhang 的记录。

区间遍历与统计(第 20~23 行) —— 累加成绩得 270、人数 3,平均 90 分。

易错点 —— ① 误写 mm["Zhang"] 编译不过(multimap 无 operator[]);② 平均分 sum/k 是整数除法,若题目要小数需转 double。

① 要有序吗需要升序 / 前驱后继 / 分组?
② 允许重复吗同值出现多次要不要保留?
③ 是键值对吗需要键 → 值 的对应?
④ 数据多大n ≤ 10⁵ 还是 ≥ 10⁶?
⑤ 还缺能力吗第 k 小 / 区间操作?
容器有序允许重复operator[]典型用途
set✅ 升序❌❌去重 + 存在性判断 + 前驱后继
multiset✅ 升序✅❌可删除的有序多重集合、双端最值
map✅ 按键升序键唯一✅频次统计、大值域散列、离散化
multimap✅ 按键升序✅❌一键多值、按关键字分组
unordered_set/map❌ 无序—/键唯一map 支持只判存在 / 只计数,追求平均 O(1)
7为什么是 O(log n):红黑树直觉

你不需要会写红黑树,但必须理解它为什么快——这决定了你能不能正确判断一道题该不该用 set。

图:一棵平衡的 BST(黑点=黑结点,粉点=红结点),任意路径长度差不超过 2 倍
🌳 平衡 → 树高 O(log n)
  1. 二叉搜索树性质:左子树全部 < 根 < 右子树全部。于是「查一个值」等价于「从根往下走一条路径」。
  2. 树高决定速度:如果树退化成链(按 1,2,3,…,n 顺序插入),树高变成 n,查找退化成 O(n)。
  3. 红黑树的修补:每次插入/删除后做有限的旋转与染色,保证「最长路径 ≤ 2×最短路径」,于是树高 ≤ 2log₂(n+1)。
  4. 结论:树高 O(log n) → 查找/插入/删除都是 O(log n),n = 10⁵ 时只需要约 17 次比较。
n 的规模数组线性查找set/map 查找差距
10³1000 次≈ 10 次100 倍
10⁵100000 次≈ 17 次≈ 6000 倍
10⁶1000000 次≈ 20 次≈ 50000 倍
10⁷ 次查询(n=10⁵)10¹² → 必超时1.7×10⁶ → 轻松通过质变
🔑 复杂度意识

看到 n ≤ 10⁵ 且需要反复「查某个值在不在 / 出现几次 / 前驱后继」,第一反应就应该是 set / map。
看到 n ≤ 10⁶ 且值域小,先用数组哈希(桶)或 unordered_map,别硬上 map。

案例案例1:P2580 于是他错误的点名开始了 洛谷真题

题目:给出 n 个名字作为「初始名单」。接下来 m 次点名,每次给出一个名字,需要输出:如果这个名字不在名单里 → WRONG;如果在名单里且这是第一次被点到 → OK;如果被重复点到 → REPEAT。

🔍 详细分析过程

第1步:识别模型 —— 「名字在不在名单里」= 存在性判断;「是否第一次被点到」= 还要额外记录点名状态。名称是字符串,值域无限 → 必须用 set / map 这类散列容器。

第2步:数据结构选择 —— 名单用 set<string> 存(只需存在性);已点过的名字用另一个 set<string> called 记录。两个 set 各 O(log n)。

第3步:三次判断的顺序 —— 必须先判断「在不在名单里」,再判断「是否已点过」。顺序颠倒会把 WRONG 判成 REPEAT。

第4步:复杂度 —— 预处理 O(n log n),每次点名 O(log n),总计 O((n+m) log n),n, m ≤ 10⁴,轻松通过。

C++P2580 完整代码(set 版)
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);

    int n; cin >> n;
    set<string> roll;                 // 初始名单
    for (int i = 0; i < n; i++) {
        string name; cin >> name;
        roll.insert(name);
    }

    set<string> called;               // 记录已经被点到过的名字
    int m; cin >> m;
    while (m--) {
        string name; cin >> name;
        if (!roll.count(name)) {           // ① 不在名单里
            cout << "WRONG\n";
        } else if (called.count(name)) {    // ② 已点过
            cout << "REPEAT\n";
        } else {                            // ③ 首次点到
            called.insert(name);
            cout << "OK\n";
        }
    }
    return 0;
}
运行结果
输入:
5
a b c d e
6
a
b
a
x
d
x
输出:
OK
OK
REPEAT
WRONG
OK
WRONG

💡 还能更省内存吗

可以。把「已点过」的信息直接编码进值里,用 map<string,int>:值 0 表示没点过,1 表示点过一次。例如 map<string,int> st; 读名单时 st[name] = 0;,点名时若 st.count(name)==0 输出 WRONG;否则若 st[name]==1 输出 REPEAT,否则 st[name]=1 输出 OK。一个容器搞定,代码更短。

案例案例2:set 去重排序 —— 明明的随机数 秒杀

题目:输入 n 个 1 ~ 1000 之间的整数(可能有重复),要求去重后按升序输出,第一行输出去重后的个数。

🔍 两种方法对比

方法一:数组 + 排序 + unique sort(a, a+n); int m = unique(a, a+n) - a; 复杂度 O(n log n),思路直白,但要额外维护数组长度。

方法二:直接丢进 set s.insert(x); 然后顺序遍历输出。复杂度 O(n log n),代码只有三行,去重和排序同时完成。

什么时候 set 更优?当元素要边读边插、还要随时查询时,set 自然胜出;如果只是「一次性去重排序」,两者差不多,选顺手的即可。

C++法一:排序 + unique(O(n log n),就地操作)
#include <bits/stdc++.h>
using namespace std;

int a[105];

int main() {
    int n; cin >> n;
    for (int i = 0; i < n; i++) cin >> a[i];

    sort(a, a + n);                        // 第一步:排序
    int m = unique(a, a + n) - a;          // 第二步:把相邻重复项挤到末尾

    cout << m << "\n";
    for (int i = 0; i < m; i++) cout << a[i] << " ";
    cout << "\n";
    return 0;
}
C++法二:set 自动去重排序(同样 O(n log n),零心智负担)
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n; cin >> n;
    set<int> s;                       // 一步到位:有序 + 去重
    for (int i = 0; i < n; i++) {
        int x; cin >> x;
        s.insert(x);                   // 重复的自动被丢弃
    }
    cout << s.size() << "\n";
    for (int x : s) cout << x << " ";   // 遍历即升序
    cout << "\n";
    return 0;
}
运行结果(输入 10 个数:20 40 32 67 40 20 89 300 400 15)
8
15 20 32 40 67 89 300 400

🔍 代码逐段解析 · 去重排序两法

法一 · sort(第 7~8 行) —— 就地排序,重复元素变得相邻。

法一 · unique(第 9 行) —— 把相邻重复项挤到数组尾部,返回「去重后末尾」的指针;- a 得到新长度 m=8。

法一 · 易错点 —— ① 必须先 sort 再 unique(unique 只删相邻重复);② 输出要用 m 而不是 n,否则会把尾部垃圾一起打印。

法二 · insert(第 7~9 行) —— 循环 s.insert(x),重复值自动丢弃,树中始终有序。

法二 · 输出(第 11~12 行) —— s.size() 是去重个数 8,范围 for 直接升序输出,一行不用排序。

法二 · 易错点 —— 元素最多 1000 时也可用桶数组 bool vis[1001],O(n+V) 更快;set 的优势在值域大或需动态插入时才明显。

案例案例3:map 统计单词频率 高频套路

题目:读入 n 个小写单词,统计每个单词出现的次数,按字典序输出出现次数 ≥ 2 的单词。n ≤ 10⁵,单词长度 ≤ 20。

🔍 详细分析过程

第1步:为什么用 map 而不是数组 —— 键是字符串,无法直接用下标寻址。map 的键可以是任意可比较类型(string 支持 <)。

第2步:一行计数 —— cnt[w]++; 就完成了「不存在则建 0,然后加 1」。

第3步:字典序输出 —— map 按键升序,string 的 < 正是字典序(逐字符比较 ASCII),所以直接顺序遍历即可,不用再排序。

第4步:复杂度 —— 字符串比较是 O(L),总复杂度 O(n · L · log n) ≈ 10⁵ × 20 × 17,可以接受。若数据再大,改为先字符串哈希成整数再用 map / 排序。

C++map<string,int> 词频统计
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);

    int n; cin >> n;
    map<string, int> cnt;

    for (int i = 0; i < n; i++) {
        string w; cin >> w;
        cnt[w]++;                         // 一行完成建键 + 计数
    }

    // map 已按键升序,顺序遍历即为字典序
    for (auto& kv : cnt) {
        if (kv.second >= 2)
            cout << kv.first << " " << kv.second << "\n";
    }

    // 顺便求出现最多的单词(众数)
    string best; int mx = 0;
    for (auto& kv : cnt)
        if (kv.second > mx) { mx = kv.second; best = kv.first; }
    cout << "最高频:" << best << " (" << mx << " 次)\n";
    return 0;
}
运行结果
输入:
9
apple banana apple cherry banana apple date banana cherry fig
输出:
apple 3
banana 3
cherry 2
最高频:apple (3 次)

💡 众数并列怎么办?

本题遍历时用了 kv.second > mx(严格大于),所以并列时保留的是字典序最小的那个。如果题目要求「并列取先出现的」,就把条件改成 >=,因为 map 是按字典序遍历的再配合输入顺序——注意此时要额外记录「首次出现的位置」才能严格正确。这类细节在提高组题面里经常是隐藏的坑。

案例案例4:multiset 实现可删除的双端最值 进阶

题目:维护一个可重复集合,支持三种操作共 q 次(q ≤ 10⁵):1 x 插入 x、2 x 删除一个 x(若不存在则忽略)、3 查询当前集合的最大值与最小值之差。集合保证操作 3 时非空。

🔍 详细分析过程

第1步:为什么不能用数组 + 排序 —— 如果每次操作后重排,q 次就是 O(q·n log n),必然超时。

第2步:multiset 双端最值 —— *s.begin() 是最小值,*s.rbegin() 是最大值,都是 O(1)。插入删除 O(log n)。

第3步:删除「一个」而非「全部」 —— 这是本讲最核心的考点:必须 auto it = s.find(x); if (it != s.end()) s.erase(it);。写成 s.erase(x) 会把所有 x 全删掉,答案直接错。

第4步:复杂度 —— 每次操作 O(log n),总计 O(q log q),10⁵ × 17 ≈ 1.7×10⁶,稳过。

C++multiset 双端最值 + 安全删除
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);

    int q; cin >> q;
    multiset<long long> ms;

    while (q--) {
        int op; cin >> op;
        if (op == 1) {
            long long x; cin >> x;
            ms.insert(x);                          // O(log n)
        } else if (op == 2) {
            long long x; cin >> x;
            auto it = ms.find(x);
            if (it != ms.end()) ms.erase(it);      // ★ 只删一个,绝不能写 ms.erase(x)
        } else {
            long long mn = *ms.begin();             // 最小值,O(1)
            long long mx = *ms.rbegin();            // 最大值,O(1)
            cout << mx - mn << "\n";
        }
    }
    return 0;
}
运行结果
输入:
8
1 5
1 3
1 5
3
2 5
3
2 3
3
输出:
2
2
0

🔍 代码逐段解析 · multiset 双端最值

容器选择(第 9 行) —— multiset<long long>:要保留重复值(两个 5),且随时取双端最值。

操作 1(第 13~14 行) —— ms.insert(x),O(log n) 插入并保持有序。

操作 2(第 15~18 行) —— 核心考点:find(x) 判存在后 erase(it) 只删一个;注释里特别标注「绝不能写 ms.erase(x)」。

操作 3(第 19~22 行) —— *ms.begin() 最小值、*ms.rbegin() 最大值,都是 O(1),直接输出差值。题目保证操作 3 时非空,所以这里不用判空。

易错点 —— ① erase(x) 会全删,样例中第二次查询就会从 2 变 0;② 若题目不保证操作 3 非空,必须加 if (!ms.empty()) 保护。

🧠 逐个操作推演

1 5 → ms = {5}

1 3 → ms = {3, 5}

1 5 → ms = {3, 5, 5}

3 → max = 5, min = 3 → 输出 2

2 5 → find(5) 拿到第一个 5 的迭代器,只删掉它 → ms = {3, 5}

3 → max = 5, min = 3 → 输出 2(若误写 erase(5),两个 5 全删,ms = {3},会输出 0 ❌)

2 3 → ms = {5}

3 → max = min = 5 → 输出 0 ✅

结论:三次查询依次输出 2 2 0,与运行结果完全一致。养成手推样例的习惯,才能第一时间发现 erase 写错。

模块 03

实践应用

完整题目 + 样例 + 多角度分析 + 代码 + 运行结果。

题1明明的随机数(去重排序)
难度普及-/入门 · set 基础

题目背景

明明想在学校中请一些同学一起做一项问卷调查。为了实验的客观性,他先用计算机生成了 N 个 1 到 1000 之间的随机整数(N ≤ 100),对于其中重复的数字,只保留一个,把其余相同的数去掉,不同的数对应着不同的学生的学号。然后再把这些数从小到大排序。

输入格式:第 1 行一个正整数 N;第 2 行有 N 个用空格隔开的正整数。

输出格式:第 1 行为一个正整数 M,表示不相同的随机数的个数;第 2 行为 M 个用空格隔开的正整数,为从小到大排好序的不相同的随机数。

输入样例:10
20 40 32 67 40 20 89 300 400 15

输出样例:8
15 20 32 40 67 89 300 400

数据范围:N ≤ 100,1 ≤ 每个数 ≤ 1000;时间限制 1s,空间 125MB。

方法一:排序 + unique(数组派)O(n log n)

先把数组 sort 一遍,再用 unique 把相邻重复项「挤」到数组尾部,返回新的有效长度。写完需要记住 unique 的返回值和长度计算。

方法二:set 一步到位(容器派)O(n log n),代码最短

直接 s.insert(x),插入时自动去重、自动排序。输出时遍历即可。缺点是常数比数组大,但 n ≤ 100 时完全无感。

结论:本题两种方法都满分。但如果是「边读边查」的变体(如要求每读一个数就输出当前不同数的个数),只有 set 能优雅解决。

C++完整代码(set 解法)
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n; cin >> n;
    set<int> s;
    for (int i = 0; i < n; i++) {
        int x; cin >> x;
        s.insert(x);                  // 自动去重 + 自动排序
    }
    cout << s.size() << endl;
    bool first = true;
    for (int x : s) {
        if (!first) cout << " ";
        cout << x; first = false;
    }
    cout << endl;
    return 0;
}
运行结果
输入: 10
      20 40 32 67 40 20 89 300 400 15
输出: 8
      15 20 32 40 67 89 300 400

🔍 代码逐段解析 · 明明的随机数(set 解法)

读入与插入(第 5~8 行) —— 循环读 n 个数进 set;重复的 40 和 20 在第二次 insert 时被自动丢弃。

输出去重个数(第 9 行) —— s.size() 即不同的随机数个数 M=8。

首空格处理(第 10~14 行) —— 用 first 标志避免行首/行尾多余空格,这是输出格式的经典细节。

易错点 —— ① N ≤ 100 很小,数组桶 bool vis[1001] 也行,但 set 写法更通用;② 行末多余的空格在某些评测机会被判 WA,用 first 标志最稳。

💡 追问:如果要求输出「每个数出现了几次」呢?

那时 set 就不够了(它把重复信息丢了),要换成 map<int,int> 或 multiset + count()。这就是容器选型由需求驱动的典型例子。

题2学分绩点统计(map 应用)
难度普及- · map 计数

题目背景

集训队有 n 名同学参加 m 门课程考试,每门课给出「学号 课程名 成绩」。请你统计:(1) 每位同学的总分;(2) 每门课程的平均分(保留一位小数);(3) 全校最高分是哪门课(若并列输出字典序最小的课程名)。

输入格式:第 1 行两个整数 n, m;接下来 n×m 行,每行 学号(int) 课程名(string,仅含小写字母) 成绩(int)。

输出格式:先按学号升序输出每人的总分(每行「学号 总分」);空行后按课程名字典序输出每门课的平均分(保留 1 位小数);最后一行输出「最高分课程名 该课最高分」。

输入样例:

2 3
1 math 90
1 cpp 85
1 english 70
2 math 100
2 cpp 60
2 english 80

输出样例:

1 245
2 240

cpp 72.5
english 75.0
math 95.0
最高:math 100

数据范围:1 ≤ n, m ≤ 10³,学号 ≤ 10⁹,成绩 ≤ 100;1s / 256MB。

方法一:结构体数组 + 排序需要多关键字排序,代码长

把所有记录存进结构体,再分别按学号、课程名排序,分组累加。逻辑清楚但代码量大,且「最高分课程」要额外一趟统计。

方法二:三个 map 各司其职O(nm log(nm)),代码短

map<int,long long> sumStu 存每人总分;map<string,long long> sumCou 存每课总分;map<string,int> bestCou 存每课最高分。因为 map 自动按键升序,输出天然有序,完全不需要排序!这是 map 最舒服的用法。

C++完整代码(三个 map)
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);

    int n, m; cin >> n >> m;
    map<int, long long>    sumStu;   // 学号 -> 总分
    map<string, long long> sumCou;   // 课程名 -> 总分
    map<string, int>       bestCou;  // 课程名 -> 最高分

    for (int i = 0; i < n * m; i++) {
        int id, sc; string cou;
        cin >> id >> cou >> sc;
        sumStu[id] += sc;                 // 计数型用法,[] 是安全的
        sumCou[cou] += sc;
        bestCou[cou] = max(bestCou[cou], sc);
    }

    // ① 按学号升序(map 已排好)
    for (auto& kv : sumStu)
        cout << kv.first << " " << kv.second << "\n";

    cout << "\n";

    // ② 按课程名字典序输出平均分
    for (auto& kv : sumCou) {
        cout << kv.first << " "
             << fixed << setprecision(1)
             << (double)kv.second / n << "\n";
    }

    // ③ 找最高分课程(并列取字典序最小,因为遍历是有序的,用 > 即可)
    string bestName; int bestVal = -1;
    for (auto& kv : bestCou)
        if (kv.second > bestVal) { bestVal = kv.second; bestName = kv.first; }

    cout << "最高:" << bestName << " " << bestVal << "\n";
    return 0;
}
运行结果
1 245
2 240

cpp 72.5
english 75.0
math 95.0
最高:math 100

💡 为什么这里用 [] 反而安全?

因为 sumStu[id] += sc 的语义本来就是「不存在就建一个 0 再加入」,这正是我们想要的。而如果写成 if (sumStu[id] > 0) 去「查询」,就会凭空插入一堆 0 分的学号。同一行语法,语义不同——判断标准是「我到底想不想让它存在」。

题3滑动窗口内的不同元素个数(set + 双指针)
难度普及+ · set 进阶

题目背景

给定长度为 n 的数组 a 和一个窗口长度 k。窗口从最左端开始,每次向右滑动一格,共产生 n-k+1 个窗口。请输出每个窗口内不同元素的个数。

输入格式:第 1 行两个整数 n, k;第 2 行 n 个整数。

输出格式:一行 n−k+1 个整数,表示每个窗口内不同元素个数。

输入样例:8 3
1 2 1 3 1 2 4 4

输出样例:2 3 2 3 3 2

数据范围:1 ≤ k ≤ n ≤ 10⁵,1 ≤ a[i] ≤ 10⁹;1s / 256MB。

方法一:暴力统计每个窗口O(n·k) 会超时

每个窗口重新扫一遍并去重,n = k = 10⁵ 时是 10¹⁰ 次操作,必然 TLE。

方法二:multiset + 滑动(可跑,但常数大)O(n log n)

维护一个 multiset<int> 作为窗口。右端进一个、左端删一个。窗口内不同元素个数 = s.size()(multiset 会自动把重复元素也存进去,所以不能直接当不同数)。注意:要用 map<int,int> 记频次才对。

方法三:map 记频次 + 滑动窗口O(n log n),正确且稳

map<int,int> cnt 记录窗口内每个值的出现次数,另用变量 distinct 维护「有多少个不同的值」。加入时若 cnt[x] 由 0 变 1,distinct++;移除时若 cnt[x] 由 1 变 0,distinct--。这是滑动窗口计数类题目的标准套路。

C++完整代码(map 频次 + 双指针)
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
long long a[MAXN];

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);

    int n, k; cin >> n >> k;
    for (int i = 1; i <= n; i++) cin >> a[i];

    map<long long, int> cnt;   // 值 -> 在当前窗口内的出现次数
    int distinct = 0;             // 当前窗口内不同值的个数

    for (int i = 1; i <= n; i++) {
        // 右端点进入窗口
        if (cnt[a[i]]++ == 0) distinct++;

        // 窗口已满 k 个,输出答案,然后收缩左端点
        if (i >= k) {
            cout << distinct << " ";
            int lv = a[i - k + 1];
            if (--cnt[lv] == 0) distinct--;
        }
    }
    cout << "\n";
    return 0;
}
运行结果
输入: 8 3
      1 2 1 3 1 2 4 4
输出: 2 3 2 3 3 2

🧠 逐步推演(k = 3)

i=1: 加入 a[1]=1,cnt{1:1},distinct=1;窗口未满 3,不输出

i=2: 加入 a[2]=2,cnt{1:1,2:1},distinct=2;不输出

i=3: 加入 a[3]=1,cnt{1:2,2:1},distinct=2 → 输出 2;收缩 a[1]=1,cnt{1:1,2:1}

i=4: 加入 a[4]=3,cnt{1:1,2:1,3:1},distinct=3 → 输出 3;收缩 a[2]=2,cnt{1:1,3:1}

i=5: 加入 a[5]=1,cnt{1:2,3:1},distinct=2 → 输出 2;收缩 a[3]=1,cnt{1:1,3:1}

i=6: 加入 a[6]=2,cnt{1:1,2:1,3:1},distinct=3 → 输出 3;收缩 a[4]=3,cnt{1:1,2:1}

i=7: 加入 a[7]=4,cnt{1:1,2:1,4:1},distinct=3 → 输出 3;收缩 a[5]=1,cnt{2:1,4:1}

i=8: 加入 a[8]=4,cnt{2:1,4:2},distinct=2 → 输出 2;结束

关键时序:必须先加入右端点,再判断窗口是否达到 k,输出后才收缩左端点。顺序错了答案会整体偏移一格。

提示:本题用 unordered_map 可以做到平均 O(n),在 n = 10⁶ 时会明显更快;但 unordered_map 会被卡哈希(出题人构造冲突),所以考场上「不确定就用 map」是更稳妥的策略。
模块 04

总结提升

①核心要点与万能模板
知识板块必须记住的核心
set 声明set<int> s;,遍历用 for (int x : s),即升序
有序 + 去重中序遍历升序;重复 insert 返回 pair(it,false)
存在性判断s.count(v) 或 s.find(v) != s.end()
前驱 / 后继前驱:it = s.upper_bound(v); if (it != s.begin()) --it; 后继:s.lower_bound(v)
双端最值最小 *s.begin(),最大 *s.rbegin()
multiset 删除删一个用 s.erase(s.find(v));绝不能 s.erase(v)
map 计数cnt[x]++ 一行搞定建键 + 计数
map 只读查询用 count / find,不要用 [](会凭空插入)
multimap无 [],用 equal_range(k) 拿区间
复杂度所有核心操作 O(log n),空间 O(n),常数大于数组
选型原则要排序/前驱后继 → set;要键值对/计数 → map;要平均 O(1) → unordered_*
C++万能模板:set / map 常用操作速记卡
#include <bits/stdc++.h>
using namespace std;

// ============ 一、set 模板 ============
void setTemplate() {
    set<int> s;
    s.insert(5);                       // O(log n)
    s.erase(5);                          // 删所有 5(set 中最多 1 个)
    s.erase(s.find(5));                 // 删迭代器指向的那个
    bool has = s.count(5) > 0;         // 存在性
    auto it = s.lower_bound(5);         // 第一个 >= 5
    auto it2 = s.upper_bound(5);        // 第一个 > 5
    if (it2 != s.begin()) { auto p = it2; --p; /* p 指向 <= 5 的最大值 */ }
    if (!s.empty()) { int mn = *s.begin(), mx = *s.rbegin(); }
    for (int x : s) { /* 升序 */ }
}

// ============ 二、multiset 模板(重点在删除)============
void multisetTemplate() {
    multiset<int> ms;
    ms.insert(2); ms.insert(2); ms.insert(2);
    cout << ms.count(2);                // 3,真实个数
    auto it = ms.find(2);
    if (it != ms.end()) ms.erase(it);    // ✅ 只删一个
    // ms.erase(2);                      // ❌ 三个全删
    auto rg = ms.equal_range(2);          // 一次拿到所有 2
}

// ============ 三、map 模板 ============
void mapTemplate() {
    map<string, int> cnt;
    cnt["apple"]++;                     // ✅ 计数场景放心用
    if (cnt.count("pear")) { }          // ✅ 只读查询用 count
    auto it = cnt.find("pear");
    if (it != cnt.end()) cout << it->second;
    for (auto& kv : cnt) { /* kv.first 键,kv.second 值,按键升序 */ }
    cnt.erase("apple");                    // 键唯一,删一个就是删全部

    // 离散化:大值域 -> 1..m 编号
    map<long long, int> disc;
    int id = 0;
    for (auto& kv : cnt) disc[kv.first] = ++id;
}

// ============ 四、multimap 模板 ============
void multimapTemplate() {
    multimap<string, int> mm;
    mm.insert({"Zhang", 90});
    mm.insert({"Zhang", 80});
    auto rg = mm.equal_range("Zhang");
    for (auto it = rg.first; it != rg.second; ++it)
        cout << it->second << " ";
}

// ============ 五、自定义排序(set 存结构体)============
struct Node { int x, y; };
struct Cmp { bool operator()(const Node& a, const Node& b) const {
    return a.x != b.x ? a.x < b.x : a.y < b.y;   // 严格弱序!
} };
// set<Node, Cmp> ns;
②拓展案例
拓1维护中位数:对顶堆的 set 版

题目:动态插入 n 个数(n ≤ 10⁵),每次插入后输出当前所有数的中位数(奇数个取中间,偶数个取中间两个的平均值下取整)。

思路一:multiset 取第 k 小O(n²) 太慢 用 auto it = ms.begin(); advance(it, k); 取第 k 个,但 advance 是 O(k),总共 O(n²),n=10⁵ 会 TLE。

思路二:两个 multiset 对顶O(n log n) 用 lo 存较小的一半、hi 存较大的一半,并保证 lo.size() == hi.size() || lo.size() == hi.size()+1。每次插入后调整两边大小,中位数就是 *lo.rbegin()(或奇数时的这个值)。

C++对顶 multiset 求动态中位数
#include <bits/stdc++.h>
using namespace std;

multiset<long long> lo, hi;   // lo 是较小的一半,hi 是较大的一半

void add(long long x) {
    if (lo.empty() || x <= *lo.rbegin()) lo.insert(x);
    else hi.insert(x);

    // 让 |lo.size() - hi.size()| <= 1,且 lo 不少于 hi
    while (lo.size() > hi.size() + 1) {
        auto it = prev(lo.end());      // lo 的最大值
        long long v = *it;
        lo.erase(it);
        hi.insert(v);
    }
    while (hi.size() > lo.size()) {
        auto it = hi.begin();          // hi 的最小值
        long long v = *it;
        hi.erase(it);
        lo.insert(v);
    }
}

long long median() { return *lo.rbegin(); }

int main() {
    int n; cin >> n;
    for (int i = 0; i < n; i++) {
        long long x; cin >> x;
        add(x);
        cout << median() << " ";
    }
    cout << "\n";
    return 0;
}
运行结果
输入: 7
      3 1 5 2 8 7 4
输出: 3 1 3 2 3 3 4

🔍 代码逐段解析 · 对顶 multiset 求动态中位数

两个容器(第 4 行) —— lo 存较小一半(最大值在 *lo.rbegin()),hi 存较大一半(最小值在 *hi.begin()),中位数永远在 lo 顶端。

add 的分流(第 6~9 行) —— x <= *lo.rbegin() 进 lo,否则进 hi;lo 为空时直接进 lo。

两个调整 while(第 11~21 行) —— 保证 lo.size() == hi.size() 或 lo.size() == hi.size()+1:lo 太大就把它最大值搬给 hi;hi 太大就把它最小值搬给 lo。

median(第 23 行) —— 直接返回 *lo.rbegin(),O(1)。以样例第 4 次插入为例:lo={1,2}、hi={3,5},中位数 2。

易错点 —— ① 平衡条件是「lo 不少于 hi 且最多多 1」,写反会取错中位数;② 从 lo 搬运用 prev(lo.end()) 而不是 lo.rbegin()(erase 不接受反向迭代器)。

小结:对顶结构是「动态中位数」的标准解法。下一讲的 priority_queue 可以把这里换成两个堆,代码几乎一样,常数更小——这正是「同一思想,两种实现」的经典对比。
拓2二维偏序:set 求前驱后继

题目:依次插入 n 个数,每插入一个数 x 后,输出「已插入的数中与 x 最接近的那个数之差的绝对值」;第一个数输出 0。n ≤ 10⁵,|x| ≤ 10⁹。

思路一:每次重排O(n²log n) 超时

思路二:multiset 的前驱后继O(n log n) 插入 x 后,答案必然来自「≤ x 的最大值」和「≥ x 的最小值」两者之一。用 lower_bound 一次拿到后继,回退一步拿到前驱,取 min 即可。

C++前驱后继求最近差
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n; cin >> n;
    set<long long> s;
    for (int i = 0; i < n; i++) {
        long long x; cin >> x;

        if (i == 0) { s.insert(x); cout << 0 << " "; continue; }

        long long best = LLONG_MAX;

        // 后继:第一个 >= x 的数
        auto up = s.lower_bound(x);
        if (up != s.end()) best = min(best, *up - x);

        // 前驱:第一个 <= x 的数(用 upper_bound 再回退一步)
        auto low = s.upper_bound(x);
        if (low != s.begin()) { --low; best = min(best, x - *low); }

        if (s.count(x)) best = 0;    // 已经出现过相同的数

        s.insert(x);
        cout << best << " ";
    }
    cout << "\n";
    return 0;
}
运行结果
输入: 6
      5 1 7 3 3 100
输出: 0 4 2 2 0 93
易错:忘了 low != s.begin() 的判断就 --low,迭代器越界导致 RE;以及漏掉「相同数已存在时答案为 0」的特判。
拓3字符串映射:前缀和 + map 计数

题目:给定长度为 n 的 01 串,求有多少个子串满足「0 和 1 的个数相等」。n ≤ 10⁵。

思路:把 0 记作 −1、1 记作 +1,则「0 和 1 个数相等」等价于「子串和为 0」。设前缀和 s[i],问题变成「有多少对 (i,j),i<j 且 s[i] == s[j]」——用 map 统计每个前缀和出现的次数,每遇到一个 s[i] 就累加此前出现过的次数。O(n log n)。

C++map 统计前缀和相等对数
#include <bits/stdc++.h>
using namespace std;

int main() {
    string t; cin >> t;
    map<int, long long> cnt;
    cnt[0] = 1;             // 空前缀和出现过 1 次

    int s = 0; long long ans = 0;
    for (char c : t) {
        s += (c == '1') ? 1 : -1;
        ans += cnt[s];            // 之前出现过几次 s,就有几个合法子串
        cnt[s]++;                 // 计数场景,[] 安全
    }
    cout << ans << "\n";          // 注意 ans 会超 int,用 long long
    return 0;
}
运行结果
输入: 10101
输出: 6
(长度 2:10、01、10、01 共 4 个;长度 4:1010、0101 共 2 个,合计 6 个)

🔍 代码逐段解析 · map 统计前缀和相等对数

cnt[0]=1(第 6 行) —— 「空前缀」和为 0 出现过 1 次,漏掉它所有从下标 0 开始的合法子串都会被漏算。

01 → ±1(第 9 行 —— 把 '1' 记 +1、'0' 记 −1,子串 0/1 个数相等 ⟺ 子串和为 0 ⟺ 两端前缀和相等。

累加答案(第 10 行) —— ans += cnt[s]:当前前缀和 s 之前出现过几次,就有几个合法子串以当前位置结尾。样例 10101 依次累加 0+1+1+2=4,最后一步 +2 得 6。

再计数(第 11 行) —— 累加完再 cnt[s]++,顺序不能反。

易错点 —— ① ans 必须开 long long(最多 C(10⁵,2) ≈ 5×10⁹);② 先累加后自增的顺序颠倒会把「自己配自己」算进去。

要点:cnt[0] = 1 这一行绝不能漏,它代表「空前缀」,否则所有从下标 0 开始的合法子串都会被漏掉。
③思考与启发

💡 思考1:为什么 set 不支持随机访问,却依然比数组快?

查看参考

因为二者优化的目标不同。数组的 a[i] 是 O(1),但「查值为 v 的元素在哪」是 O(n)。set 牺牲了下标访问(改为 O(k) 的迭代器前进),换来了 O(log n) 的按值查找。

启发:数据结构是「能力取舍」的产物。做题时先写下本题最频繁的操作是什么,再选那个把该操作做到最省的容器。本题若主要操作是「查值」→ set;若是「按下标取第 k 个」→ vector / 数组。

💡 思考2:既然 unordered_map 是 O(1),为什么考试还要用 map?

查看参考

三点原因:①稳定性——unordered_map 依赖哈希函数,出题人可以构造让所有关键字落在同一个桶的数据(即「卡哈希」),退化成 O(n);②有序性——很多题要按关键字升序输出或求前驱后继,哈希表做不到;③实现差异——不同编译器/评测机的哈希策略不同,行为不完全一致。

结论:n ≤ 10⁵ 时老老实实用 map;n ≥ 10⁶ 而且确定不需要有序时,才考虑 unordered_map,并可以给裸哈希加随机扰动(如自定义哈希乘以随机种子)。

💡 思考3:能不能用 set 实现「第 k 小」的 O(log n) 查询?

查看参考

标准 std::set 不能,因为它不维护子树大小,无法二分定位。解决办法有三条:① 用 pb_ds 扩展库里的 tree,支持 order_of_key 和 find_by_order,可以 O(log n) 求第 k 小;② 手写 Treap / Splay / 替罪羊树等平衡树,额外维护 size 域;③ 用值域线段树或树状数组 + 二分。

启发:知道「标准容器做不到什么」,才能判断该不该自己写数据结构。这也是从「会用 STL」到「会设计数据结构」的分水岭。

模块 05

学习检测

先找错,再背口诀——把易错点消灭在考试之前。

⚡高频错误诊断
易错1

multiset 里用 erase(值) 删元素

❌ 错误
multiset<int> ms = {1,2,2,2,3};
ms.erase(2);   // 想把「一个 2」删掉
// 结果:{1,3} —— 三个 2 全没了!
✅ 正确
auto it = ms.find(2);
if (it != ms.end()) ms.erase(it);
// 结果:{1,2,2,3} —— 只删了一个
口诀:multiset 删一个,先 find 拿迭代器;直接 erase 值,小心全军覆没!
易错2

用 operator[] 做「只读查询」

❌ 错误
map<string,int> m;
m["Alice"] = 95;
if (m["Carol"] > 0) /* ... */;
// m.size() 由 1 变成 2,Carol 被凭空插入!
✅ 正确
if (m.count("Carol")) /* 存在 */;
auto it = m.find("Carol");
if (it != m.end()) cout << it->second;
// size 保持不变
口诀:计数自增用方括号,只读查询用 count;方括号一旦写下,元素就已经诞生!
易错3

erase 迭代器后继续用这个迭代器

❌ 错误
// 删除所有偶数
for (auto it = s.begin(); it != s.end(); ++it)
    if (*it % 2 == 0) s.erase(it);
// it 失效,++it 是未定义行为,可能死循环或崩
✅ 正确
// 用 erase 的返回值(C++11)
for (auto it = s.begin(); it != s.end(); )
    if (*it % 2 == 0) it = s.erase(it);
    else ++it;
口诀:边遍历边删除,绝不 ++it 删;删除必接返回值,it = s.erase(it)!
易错4

set 里存结构体但比较函数不是严格弱序

❌ 错误
struct Cmp {
    bool operator()(const Node& a, const Node& b) const {
        return a.x <= b.x;     // 用了 <=
    }
};
// a==a 时返回 true,违反严格弱序 → 插入后行为异常/崩溃
✅ 正确
struct Cmp {
    bool operator()(const Node& a, const Node& b) const {
        return a.x != b.x ? a.x < b.x : a.y < b.y;
    }
};
// 全部用 <,保证「相等时返回 false」
口诀:比较函数只用小于号,相等必须返回 false;写成小于等于,运行结果全乱套!
易错5

对空容器取 *begin() / *rbegin()

❌ 错误
set<int> s;
cout << *s.begin();    // 空集合解引用 end()
// 未定义行为,通常直接 RE / 段错误
✅ 正确
if (!s.empty()) {
    cout << *s.begin() << " " << *s.rbegin();
} else {
    cout << "EMPTY\n";
}
口诀:取最值先判空,empty() 挡在前;空集解引用,评测机上直接 RE!
📋速度与复杂度速查表
操作set / multisetmap复杂度
插入insert(v)insert({k,v}) / m[k]=vO(log n)
删除指定值erase(v)(multiset 删全部)erase(k)O(log n + c)
删除迭代器erase(it)erase(it)均摊 O(1)
查找find(v)find(k)O(log n)
存在性count(v) ∈ {0,1} / 真实个数count(k) ∈ {0,1}O(log n)
下界 / 上界lower_bound / upper_boundlower_bound / upper_boundO(log n)
取最值*begin() / *rbegin()begin()->first / rbegin()->firstO(1)
范围遍历equal_range(v)equal_range(k)O(log n)
清空 / 判空clear() / empty()clear() / empty()O(n) / O(1)
内存每个结点约 40~48 字节(三指针 + 颜色 + 值),比数组大得多O(n)
📌笔记卡
📗 必记概念
  • set:有序 + 去重;multiset:有序 + 可重复
  • map:键唯一的有序键值对;multimap:键可重复
  • 底层红黑树,所有核心操作 O(log n)
  • 遍历 set/map 天然升序,不需要再排序
  • unordered_* 无序但平均 O(1),可能被卡哈希
📘 必背模板
  • 存在性:s.count(v)
  • 前驱:it = s.upper_bound(v); --it;
  • 后继:s.lower_bound(v)
  • 双端最值:*s.begin() / *s.rbegin()
  • 删一个:s.erase(s.find(v))
  • 计数:cnt[x]++
📙 考场提醒
  • n ≥ 10⁶ 就别用 map,常数会拖死
  • set 不能随机访问,求第 k 小要换结构
  • 删除时用迭代器返回值续接循环
  • 比较函数严禁用 <=
  • 取最值前先 empty() 判空
课后练习

分层练习作业

A基础 / B提高 / C挑战,难度递增。C组后附 12 道洛谷原题。

🟢 A组 · 基础(4题)

A1概念题

下面四个容器中,哪些允许元素重复?哪些支持 operator[]?

查看参考答案

思路:从「多重」二字入手——带 multi 前缀的允许重复。

允许重复:multiset、multimap。不允许:set、map(键唯一)。

支持 operator[]:只有 map。multimap 因为一个键对应多个值,无法确定返回哪个,所以不支持;set / multiset 根本没有「值」这一层,也不支持。

考点:四个容器的差异记忆表。

易错:以为 multiset 支持 ms[0] 取第一个元素。set 系列一律要经迭代器访问。

A2编程题

输入 n 个整数,输出去重后按升序排列的结果。n ≤ 10⁵。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
int main() {
    ios::sync_with_stdio(0);
    int n; cin >> n;
    set<long long> s;
    for (int i = 0; i < n; i++) { long long x; cin >> x; s.insert(x); }
    cout << s.size() << "\n";
    for (long long x : s) cout << x << " ";
    cout << "\n";
}

思路:直接依赖 set 的「有序 + 去重」两个性质,插入完成即答案。

逐段:第 4~6 行读入并 insert(重复值自动丢弃);第 7 行 s.size() 输出去重个数;第 8~9 行范围 for 升序输出。总复杂度 O(n log n)。

易错:元素可能达到 10⁹,用 int 读不会溢出但比较时要注意;输出格式若要求行末无多余空格,需要额外判断。

A3编程题

输入 n 个数,再输入 q 次查询,每次问「值 x 在不在集合里」,输出 YES / NO。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
int main() {
    ios::sync_with_stdio(0);
    int n; cin >> n;
    set<int> s;
    for (int i = 0; i < n; i++) { int x; cin >> x; s.insert(x); }
    int q; cin >> q;
    while (q--) {
        int x; cin >> x;
        cout << (s.count(x) ? "YES\n" : "NO\n");
    }
}

思路:O(n log n) 建集合,每次查询 O(log n),总计 O((n+q) log n)。

易错:若这里写 map<int,int> m; if (m[x]) ...,就会不断插入 0,导致内存膨胀与 size 错误。

A4编程题

统计一句话中每个单词出现的次数,按字典序输出(单词仅含小写字母)。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
int main() {
    map<string, int> cnt;
    string w;
    while (cin >> w) cnt[w]++;
    for (auto& kv : cnt) cout << kv.first << " " << kv.second << "\n";
}

思路:while (cin >> w) 读到文件结束;map 自动按键升序,输出天然字典序。

逐段:第 2~4 行 cnt[w]++ 边读边计数;第 5 行顺序遍历 map 即字典序输出「单词 次数」。

易错:cin 读字符串默认以空白切分,若单词含标点需要手动去掉;若要按出现顺序输出就不能用 map,得改用 vector + 排序或 unordered_map + 顺序数组。

🟡 B组 · 提高(4题)

B1set 应用

给定 n 个数,每次插入后输出「已插入的数的最大最小值之差」。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
int main() {
    ios::sync_with_stdio(0);
    int n; cin >> n;
    multiset<long long> ms;
    for (int i = 0; i < n; i++) {
        long long x; cin >> x;
        ms.insert(x);
        cout << (*ms.rbegin() - *ms.begin()) << " ";
    }
    cout << "\n";
}

思路:*begin() 与 *rbegin() 都是 O(1),插入 O(log n)。

易错:用 set 也能过(因为只关心最值),但若题目要求「删除一次某值」就必须用 multiset。

B2map 应用

给定 n 个数,求出现次数最多的数(众数)及次数;若有多个,输出最小的那个。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    map<long long, int> cnt;
    for (int i = 0; i < n; i++) { long long x; cin >> x; cnt[x]++; }
    long long best = 0; int mx = 0;
    for (auto& kv : cnt)
        if (kv.second > mx) { mx = kv.second; best = kv.first; }
    cout << best << " " << mx << "\n";
}

思路:map 计数后顺序扫描,严格 > 保证并列时保留更小的键。

易错:用 >= 会让并列时保留更大的键(因为遍历是升序),与题目要求相反。

B3mutliset 陷阱

模拟一个优先队列:支持「插入 x」「取出并删除最大值」两种操作,共 q 次。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
int main() {
    ios::sync_with_stdio(0);
    int q; cin >> q;
    multiset<long long> ms;
    while (q--) {
        int op; cin >> op;
        if (op == 1) { long long x; cin >> x; ms.insert(x); }
        else {
            if (ms.empty()) { cout << "EMPTY\n"; continue; }
            auto it = prev(ms.end());   // 指向最大值
            cout << *it << "\n";
            ms.erase(it);
        }
    }
}

思路:prev(ms.end()) 即最大值的迭代器,删除 O(1) 均摊。

易错:写 ms.erase(*prev(ms.end())) 会把所有等于最大值的元素全删掉,与「删一个」语义不符。

B4前驱后继

给定 n 个互不相同的数,对每个数 x,求集合中比 x 大的最小数;不存在输出 −1。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    vector<long long> a(n);
    set<long long> s;
    for (auto& x : a) { cin >> x; s.insert(x); }
    for (long long x : a) {
        auto it = s.upper_bound(x);      // 第一个 > x
        cout << (it == s.end() ? -1 : *it) << " ";
    }
    cout << "\n";
}

思路:直接把所有数先塞进 set,然后对每个 x 调 upper_bound(x)。总复杂度 O(n log n)。

逐段:第 4~6 行读入数组同时建 set;第 7~10 行对每个 x 求 upper_bound(x),等于 end() 输出 −1,否则输出 *it。

易错:误用 lower_bound——它返回的是 ≥ x 的位置,当 x 自己就在集合里时会返回 x 本身。要「严格大于」必须用 upper_bound。

🔴 C组 · 挑战(4题)

C1初赛真题

下列关于 C++ STL 中 set 的说法,错误的是:

A. set 中的元素自动按升序排列
B. 向 set 中插入已存在的元素不会改变 set 的内容
C. set 支持用下标运算符 s[i] 访问第 i 个元素
D. set 的 find 操作时间复杂度为 O(log n)

查看参考答案

答案:C

set 底层是平衡二叉搜索树,只支持双向迭代器,不支持随机访问,因此 s[i] 无法编译通过。

考点:容器迭代器类别(随机访问 vs 双向)与操作复杂度。

易错:把 set 与 vector 混为一谈。要取第 k 小的元素,set 只能迭代前进 k 步(O(k))。

C2综合编程

给定 n 个区间 [l, r],求被最多区间覆盖的点的覆盖数。坐标 ≤ 10⁹,n ≤ 10⁵。

查看参考答案

思路一(值域小):差分数组,O(n + V)。

思路二(值域大):离散化 + 事件扫描。把每个区间拆成两个事件 (l, +1)、(r+1, −1),用 map 累加净变化量(map 自动按坐标排序),再顺序前缀和一遍即可。O(n log n)。

#include <bits/stdc++.h>
using namespace std;
int main() {
    ios::sync_with_stdio(0);
    int n; cin >> n;
    map<long long, int> ev;      // 坐标 -> 净变化
    while (n--) {
        long long l, r; cin >> l >> r;
        ev[l]++;                        // 区间开始,覆盖 +1
        ev[r + 1]--;                     // 区间结束的下一位,覆盖 -1
    }
    long long cur = 0, best = 0;
    for (auto& kv : ev) {              // 按坐标升序扫描
        cur += kv.second;
        best = max(best, cur);
    }
    cout << best << "\n";
}

逐段:第 3~8 行把每个区间拆成 ev[l]++、ev[r+1]-- 两个事件;第 9~13 行按坐标升序扫描累加 cur,用 best 记录扫描过程中的最大覆盖数。

易错:把 ev[r+1]-- 写成 ev[r]--,会让「区间右端点本身」少算一次覆盖;坐标达到 10⁹ 时 r+1 也要用 long long。

C3思维题

为什么 multimap 不提供 operator[]?如果要给相同键的多个值排序,该怎么做?

查看参考答案

原因:m[k] 的语义是「返回键 k 对应的那个值的引用」。multimap 中键 k 可能对应多个值,无法确定返回哪一个,所以标准委员会干脆去掉了这个接口。

替代方案:用 equal_range(k) 取出 [first, second) 区间——注意这个区间内元素只保证「键相同」,值的顺序是插入顺序,不保证有序。

要对值排序:把区间内的值拷贝到 vector,sort 后再处理;或者干脆改用 map<K, multiset<V>> 或 map<K, vector<V>>,让每个键挂一个有序/可排序的容器。

考点:容器语义与接口设计的对应关系。

C4综合编程

给定 n 个字符串(n ≤ 10⁵),求其中有多少对不同下标的字符串互为「变位词」(字母组成相同,如 listen / silent)。

查看参考答案

思路:互为变位词 ⟺ 排序后的字符串相同。于是把每个字符串内部字符排序,得到「规范形」,再用 map 统计每种规范形的出现次数 c,答案累加 C(c,2) = c(c−1)/2。

复杂度:每个字符串排序 O(L log L),总 O(n L log L),n = 10⁵、L ≤ 10 时轻松通过。

#include <bits/stdc++.h>
using namespace std;
int main() {
    ios::sync_with_stdio(0);
    int n; cin >> n;
    map<string, long long> cnt;
    for (int i = 0; i < n; i++) {
        string s; cin >> s;
        sort(s.begin(), s.end());      // 规范形
        cnt[s]++;
    }
    long long ans = 0;
    for (auto& kv : cnt)
        ans += kv.second * (kv.second - 1) / 2;   // C(c,2)
    cout << ans << "\n";
}

逐段:第 4~8 行对每个串内部 sort 得到「规范形」后 cnt[s]++;第 9~12 行对每种规范形累加 c*(c-1)/2,即同组内不同下标对数。

易错:答案要开 long long(最多 C(10⁵,2) ≈ 5×10⁹);c*(c-1) 若用 int 会溢出,必须先转 long long 或直接声明为 long long。

🏆 洛谷原题精选(12道)

以下 12 道洛谷题目均与 set / map 家族的使用相关,按难度从入门到提高排列。

1
P1059
明明的随机数入门
2
P1177
【模板】排序入门
3
P2580
于是他错误的点名开始了普及-
4
P1071
潜伏者(映射关系判定)普及-
5
P3613
【深基15.例2】寄包柜普及-
6
P5250
【深基17.例5】木材仓库普及-
7
P5266
【深基17.例6】学籍管理普及-
8
P1918
保龄球(值到下标映射)普及-
9
P4305
[JLOI2011] 不重复数字普及-
10
P1102
A-B 数对(map 计数)普及-
11
P3879
[TJOI2010] 阅读理解(set 存字符串)普及+
12
P2234
[HNOI2002] 营业额统计(前驱后继)提高