STL 容器不是一个单独的数据结构,而是一组通用的模板类。算法通常不直接依赖某个具体容器,而是通过迭代器操作容器中的元素。
当前 C++ 容器库主要分为:
- 顺序容器:
array、vector、deque、list、forward_list - 有序关联容器:
set、multiset、map、multimap - 无序关联容器:
unordered_set、unordered_multiset、unordered_map、unordered_multimap - 容器适配器:
stack、queue、priority_queue - 较新的平面容器:
flat_set、flat_multiset、flat_map、flat_multimap - 较新的固定容量或块状容器:
inplace_vector、hive
一、理解容器必须先掌握几个概念
1.size和capacity
以vector为例:
std::vector<int>v;
它内部大致有三个信息:
begin 指向第一个元素
end 指向最后一个元素之后
capacity 指向当前已分配空间的末尾
eg:
size=3
capacity=5
10\]\[20\]\[30\]\[空\]\[空
begin capacity
size():当前已经构造出来的元素数量capacity():当前已经分配的存储空间可以容纳多少个元素reserve(n):提前申请容量resize(n):真正改变元素数量
std::vector<int>v;
v.reserve(100);//capacity至少为100,但是size仍然是0
v.resize(100);//size变成100,元素真正构造出来
2.迭代器
迭代器可以理解为"广义指针"
cpp
std::vector<int> v{1, 2, 3};
for (auto it = v.begin(); it != v.end(); ++it) {
std::cout << *it << '\n';
}
不同容器的迭代器能力不同:
| 迭代器类型 | 能力 |
|---|---|
| InputIterator | 只能读取、向前移动 |
| ForwardIterator | 可以多次遍历、向前移动 |
| BidirectionalIterator | 可以前后移动 |
| RandomAccessIterator | 可以 it + n、比较距离 |
| ContiguousIterator | 元素连续存储 |
forward_list 单项迭代器
list 双向迭代器
set/map 双向迭代器
vector/deque 随机访问迭代器
array 连续迭代器
3.迭代器失效
cpp
std::vector<int> v{1,2,3};
auto it=v.begin();
v.push_back(4);
原因是vector扩容后,所有元素可能被搬到一块新的内存中,原来的迭代器、指针、引用都不再有效
| 容器 | 插入时稳定性 |
|---|---|
vector |
扩容可能使全部迭代器失效 |
deque |
迭代器失效规则较复杂 |
list |
插入通常不影响其他元素迭代器 |
forward_list |
插入通常不影响其他元素迭代器 |
set/map |
插入通常不影响其他迭代器 |
unordered_map |
重新哈希会使迭代器失效 |
flat_* |
类似 vector,移动元素可能导致失效 |
所以修改容器之后,不要继续使用之前保存的迭代器,除非你非常清楚该容器的失效规则
二、顺序容器
顺序容器中的元素按照线性顺序排列
1.std::array(不支持大小改变,大小写在模版参里了)
头文件:#include<array>
表层概念:
std::array<T,N>是固定长度数组
std::array<int,5>a;
它和原生数组类似,但是提供了STL风格的接口:
- size()
- begin()
- end()
- front()
- back()
- at()
- fill()
- data()
底层原理:
std::array<int,5>本质上就是对对象内部直接保存五个int
array 对象
+----+----+----+----+----+
| 10 | 20 | 30 | 40 | 50 |
+----+----+----+----+----+
它不会动态分配内存
cpp
std::array<int,5>a;
a是局部变量,通常在栈上
cpp
auto p=new std::array<int,5>;
通过new创建,则整个对象位于堆上
复杂度:
| 操作 | 复杂度 |
|---|---|
| 随机访问 | O(1) |
| 修改元素 | O(1) |
| 查找 | O(n) |
| 插入/删除 | 不支持改变长度 |
#include <array>
#include <iostream>
int main() {
std::array<int, 5> a{10, 20, 30, 40, 50};
std::cout << a[2] << '\n';
std::cout << a.at(2) << '\n';
a.fill(0);
for (int x : a) {
std::cout << x << ' ';
}
}
operator[] 越界时不会检查,at() 越界会抛出异常。
适用场景:
- 大小编译期固定
- 追求性能,不想有堆分配
2.std::vector
头文件:
#include <vector>
表层概念
vector 是最常用的 STL 容器,可以理解为"自动扩容的动态数组"。
std::vector<int> v;
特点:
- 元素连续存储
- 支持随机访问
- 尾部插入很快
- 中间插入、删除较慢
- 可以自动扩容
底层原理
vector 通常维护一块连续内存:
[10][20][30][空][空][空]
当容量不足时,通常会:
- 申请一块更大的连续空间
- 将旧元素移动或复制过去
- 销毁旧元素
- 释放旧内存
- 更新内部指针
扩容倍数由实现决定,不能依赖具体是 1.5 倍还是 2 倍。
std::vector<int> v;
for (int i = 0; i < 1000; ++i) {
v.push_back(i);
}
虽然某一次扩容可能需要 O(n),但整体平均下来,尾部插入是摊销 O(1)。
复杂度
| 操作 | 复杂度 |
|---|---|
v[i] |
O(1) |
front/back(可以通过计算得到) |
O(1) |
| 尾部插入 | 摊销 O(1) |
| 中间插入 | O(n) |
| 尾部删除 | O(1) |
| 中间删除 | O(n) |
| 查找 | O(n) |
| 排序 | O(n log n) |
示例
#include <vector>
#include <iostream>
int main() {
std::vector<int> v{1, 2, 3};
v.push_back(4);
v.emplace_back(5);
v.insert(v.begin() + 1, 100);
v.erase(v.begin() + 2);
for (int x : v) {
std::cout << x << ' ';
}
}
reserve 的作用
如果提前知道元素数量:
std::vector<int> v;
v.reserve(100000);
for (int i = 0; i < 100000; ++i) {
v.push_back(i);
}
这样可以减少扩容次数。
注意:
v.reserve(100); // 只改变 capacity
v.resize(100); // 改变 size,并创建 100 个元素
迭代器失效
发生扩容时:
- 所有迭代器失效
- 所有指针失效
- 所有引用失效
即使没有扩容,在中间插入或删除时,插入/删除位置之后的迭代器通常也会失效。
适用场景
默认优先考虑 vector:
- 需要动态数组
- 需要随机访问
- 主要在尾部添加元素
- 希望良好的缓存局部性
- 需要把数据传给 C 接口
3.std::vector<bool>
这是 vector 的特殊版本。
std::vector<bool> flags;
底层原理
普通的:
std::vector<int>
通常一个元素占 4 字节。
但 vector<bool> 通常会把一个 bool 压缩成一个 bit:
101100101010...
因此它节省空间。
但是它并不真正保存一组普通的 bool 对象。访问元素时:
flags[0]
返回的通常不是 bool&,而是一个代理对象。
示例
#include <vector>
int main() {
std::vector<bool> flags(10);
flags[3] = true;
flags[5] = true;
if (flags[3]) {
// ...
}
}
优缺点
优点:
- 节省空间
- 适合大量布尔标志
缺点:
- 不是普通
vector<T>的行为 - 不能正常取得
bool& - 与模板代码组合时容易产生意外
如果你需要普通的布尔数组,可以考虑:
std::vector<char>
4.std::deque
头文件:
#include <deque>
deque 读作 double-ended queue,双端队列。
表层概念
deque 支持在两端高效插入和删除:
std::deque<int> d;
d.push_front(1);
d.push_back(2);
它也支持随机访问:
d[3];
底层原理
deque 通常不是一整块连续内存,而是:
中央索引数组
|
+----> 数据块 1:[1][2][3][4]
|
+----> 数据块 2:[5][6][7][8]
|
+----> 数据块 3:[9][10][11][12]
中央索引数组保存多个数据块的地址。
因此:
- 头部扩展不需要整体搬迁
- 尾部扩展不需要整体搬迁
- 随机访问时,需要先定位数据块,再定位块内偏移
- 元素通常不连续
复杂度
| 操作 | 复杂度 |
|---|---|
| 随机访问 | O(1) |
| 头部插入 | O(1) |
| 尾部插入 | O(1) |
| 头部删除 | O(1) |
| 尾部删除 | O(1) |
| 中间插入 | O(n) |
| 中间删除 | O(n) |
示例
#include <deque>
#include <iostream>
int main() {
std::deque<int> d;
d.push_back(2);
d.push_front(1);
d.push_back(3);
std::cout << d[1] << '\n';
d.pop_front();
d.pop_back();
for (int x : d) {
std::cout << x << ' ';
}
}
与 vector 的区别
| 特性 | vector |
deque |
|---|---|---|
| 内存连续 | 是 | 通常不是 |
| 尾部插入 | 快 | 快 |
| 头部插入 | 慢 | 快 |
| 随机访问 | O(1) | O(1) |
| 缓存友好性 | 更好 | 略差 |
支持 data() |
支持 | 不保证连续,不适合直接作为数组 |
适用场景
- 需要两端插入和删除
- 需要随机访问
- 不要求连续内存
- 实现任务队列、滑动窗口
5.std::list
头文件:
#include <list>
表层概念
list 是双向链表。
每个节点保存:
-
数据
-
指向前一个节点的指针
-
指向后一个节点的指针
nullptr <- [prev | data | next] <-> [prev | data | next] -> nullptr
底层原理
典型节点结构:
struct Node {
Node* prev;
Node* next;
T value;
};
插入一个节点时,只需要修改相邻节点的指针:
原来:
A <-> B
插入 X:
A <-> X <-> B
不需要搬动其他元素。
复杂度
| 操作 | 复杂度 |
|---|---|
| 访问第 n 个元素 | O(n) |
| 头部插入 | O(1) |
| 尾部插入 | O(1) |
| 已知位置插入 | O(1) |
| 已知位置删除 | O(1) |
| 查找 | O(n) |
示例
#include <list>
#include <iostream>
int main() {
std::list<int> values{1, 2, 4};
auto it = values.begin();
++it;
++it;
values.insert(it, 3);
for (int x : values) {
std::cout << x << ' ';
}
}
list::splice
list 有一个很有特色的操作:splice。
它可以把一个链表中的节点直接转移到另一个链表中,通常不需要复制元素。
cpp
// 1. 转移单个节点:把 it 指向的节点放到 pos 之前
void splice(const_iterator pos, list& other, const_iterator it);
// 2. 转移一段区间:把 [first, last) 区间内的节点放到 pos 之前
// 注意:last 指向的元素不包含在内
void splice(const_iterator pos, list& other,
const_iterator first, const_iterator last);
// 3. 转移整条链表:把 other 的所有节点放到 pos 之前
void splice(const_iterator pos, list& other);
三种转移元素的方式
#include <list>
#include <iostream>
int main() {
std::list<int> a{1, 2, 3};
std::list<int> b{10, 20};
auto pos = a.begin();
++pos;
a.splice(pos, b);
for (int x : a) {
std::cout << x << ' ';
}
}
缺点
每个元素至少额外需要两个指针:
数据 + prev 指针 + next 指针
而且节点分散在堆内存中,缓存局部性较差。
因此,很多情况下虽然理论上链表插入是 O(1),实际性能却可能不如 vector。
适用场景
- 已经拥有某个位置的迭代器
- 需要频繁在中间插入、删除
- 需要节点稳定地址
- 需要
splice
不要因为"插入 O(1)"就默认使用 list。如果你每次都要先遍历到插入位置,整体仍然是 O(n)。
6.std::forward_list
头文件:#include<forward_list>
表层概念
forward_list 是单向链表。
每个节点只有一个 next 指针:
[A] -> [B] -> [C] -> nullptr
底层原理
struct Node {
Node* next;
T value;
};
相比 list,它少了一个 prev 指针,因此占用空间更少。
但是它只能向前遍历,不能后退。
特殊接口
因为它是单向链表,所以主要接口是:
before_begin()
insert_after()
erase_after()
而不是普通 list 的:
insert()
erase()
示例
#include <forward_list>
#include <iostream>
int main() {
std::forward_list<int> values{1, 3, 4};
auto it = values.before_begin();
++it;
values.insert_after(it, 2);
values.remove(4);
for (int x : values) {
std::cout << x << ' ';
}
}
为什么没有 size()
forward_list 通常不提供 size() 成员函数。
因为单向链表为了节省空间,可以不额外维护元素数量。如果调用:
std::distance(fl.begin(), fl.end());
就必须遍历整个链表,复杂度是 O(n)。
适用场景
- 只需要单向遍历
- 希望比
list更节省空间 - 频繁在已知位置后面插入或删除
- 实现简单链表、邻接表
7.std::inplace_vector
这是较新的容器,实际使用时要看编译器和标准库实现支持情况。
标准草案中,inplace_vector 是一种连续存储、容量固定且元素直接存放在容器对象内部的容器。
表层概念
它类似:
std::vector<T>
但最大容量在编译期确定:
std::inplace_vector<int, 100> v;
最多存储 100 个元素。
底层原理
inplace_vector 对象内部:
[元素存储区,最多容纳 100 个 int]
不会进行普通 vector 那样的堆扩容。
适用场景
- 实时系统
- 嵌入式系统
- 不希望动态分配内存
- 最大元素数量已知
- 仍然需要动态改变当前
size
示意代码:
#include <inplace_vector>
int main() {
std::inplace_vector<int, 100> values;
values.push_back(10);
values.push_back(20);
}
8. std::hive
hive 是较新的块状序列容器。标准草案描述它使用多个元素块管理存储,并且删除后的空间可以被后续插入重新利用。
表层概念
它适合:
- 元素频繁插入和删除
- 希望元素地址尽可能稳定
- 不要求元素按照插入顺序排列
- 不需要随机访问
底层原理
内部大致是多个块:
块 1:[A][B][空][D]
块 2:[E][F][G]
块 3:[H][空][J]
删除元素后,位置可能变成空洞;后续插入可以重新利用这些空位。
示例
#include <hive>
int main() {
std::hive<int> values;
values.push_back(1);
values.push_back(2);
values.push_back(3);
values.erase(values.begin());
values.insert(4);
}
它适合特殊场景,不是日常代码中的默认容器。
三、有序关联容器
有序关联容器通常基于平衡搜索树实现
它们的共同特点:
- 元素按照比较器排序
- 查找、插入、删除通常为 O(log n)
- 迭代器遍历结果有序
- 插入通常不会让已有迭代器失效
标准只规定行为和复杂度,不强制要求具体使用红黑树,但绝大多数实现采用红黑树或类似平衡树。
1.std::set
头文件:
#include<set>
表层概念
set 保存唯一的键,并自动排序。
std::set<int> s{5, 1, 3, 3};
结果是:
1 3 5
重复的 3 只保存一次。
底层原理
典型实现是平衡二叉搜索树:
3
/ \
1 5
插入元素时:
- 从根节点开始比较
- 小于当前节点则向左
- 大于当前节点则向右
- 插入后通过旋转和重新着色保持平衡
复杂度
| 操作 | 复杂度 |
|---|---|
| 插入 | O(log n) |
| 删除 | O(log n) |
| 查找 | O(log n) |
lower_bound |
O(log n) |
| 遍历 | O(n) |
示例
#include <set>
#include <iostream>
int main() {
std::set<int> s{5, 1, 3, 3};
s.insert(4);
s.erase(1);
if (s.contains(3)) {
std::cout << "found\n";
}
auto it = s.lower_bound(4);
for (int x : s) {
std::cout << x << ' ';
}
}
遍历结果始终按照比较器排序。
适用场景
- 需要去重
- 需要自动有序
- 需要频繁查找、插入和删除
- 不需要通过下标访问
2.std::multiset
表层概念
multiset 和 set 类似,但允许重复键。
std::multiset<int> ms{1, 2, 2, 3, 3, 3};
内容是:
1 2 2 3 3 3
底层原理
同样通常是平衡搜索树。
与 set 的区别只是:遇到等价键时,不拒绝插入,而是把它插入到等价元素范围中。
示例
#include <set>
#include <iostream>
int main() {
std::multiset<int> scores{90, 90, 80, 70};
scores.insert(90);
std::cout << scores.count(90) << '\n';
auto range = scores.equal_range(90);
for (auto it = range.first; it != range.second; ++it) {
std::cout << *it << ' ';
}
}
适用场景
- 需要排序
- 允许重复元素
- 需要统计某个值出现次数
- 需要查找某个值的全部范围
3.std::map
头文件:
#include <map>
表层概念
map 保存键值对:
key -> value
例如:
std::map<std::string, int> ages;
可以表示:
Alice -> 18
Bob -> 20
每个键唯一。
底层原理
map 通常也是平衡搜索树。
节点类似:
struct Node {
const Key key;
T value;
Node* left;
Node* right;
};
关键点:
std::map<Key, T>::value_type
通常是:
std::pair<const Key, T>
键是 const,因为如果允许你直接修改键,就可能破坏树的排序结构。
复杂度
| 操作 | 复杂度 |
|---|---|
| 插入 | O(log n) |
| 删除 | O(log n) |
| 查找 | O(log n) |
operator[] |
O(log n) |
| 遍历 | O(n) |
示例
#include <map>
#include <iostream>
#include <string>
int main() {
std::map<std::string, int> ages;
ages["Alice"] = 18;
ages["Bob"] = 20;
ages.insert({"Charlie", 25});
ages.emplace("David", 30);
std::cout << ages["Alice"] << '\n';
for (const auto& [name, age] : ages) {
std::cout << name << ": " << age << '\n';
}
}
operator[] 的陷阱
std::map<std::string, int> scores;
std::cout << scores["Tom"];
如果 "Tom" 不存在,operator[] 会自动插入:
"Tom" -> 0
如果你只想查找,不希望插入,使用:
auto it = scores.find("Tom");
或者:
scores.at("Tom");
at() 找不到时会抛出 std::out_of_range。
try_emplace
std::map<std::string, std::string> users;
users.try_emplace("Alice", "admin");
如果 "Alice" 已经存在,就不会构造新的值对象。
适用场景
- 根据键查找值
- 需要键自动排序
- 需要 O(log n) 的稳定查找性能
- 需要范围查询,例如查找某个区间内的键
4.std::multimap
表层概念
multimap是允许一个键对应多个值的有序映射
课程 -> 学生
数学 -> Alice
数学 -> Bob
英语 -> Tom
示例
#include <map>
#include <iostream>
#include <string>
int main() {
std::multimap<std::string, std::string> courses;
courses.emplace("Math", "Alice");
courses.emplace("Math", "Bob");
courses.emplace("English", "Tom");
auto range = courses.equal_range("Math");
for (auto it = range.first; it != range.second; ++it) {
std::cout << it->first << ": " << it->second << '\n';
}
}
适用场景
- 一个键对应多个值
- 需要按照键排序
- 需要使用
equal_range获取全部关联值
如果一个键对应多个值,也可以使用:
std::map<Key, std::vector<Value>>
这种设计往往更方便管理。
四、无序关联容器
无序关联容器通常基于哈希表实现。
共同特点:
- 不保证遍历顺序
- 平均查找、插入、删除为 O(1)
- 最坏情况可能退化为 O(n)
- 通过哈希函数定位元素
1.哈希表底层原理
假设有:
std::unordered_map<std::string, int> table;
内部可能类似:
bucket 0 -> 节点
bucket 1 -> 节点 -> 节点
bucket 2 -> 空
bucket 3 -> 节点
bucket 4 -> 节点 -> 节点
插入键 "Alice" 时:
hash("Alice") -> 某个整数
bucket = hash("Alice") % bucket_count
如果多个键落入同一个 bucket,就发生哈希冲突。
常见解决方式是:
- 链地址法
- 桶中挂链表
- 更复杂的开放寻址或混合结构
具体实现由标准库决定。
当负载因子过高时,容器会重新哈希:
旧桶数组 -> 新桶数组
这会重新计算所有元素的位置。
2. std::unordered_set
表层概念
unordered_set 保存唯一键,但不排序。
std::unordered_set<int> s{5, 1, 3, 3};
其中只保存:
1, 3, 5
但遍历顺序不确定。
复杂度
| 操作 | 平均复杂度 | 最坏复杂度 |
|---|---|---|
| 查找 | O(1) | O(n) |
| 插入 | O(1) | O(n) |
| 删除 | O(1) | O(n) |
示例
#include <unordered_set>
#include <iostream>
int main() {
std::unordered_set<int> s{1, 2, 3};
s.insert(4);
s.erase(2);
if (s.contains(3)) {
std::cout << "found\n";
}
for (int x : s) {
std::cout << x << ' ';
}
}
适用场景
- 只关心元素是否存在
- 不需要排序
- 需要平均 O(1) 查找
- 去重
3. std::unordered_multiset
允许重复键,不保证顺序。
#include <unordered_set>
#include <iostream>
int main() {
std::unordered_multiset<int> values{
1, 2, 2, 3, 3, 3
};
std::cout << values.count(3) << '\n';
}
适合:
- 统计频率
- 不需要顺序的重复元素集合
- 只关心平均 O(1) 查找