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

容器、迭代器 与 pair 和 tuple

从"写一堆数组和计数器"升级到"用标准库提供的现成工具箱"。这一讲带你看懂容器的分类地图、迭代器这个统一接口,以及把多个值捆绑在一起的 pair 与 tuple —— 并搞清那个让无数人崩溃的"迭代器失效"。

6 大核心知识点 4 个完整案例 1 张容器选择地图 3 道实践题 12 道洛谷原题
本讲 · 第 6/52 讲容器、迭代器与 pair 和 tuple
模块 01

情景导航

同一个需求,有人写了六十行还出错,有人二十行就跑通 —— 差别在于"知不知道该用哪个容器"。

故事一个 auto 引发的三小时

小 C 拿到一道题:读入 n 个数,统计每个数出现的次数,输出出现次数最多的那个数(若并列,输出数值最小的)。

他很自然地想到了"值和次数"要成对存放,于是写下:

vector<pair<int,int>> v;   // (数值, 次数)

第一次翻车:给 v 排序时,他手写了一个插入排序去交换相邻元素,代码写成了:

swap(v[i].first, v[j].first);   // 只交换了数值,忘了次数!

结果每个数的"数值"和"次数"完全错位,输出了几个毫无意义的答案。他排查了半个小时。

第二次翻车:他想用更"现代"的写法给某个数的次数加一:

for (auto p : v) if (p.first == x) p.second++;

跑完之后,v 里的次数一个都没变。他以为编译器坏了。小 D 看了一眼说:auto p 是拷贝,你改的是副本。改成 auto& p。又是一个小时。

第三次翻车:他需要删掉次数为 0 的元素:

for (auto it = v.begin(); it != v.end(); ++it)
if (it->second == 0) v.erase(it);

程序随机崩溃。这次小 D 连代码都不用看:"erase 会让后面的迭代器全部失效,你还在用失效的 it 自增,这是标准 UB。"

三小时后,小 D 递给他一份二十行的代码:

map<int,int> cnt;
for (int i = 0; i < n; i++) { int x; scanf("%d", &x); cnt[x]++; }
int best = -1, bestv = 0;
for (auto& [val, c] : cnt) if (c > best) { best = c; bestv = val; }
printf("%d\n", bestv);

小 C 盯着这五行看了很久。他意识到自己那三小时,不是在"写程序",而是在"重新实现标准库已经有、而且写得更好的东西"。

STL 不是"高级技巧",而是"不用再手写的基础设施"。

💡 这一讲要解决的三类问题

问题一 · 不知道该用哪个容器。数组、动态数组、有序集合、键值映射、队列、栈、优先队列 —— 每种都有明确的适用场景。选错容器,代码会异常啰嗦;选对容器,问题常常自动简化一半。本讲先给你一张"容器选择地图"。

问题二 · 不知道迭代器是什么、为什么到处都要它。sort(v.begin(), v.end())、lower_bound(...)、for (auto& x : v) —— 这些写法背后都是迭代器。它不是"高级语法",而是 STL 所有算法与所有容器之间的唯一通用接口。理解它,你才能看懂标准库函数的签名。

问题三 · 不知道 pair 和 tuple 的价值。小 C 那三小时的很大一部分,花在"手动维护两个平行数组"和"交换时漏字段"上。pair 和 tuple 让多个值在语法上成为一个整体,从此不可能"只交换一半"、也不可能"排序后对不上"。

本讲的隐藏主线:上面三个问题有一个共同答案 —— 把"数据怎么组织"这件事交给标准库,你只负责"解决问题"。这正是从入门走向提高的关键转变。

①生活类比
🗄️

容器 = 不同的收纳工具

书架的格子(vector)按编号取放;字典(map)按词查页;不许重复的展柜(set);排队买饭(queue);叠盘子(stack);急诊分诊(priority_queue)。收纳方式决定了取东西的效率。

→ STL 六大容器
🧭

迭代器 = 指着某个位置的手指

它不保存数据,只标记"在哪个位置"。begin() 是第一个位置,end() 是最后一个位置的后面一格(像书末的"最后一页之后",不是最后一页)。

→ iterator
🔑

pair = 绑在一起的两件东西

就像一张"值 + 次数"的标签,或者地图上的"坐标 (x, y)"。两件东西永远同进同出,移动时不可能只移动一半。

→ std::pair
📦

tuple = 多格快递盒

当需要打包三个、四个、五个值时用它。拆包时可以按格取出(get<0>),也可以整箱拆开分给几个变量(tie / 结构化绑定)。

→ std::tuple
②核心定义

容器(container):用于存储一组元素的类模板。STL 容器分为三大类:序列容器(vector、deque、list、array,元素按插入位置排列)、关联容器(set、map 及其 multi 版本,元素按键的大小自动有序)、容器适配器(stack、queue、priority_queue,在底层容器之上提供受限接口)。

迭代器(iterator):一种"泛化的指针",用来统一地访问容器中的元素。*it 取元素、++it 移到下一个、it->member 访问成员。它是 STL 算法与容器之间的唯一接口:只要容器提供迭代器,任何算法就都能作用于它。

begin / end / rbegin / rend:begin() 指向第一个元素,end() 指向最后一个元素的下一个位置(称为"尾后迭代器",不可解引用)。rbegin()、rend() 是反向版本,rbegin() 指向最后一个元素,++ 是往前移动。用 [begin, end) 这个左闭右开区间表示容器内容,是 STL 最重要的约定。

迭代器失效(iterator invalidation):某些容器操作会导致原有的迭代器不再指向有效位置,此时使用它就是未定义行为。vector 在插入/删除/扩容后、set/map 在删除元素后都可能失效。这是本讲最容易出事的知识点。

pair<T1, T2>:把两个值打包成一个整体,成员名为 first 和 second。它自带按 (first, second) 的字典序比较,因此可以直接放进 sort、set、map 使用,无需自己写比较函数。

tuple<Ts...>:把任意多个值打包成一个整体。用 get<i>(t) 按位置取出,用 tie 或结构化绑定一次性解开。pair 可以看作"只有两个元素的 tuple"。

结构化绑定(structured bindings,C++17):auto& [a, b, c] = t; 一行把 tuple/pair/结构体的成员解到三个变量里。加上 & 才能修改原对象,不加就是拷贝。

auto:让编译器从初始化表达式推导变量类型。它的价值不在于"少打字",而在于避免手写那些又长又容易写错的类型名(比如 map<string, vector<int>>::iterator)。

📌 本讲核心知识地图

板块核心内容对应大纲
容器总览序列 / 关联 / 适配器三大类,选择依据【5】容器
迭代器begin/end、*it、++it、it->、反向迭代器【5】迭代器
算法接口sort / find / lower_bound / reverse 均接迭代器【5】迭代器
range-based for底层展开为迭代器循环;auto& 才能改元素【5】迭代器
迭代器失效vector 扩容 / erase 后失效;正确删除写法【5】迭代器
pairfirst / second、make_pair、自带字典序比较【5】对(pair)
tuplemake_tuple、get<i>、tie、结构化绑定【5】元组(tuple)
③核心口诀
🔑 选容器的四问

要"按下标随机访问" → vector;
要"自动有序 + 去重" → set;要"按键查找" → map;
要"先进先出" → queue;要"后进先出" → stack;
要"每次取最大/最小" → priority_queue;要"头尾都能插删" → deque。

🔑 迭代器三句

begin() 是第一个,end() 是最后一个的后面一格(不能解引用);
区间一律左闭右开 [begin, end);
容器变结构(扩容 / 删除),旧迭代器可能立刻失效。

🔑 pair 与 tuple 的价值

多个值要"绑定移动"就打包:两个用 pair,更多用 tuple;
pair 自带字典序比较,塞进 sort / set 就能用;
拆包用 auto& [a, b] = x; —— 要改就加 &,不加只拿到副本!

模块 02

知识探究

先建立"容器地图",再吃透迭代器这个统一接口,最后落到 pair 与 tuple 两个打包工具。

1STL 容器总览:一张地图选对工具 基础

STL 容器听起来很多,其实按"元素的组织方式"分成三大类。记住分类,比记住函数名重要得多——因为分类决定了"什么操作快、什么操作慢"。

分类容器元素的顺序由谁决定核心特点
序列容器
按插入位置排列
vector你插入的位置动态数组;随机访问 O(1),尾部插入均摊 O(1),中间插入/删除 O(n)
deque你插入的位置双端队列;头尾插入删除都是 O(1),也支持随机访问
list你插入的位置双向链表;任意位置插入删除 O(1)(前提是已有迭代器),但不支持随机访问
array固定不变定长数组的封装;大小编译期确定,零额外开销
关联容器
按键自动有序
set / multiset键的大小(自动排序)存"键"本身;用红黑树实现,插入/删除/查找均 O(log n),set 不允许重复
map / multimap键的大小(自动排序)存"键 → 值"的映射;map 的键唯一,multimap 允许重复键
unordered_*哈希值(看似无序)哈希表实现,平均 O(1) 查找,但最坏可退化到 O(n),且不保证有序
容器适配器
受限接口
stack后进先出(LIFO)只允许访问栈顶;底层默认用 deque
queue先进先出(FIFO)只允许访问队头队尾;底层默认用 deque
priority_queue按优先级(默认最大在顶)底层是堆(默认用 vector);只能访问堆顶,插入/删除 O(log n)

通过"我要做什么操作"来选容器:

1
📊
按下标访问

要 v[i] 随机读取 → vector(首选)或 deque。list 做不到。

2
🔍
动态查找是否存在

查找远多于插入 → set(有序,O(log n))或 unordered_set(哈希,平均 O(1))

3
🗺️
建立映射关系

"名字 → 成绩"这类对应 → map。下标运算符 m[key] 会自动创建不存在的键(小心!)

4
🔢
反复取最小值

每次要"当前最小的" → priority_queue(小根堆),比每次重新 sort 快得多

5
🚶
元素在中间频繁插删

vector 每次 O(n) 太慢 → 用 list,但代价是失去随机访问

6
📏
大小固定且追求速度

大小已知 → 直接用 vector 预留空间(reserve)或原生数组,往往比任何容器都快

竞赛实战中最常用的四个:vector(几乎无处不在)、map/set(自动有序 + 查找)、priority_queue(贪心)、queue/stack(BFS / 单调栈)。list 在竞赛中极少使用——因为它不支持随机访问,而且链表节点分散在内存中,缓存命中率极低,实际常数往往比 vector 还差。
① 明确核心操作
② 估算数据规模
③ 查容器地图
④ 检查有序性需求

🔍 一个容易忽略的细节:复杂度相同,常数不同

set 和 map 是红黑树,每次操作要沿着树走 log n 层,每层都是一次指针跳转(很可能是"缓存未命中"),所以常数相当大。unordered_set 和 unordered_map 是哈希表,平均常数更小,但最坏情况可以被卡到 O(n)——出题人只要构造出大量哈希冲突的数据,你的程序就会从 1s 变成 20s。

竞赛结论:① 能用 vector + 排序解决的,不要用 set——数组的连续内存访问要快 5~10 倍;② 需要卡常时,unordered_map 必须换成手写哈希(加随机化种子)才安全;③ 如果只是"最后统一查一次"而不是"边插入边查",sort + lower_bound 往往比 set 更快。

2迭代器:STL 的通用接口 核心概念

迭代器最反直觉的一点是 end():它指向"最后一个元素的后一格",而不是最后一个元素。理解这个设计,你就理解了整个 STL 的区间约定。

10
20
30
40
—
↑ begin()                                      ↑ end()(尾后位置,不可解引用)
C++迭代器的六种基本操作
vector<int> v = {10, 20, 30, 40};

// ── 1. 获取迭代器 ──
vector<int>::iterator it = v.begin();    // 指向 10
vector<int>::iterator en = v.end();      // 指向 40 的【后一格】,不是 40!
auto it2 = v.begin();                    // 推荐:让编译器推导类型

// ── 2. 解引用:取出元素 ──
cout << *it << endl;                     // 10
*it = 99;                                // 用迭代器【修改】元素(vector 支持)

// ── 3. 移动 ──
++it;                                    // 移到 20
it += 2;                                 // 随机访问迭代器支持跳跃:从 20 跳两格 → 指向 40
it = v.end() - 1;                        // 指向最后一个元素 40

// ── 4. 成员访问(当元素是结构体/pair) ──
vector<pair<int,int>> ps = {{1,2},{3,4}};
auto p = ps.begin();
cout << p->first << endl;                // 相当于 (*p).first

// ── 5. 比较 ──
if (v.begin() != v.end()) cout << "非空" << endl;
if (v.begin() < v.end())  cout << "begin 在前" << endl;   // 仅随机访问迭代器可比较大小

// ── 6. 反向迭代器 ──
for (auto rit = v.rbegin(); rit != v.rend(); ++rit)
    cout << *rit << " ";                 // 40 30 20 99 —— 注意 ++ 是【往前】移动
cout << endl;
cout << *(v.rbegin()) << endl;           // 最后一个元素
运行结果
10
非空
begin 在前
40 30 20 99
40

🔍 为什么 end() 要设计成"尾后一格"?

为了让"空区间"能被自然地表达。如果 end() 指向最后一个元素,那么空容器就没有合法的 end() 可返回(没有最后一个元素)。而用"尾后一格"的设计,空容器的 begin() == end(),判断"是否遍历完"永远只需要一次 it != end(),不需要额外的边界判断。

三个直接推论:

① 遍历的写法固定为 [begin, end)。for (auto it = v.begin(); it != v.end(); ++it) —— 用 != 而不是 <,因为 list 的迭代器不支持比较大小,只有随机访问迭代器才支持。写 != 是通用的,写 < 只对 vector/deque 有效。

② 元素个数 = end() - begin()。这也是左闭右开区间的自然结果。

③ 千万不要解引用 end()。*v.end() 是典型的越界访问,可能读到垃圾值,也可能直接段错误。而且它在"刚好没崩"的时候更危险——你会以为代码是对的。

第四个推论(重要):v.end() - 1 才是最后一个元素。要访问末尾元素,除了 v.back(),也可以写 *(v.end() - 1) 或 *(v.rbegin())。注意 end() - 1 在空容器上是非法的(会越界到 begin 之前)。

C++迭代器是算法与容器的"共同语言"
// 所有 STL 算法都只接迭代器,不接容器本身
vector<int> v = {3, 1, 4, 1, 5, 9, 2, 6};
sort(v.begin(), v.end());                          // 升序
sort(v.rbegin(), v.rend());                        // 降序(用反向迭代器)
sort(v.begin() + 2, v.end());                      // 只排下标 2 之后的部分

reverse(v.begin(), v.end());
int mx = *max_element(v.begin(), v.end());
int cnt = count(v.begin(), v.end(), 1);
auto pos = find(v.begin(), v.end(), 5);            // 返回【迭代器】,不是下标
if (pos != v.end()) cout << "找到,下标 " << (pos - v.begin()) << endl;

// 同一个算法也能作用于数组(指针就是迭代器!)
int a[5] = {5, 3, 1, 4, 2};
sort(a, a + 5);                                    // 指针即迭代器
int mn = *min_element(a, a + 5);
为什么 sort(a, a+n) 能对原生数组排序?因为指针本身就是一种迭代器 —— a 指向首元素,a+n 指向尾后一格,完全符合 [begin, end) 的约定。这就是"统一接口"的威力:算法不关心你给的是 vector 的迭代器还是普通指针,只要能 *、能 ++、能比较就行。
动画:一个迭代器的六步人生(数据取自上方代码,v 已被改成 {99, 20, 30, 40})
1auto it = v.begin(); — it 指向下标 0(值 10)。迭代器只是"位置标记",本身不是元素。
2*it = 99; — 解引用写入,v 变成 {99, 20, 30, 40}。it 没动,改的是它指向的那个元素。
3++it; — it 从下标 0 前进到下标 1,指向 20。
4it += 2; — 随机访问迭代器支持跳跃:从下标 1 跳两格到下标 3,指向 40(list 的迭代器就写不出这句)。
5it = v.end() - 1; — end() 是尾后一格,减 1 才是最后一个元素 40;直接解引用 end() 是越界。
6反向迭代器 for (auto rit = v.rbegin(); rit != v.rend(); ++rit) — 从 40 开始"往前"走:输出 40 30 20 99,与运行结果一致。
六步对应六类操作:获取、解引用写、前进、跳跃、end()-1、反向遍历 —— 全部 STL 迭代操作都逃不出这几招。
3range-based for 的真相:auto 后面那个符号 高频失分

故事里小 C 的第二次翻车,就栽在这个符号上。for (auto x : v) 和 for (auto& x : v) 只差一个 &,行为却完全不同。先看编译器把它展开成什么,一切就清楚了。

C++range-based for 的底层展开
// ── 你写的 ──
for (auto& x : v) { cout << x << endl; }

// ── 编译器展开成(等价写法) ──
{
    auto __b = v.begin();          // 拿到起始迭代器
    auto __e = v.end();            // 拿到尾后迭代器
    for (; __b != __e; ++__b) {
        auto& x = *__b;              // ★ 这里决定了 x 是引用还是副本
        cout << x << endl;
    }
}

看清了 auto& x = *__b; 这一行,就知道问题出在哪:没有 & 时,这句是"拷贝一份元素给 x",你之后怎么改 x,容器里的元素都无动于衷。

写法对应展开是否拷贝能否修改容器什么时候用
for (auto x : v)auto x = *it;拷贝不能只读、且元素很小(int、char、double)
for (auto& x : v)auto& x = *it;不拷贝能需要修改元素
for (const auto& x : v)const auto& x = *it;不拷贝不能最推荐:只读大对象(string、结构体、容器)
for (auto&& x : v)auto&& x = *it;不拷贝能通用引用,可绑定临时对象或返回右值的容器

❌ 改了副本,容器毫无变化

