C++ 竞赛入门
map 键值映射
按键找值:operator[] 会插入默认值而 find 不会,遍历按键升序。
map 键值映射
读完能做什么
- 能创建
map,用键m[key]存值、取值,并说出它和数组下标的区别。 - 能遍历
map,读出每个元素的first(键)和second(值),并解释输出为什么按键升序。 - 能用
count、find查询键、用erase删除键,并说清find查不存在的键不会插入、operator[]查不存在的键会插入默认值(用size()验证)。 - 能解释为什么迭代得到的键不能改,值可以改。
为什么需要它
数组用连续整数下标取数据,很适合“第几个”的问题:
int score[1005];
score[7] = 85; // 片段:下标 7 就是第 8 个位置
cout << score[7]; // 按位置直接取
但很多问题的“索引”不是整数位置,而是任意一种键:姓名、单词、编号。比如统计每个单词出现了几次,我们想直接问“apple 的计数是多少”。用数组得自己把单词映射成整数,再开一张对照表;用两个平行数组查找要线性扫描,很慢。
我们希望有一个容器,能像数组那样用中括号 [] 按“键”取数据,只不过键可以是字符串这样的任意可比较类型。这就是 map。
它是什么
一句话:map 是标准库提供的关联数组,它把**键(key)和值(value)**一对一地存起来,按键就能快速找到对应的值。
键值对。 map 里的每个元素由两部分组成:一个键、一个值。map<string, int> 表示“键是 string、值是 int”。元素类型是 pair<const string, int>——也就是把两个值绑在一起的 pair,其中 first 是键、second 是值,键带 const(只读)。
与数组下标的区别。 数组的下标是连续的整数位置,从 0 开始,中间不能空;map 的键是任意可比较的类型,可以是字符串、整数、甚至 pair,不必连续、不必从 0 开始。数组下标只能是整数,map 的键是什么类型由你决定。
键不重复。 一个键在 map 里最多对应一个值。重复给同一个键赋值只会覆盖旧值。
默认按键升序。 map 按键的默认比较规则(<)自动排序,所以遍历时输出总是按键从小到大。对 map<string, int> 就是按字典序。
键不能改,值能改。 遍历得到的元素里,first(键)是 const 的,不能改;second(值)可以随意改。因为键决定了元素在容器里的位置,改键会破坏有序性。
复杂度 O(log n)。 按键查找、插入、删除都是 O(log n),n 是键的个数。这比“用数组线性扫描”的 O(n) 快很多。
适用场景:需要“按某个键快速存取数据”,比如统计词频、姓名到成绩的对照、编号到信息的映射。使用时要 #include <map>,本页示例用 #include <bits/stdc++.h> 一次包含全部标准头文件。
选读。 map 内部通常用平衡二叉搜索树(红黑树)实现,这就是它能按键快速定位、并保持有序的原因。不了解树的结构也能正常使用 map。
最小可运行示例
数据写在代码里,不需要输入:
#include <bits/stdc++.h>
using namespace std;
int main() {
map<string, int> score; // 键是 string,值是 int
score["Li"] = 85; // 用键赋值:键不存在就先插入再赋值
score["Wang"] = 92;
cout << "size = " << score.size() << '\n';
cout << "Li 的成绩 = " << score["Li"] << '\n';
for (const auto& p : score) { // 每个元素是一个 pair<const string, int>
cout << p.first << " " << p.second << '\n';
}
return 0;
}
输出:
size = 2
Li 的成绩 = 85
Li 85
Wang 92
逐行看:
map<string, int> score;创建一个空map,键是string,值是int。score["Li"] = 85;用中括号按键访问。因为Li这个键还不存在,map会先插入一个值为默认值0的元素,再把它赋成 85。score.size()是 2,说明Li、Wang各占一个元素。for (const auto& p : score)遍历容器,p是一个pair<const string, int>,p.first是键、p.second是值。用const auto&是“只读地引用每个元素”,不复制、也不改动。- 输出顺序是
Li在前、Wang在后:按键升序排列,和赋值顺序无关。
一步步看变化
用一组词 apple banana apple pear banana apple 统计每个词出现的次数,每读一个词就打印一次当前状态:
#include <bits/stdc++.h>
using namespace std;
int main() {
string words[] = {"apple", "banana", "apple", "pear", "banana", "apple"};
map<string, int> cnt;
for (const string& w : words) {
cnt[w]++; // 不存在就先插入 0,再 +1
cout << "读入 " << w << " 后 size = " << cnt.size() << ",计数:";
for (const auto& p : cnt) cout << p.first << "=" << p.second << " ";
cout << '\n';
}
cout << "最终结果:\n";
for (const auto& p : cnt) cout << p.first << " " << p.second << '\n';
return 0;
}
输出:
读入 apple 后 size = 1,计数:apple=1
读入 banana 后 size = 2,计数:apple=1 banana=1
读入 apple 后 size = 2,计数:apple=2 banana=1
读入 pear 后 size = 3,计数:apple=2 banana=1 pear=1
读入 banana 后 size = 3,计数:apple=2 banana=2 pear=1
读入 apple 后 size = 3,计数:apple=3 banana=2 pear=1
最终结果:
apple 3
banana 2
pear 1
把每个词和计数变化对应起来:
| 读入的词 | cnt[w]++ 做了什么 |
计数(按键升序) | size() |
|---|---|---|---|
apple |
新键,先插入 0 再加 1 |
apple=1 |
1 |
banana |
新键 | apple=1 banana=1 |
2 |
apple |
已有键,直接加 1 | apple=2 banana=1 |
2 |
pear |
新键 | apple=2 banana=1 pear=1 |
3 |
banana |
已有键,直接加 1 | apple=2 banana=2 pear=1 |
3 |
apple |
已有键,直接加 1 | apple=3 banana=2 pear=1 |
3 |
cnt[w]++一行的效果分两种情况:w不在map里时,先插入一个值0的元素,再+1;w已经在时,直接+1。所以统计词频只需要一行。- 观察
size():只有第一次见到某个词时才从 1 变大,重复的词不会增加键的个数。 - 最终输出按键升序:
apple、banana、pear,和读入顺序apple banana pear不同也不影响结果。
逐项扩展核心操作
查找与计数:count、find
#include <bits/stdc++.h>
using namespace std;
int main() {
map<string, int> cnt = {{"apple", 3}, {"banana", 2}, {"pear", 1}};
cout << "count(\"banana\") = " << cnt.count("banana") << '\n';
cout << "count(\"kiwi\") = " << cnt.count("kiwi") << '\n';
auto it = cnt.find("banana");
if (it != cnt.end()) {
cout << "find 命中:" << it->first << " " << it->second << '\n';
}
cout << "查询后 size = " << cnt.size() << '\n';
return 0;
}
输出:
count("banana") = 1
count("kiwi") = 0
find 命中:banana 2
查询后 size = 3
cnt.count(key)返回键为key的元素个数。map里一个键最多一个元素,所以结果是 0 或 1。banana这个键存在,返回 1;kiwi不存在,返回 0。注意count数的是元素个数,不是值的大小。cnt.find(key)返回指向该元素的迭代器,找不到返回cnt.end()。用之前先和cnt.end()比较。it->first是键、it->second是值(it->first等价于(*it).first)。所以find("banana")命中后打印出键banana、值2。- 两次查询之后
size()仍是 3,find、count都不会插入。
find 不会插入,operator[] 会插入
这是 map 最容易踩的坑。下面用一个独立小例对比:先放一个键 apple,然后分别用 find 和 operator[] 去碰一个不存在的键 pear,每次都打印 size():
#include <bits/stdc++.h>
using namespace std;
int main() {
map<string, int> cnt;
cnt["apple"] = 3;
cout << "初始 size = " << cnt.size() << '\n';
// find 查不存在的键:不会插入
auto it = cnt.find("pear");
cout << "find(pear) 命中?" << (it != cnt.end())
<< ",之后 size = " << cnt.size() << '\n';
// operator[] 查不存在的键:插入一个默认值 0
int v = cnt["pear"];
cout << "读 cnt[pear] 得到 " << v
<< ",之后 size = " << cnt.size() << '\n';
// 现在这个键真的在容器里了,find 能命中
cout << "find(pear) 现在命中?" << (cnt.find("pear") != cnt.end()) << '\n';
return 0;
}
输出:
初始 size = 1
find(pear) 命中?0,之后 size = 1
读 cnt[pear] 得到 0,之后 size = 2
find(pear) 现在命中?1
| 操作 | 键不存在时的行为 | 输出里的证据 |
|---|---|---|
cnt.find("pear") |
只返回 cnt.end(),不插入 |
命中 0,size 仍是 1 |
cnt["pear"](读) |
插入一个默认值 0 的元素,返回它 |
得到 0,size 变成 2 |
所以:只想判断“这个键在不在”,用 find 或 count,不要用 operator[]。 否则一个只读的查询会悄悄改变容器,后面 size()、遍历结果都会多出不该有的键。
添加元素:insert 与下标
#include <bits/stdc++.h>
using namespace std;
int main() {
map<string, int> m;
m["apple"] = 3;
m["apple"] = 5; // 下标赋值:直接覆盖旧值
cout << "下标赋值后 apple = " << m["apple"] << '\n';
m.insert(make_pair("apple", 9)); // insert:键已存在则不覆盖
cout << "insert 后 apple = " << m["apple"] << '\n';
auto ret = m.insert(make_pair("pear", 1));
cout << "插入新键 pear 成功?" << ret.second
<< ",pear = " << m["pear"] << '\n';
return 0;
}
输出:
下标赋值后 apple = 5
insert 后 apple = 5
插入新键 pear 成功?1,pear = 1
| 写法 | 键已存在时 | 键不存在时 |
|---|---|---|
m[key] = v |
覆盖旧值 | 插入新键并赋值 |
m.insert(make_pair(key, v)) |
不覆盖,保持原值 | 插入新键 |
insert 返回一个 pair<iterator, bool>:first 是指向该键元素的迭代器,second 表示这次是否真的插入了新元素。上面 insert(make_pair("apple", 9)) 因为 apple 已存在,没有插入,apple 的值仍是 5;insert(make_pair("pear", 1)) 是新键,second 是 1(真),插入成功。
注意:
ret.second为真表示插入成功,为假表示键已存在、没有插入。这两个含义不要记反。
删除元素
#include <bits/stdc++.h>
using namespace std;
int main() {
map<string, int> m = {{"apple", 3}, {"banana", 2}, {"pear", 1}};
m.erase("banana"); // 按键删除
cout << "erase(banana) 后 size = " << m.size() << '\n';
auto it = m.find("pear");
if (it != m.end()) m.erase(it); // 按迭代器删除
cout << "erase(find(pear)) 后 size = " << m.size() << '\n';
for (const auto& p : m) cout << p.first << " " << p.second << '\n';
return 0;
}
输出:
erase(banana) 后 size = 2
erase(find(pear)) 后 size = 1
apple 3
| 写法 | 删除什么 | 备注 |
|---|---|---|
m.erase(key) |
键为 key 的那个元素 |
返回删掉的个数(map 上是 0 或 1) |
m.erase(it) |
it 指向的那个元素 |
it 必须有效,不能是 m.end() |
m.erase(first, last) |
区间 [first, last) 内的元素 |
选读 |
m.clear() |
全部元素 | size() 变 0 |
修改键要“先删后插”
遍历得到的元素里,first 是 const,不能直接改:
// 错误示例:map 元素的键是 const,不能直接赋值修改
#include <bits/stdc++.h>
using namespace std;
int main() {
map<string, int> m;
m["apple"] = 3;
auto it = m.begin();
it->first = "pear"; // 编译错误:first 是 const,不能改
return 0;
}
这段不能通过编译。如果确实要“改键”,只能先删除旧键、再插入新键:
#include <bits/stdc++.h>
using namespace std;
int main() {
map<string, int> m;
m["apple"] = 3;
int v = m["apple"]; // 先取出值
m.erase("apple"); // 删掉旧键
m["pear"] = v; // 用新键存回去
for (const auto& p : m) cout << p.first << " " << p.second << '\n';
return 0;
}
输出:
pear 3
什么时候选它、哪里容易错
operator[] 会插入,find、count 不会。 这是 map 最危险的坑:m["x"] 即使只是读,只要 x 不存在就会插入一个默认值元素。只判断存在性时用 count 或 find;只有确实想新建或取值时才用 []。
迭代得到的是 pair<const Key, T>,键不能改、值能改。 用 it->first 读键、it->second 读写值。想换键必须“先删后插”。
默认按键升序,和插入顺序无关。 需要按值排序、或按别的规则排序,得把元素先搬进 vector 再 sort。
一个键只对应一个值。 如果同一个键要多条记录,用 multimap 一键多值。
复杂度 O(log n)。 按键查找、插入、删除都是 O(log n)。如果不需要有序、只要查找快,可以用 unordered_map(哈希表,平均 O(1),选读)。
选读。 map 底层通常是红黑树,按键比较一步步下降,所以能保持有序。底层结构不是使用前提,掌握本页的操作就够了。
操作速查表
| 操作 | 作用 | 备注 |
|---|---|---|
map<K, V> m; |
创建空 map |
键 K、值 V |
map<K, V> m = {{k1,v1},{k2,v2}}; |
用一组键值对创建 | 按键自动排序 |
m[key] |
按键访问值 | 键不存在会插入默认值 |
m[key] = v |
按键赋值 | 已有键则覆盖 |
m.insert(make_pair(k, v)) |
插入键值对 | 键已存在则不覆盖,返回 pair<iterator,bool> |
m.count(key) |
键的个数 | 0 或 1 |
m.find(key) |
返回指向该键的迭代器 | 找不到返回 m.end() |
m.erase(key) |
按键删除 | 返回删掉的个数 |
m.erase(it) |
按迭代器删除 | it 必须有效 |
m.size() |
键的个数 | 类型是无符号整数 |
m.empty() |
是否为空 | 打印成 0/1 |
m.clear() |
删除全部元素 | size() 变 0 |
it->first / it->second |
键 / 值 | first 是 const,不能改 |
动手检查
第 1 题:预测输出
下面是一段片段(省略了头文件和 main),接着最小示例的统计结果又读入了 banana 和 apple 两个词。请先手推出输出,再运行核对:
map<string, int> cnt = {{"apple", 3}, {"banana", 2}, {"pear", 1}};
vector<string> more = {"banana", "apple"}; // 再读入两个词
for (const string& w : more) cnt[w]++;
cout << "apple = " << cnt["apple"]
<< ",banana = " << cnt["banana"]
<< ",pear = " << cnt["pear"] << '\n';
第 2 题:修改代码
下面这段片段按键是字符串统计个数。请把它改成按整数编号统计 {5, 3, 5} 的出现次数,让输出变成两行 3 1 和 5 2(按编号升序):
map<string, int> cnt; // 键现在是字符串
vector<string> data = {"a", "b", "a"};
for (const string& x : data) cnt[x]++;
for (const auto& p : cnt) cout << p.first << " " << p.second << '\n';
验证条件:先说出 3 和 5 各出现几次、输出顺序如何,再运行核对。
参考结果与解释
第 1 题:输出是 apple = 4,banana = 3,pear = 1。cnt[w]++ 对已有的键直接加 1:banana 从 2 变 3,apple 从 3 变 4,pear 没被读到,仍是 1。完整程序与实测输出:
#include <bits/stdc++.h>
using namespace std;
int main() {
map<string, int> cnt = {{"apple", 3}, {"banana", 2}, {"pear", 1}};
vector<string> more = {"banana", "apple"}; // 再读入两个词
for (const string& w : more) cnt[w]++;
cout << "apple = " << cnt["apple"]
<< ",banana = " << cnt["banana"]
<< ",pear = " << cnt["pear"] << '\n';
return 0;
}
输出:
apple = 4,banana = 3,pear = 1
第 2 题:把键的类型从 string 换成 int,数据换成整数即可。map<int, int> 默认按键升序,所以先输出 3、再输出 5。
#include <bits/stdc++.h>
using namespace std;
int main() {
map<int, int> cnt; // 键换成整数编号
vector<int> data = {5, 3, 5};
for (int x : data) cnt[x]++;
for (const auto& p : cnt) cout << p.first << " " << p.second << '\n';
return 0;
}
输出:
3 1
5 2
cnt[x]++ 的逻辑和词频统计完全一样:3 第一次出现时插入并加 1,5 出现两次所以是 2,最后按键升序输出。改成整数键后,map 就变成了“用任意整数当索引的数组”。
立即练习
| 难度 | 题目 | 题解 | 训练目标 |
|---|---|---|---|
| 普及/提高- | 洛谷 P2580:于是他错误的点名开始了 | 题解 | 用 map 建立“名字 → 状态”的对照,支持“没出现过 / 第一次点到 / 重复点到”三种判断,练习 count、find 与插入。 |
| 普及- | 洛谷 P1102:A-B 数对 | 题解 | 用 map 统计每个数出现的次数,再对每个数查 a - c 的计数,练习“按值计数 + 按键查询”的组合。前置知识:数组、循环、本页的 map 操作。 |
两道题都只需要数组、循环和本页讲过的 map 操作。做完后请说明:为什么这里用 map 比用数组更合适?