参考资料
杂项 | 算竞常用 C++ STL 用法 - 颢天笔记
本文参考这个大佬的博客,确实写的非常简洁明快,十分推荐去看这个大佬的博客,这篇文章对于我感兴趣的细节进行补充
Day1:Vector
创建
- 创建一个空 vector
vector<int> a;此时没有任何元素。
- 创建一个指定长度的 vector
vector<int> a(5);创建 5 个元素,默认初始化。
对于 int,这 5 个元素都是 0。
- 创建指定长度,并赋予统一初值
vector<int> a(5, 10);- 使用初始化列表
vector<int> a = {1, 2, 3, 4, 5};增删改查
- 添加元素:
push_back() - 删除末尾元素:
pop_back() - 删除指定位置的元素:
erase()
a.erase(a.begin() + 2);a.erase(a.begin() + 1, a.begin() + 3); //删除下标 `[1, 3)`erase() 会返回一个迭代器,指向被删除元素之后的那个元素(如果存在),你阅读之后的迭代器失效的情况下,你就知道这有什么用了。
- 使用下标修改元素,譬如:
a[1] = 100;- 访问元素
常用的几个方法:
| 写法 | 含义 | 结果 |
|---|---|---|
a[0] | 第一个元素 | 10 |
a.front() | 第一个元素 | 10 |
a.back() | 最后一个元素 | 30 |
a.at(1) | 下标为 1 的元素 | 20 |
at() 和 [] 的区别:
a[100]:越界访问会产生未定义行为。a.at(100):越界时会抛出异常。
size、empty、clear
size():获取元素数量empty():判断是否为空clear():清空元素
注意:clear()清除的是元素,不保证释放已经申请的全部内存。
重点!resize 和 reserve
先认识两个概念:
size:当前实际有多少个元素。capacity:当前已经分配的空间最多可以容纳多少个元素(不重新分配的情况下)。
例如:
vector<int> a = {1, 2, 3};此时:
size = 3capacity >= 3注意,capacity 不一定恰好等于 3。
resize(n)
改变 vector 的实际元素数量。比如对vector<int>进行resize(n)操作,新增的int元素会被初始化为0reserve(n)
只预留空间,不改变实际元素数量。也就是改变capacity,reserve(10)之后不能直接通过a[9]访问一个不存在的元素。
二维Vector
譬如:
vector<vector<int>> a(5, vector<int>(5));它相当于创建了一个 5 行 5 列的二维动态数组。
访问方式:
a[0][0] = 10;a[1][2] = 20;
cout << a[0][0];当然,还有一种更灵活的写法:
vector<vector<int>> a;
a.push_back({1, 2, 3});a.push_back({4, 5, 6});a.push_back({7, 8, 9});关于Vector的补充
capacity到底怎么决定?
如果你有一定的计算机基础知识,你应该会知道,数组和连续内存的关系密不可分,而capacity和连续内存分配也有重要的直接关系。
一、vector 为什么需要 capacity?
首先明确一个事实:C++ 标准规定,vector 中的元素必须连续存储。
也就是说:
vector<int> a = {10, 20, 30, 40};可以理解成是这样子的:

假设第一个元素的地址是 0x1000,那么:
| 元素 | 地址 |
|---|---|
| a[0] | 0x1000 |
| a[1] | 0x1004 |
| a[2] | 0x1008 |
| a[3] | 0x100C |
这就是连续内存。
因此,我们可以通过:
a[i]非常高效地访问任意位置的元素。
二、那么 capacity 是怎么来的?
假设我们创建:
vector<int> a;一开始:
size = 0capacity = 0现在:
a.push_back(10);vector 需要申请一块能够存储元素的连续内存。
为了避免每添加一个元素就重新申请内存,STL 通常会采用一种策略:一次申请比当前实际需求更多的空间。
例如,假设某个实现采用如下扩容策略(仅作演示):