vector<int> v = {1, 2, 3};
for (auto x : v) x *= 10;
// 结果:v 仍然是 {1, 2, 3}

vector<string> vs = {"aa", "bb"};
for (auto s : vs) s += "!";
// 结果:vs 仍然是 {"aa", "bb"}
// 而且每次循环都拷贝了一个 string —— 既白改又慢

✅ 一个 & 搞定

vector<int> v = {1, 2, 3};
for (auto& x : v) x *= 10;
// 结果:v = {10, 20, 30}

// 只读时用 const auto& 最稳:
for (const auto& s : vs) cout << s << endl;
// 零拷贝,且编译器保证你不会误改

🔍 一个容易被忽视的坑:遍历时修改容器

即使你写了正确的 auto&,在 range-based for 里修改容器的结构(增删元素)依然是危险的:

// ❌ 危险:遍历中用 auto& 顺便 push_back
for (auto& x : v) {
    if (x == 0) v.push_back(1);    // 展开后的 __e 是【一开始就记住的】end()
}
// 问题一:push_back 可能触发扩容 → __b 与 __e 全部失效 → UB
// 问题二:即使不扩容,__e 也早已过期,新加的元素不会被遍历到

为什么 __e 会过期?因为展开后的代码是 auto __e = v.end(); —— 它在循环开始前就被"取下来存好"了。插入新元素后,v.end() 已经变了,但 __e 还是老的那个。这就解释了"为什么有时候新元素没被遍历、有时候直接崩溃"。

正确做法:遍历时如需增删,改用显式的迭代器循环(见知识点 4),或者先记录要删的元素,循环结束后统一处理。

一条通用原则:range-based for 适合"只读遍历"或"只改元素值";一旦要改变容器的结构(长度),就必须换写法。把这条记住,能避免大量"随机崩溃"的调试时间。

动画:同一个循环,& 有无的两种命运(v = {1, 2, 3})
1你写下 for (auto x : v) x *= 10; — 想把每个元素放大 10 倍。
2编译器展开:auto __b = v.begin(); auto __e = v.end(); — __e 在循环开始前就取好存死。
3每轮执行 auto x = *__b; — 没有 &,x 是元素的副本,拷贝走的是"复印件"。
4x *= 10 改的是复印件;一轮结束副本销毁。循环跑完 v 仍是 {1, 2, 3} —— 程序不报错,但什么都没发生。
5改成 for (auto& x : v):展开成 auto& x = *__b; — x 是元素的别名,正本本身。
6x *= 10 直接写进容器,循环结束 v = {10, 20, 30}。只读时改用 const auto&:同样零拷贝,还能防误改。
一句话:auto x 拷贝、auto& x 正本、const auto& x 只读正本 —— 差一个符号,差一个世界。
4迭代器失效:本讲最危险的知识点 必须掌握

小 C 的第三次翻车就是它。迭代器失效不会报编译错误,也不会每次都崩,所以它是最典型的"随机 WA / 随机 RE"来源。先把最常出事的 vector 讲透。

1
📈
push_back 触发扩容

vector 空间不够时会申请一块更大的新内存,把元素搬过去,再释放旧内存。此时所有迭代器、指针、引用全部作废——它们还指向那块已经被释放的内存。

2
✂️
erase 删除元素

删除会让被删位置及其之后的所有元素前移一格。it 本身和它之后的迭代器都失效。

3
📥
insert 插入元素

插入会让插入位置及其之后的迭代器失效。若同时触发扩容,则所有迭代器失效。

4
🎯
reserve 之后仍要小心

reserve 本身也可能重新分配内存。提前 reserve 可以避免后续 push_back 扩容,但 reserve 那一次调用自己就会让旧迭代器失效。

用动画看清"扩容"那一刻发生了什么:

1
2
3
4
—
—
新
区

🔍 扩容到底发生了什么?为什么迭代器会失效?

假设 vector<int> v 当前容量 4、已装 4 个元素,此时 v.push_back(5):

第 1 步:申请一块更大的新内存(典型实现是容量的 2 倍,即 8)。
第 2 步:把原来 4 个元素逐个拷贝(或移动)到新内存。
第 3 步:释放旧内存。
第 4 步:把新元素放进新内存的第 5 个位置。

关键就在第 3 步:旧内存被释放了,而你手里的迭代器 it 仍然保存着旧内存的地址。此时 *it 读的是已经还给系统的那块内存 —— 这就是"使用已释放内存",行为完全不确定。

为什么有时看起来"没事"?因为那块内存可能还没被别的程序占用,内容还是旧值。这种"碰巧能跑"恰恰是最可怕的 —— 它会让你误以为代码是对的。

怎么预防?

① 提前 reserve。如果已知最多要放 n 个元素,先 v.reserve(n);,之后 push_back 就不会再扩容,迭代器也不会失效。这是竞赛中非常常用的优化手段,既提速又避险。

② 不要在循环里保存迭代器。如果需要写 for (auto it = v.begin(); it != v.end(); ++it) v.push_back(...),先停下来想想能不能一次构造完。

③ 保持"用后即弃"。迭代器不要跨操作保存。需要位置就存下标,用的时候重新 v.begin() + i。下标不会随扩容失效(虽然扩容后 v[i] 仍然有效,因为它是通过新地址算的)。

④ 记住不同容器的差异:

容器插入元素删除元素是否需重新分配
vector可能全部失效(扩容时)被删位置及之后失效是
deque头尾插入:迭代器失效但引用/指针仍有效中间删除:全部失效部分
list不影响其他元素只使被删元素的失效否
set / map不影响其他元素只使被删元素的失效否

结论:vector 是唯一"插删会波及一片"的常用容器。因为它用一整块连续内存存元素(这正是它能 O(1) 随机访问的原因),一旦需要变长变短,就必然牵动整块内存。这也解释了那句著名的话:"vector 的快,是用迭代器的脆弱换来的。"

❌ 三种会崩的删除写法

// 错误 1:erase 后继续 ++it
for (auto it = v.begin(); it != v.end(); ++it)
    if (*it == 0) v.erase(it);
// it 已失效,++it 是 UB

// 错误 2:erase 后比较 it != v.end()
// 同上,失效的 it 与 end() 比较无意义

// 错误 3:保存了 begin() 用于后续
auto b = v.begin();
v.push_back(1);            // 可能扩容 → b 失效
cout << *b << endl;          // UB

✅ 两种正确写法

// 正确 1:用 erase 的【返回值】继续(C++11 起)
for (auto it = v.begin(); it != v.end(); ) {
    if (*it == 0) it = v.erase(it);   // 返回下一个有效位置
    else ++it;                        // 注意:++ 放在 else 里
}

// 正确 2:erase-remove 惯用法(推荐,一行搞定)
v.erase(remove(v.begin(), v.end(), 0), v.end());

// 按条件删除:
v.erase(remove_if(v.begin(), v.end(),
        [](int x) { return x % 2 == 0; }), v.end());

💡 为什么 it = v.erase(it) 是对的?

因为 C++11 起,vector::erase 会返回"被删元素的下一个位置"的有效迭代器。所以 用返回值覆盖掉已经失效的 it,就得到了继续循环所需的合法迭代器。

关键细节:++it 必须放在 else 分支里。如果写成:

// ❌ 错:无论删不删都 ++it
for (auto it = v.begin(); it != v.end(); ++it)
    if (*it == 0) it = v.erase(it);
// erase 已经让你指向了下一个元素,再 ++it 就【跳过】了一个元素
// 症状:连续两个要删的元素,只删掉了一个

这个"跳过元素"的 bug 比崩溃更隐蔽:程序不崩,只是漏删了。所以正确写法里必须把 ++it 挪进 else,或者干脆用 erase-remove 惯用法彻底避开手动循环。

关于 remove 的一个反直觉点:remove 不会真的删除元素,它只是把"不需要删的"往前搬,返回"新的逻辑结尾",容器长度根本没变。必须再调用 v.erase(...) 把尾巴切掉,这就是为什么要写成 v.erase(remove(...), v.end()) 这个"两段式"。记住这个名字:erase-remove 惯用法(erase-remove idiom)。

5pair:两个值的打包,自带排序能力 极常用

故事里小 C 花了半小时排查的"数值和次数错位",用 pair 根本不可能发生:两件东西在语法上就是一个整体,不存在"只交换一半"。更重要的是,pair 自带按字典序的比较,这让它成为竞赛中最省事的排序工具之一。

C++pair 的完整用法
// ── 1. 定义与构造 ──
pair<int, int> p1;                      // 默认构造:(0, 0)
pair<int, int> p2(3, 4);                // 直接构造
pair<int, int> p3 = {3, 4};            // 列表初始化(最常用)
auto p4 = make_pair(3, 4);              // 类型自动推导
auto p5 = make_pair(3, "abc");          // 可以混类型

// ── 2. 访问成员:first 和 second ──
cout << p3.first << " " << p3.second << endl;   // 3 4
p3.first = 100;                              // 可修改
p3.second++;

// ── 3. 自带比较:先比 first,相同再比 second ──
cout << (make_pair(1, 9) < make_pair(2, 0)) << endl;  // 1(true)
cout << (make_pair(1, 9) < make_pair(1, 10)) << endl; // 1(first 相同,比 second)
cout << (make_pair(2, 0) == make_pair(2, 0)) << endl; // 1

// ── 4. 直接排序:不需要写任何比较函数 ──
vector<pair<int, int>> v = {{3,1}, {1,5}, {3,0}, {1,2}};
sort(v.begin(), v.end());
// 结果:(1,2) (1,5) (3,0) (3,1) —— 先按 first 升序,再按 second 升序

// ── 5. 放进 set / map 也没问题 ──
set<pair<int,int>> s(v.begin(), v.end());       // 自动排序 + 去重
map<pair<int,int>, int> mp;                  // 用点对作键

// ── 6. 解包:两种方式 ──
auto& [a, b] = v[0];                          // C++17 结构化绑定(推荐)
int x, y;
tie(x, y) = v[0];                              // C++11 起可用

// ── 7. 嵌套:当成"复合关键字"用 ──
pair<int, pair<int, int>> np = {1, {2, 3}};
cout << np.first << " " << np.second.first << " " << np.second.second << endl;
运行结果
3 4
1
1
1
1 2 3
动画:pair 为什么"自带排序"(数据取自上方第 4 段代码)
1v = {{3,1}, {1,5}, {3,0}, {1,2}},直接 sort(v.begin(), v.end()),一个比较函数都不写。
2pair 的 < 是字典序:先比 first,first 相同再比 second —— 这正好就是"多关键字排序"。
3{3,1} 与 {3,0}:first 都是 3,比 second:0 < 1 → {3,0} 在前。
4{1,5} 与 {1,2}:first 都是 1,比 second:2 < 5 → {1,2} 在前。
5排完:{1,2} {1,5} {3,0} {3,1} —— 与注释里的结果完全一致,等价于手写"先比 x 再比 y"的 cmp。
6把 v 塞进 set<pair<int,int>> 走的是同一套规则:自动有序,重复元素自动去重。
需要"降序 + 升序混排"时,把要降序的字段取负即可(见下方分析框与案例 2)。

🔍 pair 的比较规则:为什么它这么有用?

pair 的 operator< 是字典序(lexicographic)的,等价于:

if (a.first != b.first) return a.first < b.first;
return a.second < b.second;

这恰好就是"多关键字排序"的标准写法!所以只要把排序关键字按优先级装进 pair,就再也不需要手写比较函数了。

举例:要让"总分高的在前,总分相同时姓名字典序小的在前"——注意降序和升序混用了,不能直接塞进 pair 升序。这时有个经典技巧:把要降序的字段取负:

// 需求:总分【降序】,同分时姓名【升序】
// 技巧:存 (-总分, 姓名),升序排完就等价于总分降序
vector<pair<int, string>> v;      // first = -tot
for (...) v.push_back({-tot, name});
sort(v.begin(), v.end());            // 不需要比较函数!
for (auto& [ntot, name] : v) cout << name << " " << -ntot << endl;

取负技巧的适用范围:适用于整数和浮点数字段。如果字段是 string 或自定义类型,就不能取负,那就只能老老实实写比较函数(或者用 tuple + lambda)。

三个使用提醒:

① map 的键用 pair 完全合法,因为 pair 有 operator<。这是"用两个整数当键"的常见做法,比把两个数编码成一个 long long 更直观、也不会溢出。

② make_pair 会做类型推导,make_pair(3, 4) 的类型是 pair<int,int>,而 make_pair(3, "abc") 是 pair<int, const char*>(不是 string!)。想得到 string 就要显式写 make_pair(3, string("abc")) 或用 {3, "abc"} 配合明确的类型声明。

③ pair 只有两个元素。三个及以上就要用 tuple,或者嵌套 pair<a, pair<b, c>>。但嵌套的写法可读性很差(p.second.first),三个以上建议直接用 tuple。

6tuple:任意多个值的打包 C++17 利器

pair 只有两个格子。tuple 想装几个就装几个,而且同样自带字典序比较。它在两种场景下特别好用:① 需要三个以上关键字排序;② 一个函数要返回多个值。

C++tuple 的完整用法
// ── 1. 定义与构造 ──
tuple<int, int, string> t1;                     // (0, 0, "")
tuple<int, int, string> t2(1, 2, "abc");
auto t3 = make_tuple(1, 2, "abc");            // 类型自动推导
auto t4 = tie(1, 2, "abc");                 // 注意:tie 得到的是【引用元组】

// ── 2. 按位置取值:get<下标> ──
cout << get<0>(t2) << " " << get<1>(t2) << " " << get<2>(t2) << endl;
get<0>(t2) = 100;                            // 可以修改

// ── 3. 元素个数:用编译期常量 ──
cout << tuple_size<decltype(t2)>::value << endl;      // 3

// ── 4. 自带字典序比较 ──
cout << (make_tuple(1, 2, 3) < make_tuple(1, 2, 4)) << endl;   // 1
cout << (make_tuple(1, 3, 0) < make_tuple(1, 2, 9)) << endl;   // 0(第二个位置 3 > 2)

// ── 5. 三关键字排序:一行 sort 搞定 ──
vector<tuple<int,int,int>> v = {{2,1,5}, {1,3,2}, {2,1,0}};
sort(v.begin(), v.end());
// 结果:(1,3,2) (2,1,0) (2,1,5)
// 先按第 0 位升序,相同按第 1 位,再相同按第 2 位

// ── 6. 解包:tie 与结构化绑定 ──
int a, b; string c;
tie(a, b, c) = t2;                        // 全部解出
tie(a, ignore, c) = t2;                   // 用 ignore 跳过不想取的

for (auto& [x, y, z] : v)                  // C++17 结构化绑定(最推荐)
    cout << x << "," << y << "," << z << "  ";
cout << endl;
运行结果
1 2 abc
3
1
0
1,3,2  2,1,0  2,1,5  
① 打包 make_tuple
② 按位置取 get<i>
③ 解包 tie / ignore
④ 结构化绑定(最简洁)

🔍 tuple 的三个关键细节

① get<i> 的下标必须是编译期常量。get<0>(t) 合法,但 int i = 0; get<i>(t) 会编译错误。原因:tuple 每个位置的类型可能不同,编译器必须在编译期就确定 get<i> 返回的是 int 还是 string。这也意味着不能用 for 循环遍历 tuple——需要遍历就应该用 vector 或结构体数组。

② tie 与 make_tuple 有本质区别。make_tuple(1, 2, "abc") 创建一个新元组(存的是拷贝);tie(a, b, c) 创建的是三个变量的引用组成的元组。所以 tie(a,b,c) = t2; 是"把 t2 的三个值分别赋给 a、b、c"。理解这一点,才能看懂 tie 为什么能用来接收返回值。

③ 结构化绑定加不加 & 差别巨大。auto [x, y, z] = v[i] 是拷贝(改 x 不影响容器);auto& [x, y, z] = v[i] 是引用(改 x 就改了容器)。这与知识点 3 里 range-based for 的结论完全一致 —— 要改就加 &,只读就加 const auto&。故事里小 C 的第二次翻车,本质就是忘了这个 &。

关于性能:tuple 是纯粹的编译期展开,没有任何运行时开销——它和"手写一个结构体"编译出来的代码几乎一样。所以用 tuple 不会比写结构体慢,但可读性上要权衡:get<2>(t) 显然不如 t.score 清楚。字段有明确含义时用结构体,字段只是"临时的排序键"时才用 tuple。

✅ pair / tuple 适合用的场景

• 临时的排序关键字组合("按 (x, y) 排序")
• 用作 map 的复合键
• 函数要返回两个/多个值(如 {最小, 最大})
• 存储坐标、区间、边({u, v, w})
• 局部作用域内的临时打包,用完即弃

⚠️ 更适合写结构体 / 类的场景

• 字段有明确业务含义(Stu.name 比 get<0> 清楚得多)
• 需要成员函数(比如 dist()、isValid())
• 需要重载特定语义的运算符
• 字段数量多(5 个以上),get<3> 会完全失去可读性
• 需要在多处复用这套数据结构

案案例 1:pair 存坐标排序——不写比较函数的排序 入门范本

先看一个对比:同一个需求,写结构体要写比较函数,用 pair 一行都不用写。这个差异在赶时间的考场上非常实际。

C++两种写法对比
// ── 写法 A:自定义结构体,必须写比较函数 ──
struct Point {
    int x, y;
};
bool cmp(const Point& a, const Point& b) {
    if (a.x != b.x) return a.x < b.x;
    return a.y < b.y;
}
vector<Point> v(n);
sort(v.begin(), v.end(), cmp);        // ★ 别忘了第三个参数

