第十三章讲的是"不要再自己造轮子"。 一句话串起来:容器(13.1 / 13.2)是"装数据的盒子",算法(13.3)是"往盒子上干活的手法",迭代器(13.4)是"两者的通用插头"。三者拼起来的结果是:你写业务代码时可以只关心"我要干什么",而不用关心"数组多大、内存怎么管、排序怎么写"。
整章最重要的一条因果链:内存怎么摆 → 迭代器是哪一级 → 能用哪些算法。 所以 "选容器"这个决定,会一路决定到"你能不能 sort"。
收束整章。它是全课程里"最实用"的一章 ——前面十二章你学的所有东西(类、模板、运算符重载、指针),在这一章变成了"用别人造好的巨型工具箱"。
往回想:第十二章的模板是这一章的地基(vector<int> 是类模板、sort 是函数模板);第十章的类是"vector 里装的对象";第十一章的运算符重载是"让自定义类能被 sort 排";第七章的指针是"迭代器的原型"。
往前看:13.4.5 那张表里"vector 中间插删要搬家"这一点,其实是 vector 唯一的痛点;而标准库里还有一个能和 vector 平起平坐的主力容器,这一章只提了名字没展开(string 之外的"能自动扩容的字符串 / 更现代的容器") ——这一笔账也算在了后面。
一、本章知识地图
| 块 | 小节 | 一句话要点 |
|---|---|---|
| 序列容器 | 13.1 | 按"位置"存(有 []、按下标取)。三兄弟的性格:vector(连续内存、尾快)、deque(分段连续、两头快)、list(链表、中间快但不能 []) |
vector |
13.1.1(含实例01) | 默认就该用它。size / capacity / reserve 的区别;[] 不检查、at() 会抛 out_of_range;扩容成倍(O(n) 摊还);erase 传迭代器不传下标;循环里删元素要处理好 i |
deque |
13.1.2 | 两头 push/pop 都是 O(1)(vector 头插是 O(n),差三个数量级);但没有 capacity / reserve;数据不是一整块,不能对接 C 接口;stack / queue 的底层默认就是它 |
list |
13.1.3 | 任意已知位置插删 O(1),但没有 []、不能 +n、不能用 std::sort;独有技能 splice(摘节点不复制)、merge、remove(真删)、unique(只合并相邻);优势的前提是"你已经拿着那个位置的迭代器" |
| 关联式容器 | 13.2 | 按"键"存(元素自动排序、查找 O(log n))。表格:set / multiset(只存键)、map / multimap(键+值);带 multi- 的允许重复 |
set / multiset |
13.2.1 / 13.2.2(含实例02) | set:自动去重 + 自动升序;元素不能改(要 erase + insert);insert 返回 pair<iterator,bool>;erase(键) 在 multiset 里会删一片(只删一个要 erase(find(x)));equal_range 一次拿到"一段相同的键" |
map / multimap |
13.2.3 / 13.2.4 | m[k] 在键不存在时会"偷偷插入一个默认值"(查询必须用 find);元素是 pair<const K, V>(键不能改);map 只能按键排(按值排要搬到 vector);multimap 没有 operator[](语义有歧义);"一对多"常常 map<K, vector<V>> 更好用 |
| 算法总览 | 13.3 | 统一姿势:算法名(first, last [, 额外参数]);区间左闭右开;四类:非修正 / 修正 / 排序 / 数值;带 _if 的配条件(普通函数 / 函数对象 / lambda) |
| 非修正序列算法 | 13.3.1(含实例03、04) | 只看不改:find / find_if / count / count_if / adjacent_find(只找"相邻"的重复) / for_each / all_of / equal / max_element |
| 修正序列算法 | 13.3.2(含实例05、06) | 会改数据:copy / fill / replace / remove(不删!只把要留的挪前面) / reverse / random_shuffle(C++14 弃用、C++17 移除 → 用 shuffle) / partition(不保序)/ stable_partition(保序) / unique(只合并相邻) |
| 排序算法 | 13.3.3(含实例07) | sort(不稳定)/ stable_sort(稳定);partial_sort(前 N 名)/ nth_element(中位数,O(n));binary_search / lower_bound / upper_bound(必须已排序);比较器必须严格弱序(写 < 不写 <=);sort 要随机访问迭代器 |
| 数值算法 | 13.3.4(含实例08) | 在 <numeric>(不在 <algorithm>!):accumulate / inner_product / partial_sum / adjacent_difference;初值的类型 = 结果的类型(初值给 0 会让 double 数据被截断) |
| 迭代器 | 13.4 | 五级阶梯:输出(只写)/ 输入(只读)/ 前向(只 ++)/ 双向(++ --)/ 随机访问(+n [] <);等级决定能用的算法 |
| 输出 / 输入迭代器 | 13.4.1 / 13.4.2(含实例09) | ostream_iterator(往屏幕写)、back_inserter(自动 push_back —— 解决 copy 到空容器崩溃);istream_iterator(vector<int> v(in, eof); 一句读完);两者都是单遍 |
| 前向 / 双向 / 随机访问 | 13.4.3 / 13.4.4 / 13.4.5 | 前向:forward_list、unordered_*;双向:list / set / map(能用 reverse,不能 sort);随机访问:vector / deque / string / 数组(能用 sort / binary_search);next / prev / advance / distance 是"走"的通用替代 |
二、必须记住的十个结论
- 默认用
vector。90% 的场合它都是最好的选择(连续内存、缓存友好、能随机访问)。只有明确知道"瓶颈在中间插删"或"两头进出"时才换。 - 选容器问一句话:"我的数据主要在哪一端动?" —— 尾 →
vector;两头 →deque;中间 →list;按值找 →set/map。 erase-remove惯用法必须背下来:v.erase(remove(v.begin(), v.end(), x), v.end());—— 因为remove不删元素,只把要留的挪到前面,返回新的逻辑末尾。unique也是同一个脾气(还要先sort)。map的m[k]会"偷偷插入"。只想查询必须用find(或at,它会抛异常)。写if (m[k] != 0)是错的——每次判断都会往容器里塞一个。set/map的元素"键"不能改(是排序依据,const)。要改就erase+insert。- 算法的调用姿势是"一对迭代器 + 额外参数",区间左闭右开
[first, last)。算法不认识容器 —— 所以sort(arr, arr+n)对裸数组也能用。代价:算法改不了容器大小(要扩容就用插入迭代器)。 - "迭代器等级"决定"能用的算法":
sort/binary_search/random_shuffle要随机访问(list/set/map用不了);reverse要双向;find/count只要输入迭代器(哪都能用)。 accumulate的初值类型就是结果类型(在<numeric>)。要浮点结果,初值写0.0(初值0会让每一步都截断)。sort的比较规则必须"严格弱序":写<或>,绝不能写<=/>=(会造成越界、崩溃或结果错乱)。同样适用于set的比较器和所有_if条件。- "会用"占 90%,"会写"占 10%。现实里写代码:
vector+sort+find+map就能解决绝大多数问题。 自己写容器/算法是"造工具箱"的活(第十二章说的"学习顺序:会用 → 会定制 → 会写")。
三、动手 Demo:两关自测
第一关(写代码):一个"班级成绩管理"
要求:只用本章的东西,做出一个能跑的小系统。
| 功能 | 该用什么 | 对应小节 |
|---|---|---|
| 存一组学生成绩 | vector<Student> |
13.1.1 |
| 按分数排名(同分按名字) | sort + lambda |
13.3.3 |
| 算总分 / 平均 | accumulate(注意初值给 0.0) |
13.3.4 |
| 找出最高最低 | max_element / min_element |
13.3.1 |
| 统计各分数段人数 | map<string,int> 或 count_if |
13.2.3 / 13.3.1 |
| 按名字查某人的成绩 | map<string,int>(O(log n)) |
13.2.3 |
| 去掉重复的分数(看有哪些档次) | set<int> |
13.2.1 |
| 删掉所有不及格的 | erase-remove 惯用法 | 13.3.2 |
参考实现(自己先写,写不出来再看):
#include <iostream>
#include <vector>
#include <map>
#include <set>
#include <string>
#include <algorithm>
#include <numeric>
using namespace std;
struct Student {
string name;
int score;
Student(const string &n, int s) : name(n), score(s) {}
};
void line(const char *t) { cout << "── " << t << " ──" << endl; }
int main() {
vector<Student> cls = {
Student("小明", 88), Student("小红", 95), Student("小刚", 71),
Student("小丽", 88), Student("小强", 100), Student("小敏", 55)
};
// ① 原始名单
line("原始名单");
for (const Student &s : cls) cout << s.name << "(" << s.score << ") ";
cout << endl;
// ② 排名(分数降序,同分按名字)
vector<Student> rank = cls;
sort(rank.begin(), rank.end(), [](const Student &a, const Student &b) {
if (a.score != b.score) return a.score > b.score;
return a.name < b.name;
});
line("排行榜");
int no = 1;
for (const Student &s : rank) cout << no++ << ". " << s.name << "(" << s.score << ") ";
cout << endl;
// ③ 总分 / 平均(★ 初值 0.0)
double total = accumulate(cls.begin(), cls.end(), 0.0,
[](double acc, const Student &s) { return acc + s.score; });
cout << "总分 " << total << ",平均 " << total / cls.size() << endl;
// ④ 最高最低
auto hi = max_element(cls.begin(), cls.end(),
[](const Student &a, const Student &b) { return a.score < b.score; });
auto lo = min_element(cls.begin(), cls.end(),
[](const Student &a, const Student &b) { return a.score < b.score; });
cout << "最高 " << hi->name << "(" << hi->score << "),"
<< "最低 " << lo->name << "(" << lo->score << ")" << endl;
// ⑤ 分数档次(set 自动去重排序)
set<int> levels;
for (const Student &s : cls) levels.insert(s.score);
cout << "出现过的分数档次(去重+排序):";
for (int x : levels) cout << x << " ";
cout << endl;
// ⑥ 分数段人数(count_if)
line("分数段");
int seg[5] = {0};
const char *names[] = {"90+", "80-89", "70-79", "60-69", "<60"};
for (const Student &s : cls) {
if (s.score >= 90) seg[0]++;
else if (s.score >= 80) seg[1]++;
else if (s.score >= 70) seg[2]++;
else if (s.score >= 60) seg[3]++;
else seg[4]++;
}
for (int i = 0; i < 5; i++) cout << names[i] << ": " << seg[i] << " 人 ";
cout << endl;
// ⑦ 按名字查成绩(map:O(log n))
map<string, int> lookup;
for (const Student &s : cls) lookup[s.name] = s.score;
line("按名字查");
for (const string &who : {"小红", "小强", "不存在"}) {
auto it = lookup.find(who); // ★ 必须用 find(不能用 [])
if (it != lookup.end()) cout << who << " 的成绩是 " << it->second << endl;
else cout << who << ":查无此人" << endl;
}
// ⑧ 删掉不及格的(erase-remove 惯用法)
cls.erase(remove_if(cls.begin(), cls.end(),
[](const Student &s) { return s.score < 60; }), cls.end());
line("删掉不及格后");
for (const Student &s : cls) cout << s.name << "(" << s.score << ") ";
cout << " 共 " << cls.size() << " 人" << endl;
return 0;
}
看到什么算成功:
── 原始名单 ──
小明(88) 小红(95) 小刚(71) 小丽(88) 小强(100) 小敏(55)
── 排行榜 ──
1. 小强(100) 2. 小红(95) 3. 小明(88) 4. 小丽(88) 5. 小刚(71) 6. 小敏(55)
总分 497,平均 82.8333
最高 小强(100),最低 小敏(55)
出现过的分数档次(去重+排序):55 71 88 95 100
── 分数段 ──
90+: 2 人 80-89: 2 人 70-79: 1 人 60-69: 0 人 <60: 1 人
── 按名字查 ──
小红 的成绩是 95
小强 的成绩是 100
不存在:查无此人
── 删掉不及格后 ──
小明(88) 小红(95) 小刚(71) 小丽(88) 小强(100) 共 5 人
这一关用到了整章的每一条主线。如果 ⑧ 那句 erase(remove_if(...), end()) 你能默写出来,说明这一章最"要命"的那个惯用法已经进肌肉了。
加分实验:
- 把 ③ 的初值从
0.0改成0→ 平均变成 82(整数除法)。亲眼确认一次"初值类型"这件事。 - 把 ⑦ 的
find改成lookup[who]→ "不存在"那个会输出 0,而且lookup.size()多了一个。map::operator[]的坑。 - 把 ⑤ 的
set改成vector+sort+unique→ 结果一样,体会"两种去重手段"(set保持有序;sort+unique一次性开销)。 - 把
vector<Student>换成list<Student>→sort那句编译不过,得改成cls.sort(比较器),而且cls.erase(remove_if(...))那套也要换成cls.remove_if(...)。这就是 13.4.5 讲的"等级"的真实代价。
第二关(找错):十个判断,逐个说明对错与原因
| # | 代码 / 说法 | 对/错 | 为什么 |
|---|---|---|---|
| ① | vector<int> v(5); cout << v[10]; |
错 | 越界(未定义行为)。v[10] 不检查;要检查写 v.at(10)(抛 out_of_range) |
| ② | vector<int> v; copy(a.begin(), a.end(), v.begin()); |
错 | v.begin() 在空容器里就是 end(),没位置可写 → 崩。要用 back_inserter(v) |
| ③ | for (int i = 0; i < v.size(); i++) |
⚠️ 不推荐 | size() 是 size_t(无符号),会有比较警告;写成 size_t i。若写成 i <= v.size() - 1,空容器时会下溢成死循环 |
| ④ | map<string,int> m; if (m["x"] > 0) ... |
错 | m["x"] 会插入一个 x -> 0,容器被改了。要用 find |
| ⑤ | map<string,int> m; for (auto &p : m) p.first = "y"; |
错 | 键是 const(pair<const string,int>),报 assignment of read-only。要改键就 erase + insert |
| ⑥ | vector<int> v; v.erase(remove(v.begin(), v.end(), 5), v.end()); |
对 | 这就是 erase-remove 惯用法。如果只写 remove(...),size() 不会变 |
| ⑦ | list<int> l; sort(l.begin(), l.end()); |
错 | list 的迭代器是双向,而 sort 要随机访问 → no match for 'operator-'。要用 l.sort() |
| ⑧ | accumulate(v.begin(), v.end(), 0)(v 是 vector<double>) |
错(且不报错) | 初值 0 是 int → 每步都截断。要写成 0.0。这是本章最"安静"的一个错 |
| ⑨ | set<int> s; *s.begin() = 5; |
错 | set 的元素是 const(它是排序依据)。要改就 erase + insert |
| ⑩ | unordered_map<string,int> m; sort(m.begin(), m.end()); |
错 | 哈希表的迭代器是前向的(而且元素只读)。它根本不有序,也不能排序 |
30 秒实验:把 ① ② ④ ⑤ ⑦ ⑨ ⑩ 逐个在编译器里敲一遍,认一下每种报错(或崩溃)的样子。
- 重点看 ⑧:它不报错,只是结果错 —— 所以"数值计算一定要跑一遍看结果",这一条比记住语法更重要。
- 第 ③ 条要单独记:它不是"编译错误",而是"隐患"(空容器 +
<= size()-1)。这一条在真实项目里制造过无数 bug。
四、常见误区(本章汇总)
- "我自己写循环也行,何必用算法"——行,但更容易错。
remove/unique/accumulate这些"看起来简单"的循环里藏着不少坑(初值类型、越界、单遍)。算法是"被无数人检视过的循环"。 - "STL 慢"——恰恰相反:
sort是精心调优的混合排序、vector的扩容是摊还 O(1)、二分查找是 O(log n)。 慢通常是因为"选错了容器/算法",而不是"用了 STL"。 - "
vector一定要用,list是过时的东西"——不是过时,是专用。"已知位置频繁插删"的场景list是不可替代的(还有splice这种独门技能)。只是这类场景比想象中少。 - "关联式容器比序列容器高级"——只是用法不同:按位置操作 → 序列;按值查找 → 关联。
vector按下标遍历比map快得多(缓存友好)。 - "算法和成员函数哪个好,随便"——要看名字后面的东西:
list::remove真删、std::remove不删;list::sort能用、std::sort不能用;set::find是 O(log n)、std::find是 O(n)。"同名不同行为"是 STL 里最需要小心的一类陷阱。 - "迭代器就是指针,一样用"——接口像,能力差很远。能不能
--、能不能+n、能不能比大小,决定了它能用哪些算法(这是本章最主要的一句话)。 - "模板报错太难懂,STL 没法用"——报错长是因为模板层层展开。技巧:先看"你写的哪一行",再看"报错里有没有
no match for/is not a member of",最后看"最后一条 note"(12.4 小结说的)。本章那些"❌"的例子,报错脸都认一认,以后看到就知道是哪一类问题。 - "学了 STL 就不用懂数据结构了"——反过来:不知道"链表不能随机访问",就不明白
list为什么没有[];不知道"哈希 vs 树",就选不出map还是unordered_map。 STL 是"数据结构的成品",但你得知道每个成品适合什么场合。
五、写完这一章,你会什么了
这一章之后,你的代码"上一档次"了 ——因为你不再写那些"和业务无关、却必须写对"的样板代码:
- 不用再写"数组够不够大"(
vector/deque/list自己长); - 不用再写"排序"(
sort+ lambda); - 不用再手写"找一个值"(
find/map/set); - 不用再手写"删掉所有符合条件的"(
erase+remove_if); - 不用再手写"求和求平均求最大"(
accumulate/max_element); - 不用再手写"去重、分组、打乱"(
set/partition/shuffle); - 不用再手写"读一串数据存起来"(
istream_iterator); - 能看懂标准库里那些吓人的类型名:
std::vector<int, std::allocator<int>>—— 知道多出来的都是默认参数(12.2.3)。
同时拿到三张能一直用的"判断表":
| 判断 | 出处 | 一句话 |
|---|---|---|
| "这个容器能切片吗?" | 13.4.5 | 看内存怎么摆 —— 连续/分段连续 → 能随机访问 → 能用 sort;链表/树/哈希 → 不能 |
| "名字一样,行为一样吗?" | 13.1.3 / 13.3.2 | list::remove 真删、std::remove 不删 —— 成员版和算法版要分开记 |
| "这个错是"编译错"还是"安静错"?" | 13.3.4 / 13.2.3 | accumulate 初值、map[] 偷偷插入 —— 这类错必须靠"跑一遍看结果"来抓 |
自测十问(点开对答案)
1. STL 的三个零件是什么?一句话说清它们的关系。 —— **容器(装数据的盒子)+ 算法(干活的手法)+ 迭代器(通用插头)**。**算法只认迭代器、不认识容器**,所以一份 `sort` 能服务所有"迭代器等级够"的容器(以及裸数组)。 2. 三个序列容器各快在哪?怎么选? —— **`vector`:连续内存,按下标取 / 尾部加 / 遍历最快**;**`deque`:两头插删 O(1),也有 `[]`**;**`list`:已知位置插删 O(1),但没有 `[]`、不能 `sort`**。**判断标准:"数据主要在哪一端动?" 无特殊情况默认 `vector`。** 3. `remove` 为什么"不删元素"?正确的删除写法是什么? —— 因为**算法只拿到两个迭代器,改不了容器大小**。它**把要留的挪到前面,返回新的逻辑末尾**。**正确写法(erase-remove 惯用法):`v.erase(remove(v.begin(), v.end(), x), v.end());`** 条件版用 `remove_if`。 4. `map` 的 `m[k]` 有什么坑?查询该用什么? —— **键不存在时会插入一个默认值再返回引用**(容器被改了)。**查询必须用 `find`**(或 `at`,越界抛异常)。**`if (m[k] != 0)` 是错的。** 5. `set` / `map` 的元素为什么不能改? —— **键是排序依据**(`set` 的元素、`map` 的 `first` 都是 `const`)。**改它会破坏有序性**。**要改:`erase` + `insert`。** 6. 迭代器五个等级是什么?等级由什么决定? —— **输出(只写)/ 输入(只读)/ 前向(只 `++`)/ 双向(`++ --`)/ 随机访问(`+n [] <`)**。**由内存布局决定**:连续/分段连续 → 随机访问;链表 → 双向/前向;树 → 双向;哈希 → 前向。 7. 为什么 `list` 不能 `std::sort`? —— **`sort` 要随机访问**(快排/堆排要跳着选主元和交换),**`list` 的迭代器是双向的**("跳 n 步"要 O(n))。**用成员 `l.sort()`**(归并排序,只需往前挪)。 8. `accumulate` 在哪个头文件?初值有什么讲究? —— **`- ☑ 第十四章 RTTI与异常处理 —— 已完结:小结(RTTI 三件套 + 四种转换运算符 + 异常三关键字 + 栈展开与
std::exception家族)
相关笔记
- 序列容器 —— 本章起点:三个"按位置排队"的盒子
- 向量类模板(含实例01) —— 默认选择
vector - 链表类模板 —— 三兄弟对比的收尾
- set类模板(含实例02) —— 去重 + 排序
- map类模板 —— 最常用的关联式容器
- 修正序列算法(含实例05、06) —— erase-remove 惯用法
- 排序算法(含实例07) ——
sort与二分查找 - 数值算法(含实例08) ——
accumulate初值的坑 - 随机访问迭代器 —— 五级阶梯的顶端("能不能
sort"的答案) - 小结 —— 上一章知识地图:"把类型变成参数"
- RTTI —— 下一章起点:运行期问类型
- 小结 —— 下一章知识地图:RTTI 与异常处理
- 🔷 C++基础 · 索引 —— 全课路线图