你应该已经发现规律了:
size表示实际元素数量。capacity表示当前分配的存储空间可以容纳多少元素。- 当
size超过capacity,就需要扩容。
三、扩容时究竟发生了什么?
这是最有意思的地方。假设原来:
capacity = 4size = 4现在执行:
a.push_back(50);原来的空间已经满了。
由于 vector 要求连续内存,它不能简单地在原内存后面随便找个地方放第五个元素。
因此,典型的扩容过程是:
- 申请新的连续内存
假设新空间可以容纳 8 个元素。 - 迁移原来的元素
把原来的 4 个元素复制或移动到新空间。 - 释放旧空间
原来的内存不再使用,释放给内存分配器。 - 添加新元素
将50放入新空间
这也解释了一个非常重要的现象:
vector 扩容时,原来元素的内存地址可能全部发生变化。因此,扩容之后,之前保存的指针、引用和迭代器可能失效。
例如:
vector<int> a = {1, 2, 3};
int* p = &a[0];
a.push_back(4); // 如果发生扩容
// 此时 p 可能已经失效不要再使用这个失效的指针。
四.为什么每次不扩容一个空间?
- 策略 A:每次只增加一个元素的空间,可能需要进行大量内存重新分配和元素迁移。
- 策略 B:按一定比例增长:
例如每次将容量扩大到原来的两倍。
迁移次数就会大幅减少。
这使得push_back()的时间复杂度具有一个很重要的性质:
均摊 O(1),意思是:虽然某一次
push_back()可能因为扩容而需要 (O(n)) 的时间,但大量连续添加元素时,平均每次添加的时间复杂度是 (O(1))。
是不是很抽象呢?不严谨来说就是每次搬迁都会预留一下内存空间,导致下次不一定搬迁,平均下来就是O(N)
我们来不严谨的举一个例子:假设我们采用“两倍扩容”的策略,假设我们采用“两倍扩容”的策略,初始容量是 1:
| 操作 | size(元素数量) | capacity(容量) | 是否扩容 |
|---|---|---|---|
| 初始 | 0 | 1 | — |
| 添加 10 | 1 | 1 | 否 |
| 添加 20 | 2 | 2 | 是 |
| 添加 30 | 3 | 4 | 是 |
| 添加 40 | 4 | 4 | 否 |
| 添加 50 | 5 | 8 | 是 |
| 添加 60 | 6 | 8 | 否 |
| 添加 70 | 7 | 8 | 否 |
| 添加 80 | 8 | 8 | 否 |
| 添加 90 | 9 | 16 | 是 |
注意:不是每次添加元素都扩容。 扩容之后会空出不少位置,可以连续添加好几个元素。
那么扩容时,究竟做了多少工作?
假设从容量 1 开始,连续添加 9 个元素:
- 第 2 个元素到来:原来有 1 个元素,需要搬迁 1 个。
- 第 3 个元素到来:原来有 2 个元素,需要搬迁 2 个。
- 第 5 个元素到来:原来有 4 个元素,需要搬迁 4 个。
- 第 9 个元素到来:原来有 8 个元素,需要搬迁 8 个。
总搬迁次数 1+2+4+8=15 ,虽然扩容时需要搬迁元素,但扩容并不频繁。
这就是均摊复杂度
我们连续添加了 9 个元素:
- 普通添加:大多数时候只需要把新元素放进去。
- 扩容添加:偶尔需要搬迁已有元素,成本比较高。
- 所有操作加起来:搬迁次数是 15 次,再加上 9 次新元素写入,总工作量约为 24。
平均到每次添加:
也就是说,虽然某一次push_back()可能很慢,但长期来看,每次添加的平均工作量仍然是常数级。
这就是
这里的关键是:均摊 O(1) 不代表每次操作都只花 O(1),而是连续进行很多次操作后,平均每次是 O(1)。
注意:C++ 标准不规定必须翻倍,不同 STL 实现可以采用不同的增长策略。
迭代器失效
之前我们提过,
vector 扩容时,原来元素的内存地址可能全部发生变化。因此,扩容之后,之前保存的指针、引用和迭代器可能失效
需要注意以下操作可能会导致迭代器失效:
| 操作 | 可能发生的失效 |
|---|---|
push_back() | 如果触发扩容,所有迭代器失效 |
reserve() | 如果改变了容量,所有迭代器失效 |
clear() | 所有指向元素的迭代器失效 |
erase() | 被删除位置及其后面的迭代器失效 |
pop_back() | 指向被删除元素的迭代器失效 |
| 迭代器失效的应用:删除一个数组里面的偶数 |
for (auto it = a.begin(); it != a.end(); it++) { if (*it % 2 == 0) { a.erase(it); }}想一想,这个代码有问题吗?看起来没问题,对吧?但实际上,这段代码有一个严重的问题。
关键在这里:
a.erase(it);我们之前学过,erase() 会让被删除位置及其后面的迭代器失效。
假设当前:
a = {1, 2, 3, 4, 5} ↑ it执行:
a.erase(it);删除 2 后:
a = {1, 3, 4, 5}原来的 it 已经失效了!
但是 for 循环结束这一轮后,还会执行:
it++;这相当于对一个已经失效的迭代器进行操作,属于未定义行为。
那我们怎么办呢?
还记得吗?erase() 会返回一个新的有效迭代器。
所以应该这样写:
for (auto it = a.begin(); it != a.end();) { if (*it % 2 == 0) { it = a.erase(it); } else { it++; }}迭代器相关误区
如以下所示,笔者写了一个十分有九分愚蠢的代码:
for(auto it = dlist.end();it != dlist.begin();--it){//do sth.}初学者乍一看可能绝对没有什么问题,就是应该“正常的(?)”的倒序for循环,但是一段代码就有三个问题。我们首先来看最明显的错误:
end() 迭代器的行为
初始化时 it = dlist.end(),但是end() 指向的是最后一个元素的“下一个位置”,它里面没有有效数据! 如果我们在循环体里面解引用它,程序会直接崩溃(段错误)。
所以说关于上面的a.back();其实等效于*(--a.end()),而不是end()
it != dlist.begin() 的问题
这里来举个例子,你就发现其实这里还是有毒点的。
假设我们的容器里面有 3 个元素:[10, 20, 30]。
在内存里,它们的排列是这样的:
数据: [ 10 ] [ 20 ] [ 30 ] 无意义内存位置: begin() 中间 end()当 it 退到 20 时,判断 it != begin() 成立,打印 20,然后 —it。
此时 it 指向了第一个元素 10。 再次判断 it != begin()。因为此时 it 已经等于 begin() 了,条件为假,循环直接结束了! 所以,第一个元素 10 永远没有被打印出来。
所以说我们的第一个元素好像被很悲哀的吃掉了(
那么逆序输出要怎么办才好呢,请看下一节:
逆序输出的最终解
方法一:使用反向迭代器(最推荐、最安全)
C++ 专门为倒序遍历设计了 rbegin()(反向起点,指向最后一个元素)和 rend()(反向终点,指向第一个元素之前)。
for(auto it = dlist.rbegin(); it != dlist.rend(); ++it) { cout << *it << endl;}注意:反向迭代器的 ++it 在底层其实是往前移动。
方法二:使用正向迭代器强行倒序(笔试/考试常考)
如果你非要用正向迭代器倒着走,必须先判断容器不为空,然后用前置递减:
if (!dlist.empty()) { // 从最后一个元素的前一个位置开始(也就是最后一个有效元素) auto it = dlist.end(); do { --it; // 先往前退一步 cout << *it << " "; } while (it != dlist.begin()); // 退到开头就停止}如果是C++20还可以用 C++ 的范围 for 结合反向适配器,但是比赛仅支持到C++11,故在此不表,有兴趣可以自己查一下。
范围循环
在迭代器基础上,也可以学习使用范围循环,示例代码如下:
for (auto& x : a) { cout << x << endl;}在这个范围循环的语境下,&x 的意思是“引用”,绝对不是“取地址”。
这里 x 不是容器里的某个元素的复制品,而是那个元素的**别名(引用
引用
我们来对比一下加 & 和不加 & 的区别,这是 C++ 里非常关键的一个知识点:
假设你有一个容器:
vector<int> a = {1, 2, 3};1. 不加 &:for (auto x : a)
- 这里的
x是容器里元素的拷贝。 - 每次循环,系统会把
a里面的元素复制一份,放进x里。 - 如果你在循环里修改
x(比如x = 100;),原容器a不会发生任何改变,因为x只是一个副本。
2. 加 &:for (auto& x : a)
- 这里的
&是引用的意思。x是原容器中元素的别名。 - 它不会复制元素,而是直接“指”向原容器里的那个元素。
- 如果你在循环里修改
x(比如x = 100;),原容器a里面的值会被真正修改。
3. 加 const &:for (const auto& x : a)
- 引用(不拷贝)+ 常量(不能修改)。
- 如果你只是想遍历打印,不想修改原数据,或者容器的元素非常大(比如是结构体、长字符串),强烈推荐这样写。这样既避免了拷贝(省内存、速度快),又保证了数据安全。
迭代器的++i与i++
谭浩强狂喜
for (vector<int>::iterator it = a.begin(); it != a.end(); ++it) cout << *it << endl;你有没有想过,为什么不是i++,而是++i?
最基础的区别
++i是先自增,后返回,i++是先返回,后自增,从后面的一个小例子你可以显然看出来他们的区别:
int a = 1;int b;b = a++; //b = 1但是如果是++i呢?
int a = 1;int b;b = ++a; //b = 2CPP与C不同的行为:引用
在 C 语言中,++i 和 i++ 只能作用于基本数据类型和指针。
C 语言底层直接对应 CPU 的自增指令,效率上没有区别。
但是在CPP中,就不一样了,C++ 比 C 强大在于它支持运算符重载。对于 C++ 的类(Class/Struct,包括迭代器):
++i调用的是operator++()函数。i++调用的是operator++(int)函数(括号里的int只是个占位符,用来区分前置和后置)。
为了实现后置递增(i++),C++ 编译器会在底层生成类似这样的代码:
Iterator operator++(int) { Iterator temp = *this; // 1. 拷贝自身 ++(*this); // 2. 调用前置递增,把自己加1 return temp; // 3. 返回旧值的拷贝}// 简化的后置递增内部实现看,i++ 里面其实还是调用了 ++i。所以对于自定义类(迭代器),it++ 比 ++it 多做了一次拷贝。
这样带来了性能的损耗,所以说我们使用++i而不是i++
分享文章
生成精美分享图或复制链接,与更多人分享本文。
继续阅读
换条路线
从其他文章中稳定抽取
评论