// ── 写法 B:用 pair,什么都不用写 ──
vector<pair<int, int>> v2(n);
sort(v2.begin(), v2.end());           // ★ pair 自带字典序比较
C++案例 1 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    scanf("%d", &n);

    vector<pair<int, int>> v(n);
    for (auto& p : v) {                     // ★ 必须加 & 才能写进去
        scanf("%d %d", &p.first, &p.second);
    }

    sort(v.begin(), v.end());               // 按 (x, y) 字典序升序

    for (auto& [x, y] : v) {                // 结构化绑定拆成两个变量
        printf("%d %d\n", x, y);
    }

    // 附带演示:改成按 y 优先排序
    sort(v.begin(), v.end(), [](const pair<int,int>& a, const pair<int,int>& b) {
        if (a.second != b.second) return a.second < b.second;
        return a.first < b.first;
    });
    printf("--- 按 y 优先 ---\n");
    for (const auto& p : v) printf("%d %d\n", p.first, p.second);
    return 0;
}
运行结果(输入见下)
5
3 4
1 9
3 -2
1 2
0 7
0 7
1 2
1 9
3 -2
3 4
--- 按 y 优先 ---
3 -2
1 2
3 4
0 7
1 9
动画:案例 1 两轮排序手算全过程(数据取自输入样例)
1读入 5 个点:(3,4)、(1,9)、(3,−2)、(1,2)、(0,7)。
2第一轮(默认字典序,先比 x):x 最小的是 0 → (0,7) 排最前。
3x=1 有两个:比 second,2 < 9 → (1,2) 在 (1,9) 前。
4x=3 有两个:比 second,−2 < 4 → (3,−2) 在 (3,4) 前。第一轮输出:(0,7) (1,2) (1,9) (3,−2) (3,4)。
5第二轮(lambda 按 y 优先):y 从小到大依次是 −2, 2, 4, 7, 9。
6y 相同不存在于本例;若相同则再比 first(lambda 里的 a.first < b.first 就是干这个的)。
7第二轮输出:(3,−2) (1,2) (3,4) (0,7) (1,9) —— 与运行结果逐行一致,两次排序都没写错。
同一个数据排两次:默认规则白给、自定义规则写 lambda —— 这就是"pair 够用就省事,不够再写"的分界线。

🔍 逐段解析

① for (auto& p : v) 里的 & 是必需的。如果写成 for (auto p : v),p 是每个元素的副本,scanf 写进去的是副本,v 里全是 0。这正是故事里小 C 的第二次翻车 —— 只差一个 &,程序却完全错误,而且不会报任何错。

② &p.first 为什么合法?因为 p 是引用,p.first 就是 v[i].first 这个真实的 int 变量,取地址得到合法指针。如果 p 是副本,&p.first 也能编过(指向副本),但写进去的东西立刻销毁 —— 又是那个 & 的问题。

③ sort(v.begin(), v.end()) 为什么不用比较器?因为 pair 已经定义了 operator<,规则是"先比 first,相同再比 second"。这正是本题要的"按 (x, y) 字典序"。

④ 结构化绑定的两种写法。auto& [x, y] 是引用解包(改了 x 就改了容器),const auto& [x, y] 是只读解包。只输出不改的时候用 const auto&,既零拷贝又防止误改。

⑤ 后半段为什么又要写 lambda?因为"按 y 优先"不是 pair 的默认规则(默认先比 first)。这说明"默认规则够用时省事,不够用时仍要自己写" —— pair 不是万能,只是覆盖了最常见的情况。如果这个"按 y 优先"的规则要复用多次,就应该换成结构体 + operator<(上一讲内容)。

⑥ 输入用 scanf("%d %d", &p.first, &p.second) 的细节。pair 的两个成员是连续的 int,这样直接取地址完全安全。但要注意 p 必须是引用。

案案例 2:多关键字排序——取负技巧与 tuple 高频实用

真实题目里"降序升序混用"非常常见。如果每次都手写比较函数,既啰嗦又容易写错。用 tuple + 取负技巧,可以一行 sort 解决。

C++案例 2 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    scanf("%d", &n);

    // 排序键:(负总分, 负语文, 姓名)
    // tuple 升序排列 ⇒ 总分降序、语文降序、姓名升序
    vector<tuple<int, int, string>> v;
    v.reserve(n);                                    // ★ 避免扩容

    for (int i = 0; i < n; i++) {
        char name[32];
        int ch, ma;
        scanf("%s %d %d", name, &ch, &ma);
        v.push_back(make_tuple(-(ch + ma), -ch, string(name)));
    }

    sort(v.begin(), v.end());                        // 不需要比较函数!

    for (const auto& [negTot, negCh, nm] : v) {
        printf("%s %d\n", nm.c_str(), -negTot);      // 输出时把负号还原
    }
    return 0;
}
运行结果(输入见下)
5
alice 90 85
bob 78 92
cathy 90 85
dave 60 60
eve 95 80
eve 175
alice 175
cathy 175
bob 170
dave 120
动画:取负技巧手算全过程(数据取自输入样例)
1先算总分:alice 90+85=175、bob 78+92=170、cathy 175、dave 120、eve 175。
2存键 (−总分, −数学, 姓名):alice (−175,−90)、bob (−170,−78)、cathy (−175,−90)、dave (−120,−60)、eve (−175,−95)。
3升序排第一分量:−175 最小 → 三个 175 分的人排最前 —— 总分降序达成(数越大、负数越小、排得越前)。
4−175 组内比第二分量:eve 的 −95 最小(数学 95 最高)→ eve 第一。
5alice 与 cathy 前两个分量完全相同 (−175,−90) → 轮到姓名:alice < cathy。
6之后依次是 −170(bob,170 分)、−120(dave,120 分)。
7最终顺序 eve → alice → cathy → bob → dave,与运行结果一致;输出时用 -negTot 把负号还原成总分。
一行 sort 完成三个关键字的混合排序 —— 代价只是"存负、还原"两个小动作。

🔍 取负技巧的原理与边界

原理:升序排列 -a 等价于降序排列 a。因为 a 越大,-a 越小,在升序里就排得越前。把"要降序的字段取负",就能让整个 tuple 用统一的升序规则排列。

为什么这个技巧值得记?因为如果没有它,你就必须手写比较函数:

// 不用取负技巧时,只能这样写:
sort(v.begin(), v.end(), [](const auto& a, const auto& b) {
    if (get<0>(a) != get<0>(b)) return get<0>(a) > get<0>(b);   // 降序用 >
    if (get<1>(a) != get<1>(b)) return get<1>(a) > get<1>(b);
    return get<2>(a) < get<2>(b);                                // 升序用 <
});

对比一下:取负技巧让代码从 6 行变成 1 行,而且顺序与优先级一目了然(tuple 里从左到右就是关键字优先级)。在考场上少写 5 行容易写错的代码,是实打实的收益。

三个必须注意的边界:

① 只能对数值类型取负。int、long long、double 都可以,但 string、pair、自定义类型不行。本例中姓名是升序,正好不需要取负;如果姓名也要降序,就不能用这个技巧了。

② 注意溢出。如果字段可能取到 INT_MIN,那 -x 会溢出(-INT_MIN 超出 int 范围)。竞赛中通常不会这么极端,但把字段类型写成 long long 是安全的做法。

③ 输出时要记得还原。存进去的是 -tot,输出就要写 -negTot。这是取负技巧唯一"别扭"的地方 —— 建议在变量名里带上 neg 前缀(如本例),避免忘记。

④ v.reserve(n) 为什么放在这里?既避免多次扩容带来的拷贝开销(提速),也避免"循环中扩容导致迭代器失效"的隐患(避险)。这是竞赛中的标准好习惯:已知元素个数就先 reserve。

⑤ 为什么用 string(name) 而不是直接 name?因为 name 是 char[32],make_tuple(-tot, -ch, name) 推导出的类型是 tuple<int,int,char*> —— 存的是一个指向局部数组的指针!循环下一轮 name 被覆盖,所有指针都指向同一块内存,最终结果全是最后一个名字。这是极其隐蔽的 bug,必须显式构造 string 才能真正拷贝。

案案例 3:tuple 返回多个值 接口设计

tuple 最经典的用途是让函数返回多个值。对比"用引用参数传出去"的老写法,tuple 的调用端更清楚。

C++两种"返回多值"的写法对比
// ── 写法 A:引用参数(老写法) ──
void minMaxPos(const vector<int>& a, int& mn, int& mx, int& pos) {
    mn = mx = a[0]; pos = 0;
    for (int i = 1; i < (int)a.size(); i++) {
        if (a[i] < mn) { mn = a[i]; pos = i; }
        if (a[i] > mx) mx = a[i];
    }
}
// 调用:
int mn, mx, pos;
minMaxPos(a, mn, mx, pos);         // 三个"空壳变量"必须先声明好

// ── 写法 B:返回 tuple(推荐) ──
tuple<int, int, int> minMaxPos(const vector<int>& a) {
    int mn = a[0], mx = a[0], pos = 0;
    for (int i = 1; i < (int)a.size(); i++) {
        if (a[i] < mn) { mn = a[i]; pos = i; }
        if (a[i] > mx) mx = a[i];
    }
    return make_tuple(mn, mx, pos);          // ★ 一次打包返回
}
// 调用:
auto [mn, mx, pos] = minMaxPos(a);          // ★ 一行解出三个值
C++案例 3 完整代码
#include <bits/stdc++.h>
using namespace std;

tuple<int, int, int> minMaxPos(const vector<int>& a) {
    int mn = a[0], mx = a[0], pos = 0;
    for (int i = 1; i < (int)a.size(); i++) {
        if (a[i] < mn) { mn = a[i]; pos = i; }   // 严格小于 → 保留最靠前的下标
        if (a[i] > mx) mx = a[i];
    }
    return make_tuple(mn, mx, pos);
}

// 顺带演示:返回"最大公约数 + 最小公倍数"
pair<long long, long long> gcdLcm(long long a, long long b) {
    long long g = __gcd(a, b);
    return {g, a / g * b};                       // ★ 先除后乘,避免溢出
}

int main() {
    int n;
    scanf("%d", &n);
    vector<int> a(n);
    for (int& x : a) scanf("%d", &x);

    auto [mn, mx, pos] = minMaxPos(a);
    printf("min=%d (first at index %d), max=%d\n", mn, pos, mx);

    auto [g, l] = gcdLcm(12, 18);
    printf("gcd=%lld lcm=%lld\n", g, l);
    return 0;
}
运行结果(输入见下)
8
5 3 9 1 3 7 1 8
min=1 (first at index 3), max=9
gcd=6 lcm=36
动画:minMaxPos 逐元素手算(a = {5, 3, 9, 1, 3, 7, 1, 8})
1初始:mn = mx = a[0] = 5,pos = 0。
2i=1:a[1]=3 < 5 → mn=3,pos=1;3 不大于 mx。
3i=2:a[2]=9 > 5 → mx=9;9 不小于 mn,pos 不动。
4i=3:a[3]=1 < 3 → mn=1,pos=3。
5i=4、i=5:a=3、7 既不更小也不更大 → 全部跳过。
6i=6:a[6]=1,条件是严格小于:1 < 1 不成立 → pos 保留 3(第一次出现的位置;写成 <= 就会变成 6)。
7i=7:a[7]=8 不大于 9 → 跳过。
8返回 make_tuple(1, 9, 3),结构化绑定解出 → min=1(首次出现在下标 3)、max=9,与运行结果一致。
注意第 6 步:一个 < 和 <= 的差别,决定了"第一次出现"还是"最后一次出现"。

🔍 为什么返回 tuple 比引用参数好?

① 调用端不会"忘记声明变量"。写法 A 要求你提前准备 mn、mx、pos 三个变量,顺序还必须与函数参数一致。如果函数有 5 个输出参数,很容易传错顺序 —— 而且这种错误编译器完全无法发现(都是 int&,类型一样)。返回 tuple 用结构化绑定,绑定顺序与被赋值的变量一一对应,不会被"参数位置"坑到。

② 语义更直接。auto [mn, mx, pos] = minMaxPos(a); 读起来就是"求最小、最大、位置"。而写法 A 里函数名和三个变量分散在声明与调用两处,理解成本更高。

③ 可以和其它表达式组合。返回 tuple 的函数结果可以直接参与运算或继续传递,而引用参数版本必须先声明变量、再调用、再用。

两个实现细节值得注意:

① if (a[i] < mn) 用严格小于,是为了取"第一次出现"的下标。如果写成 <=,那么遇到相同的更小值时会更新 pos,得到的就是"最后一次出现"的位置。这种"边界条件决定语义"的细节,在题目要求"输出第一次出现的位置"时至关重要。

② a / g * b 的顺序不能颠倒。最小公倍数公式是 a * b / g,但如果先算 a * b,a、b 都接近 109 时乘积达到 1018 已经贴着 long long 上限,更大会直接溢出。先除再乘(a/g*b)因为 g 一定是 a 的约数,除法是整除,结果完全正确且不会溢出。这是数论题的一条标准习惯。

关于性能:tuple 的打包与解包都是编译期展开的,编译器会把它优化成"三个独立的局部变量"。所以下面的写法和写法 A 编译出的机器码几乎一样 —— 用 tuple 不会有任何速度损失。

什么时候该用结构体而不是 tuple?如果这三个值有明确的业务含义(比如 {姓名, 总分, 排名}),写成结构体更清楚:r.name 比 get<0>(r) 好读太多。一旦函数有 5 个以上的返回值,get<3> 这种写法基本就失去意义了。判断标准:返回值只是"临时的数值组合"就用 tuple;一旦它有"名字",就给它一个结构体。
案案例 4:迭代器失效陷阱——三个真实崩溃现场 必须吃透

这个故事不是编的,是每年都有人栽的地方。先亲自看到"容量成倍增长"和"内存被搬走"这两个现象,你才会真正记住它。

C++亲眼看见扩容会发生什么
#include <bits/stdc++.h>
using namespace std;

int main() {
    // ── 现象 1:容量会成倍增长 ──
    vector<int> v;
    size_t last = 0;
    for (int i = 0; i < 20; i++) {
        v.push_back(i);
        if (v.capacity() != last) {
            printf("size=%2zu 时扩容 → capacity=%zu\n", v.size(), v.capacity());
            last = v.capacity();
        }
    }

    // ── 现象 2:扩容让首元素的地址改变(内存被搬走了) ──
    vector<int> w = {1, 2, 3, 4};
    const int* oldAddr = &w[0];              // 记下旧地址
    w.reserve(1000);                         // 强制重新分配内存
    printf("\n首元素地址是否改变:%s\n",
           (oldAddr == &w[0]) ? "没有" : "改变了(说明数据被搬到了新内存)");

    // ── 所以:保存下来的迭代器/指针会指向【已释放的内存】 ──
    printf("w[0] = %d  ←  用【下标】访问永远安全\n", w[0]);
    printf("size = %zu,capacity = %zu\n", w.size(), w.capacity());
    return 0;
}
运行结果
size= 1 时扩容 → capacity=1
size= 2 时扩容 → capacity=2
size= 3 时扩容 → capacity=4
size= 5 时扩容 → capacity=8
size= 9 时扩容 → capacity=16
size=17 时扩容 → capacity=32

首元素地址是否改变:改变了(说明数据被搬到了新内存)
w[0] = 1  ←  用【下标】访问永远安全
size = 4,capacity = 1000
动画:vector 扩容全程(数据取自上方运行结果)
1空 vector v:capacity = 0,一块内存都没分。
2i=0 push_back 后 size=1,capacity 0→1:第一次扩容,打印 size=1 → capacity=1。
3i=1 时 size=2 装不下 → 扩容 1→2,整块搬到新内存。
4i=2 时 size=3 → 扩容 2→4;size=4 之内不用再搬。
5继续:size=5 时 → 8;size=9 时 → 16;size=17 时 → 32。每次翻倍,20 次 push_back 只搬 6 次家。
6w = {1,2,3,4},记下 &w[0];w.reserve(1000) 强制重分配 → 首元素地址改变,数据被搬到新内存。
7所以指向旧内存的迭代器/指针全部作废;而 w[0] 用下标访问永远安全(下标 = 当前首地址 + 偏移,每次都现算)。注意 reserve 后 size 仍是 4,capacity 才是 1000。
扩容序列 1→2→4→8→16→32 是 GCC 的翻倍规律;"成倍增长"正是均摊 O(1) push_back 的来源,也是迭代器失效的根源。

💥 三个崩溃现场,逐一解剖

现场 1 · 保存了迭代器,之后 push_back。

auto it = v.begin();
for (int i = 0; i < 100; i++) v.push_back(i);   // 期间多次扩容
cout << *it << endl;                     // ❌ UB!it 指向已被释放的内存

为什么危险:那块旧内存可能还没被别的程序覆盖,所以 *it 有时能打印出正确的旧值。这种"碰巧能跑"会让你完全意识不到代码有问题 —— 直到某次评测机上内存布局不同,就变成了随机 RE。

修复:需要位置就存下标。int idx = 0; 之后用 v[idx],下标是通过"容器的当前起始地址 + 偏移量"算出来的,所以扩容后依然有效。

现场 2 · erase 之后继续用 it。

for (auto it = v.begin(); it != v.end(); ++it)
    if (*it == 0) v.erase(it);          // ❌ 之后 ++it 是 UB

为什么危险:erase 之后,it 以及它之后的所有迭代器全部失效(因为后面元素都往前挪了一格)。++it 就是在已失效的位置上做自增。症状通常是崩溃,但偶尔也会"跳过"某些元素 —— 后者更隐蔽。

修复:用 erase 的返回值。

for (auto it = v.begin(); it != v.end(); ) {      // ★ 这里不写 ++it
    if (*it == 0) it = v.erase(it);              // 返回下一个有效位置
    else ++it;                                    // ★ ++ 放进 else 分支
}

关键:++it 必须放在 else 里。因为 erase 已经帮你指向了下一个元素,如果外面再统一 ++it,就会跳过一个元素。症状是"连续两个要删的元素只删掉一个",程序不崩但答案错 —— 比崩溃更难查。

