从零开始 / 一次只解释眼前的一步
关联容器与适配器:
查找、唯一性与访问顺序。
按ID查记录、拒绝重复键和按到达顺序处理任务,需要不同的接口。用具体输入分开追踪查询、插入与取出,说明容器里究竟留下了什么。
9小时是包含阅读、推演和编码的设计估算,未经真人试学校准。可分多次学习,遇到不清楚的地方保留预测、实际结果和疑问,之后再回修教材。手机可读图与做预测,编译需要电脑终端。
这一章怎样学
先读一小段,写下预测,再运行程序。每次只改一个条件,最后关掉示例,从空文件独立写一次。遇到错误,把第一条报错和自己的修复记下来;不用赶着把页面滚到底。
每次写下输入、查询或修改动作,以及最后留下的键和值。先完成第10章算法与lambda,再开始本章;此前的M1仍按自己的独立材料核对,打开本页不会替你判定M1或G0通过。
11.1 pair和结构化绑定:把两项结果一起交回来
已经会在vector里逐项查找,为什么还要换容器?先看两个任务:按ID查一条记录,需要“这个键对应什么”;按到达顺序处理工作,需要“下一条轮到谁”。这两种问题需要的访问方式不同。本章先学把两项信息放在一起,再分别选择按键组织和按顺序处理的容器。先完成第10章的算法与lambda。
std::pair<int, int>是标准库提供的“一对值”类型,头文件为<utility>。尖括号的两个类型依次规定第一项和第二项,沿用08.1“给现成库类型填实参”的读法;这里不定义模板。对象values的两项分别叫values.first和values.second,可以像07章的普通成员一样读取。它们不必类型相同,例如键为string、计数为int时可写std::pair<std::string, int>。
花括号中的值按这两个位置初始化。std::pair<int, int> values{2,5};建立一个pair对象,其两项为2和5;它不是长度可以继续增加的vector。
两个好读的名字,是否仍在访问原对象
auto [left, right] = values;叫结构化绑定(structured binding),C++17起可用,本书使用C++20。这里先由values建立一份pair副本,再让left和right分别命名这份副本的两项。不是按变量名去找成员,也不是将原values拆坏。名字可以自己取,但位置顺序不能猜反。
auto& [first, second] = values;则借用原values;first和second对应原对象的成员,写first会改变values.first。const auto& [a,b] = values;通过只读路径借用两项,不能用a或b写入;它不会冻结原对象的其他合法访问路径。这里借用的都是仍在作用域内的values,期限继续遵守05章的规则。const auto [a,b] = values;则建立只读的pair副本;它没有&,不是对原pair的只读借用。
#include <iostream>
#include <utility>
int main() {
std::pair<int, int> values{2, 5};
auto [left, right] = values;
left = 7;
std::cout << left << ' ' << right << '\n';
std::cout << values.first << ' ' << values.second << '\n';
auto& [first, second] = values;
first = 9;
std::cout << first << ' ' << second << '\n';
const auto& [read_first, read_second] = values;
std::cout << read_first << ' ' << read_second << '\n';
return 0;
}
先将副本的left改成7:副本两项是7和5,原values仍为2和5。随后通过引用绑定把原第一项改成9,原对象成为9和5。区别在声明有没有借用原对象,不能因为变量名都来自同一份输入就推断它们自动同步。
以后见到for (const auto& [key,value] : table),把已经会的两件事组合起来读:范围for逐次拿到一个元素,结构化绑定给这个元素的两项取名。是否复制、是否只读仍由声明决定;本章下一节会提供真实table。
小练习:只把第一个auto改成auto&,改left会怎样
提示:先画出left指向哪一个pair成员,再执行赋值。
原values的第一项也会变成7,第二项仍为5;后面再次通过引用写9,原第一项继续变成9。这里并没有两个原对象,两个可写别名最终都访问同一项。若使用const auto&,则通过绑定名字写入应被编译器拒绝,不能当作“运行后原值不变”的正常程序。
11.2 map和set:键是什么,查询会不会改变内容
先认键和值,再读接口
std::map<std::string, int>需要<map>和<string>。它存放键(key)到值(mapped value)的关联:例如red对应2,blue对应1。map中的每个元素是一对键值,读作std::pair<const std::string, int>。键在元素中是const,计数可以修改;不能直接改元素的键来“换一个位置”,那会破坏容器按键组织的关系。
对本章的默认比较方式,键按大小顺序组织;这些小写字符串按内容比较,blue排在red前面,与插入先后无关。对于map和set,更一般的“同一个键”由比较器决定:两边都不排在对方前面,就视为等价,沿用10.3的严格弱序。当前的整数和普通string例子与按值相等一致,不需要自己写比较器。
容器名字后花括号中的{"red",2}、{"blue",1}各表示一条键值初值。这里的键类型明确是std::string;传入"red"这样的字符串字面量时,按本例接口构造string键来查询,不是拿字符指针地址当键。size()数的是已经存储的键值条目,不是所有计数的总和。
缺失时,你希望得到什么
| 本例接口 | 结果 | 缺失时 | 会因查询插入吗 |
|---|---|---|---|
table.contains(key) |
bool是否存在,C++20接口 | false | 不会 |
table.count(key) |
对map/set,匹配项数只能是0或1 | 0 | 不会 |
table.find(key) |
元素位置 | 本容器的end | 不会 |
table[key] |
对应值的可写引用 | 建立该键;本例int值初始化为0 | 会 |
table.at(key) |
已有键对应值的引用 | 报告out_of_range异常,不给默认值 | 不会 |
at的错误传播和捕获在错误处理章再展开;本章只在已经确认键存在时调用它,不借异常做存在性检查。没有把“缺失”和“已有键的值恰好为0”区分开的接口,不适合用来判断存在性。map的[]也不接收“第几项”的序号;方括号里的参数是键。
find返回的迭代器仍遵守09章的读取规则:先确认不等于end,再解引用。(*position).second先取得位置上的键值pair,再读取它的第二项。外面这对圆括号让成员访问发生在解引用之后;得到end时不能读取first或second。
#include <iostream>
#include <map>
#include <string>
int main() {
const std::map<std::string, int> counts{{"red", 2}, {"blue", 1}};
std::cout << counts.contains("green") << ' ' << counts.count("green")
<< ' ' << counts.size() << '\n';
const auto missing = counts.find("green");
if (missing == counts.end()) {
std::cout << "missing\n";
}
const auto red = counts.find("red");
if (red != counts.end()) {
std::cout << (*red).second << '\n';
}
std::cout << counts.size() << '\n';
return 0;
}
初始两个键。查询green失败后,键数仍是2;查询red成功,取到值2。contains问“有没有”,count给匹配数量,find提供继续读取的具体位置;它们的返回类型和用途不同。
一个看起来只读、实际上插入的表达式
#include <iostream>
#include <map>
#include <string>
int main() {
std::map<std::string, int> counts{{"red", 2}, {"blue", 1}};
std::cout << counts.size() << '\n';
const int observed = counts["green"];
std::cout << observed << '\n';
std::cout << counts.size() << '\n';
return 0;
}
读counts["green"]之前,只有red和blue。green缺失,所以下标操作先建立green对应的int零值,再把这个0交给读取者。即使把结果保存进一个普通副本、之后从未修改它,插入也已经发生,键数从2变成3。
要修复的是查询方式:先find,再比较end;只需要真假时用contains。不要在查询完以后删掉green来伪装成没有副作用,也不要用“读取结果等于0”推断键不存在。
set只有键,没有单独的计数值
std::set<int>来自<set>,只保存唯一的整数键。用3初始化,两个3对应同一个键,最终只有1和3,按升序遍历;它不会自动记录3出现过两次。要记录次数,应该使用map的映射值。set没有map那样的[],它提供find、contains和count等按键接口。values.insert(x)尝试加入一个整数键,已有等价键时集合不变;本节只观察集合内容,不读取这次调用的返回值。
#include <iostream>
#include <set>
int main() {
std::set<int> values{3, 1, 3};
values.insert(1);
for (const int value : values) {
std::cout << value << '\n';
}
std::cout << "size " << values.size() << '\n';
std::cout << values.count(3) << ' ' << values.contains(2) << '\n';
return 0;
}
初始集合已含1,额外的insert(1)不增加条目;所以size仍为2,count(3)为1,contains(2)为false。通过set迭代器也不能改键值。map/set的迭代器可逐项前进,但不支持vector那样的任意整数偏移和双迭代器减法;不能把它们的begin/end交给要求随机访问的std::sort。容器已经按自己的键比较规则组织顺序。
以键比较次数看,map/set的这类查找有O(log n)的增长上界,而不必扫描所有n项。这个说法不指定某一种树的节点布局,也不是实际耗时;字符串比较本身也有成本。当前先把“有序、唯一、查询是否写入”说准确。
小练习:green已存在但值为0,与green缺失怎样区分
提示:为两种输入分别写出contains和find应返回什么,不用下标查询。
已有零值时contains为true,find返回有效元素位置,其second为0;缺失时contains为false,find等于end,不能解引用。两种情形的只读查询都不改变键数。若只看下标读出的0,就会把两种输入混在一起,还可能新建条目。
11.3 unordered容器:按键查找不意味着按键排列
std::unordered_map<std::string,int>来自<unordered_map>。它同样把一个键对应到一个值,同样不保存重复等价键,但不承诺升序,也不承诺插入顺序。contains、count、find、下标和at在本章的存在性/插入行为上按上节读法使用;不要顺手把map的遍历顺序和位置稳定性也搬过来。
哈希先缩小查找范围,相等判断确认是不是那个键
哈希(hash)把键计算成用于组织查找的数值,容器再据此选择桶(bucket)。不同键可能落到同一个桶,也可能得到同一哈希值,这叫碰撞(collision)。碰撞不表示两条记录必须覆盖:还要比较键是否相等,才能确定究竟是哪条记录。
可以在纸上把red和blue放进同一个示意桶:查red时,不能遇到blue就返回它的值,要继续按字符串内容确认。这只是解释合同的教学模型,不声称本机标准库真的采用这些桶编号、存储布局或检查顺序。本章不实现哈希表,也不编造一个std::hash的固定输出。
需要记住的方向是:键相等,必须得到相同哈希值;哈希值相同,不足以证明键相等。若以后定制哈希与相等规则,两者要一致,同一存储期间对同一键的判断也要稳定。现在使用标准库为string提供的默认规则,不能把它误读成字符指针地址比较。
在常见的平均情形下,按键查找的复杂度为O(1),最坏可能退化到O(n)。这是按表中键数量n讨论的增长,实际还要支付哈希/相等判断的成本;长字符串、分配和缓存都可能影响耗时。“平均常数”既不保证每次只检查一个键,也不保证一定比小vector更快。
跟踪计数表,但明确指定输出键
#include <iostream>
#include <string>
#include <unordered_map>
#include <vector>
int main() {
const std::vector<std::string> words{"red", "blue", "red"};
std::unordered_map<std::string, int> counts{};
for (const auto& word : words) {
++counts[word];
}
const auto red = counts.find("red");
const auto blue = counts.find("blue");
std::cout << "red " << (red == counts.end() ? 0 : (*red).second) << '\n';
std::cout << "blue " << (blue == counts.end() ? 0 : (*blue).second) << '\n';
std::cout << "keys " << counts.size() << '\n';
return 0;
}
从空表开始,处理red时先建立red=0再加一,得到red=1;处理blue后增加blue=1;再次处理red时已有该键,只把其计数从1改为2。最后是red=2、blue=1、键数2。
按完整键区分记录,再更新计数
hash-count中的counts为空。接下来按输入vector的red、blue、red顺序更新;图不假设unordered_map的遍历位置。
counts[word]发现red不存在,建立int值0,再由++变成1。只有一个不同键。
第二个词blue建立自己的0,再加一成1。red仍为1,不同键的数量变成2。
第三个词red命中已有键,1加一成2;blue仍为1,键数仍为2。固定键查询最终输出red 2、blue 1、keys 2。
从此帧切换到独立教学模型:指定red和blue都进入模型桶0,并按red后blue展示候选。它不是本机std::hash的数值或真实容器布局。相同桶位置不表示两个完整键相等。
在这个指定模型里查询blue:先与red比较,不相等;再与blue比较,相等,取到值1。模型说明碰撞不应合并不同键;它不保证标准库的具体查找顺序或每次查询耗时。
查看所有步骤的文字与数值
- 1 · 开始时没有键
red:不存在;blue:不存在;键数:0
hash-count中的counts为空。接下来按输入vector的red、blue、red顺序更新;图不假设unordered_map的遍历位置。
- 2 · 第一个red建立记录
当前词:red;red:1;键数:1
counts[word]发现red不存在,建立int值0,再由++变成1。只有一个不同键。
- 3 · blue是另一个键
red:1;blue:1;键数:2
第二个词blue建立自己的0,再加一成1。red仍为1,不同键的数量变成2。
- 4 · 再次red只改原计数
red:2;blue:1;键数:2
第三个词red命中已有键,1加一成2;blue仍为1,键数仍为2。固定键查询最终输出red 2、blue 1、keys 2。
- 5 · 另一个明确指定的碰撞模型
模型桶0:red:2 | blue:1;完整键:red与blue不同;真实桶/遍历顺序:本图未测量
从此帧切换到独立教学模型:指定red和blue都进入模型桶0,并按red后blue展示候选。它不是本机std::hash的数值或真实容器布局。相同桶位置不表示两个完整键相等。
- 6 · 模型内仍须比较完整键
查找键:blue;候选比较:red不等 / blue相等;模型返回值:1
在这个指定模型里查询blue:先与red比较,不相等;再与blue比较,相等,取到值1。模型说明碰撞不应合并不同键;它不保证标准库的具体查找顺序或每次查询耗时。
代码按指定键查找并输出结果,查到合法位置后才读取second。这里复用03.6的条件运算符:位置等于end时只向输出提供0,否则才读取对应计数;这个显示用的0不会建立键,也不能单独证明键缺失,存在性由end比较判断。没有通过一次哈希表遍历去猜“哪一行先打印”。如果输入为空,表仍为空;输出缺失状态时不要为了打印0而对表使用下标,除非你确实希望创建那些零计数键。
std::unordered_set<int>来自<unordered_set>,对应只保留唯一键的无序集合。它没有单独的映射值、没有下标接口;是否包含某个整数用contains/find/count。与set相比,唯一性用途相近,但它不提供升序遍历的承诺,本章独立任务需要有序结果时会选择set。
try_emplace:只在没有该键时建立值
counts.try_emplace("red",2)表示:没有red就用后面的参数建立它的值;已经有red则保留旧值,不用2覆盖。map与unordered_map都提供这个接口。它也会返回插入位置及真假结果,11.4马上学习怎样读取;当前先观察表本身,调用语句不使用返回值并不影响插入动作。
“没有覆盖”也不表示实参没有计算。make_count是一个普通函数:调用时先增加calls再返回9。把它写进重复red的插入实参里,它仍在这次函数调用之前执行,所以calls为1,而red仍为2。try_emplace不会替调用者把实参表达式变成一个延后才执行的任务。
#include <iostream>
#include <string>
#include <unordered_map>
int make_count(int& calls) {
++calls;
return 9;
}
int main() {
std::unordered_map<std::string, int> counts{};
int calls{0};
counts.try_emplace("red", 2);
counts.try_emplace("red", make_count(calls));
counts.try_emplace("blue", 1);
std::cout << counts.at("red") << ' ' << counts.at("blue")
<< ' ' << counts.size() << '\n';
std::cout << "calls " << calls << '\n';
return 0;
}
先建立red=2,再对red提出值9的插入请求,red仍是2;随后加入blue=1,最终键数为2。与counts["red"] = 9明确写回旧值不同,try_emplace把“已存在”当作保留原记录。
阅读哈希计数完整源码之前,补清其中范围的拥有者:for右侧直接建立的完整临时vector会存活到这次范围循环结束,每轮word借用其中仍存活的string元素。这条说明只针对眼前完整临时容器,不推广到由临时对象返回的view或其他临时链。单语句for体沿用09.2,固定存在的red/blue使用刚学的at;输出应为red=2 blue=1 green=0 keys=2,green没有被插入。
小练习:只把第三个输入red改成green
提示:每一步先问键是否已经在表里,再决定是新建还是增加。
三个不同键各出现一次,red=1、blue=1、green=1、键数3。若程序只输出指定的red/blue/size,则对应数值是1、1、3;要观察green时,再明确查询它。不要为unordered_map的整个遍历写死三行先后。
11.4 插入结果与位置:刚才新建了,还是原本就有
只知道表最后是什么样,有时还不够。处理重复ID时,往往还需要知道“这次是否接收了一条新记录”。本节这些没有位置提示参数的map/unordered_map插入调用返回一个pair:第一项是元素位置,第二项是bool,true表示本次确实插入;false表示已有等价键。false不是“没有得到可读记录”,位置仍指向那条已有记录。
先解包返回结果,再读位置上的pair
auto [position, inserted] = table.try_emplace(key,value);用11.1的结构化绑定取得两个返回信息。这里有两层:返回pair的第一项是迭代器;迭代器所指的容器元素本身又是键值pair。不要把position误当成那个整数value。
对本章关联容器中可解引用的迭代器,position->second访问它所指键值pair的第二项,效果与(*position).second对应。这是库迭代器提供的成员访问写法;与07章原生指针的箭头外观相同,不表示迭代器本身一定是裸指针。也不能因为用了箭头就省掉有效性检查:find得到end仍不可读取;当前插入返回的位置则按上面的合同指向新条目或已存在条目。
insert与emplace分别接收什么
table.insert(entry)接收准备好的一条键值pair;table.emplace(key,value)接收用于在容器中构造键值元素的参数。当前都是简单string/int,并且只讨论唯一键容器的不带提示位置版本,别把这些返回类型推广到其他重载。二者遇到等价键均不覆盖旧值。下面准备的候选pair类型为std::pair<std::string,int>;insert按其中的键和值建立容器条目,存储元素的键仍是const string,不要求候选pair的类型文字与容器元素完全相同。
emplace表示一种构造接口,不是无条件的性能承诺。不能据名字断言从不产生中间对象,也不能保证失败插入时完全没有做构造工作;实际成本需要在具体类型和实现上测量。当前只需准确读出输入、插入真假和最终元素。
#include <iostream>
#include <map>
#include <string>
#include <utility>
int main() {
std::map<std::string, int> counts{};
const auto [where, inserted] = counts.try_emplace("red", 2);
std::cout << inserted << ' ' << where->second << '\n';
const auto [same, duplicate] = counts.emplace("red", 9);
std::cout << duplicate << ' ' << same->second << '\n';
const std::pair<std::string, int> candidate{"blue", 1};
const auto result = counts.insert(candidate);
std::cout << result.second << ' ' << result.first->second << '\n';
std::cout << "size " << counts.size() << '\n';
return 0;
}
第一步新建red=2,插入结果为true,返回位置读取2。第二步emplace再次提出red=9,结果为false,返回位置读取的仍是旧值2。第三步把准备好的blue=1交给insert,它是新键,结果为true,位置读取1,最终键数2。把false分支一概当成“无值可读”会丢掉处理重复记录所需的信息。
用完位置,再改变容器;不要默认所有位置都一样稳定
map/set的普通插入不会使原有元素迭代器失效,删除元素则会使指向被删元素的位置失效。unordered容器插入时可能重新组织桶,称为rehash;一旦发生,它会使原有迭代器失效,但已有元素的引用不会因为这次rehash而失效。它们不是vector扩容的同一套规则。
本例对每次返回的位置立即读取,之后不跨下一次修改复用旧迭代器。需要长期保存位置时,必须按具体操作的合同判断;本章最简单的方式是在修改之后重新find。map的键是const,拿到可写迭代器也不能直接改first;set的整个元素就是键,同样不通过迭代器改它。
小练习:只把重复插入的red改成green,会改变哪两个结果
提示:先看这一键之前是否存在,再看返回位置指向谁。
这次插入会成功,返回的bool由false变成true,位置读到新值9;最后包含red、green、blue,键数由2变成3。原red仍是2。改变的是本次请求是否与旧键等价,不是emplace突然变成了覆盖接口。
11.5 deque、queue与stack:下一项从哪一端拿
按ID去重解决不了处理顺序。现在给同样几条工作选定规则:先到先做,或者最后加入的先取出。
deque:两端都能添加和移除
std::deque<int>来自<deque>,是双端序列。它可以使用合法下标访问,但不承诺所有元素像vector那样连续存储;不能从“支持下标”推断出“可把整组当作连续span”。
push_front(x)在前端加入,push_back(x)在后端加入;front()和back()分别访问两端元素。pop_front()和pop_back()移除相应端点,返回void,不顺便把被移除的值交回来。需要保存旧值时先读取成一个副本,再pop。访问或移除端点前必须确认非空,empty()为true时不能继续front/back/pop。
本例先完成两次push,此时至少有两个元素,保证接下来的端点读取和两次pop合法;删除之后再用empty检查是否还有元素。最后的范围for沿用09.2,deque也提供逐项遍历所需的begin/end。
#include <deque>
#include <iostream>
int main() {
std::deque<int> values{2, 5};
values.push_front(1);
values.push_back(8);
std::cout << values.front() << ' ' << values.back()
<< ' ' << values.size() << '\n';
values.pop_front();
values.pop_back();
if (!values.empty()) {
std::cout << values.front() << ' ' << values.back()
<< ' ' << values.size() << '\n';
}
for (const int value : values) {
std::cout << value << '\n';
}
return 0;
}
从[2,5]开始,前端加1、后端加8,得到[1,2,5,8]:front为1,back为8,size为4。随后各移除一个端点,回到[2,5]:两端为2和5,size为2。这里每次都重新查询端点,不把旧端点引用保留到删除之后。deque的端部插入会使已有迭代器失效,但不会因此使已有元素的引用失效;被删除元素的引用仍然不能再用。不要照搬vector或map的规则。
queue:FIFO,先进入的先处理
std::queue<int>来自<queue>,默认使用deque保存元素,但只公开符合队列规则的接口。FIFO是first in, first out:push从后端加入,front看最早未取走的一项,pop移除前端。size数等待项,empty回答是否没有等待项。
queue叫容器适配器(container adaptor),意思是用底层容器保存数据,再提供一种受限制的访问接口。它不是deque的另一个名字;没有公开begin/end供范围for任意遍历,也没有提供下标访问。需要依次处理时,在while中先判非空,再front,最后pop。
stack:LIFO,最后加入的先取出
std::stack<int>来自<stack>,同样是适配器,默认底层也可以由deque保存。LIFO是last in, first out:push把元素放在顶端,top看最新加入且尚未取走的项,pop移除顶端。stack使用top而不是front;它也不提供下标或范围for遍历接口。
#include <iostream>
#include <queue>
#include <stack>
#include <vector>
int main() {
const std::vector<int> arrivals{2, 5, 7};
std::queue<int> fifo{};
std::stack<int> lifo{};
for (const int value : arrivals) {
fifo.push(value);
lifo.push(value);
}
std::cout << "queue\n";
while (!fifo.empty()) {
std::cout << fifo.front() << '\n';
fifo.pop();
}
std::cout << "stack\n";
while (!lifo.empty()) {
std::cout << lifo.top() << '\n';
lifo.pop();
}
std::cout << "empty " << fifo.empty() << ' ' << lifo.empty() << '\n';
return 0;
}
同样按2、5、7加入:queue依次取出2、5、7,stack依次取出7、5、2。每次读取前都有非空检查;最终两者都为空。输出与pop分成明确的两个步骤,避免把返回void的pop当作一个可以打印的整数。
同样入场,访问顺序不同
fifo和lifo刚创建,empty都为真;此时不能读取front或top,也不能pop。
队列和栈都放入2。现在queue的front和stack的top都为2。
queue在后端追加5,front仍为2;stack把5放到顶端,top变为5。
两者都有三个元素。queue仍从最早的2开始;stack从最新的7开始。
在非空守卫内先读取front再pop。三轮依次输出2、5、7;队列剩余内容依次为[5,7]、[7]、空。stack尚未进入它的循环,仍保留2、5、7。
stack在每轮非空时先读取top再pop,依次输出7、5、2,剩余[2,5]、[2]、空。最后输出empty 1 1;不再访问已经排空的适配器。
查看所有步骤的文字与数值
- 1 · 两个适配器都为空
queue 前→后:空;stack 底→顶:空;empty:1 | 1
fifo和lifo刚创建,empty都为真;此时不能读取front或top,也不能pop。
- 2 · 先放入2
queue 前→后:2;stack 底→顶:2;下次访问:2 | 2
队列和栈都放入2。现在queue的front和stack的top都为2。
- 3 · 再放入5
queue 前→后:2 | 5;stack 底→顶:2 | 5;下次访问:2 | 5
queue在后端追加5,front仍为2;stack把5放到顶端,top变为5。
- 4 · 最后放入7
queue 前→后:2 | 5 | 7;stack 底→顶:2 | 5 | 7;下次访问:2 | 7
两者都有三个元素。queue仍从最早的2开始;stack从最新的7开始。
- 5 · 程序先排空queue
queue剩余:空;queue输出:2 | 5 | 7;stack 底→顶:2 | 5 | 7
在非空守卫内先读取front再pop。三轮依次输出2、5、7;队列剩余内容依次为[5,7]、[7]、空。stack尚未进入它的循环,仍保留2、5、7。
- 6 · 然后排空stack
stack输出:7 | 5 | 2;queue / stack:空 / 空;最后empty:1 | 1
stack在每轮非空时先读取top再pop,依次输出7、5、2,剩余[2,5]、[2]、空。最后输出empty 1 1;不再访问已经排空的适配器。
| 主要需求 | 本章合适的起点 | 本身不会替你完成什么 |
|---|---|---|
| 连续扫描、下标访问 | vector | 按键唯一性 |
| 按键组织并按键序遍历 | map / set | 到达顺序 |
| 按键查询,不要求键序 | unordered_map / unordered_set | 固定遍历顺序 |
| 两端添加与移除 | deque | 自动去重 |
| 最早加入的先处理 | queue | 查找任意键或自动去重 |
| 最新加入的先取走 | stack | 按优先级选最重要项 |
“最后加入”不等于“优先级最高”。堆与priority_queue算法在后面的算法章节再学习;本章不把名称相似当作同一种访问规则。
四步练习:从计数表到小工作清单
- 预测:关闭hash-count的运行结果,对red、blue、red逐步写出已有键、各值、size。不要写哈希遍历的固定顺序。
- 补全:用find/contains完成green的缺失查询和red的命中读取。查询前后键数必须仍为2,不能用下标补出缺失键。
- 找错:解释subscript-side-effect为什么让键数从2变3。只替换错误的查询方式,重测green缺失与已有值为0的两种输入。
- 独立迁移:从空文件开始,将[4,2,4,1]放进适合有序去重的容器,得到1、2、4;另建FIFO队列,先加入2再加入5,按2、5取出并确认最终为空。解释为什么去重和排队分别需要两种行为。
提示:先给每个容器写一句合同
集合保存哪些ID已经出现,并按键序给出唯一结果;队列保存待处理顺序。set的insert请求不会增加重复键,queue的push则不会自动去重。处理queue时,循环条件应保证front/pop的非空前提;需要值就先读取再移除。
独立完成后,对照迁移代码与边界
#include <iostream>
#include <queue>
#include <set>
int main() {
const std::set<int> unique{4, 2, 4, 1};
std::queue<int> work{};
work.push(2);
work.push(5);
std::cout << "unique\n";
for (const int value : unique) {
std::cout << value << '\n';
}
std::cout << "queue\n";
while (!work.empty()) {
std::cout << work.front() << '\n';
work.pop();
}
std::cout << "empty " << work.empty() << '\n';
return 0;
}集合按键序输出1、2、4,队列按进入顺序输出2、5,最后empty为true。若输入集合为空,遍历零次;若只重复同一个ID,只保留一个键。空队列的处理循环也应执行零次,不能为了“至少处理一次”改成无保护的do/while。测试至少保留空输入、一个元素、重复输入与这组混合输入。
补充练习:查询与访问顺序
先保留自己的预测和修改,再打开提示与答案。
练习 1
subscript-side-effect初始只有red和blue,却在观察green时把键数改为3。用find与已学条件表达式修复,使缺失时仍观察到0,但键数不变。
给我一点提示
- 不要用下标读取缺失键。
- 先保存find返回值,只有它不等于end时才读(*position).second。
查看答案与推理
将observed的声明改成先取得const auto position = counts.find("green"),再令observed在position==counts.end()时取0,否则取(*position).second。三行输出应为2、0、2;这是由两个有序步骤组成的接口修复,不是改变容器里的默认值。
练习 2
在set-queue-transfer中只把work.push(5)改成work.push(2)。先预测集合与队列输出,说明哪一类容器负责去重。
给我一点提示
- 集合的初始化输入没有改。
- queue按每次push保存任务,不因为数值相同就丢掉一次任务。
查看答案与推理
集合仍按升序输出1、2、4。队列输出2、2,两次任务都被处理;最后empty仍为1。set提供唯一键,queue提供FIFO访问规则,两者解决不同问题。
关掉参考,再做一次
把理解说出来
先完成正文的独立迁移,再回答下面六题。它们只检验第11.1–11.5节已经讲过的内容。打开答案、编译成功或填写用时,都不会自动通过本章,更不代表通过 G0。
绑定副本与引用
pair-bindings把结构化副本left改为7时,为什么原pair仍为2、5?改auto&绑定first为9后又是什么?const auto&能做什么?
对照推理与英文回答
auto的这一组绑定对应一个独立pair副本,所以left变7不改变原pair。auto&的两个名字访问原pair的成员,first=9之后原pair为9、5;const auto&可以读取这两个成员,但不能通过这条只读访问路径给它们赋值。
The auto binding uses a separate pair copy, so changing left does not change the original. The auto-reference binding refers to the original members, making the pair nine and five after the assignment. A const-reference binding reads those members without allowing writes through that access path.查询与下标副作用
只有red=2和blue=1时,find/contains/count查询green后键数是多少?counts["green"]的观察值与键数又是多少?at适合怎样的前提?
对照推理与英文回答
前三种查询不插入,键数仍为2,find返回end,contains为false,count为0。非const下标对缺失green插入一个值初始化的int 0,因此观察到0但键数变为3。at不插入;本章只在已确认键存在时使用它,缺失键不是一个可直接读取的默认值。
Find, contains, and count do not insert, so the map still has two keys. The missing-key subscript inserts an integer initialized to zero and increases the size to three. At does not insert; this chapter uses it only after the key is known to exist.哈希碰撞与输出合同
red、blue、red计数后为什么按指定键输出,而不是把unordered_map的遍历次序写成固定答案?两个不同键进入同一模型桶时,能当成一个键吗?
对照推理与英文回答
固定结果是red=2、blue=1、键数2;unordered遍历位置不由这个答案保证。进入同一桶只给出候选位置,仍必须按完整键的相等关系区分记录。模型中的red和blue不相等,分别保存2和1;模型桶0不是实际std::hash测量。
The guaranteed result here is two for red, one for blue, and two distinct keys. Unordered iteration order is not part of that output contract. Sharing a bucket does not make two keys equal: the model still distinguishes red from blue using key equality.重复插入与求参
try-emplace-values重复插入red时,原来的2会变为9吗?make_count里的calls为什么仍变成1?插入返回的pair怎样理解?
对照推理与英文回答
重复键不覆盖已有的2,但调用try_emplace之前仍会求值make_count(calls),因此calls为1;“不插入”不等于跳过普通参数表达式。插入结果的first是元素位置,second说明本次是否新插入;重复键时second为false,位置指向已有元素。
The existing value remains two, but make_count is still evaluated as a normal function argument, so calls becomes one. No insertion does not mean lazy argument evaluation. The result pair contains the element position and a flag indicating whether a new element was inserted.插入位置与成员
insertion-result第二次emplace("red",9)输出0、2分别是什么意思?为什么返回位置可以读second,却不能把map的键直接改成另一个键?
对照推理与英文回答
0说明这次没有新增键,2是返回位置处已有red记录的映射值。迭代器的->访问它指向的键值对成员,其中second是可更新的映射值;map元素的键按const规则保留,不能通过它改键来绕过容器的有序与唯一性管理。
Zero means the duplicate emplace did not insert a new key; two is the existing mapped value at the returned position. The iterator arrow accesses that element’s pair members. The key is const within the map element, so it cannot be rewritten in place to bypass the map’s ordering and uniqueness rules.容器选择与空状态
同样依次放入2、5、7,queue和stack分别取出什么?pop是否返回该元素?独立任务为何同时需要set和queue?
对照推理与英文回答
queue按FIFO取2、5、7,stack按LIFO取7、5、2。pop只移除,不返回被移除的值;程序先判断非空,再读取front或top,然后pop。set用来保留有序唯一值,queue保存每次到达的任务;重复值在queue里仍可出现,不能拿适配器当去重集合。
Queue produces two, five, seven; stack produces seven, five, two. Pop removes an element without returning it, so read front or top while nonempty before popping. Set handles unique ordered values, whereas queue preserves each arriving task, including repeated values.和正文是同一份源码
示例文件
先自己输入和预测,卡住时再下载对照。文件名相同不代表内容相同;把它们放在单独的练习目录中,避免覆盖自己的作品。
- pair-bindings.cpp一对值:副本绑定与引用绑定
- map-lookup.cpp查询green,读取red,键数不变
- subscript-side-effect.cpp看似读取的下标却插入了键
- set-unique.cpp唯一键与有序遍历
- hash-count.cpp完整例一:按键计数,不依赖哈希遍历顺序
- try-emplace-values.cpp重复键不覆盖,参数表达式仍会求值
- insertion-result.cpp插入是否成功,以及返回的元素位置
- deque-ends.cpp从双端进入和离开
- queue-stack.cpp完整例二:同一输入的FIFO与LIFO
- set-queue-transfer.cpp独立选择去重集合与到达顺序队列
下载清单来自本章元数据,正文代码与下载同源。按各例明确的输入、输出和验证状态使用;标注故意编译报错的文件只用于观察对应诊断。涉及非法范围或悬空的讨论按正文作静态分析,不通过运行未定义行为来猜答案。
可选的学习反馈
记下你真正花的时间
每完成一个学习时段,再填实际分钟。环境准备、阅读推演、独立编码和卡点排查分别记录,避免同一段时间重复计算。离开吃饭或做其他事情的时间不算进去。
记录只保存在你的浏览器,可导出给我复盘。留空表示尚未记录,不等于零耗时;页面停留时间不会自动计为学习。不要把开发者检查时间填进来。
尚无真实试学用时。
换设备:导入记录,或取回损坏的旧记录
导入会合并时段,相同编号不重复累加;发生冲突会保留现有记录。
阅读记录与课程验收分别保存。
本章资料与查证
- WG21 N4861:C++20工作草案 ↗
固定2020-04-01草案。11.1查[pairs]、[dcl.struct.bind];11.2查[associative.reqmts]、[map.access]及[set];11.3查[unord.req]、[unord.map.modifiers]、[expr.call];11.4查[map.modifiers]与迭代器成员访问;11.5查[deque]、[queue]、[stack]。
- Microsoft标准库参考:map成员接口 ↗
11.2/11.4按成员名核对find、count、contains、at、operator[]、insert、emplace和try_emplace;只调用本章已讲的接口,不先读分配器、模板实现或异常处理。
- Microsoft标准库参考:unordered_map成员接口 ↗
11.3按成员名核对find、contains、operator[]和at;哈希相等、rehash与迭代器失效以固定C++20草案为准,不采用网页顶部对插入失效的过度概括,也不依赖其示例遍历顺序。
本章独立解释所需读法;资料用于核对与补充。工具版本、操作系统和实际执行状态见自己的运行记录。