3794 字
19 分钟
Day 1:Vector

参考资料#

杂项 | 算竞常用 C++ STL 用法 - 颢天笔记
本文参考这个大佬的博客,确实写的非常简洁明快,十分推荐去看这个大佬的博客,这篇文章对于我感兴趣的细节进行补充

Day1:Vector#

创建#

  1. 创建一个空 vector
vector<int> a;

此时没有任何元素。

  1. 创建一个指定长度的 vector
vector<int> a(5);

创建 5 个元素,默认初始化。
对于 int,这 5 个元素都是 0。

  1. 创建指定长度,并赋予统一初值
vector<int> a(5, 10);
  1. 使用初始化列表
vector<int> a = {1, 2, 3, 4, 5};

增删改查#

  1. 添加元素:push_back()
  2. 删除末尾元素:pop_back()
  3. 删除指定位置的元素: erase()
a.erase(a.begin() + 2);
a.erase(a.begin() + 1, a.begin() + 3); //删除下标 `[1, 3)`

erase() 会返回一个迭代器,指向被删除元素之后的那个元素(如果存在),你阅读之后的迭代器失效的情况下,你就知道这有什么用了。

  1. 使用下标修改元素,譬如:
a[1] = 100;
  1. 访问元素
    常用的几个方法:
写法含义结果
a[0]第一个元素10
a.front()第一个元素10
a.back()最后一个元素30
a.at(1)下标为 1 的元素20

at() 和 [] 的区别:

  • a[100]:越界访问会产生未定义行为。
  • a.at(100):越界时会抛出异常。

size、empty、clear#

  1. size():获取元素数量
  2. empty():判断是否为空
  3. clear():清空元素
    注意:clear() 清除的是元素,不保证释放已经申请的全部内存。

重点!resize 和 reserve#

先认识两个概念:

  • size:当前实际有多少个元素。
  • capacity:当前已经分配的空间最多可以容纳多少个元素(不重新分配的情况下)。
    例如:
vector<int> a = {1, 2, 3};

此时:

size = 3
capacity >= 3

注意,capacity 不一定恰好等于 3。

  1. resize(n)
    改变 vector 的实际元素数量。比如对vector<int>进行resize(n)操作,新增的int元素会被初始化为0
  2. reserve(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 = 0
capacity = 0

现在:

a.push_back(10);

vector 需要申请一块能够存储元素的连续内存。
为了避免每添加一个元素就重新申请内存,STL 通常会采用一种策略:一次申请比当前实际需求更多的空间。
例如,假设某个实现采用如下扩容策略(仅作演示):

你应该已经发现规律了:

  • size 表示实际元素数量。
  • capacity 表示当前分配的存储空间可以容纳多少元素。
  • 当 size 超过 capacity,就需要扩容。

三、扩容时究竟发生了什么?#

这是最有意思的地方。假设原来:

capacity = 4
size = 4

现在执行:

a.push_back(50);

原来的空间已经满了。
由于 vector 要求连续内存,它不能简单地在原内存后面随便找个地方放第五个元素。
因此,典型的扩容过程是:

  1. 申请新的连续内存
    假设新空间可以容纳 8 个元素。
  2. 迁移原来的元素
    把原来的 4 个元素复制或移动到新空间。
  3. 释放旧空间
    原来的内存不再使用,释放给内存分配器。
  4. 添加新元素
    将 50 放入新空间

这也解释了一个非常重要的现象:
vector 扩容时,原来元素的内存地址可能全部发生变化。因此,扩容之后,之前保存的指针、引用和迭代器可能失效。
例如:

vector<int> a = {1, 2, 3};
int* p = &a[0];
a.push_back(4); // 如果发生扩容
// 此时 p 可能已经失效

不要再使用这个失效的指针。

四.为什么每次不扩容一个空间?#

  1. 策略 A:每次只增加一个元素的空间,可能需要进行大量内存重新分配和元素迁移。
  2. 策略 B:按一定比例增长:
    例如每次将容量扩大到原来的两倍。
    迁移次数就会大幅减少。
    这使得 push_back() 的时间复杂度具有一个很重要的性质:
    均摊 O(1),意思是:

    虽然某一次 push_back() 可能因为扩容而需要 (O(n)) 的时间,但大量连续添加元素时,平均每次添加的时间复杂度是 (O(1))。

是不是很抽象呢?不严谨来说就是每次搬迁都会预留一下内存空间,导致下次不一定搬迁,平均下来就是O(N)
我们来不严谨的举一个例子:假设我们采用“两倍扩容”的策略,假设我们采用“两倍扩容”的策略,初始容量是 1:

操作size(元素数量)capacity(容量)是否扩容
初始01—
添加 1011否
添加 2022是
添加 3034是
添加 4044否
添加 5058是
添加 6068否
添加 7078否
添加 8088否
添加 90916是

注意:不是每次添加元素都扩容。 扩容之后会空出不少位置,可以连续添加好几个元素。

那么扩容时,究竟做了多少工作?#

假设从容量 1 开始,连续添加 9 个元素:

  • 第 2 个元素到来:原来有 1 个元素,需要搬迁 1 个。
  • 第 3 个元素到来:原来有 2 个元素,需要搬迁 2 个。
  • 第 5 个元素到来:原来有 4 个元素,需要搬迁 4 个。
  • 第 9 个元素到来:原来有 8 个元素,需要搬迁 8 个。

总搬迁次数 1+2+4+8=15 ,虽然扩容时需要搬迁元素,但扩容并不频繁。

这就是均摊复杂度#

我们连续添加了 9 个元素:

  • 普通添加:大多数时候只需要把新元素放进去。
  • 扩容添加:偶尔需要搬迁已有元素,成本比较高。
  • 所有操作加起来:搬迁次数是 15 次,再加上 9 次新元素写入,总工作量约为 24。
    平均到每次添加:
    249≈2.67\frac{24}{9}\approx 2.67
    也就是说,虽然某一次 push_back() 可能很慢,但长期来看,每次添加的平均工作量仍然是常数级。
    这就是
    push_back 的均摊时间复杂度为 O(1)\boxed{\text{push\_back 的均摊时间复杂度为 }O(1)}
    这里的关键是:均摊 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 = 2

CPP与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++

Day 1:Vector
https://youran.netlify.app/posts/day1_vector/day-1vector/
作者
悠然
发布于
2026-10-23
许可协议
CC BY-NC-SA 4.0

分享文章

生成精美分享图或复制链接,与更多人分享本文。

继续阅读

沿着主题读

基于共同的标签与分类

换条路线

从其他文章中稳定抽取

评论

正在加载评论...