椰程信奥·互动课件 专题11·数据结构3:set / multiset / pair / map
10:00 1 / 17
🧭 椰程信奥 · CSP复赛专题系列

专题11 · 数据结构3

当"下标"不够用了:学号是字符串、编号到 20 亿、还要顺带排好序 ——
这就是 set 与 map 的主场。本讲吃透关联容器的有序性与 O(log n), 并用两道真题把"去重排序"和"频次统计"彻底拿下。

明明的随机数(set 去重排序) 主要成分(map 频次统计) 红黑树 · 有序 · O(log n)
路线图

关联容器全景(对应视频 43′42″)

关联容器 = 用「键」直接找「值」,不用遍历set 集合只存键,自动去重 + 自动排序13379输入 3 出现两次 → 只留一个查找 O(log n)map 映射存 键 → 值,按键排序A→2B→5C→1D→9unordered_map 不排序,平均 O(1)要顺序就用 map,只要快就用 unordered坑:题目要求「保持原顺序」时,set / map 会把它排掉,必须改用数组。

🌳 set / multiset 0–15′

有序 + 去重(multiset 允许重复)。红黑树实现,双向迭代器。
解决:去重排序、前驱后继、存在性判断。

🔑 pair 与 map 15–30′

key → value 映射,key 唯一、自动按 key 升序。
解决:字符串/大编号当"下标"、频次统计。

⚡ unordered_map 30–43′

哈希表版 map:平均 O(1),但不排序。
解决:只要计数、不需要顺序。

两道真题:① 明明的随机数(NOIP2006 普及 T1,set 去重排序)→ ② 主要成分(蓝桥 2023 国赛 T2,map 频次统计)
一句话贯穿全讲:数组下标是"连续的整数",map 的 key 是"任意可比较的东西"。 当你发现"想用下标,但下标不是小整数"时,就该用 map 了。
动机

三种"数组下标用不了"的时刻

① 下标太大

要统计成分编号 2e9 的出现次数。
int cnt[2000000000] → 直接 MLE。

map<long long,int> cnt; cnt[x]++;

② 下标不是整数

要存"姓名 → 分数"。a["张三"] 数组做不到。

map<string,int> sc; sc["张三"]=95;

③ 还要顺带排好序

去重 + 升序输出。用数组要 sort + 手动去重两步。

set<int> s; 插完即为升序且无重复

⚠️ 代价要清楚:数组下标是 O(1);set/map 是 O(log n)(红黑树,每次操作走树高)。 所以当 key 确实是 小范围连续整数 时,数组永远更快 —— 别为了炫技用 map 替代数组。
✅ 判断口诀: key 是 0…N-1 的小整数 → 数组;key 大 / 是字符串 / 稀疏 → map; 只要"在不在、去重、有序"不需要 value → set。
3D🎲 set = 一棵会自动平衡的二叉搜索树🖱 拖拽旋转 · 双击复位
为什么用 3D:set 的「去重 + 有序」两个特性都长在树上。立体 BST 上走一遍插入路径,再中序遍历一次,两个特性自己就解释完了。
动画①

set 的两大天性:去重 + 自动升序

逐个插入下面这串数,看 set 怎么"吞掉重复、排好顺序"。

序列
待插入序列:
点「插入下一个」开始。

insert 的返回值

pair<iterator,bool> r = s.insert(x);
r.second == true 表示新插入;false 表示已存在、没插进去。 这个 bool 经常用来判重。

set vs multiset

set:重复元素插不进去。
multiset:允许重复,s.count(x) 能拿到个数; s.erase(x) 会删掉所有等于 x 的(要删一个得用迭代器)。

⚠️ set 的元素不可修改: *it = 5 编译不过(改了会破坏有序性)。要改只能先删再插。
动画②

在有序集合里"二分定位"

查一个数 x:lower_bound 找第一个 ≥ x, upper_bound 找第一个 > x。看指针怎么跳。

查 目标 x =
有序集合:15 20 32 40 67 89 300 400。选一个 x 开始。

怎么记这两个函数

