C++ STL 容器怎么选?vector、list、map、unordered_map 对比
关键词:C++、STL、容器选择、vector、list、map、unordered_map、性能对比
选错容器,是 C++ 性能问题的头号温床。
很多代码里能看到这样的场景:
- 明明只有几十个元素,却用
std::map - 频繁随机插入,却死守
std::vector - 用
std::list存几万条数据,结果 cache miss 拖垮性能
STL 不慢,慢的是用错容器的程序员。
这篇文章从底层结构 → 时间复杂度 → 内存布局 → 真实场景,帮你建立一套"闭眼都能选对"的容器决策模型。
一、先给结论:一张表看懂四大容器
| 容器 | 底层结构 | 随机访问 | 插入/删除 | 内存连续性 | 典型场景 |
|---|---|---|---|---|---|
vector |
动态数组 | ✅ O(1) | 尾部 O(1),中间 O(n) | ✅ 连续 | 默认首选,顺序存储 |
list |
双向链表 | ❌ O(n) | 任意位置 O(1) | ❌ 不连续 | 频繁中间插入/删除 |
map |
红黑树 | ❌ O(log n) | O(log n) | ❌ 不连续 | 有序 key、范围查询 |
unordered_map |
哈希表 | ❌ O(1) 均摊 | O(1) 均摊 | ❌ 不连续 | 高频查找、key 无序 |
二、vector:99% 场景的默认答案
底层结构
┌───┬───┬───┬───┬───┬───┐
│ 1 │ 2 │ 3 │ 4 │ 5 │ │ ← 连续内存
└───┴───┴───┴───┴───┴───┘
vector 是一块动态扩容的连续数组。当容量不够时,重新分配更大的内存块,拷贝旧元素,释放旧内存。
时间复杂度
| 操作 | 复杂度 | 说明 |
|---|---|---|
| 随机访问 | O(1) | v[i] 直接指针偏移 |
| 尾部插入 | O(1) 均摊 | 偶尔触发扩容 |
| 中间插入 | O(n) | 需要搬移后续元素 |
| 尾部删除 | O(1) | |
| 中间删除 | O(n) | 同上 |
为什么它这么快?
核心原因:CPU Cache 友好。
连续内存 = 预取命中率高 = 极少 cache miss。在现代 CPU 上,这比算法复杂度本身影响更大。
// 遍历 vector ------ cache 友好
for (int i = 0; i < v.size(); ++i) {
sum += v[i]; // 连续访问,预取器开心
}
什么时候选 vector?
✅ 默认首选,没有理由不选它
✅ 频繁随机访问
✅ 尾部插入为主
✅ 数据量不大,或者一次性批量构建
✅ 需要跟 C API 交互(连续内存)
什么时候不选?
❌ 频繁在头部/中间插入删除(O(n) 搬移)
❌ 元素拷贝代价极高且不能移动(考虑 vector<unique_ptr<T>>)
性能技巧
vector<int> v;
v.reserve(10000); // 预分配,避免多次扩容
三、list:被高估的容器
底层结构
┌───┐ ┌───┐ ┌───┐
│ 1 │──→│ 3 │──→│ 5 │
└───┘ └───┘ └───┘
↑ ↑ ↑
└───────┴───────┘
双向指针,节点散落各处
每个元素是一个独立堆分配节点,通过指针串起来。
时间复杂度
| 操作 | 复杂度 | 说明 |
|---|---|---|
| 随机访问 | O(n) | 必须从头遍历 |
| 任意位置插入 | O(1) | 找到位置后只改指针 |
| 任意位置删除 | O(1) | 同上 |
致命弱点:Cache Miss
链表节点在堆上随机分布,遍历时 CPU 几乎每次都要从主存取数据:
// 遍历 list ------ cache 灾难
for (auto it = l.begin(); it != l.end(); ++it) {
sum += *it; // 每次跳到不同内存地址
}
实测中,遍历一个 list 比遍历同等大小的 vector 慢 5~10 倍,即使算法复杂度相同。
什么时候选 list?
✅ 需要稳定迭代器(插入删除不使其他迭代器失效)
✅ 频繁在已知迭代器位置插入/删除
✅ 元素极大、拷贝代价高、且插入位置明确
✅ 需要 splice(O(1) 拼接两个 list)
什么时候不选?
❌ 随机访问
❌ 遍历为主
❌ 数据量中等以上(cache miss 惩罚巨大)
经验法则 :如果你不确定要不要选
list,那就别选。
四、map:有序字典
底层结构:红黑树
8(B)
/ \
3(R) 10(R)
/ \ \
1(B) 6(B) 14(B)
/ \ /
4(R) 7(R)13(R)
红黑树是一种自平衡二叉搜索树,保证:
- 树高 = O(log n)
- 插入/删除/查找 = O(log n)
- 中序遍历 = 有序
时间复杂度
| 操作 | 复杂度 |
|---|---|
| 查找 | O(log n) |
| 插入 | O(log n) |
| 删除 | O(log n) |
| 范围查询 | O(log n + k) |
核心优势:有序性
map<int, string> m = {{3, "c"}, {1, "a"}, {2, "b"}};
for (auto& [k, v] : m) {
cout << k << ":" << v << " "; // 1:a 2:b 3:c
}
map 天然按 key 排序,支持:
auto it = m.lower_bound(10); // 找到 ≥10 的第一个元素
auto it2 = m.upper_bound(20); // 找到 >20 的第一个元素
什么时候选 map?
✅ 需要 key 有序
✅ 需要范围查询(lower_bound / upper_bound)
✅ 需要按序遍历
✅ 数据量中等(几千~几十万)
什么时候不选?
❌ 只需要查找,不需要有序(用 unordered_map)
❌ 数据量极大且对延迟极度敏感
五、unordered_map:哈希表
底层结构
bucket[0] → key1 → key2
bucket[1] → key3
bucket[2] → ∅
bucket[3] → key4 → key5 → key6
哈希表 = 数组 + 链表(或红黑树,取决于实现)。通过哈希函数将 key 映射到 bucket。
时间复杂度
| 操作 | 平均 | 最坏 |
|---|---|---|
| 查找 | O(1) | O(n) |
| 插入 | O(1) | O(n) |
| 删除 | O(1) | O(n) |
最坏情况发生在所有 key 哈希冲突时(比如哈希函数写烂了)。
核心优势:极速查找
unordered_map<string, int> dict;
dict["hello"] = 1;
dict["world"] = 2;
// O(1) 查找
auto it = dict.find("hello");
代价
- key 无序
- 哈希函数质量直接影响性能
- 负载因子高时需要 rehash(所有元素重新分布)
- 内存占用比
map大(桶数组 + 链表指针)
什么时候选 unordered_map?
✅ 高频查找/插入/删除
✅ key 不需要有序
✅ 数据量大(万级以上)
✅ 能接受偶尔的 rehash 停顿
什么时候不选?
❌ 需要有序遍历
❌ 需要范围查询
❌ 对延迟极度敏感(rehash 可能卡顿)
❌ key 的哈希函数难以设计
性能技巧
unordered_map<int, int> m;
m.reserve(10000); // 预分配桶,减少 rehash
m.max_load_factor(0.7); // 控制负载因子
六、终极决策树
需要存储数据
│
├─ 需要按索引随机访问?
│ └─ YES → vector ✅
│
├─ 需要频繁在中间插入/删除?
│ ├─ 插入位置已知(有迭代器)?
│ │ └─ YES → list(谨慎评估 cache 影响)
│ └─ NO → vector + 批量操作
│
├─ 需要 key-value 查找?
│ ├─ key 需要有序 / 范围查询?
│ │ └─ YES → map
│ └─ NO → unordered_map ✅
│
└─ 不确定 → vector(默认答案)
七、常见误区与真相
误区 1:"list 插入删除 O(1),所以比 vector 快"
真相:O(1) 是算法复杂度,不是实际运行时间。list 的 O(1) 插入后面跟着一次堆分配 + cache miss,而 vector 的 O(n) 搬移是连续内存拷贝(memcpy 级别速度)。当 n 不大时,vector 反而更快。
误区 2:"map 太慢,全换成 unordered_map"
真相:map 的 O(log n) 在 n 不大时(< 1万)和 O(1) 差距微乎其微。而 map 的有序性、稳定性、无 rehash 停顿,在某些场景是刚需。
误区 3:"vector 扩容拷贝很贵"
真相 :vector 扩容时用的是 memcpy 级别的内存移动,而且 reserve() 可以完全避免。现代 CPU 搬移连续内存的速度远超你的想象。
误区 4:"用 deque 代替 vector"
deque 是双端队列,头尾插入都是 O(1),但内存不是完全连续的(分段数组)。它介于 vector 和 list 之间,适合队列场景,但遍历性能不如 vector。
八、实战对比:选对容器,性能差 10 倍
| 场景 | 错误选择 | 正确选择 | 原因 |
|---|---|---|---|
| 存储 1000 个整数,遍历求和 | list |
vector |
cache 友好 |
| 配置表查找(启动后只读) | unordered_map |
vector + 排序后二分 |
无插入,遍历更快 |
| 高频交易订单簿(按价格排序) | unordered_map |
map |
需要有序 + 范围查询 |
| 用户会话缓存(百万级) | map |
unordered_map |
纯查找,不需要有序 |
| 日志缓冲区(只追加) | list |
vector |
尾部追加,连续内存 |
九、现代 C++ 的补充选择
除了四大经典容器,C++11/17/20 还引入了:
| 容器 | 适用场景 |
|---|---|
array |
编译期固定大小数组,零开销抽象 |
deque |
双端队列,头尾都快 |
set / unordered_set |
只需要 key 不需要 value |
flat_map (C++23 / Abseil) |
有序 + 连续内存,vector + 二分 |
span (C++20) |
零开销视图,不拥有内存 |
十、总结
STL 容器选择的核心原则:
- 默认用
vector------ 连续内存是王道 - 需要查找再考虑 map/unordered_map ------ 先问自己要不要有序
- list 是最后的选择 ------ 不是不能用,是大多数时候不该用
- 预分配容量 ------
reserve()是免费的午餐 - 用数据说话 ------ 不确定的时候 benchmark,别靠直觉
容器选对了,代码就赢了一半。