对应教材 :\[A Tour of C 3rd Edition-2022.pdf\|A Tour of C++ 3rd] 第 9、12、13、14、16 章(主线)· 10、11、15、17 章(按需)· \[Programming Principles and Practice Using C++ 3rd Edition-2024.pdf\|PPP 3rd] ch19--21(练手) 阶段定位 :标准库课。这是收获感最强的一段------你终于能扔掉一大半手写循环 。学完这一课,你阶段二那个手写线性查找 + 手写计数的
WordCounter,将被std::map和几个标准算法取代,代码量砍掉 60%。 前置 :\[阶段四-模板与泛型\|阶段四](模板与泛型)------你现在知道vector<T>、算法、迭代器都是模板,知道 lifting。STL 就是把 lifting 做到极致的结果。 编译验证 :本讲义所有代码均用g++ -std=c++20 -Wall -Wextra实际编译运行验证过。
0. 本课地图
这一课解决三个问题:
- STL 是什么? ------ 三个东西组成一个三角:容器 (存数据的结构)、算法 (对数据做的操作)、迭代器 (算法与容器之间的桥)。算法只管
[begin, end)这对迭代器,不知道容器是谁;容器只管提供 begin/end,不知道算法在做什么。 - 怎么选容器? ------ 选容器的第一问:随机访问,还是插入删除? 随机访问 →
vector;频繁中间插入 →list;按键查 →map/unordered_map。默认永远是vector。 - 怎么把代码写得像 STL? ------ 思维转变:从"我该写哪个循环"变成"我该用哪个动词" 。学算法 = 学动词:
find、sort、count、accumulate、transform、copy。再加 C++20 的 ranges 管道 :v | views::filter(pred) | views::transform(fn),从左往右读,和英文一致。
为什么 STL 值得单独一课? 因为它救过 C++ 的命。1998 年 C++98 标准化时,Alexander Stepanov 的 STL 进了标准库------\[C++之父交谈录#二、C++ 的历史与演化\|没有 STL,C++ 大概率会变成一门慢慢死掉的 OO 语言]。STL 的架构三根支柱(容器/迭代器/算法)从 1994 年交出来就没变过。这一课学的,是那套让 C++ 活下来的设计。
贯穿全课的主线 :你阶段二的词频统计工具 WordCounter 从 v0.8 重构成 STL 风格:
- v1.0 (第八讲):
std::map<std::string,int>直接取代WordCounter。add()里的手写线性查找 变成++freq[w]一行;按次数排序交给std::sort。 - v1.1 (第七讲):停用词过滤交给
std::erase_if,输出交给 ranges 管道。 - 代码量对比:v0.8 三个文件约 45 行(含两个手写循环)→ v1.1 约 20 行,查找、计数、排序、过滤全部零手写循环。
| 讲 | 章节 | 关键产出 | 主线进展 |
|---|---|---|---|
| 一 | §9 库总览 | STL 三角 + 标准库地图 | 看地图,再进丛林 |
| 二 | §12 容器 | 容器家族、选容器第一问 | v0.8 的 vector<Word_freq> 该不该换? |
| 三 | §13.1--13.2 算法是动词 | sort/find/count/accumulate/transform |
认识要用的动词 |
| 四 | §13.4 谓词 | find_if/count_if/lambda |
停用词过滤变成谓词 |
| 五 | §13.5+12.8 erase+remove_if | 真正的删除 | 停用词从容器里删掉 |
| 六 | §13.3.1 流迭代器 | istream_iterator/ostream_iterator |
读文件排序去重,零循环 |
| 七 | §14 ranges | 管道 + 懒求值 | v1.1 终极形态 |
| 八 | §16+15 工具 | span/string_view/optional/chrono |
v1.0 :map 词频统计 |
一句话总纲 (A Tour §13.5 原话):"标准库算法往往比平均水平的手写循环设计得更仔细、规定得更明确、实现得更好。认识它们,并用它们代替裸语言写出的代码。"
第一讲:先看地图再进丛林------STL 三角与标准库地图(A Tour §9)
是什么
A Tour §9.2 把标准库分成了两大类:
- STL (Stepanov 1994):容器(vector、map...)+ 算法(find、sort、merge...)+ 迭代器。这个框架可扩展------你可以往里面加自己的容器和算法。
- 其他部分 :字符串、正则、I/O、文件系统、并发(线程/锁)、数值、
variant/optional等工具、时间(chrono)、智能指针。
§9.1 的原话值得先立住:
"标准库规范占 ISO C++ 标准的三分之二以上。探索它,并用它代替自制的东西。它的设计倾注了大量思考,实现倾注了更多,维护和扩展还将倾注大量努力。"
为什么
- 先看地图,再进丛林 (教案原话)。你不需要一次学完所有东西,但要先知道"有这么个东西、管什么用、在哪个头文件"。地图的意义是:遇到问题时知道标准库有解,而不是自己造轮子。
- §9.2 讲了收录标准库的三个标准:"几乎每个 C++ 程序员都用得上"、"比简化版本不增加显著开销"(零开销)、"简单用法易学"。这三个标准就是你判断"该不该用库"的准则。
- 不用裸语言写东西 :§9.1 "没有重要程序只用裸语言写成。先用库打好地基。" 你已经见识过------阶段二的手写线性查找,换成
++freq[w]一行。 - 一句历史注脚 :我在\[C++之父交谈录#三、背后的决策逻辑\|决策逻辑里承认过]------早期没做好基础库是 C++ 的一个失败,"语言只是半成品,共享地基比语言特性更重要"。标准库就是那个"共享地基"。
代码逐行
STL 三角第一次完整亮相(A Tour §13.1 的例子):
cpp
// ex1_stl_triangle.cpp ------ STL 三角:容器 + 算法 + 迭代器
#include <algorithm>
#include <iostream>
#include <list>
#include <string>
#include <vector>
struct Entry {
std::string name;
int number;
};
bool operator<(const Entry& x, const Entry& y) { return x.name < y.name; }
bool operator==(const Entry& x, const Entry& y) { return x.name == y.name; }
int main() {
std::vector<Entry> vec {
{"Karl Popper", 234567},
{"David Hume", 123456},
{"Bertrand Russell", 345678},
{"David Hume", 123456}, // 重复
};
std::list<Entry> lst;
std::sort(vec.begin(), vec.end()); // 算法 sort
std::unique_copy(vec.begin(), vec.end(),
std::back_inserter(lst)); // 去重后拷进 list
for (const auto& e : lst) // 迭代器/范围 for
std::cout << e.name << " " << e.number << '\n';
}
operator<和operator==是Entry自己的比较能力 ------算法不关心 Entry 是什么,只要求"能用<和=="。这是\[阶段四-模板与泛型\|阶段四] lifting 思想的回报。std::sort(vec.begin(), vec.end()):排序[begin, end)这个半开区间。std::unique_copy(begin, end, std::back_inserter(lst)):把"不重复的元素"拷进lst。back_inserter(lst)是输出迭代器------它让unique_copy能给容器添加元素(见第五讲:算法本身不改容器大小,back_inserter 是例外通道)。- 输出:按名字排好序、去重后的三行(
Bertrand Russell、David Hume、Karl Popper)。
标准库地图(§9.3.4 精选头文件,先混个脸熟):<vector>、<list>、<map>、<set>、<unordered_map> 容器;<algorithm> 算法;<ranges> ranges;<string>/<string_view> 字符串;<iostream>/<fstream>/<sstream> I/O;<memory> 智能指针;<optional>/<variant>/<tuple> 工具;<chrono> 时间;<thread> 并发;<filesystem> 文件系统;<regex> 正则;<random> 随机。
常见坑
- ❌ 不 #include 就用。标准库设施都要通过头文件引入(§9.3.1)。报
xxx is not a member of std先查头文件。 - ❌
using namespace std;图省事。本书教学里可以用(§9.3.1 说"通篇用标准库时无伤大雅"),但你自己的工程里这是坏品味------名字会打架。 - ❌ 想要"标准库的 range 版"却忘了显式写
ranges::。§9.3.2:std::sort(v)会报"没有匹配函数";要么ranges::sort(v),要么using ranges::sort;。两种风格并存是历史包袱(98 年的 STL 没有 range 版)。
教材指引(可跳转)
- \[A Tour of C 3rd Edition-2022.pdf\|A Tour] §9.1--9.2(必读):标准库是什么、收录标准、完整组件清单(建立地图)。
- \[A Tour of C 3rd Edition-2022.pdf\|A Tour] §9.3(必读):命名空间 std、ranges 命名空间、头文件总表。
- \[The C++ Programming Language 4th Edition-2013.pdf\|TCPL] §14--16(查证):标准库总览。
- \[C++之父交谈录#六、从 C++98 到现代 C++:迁移指南\|交谈录:STL 的整体地图](重读):三根支柱的完整论述 + 容器家族表 + 迭代器五类。
- ⏭️ \[阶段四-模板与泛型\|阶段四]衔接 :STL 算法就是阶段四
my_find/my_count_if的"工业完成版"------你现在终于可以直接用标准库了。
第二讲:容器家族与选型(A Tour §12)
是什么
A Tour §12.8 的容器总表:
| 容器 | 是什么 | 迭代器类别 |
|---|---|---|
vector<T> |
变长数组,连续内存 | contiguous(随机访问) |
string |
字符序列 | contiguous |
array<T,N> |
固定长度连续内存 | contiguous |
deque<T> |
双端队列 | random_access |
list<T> |
双向链表 | bidirectional |
forward_list<T> |
单向链表 | forward |
map<K,V> |
按键有序(红黑树) | bidirectional |
unordered_map<K,V> |
哈希表,无序 | forward |
set<T> / multiset / multimap / unordered_* |
集合与变体 | 同 map 系 |
选容器第一问(教案原文) :随机访问,还是插入删除? 随机访问 → vector;频繁中间插入 → list;按键查 → map/unordered_map。默认永远是 vector。
为什么
A Tour §12.2 的原话(值得背):
"标准库
vector非常灵活高效。把它当默认容器 ------除非你有确凿的理由用别的。如果你因为对'效率'的模糊担忧而避开 vector,去测量。我们对容器使用性能的直觉是最靠不住的。"
选容器真正的权衡,不是"哪个快",而是你用什么操作:
- 你要按下标访问、遍历、排序 → vector(连续内存 = 缓存友好)。
- 你要在中间插入/删除 且元素很大 → list(链表原地改指针,不移动元素)。但 §12.3 警告:"vector 在短序列、小元素上往往比 list 快------即使插入删除也是。"
- 你要按键查 → map 是红黑树 O(log n);数据量大、只查不改序 →
unordered_map哈希表平均 O(1)。§12.6:map 百万元素只需约 20 次比较。
记忆锚点 :
vector就是\[阶段一-立世界观\|阶段一]那个MiniVec------三个指针[p:q:cap)、24 字节、内存连续。它快不是因为它"聪明",是因为内存连续,缓存里连预取都替你做好了。选容器先想清楚你的操作,这是"边界谁负责"在数据结构层面的体现。
代码逐行
cpp
// ex2_containers.cpp ------ 容器家族与选型
#include <iostream>
#include <list>
#include <map>
#include <string>
#include <unordered_map>
#include <vector>
int main() {
// 默认永远是 vector:随机访问最快
std::vector<int> vi {3, 1, 4, 1, 5};
std::cout << "vector 随机访问 vi[2] = " << vi[2] << '\n';
// 频繁中间插入/删除:list
std::list<int> li {1, 2, 3};
auto p = li.begin(); ++p; // 指向 2
li.insert(p, 9); // 在 2 前插入 9,不移动其他元素
for (int x : li) std::cout << x << ' ';
std::cout << '\n';
// 按键查:map(红黑树,O(log n))
std::map<std::string, int> phone_book {
{"David Hume", 123456},
{"Karl Popper", 234567},
};
std::cout << "map lookup: " << phone_book["David Hume"] << '\n';
// 大表快查:unordered_map(哈希表,平均 O(1))
std::unordered_map<std::string, int> fast_book {
{"David Hume", 123456},
{"Karl Popper", 234567},
};
std::cout << "unordered_map lookup: " << fast_book["Karl Popper"] << '\n';
}
- 输出:
vector 随机访问 vi[2] = 4、1 9 2 3(9 插到了 2 前面)、map lookup: 123456、unordered_map lookup: 234567。 phone_book["David Hume"]:map 的下标 。A Tour §12.5 有个重要警告:如果键不存在,[]会把键插入 map 并给默认值 0 。查"不该有默认值的键"要用find()/contains(),否则你会在 map 里看到幽灵词条。- 两个重要陷阱(§12.2.1):
vector<Shape> vs;是错的 ------虚函数多态需要指针/智能指针,vector<unique_ptr<Shape>>才对。对象直接放进容器 = 被切片。vector不保证范围检查 ,book[book.size()]是未定义行为;要检查就用at()(会抛out_of_range)。
- 迭代器失效 (§12.2):vector 扩容(
reserve/push_back)会让已有迭代器和指针失效;list 的插入删除则不会。A Tour 的 Advice 5:reserve()用在你需要元素指针稳定的时候。
常见坑
- ❌ "我觉得 list 插入快"------没测量 。§12.3 原话:"vector 在短序列、小元素上常比 list 快,即使 insert/erase 也是。"直觉不可信,A Tour 反复强调:测量。
- ❌ 用
map当"数组"随手[]访问,引入不存在的键。查不到就用contains()/find()。 - ❌ 忘了一开始就问"我要什么操作"。随机访问 vs 插入删除 vs 按键查------先回答这个问题,再谈容器。
- ❌ 把多态对象直接放进容器。
vector<Shape>是切片,用vector<unique_ptr<Shape>>。
教材指引(可跳转)
- \[A Tour of C 3rd Edition-2022.pdf\|A Tour] §12.1--12.6(必读):vector(含 capacity/reserve/push_back 实现)、list、forward_list、map、unordered_map。
- \[A Tour of C 3rd Edition-2022.pdf\|A Tour] §12.8 (必读):容器总表 + 通用操作表 + 30 条 Advice(这是你"背表"的来源)。
- \[The C++ Programming Language 4th Edition-2013.pdf\|TCPL] §31--32(查证):容器与容器设计。
- ⏭️ 主线 :你的
WordCounter内部是vector<Word_freq>,add()每次 O(n) 线性查找。读完第三讲你会知道:这活该交给 map。
第三讲:算法是"动词"(A Tour §13.1--13.2)
是什么
算法 = 泛型函数。A Tour §13.5 的表是核心动词清单,先记这 8 个:
erlang
find(b,e,x) 在 [b,e) 里找 x,返回迭代器(找不到返回 e)
find_if(b,e,f) 找第一个满足谓词 f 的元素
count(b,e,x) 数一数等于 x 的有几个
count_if(b,e,f) 数一数满足 f 的有几个
sort(b,e) 排序
accumulate(b,e,v) 从 v 开始累加(<numeric>)
transform(b,e,out,f) 把 f 作用到每个元素,结果写进 out
copy(b,e,out) 拷贝
for_each(b,e,f) 对每个元素执行 f
为什么
A Tour §13.2 讲透了 STL 的设计哲学:
"迭代器把算法和容器分开。 算法通过迭代器操作数据,不知道元素存在哪个容器里;反过来,容器不知道有算法在操作它的元素------它只负责在请求时提供迭代器(比如 begin 和 end)。这种存储与算法的分离,造就了非常通用灵活的软件。"
这就是\[阶段四-模板与泛型\|阶段四]"算法只认迭代器"的完整体现。所以:
- 写代码的思维要转变:从"我该写哪个循环"变成"我该用哪个动词"(教案原话)。
find返回end()表示"没找到" (§13.2):空手而归不用发明特殊值------你在阶段四的my_find里已经用了这个约定,现在用标准库同款。- 一段手写循环 = 一个算法调用。你越会"动词",循环越少。
零开销的实证 :
std::sort对int调用operator<是编译期定好的直接调用 (内联展开);C 的qsort收函数指针,每次比较都得call一次。实测-O2反汇编:std::sort+ lambda 的比较被内联成一条cmpl指令散在排序循环里,而qsort每次比较都跳一次指针------这就是std::sort永远比qsort快、而代码还能更短的原因(\[C++之父交谈录#四、五个根本技术\|交谈录原话])。
代码逐行
cpp
// ex3_verbs.cpp ------ 算法是"动词"
#include <algorithm>
#include <iostream>
#include <numeric>
#include <vector>
int main() {
std::vector<int> v {3, 1, 4, 1, 5, 9, 2, 6};
auto it = std::find(v.begin(), v.end(), 5); // 找:返回迭代器
std::cout << "找到了 5,在 " << (it - v.begin()) << " 位\n";
int n = std::count(v.begin(), v.end(), 1); // 数:等于 1 的个数
std::cout << "1 出现 " << n << " 次\n";
int sum = std::accumulate(v.begin(), v.end(), 0); // 加:从 0 开始累加
std::cout << "sum = " << sum << '\n';
std::vector<int> sq;
std::transform(v.begin(), v.end(), std::back_inserter(sq),
[](int x) { return x * x; }); // 变换:每个元素平方
std::cout << "前三个平方: ";
for (int i = 0; i < 3; ++i) std::cout << sq[i] << ' ';
std::cout << '\n';
std::sort(v.begin(), v.end()); // 排序:就地
std::cout << "排序后: ";
for (int x : v) std::cout << x << ' ';
std::cout << '\n';
}
- 输出:
找到了 5,在 4 位、1 出现 2 次、sum = 31、前三个平方: 9 1 16、排序后: 1 1 2 3 4 5 6 9。 it - v.begin():随机访问迭代器支持减法,得到下标。list 的迭代器不能这么减(阶段四讲过)。std::accumulate(v.begin(), v.end(), 0):第三个参数是初值 ,决定累加类型。传0是 int 累加,传0.0是 double 累加------阶段四sum2(vi, 0.0)的同款设计。std::transform(..., std::back_inserter(sq), ...):输出迭代器让结果追加进 sq。- range-for 与迭代器等价 (§13.1):
for (auto& x : v)就是编译器对for (auto p = v.begin(); p != v.end(); ++p)的简写,且"更简单、更不易错、往往更快"。
常见坑
- ❌ 循环里就为了找/数/累加,手写。先想动词:这是 find?count?accumulate?
- ❌ 忘了"找不到返回 end()"的约定,去检查返回值是不是别的东西。
- ❌ 把
accumulate的初值写错类型(0vs0.0),导致精度丢失------初值决定类型。 - ❌
transform的目标写进已有容器却没预留空间。用back_inserter或resize。
教材指引(可跳转)
- \[A Tour of C 3rd Edition-2022.pdf\|A Tour] §13.1(必读):Introduction + sort/unique_copy 例子 + back_inserter。
- \[A Tour of C 3rd Edition-2022.pdf\|A Tour] §13.2(必读):find、find_all、迭代器分离容器与算法。
- \[A Tour of C 3rd Edition-2022.pdf\|A Tour] §13.5 (必读):算法总表(这是一页背单词)。
第四讲:谓词------把"规则"做成参数(A Tour §13.4)
是什么
find_if、count_if、sort(f) 这类算法,把规则本身 当作参数。这个参数叫谓词 (predicate):一个"调用后返回 bool"的东西。最常用的写法是 lambda (\[阶段四-模板与泛型\|阶段四]讲过),或用函数对象(阶段四的 Less_than)。
为什么
- 算法关心"怎么找/怎么数/怎么排",规则由你给。A Tour §13.4:"我们常常想把动作做成算法的参数。"
- lambda 是 STL 的转折点 (\[C++之父交谈录#六、从 C++98 到现代 C++:迁移指南\|交谈录原话]):C++98 时代的 STL 有个巨大痛点------想给
find_if传个谓词,得先定义一个命名函数对象类,光那几行类定义比调用还长。于是没人用 STL 算法,都手写循环。C++11 的 lambda 一次性解决了这个采纳问题:
cpp
// C++98:先写一个完整的仿函数类
struct GreaterThan42 {
bool operator()(int x) const { return x > 42; }
};
auto it = find_if(v, GreaterThan42());
// C++11:一行
auto it = find_if(v, [](int x){ return x > 42; });
这不是语法糖,是让 STL 算法第一次值得用。 有了 lambda,sort 的自定义比较、find_if 的谓词、accumulate 的累积规则都能内联地写。
- 谓词不能修改它作用到的元素 (§13.4 原话)------这是约定,也是
predicate概念的定义(§14.5.1)。
代码逐行
cpp
// ex4_predicates.cpp ------ 谓词参数
#include <algorithm>
#include <iostream>
#include <map>
#include <string>
#include <vector>
struct Greater_than { // 函数对象:携带阈值 42
int val;
bool operator()(const std::pair<const std::string, int>& r) const {
return r.second > val;
}
};
int main() {
std::vector<int> v {3, 1, 4, 1, 5, 9, 2, 6};
auto it = std::find_if(v.begin(), v.end(), [](int x) { return x > 4; });
std::cout << "第一个 >4 的是 " << *it << '\n';
int cnt = std::count_if(v.begin(), v.end(), [](int x) { return x % 2 == 0; });
std::cout << "偶数个数: " << cnt << '\n';
std::map<std::string, int> m {
{"a", 1}, {"b", 50}, {"c", 10}, {"d", 99},
};
auto mp = std::find_if(m.begin(), m.end(), Greater_than{42});
std::cout << "第一个 value>42 的键是 " << mp->first << '\n';
}
- 输出:
第一个 >4 的是 5、偶数个数: 3、第一个 value>42 的键是 b。 [](int x) { return x > 4; }:就地谓词。find_if返回第一个让谓词为真的元素。Greater_than{42}:函数对象携带阈值。注意operator()的参数类型是std::pair<const std::string,int>------map 的元素是pair<const Key, Value>,const 别忘了 (阶段四my_count_if不挑类型,这里 map 的约束写死了)。- 两者等价(§13.4 原话):
auto p = find_if(m, [](const auto& r) { return r.second > 42; });
常见坑
- ❌ 谓词里有副作用(改了元素、改了外部状态)。
find_if可能多次调用谓词,且概念predicate要求"等值保持"(§14.5.1)------同一输入两次调用结果必须一致。 - ❌ 为了一个一行规则写个具名类。用 lambda。
- ❌ 忽略
const:map 的 key 是const的,谓词参数类型写错编译不过。
教材指引(可跳转)
- \[A Tour of C 3rd Edition-2022.pdf\|A Tour] §13.4(必读):find_if + Greater_than + lambda 等价写法。
- \[A Tour of C 3rd Edition-2022.pdf\|A Tour] §14.5.1 (查证):
predicate/relation/strict_weak_order概念------语义约定的来源。 - \[C++之父交谈录#六、从 C++98 到现代 C++:迁移指南\|交谈录:lambda 是 STL 的转折点](重读):C++98 的仿函数之痛 → C++11 的一行。
第五讲:算法不改容器大小------erase + remove_if(A Tour §13.5 + §12.8)
是什么
A Tour §13.5 有一条铁律:
"有些算法(如
replace()、sort())会修改元素的值,但没有任何算法会向容器添加或删除元素。原因是一个序列并不指明承载它的容器是谁。"
所以"删除所有满足条件的元素"是两步:
std::remove_if(begin, end, pred):把不删的元素搬到前面,返回"新逻辑末尾"的迭代器。注意:它不删,只是搬。container.erase(新末尾, 原末尾):真正缩小容器。
这个组合叫 erase--remove 惯用法 ,用 v.erase(std::remove_if(v.begin(), v.end(), pred), v.end())。
为什么
- 算法的通用性靠"序列 = 迭代器对":算法只看到一对迭代器,看不到容器,所以没法(也不该)改容器大小。这是第一讲 STL 三角的推论。
- 例外:输出迭代器 。
back_inserter(lst)假装是"迭代器",实际每次写都push_back------它是"容器知道自己是容器"的通道(§13.1)。 - 关联容器(map/set)不能 erase--remove (元素不是连续内存),C++20 给它们专门加了
std::erase_if(container, pred)直接一步删。
代码逐行
cpp
// ex5_erase_remove.cpp ------ 经典组合:erase + remove_if
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<int> v {1, 2, 3, 4, 5, 6, 7, 8};
// remove_if 把"不删的元素"搬到前面,返回新逻辑末尾的迭代器;
// erase 用 [新末尾, 原末尾) 真正缩小容器。
v.erase(std::remove_if(v.begin(), v.end(),
[](int x) { return x % 2 == 0; }),
v.end());
for (int x : v) std::cout << x << ' ';
std::cout << '\n';
}
- 输出:
1 3 5 7。 remove_if之后、erase之前,容器的size()没变------只是"偶数都被移到了末尾",[新末尾, end)是一堆无用的旧值。忘了 erase 是新手最常见的 bug:容器看起来没变小。
map 上的版本(教案"过滤停用词"的正解):
cpp
// ex10_eraseif_map.cpp ------ 关联容器要用 std::erase_if(C++20)
#include <algorithm>
#include <iostream>
#include <map>
#include <string>
int main() {
std::map<std::string, int> freq {
{"the", 40}, {"fox", 10}, {"dog", 5}, {"a", 100}, {"and", 30},
};
std::erase_if(freq, [](const auto& kv) { // 一步删停用词
const auto& w = kv.first;
return w == "the" || w == "a" || w == "and";
});
for (const auto& [w, c] : freq) std::cout << w << ": " << c << '\n';
}
- 输出:
dog: 5、fox: 10(the/a/and 没了)。 - 为什么 map 不能 erase--remove :
remove_if需要把元素"搬到前面",map 是树、元素地址不可搬动。std::erase_if是 C++20 为关联容器特化的版本------直接遍历删除。
常见坑
- ❌ 只
remove_if不erase,然后困惑"为什么没删掉"。两步缺一不可(对序列容器)。 - ❌ 对 map 用
remove_if------编译不过,用std::erase_if。 - ❌ 忘了"算法不加不减元素",试图在
for_each里push_back到正在遍历的容器------迭代器失效、未定义行为。
教材指引(可跳转)
- \[A Tour of C 3rd Edition-2022.pdf\|A Tour] §13.5(必读):算法总表 + "没有算法增删容器元素"的原文。
- \[A Tour of C 3rd Edition-2022.pdf\|A Tour] §13.1(查证):back_inserter 这个"例外通道"。
- \[A Tour of C 3rd Edition-2022.pdf\|A Tour] §12.8(查证):erase/insert 的语义。
第六讲:流迭代器------算法作用于 I/O(A Tour §13.3.1)
是什么
迭代器不只适用于容器。输入流产生一个值序列,输出流接收一个值序列------所以流也可以是"容器":
istream_iterator<T>:把输入流当只读容器。ostream_iterator<T>:把输出流当可写目标。
为什么
- 这样算法就能直接作用在流上:从 cin 读单词 → sort → unique → 写 cout,全程没有一行手写循环。
- §13.3.1 给出了标准库的"思想实验":读文件、排序、去重、写文件 ,先给出 10 行版本,再指出"用 set 压根不存重复项"只需 5 行。这个例子展示了标准库的威力:数据结构选对了,算法自然简单。
ifstream是能接文件的istream,ofstream同理(§11.7.2)------所以这段代码的输入输出换成文件,只改一行。
代码逐行
cpp
// ex6_streamiter.cpp ------ 流迭代器:读单词 -> 排序 -> 去重 -> 输出(零手写循环)
#include <algorithm>
#include <iostream>
#include <iterator>
#include <string>
#include <vector>
int main() {
// 从 cin 读入所有单词
std::vector<std::string> b {std::istream_iterator<std::string>{std::cin},
std::istream_iterator<std::string>{}};
std::sort(b.begin(), b.end());
std::unique_copy(b.begin(), b.end(),
std::ostream_iterator<std::string>{std::cout, " "});
std::cout << '\n';
}
-
输入
the quick fox the quick brown fox,输出brown fox quick the。 -
std::istream_iterator<std::string>{std::cin}:开始迭代器(从 cin 读 string);{}默认构造的是结束迭代器 (哨兵)。{a, b}两个迭代器初始化 vector = "把 cin 里的所有单词读进 b"。这个 vector 构造就是一次读取循环。 -
std::ostream_iterator<std::string>{std::cout, " "}:写到 cout,每个值后面跟一个空格。 -
A Tour 的更优解 :需求是"去重输出",为什么不压根不存重复?用
std::set:cppstd::set<std::string> b {std::istream_iterator<std::string>{std::cin}, std::istream_iterator<std::string>{}}; std::copy(b.begin(), b.end(), std::ostream_iterator<std::string>{std::cout, " "});set 自动有序、自动去重,
unique_copy降级为copy。数据结构选对了,代码自然变短。 -
警告(§13.3.1):
sort(b)这种 range 版和传统sort(b.begin(),b.end())并存可能歧义------要显式ranges::sort(b)或using ranges::sort;。
常见坑
- ❌ 忘记
istream_iterator的默认构造就是结束哨兵 。std::istream_iterator<T>{}表示"流的末尾"。 - ❌ 流迭代器用错类型(比如想读 int 却用 string)。读什么类型模板参数说了算。
- ❌ 用
set时忘了它默认有序去重------这正是选对数据结构让算法变简单的例子。
教材指引(可跳转)
- \[A Tour of C 3rd Edition-2022.pdf\|A Tour] §13.3.1(必读):istream_iterator/ostream_iterator 全文 + set 版本对比。
- \[A Tour of C 3rd Edition-2022.pdf\|A Tour] §11.7.2(查证):ifstream/ofstream------这段代码接文件只改一处。
第七讲:Ranges 管道与懒求值(A Tour §14)
是什么
C++20 的 <ranges> 给了三样东西:
- range 算法 :
std::ranges::sort(v)------整个容器一次传进去,不用begin/end(§14.1 原话:"range 这个概念让我们能写sort(v)而不是 1994 年以来的sort(v.begin(),v.end())")。 - view(视图) :不拥有元素的"观察窗口"。
filter_view(挑出满足谓词的)、transform_view(对每个元素做变换)、take_view(取前 n 个)...... - 管道 :
v | views::filter(pred) | views::transform(fn)------从左往右读,像 Unix 管道。
为什么
- §14.1 说 range 表达了"99% 的常见算法用法",还消灭一类愚蠢错误 ------
sort(v1.begin(), v2.end())(两个不同容器的迭代器)这类错,range 版根本没法写。 - 懒求值 (教案必懂点):管道里的变换只在被消费时才计算 。§14.4 的例子:
r | filter(odd) | take(3)只消费到第 3 个奇数就停,后面的元素 filter 都不会碰。 - 可读性 :§14.4 "管道风格(用 Unix 管道符 |)被普遍认为比嵌套调用更可读。管道从左往右工作"。
filter(transform(r))要从里往外读,r | filter | transform从前往后读,和英文一致。 - 这是函数式组合的入口 (\[C++之父交谈录#四、五个根本技术\|交谈录:泛型的组合能力]):
v | views::filter(...) | views::transform(...)就是 filter → map 的流水线------为什么这要归功于泛型而不是 OO?因为"函数作为参数"必须在编译期定型,模板才干得了;虚函数在运行期只能分派"对象",分派不了"操作"。 - 性能兜底:§14.4 也承认"view 和管道的实现涉及一些高难模板元编程,如果你关心性能一定要测量;不行就用传统写法"------诚实,且和 A Tour 一贯的"测量"主张一致。
代码逐行
cpp
// ex7_ranges.cpp ------ ranges 管道 + 懒求值验证
#include <iostream>
#include <ranges>
#include <vector>
int main() {
std::vector<int> v {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// 管道从左往右读:过滤奇数 -> 平方 -> 取前 3 个
for (int x : v | std::views::filter([](int x) { return x % 2 == 1; })
| std::views::transform([](int x) { return x * x; })
| std::views::take(3))
std::cout << x << ' ';
std::cout << '\n';
// 懒求值验证:transform 的 lambda 数一数自己被调了几次
int calls = 0;
auto counted = [&calls](int x) { ++calls; return x * x; };
int total = 0;
for (int x : v | std::views::filter([](int x) { return x % 2 == 1; })
| std::views::transform(counted)
| std::views::take(3))
total += x;
std::cout << "只消费 3 个,transform 实际被调 " << calls << " 次,total=" << total << '\n';
}
- 输出:
1 9 25;只消费 3 个,transform 实际被调 3 次,total=35。 - 懒求值的实证 :
v有 10 个元素,filter 只挑奇数(1、3、5、7、9),take(3) 只要前 3 个------transform 的 lambda 被调了恰好 3 次 ,不是 5 次、更不是 10 次。每个元素只在被需要时才流经管道。 - 管道是表达式 :
v | filter(...) | transform(...) | take(3)求值后是一个 view,可直接 range-for。 - view 不拥有元素 (§14.2):它只"看"。所以
auto bad() { return v | views::filter(...); }里若 v 被销毁,返回的 view 悬空------view 不能活得比它的 range 久。 - 生成器(§14.3):
views::iota(42, 52)生成 42..51;istream_view<T>(cin)把流变成 view。
range 算法(§14.1 的现代写法,已验证):
cpp
// ex11_ranges_alg.cpp ------ ranges 算法:sort(v) 而不是 sort(begin,end)
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<int> v {5, 3, 1, 4, 2};
std::ranges::sort(v); // 整个容器一次传进去
for (int x : v) std::cout << x << ' ';
std::cout << '\n';
auto it = std::ranges::find(v, 3);
if (it != v.end())
std::cout << "ranges::find 找到 3 在 " << (it - v.begin()) << " 位\n";
}
- 输出:
1 2 3 4 5、ranges::find 找到 3 在 2 位。 std::ranges::sort(v):约束算法 (概念版),整个 range 一次传入。std::ranges::find返回迭代器,找不到返回v.end()------和传统版同一个约定。
常见坑
- ❌ 让 view 比 range 活得久。view 不拥有元素,range 销毁 view 即悬空(§14.2 的
bad()例子)。 - ❌ 以为管道立即算完。懒求值:管道是"生产式"描述,真正遍历时才触发计算。
- ❌ 混用传统
std::sort和 range 版导致歧义。要显式std::ranges::sort(v)或using std::ranges::sort;(§9.3.2)。 - ❌ 对性能没把握。测量;不行就退回传统算法(§14.4)。
教材指引(可跳转)
- \[A Tour of C 3rd Edition-2022.pdf\|A Tour] §14.1 (必读):range 概念与
sort(v);§14.4 (必读):管道与懒求值;§14.2(必读):view 与"不拥有元素"。 - \[A Tour of C 3rd Edition-2022.pdf\|A Tour] §14.3(选读):生成器 iota/istream。
- \[A Tour of C 3rd Edition-2022.pdf\|A Tour] §14.5(查证):迭代器/range 概念表------你背表的正源。
- \[The C++ Programming Language 4th Edition-2013.pdf\|TCPL] §18(查证):ranges 与 concepts。
- \[C++之父交谈录#四、五个根本技术\|交谈录:泛型编程的组合能力](重读):为什么"函数作为参数"必须靠模板。
第八讲:现代实用工具 + 词频统计 v1.0(A Tour §16 + §15 按需)
是什么
按需章节里的高频工具,配主线收官:
string_view(§10.3 / §15):字符串的只读视图 ,零拷贝。函数参数用它,接受string、const char*、字面量都行。span(§15.2.2):连续内存的视图。函数参数用它,vector、数组、&v[i], n片段都行,替代裸指针 + 长度。optional<T>(§15.4.2):可能没有值。替代"用 -1 表示没找到""用 nullptr 表示空"这类魔法值。pair/tuple:打包几个值。- chrono 计时 (§16.2):
steady_clock::now()+duration_cast------测量,而不是猜。
为什么
string_view/span是现代指针:只读、零拷贝、自动携带长度,消灭"指针 + 长度"这堆易错参数。A Tour Advice 21:用它们替换裸指针。它们正是\[C++之父交谈录#四、五个根本技术\|"直接映射硬件"和"类型安全"在同一种类型里的交汇]------硬件看连续内存,类型看长度。optional消灭魔法值 :函数"可能不返回值"这个事实,用类型表达(std::optional<int>),编译器强制你检查。这是"类型表达意图"(\[阶段一-立世界观\|阶段一]三问题之三)的标准答案。- chrono 让"测量"成为一等公民 :A Tour §16.2.1 原话:"不要先做时间测量就说代码'效率'如何。关于性能的猜测最不可靠。"这是全书的基调,也是你判断"vector vs list""管道 vs 手写"的唯一依据。
代码逐行
cpp
// ex9_utilities.cpp ------ span / string_view / optional / chrono 计时
#include <chrono>
#include <iostream>
#include <optional>
#include <span>
#include <string_view>
#include <vector>
// span:连续内存的只读视图,函数不用关心容器类型
int sum(std::span<const int> s) {
int t = 0;
for (int x : s) t += x;
return t;
}
// string_view:字符串的只读视图,零拷贝
void print_word(std::string_view sv) { std::cout << '[' << sv << "] "; }
int main() {
using namespace std::chrono;
auto t0 = steady_clock::now();
std::vector<int> v {1, 2, 3, 4, 5};
std::cout << "sum(v) = " << sum(v) << '\n';
std::cout << "sum(中间 3 个) = " << sum(std::span<const int>{&v[1], 3}) << '\n';
std::string s = "hello world";
print_word(std::string_view{s}.substr(0, 5)); // "hello"
print_word(std::string_view{s}.substr(6)); // "world"
std::cout << '\n';
std::optional<int> maybe{42}; // 可能没有值
if (maybe) std::cout << "optional 有值: " << *maybe << '\n';
std::optional<int> none{};
if (!none) std::cout << "optional 空: " << none.value_or(-1) << '\n';
auto t1 = steady_clock::now();
std::cout << "全程耗时: " << duration_cast<microseconds>(t1 - t0).count() << "us\n";
}
- 输出:
sum(v) = 15、sum(中间 3 个) = 9、[hello] [world]、optional 有值: 42、optional 空: -1、全程耗时: ...us。 sum(std::span<const int>{&v[1], 3}):span 可以盯住 vector 的任意一段------这是"指针+长度"的现代替代。std::string_view{s}.substr(0, 5):子串是视图 ,不拷贝,print_word直接收下。optional的if (maybe)检查 +*maybe取值;value_or(-1)给默认值。steady_clock计时:duration_cast<microseconds>转成好读的单位。
主线收官:词频统计 v1.0
阶段二 WordCounter(45 行,手写线性查找 + 手写计数 + 手写输出)重构成 STL 风格:
cpp
// ex8_wordcount_v10.cpp ------ 词频统计 v1.0:map + 算法,零手写查找/计数循环
#include <algorithm>
#include <iostream>
#include <map>
#include <string>
#include <vector>
int main() {
std::map<std::string, int> freq; // 取代整个 WordCounter!
std::string w;
while (std::cin >> w) ++freq[w]; // 查词 + 计数:map 一趟搞定
// 按次数降序输出:map 按键序,拷贝出来按 value 排
std::vector<std::pair<std::string, int>> v(freq.begin(), freq.end());
std::sort(v.begin(), v.end(),
[](const auto& a, const auto& b) { return b.second < a.second; });
for (const auto& [word, count] : v)
std::cout << word << ": " << count << '\n';
}
- 输入
the fox the dog the fox the,输出:the: 4、fox: 2、dog: 1。 ++freq[w]这一行顶掉了WordCounter::add()里整个手写线性查找 + 计数逻辑。map 的[]在键不存在时自动插入、默认 0------正好配合++。std::sort(v.begin(), v.end(), lambda):按次数降序 。lambda 用const auto&参数(泛型 lambda)匹配pair<const string,int>。- 结构化绑定
for (const auto& [word, count] : v):一次取到键和值。 - 代码量 :45 行 → 18 行,砍掉 60%。唯一剩下的循环是
while (std::cin >> w)------那是输入循环,不是算法循环;查找、计数、排序全部交给标准库。
常见坑
- ❌ 用
optional却不解引用就打印------*maybe或value_or()二选一。 - ❌ 让
string_view/span指向被销毁的对象。它们是视图,不拥有数据(和 view 同理)。 - ❌ 计时只用一次、依赖单次结果。A Tour §16.2.1:多次测量,小心缓存和罕见事件。
- ❌ 用
std::move()表演。A Tour §16.6:std::move不搬动任何东西(它只是 cast 成右值引用);能省就省,编译器对返回值已经会做 RVO/移动。
教材指引(可跳转)
- \[A Tour of C 3rd Edition-2022.pdf\|A Tour] §15.2.2 (必读):span;§15.4.2(必读):optional。
- \[A Tour of C 3rd Edition-2022.pdf\|A Tour] §16.2.1(必读):chrono 计时与"先测量再说效率"。
- \[A Tour of C 3rd Edition-2022.pdf\|A Tour] §16.6(选读):move/forward------认识就好,别滥用。
- \[A Tour of C 3rd Edition-2022.pdf\|A Tour] §10.3(按需):string_view。
练习与参考答案
练习 1(教案必做):词频统计重构为 map<string, int>
把阶段二 v0.8(freq.h/freq.cpp/main.cpp)重构成第八讲的 v1.0,对比重构前后的代码量 ,并逐行解释 ++freq[w] 为什么能顶掉整个手写查找。
参考答案要点 :见第八讲 ex8。45 行 → 18 行。++freq[w] 做的事:freq[w] 若键不存在则插入 {w, 0} 并返回引用,++ 自增------查找、插入、计数三合一 。这正是 map 的价值:把"线性查找 O(n)"变成"红黑树 O(log n)"。试着把 map 换成 unordered_map,数据量大时用 chrono 计时对比(这就是"测量")。
练习 2(教案必做):用 erase+remove_if 过滤停用词
给词频统计加"过滤停用词(the、a、and...)"。注意:map 不能用 erase+remove_if ------用 std::erase_if(第五讲 ex10);如果你先把 map 拷成 vector<pair<...>> 再处理,就可以用 erase+remove_if(第五讲 ex5)。两条路都写一遍,说出区别。
参考答案要点 :map 版用 std::erase_if(freq, lambda),一步删(ex10)。vector 版:先 v.erase(std::remove_if(v.begin(), v.end(), pred), v.end())。区别在于容器 :序列容器(vector/list/deque/string)用 erase--remove 惯用法;关联容器(map/set)没有"搬运元素"的概念,C++20 直接给 std::erase_if。教案说"用 erase+remove_if",在 vector 上照做,在 map 上要用对工具------这正是"选容器"的影响延伸到算法层面。
练习 3:给一个循环场景,说出该用哪个算法
逐一说出下面每个手写循环对应的标准库算法:
- 找第一个值 > 10 的元素
- 数一数有多少个
"the" - 把所有单词转大写
- 把排序后的结果写进文件
- 从 vector 里删掉所有值为 0 的
参考答案 :1. std::find_if(begin, end, [](int x){ return x > 10; })。2. std::count(begin, end, "the")(或 count_if)。3. std::transform(begin, end, out, toupper_fn)。4. std::copy(begin, end, std::ostream_iterator<string>{ofs, "\n"})(第六讲)。5. v.erase(std::remove(v.begin(), v.end(), 0), v.end())。这是过关题"给循环场景说出算法"的专项练习------答不上来的动词,回到 §13.5 的表复习。
练习 4:容器家族与迭代器类别(背表)
默写容器家族表,并说出每个的迭代器类别(至少 vector/list/forward_list/map/unordered_map)。
参考答案 :vector→contiguous(随机访问);string/array→contiguous;deque→random_access;list→bidirectional;forward_list→forward;map/set(有序树)→bidirectional;unordered_(哈希表)→forward。记忆法:连续内存的都是 random_access+(vector/array/deque);双向链表→bidirectional;单向→forward;树(map/set)是双向;哈希表(unordered_ )是 forward。sort 需要 random_access(阶段四的过关题),所以 list/forward_list/map/unordered_map 都不能 std::sort。
练习 5:用 ranges 重写过滤 + 变换
给定 vector<int> v {1..10},用一条 ranges 管道输出"奇数的平方的前 4 个",并解释懒求值发生在哪里。
参考答案:
cpp
for (int x : v | std::views::filter([](int x) { return x % 2 == 1; })
| std::views::transform([](int x) { return x * x; })
| std::views::take(4))
std::cout << x << ' '; // 1 9 25 49
懒求值:filter 逐个问 v 的每个元素"奇数吗",只放行奇数;transform 只在需要输出下一个值 时才平方;take(4) 拿到第 4 个就停------第 7 个奇数 13 及以后,filter 根本不会去碰 。你可以用第七讲的 calls 计数器实证:transform 恰好被调 4 次。
练习 6(动手做):词频统计 v1.1 终极形态
把词频统计做成:map 计数 → std::erase_if 删停用词 → ranges::sort 按次数降序 → take(3) 输出前三。对照 v0.8,数一数还剩几个手写循环。
参考答案要点 :见 ex12(已验证,输出示例 fox: 2 / dog: 1 / quick: 1)。剩下的"循环"只有输入循环 while (std::cin >> w)------查找、计数、过滤、排序、截取全部是标准库调用。 这就是教案过关标准"没有手写循环"的含义:算法循环归算法,数据读取的循环是 I/O 本质,不算。
练习 7(选做):string_view 与 span
写一个函数 void print_len(std::span<const std::string>),打印每个字符串长度;再写 void shout(std::string_view),把 view 的每个字符转大写输出(用 range-for + std::toupper)。体会"视图不拷贝"。
参考答案要点:
cpp
void print_len(std::span<const std::string> sv) {
for (const auto& s : sv) std::cout << s.size() << ' ';
std::cout << '\n';
}
void shout(std::string_view sv) {
for (char c : sv) std::cout << static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
std::cout << '\n';
}
print_len 能接 vector<string>,也能接 std::array<string,3>;shout("hello") 直接收字符串字面量------视图让函数签名不再绑定具体容器/字符串类型 。注意 std::toupper 的参数要 cast 成 unsigned char(避免负值 UB)。
练习 8(教材精读):读 A Tour §13.5 的算法总表
把总表里没在本讲展开的算法(replace/replace_if/merge/equal_range/copy_if/move)各查一遍,用自己的话写一行注释。选出三个"你接下来最会用到的",说明为什么。
参考答案 :示例------replace(v, x, y):把所有 x 换成 y;copy_if(b,e,out,pred):把满足谓词的拷出来;equal_range:有序序列上二分查某个值的区间。最会用到的三个,通常是 sort+find_if+copy_if (排序、查找、过滤------覆盖大多数任务)。这题的用意:§13.5 的表是"动词字典",不要背,要用时查;读代码遇到陌生算法时回这页。
验收自测(对应交谈录"怎么算过关")
读完这一课,不看资料,你应该能答出:
- STL 三角是哪三样?各自职责? ------ 容器(存数据)、算法(操作数据)、迭代器(桥)。算法只认迭代器对
[begin,end),容器只负责提供 begin/end。 - 选容器第一问是什么? ------ 随机访问,还是插入删除?随机访问→vector;频繁中间插入→list;按键查→map/unordered_map。默认永远是 vector。
- 背容器家族 + 迭代器类别(vector→contiguous、list→bidirectional、forward_list→forward、map/set→bidirectional、unordered_*→forward)。
- 为什么默认 vector? ------ 连续内存、缓存友好、遍历/排序/搜索都快;A Tour:"除非有确凿理由,否则用 vector。直觉不可信,去测量。"
- 算法是"动词"是什么意思? ------ 思维从"写哪个循环"变成"用哪个动词"(find/sort/count/accumulate/transform/copy)。
- 算法会改容器大小吗? ------ 不会。序列容器删除 = erase+remove_if 两步;关联容器 =
std::erase_if(C++20)。 find找不到返回什么? ------end()。这是 STL 约定(阶段四my_find同款)。- 流迭代器能干什么? ------ 让算法直接作用在流上:
istream_iterator读、ostream_iterator写,读文件排序去重零手写循环。 - range 版算法怎么用? ------
std::ranges::sort(v)(整个 range 一次传入);与传统迭代器版并存需显式ranges::限定。 - ranges 管道怎么写?为什么可读? ------
v | views::filter(pred) | views::transform(fn),从左往右读,与英文一致。 - 懒求值是什么? ------ 管道里的变换只在被消费时才计算;
take(n)之后的元素根本不会被处理。 - view 和容器什么关系? ------ view 不拥有元素,只是观察窗口;view 不能活得比它的 range 久。
string_view/span是什么? ------ 只读视图,零拷贝,自动携带长度,替代"裸指针+长度"。optional解决什么? ------ "可能没有值"的类型安全表达,替代 -1/nullptr 魔法值。- 阶段二工具重构后还剩几个手写循环? ------ 只剩输入循环
while (std::cin >> w);查找/计数/排序/过滤全交给标准库,代码量砍 60%。 - 验收红线 :不看讲义,把阶段二 v0.8 的
WordCounter用std::map+ 标准算法重写成不超过 20 行、零手写算法循环的版本,编译运行通过。写不出这一步,本课不算过关。
走到这里,你已经完成了从"手写一切"到"标准库一切"的转变:阶段二你自己写了
my_find、my_count_if、median,阶段四把它们泛型化,阶段五------你终于可以直接用标准库了 。你的词频统计从 45 行缩到 18 行,从"数组+手写查找"变成"map+算法+ranges"。 下一课(\[阶段六-并发与资源\|阶段六])是另一座高峰:并发与资源 ------C++98 连线程的标准支持都没有,而你要学的是 A Tour §18 的生产者-消费者模型,用jthread+scoped_lock+ 条件变量,让两个线程安全地共享你的词频数据。