把结果想成插入位置:
lower_bound(x) → 插在所有 < x 的后面(即第一个 ≥ x 处);
upper_bound(x) → 插在所有 ≤ x 的后面(即第一个 > x 处)。

常用套路

① 判存在:s.find(x) != s.end()(或 s.count(x));
② 前驱:--s.lower_bound(x);
③ 后继:s.upper_bound(x);
④ 区间计数:distance(l, r)。

⚠️ --s.begin() 是未定义行为: 求前驱前必须确认 lower_bound(x) != s.begin(),否则 x 比所有元素都小时会崩。
动画③

map:把"名字"当下标用

姓名 → 分数。注意观察 用 [] 访问一个不存在的 key 会发生什么。

点按钮操作这张 map。

pair:两个值绑一起

pair<string,int> p("张三", 95);
p.first;  p.second;
auto q = make_pair("李四", 88);
// 比较:先比 first,相等再比 second

map 的每个元素就是一个 pair<key,value>。

遍历 map

for(auto& p : mp){
  cout << p.first << " "
       << p.second << '\n';
}

按 key 升序自动排好(红黑树中序遍历)。

⚠️ 本讲最大的坑:mp[key] 会把不存在的 key 插进去! 判断"某 key 是否存在"必须用 mp.find(key) != mp.end() 或 mp.count(key), 不要用 mp[key] —— 它会悄悄插入一个值为 0 的元素,改变 mp.size()。
动画④

O(n) 扫一遍 vs O(log n) 走树高

同一个"找不找得到"的问题,两种做法的比较次数差多少?

规模 n
选规模后点「开始查找」。

为什么是 log n(像查字典)

把 set 想成一本永远按页码排好序的书:翻到中间看一眼,就能排除掉一半, 再在剩下的一半里翻中间……n = 10 万时只要约 17 次就能找到。

对比:用数组一个个找,最坏要找 10 万次。

红黑树做了什么(了解即可)

它就是上面那本"会自动整理顺序的书"。插入删除后它会自己调整,保证查找一直很快。 调整的细节(变色、旋转)不需要你写,只要记住结论:它是平衡的,所以快。

✅ 复杂度速查: insert / erase / find / count / lower_bound 都是 O(log n); begin() / rbegin()、size() / empty() 是 O(1); distance() 是 O(n)。
例题1

去重 + 排序:set 一遍搞定

原始序列逐个进 set:重复的被吞掉,剩下的天然升序。

原始序列:20 40 32 67 40 20 89 300 400 15
set 内容(升序、无重复):(空)
点「插入下一个」开始。

用 set 的解法

set<int> s;
for(...) s.insert(x);
cout << s.size() << '\n';
for(auto v : s) cout << v << ' ';

两行核心代码,去重排序一次完成。

不用 set 的解法

sort(a, a+n);
int m = 0;
for(int i=0;i<n;i++)
  if(i==0 || a[i]!=a[i-1])
    b[m++] = a[i];

先排序,再用"和前一个不同"去重。

⚠️ 输出格式:第 1 行是个数,第 2 行是升序序列; 两个数之间有空格,行末多一个空格没关系,但不能换行输出每个数。
例题1

明明的随机数 · 完整代码

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

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

  int n;
  if(!(cin >> n)) return 0;
  set<int> s;                // ★ 去重 + 升序
  for(int i=0;i<n;i++){
    int x; cin >> x;
    s.insert(x);                // 重复自动忽略
  }
  cout << s.size() << '\n';      // ★ 第一行:个数
  for(set<int>::iterator it = s.begin();
      it != s.end(); ++it){     // ★ 不能写 it < s.end()
    cout << *it << ' ';       // 迭代器是"指针",取值要 *
  }
  cout << '\n';
  return 0;
}
坑① 迭代器比较:set 的迭代器是双向迭代器, 不支持 < / +n,只能用 != 和 ++。
坑② 取值要解引用:cout << it 会输出地址(或编译错误), 必须 *it。
坑③ 不能用下标访问:s[0] 编译不过 —— set 没有 [],因为它不是随机访问。要第 k 小只能从头 advance。
✅ 复杂度:n 次 insert,每次 O(log n) → O(n log n); n ≤ 100 完全无压力。(即使用 sort 版本也是 O(n log n),但要多写 5 行。)
例题2

