STL总览与vector:从六大组件到实战细节
STL,说白了就是C++标准库中最值钱的那部分。我刚开始学C++时,总觉得STL是个黑盒子——用起来爽,但心里没底。后来做项目多了才发现,理解它的底层逻辑,能帮你避开无数坑。
今天咱们就从STL的骨架讲起,再深入到vector这个最常用的容器。嗯,vector就像C++世界的“万能工具箱”,你几乎每个项目都会用到它。
STL六大组件:一个都不能少
STL不是一堆零散工具的堆砌,它有一套清晰的设计哲学。我个人习惯把这六大组件记成“三核心三辅助”:
- 容器(Container):存数据的地方。vector、list、map这些都属于容器。
- 算法(Algorithm):处理数据的方法。sort、find、copy等。
- 迭代器(Iterator):连接容器和算法的桥梁。说白了就是“智能指针”。
- 仿函数(Functor):行为像函数的对象。常用于算法的自定义操作。
- 适配器(Adapter):给已有组件“换皮”。比如stack底层可以用deque实现。
- 分配器(Allocator):管理内存分配。一般不用碰,但出问题时得懂。
核心关系:容器通过迭代器暴露数据,算法通过迭代器操作数据,仿函数和适配器提供定制能力,分配器在幕后管内存。这六者配合,构成了STL的完整生态。
你想想看,如果没有迭代器这个中间层,每个容器都得自己实现一套算法,那代码得多臃肿?
vector容器原理:动态数组的智慧
vector本质上就是个动态数组。它维护一块连续的内存空间,当空间不够时自动扩容。我记得第一次看vector源码时,被它的扩容策略惊到了——不是每次加元素都扩容,而是按倍数增长。
扩容机制:当size() == capacity()时,vector会申请一块新内存(通常是原大小的1.5~2倍),然后把旧元素拷贝过去,再释放旧内存。这个操作的时间复杂度是O(n),所以频繁扩容会影响性能。
为什么会这样设计?你想想看,如果每次push_back都只多分配一个元素的空间,那插入n个元素就是O(n²)了。而按倍数扩容,均摊下来每个插入操作就是O(1)。
vector的构造与赋值
vector的构造方式有好几种,我项目中常用的是这些:
#include <vector>
using namespace std;
// 默认构造
vector<int> v1; // 空vector
// 指定初始大小和值
vector<int> v2(5, 10); // 5个元素,每个都是10
// 列表初始化(C++11起)
vector<int> v3 = {1, 2, 3, 4, 5};
// 拷贝构造
vector<int> v4(v3); // 复制v3
// 迭代器范围构造
vector<int> v5(v3.begin(), v3.begin() + 3); // 取前3个
// 赋值操作
vector<int> v6;
v6 = v3; // operator= 赋值
v6.assign(3, 100); // 赋值为3个100
v6.assign(v3.begin(), v3.end()); // 用迭代器范围赋值
注意:assign会清空原有内容再填充。我曾经在循环里误用assign,结果每次迭代都清空了数据,排查了半天才发现问题。
容量与大小:别搞混了
很多新手分不清size和capacity。我简单解释一下:
- size():当前实际存储的元素个数。
- capacity():当前分配的内存能容纳的元素个数。
- empty():判断是否为空(等价于size() == 0)。
- reserve(n):预留至少能容纳n个元素的空间,但不改变size。
- resize(n):改变size为n,多出来的元素用默认值填充。
vector<int> v;
cout << v.size() << " " << v.capacity(); // 0 0
v.reserve(100);
cout << v.size() << " " << v.capacity(); // 0 100
v.push_back(1);
cout << v.size() << " " << v.capacity(); // 1 100
v.resize(50);
cout << v.size() << " " << v.capacity(); // 50 100
实战建议:如果你预先知道要存多少元素,用reserve提前分配好空间。这样可以避免多次扩容带来的性能损耗。我在处理百万级数据时,光这一步就能省下好几秒。
插入与删除:小心迭代器失效
vector的插入和删除操作,最让人头疼的就是迭代器失效问题。我踩过这个坑,所以特别提醒你:
vector<int> v = {1, 2, 3, 4, 5};
// 尾部插入
v.push_back(6); // v: 1 2 3 4 5 6
// 指定位置插入
v.insert(v.begin() + 2, 99); // v: 1 2 99 3 4 5 6
// 尾部删除
v.pop_back(); // v: 1 2 99 3 4 5
// 指定位置删除
v.erase(v.begin() + 1); // v: 1 99 3 4 5
// 删除区间
v.erase(v.begin() + 1, v.begin() + 3); // v: 1 4 5
// 清空
v.clear(); // v: empty
迭代器失效:插入或删除元素后,所有指向被操作位置之后元素的迭代器、指针、引用都会失效。我曾经在循环里用失效的迭代器继续操作,结果程序直接崩溃。正确的做法是:每次插入/删除后,重新获取迭代器。
数据存取:随机访问的快乐
vector最大的优势就是随机访问——O(1)时间复杂度。因为底层是连续内存,通过偏移量直接计算地址。
vector<int> v = {10, 20, 30, 40, 50};
// 使用下标操作符
cout << v[0]; // 10
cout << v[3]; // 40
// 使用at()方法(带边界检查)
cout << v.at(2); // 30
// cout << v.at(10); // 越界,抛出out_of_range异常
// 获取首尾元素
cout << v.front(); // 10
cout << v.back(); // 50
// 获取底层数组指针
int* p = v.data();
cout << p[1]; // 20
个人习惯:在确定下标不会越界时用[],不确定时用at()。at()虽然慢一点点,但能帮你快速定位越界问题。调试阶段我一般用at(),发布版本再改成[]。
互换容器与预留空间:两个实用技巧
这两个操作看似简单,但用好了能解决不少实际问题。
swap:交换两个vector的内容
vector<int> v1 = {1, 2, 3};
vector<int> v2 = {10, 20, 30, 40};
v1.swap(v2); // 现在v1有4个元素,v2有3个元素
// 或者用非成员函数版本
swap(v1, v2);
swap还有一个妙用——收缩内存。当vector扩容后,即使删除了元素,capacity也不会自动缩小。这时候可以用swap技巧:
vector<int> v(1000, 1); // capacity = 1000
v.clear(); // size = 0, capacity = 1000
// 收缩到合适大小
vector<int>(v).swap(v); // 用临时vector交换,capacity = 0
// 或者用shrink_to_fit(C++11)
v.shrink_to_fit(); // 请求缩小capacity到size
注意:shrink_to_fit()只是一个请求,标准库不保证一定会缩小。而swap技巧是100%有效的。我在做内存敏感的项目时,一直用swap方式。
reserve:预留空间
前面已经提过,reserve用于提前分配内存。这里再强调一个细节:
vector<int> v;
v.reserve(1000); // 分配1000个int的空间
// 此时v.size()还是0,但capacity()是1000
// 接下来push_back 1000次,不会触发任何扩容
for (int i = 0; i < 1000; ++i) {
v.push_back(i); // 全部O(1),没有扩容开销
}
reserve和resize的区别要搞清楚:reserve只改capacity,resize改size。resize(100)会创建100个默认构造的元素,而reserve(100)只是预留空间。
避坑指南:我曾经在项目中用reserve(100)后,直接通过下标v[i]赋值,结果程序崩溃了。因为reserve不改变size,下标访问的是未初始化的内存。记住:reserve后只能用push_back或emplace_back,不能直接用下标。
好了,vector的核心操作就这些。从构造到扩容,从插入到删除,从随机访问到内存管理,每个细节都值得你亲手敲一遍代码。STL的设计哲学就是“高效且通用”,vector正是这一哲学的完美体现。
公众号:蓝海资料掘金营,微信deep3321