第 3.1 章:STL 核心用法
📝 前置条件: 第 2.1–2.3 章(变量、循环、函数、向量)
标准模板库(STL) 是 C++ 内置的现成数据结构和算法集合。不需要从零实现链表、哈希表或排序算法,直接用 STL——它快速、可靠,经过数百万程序员的检验。
学会为问题选择正确的 STL 容器是竞赛编程中最重要的技能之一。
本章你将学到:
sort—— 一行代码,按任意规则排序任意序列pair—— 简洁地将两个值捆绑在一起map/set—— 有序键值存储和唯一元素集合stack/queue—— 用于经典算法的 LIFO 和 FIFO 容器priority_queue—— 始终能 O(log N) 取得最大(或最小)值unordered_map/unordered_set—— 基于哈希的 O(1) 查找auto和范围 for —— 写出更简洁的代码- 常用 STL 算法:
binary_search、lower_bound、accumulate等
3.1.0 STL 工具箱
把 STL 想象成一个工具箱,每种工具都为特定任务设计:
快速参考——该用哪个容器:
| 需求 | 使用 |
|---|---|
| 有序列表,随机访问 | vector |
| 两个值捆绑在一起 | pair |
| 键 → 值映射(有序) | map |
| 唯一元素(有序) | set |
| 键 → 值映射(快速,无序) | unordered_map |
| 唯一元素(快速,无序) | unordered_set |
| LIFO(后进先出) | stack |
| FIFO(先进先出) | queue |
| 快速获取最大/最小值 | priority_queue |
选对工具 = 竞赛编程中解题的一半!
「该用哪个容器?」决策树
图示:STL 容器概览
3.1.1 sort —— 你唯一需要的排序
我们从 sort 开始,因为几乎每道题都会用到它。
是什么,为什么重要
排序将元素序列重新排列成有序状态。不用自己实现排序算法(容易出错且费时),C++ 的 sort 具有以下特点:
- 快速:O(N log N) —— 基于比较的排序的理论最优
- 易用:一行代码
- 灵活:按任意你定义的规则排序
⚠️ 重要:
sort需要#include <algorithm>(通过#include <bits/stdc++.h>自动包含)。
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;
int main() {
// 对向量排序——升序(默认)
vector<int> v = {5, 2, 8, 1, 9, 3};
sort(v.begin(), v.end());
// v 现在是:{1, 2, 3, 5, 8, 9}
for (int x : v) cout << x << " ";
cout << "\n";
// 降序排序
sort(v.begin(), v.end(), greater<int>());
// v 现在是:{9, 8, 5, 3, 2, 1}
// 对数组排序
int arr[] = {4, 2, 7, 1, 5};
int n = 5;
sort(arr, arr + n); // 排序 arr[0..n-1]
// arr 现在是:{1, 2, 4, 5, 7}
return 0;
}
自定义排序(Lambda 函数)
如果想按非自然顺序排序?用 lambda —— 一个小的内联函数:
vector<int> v = {5, -3, 2, -8, 1};
// 按绝对值排序
sort(v.begin(), v.end(), [](int a, int b) {
return abs(a) < abs(b); // a 应该排在 b 前面时返回 true
});
// v 现在是:{1, 2, -3, 5, -8}(按 |值| 排序)
lambda [](int a, int b) { return ...; } 是比较规则。当 a 应该排在 b 前面时返回 true。
🐛 常见错误: 当
a == b时永远不要返回true——这违反了「严格弱序」规则,会导致未定义行为(崩溃或错误答案)。始终用<或>,绝不用<=或>=。
// 对 pair 向量排序:按第二元素降序,相同时按第一元素升序
vector<pair<int,int>> pts = {{3,5},{1,7},{2,5},{4,3}};
sort(pts.begin(), pts.end(), [](pair<int,int> a, pair<int,int> b) {
if (a.second != b.second) return a.second > b.second; // 第二元素更大的优先
return a.first < b.first; // 打平:第一元素更小的优先
});
// 结果:{1,7}, {2,5}, {3,5}, {4,3}
3.1.2 pair —— 把两个值存在一起
pair 将两个值捆绑成一个对象,可以理解为两个值的「迷你结构体」。
为什么用 pair? 经常需要把两个相关的值放在一起——比如(值,下标)、(x 坐标,y 坐标)、(成绩,姓名)。pair 能简洁地做到这点。
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;
int main() {
// 创建 pair
pair<int, int> point = {3, 5}; // (x, y)
pair<string, int> student = {"Alice", 95}; // (姓名, 成绩)
// 访问元素:.first 和 .second
cout << point.first << " " << point.second << "\n"; // 3 5
cout << student.first << ": " << student.second << "\n"; // Alice: 95
// pair 支持比较:先比较 .first,相同时比较 .second
pair<int,int> a = {1, 3};
pair<int,int> b = {1, 5};
cout << (a < b) << "\n"; // 1(真)—— .first 相同,比较 .second:3 < 5
// 非常常见的模式:用 pair 按第二元素排序
vector<pair<int,int>> v = {{3,9},{1,2},{4,1},{1,5}};
sort(v.begin(), v.end()); // 先按 .first 排序,再按 .second 排序
// 结果:{1,2}, {1,5}, {3,9}, {4,1}
return 0;
}
⚡ 专业技巧: 需要按某个值排序但又想保留原始下标时,把它们存成
pair<值, 下标>后排序。排序后.second就是原始下标。
💡
make_pairvs 花括号:make_pair(3, 5)和{3, 5}都能创建 pair。花括号语法{3, 5}更短,是现代 C++(C++11 及以后)的首选方式。
3.1.3 map —— 字典
map 存储键值对,就像真正的字典:给定一个单词(键),查找它的定义(值)。每个键最多出现一次。
什么时候用 map
- 频率统计:单词 → 计数,成绩 → 频率
- ID 映射到名字:学生 ID → 姓名
- 存储属性:奶牛名字 → 产奶量
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;
int main() {
map<string, int> phoneBook;
// 插入键值对
phoneBook["Alice"] = 555001;
phoneBook["Bob"] = 555002;
phoneBook["Charlie"] = 555003;
// 按键查找
cout << phoneBook["Alice"] << "\n"; // 555001
// 遍历(始终按键的**排序顺序**!)
for (auto& entry : phoneBook) {
cout << entry.first << " -> " << entry.second << "\n";
}
// 打印:
// Alice -> 555001
// Bob -> 555002
// Charlie -> 555003
// 删除键
phoneBook.erase("Charlie");
cout << phoneBook.size() << "\n"; // 2
return 0;
}
常用操作及时间复杂度
| 操作 | 代码 | 时间 |
|---|---|---|
| 插入/更新 | m[key] = value | O(log n) |
| 查找 | m[key] 或 m.at(key) | O(log n) |
| 检查是否存在 | m.count(key) 或 m.find(key) | O(log n) |
| 删除 | m.erase(key) | O(log n) |
| 大小 | m.size() | O(1) |
| 遍历全部 | 范围 for | O(n) |
🐛 map 访问陷阱
这是 map 最常见的 bug 之一:
📄 这是 `map` 最常见的 bug 之一:
map<string, int> freq;
// 危险:访问不存在的键会以值 0 创建它!
cout << freq["apple"] << "\n"; // 打印 0,但现在 "apple" 已经在 map 里了!
cout << freq.size() << "\n"; // 1——即使我们只是「查了一下」,并没有「插入」!
// 安全方式一:先检查 count
if (freq.count("apple") > 0) {
cout << freq["apple"] << "\n"; // 安全——键已存在
}
// 安全方式二:用 .find()
auto it = freq.find("apple");
if (it != freq.end()) { // .end() 表示「未找到」
cout << it->second << "\n"; // it->second 是值
}
频率统计——最常见的 map 模式
📄 查看代码:频率统计——最常见的 map 模式
vector<string> words = {"apple", "banana", "apple", "cherry", "banana", "apple"};
map<string, int> freq;
for (const string& w : words) {
freq[w]++; // 如果 "w" 不存在,以 0 创建,然后递增为 1
}
// freq: apple→3, banana→2, cherry→1
for (auto& p : freq) {
cout << p.first << " 出现了 " << p.second << " 次\n";
}
💡 为什么
freq[w]++对新键也有效:map对缺失的值进行默认初始化。对int来说默认值是0。所以访问新键会以值0创建它,然后++使其变为1。这是故意设计的,广泛用于计数。
3.1.4 set —— 唯一有序集合
set 以有序状态存储唯一元素。插入重复值——会被悄悄忽略。
什么时候用 set
- 去除列表中的重复项
- 快速检查成员资格:「我见过这个值吗?」
- 获取动态集合的最小/最大值
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;
int main() {
set<int> s;
s.insert(5);
s.insert(2);
s.insert(8);
s.insert(2); // 重复——被忽略!set 仍是 {2, 5, 8}
s.insert(1);
// s 现在是:{1, 2, 5, 8}(自动排序,无重复)
// 检查成员资格
cout << s.count(2) << "\n"; // 1(存在)
cout << s.count(7) << "\n"; // 0(不存在)
// 删除
s.erase(2); // s = {1, 5, 8}
// 遍历(始终排序)
for (int x : s) cout << x << " ";
cout << "\n"; // 1 5 8
// 最小值和最大值
cout << *s.begin() << "\n"; // 1(最小;* 解引用迭代器)
cout << *s.rbegin() << "\n"; // 8(最大;r = 反向)
cout << s.size() << "\n"; // 3
return 0;
}
常用操作及时间复杂度
| 操作 | 代码 | 时间 |
|---|---|---|
| 插入 | s.insert(x) | O(log n) |
| 检查是否存在 | s.count(x) 或 s.find(x) | O(log n) |
| 删除 | s.erase(x) | O(log n) |
| 最小值 | *s.begin() | O(1) |
| 最大值 | *s.rbegin() | O(1) |
| 大小 | s.size() | O(1) |
用 set 去重
vector<int> v = {3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5};
set<int> unique_set(v.begin(), v.end()); // 从向量构建 set
// unique_set = {1, 2, 3, 4, 5, 6, 9}
cout << "唯一值个数:" << unique_set.size() << "\n"; // 7
// 如需转回排序向量:
vector<int> deduped(unique_set.begin(), unique_set.end());
💡
setvsmultiset:set每个值最多存一次。如果需要存重复值但仍想保持有序,用multiset<int>—— 它允许重复元素。
3.1.5 stack —— 后进先出
栈就像一叠盘子:你只能在顶部添加或移除。最后加入的最先被移除(LIFO:Last In, First Out,后进先出)。
什么时候用 stack
- 括号匹配
- 撤销/重做历史
- 深度优先搜索(DFS)——后续章节讲解
- 反转序列
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;
int main() {
stack<int> st;
st.push(1); // [1] (顶部在右侧)
st.push(2); // [1, 2]
st.push(3); // [1, 2, 3]
cout << st.top() << "\n"; // 3(查看顶部,不移除)
st.pop(); // 移除顶部 → [1, 2]
cout << st.top() << "\n"; // 2
cout << st.size() << "\n"; // 2
cout << st.empty() << "\n"; // 0(不为空)
return 0;
}
常用操作及时间复杂度
| 操作 | 代码 | 时间 |
|---|---|---|
| 压入顶部 | st.push(x) | O(1) |
| 弹出顶部 | st.pop() | O(1) |
| 查看顶部 | st.top() | O(1) |
| 检查是否为空 | st.empty() | O(1) |
| 大小 | st.size() | O(1) |
🐛 常见栈错误:不检查就弹出
stack<int> st;
// st.top(); // 崩溃!不能查看空栈的顶部
// st.pop(); // 崩溃!不能从空栈弹出
// 访问前始终检查:
if (!st.empty()) {
cout << st.top() << "\n";
st.pop();
}
经典栈问题:括号匹配
📄 查看代码:经典栈问题:括号匹配
string expr = "((a+b)*(c-d))";
stack<char> parens;
bool balanced = true;
for (char ch : expr) {
if (ch == '(') {
parens.push(ch); // 开括号:压栈
} else if (ch == ')') {
if (parens.empty()) { // 闭括号但没有对应的开括号
balanced = false;
break;
}
parens.pop(); // 找到匹配:弹出开括号
}
}
if (!parens.empty()) balanced = false; // 还有未匹配的开括号
cout << (balanced ? "匹配" : "不匹配") << "\n";
3.1.6 queue —— 先进先出
队列就像商店排队:你从后面加入,从前面离开。第一个加入的最先被服务(FIFO:First In, First Out,先进先出)。
什么时候用 queue
- 模拟顾客、进程、任务的排队
- 广度优先搜索(BFS)—— 竞赛编程中最重要的算法之一(第 5.2 章)
- 按到达顺序处理元素
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;
int main() {
queue<int> q;
q.push(10); // [10]
q.push(20); // [10, 20]
q.push(30); // [10, 20, 30]
cout << q.front() << "\n"; // 10(排在最前——最先离开)
cout << q.back() << "\n"; // 30(排在最后——最后离开)
q.pop(); // 从前面移除 → [20, 30]
cout << q.front() << "\n"; // 20
cout << q.size() << "\n"; // 2
return 0;
}
常用操作及时间复杂度
| 操作 | 代码 | 时间 |
|---|---|---|
| 加入队尾 | q.push(x) | O(1) |
| 从队首移除 | q.pop() | O(1) |
| 查看队首 | q.front() | O(1) |
| 查看队尾 | q.back() | O(1) |
| 检查是否为空 | q.empty() | O(1) |
注意: 第 5.2 章中你会大量用到
queue来实现 BFS —— USACO 中最重要的图算法之一。
🐛 常见错误:
queue没有top()方法(那是stack的)。用front()查看队首元素,用back()查看队尾。
3.1.7 priority_queue —— 堆
priority_queue 就像一个魔法队列:无论以什么顺序插入,它总是先给你最大的元素(默认最大堆)。
什么时候用 priority_queue
- 需要快速获取最大(或最小)元素
- 找第 K 大的数
- Dijkstra 最短路算法(第 5.4 章)
- 每次都处理「最优」元素的贪心算法
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;
int main() {
// 最大堆:始终给你最大值
priority_queue<int> maxPQ;
maxPQ.push(5);
maxPQ.push(1);
maxPQ.push(8);
maxPQ.push(3);
// 按降序弹出
while (!maxPQ.empty()) {
cout << maxPQ.top() << " "; // 始终是当前最大值
maxPQ.pop();
}
cout << "\n"; // 打印:8 5 3 1
return 0;
}
🐛 最小堆陷阱
默认情况下,priority_queue 是最大堆(优先给最大值)。要创建最小堆(优先给最小值),需要特殊语法:
📄 默认情况下,`priority_queue` 是**最大堆**(优先给最大值)。要创建**最小堆**(优先给最小值),需要特殊语法
// 最大堆(默认)——优先给最大值
priority_queue<int> maxPQ;
// 最小堆——优先给最小值(注意额外的模板参数!)
priority_queue<int, vector<int>, greater<int>> minPQ;
minPQ.push(5);
minPQ.push(1);
minPQ.push(8);
minPQ.push(3);
while (!minPQ.empty()) {
cout << minPQ.top() << " ";
minPQ.pop();
}
// 打印:1 3 5 8(最小值优先)
常用操作及时间复杂度
| 操作 | 代码 | 时间 |
|---|---|---|
| 插入 | pq.push(x) | O(log n) |
| 获取最大/最小值 | pq.top() | O(1) |
| 移除最大/最小值 | pq.pop() | O(log n) |
| 检查是否为空 | pq.empty() | O(1) |
💡 含 pair 的优先队列: 可以在优先队列中存储
pair<int, int>,先比较.first再比较.second——用于 Dijkstra 算法存储{距离, 节点}时非常有用。priority_queue<pair<int,int>, vector<pair<int,int>>, greater<pair<int,int>>> minPQ; // (距离, 节点) 对的最小堆——用于 Dijkstra
3.1.8 unordered_map 和 unordered_set —— 基于哈希的速度
普通 map 和 set 是有序的(内部使用平衡二叉搜索树),操作是 O(log N)。unordered_ 变体使用哈希表,平均 O(1) —— 更快,但没有顺序保证。
💡 底层原理:为什么 map 是 O(log N) 而 unordered_map 是 O(1)?
map/set内部使用红黑树(自平衡二叉搜索树)。每次插入、查找或删除都从根到叶遍历,树高约 log₂N,因此 O(log N)。优点:元素始终有序,支持lower_bound和范围查询。unordered_map/unordered_set内部使用哈希表。哈希函数直接计算存储位置,平均 O(1)。但不保证元素顺序,最坏情况(严重哈希碰撞)可能退化到 O(N)。- 竞赛经验:只需查找/插入而不需要有序遍历时,优先用
unordered_map。但如果因最坏情况被「hack」而 TLE,改回map是最安全的选择。
📄 C++ 完整代码
unordered_map<string, int> freq;
freq["apple"]++;
freq["banana"]++;
freq["apple"]++;
cout << freq["apple"] << "\n"; // 2(与 map 接口相同)
unordered_set<int> seen;
seen.insert(5);
seen.insert(10);
cout << seen.count(5) << "\n"; // 1(找到)
cout << seen.count(7) << "\n"; // 0(未找到)
防 Hack 的 unordered_map
竞赛中对手可以构造导致大量哈希碰撞的输入,使 unordered_map 每次操作退化到 O(N)。一个简单的防御方法是使用自定义哈希:
// 在 main() 之前添加,让 unordered_map 更难被 hack
struct custom_hash {
size_t operator()(long long x) const {
x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9LL;
x = (x ^ (x >> 27)) * 0x94d049bb133111ebLL;
return x ^ (x >> 31);
}
};
unordered_map<long long, int, custom_hash> safe_map;
大多数题目默认的 unordered_map 就够了。只有当你怀疑存在反哈希测试时才用自定义哈希。
什么时候用哪个
| 容器 | 使用场景 |
|---|---|
map / set | 需要有序遍历;需要 lower_bound;N 较小;优先安全 |
unordered_map / unordered_set | 只需查找;N 较大(> 10^5);键是字符串或整数 |
⚡ 专业技巧: 对于大量字符串键的输入,
unordered_map比map快 5-10 倍。但它有极少数情况下可能被竞赛中利用的「最坏情况」行为——如果用unordered_mapTLE 且怀疑被 hack,改用map。
3.1.9 auto 关键字和范围 for
C++ 通常能自动推断变量的类型。auto 关键字告诉编译器:「你来决定类型。」
auto x = 42; // x 是 int
auto y = 3.14; // y 是 double
auto v = vector<int>{1, 2, 3}; // v 是 vector<int>
map<string, int> freq;
auto it = freq.find("cat"); // 类型本应是 map<string,int>::iterator——很长!
// auto 省去了写这么长类型名的麻烦
⚠️
auto陷阱:auto在编译时推断类型——它不会让变量变成动态类型。另外,auto x = 1000000000 * 2;会推断出int并可能溢出;写auto x = 1000000000LL * 2;才能得到long long。
范围 for
对任何容器的简洁遍历:
📄 对任何容器的简洁遍历:
vector<int> nums = {10, 20, 30, 40, 50};
// 只读遍历(复制每个元素——对 int 没问题,对 string 浪费)
for (int x : nums) {
cout << x << " ";
}
// 引用:不复制,可修改元素
for (int& x : nums) {
x *= 2; // 就地翻倍每个元素
}
// const 引用:不复制,只读(大类型的最佳方式)
for (const auto& x : nums) {
cout << x << " ";
}
范围 for 经验法则:
- 小类型(
int、char):for (int x : v)—— 复制没问题 - 大类型(
string、pair、struct):for (const auto& x : v)—— 避免复制 - 需要修改:
for (auto& x : v)
3.1.10 常用 STL 算法
<algorithm> 和 <numeric> 中的这些函数适用于任何序列:
📄 `` 和 `` 中的这些函数适用于任何序列:
#include <bits/stdc++.h>
using namespace std;
int main() {
vector<int> v = {3, 1, 4, 1, 5, 9, 2, 6};
// 升序排序
sort(v.begin(), v.end());
// v = {1, 1, 2, 3, 4, 5, 6, 9}
// 二分查找(只能用于**已排序**的序列!)
cout << binary_search(v.begin(), v.end(), 5) << "\n"; // 1(找到)
cout << binary_search(v.begin(), v.end(), 7) << "\n"; // 0(未找到)
// lower_bound:第一个 >= 目标值的位置
auto it = lower_bound(v.begin(), v.end(), 4);
cout << *it << "\n"; // 4
cout << (it - v.begin()) << "\n"; // 下标:3
// upper_bound:第一个 > 目标值的位置
auto it2 = upper_bound(v.begin(), v.end(), 4);
cout << (it2 - v.begin()) << "\n"; // 下标:4(第一个 > 4 的元素)
// [lo, hi] 范围内的元素个数:upper_bound(hi+1) - lower_bound(lo)
// 最小值和最大值
cout << *min_element(v.begin(), v.end()) << "\n"; // 1
cout << *max_element(v.begin(), v.end()) << "\n"; // 9
// 所有元素之和
long long total = accumulate(v.begin(), v.end(), 0LL);
cout << total << "\n"; // 31
// 统计出现次数
cout << count(v.begin(), v.end(), 1) << "\n"; // 2
// 反转
reverse(v.begin(), v.end());
// 用一个值填充
fill(v.begin(), v.end(), 0); // 全为零
return 0;
}
3.1.11 综合示例:词频统计器
让我们写一个综合运用 map、vector 和 sort 的完整小程序。
问题: 读取 N 个单词,统计每个单词出现次数,然后打印:
- 所有单词及其计数(按字母顺序)
- 出现次数最多的单词
📄 2. 出现次数最多的单词
#include <bits/stdc++.h>
using namespace std;
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n;
cin >> n;
// 第一步:用 map 统计词频
map<string, int> freq;
for (int i = 0; i < n; i++) {
string word;
cin >> word;
freq[word]++; // 如果 word 是新的,以 0 创建,然后递增
}
// 第二步:按字母顺序打印所有单词(map 按键有序迭代)
cout << "所有单词:\n";
for (auto& entry : freq) {
// entry.first = 单词,entry.second = 计数
cout << entry.first << ": " << entry.second << "\n";
}
// 第三步:找最频繁的单词
// 对 map 用 max_element:按值(.second)比较
auto best = max_element(
freq.begin(),
freq.end(),
[](const pair<string,int>& a, const pair<string,int>& b) {
return a.second < b.second; // 按计数比较
}
);
cout << "\n最频繁的:\"" << best->first << "\"(出现了 "
<< best->second << " 次)\n";
return 0;
}
样例输入:
10
the cat sat on the mat the cat sat on
样例输出:
所有单词:
cat: 2
mat: 1
on: 2
sat: 2
the: 3
最频繁的:"the"(出现了 3 次)
复杂度分析:
- 时间:O(N log N)——每次
freq[word]++是 O(log N),共 N 次;max_element遍历 map 是 O(M),M 为不重复单词数- 空间:O(M)——map 存储 M 个不重复单词
🤔 为什么遍历
map是字母顺序?map内部使用平衡 BST,保持键有序。遍历时自动按键的有序顺序输出——无需额外排序!
💡 寻找最大值的替代方案: 除了在 map 上用
max_element,也可以把条目转移到vector<pair<string,int>>,按.second降序排序,取第一个元素。两种方法在计数后都是 O(M)。
本章总结
📌 核心要点
| 容器 | 描述 | 关键操作 | 时间 | 为什么重要 |
|---|---|---|---|---|
vector<T> | 动态数组 | push_back、[]、size | O(1) 均摊 | 最常用容器,默认选择 |
pair<A,B> | 存储两个值 | .first、.second | O(1) | 图的边、坐标等 |
map<K,V> | 有序键值对 | []、find、count | O(log n) | 频率统计、有序映射 |
set<T> | 有序唯一集合 | insert、count、erase | O(log n) | 去重、范围查询 |
stack<T> | 后进先出 | push、pop、top | O(1) | 括号匹配、DFS |
queue<T> | 先进先出 | push、pop、front | O(1) | BFS、模拟 |
priority_queue<T> | 最大堆 | push、pop、top | O(log n) | 贪心最大/最小、Dijkstra |
unordered_map<K,V> | 哈希映射(无序) | []、find、count | O(1) 均摊 | 大数据快速查找 |
unordered_set<T> | 哈希集合(无序) | insert、count、erase | O(1) 均摊 | 快速成员检查、去重 |
❓ 常见问题
Q1:vector 和普通数组有什么区别?什么时候用哪个?
A:
vector可以动态增长(push_back),知道自己的大小(.size()),可以安全传给函数。普通数组大小固定,但稍快(全局数组自动初始化为 0)。竞赛中大多数情况用vector;全局大数组(如int dp[100001])有时更方便。
Q2:什么时候选 map vs unordered_map?
A:只需查找/插入/删除时,用
unordered_map(O(1))更快。需要有序遍历或lower_bound/upper_bound时,用map(O(log N))。没有特殊要求的竞赛中,map更安全(不会被 hack)。
Q3:priority_queue 默认是最大堆还是最小堆?
A:最大堆。
pq.top()返回最大元素。最小堆需要声明为priority_queue<int, vector<int>, greater<int>>。
Q4:什么时候自定义 struct 比 pair 更好?
A:
pair的.first/.second可读性差——三个月后你可能忘了.first代表什么。struct让你给成员起有意义的名字(如.weight、.value)。字段有 3 个或以上时,必须用struct。
Q5:为什么对 vector<pair<int,int>> 排序时先按 .first?
A:
pair有内置的operator<,先比较.first,相同时比较.second。这叫字典序——与字典中单词的排序方式相同。可以放心依赖这个行为,无需自定义比较器。
Q6:s.count(x) 和 s.find(x) != s.end() 对于 set 有什么区别?
A:对
set和map,两者都是 O(log N),在检查存在性方面功能等价。count返回 0 或 1(集合无重复),find返回一个可以直接访问元素的迭代器。需要读取值时用find,只需是/否检查时用count。
🔗 与后续章节的联系
- 第 3.4 章(单调栈与单调队列):用于下一个更大元素问题的单调栈;用于滑动窗口最大/最小的单调双端队列
- 第 3.6 章(栈与队列):深入探讨
stack和queue的算法应用——括号匹配、BFS - 第 3.8 章(映射与集合):
map/set的进阶用法——频率统计、multiset - 第 3.3 章(排序):带自定义比较器的
sort与vector和pair一起使用 priority_queue在第 4.1 章(贪心)和第 5.5 章(Kruskal MST)中频繁出现- 本章的 STL 容器是本书所有后续章节的基础工具
练习题
🌡️ 热身题
热身 3.1.1 — 集合成员查询
读取 N 个整数,然后读取 Q 个查询。对每个查询读取一个整数,若它出现在原始 N 个整数中打印 YES,否则打印 NO。
样例输入:
5
10 20 30 40 50
3
20
35
50
样例输出:
YES
NO
YES
💡 题解(点击展开)
思路: 把 N 个整数存入集合,对每个查询检查 s.count(x)。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
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" : "NO") << "\n";
}
return 0;
}
关键点:
s.count(x)找到时返回 1,未找到时返回 0while (q--)是常见惯用法:循环执行 q 次(每次 q 递减)- 使用 set 每次查询 O(log N),比线性搜索 O(N) 快得多
热身 3.1.2 — 字符频率 读取一个字符串(无空格),按字母顺序打印其中每个字符及其出现次数。
样例输入: hello
样例输出:
e: 1
h: 1
l: 2
o: 1
💡 题解(点击展开)
思路: 用 map<char, int> 统计字符频率,遍历 map 自动按字母顺序输出。
#include <bits/stdc++.h>
using namespace std;
int main() {
string s;
cin >> s;
map<char, int> freq;
for (char c : s) {
freq[c]++;
}
for (auto& entry : freq) {
cout << entry.first << ": " << entry.second << "\n";
}
return 0;
}
关键点:
for (char c : s)遍历字符串中的每个字符freq[c]++第一次访问时以值 0 创建条目,然后递增- map 遍历始终按键有序——字符自动按字母顺序输出
热身 3.1.3 — 栈实现反转
读取一个字符串(无空格),用 stack 将其反转打印。
样例输入: hello → 样例输出: olleh
💡 题解(点击展开)
思路: 把每个字符压栈,然后全部弹出——LIFO 顺序正好是倒序。
#include <bits/stdc++.h>
using namespace std;
int main() {
string s;
cin >> s;
stack<char> st;
for (char c : s) {
st.push(c);
}
while (!st.empty()) {
cout << st.top();
st.pop();
}
cout << "\n";
return 0;
}
关键点:
- 栈的 LIFO 特性:最后压入的最先弹出 = 反转
- 访问
st.top()或调用st.pop()前始终检查!st.empty() - 注意:
reverse(s.begin(), s.end()); cout << s;更简单——但用栈实现能理解概念
热身 3.1.4 — 队列模拟 模拟 5 人排队:Alice、Bob、Charlie、Dave、Eve,按顺序加入。逐一服务(从队首弹出),打印每位被服务者的名字。
期望输出:
Serving: Alice
Serving: Bob
Serving: Charlie
Serving: Dave
Serving: Eve
💡 题解(点击展开)
思路: 把所有名字加入队列,然后逐一弹出并打印直到队列为空。
#include <bits/stdc++.h>
using namespace std;
int main() {
queue<string> line;
line.push("Alice");
line.push("Bob");
line.push("Charlie");
line.push("Dave");
line.push("Eve");
while (!line.empty()) {
cout << "Serving: " << line.front() << "\n";
line.pop();
}
return 0;
}
关键点:
queue.front()不移除地访问第一个元素queue.pop()移除队首元素(无返回值——如需值,在pop()前用front())- 队列保持插入顺序——先压入的先弹出
热身 3.1.5 — 前 3 大
读取 N 个整数,用 priority_queue 找出并打印最大的 3 个值(降序)。
样例输入:
7
5 1 9 3 7 2 8
样例输出:
9
8
7
💡 题解(点击展开)
思路: 全部压入最大堆优先队列,弹出 3 次得到最大的 3 个。
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
cin >> n;
priority_queue<int> pq;
for (int i = 0; i < n; i++) {
int x;
cin >> x;
pq.push(x);
}
for (int i = 0; i < 3 && !pq.empty(); i++) {
cout << pq.top() << "\n";
pq.pop();
}
return 0;
}
关键点:
priority_queue<int>是最大堆——top()始终给出最大值- 弹出 3 次按顺序得到最大的 3 个值
&& !pq.empty()防卫处理 N < 3 的边界情况
🏋️ 核心练习题
题目 3.1.6 — 唯一元素 读取 N 个整数,只打印唯一的值,按它们第一次出现的顺序(不排序)。一个值出现多次时,只在第一次出现时打印。
样例输入:
8
3 1 4 1 5 9 2 6
样例输出: 3 1 4 5 9 2 6
(注意:1 出现两次,但只在第一次位置打印一次。)
💡 题解(点击展开)
思路: 用 unordered_set 追踪已见过的值。对每个元素,只有还没见过时才打印。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n;
cin >> n;
unordered_set<int> seen;
bool first = true;
for (int i = 0; i < n; i++) {
int x;
cin >> x;
if (seen.count(x) == 0) { // 还没见过 x
seen.insert(x);
if (!first) cout << " ";
cout << x;
first = false;
}
}
cout << "\n";
return 0;
}
关键点:
- 不能用普通
set,因为 set 会排序输出——我们要保持原始顺序 unordered_set提供 O(1) 均摊查找:比搜索向量快得多first标志处理间隔(不打印前导/尾随空格)
题目 3.1.7 — 出现最多的单词 读取 N 个单词,打印出现次数最多的单词。如果有平局,打印字典序最小的单词。
样例输入:
7
apple banana apple cherry banana apple cherry
样例输出: apple
💡 题解(点击展开)
思路: 用 map 统计计数,然后找最大计数。在所有出现该最大计数的单词中,取字典序最小的(map 遍历自然给出)。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n;
cin >> n;
map<string, int> freq;
for (int i = 0; i < n; i++) {
string w;
cin >> w;
freq[w]++;
}
string bestWord;
int bestCount = 0;
// map 按字母顺序迭代——最先见到的达到最大计数的单词胜出
for (auto& entry : freq) {
if (entry.second > bestCount) {
bestCount = entry.second;
bestWord = entry.first;
}
}
cout << bestWord << "\n";
return 0;
}
关键点:
- 用
>(严格大于)意味着我们保留第一个达到最大计数的单词 - 由于
map按字母顺序迭代,第一个见到的最大计数单词就是字典序最小的 - 得益于 map 的有序特性,平局处理自动完成
题目 3.1.8 — 配对求和 读取 N 个整数和目标 T。对于每对值 (a, b),a 在输入中排在 b 前面且 a + b = T,打印该对。用集合实现 O(N) 解法。
样例输入:
6 9
1 8 3 6 4 5
样例输出:
1 8
3 6
4 5
💡 题解(点击展开)
思路: 对每个元素 x,检查 T - x 是否已在已见元素的集合中。如果是,找到了一对。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n, t;
cin >> n >> t;
set<int> seen;
vector<int> arr(n);
for (int i = 0; i < n; i++) cin >> arr[i];
for (int i = 0; i < n; i++) {
int complement = t - arr[i]; // 需要 arr[i] + complement = t
if (seen.count(complement)) {
// complement 在 arr[i] 之前,按顺序打印:complement arr[i]
cout << complement << " " << arr[i] << "\n";
}
seen.insert(arr[i]);
}
return 0;
}
关键点:
- 对每个元素 x,需要的「补数」是
T - x - 如果补数已在「已见」集合中,它在数组前面出现 → 有效的对
- 这是用 set 的 O(N log N)(或用 unordered_set 的 O(N)),优于暴力 O(N²)
- 先打印
complement(它在输入中较早),再打印arr[i]
题目 3.1.9 — 括号匹配
读取只含 (、)、[、]、{、} 的字符串,若所有括号都正确匹配和嵌套打印 YES,否则打印 NO。
样例输入 1: {[()]} → 输出: YES
样例输入 2: ([)] → 输出: NO
样例输入 3: ((() → 输出: NO
💡 题解(点击展开)
思路: 用栈。压入开括号;见到闭括号时,检查栈顶是否是对应的开括号。
#include <bits/stdc++.h>
using namespace std;
int main() {
string s;
cin >> s;
stack<char> st;
bool ok = true;
for (char ch : s) {
if (ch == '(' || ch == '[' || ch == '{') {
st.push(ch); // 开括号:压栈
} else {
// 闭括号
if (st.empty()) {
ok = false; // 没有对应的开括号
break;
}
char top = st.top();
st.pop();
// 检查是否匹配
if ((ch == ')' && top != '(') ||
(ch == ']' && top != '[') ||
(ch == '}' && top != '{')) {
ok = false;
break;
}
}
}
if (!st.empty()) ok = false; // 还有未匹配的开括号
cout << (ok ? "YES" : "NO") << "\n";
return 0;
}
关键点:
- 核心思路:最近打开的括号必须是下一个关闭的
- 栈的 LIFO 特性完美模拟了「最近打开」的要求
- 三种失败条件:(1) 空栈时遇到闭括号;(2) 括号类型不匹配;(3) 末尾还有未关闭的括号
题目 3.1.10 — 前 K 大 读取 N 个整数和 K,打印最大的 K 个值(降序)。
样例输入:
8 3
4 9 1 7 3 5 2 8
样例输出:
9
8
7
💡 题解(点击展开)
思路: 全部压入最大堆,弹出 K 次。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n, k;
cin >> n >> k;
priority_queue<int> pq;
for (int i = 0; i < n; i++) {
int x;
cin >> x;
pq.push(x);
}
for (int i = 0; i < k && !pq.empty(); i++) {
cout << pq.top() << "\n";
pq.pop();
}
return 0;
}
关键点:
- 优先队列自动把最大值放在顶部
- 每次
pop()移除当前最大值,下一个最大值浮现 - 替代方案:降序排序后取前 K 个——结果相同
🏆 挑战题
挑战 3.1.11 — 库存系统 处理库存的 M 条命令,每条是以下之一:
ADD name quantity—— 添加quantity单位的产品nameREMOVE name quantity—— 移除quantity单位(若移除量超过库存,设为 0)QUERY name—— 打印name的当前库存量(从未添加过则为 0)
样例输入:
6
ADD apple 10
ADD banana 5
QUERY apple
REMOVE apple 3
QUERY apple
QUERY grape
样例输出:
10
7
0
💡 题解(点击展开)
思路: 用 map<string, long long> 作为库存,解析每条命令并相应更新。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int m;
cin >> m;
map<string, long long> inventory;
while (m--) {
string cmd;
cin >> cmd;
if (cmd == "ADD") {
string name;
long long qty;
cin >> name >> qty;
inventory[name] += qty;
} else if (cmd == "REMOVE") {
string name;
long long qty;
cin >> name >> qty;
inventory[name] -= qty;
if (inventory[name] < 0) inventory[name] = 0;
} else { // QUERY
string name;
cin >> name;
// 用 count 检查存在性,避免为缺失的商品创建条目
if (inventory.count(name)) {
cout << inventory[name] << "\n";
} else {
cout << 0 << "\n";
}
}
}
return 0;
}
关键点:
inventory[name] += qty——若name不存在,以 0 创建后加 qty(正确!)- QUERY 时用
inventory.count(name)检查存在性,避免悄悄创建值为 0 的条目 - 数量用
long long以防较大
挑战 3.1.12 — 滑动窗口最大值 读取 N 个整数和窗口大小 K,打印每个连续 K 元素窗口的最大值。
样例输入:
8 3
1 3 -1 -3 5 3 6 7
样例输出:
3
3
5
5
6
7
(窗口:[1,3,-1]→3,[3,-1,-3]→3,[-1,-3,5]→5,[-3,5,3]→5,[5,3,6]→6,[3,6,7]→7)
💡 题解(点击展开)
思路: 用 deque(双端队列)维护一个有用下标的窗口。双端队列按值的递减顺序存储下标——deque.front() 始终是当前窗口最大值的下标。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n, k;
cin >> n >> k;
vector<int> arr(n);
for (int i = 0; i < n; i++) cin >> arr[i];
deque<int> dq; // 存储下标,队首 = 当前最大值的下标
for (int i = 0; i < n; i++) {
// 移除已超出当前窗口的下标
while (!dq.empty() && dq.front() < i - k + 1) {
dq.pop_front();
}
// 从队尾移除值小于 arr[i] 的下标
//(只要 arr[i] 还在窗口里,它们就不可能成为最大值)
while (!dq.empty() && arr[dq.back()] <= arr[i]) {
dq.pop_back();
}
dq.push_back(i);
// 从窗口满的位置(下标 k-1)开始打印最大值
if (i >= k - 1) {
cout << arr[dq.front()] << "\n";
}
}
return 0;
}
关键点:
- 双端队列维护「单调递减队列」的下标
- 队首 = 当前窗口最大值的下标
- 加入新元素
arr[i]时:从队尾移除所有 ≤arr[i]的元素(它们无用——arr[i] 更大且在窗口中待的时间更长) - 当该下标不再在窗口内时(下标 < i - k + 1),从队首移除
- 总计 O(N)——每个下标最多压入和弹出各一次
挑战 3.1.13 — 干草堆范围计数 (USACO Bronze 风格)
N 捆干草堆放在数轴上的不同位置,处理 Q 个查询:对每个查询 (L, R),打印位置在 [L, R] 范围内(包含端点)的干草堆数量。
样例输入:
5 4
3 1 7 5 2
1 3
2 6
4 8
1 10
样例输出:
3
3
2
5
💡 题解(点击展开)
思路: 对位置排序,对每个查询用 lower_bound 和 upper_bound 在 O(log N) 内找出范围内的数量。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n, q;
cin >> n >> q;
vector<int> pos(n);
for (int i = 0; i < n; i++) cin >> pos[i];
sort(pos.begin(), pos.end()); // 二分查找前必须排序
while (q--) {
int l, r;
cin >> l >> r;
// lower_bound(l):第一个 >= l 的位置
// upper_bound(r):第一个 > r 的位置
// [l, r] 内的元素个数 = 这两个迭代器之间的距离
auto lo = lower_bound(pos.begin(), pos.end(), l);
auto hi = upper_bound(pos.begin(), pos.end(), r);
cout << (hi - lo) << "\n"; // 迭代器间距离 = 个数
}
return 0;
}
关键点:
- 先对位置排序——二分查找的前提条件
lower_bound(pos.begin(), pos.end(), l)返回第一个 ≥ l 的元素的迭代器upper_bound(pos.begin(), pos.end(), r)返回第一个 > r 的元素的迭代器- [l, r] 内元素个数 = 两迭代器之间的距离 =
hi - lo - 总复杂度:排序 O(N log N) + 查询 O(Q log N)——远优于暴力 O(N×Q)
展望:超越基础 STL
本章涵盖了 90% 题目中会用到的核心 STL 容器。随着进阶,你还会遇到更专业的结构:
deque<T>—— 双端队列;支持 O(1) 在两端压入/弹出。用于滑动窗口最大值(挑战 3.1.12)和单调队列(第 3.4 章)。multiset<T>—— 类似set但允许重复元素。需要有序且有重复时使用。bitset<N>—— 固定大小的位序列;处理子集/成员问题极快。- Trie(前缀树) —— 通过共享公共前缀存储字符串,支持 O(L) 查找(L 是字符串长度)。
图示:Trie 数据结构
Trie(前缀树)通过共享公共前缀存储字符串。单词 "bat"、"car"、"card"、"care"、"cat" 高效共享前缀:"ca" 只存一次,分支到 "r" 和 "t"。双圈节点标记单词结尾。Trie 用于自动补全、拼写检查和字符串匹配。字符串哈希的替代方案参见第 3.7 章(哈希技术)。