频次统计:map 计数 vs 摩尔投票

同一个问题两种方法。先看 map 计数,再看 摩尔投票(两两抵消)。

方法
序列:1 2 3 2 2 1 2  N = 7,一半 = 3
点「单步」开始。

map 计数(本讲主题)

每个成分记一个次数,最后找有没有 cnt > N/2。
优点:直观、还能顺便输出所有频次;代价:O(n log n) 时间、O(n) 空间。

摩尔投票(拓展)

把"当前候选"和"别的数"两两抵消,剩下的就是候选,最后数一遍验证。
优点:O(n) 时间、O(1) 空间;局限:只能找"超过一半"的。

例题2

主要成分 · 两种写法

// 方法一:map 计数(本讲主题)
#include <map>
map<long long, int> cnt;
for(int i=0;i<n;i++){
  long long x; cin >> x;
  cnt[x]++;               // ★ 不存在就插入,初值 0
}
int half = n / 2;         // ★ N÷2 向下取整
for(auto& p : cnt){
  if(p.second > half){    // ★ 严格大于
    cout << p.first << '\n';
    return 0;
  }
}
cout << "No\n";
// 方法二:摩尔投票(O(1) 空间)
long long cand = 0; int c = 0;
for(int i=0;i<n;i++){
  long long x; cin >> x;
  a[i] = x;
  if(c == 0){ cand = x; c = 1; }
  else if(x == cand) c++;
  else c--;             // 不同就抵消
}
int tot = 0;              // ★ 必须再数一遍验证
for(int i=0;i<n;i++) if(a[i]==cand) tot++;
if(tot > n/2) cout << cand << '\n';
else cout << "No\n";
⚠️ 三个必错点: ① 值域到 2e9,map 的 key 必须用 long long,用 int 会溢出; ② 条件是 > n/2(严格大于),写成 >= 在偶数 N 时会错; ③ 摩尔投票必须再数一遍验证 —— 抵消剩下的只是"候选",不一定真超过一半。
选型

要"有序"还是要"更快"?

特性map(红黑树)unordered_map(哈希表)
查找/插入/删除O(log n),稳定平均 O(1),最坏 O(n)
是否按键排序✅ 自动升序❌ 无序
能否 lower_bound / 前驱后继✅ 可以❌ 不支持
key 的要求能比较(<)能哈希 + 判等
自定义类型写比较函数要写哈希函数(麻烦)

什么时候必须用 map

① 要按 key 顺序输出(如统计后按数升序输出);
② 要前驱 / 后继 / 区间(lower_bound);
③ 数据卡哈希(担心被构造数据卡成 O(n))。

什么时候用 unordered_map

① 只做计数、判重,完全不在乎顺序(如本题"主要成分");
② n 很大(1e6)且时间紧;
③ key 是整数 / string 这类有默认哈希的类型。

⚠️ unordered_map 也有同样的 [] 陷阱: mp[key] 会插入不存在的 key。另外它没有 lower_bound, 凡是"要找第一个 ≥ x"的需求,只能回到 map。
拓展

容器套容器:真题里常见的三种组合

① map + set

map<string, set<int>> mp;
// 每个人 → 他做过的题号集合
mp["张三"].insert(1001);

用于"诗歌在线评测"类:按作者归档并自动去重排序。

② map 记最近位置

map<int, int> last;
if(last.count(x))
  ans = min(ans, i - last[x]);
last[x] = i;

用于"最近的一对":边扫边更新上次出现位置,一趟 O(n log n)。

③ 结构体入 set

struct Stu{ int id; string name; };
struct Cmp{
  bool operator()(
    const Stu& a, const Stu& b) const{
    return a.id < b.id;   // 按 id 排序
  }
};
set<Stu, Cmp> s;

自定义"什么叫小",set 才知道怎么排。