现场 3 · range-based for 里增删元素。

for (auto& x : v)
    if (x == 0) v.erase(v.begin());      // ❌ 两层问题

为什么危险:回想知识点 3 的展开代码 —— auto __e = v.end(); 是循环开始前就取下来存好的。一旦删除了元素,v.end() 已经变了,但 __e 还是老的那个。于是循环的终止条件本身就是错的(可能多走几轮,也可能越界)。

修复:改用显式迭代器循环(现场 2 的写法),或者用 erase-remove 惯用法一次搞定:

v.erase(remove(v.begin(), v.end(), 0), v.end());        // 删所有等于 0 的
v.erase(remove_if(v.begin(), v.end(),
        [](int x) { return x % 2 == 0; }), v.end());    // 删所有偶数

为什么要写成两段?因为 remove 并不真的删除元素,它只是把"要保留的"往前搬,返回"新的逻辑结尾",容器长度一个都没变。所以必须再 erase 一次把尾巴切掉。这个"两段式"就是著名的 erase-remove 惯用法。

逐段解析:扩容演示代码是怎么"抓到"扩容的
1size_t last = 0; — 记录上一次的容量,用来检测"这次 push_back 有没有触发扩容"。
2循环内每次 push_back 后比较 v.capacity() != last — capacity 是"已分配能装多少",size 是"实际装了多少",别混。
3size=1/2/3/5/9/17 时各扩一次:1→2→4→8→16→32 — 每次翻倍,所以 20 次 push_back 只搬 6 次家,这就是均摊 O(1) 的来源。
4const int* oldAddr = &w[0]; — 记下首元素的旧地址(指向元素的裸指针,与迭代器同命运)。
5w.reserve(1000) — 一次性分配 1000 个 int 的空间并搬家 → oldAddr 指向已释放的内存。
6oldAddr == &w[0] 输出"改变了" — 用地址对比实证了"数据真的被搬走",不是猜的。
7易错点:① 以为 reserve 会改 size — 不会,size 仍是 4,只有 capacity 变 1000;② 以为"指针指向 vector 对象"就安全 — 失效的是指向元素的指针/迭代器,vector 对象本身没搬家。
这段"测量代码"本身就是一套调试手段:用 capacity 探扩容、用地址对比探搬家,以后排查失效问题可以直接借用。
C++三种正确删除方式的实测
#include <bits/stdc++.h>
using namespace std;

void show(const vector<int>& v, const char* tag) {
    printf("%s: ", tag);
    for (int x : v) printf("%d ", x);
    printf("\n");
}

int main() {
    vector<int> a = {0, 1, 2, 0, 0, 3, 4, 0};
    show(a, "原始");

    // ── 方式 1:迭代器 + erase 返回值 ──
    vector<int> b = a;
    for (auto it = b.begin(); it != b.end(); ) {
        if (*it == 0) it = b.erase(it);
        else ++it;
    }
    show(b, "方式1 迭代器");

    // ── 方式 2:erase-remove 惯用法 ──
    vector<int> c = a;
    c.erase(remove(c.begin(), c.end(), 0), c.end());
    show(c, "方式2 remove");

    // ── 方式 3:remove_if + lambda(按条件) ──
    vector<int> d = a;
    d.erase(remove_if(d.begin(), d.end(), [](int x) { return x % 2 == 0; }), d.end());
    show(d, "方式3 删偶数");
    return 0;
}
运行结果
原始: 0 1 2 0 0 3 4 0 
方式1 迭代器: 1 2 3 4 
方式2 remove: 1 2 3 4 
方式3 删偶数: 1 3
逐段解析:三种删除方式各自怎么走(a = {0, 1, 2, 0, 0, 3, 4, 0})
1show 函数接收 const vector& 且用 for (int x : v) 只读遍历 — 元素是 int,拷贝代价为零,写法合理。
2方式 1:it 走到 0 → it = b.erase(it) 接住返回值(指向被删元素的下一个位置),循环头不写 ++。
3方式 1:it 走到 1、2、3、4 → 走 else 分支才 ++。结果 {1, 2, 3, 4} — 每个 0 都没漏。
4方式 2:c.erase(remove(c.begin(), c.end(), 0), c.end()) 一行。remove 把不等于 0 的元素依次往前搬 → 前段变成 1 2 3 4,返回"新逻辑结尾"。
5remove 之后容器长度仍是 8,尾部残留旧数据;erase 把 [新结尾, end) 一刀切掉 → {1, 2, 3, 4}。这就是"两段式"的原因:remove 只搬不删。
6方式 3:remove_if + lambda x % 2 == 0 — 0、2、0、0、4、0 都是偶数被删 → {1, 3}。按条件删除时把"等于 0"换成任意谓词即可。
7三种方式输出与运行结果逐行一致。易错点:① 方式 1 忘接返回值(it 失效继续用);② 以为 remove 一行就能删完(忘了 erase 切尾,size 不变)。
选型口诀:按值/简单条件删 → erase-remove 一行;复杂条件且要精细控制 → 迭代器 + erase 返回值。

💭 记忆要点

一条判断规则:只要这个操作可能改变容器的"长度",之前拿到的迭代器就要重新获取。读操作、只改元素值的操作不影响迭代器。

为什么 vector 是唯一"重灾区"?因为它用一整块连续内存存元素(这正是它能 O(1) 随机访问的原因)。一旦需要变长变短,就可能要"换一块更大的内存 + 搬家",于是所有旧地址全部作废。list、set、map 用链表/树实现,每个元素是独立节点,插入删除不会搬家,所以其他迭代器不受影响。

竞赛中的两条预防措施:

① 提前 reserve。已知最多放 n 个元素就写 v.reserve(n);。之后所有 push_back 都不会扩容,迭代器不会失效,而且还省下了反复拷贝的开销 —— 提速又避险。

② 优先用 erase-remove 惯用法。v.erase(remove(v.begin(), v.end(), x), v.end()); 不会出现"忘记在 else 里 ++it"这类手写错误,一行完成,可读性好。

一句话总结:vector 的快,是用迭代器的脆弱换来的。知道这笔交易,你就能在该谨慎的地方保持谨慎。

模块 03

实践应用

三道完整题目:pair 排序去重、tuple 多字段排序、迭代器失效的诊断与修复。

题 1点集排序与去重 pair 基础
📋 题目描述

给定平面上 n 个整数坐标点。请把所有不同的点按 x 坐标从小到大排序输出;x 相同时按 y 从小到大。

重复出现的点只输出一次。

⌨️ 输入格式

第一行一个整数 n。

接下来 n 行,每行两个整数 x y。

🖨️ 输出格式

第一行一个整数 k,表示不同点的个数。

接下来 k 行,每行两个整数,表示一个点。

📥 输入样例
6
3 1
1 5
1 2
3 1
1 5
2 0
📤 输出样例
4
1 2
1 5
2 0
3 1

样例解释:六个点中有两组重复 —— (3,1)、(1,5) 各出现两次,去重后剩 4 个不同的点。

📏 数据范围

1 ≤ n ≤ 2×105,-109 ≤ x, y ≤ 109。时间限制 1s,空间限制 256MB。

🧠 题目分析

这题的关键是"排序 + 去重"要一起完成。有三种思路,复杂度差别不大,但代码量差别明显。

方案做法复杂度代码量评价
手写去重排序后用双层循环两两比较O(n²)多n = 2×105 必然超时,不能用
sort + unique排序后扫一遍,把不同的往前搬O(n log n)中需要 pair 的 == 与 < 一致(pair 天然满足)
set 去重(推荐)set<pair<int,int>> s(v.begin(), v.end());O(n log n)最少一句话完成"排序 + 去重",且天然有序

为什么本题推荐 set?因为题目要求"排序输出 + 去重",而 set 本身就是"有序且无重复"的容器 —— 需求与容器的性质完全吻合。set<pair<int,int>> 能直接用,因为 pair 自带 operator<,不需要写任何比较函数。

关于复杂度:set 是红黑树,插入 n 个元素是 O(n log n)。n = 2×105 时约 2×105 × 18 ≈ 3.6×106 次操作,但每次都要做指针跳转,常数比数组大。实测大约是"sort + unique"的两到三倍时间,但在 1s 内完全够用。

如果追求极致速度:用 sort + unique。vector 的连续内存访问有缓存优势,实际跑起来更快。这里把 unique 的用法也讲清楚,两种方案都写给你。

关于 unique 的一个细节:unique 只去除相邻的重复元素,所以必须先排序。它的返回值是"新的逻辑结尾"的迭代器,容器长度没变 —— 必须再配合 erase 把尾巴切掉。这就是上一讲案例 4 提到的 erase-remove 惯用法。

C++题 1 参考代码(两种方案都给出)
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    scanf("%d", &n);

    // ═══ 方案 A:set 一句话搞定 ═══
    set<pair<int, int>> s;
    for (int i = 0; i < n; i++) {
        int x, y;
        scanf("%d %d", &x, &y);
        s.insert({x, y});              // 自动排序 + 自动去重
    }
    printf("%zu\n", s.size());
    for (const auto& [x, y] : s) printf("%d %d\n", x, y);

    // ═══ 方案 B:sort + unique(更快,作为对比) ═══
    // vector<pair<int,int>> v(n);
    // for (auto& p : v) scanf("%d %d", &p.first, &p.second);
    // sort(v.begin(), v.end());
    // v.erase(unique(v.begin(), v.end()), v.end());   // erase-remove 惯用法
    // printf("%zu\n", v.size());
    // for (const auto& p : v) printf("%d %d\n", p.first, p.second);
    return 0;
}
运行结果
4
1 2
1 5
2 0
3 1
逐段解析:6 个点依次 insert 进 set 发生了什么
1scanf("%d %d", &x, &y) 后 s.insert({x, y}) — 列表初始化直接构造 pair,无需 make_pair。
2第 1 个点 (3,1):集合为空,直接放入。
3第 2 个点 (1,5):1 < 3 → 排到 (3,1) 前面。第 3 个点 (1,2):first 同为 1,比 second,2 < 5 → 排到 (1,5) 前面。
4第 4 个点 (3,1):与已有元素完全相同 → 插入被丢弃(set 的"无重复"性质,这就是去重)。
5第 5 个点 (1,5):同样已存在 → 丢弃。第 6 个点 (2,0):1 < 2 < 3 → 插入中间。
6printf("%zu\n", s.size()) 输出 4 — 6 次插入只留下 4 个不同点;%zu 必须配 size_t,写成 %d 是未定义行为。
7遍历 set 天然有序:(1,2) (1,5) (2,0) (3,1),与输出样例一致。易错点:① 手写双层循环去重 O(n²) 超时;② 输出循环写 auto&(非 const)试图改 set 元素 — 直接编译错误(set 元素只读,这是好事)。
"排序 + 去重"两个需求被 set 的两个性质(有序、无重复)各自接管 —— 需求与容器性质吻合时,代码就该这么短。

💭 解题思路

第一步 · 把"点"打包。pair<int,int> 就是天然的坐标类型,不需要自定义结构体,也不需要写比较函数。这是本讲最直接的收益:省掉一整个比较函数的书写和调试。

第二步 · 把"排序 + 去重"交给容器。set 的定义就是"有序且无重复",所以 s.insert(...) 重复点会被自动丢弃,遍历时又天然有序。需求与容器性质完全吻合时,代码就应该是一句话。

第三步 · 用结构化绑定输出。for (const auto& [x, y] : s) 直接拆成两个变量,比 p.first、p.second 清楚。注意这里加 const auto& 而不是 auto&:因为我们只读不改,而 set 的元素本来也不允许修改(改了会破坏有序性)。

考点:pair 自带比较、set 的排序去重性质、unique 的 erase-remove 惯用法、结构化绑定、范围 for。

易错点:① 用 vector + 手写双重循环去重导致 O(n2) 超时;② 用 unique 时忘了后面的 erase,v.size() 还是原来的 n(unique 只是把重复的移到了尾部,长度没变);③ 输出循环里写了 auto& [x, y](非 const 引用)试图修改 set 的元素 —— set 的元素是只读的,编译会直接报错,这一点其实是好事。

题 2多字段排序输出 tuple 实战
📋 题目描述

给定 n 名学生的姓名(不含空格,仅小写字母)与三科成绩。请按以下优先级排序输出:

① 总分从高到低;
② 总分相同时,数学成绩从高到低;
③ 数学也相同时,姓名字典序从小到大。

每行输出姓名与总分。

⌨️ 输入格式

第一行一个整数 n。

接下来 n 行,每行一个字符串 name 与三个整数(语文、数学、英语)。

🖨️ 输出格式

共 n 行,每行 姓名 总分。

📥 输入样例
5
alice 90 85 80
bob 78 92 75
cathy 90 85 70
dave 60 60 90
eve 88 90 60
📤 输出样例
alice 255
cathy 245
bob 245
eve 238
dave 210

等一下 —— 上面的输出样例顺序有问题!cathy 和 bob 总分相同,请你先判断:按题目的优先级规则,谁应该排在前面?这正是本题第一个坑。

📏 数据范围

1 ≤ n ≤ 105,姓名长度不超过 20,每科成绩 0 ≤ score ≤ 200。时间限制 1s,空间限制 256MB。

🧠 题目分析:先纠正输出样例

各人总分:alice = 90+85+80 = 255、bob = 78+92+75 = 245、cathy = 90+85+70 = 245、dave = 60+60+90 = 210、eve = 88+90+60 = 238。

核对第二条规则:cathy 数学 85,bob 数学 92。数学高的在前,所以 bob 应该排在 cathy 前面!题面给的样例把顺序写反了,正确的输出是:

alice 255
bob 245            // 数学 92,排在前面
cathy 245          // 数学 85,排在后面
eve 238
dave 210

这个"故意写错的样例"想提醒你:多关键字排序最容易出错的地方,不是代码,而是"你自己搞混了关键字的优先级"。写代码前先在纸上把排序规则列成一行(总分↓ → 数学↓ → 姓名↑),能省下大量调试时间。

两种实现路径:

路径实现代码量可读性
tuple + 取负tuple<int,int,string>(-tot, -math, name),一行 sort少排序优先级与 tuple 顺序一一对应,很清楚
自定义 lambda写三层 if 判断多需要小心降序用 >、升序用 <,容易写错方向

本题姓名是升序,正好不需要取负,所以 tuple 取负技巧完美适用。如果姓名也要降序,就只能改用 lambda(因为 string 不能取负)。

C++题 2 参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    scanf("%d", &n);

    // 排序键:(-总分, -数学, 姓名)
    vector<tuple<int, int, string>> v;
    v.reserve(n);

    for (int i = 0; i < n; i++) {
        char name[32];
        int ch, ma, en;
        scanf("%s %d %d %d", name, &ch, &ma, &en);
        int tot = ch + ma + en;
        v.push_back(make_tuple(-tot, -ma, string(name)));   // ★ string(name) 必须拷贝!
    }

    sort(v.begin(), v.end());          // 升序排 ⇒ 总分降序、数学降序、姓名升序

    for (const auto& [negTot, negMa, nm] : v) {
        printf("%s %d\n", nm.c_str(), -negTot);
    }
    return 0;
}
运行结果
alice 255
bob 245
cathy 245
eve 238
dave 210
逐段解析:q2 参考代码的排序键是怎么工作的
1算总分:alice 90+85+80=255、bob 78+92+75=245、cathy 245、dave 210、eve 238。
2v.push_back(make_tuple(-tot, -ma, string(name))) — 键为 (−255,−85,alice)、(−245,−92,bob)、(−245,−85,cathy)、(−210,−60,dave)、(−238,−90,eve)。
3升序排第一分量:−255 最小 → alice 第一(总分最高者最前)。
4−245 组内比第二分量:bob 的 −92 < cathy 的 −85 → bob 排在 cathy 前(数学 92 > 85)。
5本题没用到姓名比较;若两人总分、数学都相同,才轮到第三分量姓名字典序(升序,不用取负)。
6然后是 −238(eve)、−210(dave)。
7输出 alice 255 / bob 245 / cathy 245 / eve 238 / dave 210,与运行结果一致。易错点:① 与题面给的(故意写错的)样例对比 —— 若照错误样例输出 cathy 在 bob 前,说明把"数学降序"搞反了;② string(name) 少写括号就变成存 char*,所有姓名串位。
写代码前把规则列成一行"总分↓ → 数学↓ → 姓名↑",再逐项翻译成 (−tot, −ma, name) —— 顺序对了,代码不会错。

💭 解题思路

第一步 · 把排序规则写成一个"关键字序列"。题目给的三条规则直接翻译成 tuple 的三个位置:总分(降序)→ 数学(降序)→ 姓名(升序)。要降序的取负,要升序的保持原样。

第二步 · 用"降序取负"把混合顺序统一成升序。这是本题最省事的技巧。tuple 按升序排,就等价于"总分降序、数学降序、姓名升序"——不需要写任何比较函数。

第三步 · 用 string(name) 真正拷贝字符串。如果直接写 name,make_tuple 推导出的第三个元素类型是 char*,指向循环里那块会被反复覆盖的栈内存。最终所有行的姓名都会变成最后一个输入的姓名 —— 这个 bug 极其隐蔽,而且不会有任何警告。

第四步 · 输出时把负号还原。变量名带 neg 前缀就是为了提醒这一点。

考点:tuple 多关键字排序、降序取负技巧、string 与 char[] 的区别、结构化绑定、v.reserve。

易错点:① 直接塞 name(char*)导致数据串位;② 忘记在输出时还原负号;③ 分不清"哪几个关键字要降序",写出与题面相反的顺序(如上面的样例纠错所示,这是最容易犯的错);④ 用 vector<tuple> 而未 reserve,在 n = 105 时反复扩容带来不必要的拷贝。

题 3修复迭代器失效 诊断能力
📋 题目描述(改错题)

