四、STL 容器与数据结构(第 10~17 项补全)
一句话总览:红黑树和哈希表分别支撑有序关联容器与无序关联容器;二叉树是很多搜索结构的基础;LRU 是"哈希表 + 双向链表"的经典组合;容器选择的本质是根据访问、增删、有序性、内存和稳定性需求做权衡;迭代器则把算法和容器解耦。
10~17 知识点关系
基础数据结构
├─ 12. 二叉树
│ ├─ 二叉搜索树 BST
│ ├─ 平衡树
│ │ └─ 10. 红黑树 ──> map/set
│ └─ 堆 ──> priority_queue
│
├─ 11. Hash
│ └─ 哈希表 ──> unordered_map/unordered_set
│
├─ 14. 数组 vs 链表
│ ├─ 数组 ──> vector / deque
│ └─ 链表 ──> list / forward_list
│
├─ 13. LRU
│ └─ 哈希表 + 双向链表
│
├─ 16. 迭代器
│ └─ 连接容器与泛型算法
│
├─ 17. vector 底层与扩容
│
└─ 15. 容器选择原则
└─ 根据访问模式、增删位置、有序性、查找方式和内存特征选择
10. 介绍一下红黑树
核心:红黑树是一种自平衡二叉搜索树,它通过"节点颜色 + 旋转 + 重新染色"保证树高约为 O(log n),从而使查找、插入、删除都保持 O(log n)。C++ 中的 map/set/multimap/multiset 主流实现通常使用红黑树。
红黑树首先是一棵二叉搜索树:
左子树 key < 根节点 key < 右子树 key
普通二叉搜索树如果按有序数据插入,可能退化成链表:
1
\
2
\
3
\
4
红黑树通过规则避免这种极端退化。
红黑树五条性质
- 每个节点是红色或黑色。
- 根节点是黑色。
- 所有空叶子节点视为黑色。
- 红色节点的两个孩子必须是黑色,即不能出现连续两个红色节点。
- 从任意节点到其所有叶子节点的路径上,黑色节点数量相同。
这些性质保证:最长路径不会超过最短路径的两倍左右,因此树高为 O(log n)。
30(B)
/ \
10(B) 50(B)
\ /
20(R) 40(R)
基本操作
查找过程与二叉搜索树相同:
find(40)
40 > 30,进入右子树
40 < 50,进入左子树
找到 40
插入新节点时,通常先把新节点染成红色。这样不会增加路径上的黑色节点数量,但可能违反"不能连续红色"的规则,因此需要修复。修复手段主要有两种:
- 变色:红黑节点颜色互换。
- 旋转:左旋或右旋,调整树结构。
左旋示意:
A B
\ /
B -> A
/ \
X X
简化代码:
enum class Color { RED, BLACK };
struct Node {
int key;
Color color;
Node *left, *right, *parent;
};
Node* rotate_left(Node* root) {
Node* new_root = root->right;
root->right = new_root->left;
if (new_root->left) {
new_root->left->parent = root;
}
new_root->left = root;
new_root->parent = root->parent;
root->parent = new_root;
return new_root;
}
实际插入修复还要根据叔叔节点颜色分情况处理:
插入后违反红黑性质
├─ 叔叔是红色:父、叔、祖父重新染色
└─ 叔叔是黑色:根据 LL/LR/RR/RL 情况旋转并染色
红黑树与 AVL 树
| 对比 | 红黑树 | AVL 树 |
|---|---|---|
| 平衡严格程度 | 近似平衡 | 严格平衡 |
| 高度 | 略高 | 更低 |
| 查找 | O(log n) | O(log n),可能略快 |
| 插入删除 | 旋转较少 | 可能频繁旋转 |
| 典型用途 | map/set |
查找极多、修改较少场景 |
红黑树并不追求绝对平衡,而是用较弱的平衡条件减少插入删除时的调整次数,因此综合性能稳定,适合通用关联容器。
#include <iostream>
#include <map>
int main() {
std::map<int, const char*> tree;
tree[30] = "thirty";
tree[10] = "ten";
tree[20] = "twenty";
// 中序遍历:10 20 30
for (const auto& [key, value] : tree) {
std::cout << key << ' ' << value << '\n';
}
}
面试中要注意表述严谨:C++ 标准并没有规定 map 必须用红黑树,但主流标准库通常使用红黑树来满足有序性和 O(log n) 复杂度要求。
11. 介绍一下 hash
核心:Hash,即哈希或散列,是把任意长度输入通过哈希函数映射成固定长度值的过程。哈希表利用哈希值快速定位元素,平均查找、插入、删除可达 O(1);但哈希冲突、负载因子和 rehash 会影响实际性能。
哈希过程如下:
key: "Alice"
↓ hash("Alice")
固定长度整数,例如 1298371
↓ bucket_index = hash % bucket_count
桶下标
↓
在对应桶中查找或插入
一个好的非加密哈希函数通常具备以下特点:
- 确定性:相同输入一定得到相同输出。
- 计算快速:适合频繁查找。
- 分布均匀:尽量让 key 分散到不同桶。
- 雪崩效应:输入发生微小变化,输出应明显不同。
- 冲突概率低:但理论上无法完全避免冲突。
哈希冲突
由于输入空间远大于固定哈希值空间,不同 key 映射到同一桶是不可避免的,这叫哈希冲突。
hash(18) % 4 = 2
hash(34) % 4 = 2
bucket 2: 18 -> 34
常见冲突处理方式:
- 链地址法:每个桶挂一个链表或节点序列。
- 开放寻址法:冲突后按规则寻找下一个空位置,如线性探测、二次探测。
- 再哈希法:使用第二个哈希函数决定步长。
C++ 标准没有强制规定 unordered_map 的具体实现,但主流版本通常采用桶加链的方式。
#include <iostream>
#include <string>
#include <unordered_map>
int main() {
std::unordered_map<std::string, int> score;
score["Alice"] = 95;
score["Bob"] = 88;
auto it = score.find("Alice");
if (it != score.end()) {
std::cout << it->second << '\n';
}
std::cout << "bucket count = " << score.bucket_count() << '\n';
std::cout << "load factor = " << score.load_factor() << '\n';
}
负载因子为:
load_factor = 元素个数 / 桶个数
负载因子越高,冲突越多。当它超过最大负载因子时,哈希表会 rehash:申请更多桶,并把旧元素重新映射到新桶。
rehash 前:
bucket0: A -> X
bucket1:
bucket2: B
bucket3:
扩容后重新分布:
bucket0:
bucket1: A
bucket2: X
bucket3: B
bucket4:
...
自定义类型作为 key 时,需要同时提供"哈希函数"和"相等判断":
#include <cstddef>
#include <functional>
#include <unordered_map>
struct Point {
int x, y;
bool operator==(const Point& other) const {
return x == other.x && y == other.y;
}
};
struct PointHash {
std::size_t operator()(const Point& p) const noexcept {
std::size_t h1 = std::hash<int>{}(p.x);
std::size_t h2 = std::hash<int>{}(p.y);
return h1 ^ (h2 << 1);
}
};
int main() {
std::unordered_map<Point, int, PointHash> count;
count[{1, 2}] = 10;
}
需要区分普通哈希和加密哈希。std::hash 主要用于哈希容器,追求快速和分布均匀,不用于密码安全;SHA-256 等加密哈希追求抗碰撞和单向性,但计算成本更高。哈希表平均 O(1),但如果哈希函数极差或遭遇冲突攻击,最坏可退化为 O(n),因此不能只记住"hash 一定最快"。
12. 介绍一下二叉树
核心:二叉树是每个节点最多有两个孩子的树结构,两个孩子分别称为左孩子和右孩子。二叉搜索树、堆、红黑树、AVL 树等都建立在二叉树基础上。
基本结构:
A
/ \
B C
/ \
D E
相关术语:
- 根节点:最顶层节点,如 A。
- 叶子节点:没有孩子的节点,如 C、D、E。
- 高度:从当前节点到最远叶子的最长边数或节点数,具体定义需说明口径。
- 深度:从根到该节点的路径长度。
- 左子树、右子树:节点左右孩子构成的子树。
二叉树节点定义:
struct TreeNode {
int value;
TreeNode* left;
TreeNode* right;
explicit TreeNode(int x)
: value(x), left(nullptr), right(nullptr) {}
};
常见二叉树类型
| 类型 | 特点 |
|---|---|
| 普通二叉树 | 每个节点最多两个孩子 |
| 满二叉树 | 每个节点要么没有孩子,要么有两个孩子 |
| 完全二叉树 | 除最后一层外都填满,最后一层节点尽量靠左 |
| 完美二叉树 | 所有叶子都在同一层 |
| 二叉搜索树 | 左子树值小于根,右子树值大于根 |
| 平衡二叉树 | 左右高度差受控,如 AVL、红黑树 |
| 堆 | 完全二叉树,同时满足大根堆或小根堆性质 |
二叉搜索树:
30
/ \
10 50
\ \
20 60
查找代码:
TreeNode* search(TreeNode* root, int target) {
while (root) {
if (target == root->value) {
return root;
}
root = target < root->value ? root->left : root->right;
}
return nullptr;
}
二叉搜索树平均高度为 O(log n),查找、插入、删除平均 O(log n)。但如果按有序序列插入,可能退化成链表,最坏 O(n)。红黑树和 AVL 树就是为了解决退化问题。
四种遍历
1
/ \
2 3
/ \
4 5
- 前序遍历:根 → 左 → 右,结果
1 2 4 5 3 - 中序遍历:左 → 根 → 右,结果
4 2 5 1 3 - 后序遍历:左 → 右 → 根,结果
4 5 2 3 1 - 层序遍历:按层访问,结果
1 2 3 4 5
递归遍历:
#include <iostream>
void preorder(TreeNode* root) {
if (!root) return;
std::cout << root->value << ' ';
preorder(root->left);
preorder(root->right);
}
void inorder(TreeNode* root) {
if (!root) return;
inorder(root->left);
std::cout << root->value << ' ';
inorder(root->right);
}
void postorder(TreeNode* root) {
if (!root) return;
postorder(root->left);
postorder(root->right);
std::cout << root->value << ' ';
}
层序遍历使用队列:
#include <queue>
void level_order(TreeNode* root) {
if (!root) return;
std::queue<TreeNode*> q;
q.push(root);
while (!q.empty()) {
TreeNode* node = q.front();
q.pop();
std::cout << node->value << ' ';
if (node->left) q.push(node->left);
if (node->right) q.push(node->right);
}
}
完全二叉树还可以用数组存储。若根节点下标为 i,则:
左孩子下标:2i + 1
右孩子下标:2i + 2
父节点下标:(i - 1) / 2
这正是堆和 priority_queue 的基础。理解二叉树时,要把"结构形状"和"节点值规则"分开:红黑树强调平衡规则,BST 强调大小关系,堆强调父子大小关系和完全二叉树形状。
13. 编程实现 LRU
核心:LRU,Least Recently Used,最近最少使用缓存,淘汰最久没有访问的元素。要求 get 和 put 都为 O(1),经典实现是"哈希表 + 双向链表":哈希表负责快速定位,双向链表负责维护访问时间顺序。
结构如下:
最近使用 最久未使用
head ↔ [key=2] ↔ [key=5] ↔ [key=9] ↔ tail
↑ 访问或插入某 key 时移动到 head 前面
容量不足时删除 tail 前面的节点
- 链表头部:最近使用。
- 链表尾部:最久未使用。
- 哈希表:
key -> Node*,实现 O(1) 查找。 - 双向链表:已知节点指针后,O(1) 删除和移动。
完整 C++ 实现:
#include <iostream>
#include <unordered_map>
class LRUCache {
private:
struct Node {
int key;
int value;
Node* prev = nullptr;
Node* next = nullptr;
Node(int k, int v) : key(k), value(v) {}
};
int capacity;
int size = 0;
Node* head;
Node* tail;
std::unordered_map<int, Node*> table;
// 从链表中摘下节点,但不删除内存
void detach(Node* node) {
node->prev->next = node->next;
node->next->prev = node->prev;
}
// 插入到 head 后面,表示最近使用
void add_to_front(Node* node) {
node->prev = head;
node->next = head->next;
head->next->prev = node;
head->next = node;
}
void move_to_front(Node* node) {
detach(node);
add_to_front(node);
}
void remove_lru() {
Node* lru = tail->prev;
detach(lru);
table.erase(lru->key);
delete lru;
--size;
}
public:
explicit LRUCache(int cap) : capacity(cap) {
head = new Node(0, 0);
tail = new Node(0, 0);
head->next = tail;
tail->prev = head;
}
~LRUCache() {
Node* current = head;
while (current) {
Node* next = current->next;
delete current;
current = next;
}
}
int get(int key) {
auto it = table.find(key);
if (it == table.end()) {
return -1;
}
Node* node = it->second;
move_to_front(node);
return node->value;
}
void put(int key, int value) {
auto it = table.find(key);
if (it != table.end()) {
Node* node = it->second;
node->value = value;
move_to_front(node);
return;
}
Node* node = new Node(key, value);
table[key] = node;
add_to_front(node);
++size;
if (size > capacity) {
remove_lru();
}
}
};
使用示例:
int main() {
LRUCache cache(2);
cache.put(1, 100);
cache.put(2, 200);
std::cout << cache.get(1) << '\n'; // 100,key=1 变最近使用
cache.put(3, 300); // 容量为 2,淘汰最久未使用的 key=2
std::cout << cache.get(2) << '\n'; // -1
std::cout << cache.get(3) << '\n'; // 300
}
为什么不能只用哈希表?因为哈希表能快速查找,但不能天然维护"最近访问顺序"。为什么不能只用链表?因为查找 key 需要 O(n)。二者结合后,哈希表 O(1) 找到节点,双向链表 O(1) 调整节点位置。
生产环境还需要考虑:多线程访问时加互斥锁;容量很大时关注内存分配;如果缓存策略按访问频率淘汰,应使用 LFU 而不是 LRU;也可以用 std::list + unordered_map<key, list::iterator> 减少手写指针错误。核心面试点是:访问过的元素移到头部,容量不足时删除尾部,哈希表和链表必须同步更新。
14. C/C++ 中数组和链表的优缺点
核心:数组使用连续内存,随机访问快、缓存友好,但中间插入删除慢;链表使用节点和指针,已知节点位置时插入删除快,但不支持随机访问、额外指针开销大、缓存性能差。
内存结构对比
数组:
连续内存
地址:1000 1004 1008 1012
值: [A ] [B ] [C ] [D ]
链表:
节点可能分散在堆上
[A|*] ──> [B|*] ──> [C|nullptr]
↑ ↑
0x12 0x88
数组访问第 i 个元素可以直接计算地址:
address(i) = base + i × sizeof(element)
链表访问第 i 个元素必须从头沿指针走 i 次。
#include <array>
#include <iostream>
#include <list>
int main() {
int arr[5] = {1, 2, 3, 4, 5};
std::cout << arr[3] << '\n'; // O(1)
std::list<int> lst = {1, 2, 3, 4, 5};
auto it = lst.begin();
std::advance(it, 3); // O(n)
std::cout << *it << '\n';
}
对比表
| 维度 | 数组 | 链表 |
|---|---|---|
| 内存 | 连续 | 节点分散 |
| 随机访问 | O(1) | O(n) |
| 按下标查找 | O(1) | O(n) |
| 尾部插入 | 动态数组均摊 O(1) | O(1) |
| 头部插入 | O(n),动态数组 | 双向/带头尾链表 O(1) |
| 已知位置插入 | O(n),需移动元素 | O(1) |
| 已知位置删除 | O(n) | O(1) |
| 额外空间 | 预留容量可能浪费 | 每节点有指针开销 |
| 缓存局部性 | 好 | 差 |
| 内存分配 | 一次性或扩容时分配 | 插入节点时频繁分配 |
| 大小变化 | 静态数组固定,vector 可增长 | 动态增长自然 |
数组优点
第一,随机访问极快。第二,顺序遍历时缓存命中率高。第三,没有指针额外开销,存储小对象时空间利用率高。第四,适合二分查找、排序、向量化计算。
数组缺点是插入删除可能移动大量元素:
在数组 [A][B][C][D] 的 B 前插入 X:
[A][ ][B][C][D]
↑ B、C、D 全部后移
[A][X][B][C][D]
静态数组大小编译期固定;C++ vector 虽然能动态扩容,但扩容时可能移动所有元素。
链表优点
第一,已知节点位置时插入删除只需修改指针:
删除 B:
[A] ↔ [B] ↔ [C]
修改 A.next、C.prev:
[A] ↔ [C]
第二,节点按需分配,不需要预先占用大块连续空间。第三,插入不会移动其他元素,其他节点的迭代器和引用通常保持有效。
链表缺点是随机访问慢、指针占用空间、频繁 new/delete 开销大、缓存不友好。双向链表还要保存前驱和后继两个指针。
工程中不要机械地认为"插入删除多就一定用链表"。如果找到插入位置本身要 O(n),而数组移动元素在连续内存上非常快,链表未必占优。大多数 C++ 场景仍应优先 vector;只有在已经持有目标迭代器、需要稳定引用、频繁拼接或删除任意已知节点时,才考虑 list。
15. 容器选择的原则
核心:容器选择不能只看理论复杂度,还要结合访问模式、增删位置、是否有序、key 类型、对象大小、内存局部性、迭代器稳定性和实时性要求。默认选择通常是 vector。
选择流程图
是否需要 key-value 或集合查找?
├─ 否:序列容器
│ ├─ 主要随机访问/顺序遍历 → vector
│ ├─ 头尾都频繁增删 → deque
│ ├─ 已知位置频繁任意插入删除/稳定引用 → list
│ ├─ 大小固定 → array
│ └─ LIFO/FIFO/优先级 → stack/queue/priority_queue
│
└─ 是:关联容器
├─ 需要有序遍历或范围查询 → map/set
├─ 只做等值查找,不关心顺序 → unordered_map/unordered_set
└─ key 可重复 → multimap/multiset 或 unordered_multimap/unordered_multiset
原则一:默认使用 vector
vector 连续内存、随机访问 O(1)、缓存友好,并且与 C API 兼容。很多看似适合链表的场景,实际 benchmark 中仍是 vector 更快。
std::vector<User> users;
users.reserve(expected_size);
原则二:根据主要操作选择
| 主要操作 | 更适合的容器 |
|---|---|
| 大量顺序遍历 | vector |
| 频繁随机访问 | vector/array/deque |
| 频繁尾部追加 | vector |
| 频繁头尾增删 | deque |
| 已知位置频繁插入删除 | list |
| 有序 key 查找 | map/set |
| 精确 key 平均 O(1) 查找 | unordered_map/unordered_set |
| 每次取最大/最小值 | priority_queue |
| 先进先出 | queue |
| 后进先出 | stack |
原则三:看是否需要有序性
如果需要范围查询:
std::map<long long, Order> orders;
auto begin = orders.lower_bound(start_time);
auto end = orders.upper_bound(end_time);
for (auto it = begin; it != end; ++it) {
// 时间范围内的订单
}
此时 map 比 unordered_map 合适。若只根据 ID 查对象:
std::unordered_map<int64_t, User> user_by_id;
哈希表通常更合适。
原则四:关注对象大小和拷贝成本
如果对象很大,vector 扩容时移动成本可能较高。可以:
- 使用
reserve避免多次扩容; - 存储
unique_ptr<T>或间接对象; - 使用
emplace_back原地构造; - 避免不必要的拷贝。
但不要默认"对象大就用链表",还要看是否真的有任意位置增删需求。
原则五:关注迭代器和引用稳定性
std::vector<int> v = {1, 2, 3};
int* p = &v[0];
v.reserve(1000); // 若触发扩容,p 可能失效
vector 扩容会使迭代器、指针、引用失效;list 插入不会使其他迭代器失效;map/set 插入通常也不会使已有迭代器失效。如果算法需要长期保存元素位置,这一点很重要。
原则六:关注性能确定性
unordered_map 平均 O(1),但 rehash 和哈希冲突可能带来延迟尖峰;map 每次 O(log n),性能更可预测。实时系统、嵌入式系统有时更重视最坏情况,而不是平均速度。
原则七:不要忽视线程安全
STL 容器本身通常不负责多线程同步。多个线程同时修改容器,或一个线程修改而另一个线程遍历,都需要外部加锁。选择容器不能替代并发设计。
最终可以用一句话做决策:没有特殊理由就用 vector;有明确双端需求用 deque;有明确已知节点增删和稳定引用需求用 list;要有序用红黑树容器;要平均最快等值查找用哈希容器;要特殊调度规则用适配器。
16. 什么是迭代器,有哪几种迭代器
核心:迭代器是连接容器和算法的抽象层,它像一个"智能指针",可以用统一方式遍历不同容器,而算法不需要知道容器底层是数组、链表还是树。STL 算法通常通过迭代器操作元素。
例如 std::find 不关心容器类型:
#include <algorithm>
#include <iostream>
#include <list>
#include <vector>
int main() {
std::vector<int> v = {1, 2, 3};
std::list<int> l = {1, 2, 3};
auto it1 = std::find(v.begin(), v.end(), 2);
auto it2 = std::find(l.begin(), l.end(), 2);
std::cout << *it1 << ' ' << *it2 << '\n';
}
find 只要求迭代器支持递增和解引用,因此同一算法可以作用于多种容器。
迭代器基本分类
C++ 传统迭代器按能力从弱到强分为五类,C++20 又明确提出连续迭代器概念。
| 类别 | 能力 | 典型例子 |
|---|---|---|
| 输入迭代器 | 单向、只读、单趟遍历 | istream_iterator |
| 输出迭代器 | 单向、只写、单趟遍历 | ostream_iterator、插入迭代器 |
| 前向迭代器 | 可单向多趟读写 | forward_list、unordered_map |
| 双向迭代器 | 可向前和向后移动 | list、map、set |
| 随机访问迭代器 | 可跳跃、比较距离 | vector、deque、数组指针 |
| 连续迭代器 | 随机访问且逻辑元素物理连续 | vector、array,C++20 |
能力关系:
输入/输出
↓
前向
↓
双向
↓
随机访问
↓
连续(C++20,强调内存连续)
输入迭代器
只能逐个读取,通常不保证回头后还能重新读取同一序列:
#include <iterator>
#include <iostream>
int main() {
std::istream_iterator<int> input(std::cin);
std::istream_iterator<int> end;
if (input != end) {
std::cout << *input << '\n';
}
}
输出迭代器
用于写出元素:
#include <iterator>
#include <iostream>
#include <vector>
int main() {
std::vector<int> v;
auto out = std::back_inserter(v);
*out = 1;
++out;
*out = 2;
}
前向迭代器
可以反复沿一个方向遍历,但不能后退。std::forward_list 是典型例子:
std::forward_list<int> fl = {1, 2, 3};
for (auto it = fl.begin(); it != fl.end(); ++it) {
// 只能 ++,不能 --
}
双向迭代器
支持 ++ 和 --。链表和红黑树容器属于这一类:
std::map<int, int> mp = {{1, 10}, {2, 20}};
auto it = mp.begin();
++it;
--it; // 可以
随机访问迭代器
支持 it + n、it - n、it[n] 和大小比较:
std::vector<int> v = {10, 20, 30};
auto it = v.begin();
std::cout << it[2] << '\n';
std::cout << *(it + 1) << '\n';
std::cout << (it < v.end()) << '\n';
list 迭代器不能 it + 5,只能多次 ++,因为链表地址不连续,跳跃访问必须线性移动。
迭代器失效
容器修改后,旧迭代器可能不再有效:
std::vector<int> v = {1, 2, 3};
auto it = v.begin();
v.reserve(1000); // 如果发生扩容,it 失效
不同容器失效规则不同:
vector:扩容后全部失效;插入点之后通常失效。deque:迭代器失效规则复杂,中间插入删除影响更大。list:只有被删除元素的迭代器失效。map/set:只有被删除元素的迭代器失效。unordered_map:rehash 会使迭代器失效,但元素引用通常仍有效。
理解迭代器分类有助于理解算法要求。例如 std::sort 需要随机访问迭代器,因此不能直接排序 std::list;list 提供自己的 sort() 成员函数。std::reverse 需要双向迭代器,因此可用于 list/map 遍历方向,但不能用于 forward_list。迭代器的本质价值是:让算法只依赖遍历能力,而不依赖具体容器结构。
17. vector 的底层原理和扩容机制是什么?
核心:vector 底层是一块连续动态内存,内部通常用三个指针管理:起始指针、已用元素末尾指针、容量末尾指针。当容量不足时,它会申请更大的连续空间,把旧元素移动或拷贝过去,再释放旧空间;由于采用几何倍数扩容,多次 push_back 的均摊复杂度是 O(1)。
内部结构可抽象为:
template <class T>
class Vector {
private:
T* first = nullptr; // 起始位置
T* last = nullptr; // 已用元素末尾
T* capacity_end = nullptr;// 容量末尾
};
图示:
first last capacity_end
↓ ↓ ↓
[A][B][C][D][ ][ ][ ][ ][ ][ ]
size = last - first = 4
capacity = capacity_end - first = 10
push_back 过程
std::vector<int> v;
v.push_back(1);
v.push_back(2);
v.push_back(3);
当 size < capacity 时,直接在尾部构造新元素。当 size == capacity 时:
1. 申请一块更大的新内存
2. 把旧元素移动或拷贝到新内存
3. 析构旧内存中的对象
4. 释放旧内存
5. 在新内存尾部构造新元素
简化实现:
#include <algorithm>
#include <cstddef>
template <class T>
class SimpleVector {
private:
T* data = nullptr;
std::size_t sz = 0;
std::size_t cap = 0;
public:
void push_back(const T& value) {
if (sz == cap) {
std::size_t new_cap = cap == 0 ? 1 : cap * 2;
T* new_data = static_cast<T*>(::operator new(new_cap * sizeof(T)));
for (std::size_t i = 0; i < sz; ++i) {
new (&new_data[i]) T(std::move_if_noexcept(data[i]));
}
for (std::size_t i = 0; i < sz; ++i) {
data[i].~T();
}
::operator delete(data);
data = new_data;
cap = new_cap;
}
new (&data[sz]) T(value);
++sz;
}
std::size_t size() const { return sz; }
std::size_t capacity() const { return cap; }
T& operator[](std::size_t i) { return data[i]; }
};
真实 STL 实现会使用分配器 allocator,通过 placement new 在已申请但未初始化的内存上构造对象,而不是简单使用 new T[]。
为什么均摊是 O(1)
假设容量按 2 倍增长:
容量变化:1 → 2 → 4 → 8 → 16
插入 n 个元素时,扩容搬移总量约为:
1 + 2 + 4 + ... + n/2 ≈ n
虽然某一次扩容可能复制 n 个元素,成本 O(n),但分摊到 n 次插入上,平均每次仍是 O(1),这叫均摊常数复杂度。
扩容倍数由标准库实现决定,并不保证一定是 2 倍。MSVC、GCC、Clang 的策略可能不同,常见为 2 倍或 1.5 倍。
reserve、resize、shrink_to_fit
#include <vector>
int main() {
std::vector<int> v;
v.reserve(1000); // 只改变 capacity,不增加 size
// v.size() 仍为 0
v.resize(10); // 改变 size,多出来的元素被值初始化
// v.size() 为 10
v.shrink_to_fit(); // 请求释放多余容量,但不保证一定生效
}
区别如下:
| 操作 | 作用 |
|---|---|
reserve(n) |
保证容量至少为 n,减少扩容 |
resize(n) |
改变元素个数,可能构造或销毁元素 |
clear() |
销毁所有元素,size 变 0,但容量通常不变 |
shrink_to_fit() |
请求归还多余容量 |
emplace_back() |
在尾部原地构造,减少临时对象 |
迭代器失效
扩容后旧内存被释放,因此指向旧内存的迭代器、指针和引用都会失效:
std::vector<int> v = {1, 2, 3};
auto it = v.begin() + 1;
v.reserve(1000);
// 若发生扩容,it 已失效,不能继续使用
即使没有扩容,插入位置及之后的迭代器也可能因元素移动而失效;删除点之后的迭代器同样不能按原语义继续使用。
重要优化建议
如果提前知道元素数量,应使用 reserve:
std::vector<int> v;
v.reserve(100000);
for (int i = 0; i < 100000; ++i) {
v.emplace_back(i);
}
这样可以避免多次扩容、对象搬移和内存碎片。另一个细节是:C++11 后 vector 扩容时会优先使用移动构造,但如果移动构造可能抛异常,为了提供强异常安全保证,编译器可能退化为拷贝构造,因此大对象的移动构造最好声明为 noexcept。
总结:vector 是"连续数组 + 几何扩容 + 自动对象生命周期管理"的组合,随机访问 O(1),尾部追加均摊 O(1),中间插入删除 O(n),扩容会导致迭代器整体失效。
17 项整体总结
| 知识点 | 一句话记忆 |
|---|---|
| vector | 连续动态数组,随机访问快,尾部追加均摊 O(1) |
| list | 双向链表,已知位置增删 O(1),随机访问慢 |
| deque | 分段连续,双端增删 O(1),支持随机访问 |
| map/set | 红黑树,有序,O(log n) |
| mutable | const 对象中允许修改缓存、锁等逻辑辅助成员 |
| map 底层 | 主流实现为红黑树 |
| unordered_map | 哈希表,平均 O(1),无序 |
| hashmap vs map | 哈希平均快,红黑树有序且最坏复杂度稳定 |
| 红黑树 | 自平衡 BST,靠颜色和旋转维持 O(log n) |
| hash | 将输入映射为固定值,哈希表靠它定位桶 |
| 二叉树 | 最多两个孩子,是 BST、堆、红黑树的基础 |
| LRU | 哈希表 + 双向链表,get/put O(1) |
| 数组 vs 链表 | 数组连续、访问快;链表节点分散、已知位置增删快 |
| 容器选择 | 默认 vector,再按访问和增删特征替换 |
| 迭代器 | 算法与容器之间的统一遍历抽象 |
| vector 扩容 | 几何倍数申请新空间并搬移元素,均摊 O(1) |
最核心的选型主线是:
连续内存优先 vector
双端操作选 deque
已知节点增删选 list
有序和范围查询选 map/set
等值快速查找选 unordered_map/unordered_set
缓存淘汰用 hash + 双向链表
算法统一通过 iterator 操作容器