书接上回,内存管理和指针:C++的双刃手术刀(一)-CSDN博客,在上篇我们聊到了什么是内存,堆栈,内存管理和智能指针相关的内容,接下来让我们一起去看看STL是什么吧。
第一步:提问
STL 是什么?
想象一下:你有一个万能工具箱,里面装满了各种现成的工具(锤子、螺丝刀、扳手…),这些工具能帮你快速完成特定任务。
STL 就是这样的工具箱,但它针对的是 “数据结构”和“算法”。
- 数据结构:比如动态数组 (
vector
)、哈希表 (unordered_map
)、链表 (list
) 等,用来存储和组织数据。 - 算法:比如排序 (
sort
)、查找 (find
)、合并 (merge
) 等,用来操作数据。
STL 的核心价值:不用自己造轮子,直接调用这些工具就能写出高效代码!
STL 最常用的四个模块是:
组件 | 作用 | 类似现实中的什么? |
---|---|---|
容器 | 存储数据的容器(如 vector ) | 书包、书架、快递箱 |
算法 | 操作容器数据的工具(如 sort ) | 计算器、清洁工、厨师 |
迭代器 | 在容器中“移动”的指针 | 手指、遥控器、游戏手柄 |
仿函数 | 行为像函数的类或对象 | 自定义规则的小助手 |
STL的数据结构(容器),容器是数据存储的核心
1. 序列式容器(数据的“线性仓库”)
容器 | 关键特点 | 适合场景 | 代码示例 |
---|---|---|---|
vector | 动态数组,支持快速随机访问 | 需要频繁随机读写的场景 | std::vector<int> v = {1,2,3}; |
list | 双向链表,插入删除快 | 频繁中间插入/删除的场景 | std::list<int> l; l.push_back(4); |
deque | 双端队列,两端操作高效 | 队列或栈的扩展场景 | std::deque<int> dq; dq.push_front(5); |
std::vector
——动态数组
动态数组是什么?我们来看一个对比:
静态数组 vs 动态数组:
-
静态数组:大小固定,内存一次性分配(比如
int arr[5]
)。- 缺点:装不下更多东西,也不能随便改变大小。
// 静态数组越界会崩溃! int arr[3] = {1,2,3}; arr[4] = 4; // 未定义行为!
- 缺点:装不下更多东西,也不能随便改变大小。
-
动态数组:大小可变,运行时动态分配内存(比如
std::vector
)。- 优点:像“伸缩的乐高盒子”,能根据需求自动调整大小。
动态数组的核心原理
1. 内存管理
动态数组本质是一块连续的内存空间,但会 预留额外空间(capacity)以应对未来的增长。
-
类比:租了一个可扩建的仓库,初始容量小,但可以不断扩建。
std::vector<int> v; // 空仓库,容量为0 v.push_back(1); // 扩建仓库,容量变为1 v.push_back(2); // 可能再次扩建(容量翻倍)
2. 关键操作
操作 | 作用 | 时间复杂度 |
---|---|---|
| 在末尾添加元素 | 平均 O(1) |
| 删除末尾元素 | O(1) |
| 强制调整大小 | O(n)(可能移动元素) |
| 预留内存空间 | O(1) |
为什么
push_back()
是均摊 O(1)`?
-
像“分期扩建仓库”:每次扩容时容量翻倍,摊薄到每次操作的代价为 O(1)。
-
例如:连续插入 8 次元素,可能只扩建 3 次(1→2→4→8)。
-
既然动态数组可以自己扩充,为啥还有
reserve()
呢?
reserve()
是做什么?
核心功能:
提前为动态数组(如 std::vector
)预留内存空间,避免后续插入元素时频繁重新分配内存和复制数据。
类比:
假设你要开一个 “可容纳100人的会议室”,但一开始只有10人参会。如果你提前预订好100人的场地:
-
优点:后续有人加入时,无需再换场地(省时间)。
-
缺点:如果最终只有50人,可能浪费空间。
reserve(100)
就是类似“提前预订100人场地”。
为什么需要 reserve()
?
1. 性能优化
-
没有
reserve()
的情况:
每次push_back()
导致容量不足时,会重新分配更大的内存,并将原有数据复制到新内存。
代价:频繁的内存分配和数据复制(尤其大数据量时非常耗时)。 -
有
reserve()
的情况:
提前分配足够内存,后续插入只需在预留空间内操作,避免重新分配和复制。
示例:插入100万整数
std::vector<int> v;
// 方式1:不使用 reserve()
for (int i = 0; i < 1e6; ++i) {
v.push_back(i); // 可能多次扩容(如1→2→4→8...→足够大)
}
// 方式2:使用 reserve()
v.reserve(1e6);
for (int i = 0; i < 1e6; ++i) {
v.push_back(i); // 仅分配一次内存
}
2. 控制内存占用
-
自动扩容策略:
vector
的默认扩容方式是 容量翻倍(如从1→2→4→8...)。
如果最终只需要100元素,但初始容量为1,会导致多次扩容,最终容量可能是128(超过需求)。 -
使用
reserve()
:直接分配所需容量(如100),避免浪费内存。
reserve()
的关键细节
方法 | 作用 | 是否改变元素个数? |
---|---|---|
| 预留至少 | 否(只影响容量) |
| 调整元素个数到 | 是 |
示例:
std::vector<int> v;
v.reserve(10); // 容量变为10,元素个数仍为0
v.push_back(1); // 元素个数变为1,容量仍足够
v.resize(5); // 元素个数变为5,未超出容量,无需改动
实际场景:为什么要用 reserve()
?
场景1:读取大规模数据
从文件或网络读取大量数据时,若不提前 reserve()
,可能导致:
-
频繁内存重新分配:程序卡顿甚至崩溃(尤其在数据量极大时)。
-
内存浪费:自动扩容后的容量远超实际需求。
解决方案:
std::vector<int> data;
data.reserve(1000000); // 提前预留100万空间
// 读取数据并 push_back...
场景2:性能敏感型应用
游戏开发、实时系统等对时间要求高的场景,reserve()
可显著减少运行时开销。
reserve()
的使用原则
-
当你知道最终数据量(或上限)时:提前
reserve()
以优化性能。 -
当你需要避免内存浪费时:如果最终数据量远小于默认扩容后的容量,手动
reserve()
可节省内存。 -
不需要时不要用:如果数据量不确定,或小规模操作,
reserve()
可能反而增加初始化开销。
动态数组 vs 静态数组
特性 | 动态数组( | 静态数组 |
---|---|---|
大小可变 | 支持运行时调整(自动扩容) | 大小固定(编译期确定) |
内存分配 | 连续内存,但可能多次重新分配 | 单次分配,连续内存 |
访问速度 | 支持随机访问(O(1)) | 支持随机访问(O(1)) |
中间插入/删除 | O(n) 时间复杂度(需移动元素) | 不允许(越界错误) |
关键区别:
-
动态数组通过 “预留空间” 和 “惰性扩容” 平衡性能与灵活性。
-
静态数组像“固定尺寸的盒子”,适合已知大小的数据。
std::list
——双向链表
什么是双向链表?
核心概念:
双向链表是一种 线性数据结构,每个节点包含 值 和 两个指针(分别指向“前一个节点”和“后一个节点”)。
- 类比:想象一列火车,每节车厢(节点)既能看到前面的车厢(前驱指针),也能看到后面的车厢(后继指针)
为了更好理解,我们先来看看链表是什么?
链表是一种 线性数据结构,由若干“节点”(Node)通过“指针”(Pointer)串联而成,每个节点存储 值 和 下一个节点的位置。
- 类比:想象一条由珍珠串成的项链,每颗珍珠(节点)上刻有信息(值),并通过绳结(指针)连接到下一颗珍珠。
双向链表的结构:
- 特点:节点包含 前驱指针(
prev
)和 后继指针(next
),支持双向遍历。 - 结构:
struct Node { int value; Node* prev; Node* next; Node(int val) : value(val), prev(nullptr), next(nullptr) {} };
- 类比:一条双向的马路,可自由来往。
示意图:
A <-> B <-> C <-> D
双向链表的关键特性
属性 | 说明 |
---|---|
节点结构 | 每个节点包含 value 、prev (前驱指针)、next (后继指针)。 |
插入/删除 | 支持在任意位置快速插入/删除节点(只需调整相邻节点的指针)。 |
遍历方向 | 可从前向后或从后向前遍历。 |
内存分配 | 节点分散在内存中,非连续存储。 |
C++ STL 中的链表
STL 提供了 std::list
(基于双向链表),简化了链表操作:
- 核心方法:
std::list<int> lst; lst.push_back(1); // 添加元素到末尾 lst.insert(lst.begin(), 2); // 在开头插入元素 auto it = lst.find(3); // 查找元素 if (it != lst.end()) { lst.erase(it); // 删除元素 }
- 遍历方式:
for (auto num : lst) { // 范围for循环 std::cout << num << " "; }
std::list
的为什么好用?
1. 对内存的零碎兼容性
-
适用场景:内存碎片化严重时(如嵌入式系统),链表能高效利用零散内存。
2. 高效的中间操作(目前看这个就够了)
-
插入/删除示例:
std::list<int> scores = {10, 20, 30, 40}; // 在 20 和 30 之间插入 25 auto it = scores.find(20); scores.insert(it, 25); // 结果:[10, 20, 25, 30, 40] // 删除 30 it = scores.find(30); scores.erase(it); // 结果:[10, 20, 25, 40]
3. 并发友好
-
双向链表支持:在多线程环境下,修改链表时只需锁定部分节点,而非整个容器。
std::list
的实际应用场景
-
动态队列/栈:
std::list<int> queue;//用list 去实现队列,FIFO queue.push_back(1); queue.push_back(2); std::cout << queue.front() << std::endl; // 输出 1 queue.pop_front(); // 队列变为 [2]
-
LRU 缓存:
当缓存满时,删除最久未使用的元素(需双向遍历和快速删除)。std::list<std::pair<int, std::string>> cache; cache.push_back({1, "Data1"}); cache.push_back({2, "Data2"}); // 删除末尾元素(LRU 策略) cache.pop_back();
-
多级分类菜单:
如电商网站的商品分类导航(父类和子类双向关联)。
std::list
的常见坑点
1. 不能随机访问
-
错误用法:
std::list<int> lst = {1, 2, 3}; std::cout << lst[2]; // 编译错误!list 不支持随机访问
-
正确做法:
auto it = lst.begin(); std::advance(it, 2); // 移动到第三个元素 std::cout << *it << std::endl; // 输出 3
2. 内存泄漏风险
-
手动管理节点时(如 C 风格链表)需释放内存,但
std::list
会自动管理节点生命周期。
std::list
的核心思想是:
-
用双向链表实现动态数据的高效操作,牺牲随机访问性能换取插入/删除的灵活性。
-
适用场景:需要频繁修改元素顺序或大小,但对随机访问要求不高的场景。
现在试着用你自己的话解释:
-
如果有一个需要频繁在中间插入和删除元素的列表,你会选
std::vector
还是std::list
?为什么? -
std::list
的双向遍历能力在实际开发中有哪些具体用途?
std::deque
——双端队列
什么是双端队列?
核心定义:
双端队列(Deque,Double-Ended Queue)是一种 线性数据结构,允许在 两端(头部和尾部)进行 高效的插入和删除操作,同时支持 随机访问。
类比:
想象一个 两端都能打开的购物车,你可以:
-
从前面装商品(
push_front
), -
从后面装商品(
push_back
), -
从前面取商品(
pop_front
), -
从后面取商品(
pop_back
), -
还能直接查看中间某个位置的物品(随机访问)。
std::deque
的核心特性
1. 双端高效操作
-
插入/删除:在头部或尾部插入、删除元素的时间复杂度均为 O(1)。
-
随机访问:通过索引直接访问元素的时间复杂度为 O(1)(类似数组)。
2. 内存连续性
-
数据存储在 连续的内存块 中,像一块完整的“内存蛋糕”。
-
优点:CPU 缓存友好,访问速度极快。
3. 动态大小
-
支持运行时动态调整大小,无需提前分配固定容量。
std::deque
vs std::vector
vs std::list
特性 |
|
|
|
---|---|---|---|
头部/尾部操作 | O(1) 时间复杂度 |
| 双向链表,任意位置 O(1) |
随机访问 | 支持 O(1) | 支持 O(1) | 不支持(O(n)) |
内存分配 | 连续内存块 | 连续内存块 | 分散内存块 |
适用场景 | 需要两端操作且随机访问的场景 | 需要尾部操作和随机访问的场景 | 需要任意位置插入/删除的场景 |
std::deque
的底层实现
1. 内存布局
-
由多个 固定大小的块(Block)组成,每个块存储一定数量的元素。
-
优点:支持高效的头部和尾部操作,无需移动整个数组。
示意图:
[Block1] <-> [Block2] <-> ... <-> [BlockN]
2. 关键操作
std::deque<int> dq;
// 插入元素到两端
dq.push_front(1); // [1]
dq.push_back(2); // [1, 2]
// 删除元素
dq.pop_front(); // []
dq.pop_back(); // []
// 访问元素
std::cout << dq.front() << std::endl; // 1 (头部)
std::cout << dq.back() << std::endl; // 2 (尾部)
std::cout << dq[0] << std::endl; // 直接索引访问
std::deque
的实际应用场景
1. 滑动窗口最大值
-
维护一个固定大小的窗口,快速获取当前窗口的最大值。
#include <iostream> #include <deque> #include <vector> int main() { const int k = 3; // 定义窗口大小 std::vector<int> nums = {2, 1, 5, 6, 2, 3}; std::deque<int> dq; // 存储元素索引 // 错误处理:验证窗口大小有效性 if (k <= 0 || k > nums.size()) { std::cerr << "Invalid window size!" << std::endl; return 1; } for (int i = 0; i < nums.size(); ++i) { // 维护单调递减队列 while (!dq.empty() && nums[dq.back()] <= nums[i]) dq.pop_back(); dq.push_back(i); // 移除超出窗口范围的元素 while (dq.front() <= i - k) dq.pop_front(); // 当窗口形成时输出当前窗口最大值 if (i >= k - 1) { std::cout << "Window [" << (i - k + 1) << "-" << i << "] Max: " << nums[dq.front()] << std::endl; } } return 0; }
2. BFS 队列的优化
-
用
deque
实现广度优先搜索(BFS),支持快速队首插入和队尾删除。#include <iostream> #include <deque> #include <limits> struct Node { int value; }; void bfs() { std::deque<Node> q; if (q.max_size() == 0) { std::cerr << "队列无法分配内存" << std::endl; return; } q.push_back({1}); int max_value = 10; // 假设我们希望在节点值达到10时停止遍历 while (!q.empty() && q.front().value <= max_value) { Node node = q.front(); q.pop_front(); // 处理节点,这里简单地输出节点的值 std::cout << "处理节点值: " << node.value << std::endl; if (node.value + 1 > max_value) { break; // 如果下一个节点的值会超过最大值,则提前停止 } if (q.max_size() == q.size()) { std::cerr << "队列已满,无法添加新节点" << std::endl; break; } q.push_back({node.value + 1}); } }
std::deque
的注意事项
1. 不要过度使用 resize()
-
resize(n)
会调整容器大小,可能导致数据移动或重新分配内存。 -
替代方案:用
reserve()
预留足够空间。
2. 中间插入/删除效率低
-
虽然
std::deque
支持随机访问,但中间插入/删除的时间复杂度为 O(n)(需移动元素)。 -
适用场景:尽量避免在中间位置操作。
std::deque
的核心思想是:
-
用连续内存块模拟双端队列,平衡插入/删除效率和随机访问性能。
-
适用场景:需要频繁在两端操作数据,且需要快速访问任意位置的场景(如滑动窗口、消息队列)。
现在试着用你自己的话解释:
-
如果有一个需要频繁在头部和尾部添加/删除元素的列表,你会选
std::deque
还是std::list
?为什么? -
std::deque
的随机访问特性在实际开发中有哪些具体用途?
2. 关联式容器(键值对存储)
关联容器(Associative Containers)是 STL 中用于 按关键字(Key)高效存储和检索数据 的容器,核心特点是 元素自动排序或哈希化(取决于具体容器)。
- 类比:想象一本电话簿,每个人的姓名是“关键字”(Key),电话号码是“值”(Value),你可以快速通过姓名查找号码。
关联容器的分类
1. 有序关联容器(基于红黑树)
容器 | 特点 | 底层实现 | 时间复杂度(操作) |
---|---|---|---|
std::set | 唯一键,按键排序 | 红黑树(自平衡二叉搜索树) | O(log n) |
std::map | 键值对,按键排序 | 红黑树 | O(log n) |
std::multiset | 允许重复键,按键排序 | 红黑树 | O(log n) |
std::multimap | 允许重复键的键值对,按键排序 | 红黑树 | O(log n) |
2.有序容器详解:std::set
和 std::map
1. std::set
(唯一键集合)
- 核心用途:存储唯一元素,自动排序。
- 代码示例(核心代码,非完整示例,下同):
#include <set> std::set<int> scores = {90, 85, 95}; // 自动排序为 {85, 90, 95} scores.insert(88); // 插入新元素 → {85, 88, 90, 95} if (scores.find(90) != scores.end()) // 查找元素 std::cout << "Found!" << std::endl;
2. std::map
(键值对映射)
- 核心用途:按键存储键值对,键唯一且排序。
- 代码示例:
#include <map> std::map<std::string, int> student_scores; student_scores["Alice"] = 95; // 插入键值对 student_scores["Bob"] = 88; for (const auto& pair : student_scores) // 遍历(按键升序) std::cout << pair.first << ": " << pair.second << std::endl;
2. 无序关联容器(基于哈希表)
容器 | 特点 | 底层实现 | 时间复杂度(平均) |
---|---|---|---|
std::unordered_set | 唯一键,无序存储 | 哈希表 | O(1) |
std::unordered_map | 键值对,无序存储 | 哈希表 | O(1) |
std::unordered_multiset | 允许重复键,无序存储 | 哈希表 | O(1) |
std::unordered_multimap | 允许重复键的键值对,无序存储 | 哈希表 | O(1) |
2.无序容器详解:std::unordered_set
和 std::unordered_map
1. std::unordered_set
(唯一键哈希集合)
- 核心用途:快速查找唯一元素,不关心顺序。
- 代码示例:
#include <unordered_set> std::unordered_set<std::string> usernames; usernames.insert("Alice"); // 插入元素 if (usernames.contains("Bob")) // C++20 语法检查是否存在 std::cout << "User exists!" << std::endl;
2. std::unordered_map
(键值对哈希表)
- 核心用途:快速键值对查找,无需排序。
- 代码示例:
#include <unordered_map> std::unordered_map<int, std::string> id_to_name; id_to_name[1] = "Alice"; // 插入键值对 id_to_name[2] = "Bob"; std::cout << id_to_name[1] << std::endl; // 输出 Alice
注意:
现在试着回答:
set
和map
允许元素唯一,而unordered_*
不保证唯一性(需要自己处理冲突)。unordered_*
在数据量大时性能更优,但哈希冲突可能导致最坏 O(n) 时间复杂度。关联容器的核心思想是:
- 有序容器:用红黑树保证有序性和高效的范围查询,适合需要排序的场景。
- 无序容器:用哈希表实现快速查找,适合对顺序不敏感的高性能需求。
- 选择依据:是否需要排序、是否允许重复键、是否需要范围查询。
- 如果需要一个允许重复键且有序的容器,应该选哪个?
- 为什么
std::unordered_map
的查找速度平均是 O(1)?
好了。今天就到这里,再见喽。