下面程序的功能是:读入 n 个整数,删除其中所有等于 x 的数,然后输出剩余元素。

它在小数据上"看起来正确",但大数据时会随机崩溃。请找出所有问题并修复。

#include <bits/stdc++.h>
using namespace std;

int main() {
    int n, x;
    scanf("%d %d", &n, &x);
    vector<int> v(n);
    for (int i = 0; i < n; i++) scanf("%d", &v[i]);

    for (auto it = v.begin(); it != v.end(); ++it) {
        if (*it == x) v.erase(it);
    }

    printf("%zu\n", v.size());
    for (int i = 0; i < (int)v.size(); i++) printf("%d ", v[i]);
    printf("\n");
    return 0;
}
📥 输入样例
8 0
0 1 2 0 0 3 4 0
📤 输出样例
4
1 2 3 4
📏 数据范围

1 ≤ n ≤ 2×105,元素与 x 均为 -109 ≤ val ≤ 109。时间限制 1s,空间限制 256MB。

🧠 诊断:这行代码有两个层次的问题

for (auto it = v.begin(); it != v.end(); ++it) {
    if (*it == x) v.erase(it);          // ❌ 问题所在
}

问题一 · erase 之后 it 立刻失效,但循环还要 ++it。erase 会让被删位置及其之后的所有迭代器失效。循环头部的 ++it 和 it != v.end() 都在使用一个已经失效的迭代器 —— 这是未定义行为(UB)。

为什么"小数据看起来正确"?因为 vector 的内存是连续的,被删元素之后的数据只是"往前挪了一格",旧地址上的内存内容往往还残留着原来的数据。所以 *it 有时读到的是"下一个元素的旧位置",看起来就像"跳过了一个元素继续走"。等到元素数量大、或者 erase 触发了内部逻辑的边界情况,就会直接崩。

问题二 · 更隐蔽的逻辑错误:会漏删元素。即使忽略 UB 不谈,erase(it) 之后 it 应该指向"被删元素的下一个位置"。但循环头部又 ++it,等于向前跳了两格。如果原数组里有连续两个要删的元素,第二个就会被跳过而不被删除。

验证一下:输入 0 1 2 0 0 3 4 0,一步步跟:

步骤当前容器it 指向动作
初始0 1 2 0 0 3 4 0下标 0(值 0)相等 → erase,容器变成 1 2 0 0 3 4 0,it 落在下标 0(值 1);循环头再 ++it → 下标 1(值 2)
第 2 轮1 2 0 0 3 4 0下标 1(值 2)不等 → ++it → 下标 2(值 0)
第 3 轮1 2 0 0 3 4 0下标 2(值 0)相等 → erase,容器变成 1 2 0 3 4 0,it 落在下标 2(值 0);再 ++it 就跳过了这个 0 → 下标 3(值 3)
之后1 2 0 3 4 03 → 4 → 末尾前的 0删掉最后一个 0,得到 1 2 0 3 4

结果:容器变成 1 2 0 3 4(长度 5),中间那个 0 被漏掉了。程序不崩、也不报错,只是答案悄悄错了 —— 这就是为什么"迭代器失效"里最危险的从来不是崩溃,而是这种静默错误。

所以这个程序同时有"UB"和"逻辑错误"两个 bug —— 而且它们都源于同一行代码。

C++题 3 修复方案(三种)
#include <bits/stdc++.h>
using namespace std;

// ═══ 修复方案 1:用 erase 的返回值 + 把 ++ 放进 else ═══
void solve1(vector<int>& v, int x) {
    for (auto it = v.begin(); it != v.end(); ) {      // ★ 循环头不写 ++it
        if (*it == x) it = v.erase(it);               // 返回下一个有效位置
        else ++it;                                    // ★ ++ 只在没删时执行
    }
}

// ═══ 修复方案 2:erase-remove 惯用法(推荐) ═══
void solve2(vector<int>& v, int x) {
    v.erase(remove(v.begin(), v.end(), x), v.end());
}

// ═══ 修复方案 3:倒序遍历(思路简单,不易出错) ═══
void solve3(vector<int>& v, int x) {
    for (int i = (int)v.size() - 1; i >= 0; i--) {    // ★ 从后往前
        if (v[i] == x) v.erase(v.begin() + i);
    }
    // 倒序删除时,erase 只影响【被删位置之后】的迭代器,
    // 而我们已经处理过那些位置了,所以安全。
}

int main() {
    int n, x;
    scanf("%d %d", &n, &x);
    vector<int> v(n);
    for (int i = 0; i < n; i++) scanf("%d", &v[i]);

    solve3(v, x);                      // 三种方案任选一种

    printf("%zu\n", v.size());
    for (int e : v) printf("%d ", e);
    printf("\n");
    return 0;
}
运行结果
4
1 2 3 4 
逐段解析:先手推错误代码(再对照修复方案)
1v = {0,1,2,0,0,3,4,0},x=0;错误写法:erase(it) 之后循环头仍 ++it。
2第 1 轮:it@下标 0(值 0)→ erase,v 变 {1,2,0,0,3,4,0},it 落在下标 0(值 1);循环头 ++it → 下标 1(值 2)。
3第 2 轮:值 2 ≠ 0 → ++it → 下标 2(值 0)。
4第 3 轮:erase,v 变 {1,2,0,3,4,0},it 落在下标 2(值 0);++it 跳到下标 3(值 3)—— 这个 0 被漏掉了!
5之后 3、4 正常走过;末尾的 0 被删 → 结果 {1,2,0,3,4},长度 5 —— 答案悄悄错了。
6修复(方案 1):it = v.erase(it) 接住返回值、++it 只放 else → 每个 0 都被检查 → {1,2,3,4},长度 4,与运行结果一致。
7易错点:① 只接返回值但循环头还留着 ++it(漏删依旧);② 接了返回值却在 else 里忘了 ++(死循环);③ main 里调用的是 solve3(倒序删除),同样输出 {1,2,3,4}。
手推一遍错误代码,你会亲眼看到"UB + 漏删"两层 bug 同源 —— 这比背结论记得牢得多。

💭 解题思路

看到"循环里 erase"应该立刻警觉。这是 C++ 里最经典的 UB 模式之一。诊断的思路是固定的三步:

第一步 · 找出所有可能让容器"变结构"的操作。这里是 v.erase(it)。只要它在循环体内,就要检查循环变量是否还安全。

第二步 · 追问"循环变量还能用吗"。erase 到底让哪些迭代器失效?答案是"被删位置及其之后全部"。所以 it 本身已经作废,不能用它做 ++ 或与 end() 比较。

第三步 · 想清楚"漏删"这一层。即使不崩,语义也必须正确。erase 已经让 it 指向下一个元素了,外面再 ++it 就是跳过 —— 这是很多人修复时仍然留下的漏洞。

三种修复方案的取舍:

方案 1(迭代器 + erase 返回值):最"正统",适用于"按任意条件删除"(比如删所有偶数、删所有满足某个复杂谓词的元素)。关键就是"循环头不写 ++it,++ 放进 else"。

方案 2(erase-remove):最简洁,一行搞定。但它只适用于"按值删除"或"按简单谓词删除"——需要根据前后元素关系做决定时就不适用了。竞赛中"删所有等于某个值"的场景,一行 v.erase(remove(...), v.end()) 是首选。

方案 3(倒序删除):思路最直观,而且天然规避了失效问题——从后往前删时,erase 只会影响"被删位置之后"的迭代器,而那些位置我们已经处理完了。不过要注意:vector 的 erase 是 O(n),如果删除次数很多,整体可能退化到 O(n2)。本题的数据范围下这仍然够用,但如果是"删掉一半元素"的极端情况就要改用方案 2。

考点:迭代器失效、vector::erase 的返回值、erase-remove 惯用法、O(n) 删除带来的复杂度影响。

易错点:① 只把 ++it 挪走但忘了用 it = v.erase(it) 接收返回值(it 仍然是失效的);② it = v.erase(it) 写对了但循环头还留着 ++it(导致漏删);③ 用 v.erase(v.begin() + i) 正序遍历(i 之后全部前移,必须 i-- 补偿);④ 在 n = 2×105 时用方案 3 且删除元素极多,导致 O(n2) 超时。

模块 04

总结提升

一张容器选择表、一个迭代器安全模板,加三个拓展与三个值得琢磨的问题。

①核心要点与万能模板
知识板块必须记住的核心赛场用途
容器分类序列(vector/deque/list)、关联(set/map)、适配器(stack/queue/priority_queue)按"要做什么操作"选容器
选容器四问按下标?自动有序?按键查?取最值?一眼定下用哪个
复杂度速记vector 随机访问 O(1);set/map 增删查 O(log n);priority_queue 顶 O(1)、增删 O(log n)估算能否在 1s 内跑完
迭代器区间[begin, end) 左闭右开;end() 是尾后一格,不可解引用看懂所有 STL 函数签名
迭代器操作*it 取值、++it 前进、it->m 访问成员、it - begin() 得下标手写遍历
range-based forauto x 拷贝、auto& x 可改、const auto& x 只读零拷贝避免"改了没生效"
迭代器失效vector 扩容 → 全失效;erase/insert → 该位置及之后失效避免随机 RE
安全删除it = v.erase(it) 且 ++it 放 else;或 v.erase(remove(...), v.end())遍历中删元素
pairfirst / second,自带字典序比较,可直接 sort / set / map坐标、区间、复合键
tuplemake_tuple / get<i> / tie,下标须为编译期常量,自带比较三关键字以上排序、返回多值
结构化绑定auto& [a, b, c] = t;,加 & 才能改原对象遍历、解包
降序取负技巧把要降序的数值字段取负,整体升序排混合升降序的多关键字排序

万能模板:迭代器安全操作清单(遇到"遍历 + 增删"时对着检查)

C++四段安全代码,直接背下来
// ══ 1. 遍历只读:const auto& 零拷贝,最稳 ══
for (const auto& x : v) ans += x;

// ══ 2. 遍历修改元素值:auto& 必须加 & ══
for (auto& x : v) x *= 2;

// ══ 3. 遍历中删除元素:用 erase 的返回值 ══
for (auto it = v.begin(); it != v.end(); ) {   // ★ 循环头不写 ++it
    if (应删除(*it)) it = v.erase(it);
    else ++it;                                 // ★ ++ 放进 else
}

// ══ 4. 按值 / 按条件批量删除:erase-remove 惯用法 ══
v.erase(remove(v.begin(), v.end(), x), v.end());                       // 删所有 x
v.erase(remove_if(v.begin(), v.end(), [](int e){ return e < 0; }), v.end());  // 删所有负数

// ══ 5. 已知规模:提前 reserve,避免扩容与失效 ══
vector<int> w;
w.reserve(n);
for (int i = 0; i < n; i++) w.push_back(a[i]);

// ══ 6. 需要保存位置:存【下标】而不是迭代器 ══
int idx = find(v.begin(), v.end(), x) - v.begin();   // 存 idx,后续用 v[idx]

🔍 模板六段逐一解析(背之前先看懂)

第 1 段 · const auto& x:只读 + 零拷贝,是"遍历大对象"的默认写法;元素是 int 这类小类型时拷贝无妨,但统一写 const auto& 永远不会错。

第 2 段 · auto& x:要改元素必须加 &,x 是正本别名;漏了 & 就是"改复印件",编译器不报错(本讲高频失分点)。

第 3 段 · erase 返回值循环:三处要点缺一不可 —— 循环头没有 ++it、it = v.erase(it) 用返回值接住新位置、++it 只在 else 里执行。少任何一处就是漏删或 UB。

第 4 段 · erase-remove:remove/remove_if 只负责"把保留的往前搬 + 返回新逻辑结尾",长度不变;erase 负责切尾。两段必须连写,中间不能插别的操作。

第 5 段 · 提前 reserve:已知规模先 reserve,之后 push_back 零扩容零搬家 —— 提速又避险,竞赛标准习惯。

第 6 段 · 存下标不存迭代器:find(...) - v.begin() 得到下标;下标每次用 v[idx] 现算,扩容后依然有效。易错点:① find 没找到时返回 end(),减 begin() 得到 size(),用 idx 前要判断;② 第 3 段写成 for (...; it != v.end(); ++it) 还自以为用了 erase 返回值。

一个统一的判断规则:只要某个操作可能改变容器的"长度",之前拿到的迭代器就要作废并重新获取。只读遍历、只改元素值都不会影响迭代器。把这句话记住,本讲最危险的那部分就掌握了一半。
②三个拓展案例
拓 1用 vector 建图:竞赛最常用的邻接表

图论题里,"每个点连了哪些点"需要动态增长。用 vector<vector<int>> 比链式前向星更直观,也更不容易写错。

C++邻接表:vector 的嵌套用法
// ── 无权图:每个点存一个"邻居列表" ──
int n, m;
cin >> n >> m;
vector<vector<int>> g(n + 1);        // g[i] 是第 i 个点的邻居列表

for (int i = 0; i < m; i++) {
    int u, v;
    cin >> u >> v;
    g[u].push_back(v);
    g[v].push_back(u);                   // 无向图要加两条
}

// 遍历 u 的所有邻居:注意用 const auto& 零拷贝
for (const auto& v : g[u]) {
    if (v == target) { /* ... */ }
}

// ── 带权图:存 pair<邻居, 边权> ──
vector<vector<pair<int, int>>> gw(n + 1);
gw[u].push_back({v, w});
gw[v].push_back({u, w});

for (auto& [to, wgt] : gw[u])        // 结构化绑定直接拆开
    relax(to, wgt);

为什么竞赛中更推荐这种写法,而不是链式前向星?

① 写起来快、读起来清楚。g[u].push_back(v) 就是"给 u 加一个邻居 v",完全不需要维护 head[]、nxt[]、to[] 三个数组和 tot 计数器。BFS、DFS、最短路这些模板代码都能直接读。

② 不容易出错。链式前向星的"忘记初始化 head 为 -1""add 函数参数顺序搞反"都是常见 bug。vector 版本没有这些陷阱。

③ 内存是动态的。链式前向星必须把数组开到 2m,而 vector 按需分配。如果点的数量多但边很少(稀疏图),vector 版本更省内存。

那什么时候还该用链式前向星?① 对缓存极度敏感的题(前向星的内存更紧凑,但现代 vector 也做了优化);② 需要快速清除整张图的题(前向星只需重置 head 和 tot,而 vector 要逐个 clear);③ 某些老旧的题目风格。对新学提高组的同学,先把 vector 版本写熟。

关于效率的一个提醒:vector<vector<int>> 的 g[u] 里面还有一些小的 vector 对象,每个都要单独管理内存。如果图特别大(点数到 106),可以考虑用"一次性分配"的技巧:先统计每个点的度数,用前缀和算出每个点的存储区间,然后塞进一个大 vector。但这是卡常级别的手段,一般题目用不到。

拓 2离散化:sort + unique + lower_bound 三件套

这是 pair、迭代器与 vector 最经典的组合应用。"值域很大但元素很少"的题目几乎都要用它。

C++离散化:把大数值映射成小下标
// 需求:原始的数值可能到 10^9,但只有 n = 10^5 个不同的值
// 想用数组记录"每个值出现了几次" —— 数组开不下 10^9
// 解决:把每个值换成它的"排第几名"(1 到 k)

vector<long long> a = {1000000000LL, 5, 1000000000LL, -7, 5, 42};

// ── 第 1 步:拷贝一份并排序 ──
vector<long long> b = a;
sort(b.begin(), b.end());
// b = {-7, 5, 5, 42, 1000000000, 1000000000}

// ── 第 2 步:去重(erase-remove 惯用法) ──
b.erase(unique(b.begin(), b.end()), b.end());
// b = {-7, 5, 42, 1000000000},k = 4
int k = b.size();

// ── 第 3 步:把原数组的每个值换成它的排名(1 ~ k) ──
for (auto& x : a) {
    x = lower_bound(b.begin(), b.end(), x) - b.begin() + 1;
}
// a = {4, 2, 4, 1, 2, 3}  —— 全是 1 到 4 的小整数!

// ── 第 4 步:现在可以放心用数组/树状数组了 ──
vector<int> cnt(k + 1, 0);
for (int x : a) cnt[x]++;

三个关键细节

① sort + unique + erase 是固定组合。unique 只去相邻重复,所以必须先 sort。而且它的返回值是"新的逻辑结尾",容器长度不变,必须再 erase 一次。这三步是个不可拆分的整体,建议直接背成一行:b.erase(unique(b.begin(), b.end()), b.end());

② lower_bound 返回迭代器,减 b.begin() 才是下标。这是 STL 的一个统一约定 —— 算法返回位置,下标要靠减法得到。全局的 lower_bound(b.begin(), b.end(), x) 是"在任意区间里二分";而 set/map 的成员函数 s.lower_bound(x) 是"在自己的树上直接走",后者更快(O(log n) 而非 O(log n) 但常数小),而且不需要区间有序性检查。用错会慢一倍以上。

③ 离散化后的数组长度是 k(不同值的个数),不是原数组的 n。本例中 k = 4 而 n = 6。后续开数组要按 k 开,写错就会越界。

复杂度:sort 是 O(n log n),每个元素 lower_bound 一次是 O(log n),总共 O(n log n)。代价很小,收益是"把值域从无穷大压缩到 n"。

什么时候需要离散化?① 要用"下标是值的数组"(如 cnt[value]++)但值域很大;② 要用树状数组 / 线段树,但下标范围是原始值;③ 只需要知道"大小关系"而不需要具体数值的时候。判据很简单:看到"值很大但个数不多",就该想到它。

拓 3用 map<pair, T> 做二维状态的记忆化

记忆化搜索常需要"用两个数作为状态键"。用 map<pair<int,int>, T> 比自己把两个数编码成一个 long long 更直观,也完全不用担心溢出。

