📖 第 3.1 章 ⏱️ 约 70 分钟 🎯 入门

第 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_searchlower_boundaccumulate

3.1.0 STL 工具箱

把 STL 想象成一个工具箱,每种工具都为特定任务设计:

STL Toolbox

快速参考——该用哪个容器:

需求使用
有序列表,随机访问vector
两个值捆绑在一起pair
键 → 值映射(有序)map
唯一元素(有序)set
键 → 值映射(快速,无序)unordered_map
唯一元素(快速,无序)unordered_set
LIFO(后进先出)stack
FIFO(先进先出)queue
快速获取最大/最小值priority_queue

选对工具 = 竞赛编程中解题的一半!

「该用哪个容器?」决策树

STL Container Decision Tree

图示:STL 容器概览

STL Containers


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_pair vs 花括号: 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] = valueO(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)
遍历全部范围 forO(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());

💡 set vs multiset 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_mapunordered_set —— 基于哈希的速度

普通 mapset 是有序的(内部使用平衡二叉搜索树),操作是 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_mapmap 快 5-10 倍。但它有极少数情况下可能被竞赛中利用的「最坏情况」行为——如果用 unordered_map TLE 且怀疑被 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 经验法则:

  • 小类型(intchar):for (int x : v) —— 复制没问题
  • 大类型(stringpair、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 综合示例:词频统计器

让我们写一个综合运用 mapvectorsort 的完整小程序。

问题: 读取 N 个单词,统计每个单词出现次数,然后打印:

  1. 所有单词及其计数(按字母顺序)
  2. 出现次数最多的单词
📄 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[]sizeO(1) 均摊最常用容器,默认选择
pair<A,B>存储两个值.first.secondO(1)图的边、坐标等
map<K,V>有序键值对[]findcountO(log n)频率统计、有序映射
set<T>有序唯一集合insertcounteraseO(log n)去重、范围查询
stack<T>后进先出pushpoptopO(1)括号匹配、DFS
queue<T>先进先出pushpopfrontO(1)BFS、模拟
priority_queue<T>最大堆pushpoptopO(log n)贪心最大/最小、Dijkstra
unordered_map<K,V>哈希映射(无序)[]findcountO(1) 均摊大数据快速查找
unordered_set<T>哈希集合(无序)insertcounteraseO(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:什么时候自定义 structpair 更好?

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:对 setmap,两者都是 O(log N),在检查存在性方面功能等价。count 返回 0 或 1(集合无重复),find 返回一个可以直接访问元素的迭代器。需要读取值时用 find,只需是/否检查时用 count

🔗 与后续章节的联系

  • 第 3.4 章(单调栈与单调队列):用于下一个更大元素问题的单调栈;用于滑动窗口最大/最小的单调双端队列
  • 第 3.6 章(栈与队列):深入探讨 stackqueue 的算法应用——括号匹配、BFS
  • 第 3.8 章(映射与集合):map/set 的进阶用法——频率统计、multiset
  • 第 3.3 章(排序):带自定义比较器的 sortvectorpair 一起使用
  • 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,未找到时返回 0
  • while (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 单位的产品 name
  • REMOVE 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_boundupper_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 Structure

Trie(前缀树)通过共享公共前缀存储字符串。单词 "bat"、"car"、"card"、"care"、"cat" 高效共享前缀:"ca" 只存一次,分支到 "r" 和 "t"。双圈节点标记单词结尾。Trie 用于自动补全、拼写检查和字符串匹配。字符串哈希的替代方案参见第 3.7 章(哈希技术)。