✅ 视频里的两道拓展题, 思路都是同一句:"要按 key 归档 / 要记住上次出现的位置" → 用 map。 先写出 map 版本跑通,再想能不能用数组或哈希优化。
诊断

关联容器的易错辨析

① 想判断 key 是否存在于 map 中,应该用?

② set 里的元素想改成一个新值,应该?

③ 需要"按 key 从小到大输出",选?

拿分

一道题 100 分是分档给的——不会正解也要先拿保底

复赛一道题的 100 分由十几到二十几个测试点组成, 前面几档专门为「没想出正解的人」准备。四道题各拿 30~40 分, 加起来往往就是一等奖和二等奖的分界。

第 1 档 特判 5~10 分 约 2 分钟 第 2 档 暴力 20~30 分 约 8 分钟 第 3 档 特殊性质 40~60 分 约 15 分钟 第 4 档 正解 100 分 约 25 分钟

① 特判档:先别想算法,看数据范围表

题目给的数据范围表就是出题人给你的送分清单。看到「n = 1」「只有一组数据」这类一行, 就先写个 if 直接输出答案。2 分钟换 5~10 分,全场最划算。

② 暴力档:按题意最直白地写一遍

多重循环、DFS 全枚举、朴素 O(n³)……不要优化,只要保证最小那档全对。 它既是保底分,又是后面对拍的标尺——没有它你无法证明正解是对的。

③ 特殊性质档:题目里那句「若……」

常见的送分性质:数据已经有序、所有值完全相同、规模小到可以 O(n²)、 只出现一种类型。题目写出来就是让你拿的,专门写一份即可。

④ 正解档:思路定了,就赢了八成

考场上的时间几乎都花在想思路上,不是写代码。写完先跑样例, 再用第 ② 档的暴力版造小数据对拍——这是唯一能证明你思路对的办法。

考场纪律:每档设时间上限,到点还没调通就 立刻提交当前版本,保住已有分数,再往上冲。最常见的翻车是"正解写了 50 分钟没过,连暴力分都没交上去"。
拿分

本讲 2 道题,各自的四档怎么走

明明的随机数 · RANDOM

第1档 · 2 分钟
n == 1 → 输出 1 和它本身

第2档 · 8 分钟
排序 + 手动去重(相邻比较),n ≤ 10⁵ 稳过

第3档 · 10 分钟
只去重不排序(保持原顺序)→ 部分测试点只查去重

第4档 · 15 分钟
set<int> 插入后直接遍历输出,天然去重 + 有序

主要成分 · MAINCOMP

第1档 · 2 分钟
只有一种元素 → 就是它

第2档 · 8 分钟
双重循环统计每种出现次数,n ≤ 10⁴ 稳过

第3档 · 10 分钟
元素值域很小(如 ≤ 1000)→ 用数组当桶计数

第4档 · 15 分钟
map<T,int> 计数,扫一遍取最大

50 分钟时间盒(一题的节奏):
0–5 分钟 读题 + 圈出数据范围表里最小的那几档 → 5–15 分钟 写完第 1、2 档并先交一次(保住 20~30 分)→ 15–40 分钟 冲第 3、4 档 → 40–50 分钟 造小数据对拍、检查 freopen 与文件名。
课堂上别让学生"从头想正解"。先要求每个人 15 分钟内交出第 1、2 档, 再放开冲正解。这个顺序一换,平均分通常能涨一大截——因为它杜绝了"想了 40 分钟,交上去 0 分"。
当堂检测

椰程信奥当堂测评

① 主要成分中,N = 6、某成分出现 3 次,算不算主要成分?

② 明明的随机数里,set 遍历输出时要用什么比较迭代器?

③ 值域到 2×10⁹ 的编号要做频次统计,key 的类型应该是?

椰程锦囊 · 关联容器三问
① key 是小连续整数吗?是 → 数组(最快); ② 需要有序 / 前驱后继吗?需要 → map / set; ③ 只计数不在乎顺序吗?是 → unordered_map。 另外记住一条铁律:判存在用 find / count,绝不用 []。💡

目录