C++二维状态记忆化:pair 当键
// 状态:(剩余容量, 当前考虑到的物品编号)
map<pair<int, int>, int> memo;

int dfs(int cap, int i) {
    if (i == n || cap == 0) return 0;

    auto key = make_pair(cap, i);
    auto it = memo.find(key);                     // ★ 用 find,不要用 memo[key]
    if (it != memo.end()) return it->second;

    int res = dfs(cap, i + 1);
    if (w[i] <= cap)
        res = max(res, v[i] + dfs(cap - w[i], i + 1));

    memo[key] = res;
    return res;
}

// ── 对比:把两个数编码成一个 long long 当键 ──
// long long key = (long long)cap * 1000000 + i;
// 缺点:要保证乘法不溢出、要保证编码不冲突、可读性差

为什么这里必须用 find 而不是 memo[key]?

因为 map::operator[] 有一个非常危险的副作用:如果要找的键不存在,它会【自动插入】一个"值初始化为 0"的元素。在记忆化搜索里,这会造成两个后果:

① 逻辑错误(最致命)。你本来是想"查一下有没有算过",结果每个查过的状态都被插进了表里,值都是 0。于是"没算过的状态"和"算出来真等于 0 的状态"混在一起,无法区分,答案就错了。

② 内存爆炸。本来只需存 O(n × cap) 个状态,现在每个查询都会创建新条目,内存可能增长到不可控,直接 MLE。

正确做法:用 find() 判断存在性,it->second 取值,memo[key] = res 只在确定要写入时使用。或者更保险:先判断 if (memo.count(key)) return memo[key];。

关于性能:map 每次操作 O(log n) 且常数大。如果状态数可控(比如 n × cap ≤ 107),直接用二维 vector 数组 + 一个"访问标记"数组会快得多;只有当状态空间稀疏、或者键的范围真的开不出数组时,才用 map。这和知识点 1 里"能用数组就不要用 set"的结论完全一致。

顺带一个小技巧:如果只是"两个 int 当键"且范围已知,key = a * (RANGE) + b 编码成一个整数放进 unordered_map 会比 map<pair> 快很多。但一定注意 a 的取值范围,a * RANGE 不能溢出。

③三个值得琢磨的问题
思 1迭代器失效这么危险,为什么标准库不设计成"自动失效并报错"?
点击查看思路

首先明确它为什么危险:失效的迭代器本质是一个悬空指针,使用它是未定义行为。而"未定义行为"在 C++ 里意味着编译器不做任何检查——不报错、不警告、有时甚至能跑对。这种"静默失败"是最难排查的 bug 类型。

那为什么不加检查?三个原因:

① 性能代价。要让迭代器"知道自己是否有效",每个迭代器就得额外保存"指向哪个容器"和"容器的版本号",每次解引用都要先做一次校验。而迭代器的使用频率是极高的——一个 O(n log n) 的排序里 ++it 可能执行 107 次。每次都校验会让所有 STL 代码显著变慢。这与 C++ "零开销抽象"的设计哲学直接冲突。

② 无法完整检测。即使加了校验,也无法覆盖所有情况。比如"扩容后旧地址恰好被新分配的内存复用"这种情况,校验也查不出来。一个"有时能查、有时查不出"的机制,反而会给人虚假的安全感。

③ 有专门的工具来做这件事。标准库把"检测"交给了调试工具:_GLIBCXX_DEBUG 模式(GCC 提供,编译时加 -D_GLIBCXX_DEBUG)会让迭代器带上校验信息,一旦使用失效迭代器就立刻报错并中止。此外还有 -fsanitize=address(ASan)能检测已释放内存的访问。思路是"发布版本追求快,调试版本追求安全"。

对竞赛的启示:练习和调试时,建议用 g++ -D_GLIBCXX_DEBUG -fsanitize=address -g 编译自己的代码——它会帮你抓出所有迭代器误用。但在正式评测时必须关掉这些选项,否则会严重超时。这正是上一讲"GDB 与调试"里讲的"本地开满检查、提交打开优化"的具体应用。

思 2什么时候该用 vector + sort,而不是直接用 set?
点击查看思路

先看两者在"能不能用"上的区别:set 的最大优势是边插入边保持有序,且插入时自动去重;vector + sort 只能先全部读入、最后统一排序。

所以第一个判据:整个过程中需不需要知道"当前的最小值/最大值"或者"元素是否存在"?

• 需要(比如一边插入一边问"有没有重复"、或者每次取最小的)→ 用 set / priority_queue。
• 不需要(先把所有数据读完,最后排序输出)→ 用 vector + sort,几乎总是更快。

第二个判据:实际速度。虽然 set 和 sort 都是 O(n log n),但常数差别很大:set 是红黑树,每次插入要沿树走 log n 层,每层都是一次指针跳转(大概率缓存未命中);sort 是原地排序,数据在连续内存中,缓存命中率极高。

实测经验:vector + sort 通常比逐个插入 set 快 3~10 倍。在需要卡常的题目里,这个差距足以决定 TLE 还是 AC。

第三个判据:内存。set 的每个元素都是一个树节点,除了数据本身还要存三个指针(父、左、右)和颜色标记。存 int 时,一个 4 字节的数据要占约 40 字节的节点内存。n = 106 时就是 40MB,而 vector 只要 4MB。

第三个判据:需要哪些操作。

// 只需要"排序 + 去重 + 二分查找" → 首选 vector + sort
sort(v.begin(), v.end());
v.erase(unique(v.begin(), v.end()), v.end());
bool has = binary_search(v.begin(), v.end(), x);            // 存在性查询
int cnt = upper_bound(v.begin(), v.end(), x) - lower_bound(v.begin(), v.end(), x);  // 出现次数

// 需要"边插边查""动态维护有序" → 用 set
set<int> s;
s.insert(x);
if (s.count(x)) { ... }                                    // 动态存在性

一句话总结:"静态"用 vector,"动态"用 set。如果数据全部到手之后才开始提问,用 vector + 二分(可能配合排序)几乎总是更优的选择。这正是很多选手明明会写 set,却在实践中大量使用"排序 + 二分"的原因。

一个常见误区:有人为了"顺便保持有序",把大量数据一个个 insert 进 set,然后只用它做一次二分查找 —— 这等于用树的代价做排序的事,还不如直接 sort。判断的关键就是那句:"我到底什么时候需要这份有序性?"

思 3为什么 pair 和 tuple "自带"比较运算符,而自定义结构体不能?
点击查看思路

因为 pair 和 tuple 的"比较语义"是唯一的、无歧义的,而自定义结构体的语义只有你知道。

标准库能给出 pair 的 operator<,是因为它有充分的理由确定一个"默认解释":按字典序比较 first,相同再比 second。这个规则与"字符串按字母序比较""vector 按元素序比较"完全一致,是"把多个可比对象组成一个复合对象"时最自然的推广。标准委员会认为这个默认解释足够合理,所以直接提供了。

那为什么结构体不给默认的 operator<?因为编译器无法判断:

① 按哪个字段比?一个 struct Stu { string name; int tot; },按姓名比还是按总分比?两种都是合理的,编译器猜不透。如果它随便选一个(比如按声明顺序),那代码就会"看起来能编过,但语义完全不是你想的"——这比编译错误危险得多。

② 升序还是降序?"总分高的排前面"是降序需求,但 operator< 只能表达一种方向。强行给一个默认方向,一半的场景就会用错。

③ 哪些字段参与比较?结构体里可能有"缓存值""辅助标记"这类不该参与比较的字段。编译器无法识别。

所以 C++ 把这件事交给程序员,是一个"宁可让你多写几行、也不猜错"的设计。这与"不发明新运算符"是同一种保守哲学:语言的默认行为必须无歧义。

换个角度看,pair 和 tuple 之所以能"自带",恰恰是因为它们【没有字段名】。只有位置(first/second 或第 0/1/2 位),没有含义,所以"按位置顺序比较"就是唯一的、无歧义的解释。一旦你给字段起了名字,就说明它有含义,比较规则也就需要你来定义了。

这带来一个很实用的设计判断:如果你的结构体只是"几个值的临时打包",用 pair/tuple 更省事(自带比较);一旦这些值有了明确的业务含义、需要挑选部分字段参与比较,就应该写成结构体并自己定义 operator<。选择的标准不是"哪个更高级",而是"字段有没有含义"。

模块 05

学习检测

五个最常踩的坑,全部来自真实的调试现场。

易错1

range-based for 忘写 &,改了等于没改

❌ 错误
vector<int> v = {1, 2, 3};
for (auto x : v) x *= 10;
// v 仍然是 {1, 2, 3}

vector<string> vs = {"aa"};
for (auto s : vs) s += "!";
// vs 仍然是 {"aa"},而且每次循环白拷贝一个 string
不报错、不崩溃、结果全错 —— 典型的"静默失败"。
✅ 正确
// 要修改 → 加 &
for (auto& x : v) x *= 10;

// 只读 → const auto& 零拷贝
for (const auto& s : vs) cout << s << endl;

// 结构化绑定同理
for (auto& [x, y] : points) x++;
口诀:要改就加 &,只读加 const auto&;忘了 & 不会报错,只会安静地什么都不做。
易错2

保存了迭代器,然后 push_back 触发扩容

❌ 错误
auto it = v.begin();
for (int i = 0; i < 100; i++) v.push_back(i);
cout << *it << endl;
// ❌ UB!扩容把数据搬到了新内存
// 症状:有时能打印正确值,换台机器就 RE
为什么"有时候没事"最可怕:旧内存可能还没被覆盖,所以读到的是残值。这种"碰巧能跑"会让你完全意识不到代码有错。
✅ 正确
// 方案 1:提前 reserve,之后不再扩容
v.reserve(100);
auto it = v.begin();          // 仍建议不要跨操作保存
for (int i = 0; i < 100; i++) v.push_back(i);

// 方案 2(更稳):保存【下标】而不是迭代器
int idx = 0;
for (int i = 0; i < 100; i++) v.push_back(i);
cout << v[idx] << endl;         // 下标永远有效
口诀:vector 一扩容,全盘皆失效;要留位置存下标,不要留迭代器。
易错3

循环里 erase 后继续 ++it:崩溃或漏删

❌ 错误
for (auto it = v.begin(); it != v.end(); ++it)
    if (*it == x) v.erase(it);
// 问题 1:it 已失效,++it 是 UB
// 问题 2:erase 已让它指向下一个,再 ++ 就【跳过】了
// → 连续两个要删的元素,只删掉一个

// 【漏删】比崩溃更隐蔽:不报错,只是答案悄悄错了
✅ 正确
// 写法 1:erase 的返回值 + ++ 放 else
for (auto it = v.begin(); it != v.end(); ) {   // ★ 头里不写 ++it
    if (*it == x) it = v.erase(it);
    else ++it;                               // ★ 只在没删时 ++
}

// 写法 2(更省事):erase-remove 惯用法
v.erase(remove(v.begin(), v.end(), x), v.end());
口诀:循环删元素,++it 挪进 else;it = v.erase(it) 接住返回值;或者一行 remove 加 erase 收工。
易错4

用了 unique 却没配 erase,长度没变

❌ 错误
vector<int> v = {3, 1, 3, 1, 3};
sort(v.begin(), v.end());        // {1, 1, 3, 3, 3}
unique(v.begin(), v.end());
cout << v.size();                // 仍然是 5!
// 而且数组变成了 {1, 3, 3, 3, 3} —— 尾部是脏数据

// 另外:不排序直接 unique 也是错的
// unique 只能去掉【相邻】的重复元素
✅ 正确
vector<int> v = {3, 1, 3, 1, 3};
sort(v.begin(), v.end());                              // ① 必须先排序
v.erase(unique(v.begin(), v.end()), v.end());          // ② 两段式删尾巴
cout << v.size();                                      // 2
记忆:sort + unique + erase 是固定三件套,缺一不可。
口诀:unique 只是"把重复的挪到后面",不删元素、不改长度;
必须接一句 erase(unique(...), end()) 才算真正去重!
易错5

make_tuple / make_pair 存了 char*,数据全部串位

❌ 错误
for (int i = 0; i < n; i++) {
    char name[32]; int sc;
    scanf("%s %d", name, &sc);
    v.push_back(make_tuple(-sc, name));      // ❌ name 是 char[32]
}
// make_tuple 推导出 tuple<int, char*>
// 存的是【指向循环里那块栈内存的指针】!
// 下一轮 name 被覆盖 → 所有元素都指向同一块内存
// 输出结果:所有名字都变成最后一个输入的
✅ 正确
for (int i = 0; i < n; i++) {
    char name[32]; int sc;
    scanf("%s %d", name, &sc);
    v.push_back(make_tuple(-sc, string(name)));   // ★ 显式构造 string
}

// 或者直接用 string 读入,最干净:
string name; int sc;
cin >> name >> sc;
v.push_back({-sc, name});
口诀:char 数组是"借来的地址",string 才是"自己的副本";
存进容器的东西要长期活着,就必须先拷贝成 string。
📋速查表:容器与迭代器
类别操作写法复杂度
vector按下标访问v[i] / v.at(i)O(1)
尾插 / 尾删v.push_back(x) / v.pop_back()均摊 O(1)
取首 / 尾元素v.front() / v.back()O(1)
大小 / 判空 / 容量v.size() / v.empty() / v.capacity()O(1)
预留空间 / 预置长度v.reserve(n) / v.assign(n, 0)O(n)
清空 / 调整长度v.clear() / v.resize(n)O(n)
迭代器首 / 尾后v.begin() / v.end()O(1)
反向首 / 反向尾后v.rbegin() / v.rend()O(1)
取值 / 前进 / 成员*it / ++it / it->mO(1)
得下标it - v.begin()O(1)
删除(返回下一个)it = v.erase(it)O(n)
算法排序sort(v.begin(), v.end())O(n log n)
查找值find(v.begin(), v.end(), x)O(n)
有序二分 / 计数lower_bound / upper_bound / binary_searchO(log n)
去重(须先排序)v.erase(unique(v.begin(),v.end()), v.end())O(n)
按条件删 / 计数 / 最值remove_if / count_if / max_elementO(n)
pair / tuple构造make_pair(a,b) / make_tuple(a,b,c)O(1)
取元素p.first / p.second / get<i>(t)O(1)
解包(拷贝)tie(a, b, c) = tO(1)
解包(引用,可改)auto& [a, b, c] = tO(1)
比较自带字典序,可直接 sort / set按元素个数
三个"必须成对出现"的写法:① sort + unique + erase(去重三件套,必须都是这个顺序);② it = v.erase(it) 配合 ++it 放 else;③ reserve 配合后续的 push_back(想避免失效就必须提前预留)。
📌知识点笔记卡
📖 必记概念
  • 容器三类:序列 / 关联 / 适配器
  • 迭代器是算法与容器的统一接口
  • 区间 [begin, end) 左闭右开
  • end() 是尾后一格,不可解引用
  • 指针也是迭代器(sort(a, a+n))
  • pair / tuple 自带字典序比较
  • 结构化绑定:加 & 才能改原对象
🧰 必背模板
  • 去重:sort → unique → erase
  • 安全删除:it = v.erase(it),++it 放 else
  • 批量删:v.erase(remove(...), v.end())
  • 离散化:sort + unique + lower_bound − begin()
  • 多关键字:tuple(-a, -b, c) + sort
  • 建图:vector<vector<int>> g(n+1)
🚨 考场提醒
  • 要改元素就写 auto&
  • 只读用 const auto&
  • 已知规模先 reserve
  • 别保存跨操作的迭代器,存下标
  • 长度会变的操作之后重新取迭代器
  • map 查存在用 find,不用 []
课后练习

分层练习作业

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

🟢 A组 · 基础(4题)

A1概念辨析

① STL 容器分为哪三大类?各举两个例子。
② end() 指向哪里?为什么这样设计?
③ 为什么 sort(a, a+n) 能对原生数组排序?

查看参考答案

① 三大类:

序列容器(元素按插入位置排列):vector、deque、list、array。
关联容器(元素按键自动有序):set、map、multiset、multimap,以及哈希版本的 unordered_set、unordered_map。
容器适配器(在底层容器上提供受限接口):stack、queue、priority_queue。

② end() 指向"最后一个元素的后一格"(尾后迭代器),不可解引用。

为什么这样设计:为了让"空区间"能被自然表达。如果用"最后一个元素"作为终点,空容器就没有合法的 end()。而用"尾后一格",空容器满足 begin() == end(),遍历的终止条件永远只需一次 it != end()。
顺带的好处:区间统一为左闭右开 [begin, end),元素个数正好是 end() - begin()。

③ 因为指针本身就是一种迭代器。对数组 int a[n],a 指向首元素(相当于 begin()),a + n 指向"最后一个元素的后一格"(相当于 end())。标准算法只要求"能 * 取值、能 ++ 前进、能比较",指针完全符合这些要求,所以同一个 sort 既能排 vector 也能排原生数组。

易错:① 以为 end() 指向最后一个元素(于是写出 *v.end() 越界);② 误答"迭代器就是指针"(list、set 的迭代器不是指针,只是行为像指针);③ 说 sort 能排数组是因为"编译器特殊处理"(没有特殊处理,就是迭代器接口的通用性)。

A2写代码

读入 n 个区间 [l, r],按 左端点升序排序输出;左端点相同时按 右端点降序。请用 pair 配合"降序取负技巧"实现,不写比较函数。

查看参考答案

思路:左端点要升序,直接存;右端点要降序,存 -r。这样整个 pair 升序排就等价于"左端点升序、右端点降序"。

#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    scanf("%d", &n);

    vector<pair<int, int>> v(n);      // (l, -r)
    for (int i = 0; i < n; i++) {
        int l, r;
        scanf("%d %d", &l, &r);
        v[i] = {l, -r};                        // ★ 右端点取负
    }

    sort(v.begin(), v.end());                  // 不需要比较函数

    for (const auto& [l, negR] : v)
        printf("%d %d\n", l, -negR);       // ★ 输出时还原
    return 0;
}

输入输出示例:

输入:
5
3 7
1 4
3 9
1 2
2 5

输出:
1 4          ← 左端点 1,右端点 4 排在 2 前面(降序)
1 2
2 5
3 9          ← 左端点 3,右端点 9 排在 7 前面
3 7

🔍 参考代码逐段解析

① vector<pair<int,int>> v(n):构造时就 resize 到 n,之后用 v[i] = {l, -r} 直接下标写入 —— 不经 push_back,零扩容零失效。

② v[i] = {l, -r}(核心一行):左端点要升序 → 原样存;右端点要降序 → 存 -r。pair 升序排完,等价于"左升右降"。

③ sort(v.begin(), v.end()):靠 pair 自带字典序,比较函数一行不写。

④ 输出 -negR 还原负号:存进去是 -r,输出必须还原。

手算验证(输入样例):键为 (3,−7) (1,−4) (3,−9) (1,−2) (2,−5) → 升序得 (1,−4) (1,−2) (2,−5) (3,−9) (3,−7) → 还原输出 1 4 / 1 2 / 2 5 / 3 9 / 3 7,与示例输出逐行一致。

易错点:① 忘取负变成"双升序"(输出 1 2 在 1 4 前面,顺序错);② 输出忘还原,打印出 1 −4;③ 若 r 可能取到 INT_MIN,−r 会溢出(本题范围安全,通用场景建议 long long)。

考点:pair 自带字典序、降序取负技巧、结构化绑定、const auto& 零拷贝。

易错:① 忘记给右端点取负,变成"两个都升序";② 输出时忘记还原负号;③ 输入循环里写 for (auto p : v) 缺 &(读不进去);④ 用 v[i] = {l, -r} 时需要 v 已经 resize(n)(vector<pair> v(n) 已经做了)。

A3改错

下面的程序想给每个元素加一,但输出没有任何变化。指出错误并给出至少两种修法。

vector<int> v = {1, 2, 3};
for (auto x : v) x++;
for (auto x : v) cout << x << " ";
查看参考答案

思路:先想清楚 for (auto x : v) 展开后是什么。

错误:for (auto x : v) 展开为 auto x = *it; —— x 是元素的副本,x++ 改的是副本,容器里的元素毫无变化。而且编译器不会报任何错。

修法一(range-based for + 引用):

for (auto& x : v) x++;                 // ★ 加 &,x 就是元素的别名

修法二(下标循环):

for (int i = 0; i < (int)v.size(); i++) v[i]++;
// 或者用 size_t 避免类型警告:
for (size_t i = 0; i < v.size(); i++) v[i]++;

修法三(迭代器):

for (auto it = v.begin(); it != v.end(); ++it) (*it)++;
// 或更简洁:for_each(v.begin(), v.end(), [](int& x) { x++; });

要不要加 const?如果只想读,写 for (const auto& x : v) —— 零拷贝且编译器保证你不会误改。一条实用习惯:先写 const auto&,等编译器报"不能修改"时再删掉 const。

易错:① 误以为"没写 & 只是慢一点"(实际上完全改不动);② 用 int i 与 v.size() 比较产生有符号/无符号警告(建议用 size_t 或加 (int));③ 误以为 for_each 的 lambda 参数可以不加 &(不加就又是副本)。

A4运行推断

下面程序输出什么?说明每一步的理由。

vector<int> v = {10, 20, 30, 40, 50};
cout << *(v.begin() + 2) << endl;
cout << *(v.end() - 1) << endl;
cout << *(v.rbegin() + 1) << endl;
cout << v.end() - v.begin() << endl;
cout << *v.begin() << " " << *v.rbegin() << endl;

vector<int> e;
cout << e.size() << " " << (e.begin() == e.end()) << endl;
查看参考答案

思路:逐个分析"迭代器 + 偏移"和"反向迭代器"的含义。

输出:

30
50
40
5
10 50
0 1

逐步解释:

① *(v.begin() + 2):begin() 指向下标 0,加 2 指向下标 2,值是 30。随机访问迭代器支持 +n 跳跃(list 的迭代器不行)。

② *(v.end() - 1):end() 是下标 5(尾后),减 1 指向下标 4,值是 50。这就是"访问最后一个元素"的通用写法(注意空容器上非法)。

③ *(v.rbegin() + 1):rbegin() 指向最后一个元素(下标 4),+1 是"往前一个",指向下标 3,值是 40。注意反向迭代器的 ++/+ 方向与普通迭代器相反。

④ v.end() - v.begin():两个迭代器相减得到元素个数,即 5。这也印证了左闭右开区间的优点:end - begin 就是长度。

⑤ *v.begin() 与 *v.rbegin():分别是第一个和最后一个元素,即 10 和 50。等价于 v.front() 和 v.back()。

⑥ 空容器:size() 是 0,且 e.begin() == e.end() 为真(输出 1)。这正是"尾后一格"设计带来的好处:空容器也能被统一处理。

易错:① 以为 rbegin() + 1 会越界(实际是往前移动,合法);② 在空容器上写 *(v.end() - 1)(end() == begin(),减 1 就跑到 begin 之前了,越界);③ 以为 end() - begin() 会得到 n-1(左闭右开区间,答案就是 n)。

🟡 B组 · 提高(4题)

B1写代码

给定 n 条带权边 (u, v, w)。请把它们按 w 升序排序;w 相同时按 u 升序;u 也相同时按 v 升序。
要求分别用 ① tuple 一行 sort 与 ② 自定义结构体 + operator< 两种方式实现,并比较两者的适用场景。

查看参考答案

思路:三个关键字全是升序,所以 tuple 不需要取负技巧,直接用就行。

// ══ 方式 ①:tuple,一行 sort ══
vector<tuple<int, int, int>> v;    // (w, u, v)
for (int i = 0; i < n; i++) {
    int u, vv, w;
    scanf("%d %d %d", &u, &vv, &w);
    v.push_back(make_tuple(w, u, vv));      // ★ 按优先级顺序放
}
sort(v.begin(), v.end());                    // 完全不需要比较函数

for (const auto& [w, u, vv] : v)
    printf("%d %d %d\n", u, vv, w);

// ══ 方式 ②:结构体 + operator< ══
struct Edge {
    int u, v, w;
    bool operator<(const Edge& o) const {
        if (w != o.w) return w < o.w;       // 第一关键字
        if (u != o.u) return u < o.u;       // 第二关键字
        return v < o.v;                       // 第三关键字
    }
};
vector<Edge> e(n);
for (auto& x : e) scanf("%d %d %d", &x.u, &x.v, &x.w);
sort(e.begin(), e.end());                    // 同样不需要第三个参数

两种方式的场景对比:

对比项tuple结构体 + operator<
书写成本低(一行 sort)中(要写 4~6 行比较函数)
可读性get<0> / 结构化绑定,字段无名字好:e.w、e.u 一眼看出含义
能否加成员函数不能能(可以加 len()、rev() 等)
用于 Kruskal / 最短路可以但不直观推荐(代码意图清楚)
需要多套排序规则要重写 tuple(改字段顺序)可以配 operator< + 自定义比较器
字段数量3~4 个还行,更多就难读任意多

结论:临时的一次性排序用 tuple;这份数据结构要在整个程序里反复使用(如建图、Kruskal)就用结构体。关键判据还是那句:字段有没有名字。有了名字就说明它承载了含义,用结构体更清楚。

易错:① tuple 里的字段顺序必须与优先级一致(不是与题目输入顺序一致)—— 本题输入是 u v w,但 tuple 要写 (w, u, v);② 结构体的比较函数用 <=(破坏严格弱序,sort 会随机崩);③ 忘记 const 使比较函数无法在 const Edge& 上调用。

B2容器选择

下面四个需求各应该用哪个容器?请说明理由,并给出该操作的时间复杂度。

① 维护一个集合,"每次取最小值并删除,然后插入若干新值",总操作数 106。
② 读入 106 个数,全部读完后输出它们排序的结果。
③ 一边读入字符串,一边随时回答"某个字符串是否已经出现过"。
④ 需要按下标 i 随机访问第 i 个元素,同时在末尾不断追加。

查看参考答案

思路:逐个需求问"核心操作是什么"。

① 用 priority_queue(小根堆)。核心操作是"取最小值"(O(1))和"插入"(O(log n))。这是典型的贪心场景(如合并果子、Dijkstra)。总复杂度 O(m log n)。
为什么不用 set?set 也可以取最小(*s.begin()),但常数更大,而且本题不并需要"任意键查找"。
为什么不用排序后逐个取?因为插入是动态发生的,每次插入后要重新维护顺序,排序方案做不到。

② 用 vector + sort。数据一次性到位,只需要最后排一次序,O(n log n)。比逐个插入 set 快 3~10 倍,内存也更省。这正是"静态用 vector,动态用 set"的典型场景。

③ 用 unordered_set<string>(哈希集合)。核心操作是"动态存在性查询",平均 O(L)(L 是字符串长度,因为要先算哈希)。比 set<string> 的 O(L log n) 快。
但要注意:unordered_set 的最坏复杂度可被构造数据卡到 O(n)。如果题目数据刻意卡哈希,就要改用 set(稳定 O(L log n))或者手写带随机种子的哈希。

④ 用 vector(配合 reserve)。随机访问 O(1),末尾追加均摊 O(1)。这是 vector 的标准用途。
如果已知最多追加 n 次,一定要先 v.reserve(n) —— 既避免多次扩容的拷贝开销,也避免扩容导致的迭代器失效。
deque 也能做(同样 O(1) 随机访问和尾插),但 vector 内存更紧凑、常数更小,除非还需要头部插删,否则首选 vector。

考点:容器选择的核心判据 —— 先明确"核心操作是什么",再查它的复杂度。

易错:① 第 ② 问选 set(过度设计,白白慢好几倍);② 第 ③ 问选 vector + find(O(n) 查询,106 次查询直接超时);③ 第 ④ 问选 list(链表不支持随机访问,O(n) 才能定位第 i 个);④ 忘记深究"是否需要有序遍历"(哈希表快但无序,若题目要求有序输出就反而要多一次排序)。

B3改错

下面程序想"删除所有小于 0 的数",输入 -1 2 -3 4 -5 时输出应为 2 4,但实际输出 2 -3 4 甚至崩溃。请指出问题并修复。

vector<int> v = {-1, 2, -3, 4, -5};
for (auto it = v.begin(); it != v.end(); ++it) {
    if (*it < 0) v.erase(it);
}
for (int x : v) cout << x << " ";
查看参考答案

思路:追踪"erase 之后 it 指向哪里",以及"循环头又做了什么"。

问题:erase(it) 之后 it 已失效,而循环头还要 ++it。两重后果:

① 未定义行为。it 本身以及它之后的迭代器全部失效,++it 和 it != v.end() 都在使用失效迭代器 —— 这是 UB,可能崩溃也可能"碰巧跑对"。

② 逻辑上会跳过元素。erase 返回后 it 应该指向"被删元素的下一个位置"(如果接收了返回值的话)。但这里没接收返回值,it 还是旧的,然后再 ++it,相当于在内存里往后走了两步。所以数组里连续两个负数时,第二个会被跳过。

验证 -1 2 -3 4 -5:删掉 -1(下标 0)后,容器变成 2 -3 4 -5,it 落在下标 0(值 2),++it → 下标 1(值 -3);删掉 -3 → 容器 2 4 -5,it 落在下标 1(值 4),++it → 下标 2(值 -5);删掉 -5 → 2 4。本例恰好结果正确 —— 但这是"碰巧",换个数据(比如两个负数相邻)就会漏删。比如输入 -1 -2 3:删掉 -1 后 it 指向 -2,++it 跳过它,最终输出 -2 3 —— 漏删了!

修复方案(三种):

// ══ 方案 1:erase 返回值 + ++ 放 else ══
for (auto it = v.begin(); it != v.end(); ) {     // ★ 头里不写 ++it
    if (*it < 0) it = v.erase(it);
    else ++it;                                 // ★ 只在没删时前进
}

// ══ 方案 2(推荐):erase-remove 惯用法 ══
v.erase(remove_if(v.begin(), v.end(),
        [](int x) { return x < 0; }), v.end());

// ══ 方案 3:倒序遍历 ══
for (int i = (int)v.size() - 1; i >= 0; i--)
    if (v[i] < 0) v.erase(v.begin() + i);

修复后输出:2 4

考点:vector::erase 的返回值语义、迭代器失效、"漏删 vs 崩溃"这两层问题、erase-remove 惯用法。

易错:① 只把 it = v.erase(it) 写对,却忘了把循环头的 ++it 删掉(仍然漏删);② 误以为"这个例子跑对了所以没问题"(改变输入数据就会暴露 bug —— 这正是 UB 的可怕之处);③ 用方案 3 但正序遍历(erase 后 i 位置换成了下一个元素,必须 i-- 补偿)。

B4简答

为什么标准库算法(如 sort、find、reverse)的接口都接收迭代器而不是容器本身?请至少说三点理由。

查看参考答案

思路:从"接口设计"的角度想 —— 如果接收容器,会失去什么能力?

理由一 · 可以只操作容器的一部分。sort(v.begin() + 2, v.end()) 只排后半段;sort(v.begin(), v.begin() + k) 只排前 k 个。如果接口收的是容器,就只能"全排",做不到部分操作。这类需求在竞赛里很常见(比如"先把前 k 个排序")。

理由二 · 一套算法可以服务所有数据结构。sort 只要求迭代器支持随机访问,所以它能排 vector、排 deque、排原生数组、排 std::array。如果接口按容器设计,就得为每种容器写一个版本(sort_vector、sort_array……)。这就是"泛型编程"的核心:算法只依赖"能做什么"(概念),不依赖"是什么"(具体类型)。

理由三 · 可以作用于"非容器"的数据。sort(a, a + n) 排原生数组;reverse(s.begin(), s.end()) 反转字符串;甚至 sort 一个动态分配的区间也可以。这些都不是 STL 容器,但它们都提供迭代器。

理由四 · 算法之间可以自由组合。因为输入输出都是迭代器,所以可以嵌套:copy(s.begin(), s.end(), back_inserter(v))、remove_if(...) 的结果直接喂给 erase。如果接口收容器,这种"管道式"组合就实现不了。

理由五 · 解耦与独立性。<algorithm> 根本不需要 #include 任何具体容器的头文件。算法库和容器库可以独立演化,第三方容器只要提供符合要求的迭代器,就能立刻用上所有标准算法。这是"接口与实现分离"的典型示范。

一个类比帮助记忆:迭代器就像"书签"。图书馆的管理规则(算法)只关心"从哪个书签到哪个书签",不关心"这是哪个书架"(容器)。这样同一套规则能用在所有书架上。

易错:① 只答"这样更灵活"这类空话,要落到"能做部分操作""能跨容器复用""能作用于非容器"这些具体能力上;② 反过来误以为"容器没有用"(容器负责内存管理与数据组织,迭代器负责访问,两者职责不同);③ 混淆"迭代器"与"指针"(list 的迭代器不是指针,但同样满足迭代器的接口要求)。

🔴 C组 · 挑战(4题)

C1综合实现

实现离散化:给定 n 个整数(可能重复、可能很大),输出每个数"在去重排序后的排名"(从 1 开始),并说明各步骤的复杂度。

查看参考答案

思路:"拷贝 → 排序 → 去重 → 二分查排名"是固定四步。

#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    scanf("%d", &n);

    vector<long long> a(n);
    for (auto& x : a) scanf("%lld", &x);

    // ── 第 1 步:拷贝一份并排序 ──
    vector<long long> b = a;
    sort(b.begin(), b.end());

    // ── 第 2 步:去重(sort + unique + erase 三件套) ──
    b.erase(unique(b.begin(), b.end()), b.end());
    int k = b.size();                     // 不同值的个数

    // ── 第 3 步:查排名并输出 ──
    for (int i = 0; i < n; i++) {
        int rank = lower_bound(b.begin(), b.end(), a[i]) - b.begin() + 1;
        printf("%d%c", rank, i + 1 == n ? '\n' : ' ');
    }
    printf("共 %d 个不同值\n", k);
    return 0;
}

输入输出示例:

输入:
6
1000000000 5 1000000000 -7 5 42

输出:
4 2 4 1 2 3
共 4 个不同值

🔍 参考代码逐段解析(以输入样例手算)

① vector<long long> b = a;:拷贝一份再折腾 —— 因为输出要按输入顺序,原数组的顺序不能动。

② sort(b) + b.erase(unique(...), b.end()):b 排序后为 {−7, 5, 5, 42, 109, 109};unique 把重复的 5、109 各留一份搬到尾部,erase 切尾后 b = {−7, 5, 42, 109},k = 4。sort 是 unique 的前提(只去相邻重复)。

③ lower_bound(b.begin(), b.end(), a[i]) - b.begin() + 1(核心一行):lower_bound 返回"第一个 ≥ a[i] 的位置"的迭代器,减 begin() 转成下标(0 基),+1 转成题目要的 1 基排名。

④ 手算输出:a[0]=109 → 下标 3 → 排名 4;a[1]=5 → 下标 1 → 排名 2;a[2]=4;a[3]=−7 → 下标 0 → 排名 1;a[4]=2;a[5]=42 → 下标 2 → 排名 3。输出 4 2 4 1 2 3,与示例一致;k=4 也正确。

⑤ printf("%d%c", rank, i+1==n ? '\n' : ' '):用三目运算符控制最后一个数后换行 —— 输出格式的常见小技巧。

易错点:① 忘 erase 切尾 —— 排名不受影响但 k 会算错;② 用 find 代替 lower_bound —— O(n) 查找 × n 次 = O(n²) 超时,先排序的全部价值就在换来的 O(log n) 二分;③ 忘减 b.begin() 或忘 +1 —— 输出 0 基下标或内存地址。

各步骤复杂度:

步骤操作复杂度说明
1拷贝 b = aO(n)整体拷贝
2sort(b)O(n log n)整个过程的主导项
3unique + eraseO(n)扫一遍,把不同的往前搬
4每个元素 lower_boundO(log n)二分查找,共 n 次
总计O(n log n)排序占主导

三个实现细节:

① lower_bound 返回迭代器,必须减 b.begin() 才是下标。这是 STL 的统一约定 —— 算法返回位置,下标靠迭代器相减。忘了减就会把"地址"当"排名"用,得到完全不相关的巨大数值。

② 排名加 1 是因为题目要求从 1 开始。不加 1 得到的是 0 ~ k-1 的下标,这种"0 基下标"在后续用数组/树状数组时反而更方便,看具体需求决定。

③ 用 lower_bound 而不是 find。find 是 O(n) 的线性查找,n 次调用会变成 O(n2);lower_bound 是 O(log n) 的二分查找,这正是"先排序"的价值所在。

一个可选的优化:如果每个元素只查一次排名,也可以用 unordered_map<long long,int> 预先把"值 → 排名"的映射建好,之后每个查询是平均 O(1)。总复杂度降到 O(n log n)(主导项仍是排序)但常数不同 —— 具体哪个快取决于数据,lower_bound 版本更稳定、不担心被卡。

易错:① 忘记 b.erase 切尾巴,导致 b 里还有重复值,lower_bound 的排名正确但 k 算多了;② 忘了 sort,unique 和 lower_bound 都会失效(二分查找要求区间必须有序,否则结果无意义);③ 后续开数组时按 n 而不是 k(本例 n=6 但 k=4,按 n 开会浪费,但按更小的开会越界);④ 忘记减 b.begin()。

C2深度分析

下面这段代码在小数据下正确、大数据下必然崩溃(或结果错误)。
① 请用"扩容"的原理解释原因;② 给出不改变算法的修复方案;③ 如果算法必须"边遍历边保存位置",应该怎么做?

vector<int> v;
for (int i = 0; i < n; i++) {
    if (a[i] % 2 == 0) {
        v.push_back(a[i]);
        oddPos.push_back(v.begin() + v.size() - 1);   // 记录刚插入元素的位置
    }
}
// 后续用 oddPos 里的迭代器访问元素并修改
for (auto it : oddPos) *it *= 10;
查看参考答案

① 原因:push_back 会触发扩容,而扩容会"搬家",让所有已保存的迭代器作废。

具体机制:vector 用一整块连续内存存元素。当空间不够时,它会:

第 1 步 申请一块更大的新内存(典型实现是原容量的 2 倍);
第 2 步 把原有元素逐个搬到新内存;
第 3 步 释放旧内存;
第 4 步 在新内存里放入新元素。

关键在"旧内存被释放"这一步:你保存的 oddPos 里的迭代器仍然存着旧内存的地址。后续 *it *= 10 就是读写已经归还给系统的内存 —— 未定义行为。

为什么"小数据正确"?因为 n 很小时 push_back 次数少,vector 的初始容量(典型是 0 或 1,之后按 1、2、4、8…… 增长)可能一次都没扩容,所有迭代器一直有效。一旦 n 超过容量阈值,第一次扩容就会让全部已保存的迭代器变成悬空指针。

为什么有时"不崩但结果错"?因为旧内存可能还没被其他数据覆盖,读到的是残留的旧值,写入也是写进"已经无效的内存"。这种"看起来能跑"的症状,会让这个 bug 极难排查。

② 不改变算法的修复方案:提前 reserve。

vector<int> v;
v.reserve(n);                  // ★ 预留足够空间,之后 push_back 不再扩容
for (int i = 0; i < n; i++) {
    if (a[i] % 2 == 0) {
        v.push_back(a[i]);
        oddPos.push_back(v.begin() + v.size() - 1);
        // reserve 之后这里安全了:容量足够,不会搬家
    }
}
for (auto it : oddPos) *it *= 10;   // ✓ 现在合法

为什么 reserve(n) 就能解决?因为它把容量一次性提到 ≥ n,之后所有 push_back 都在同一块内存上进行,不会再搬家,所以迭代器始终有效。而且顺带省掉了多次扩容带来的元素拷贝开销 —— 既修复了 bug 又提升了速度,这是竞赛中非常推荐的写法。

注意:reserve 之后不能超过容量。如果后续 push_back 的次数可能超过 n,就要 reserve 一个更大的值。一旦超出容量,扩容依然会发生 —— 此时迭代器又会失效。

③ 如果算法必须"边遍历边保存位置",最稳的做法是存下标。

vector<int> v;
vector<int> pos;                   // ★ 存下标,不存迭代器
for (int i = 0; i < n; i++) {
    if (a[i] % 2 == 0) {
        v.push_back(a[i]);
        pos.push_back((int)v.size() - 1);   // 记下标
    }
}
for (int idx : pos) v[idx] *= 10;    // ✓ 下标永远有效

为什么下标一定安全?因为 v[idx] 的求值是"当前的起始地址 + idx × sizeof(int)" —— 每次访问都重新计算,用的是容器现在这块内存。而迭代器保存的是"某一个时刻的绝对地址",一旦那块内存被换掉就作废了。

这条经验的适用范围很广:图论里要记录"边的编号"、排序里要记住"原始位置"、离线算法里要保存"查询序号"——只要这个位置需要跨操作长期保留,就一律用下标而不是迭代器。

附:如果保存位置的目的是"回头改某个元素的值",还有一个更省事的思路——先构造完容器,再统一遍历修改,就完全不需要保存任何位置了:

for (auto& x : v) x *= 10;      // 等等,这样会全改
// 只改偶数的话,判断条件本身就能再算一遍:
for (auto& x : v) if (x % 2 == 0) x *= 10;

能"事后重算"的时候,就不要"提前保存" —— 少保存一份状态,就少一类失效风险。这是防御性编程的一条实用原则。

易错:① 只在循环前 reserve 了一个"看起来够大"的数,实际 push_back 超过了它(必须严格保证不超);② 用 resize(n) 代替 reserve(n)(resize 会真的创建 n 个元素,size() 变成 n,与"追加"的意图不符);③ 只对第一个容器 reserve,忘了 pos 数组也在不断增长(它增长时自己的迭代器也会失效,但存的是下标所以无所谓)。

C3综合实现

用 map<pair<int,int>, long long> 实现记忆化搜索:求"走 n 级台阶,每步可跨 1~k 级"的方案数(对 109+7 取模)。
要求:① 状态为 (剩余级数, 剩余可用步数);② 说明为什么必须用 find 而不是 operator[];③ 分析如果改用 unordered_map 需要什么额外工作。

查看参考答案

思路:把"两个整数组成的复合状态"直接当键。pair 自带 operator<,所以 map 能直接用它。

#include <bits/stdc++.h>
using namespace std;

const long long MOD = 1000000007;

map<pair<int, int>, long long> memo;

// 状态:还剩 rem 级台阶要走,本步最多跨 k 级
long long dfs(int rem, int k) {
    if (rem == 0) return 1;                 // 走到顶,算一种方案
    if (rem < 0)  return 0;

    auto key = make_pair(rem, k);

    // ★★ 必须用 find 判断存在性 ★★
    auto it = memo.find(key);
    if (it != memo.end()) return it->second;

    long long res = 0;
    for (int step = 1; step <= k && step <= rem; step++) {
        res = (res + dfs(rem - step, k)) % MOD;
    }
    memo[key] = res;                          // 只在确定要写入时才用 []
    return res;
}

int main() {
    int n, k;
    scanf("%d %d", &n, &k);
    printf("%lld\n", dfs(n, k));
    printf("状态数 = %zu\n", memo.size());     // 顺便观察记忆化效果
    return 0;
}

输入输出示例(n=5, k=2:每步 1 或 2 级,共有 8 种走法):

5 2
8
状态数 = 5

🔍 参考代码逐段解析(以 n=5, k=2 手算)

① 边界两连:rem == 0 返回 1(一步不剩 = 恰好走完,算 1 种方案);rem < 0 返回 0(跨过头了,非法方案)—— 两个边界缺一不可,少了后者递归会跑飞。

② auto key = make_pair(rem, k);:把二维状态打包成 pair;pair 自带 operator<,所以 map<pair<int,int>, long long> 不需要任何额外代码。

③ memo.find(key)(本题灵魂):find 只查不插;找不到才往下算。若写成 memo[key],一次读就会把状态以 0 塞进表 —— "没算过"和"算出 0"再也无法区分。

④ 转移循环:for (step = 1; step <= k && step <= rem; ...) — 上界取 min(k, rem),保证 rem−step ≥ 0;每项累加后立即 % MOD,防止 long long 溢出。

⑤ memo[key] = res;:只在确定要写入时用 operator[] —— "读用 find、写用 []" 是 map 的标准分工。

手算验证:f(0)=1;f(1)=f(0)=1;f(2)=f(1)+f(0)=2;f(3)=f(2)+f(1)=3;f(4)=5;f(5)=f(4)+f(3)=5+3=8,与示例输出一致;计算过的状态是 (rem,k) 中 rem=1..5,共 5 个,"状态数 = 5" 也对上了。

易错点:① 用 memo[key] 判存在性(本题最致命错误);② 忘 % MOD;③ 忘 rem<0 边界;④ main 里忘记 memo 是全局的 —— 若把它放进 main 或函数内,每次调用都是空表,记忆化完全失效(还可能栈溢出)。

② 为什么必须用 find 而不能用 memo[key]?

因为 map::operator[] 有"自动插入"的副作用:如果键不存在,它会插入一个"值初始化为 0"的元素并返回引用。在这个算法里有两重危害:

危害一 · 逻辑彻底错误(最致命)。你写 if (memo[key] != 0) return memo[key]; 想判"算过没有",但 memo[key] 这一读就已经把状态插进去了,值为 0。而"算出来真的等于 0"的状态(本例中 k=0 或 rem 无法走到的情况)也表现为 0。两者完全无法区分 —— 于是"没算过"被误判成"算过且答案是 0",直接返回错误结果。

危害二 · 内存与状态数失控。每个查询(包括那些最终没被使用的)都会在表里留下条目。本来 O(n) 个状态可能膨胀成远超预期的数量,导致 MLE。

正确的判存在性三种写法:

// 写法 1:find(推荐,只查一次)
auto it = memo.find(key);
if (it != memo.end()) return it->second;

// 写法 2:count
if (memo.count(key)) return memo[key];

// 写法 3:C++20 的 contains(更直观)
// if (memo.contains(key)) return memo[key];

小对比:count + operator[] 会查询两次(多一次 O(log n));find 只查一次,所以是首选。如果确实想用 operator[],就必须用不会冲突的初值做哨兵(比如把"未访问"标记为 -1,因为方案数不会为负)——但这样又依赖"值域不与哨兵冲突"这个隐含假设,容易出问题,不如用 find 明确。

③ 改用 unordered_map 需要做的额外工作:

关键问题:unordered_map 需要"把键映射成哈希值",而标准库只为内置类型和 string 提供了哈希函数,pair 没有。所以直接写 unordered_map<pair<int,int>, long long> 会编译错误(提示 no matching function for call to hash<pair<int,int>>)。

解决方案一(推荐):把 pair 编码成一个整数。

// 用 long long 组合两个 int(保证不冲突、不溢出)
unordered_map<long long, long long> memo;

auto encode = [](int a, int b) -> long long {
    return (long long)a * 2000000000LL + b;   // b 的取值范围必须小于这个倍数
};

auto it = memo.find(encode(rem, k));
if (it != memo.end()) return it->second;
// ... memo[encode(rem, k)] = res;

编码要点:① 倍数必须严格大于 b 的最大可能取值,否则不同 (a,b) 会编码成同一个数(哈希冲突变成语义冲突,必错无疑);② 乘法要用 long long,a × 倍数 不能溢出;③ 建议在注释里写清 a、b 各自的取值范围,方便日后检查。

解决方案二:自定义哈希函数。写一个 struct PairHash { size_t operator()(const pair<int,int>& p) const {...} },作为第三个模板参数传给 unordered_map。代码更长,而且要自己保证哈希质量(否则大量冲突会让平均 O(1) 退化成 O(n))。

什么时候值得换 unordered_map?当状态数很大(比如 106 以上)且常数敏感时。unordered_map 的平均查询是 O(1),比 map 的 O(log n) 常数小得多。但必须注意:它可能被构造数据卡到最坏 O(n),竞赛中若出题人刻意卡哈希就要加随机化种子。

最后一个更优的选择:如果 n 和 k 的范围允许(比如 n × k ≤ 107),直接开二维 vector 数组 + 一个访问标记数组,比任何哈希/树结构都快。这再次印证了知识点 1 的结论:能用连续内存的数组,就不要用树或哈希表。

易错:① 用 memo[key] 判存在性(最常见也最致命);② 编码时倍数取小了,两个不同状态编码成同一个数;③ 编码时用 int 乘法溢出;④ 忘记取模导致答案溢出;⑤ 忘记处理 rem < 0 的边界(step > rem 时递归会得到负数)。

C4深度思辨

有同学说:"vector 的迭代器失效太麻烦了,我干脆全部改用 list,它不会失效,问题就没了。"
请从至少三个角度评价这个方案,并给出更合理的建议。

查看参考答案

思路:先承认他说对了一半(list 确实不失效),再说明"换容器"会付出什么代价。

他正确的地方:list 是双向链表,每个元素是独立分配的节点。插入和删除只改变指针指向,不会搬运已有元素。所以除了"被删除的那个元素"自身的迭代器,其它迭代器都保持有效。这一点他理解得没错。

角度一 · list 丧失了随机访问(最致命的代价)。vector 的 v[i] 是 O(1),而 list 根本没有 v[i] 和 +n——它的迭代器只能 ++ / -- 一步一动,访问第 i 个元素要 O(n)。这意味着:sort 不能用在 list 上(sort 需要随机访问迭代器)、lower_bound 不能用在 list 上、二分查找也全都失效。为了避开"迭代器失效",却丢掉了整个算法库的一半能力 —— 这是极其不划算的交易。

角度二 · 缓存性能差得多。vector 的元素在连续内存中,遍历时一次读入一整条缓存行(通常 64 字节 = 16 个 int),后续 15 次访问全部命中缓存。而 list 的节点散落在堆内存各处,每次 ++it 都跳到一块不确定的新内存,几乎每次都"缓存未命中"。
实测:同样遍历 107 个元素,vector 常常比 list 快 5~10 倍。这个差距在竞赛里足以决定 TLE 还是 AC。

角度三 · 内存开销大得多。list 的每个节点除了存数据,还要存前驱指针、后继指针(64 位系统下每个 8 字节)。存一个 int(4 字节)实际占用约 24~32 字节,放大 6~8 倍。n = 106 时 vector 需 4MB,list 需 24MB 以上。内存限制 256MB 时,这会显著压缩你能开的数据规模。

角度四 · 这个"便利"其实用不上。竞赛里 90% 以上的场景是"先读入全部数据 → 处理后输出",中间并不需要"边遍历边插删"。换句话说,迭代器失效的风险在多数题目里根本不会出现 —— 为了一个罕见的场景放弃 vector 的全部优势,是典型的"用重武器解决小问题"。

更合理的建议(四条):

① 默认继续用 vector。它应该是你的第一选择,只有在明确需要"任意位置 O(1) 插删"且不需要随机访问时才考虑 list。

② 用正确的方法规避失效,而不是换容器。三条实用措施:已知规模就 reserve(让扩容不再发生);需要保存位置就存下标(下标永远有效);循环中删除就用 it = v.erase(it) 或 erase-remove 惯用法。掌握这三条,本讲的"危险区"就基本清零了。

③ 用调试工具主动发现失效。本地调试时加 -D_GLIBCXX_DEBUG -fsanitize=address 编译,一旦使用失效迭代器就立刻报错并指出位置,比"换容器"主动得多。提交评测时记得关掉。这是"用工具解决工具层面问题"的正确思路。

④ 想清楚"到底该用哪个容器"。决策链是:是否需要按下标随机访问 → 需要就 vector/deque;不需要但需要频繁在中间插删 → 才考虑 list;需要自动有序 → set/map;需要取最值 → priority_queue。从"操作需求"出发,而不是从"哪个不容易出错"出发。

一句总结:容器的选择应该由"算法的操作需求"决定,而不是由"哪种容器更难用错"决定。避开一个 bug 的正确方式是学会正确的写法,而不是换一个性能差好几倍的工具。这背后也是竞赛中一个普遍的取舍:能接受"有代价的复杂",才能写出跑得动的代码。

顺带一提:标准库确实为 list 提供了一个专门的 list::sort() 成员函数(因为全局 sort 用不了)。但它的常数远大于 vector 版 sort,而且只适用于链表。这恰好从侧面说明:list 是一个"为特定场景存在"的容器,不是 vector 的通用替代品。

📚 洛谷原题训练(12 题)

本组题目重点练习"用容器存数据 + 用迭代器/范围 for 遍历 + 用 pair 打包"的常规套路。建议每道题都用 vector 而不是定长数组来写,体会容器带来的便利。

1
P1554
梦中的统计入门
2
P5015
标题统计入门
3
P1598
垂直柱状图普及-
4
P5727
【深基5.例3】冰雹猜想入门
5
P5728
【深基5.例5】旗鼓相当的对手入门
6
P5731
【深基5.习6】蛇形方阵入门
7
P5732
【深基5.习7】杨辉三角入门
8
P2670
[NOIP2015 普及组] 扫雷游戏普及-
9
P1789
【Mc生存】插火把普及-
10
P1304
哥德巴赫猜想入门
11
P1217
[USACO1.5] 回文质数 Prime Palindromes普及-
12
P3799
妖梦拼木棒普及-