【C++面试】vector底层原理:扩容、拷贝移动与迭代器失效

一、vector底层到底是什么

vector 可以简单理解成:

一块动态申请的连续内存。

例如:

复制代码
std::vector<int> nums;

nums.push_back(10);
nums.push_back(20);
nums.push_back(30);

底层大致可以理解成:

复制代码
连续内存:

┌────┬────┬────┐
│ 10 │ 20 │ 30 │
└────┴────┴────┘

因为元素在内存中连续排列,所以可以直接:

复制代码
nums[0];
nums[1];
nums[2];

访问。

假设第一个元素地址是:

复制代码
0x1000

一个 int 占 4 字节,那么:

复制代码
nums[0] → 0x1000

nums[1] → 0x1004

nums[2] → 0x1008

所以访问某个位置只需要计算:

复制代码
首地址 + 下标 × 元素大小

因此:

复制代码
nums[i];

的时间复杂度可以做到:

复制代码
O(1)

这也是 vector 和 list 一个很大的区别。

list 的元素不是连续存储:

复制代码
Node1 → Node2 → Node3 → Node4

想访问第 100 个元素,需要从前面不断往后找。

而 vector:

复制代码
连续内存
   ↓
直接计算地址

所以支持:

复制代码
随机访问

为了管理这块动态内存,可以把 vector 的底层简化理解成维护三个位置:

复制代码
start
 ↓
┌────┬────┬────┬────┬────┬────┐
│ 10 │ 20 │ 30 │    │    │    │
└────┴────┴────┴────┴────┴────┘
               ↑              ↑
             finish        end_of_storage

也就是:

复制代码
start
    ↓
第一个元素的位置

finish
    ↓
当前最后一个有效元素的下一个位置

end_of_storage
    ↓
整块内存空间的末尾

因此:

复制代码
size = finish - start

capacity = end_of_storage - start

例如:

复制代码
std::vector<int> nums;

nums.reserve(10);

nums.push_back(1);
nums.push_back(2);
nums.push_back(3);

此时可能是:

复制代码
size = 3

capacity = 10

也就是:

复制代码
真正存了3个元素
但是已经准备好了10个元素的空间

所以一定要区分:

复制代码
size

和:

复制代码
capacity

它们不是一个概念。

二、vector为什么需要扩容

假设当前 vector:

复制代码
size = 4
capacity = 4

内存:

复制代码
┌────┬────┬────┬────┐
│ 10 │ 20 │ 30 │ 40 │
└────┴────┴────┴────┘

空间已经全部使用

这时候再:

复制代码
nums.push_back(50);

原来的空间已经放不下了。

但是 vector 的数据必须:

复制代码
连续存储

它不能简单地在旁边随便找一块空间:

复制代码
旧内存:

┌────┬────┬────┬────┐
│ 10 │ 20 │ 30 │ 40 │
└────┴────┴────┴────┘

另一处:

┌────┐
│ 50 │
└────┘

如果这样存,数据就不连续了。

所以 vector 必须进行:

复制代码
扩容

整个扩容过程可以理解成:

复制代码
原空间不足
   ↓
申请一块更大的连续内存
   ↓
把原来的元素搬过去
   ↓
构造新元素
   ↓
销毁旧元素
   ↓
释放旧内存

例如原来:

复制代码
capacity = 4

旧区域:

复制代码
┌────┬────┬────┬────┐
│ 10 │ 20 │ 30 │ 40 │
└────┴────┴────┴────┘

现在申请一块更大的空间:

复制代码
┌────┬────┬────┬────┬────┬────┬────┬────┐
│    │    │    │    │    │    │    │    │
└────┴────┴────┴────┴────┴────┴────┴────┘

然后搬过去:

复制代码
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ 10 │ 20 │ 30 │ 40 │ 50 │    │    │    │
└────┴────┴────┴────┴────┴────┴────┴────┘

最后原来的内存:

复制代码
释放

这就是 vector 扩容的基本过程。

很多时候会听到:

复制代码
vector按1.5倍扩容

或者:

复制代码
vector按2倍扩容

这里需要注意:

C++ 标准并没有规定 vector 必须按照固定的 1.5 倍或者 2 倍扩容。

具体增长策略由:

复制代码
STL实现
编译器
标准库版本

决定。

不同实现可能采用不同策略。

因此面试时更严谨的说法是:

vector 容量不足时一般会按照某种增长因子申请更大的连续内存,不同标准库的具体扩容倍数可能不同,常见实现可能采用约 1.5 倍或 2 倍增长。

为什么不每次只增加一个元素?

假设每次:

复制代码
capacity + 1

那么连续插入 10000 个元素,就可能发生大量:

复制代码
申请新内存
复制旧数据
释放旧内存

开销非常大。

采用成倍增长以后:

复制代码
1
↓
2
↓
4
↓
8
↓
16
↓
32
...

扩容次数会大幅减少。

这也是为什么:

复制代码
push_back();

虽然偶尔一次扩容需要:

复制代码
O(N)

但连续很多次 push_back() 的平均复杂度,也就是:

复制代码
均摊复杂度

通常可以认为是:

复制代码
O(1)

三、扩容时到底是拷贝还是移动

这里是 vector 面试中很容易继续被问到的一点。

假设:

复制代码
class Student {
public:
    Student() {
        std::cout << "构造" << std::endl;
    }

    Student(const Student &) {
        std::cout << "拷贝构造" << std::endl;
    }

    Student(Student &&) noexcept {
        std::cout << "移动构造" << std::endl;
    }
};

然后:

复制代码
std::vector<Student> students;

students.push_back(Student());
students.push_back(Student());
students.push_back(Student());

当 vector 需要扩容时,旧内存中的对象必须被搬到:

复制代码
新的内存区域

这时候有两种方案。

第一种:

复制代码
拷贝构造

相当于:

复制代码
Student newObject(oldObject);

第二种:

复制代码
移动构造

相当于:

复制代码
Student newObject(std::move(oldObject));

对于一个内部拥有大量资源的对象来说,移动通常比深拷贝成本更低。

例如:

复制代码
class Buffer {
private:
    char *data_;
};

拷贝可能需要:

复制代码
重新申请内存
   ↓
复制全部数据

而移动可以:

复制代码
直接接管data_指针
   ↓
原对象置空

所以:

复制代码
移动

通常会更高效。

但是这里又涉及:

复制代码
noexcept

例如:

复制代码
Student(Student &&other) noexcept;

为什么移动构造经常建议加:

复制代码
noexcept

?

因为 vector 扩容时很重视:

复制代码
异常安全

假设原来:

复制代码
A B C D

准备搬到新空间。

已经成功移动:

复制代码
A B

但是移动:

复制代码
C

的时候突然抛异常。

如果前面的:

复制代码
A B

已经被移动走,原对象状态可能已经发生变化。

vector 就比较难保证:

复制代码
扩容失败以后
原来的vector仍然保持原状

因此标准库实现通常会根据类型特性决定:

复制代码
优先移动
还是
优先拷贝

常见情况可以理解为:

复制代码
移动构造是noexcept
      ↓
优先使用移动

如果:

复制代码
移动可能抛异常
但是类型又可以拷贝

为了更好的异常安全保证,实现可能选择:

复制代码
拷贝构造

这也是:

复制代码
std::move_if_noexcept

背后的一个重要思路。

所以面试问:

vector扩容时为什么移动构造最好写noexcept?

可以回答:

vector 扩容需要把旧元素搬到新的内存区域。如果类型的移动构造明确标记为 noexcept,标准库可以更放心地使用移动来提高效率;如果移动可能抛异常而类型又支持拷贝,实现可能为了保证异常安全而选择拷贝。

所以:

复制代码
vector扩容
    ↓
重新申请内存
    ↓
搬迁旧元素
    ↓
移动 / 拷贝
    ↓
释放旧内存

而不是简单:

复制代码
realloc一下

就结束了。

四、resize、reserve和push_back有什么区别

这几个函数也是 vector 面试高频。

先来看:

复制代码
reserve();

例如:

复制代码
std::vector<int> nums;

nums.reserve(100);

它主要改变:

复制代码
capacity

但是:

复制代码
size不变

例如:

复制代码
size = 0
capacity = 100

可以理解成:

复制代码
提前准备100个元素的空间
但现在一个元素都没有

所以不能因为:

复制代码
nums.reserve(100);

就直接认为:

复制代码
nums[50] = 10;

是合理的。

因为:

复制代码
size仍然是0

还不存在第 50 个有效元素。


而:

复制代码
resize();

改变的是:

复制代码
size

例如:

复制代码
std::vector<int> nums;

nums.resize(5);

此时:

复制代码
size = 5

真的已经存在 5 个元素:

复制代码
┌───┬───┬───┬───┬───┐
│ 0 │ 0 │ 0 │ 0 │ 0 │
└───┴───┴───┴───┴───┘

如果新的 size 超过当前 capacity:

复制代码
resize

也可能触发扩容。

所以可以简单区分:

复制代码
reserve
   ↓
提前准备空间
   ↓
主要改变capacity

resize
   ↓
改变真正的元素数量
   ↓
改变size

再看:

复制代码
push_back();

例如:

复制代码
nums.push_back(10);

表示:

复制代码
在vector末尾增加一个元素

如果:

复制代码
size < capacity

当前空间还有位置:

复制代码
直接在尾部构造

例如:

复制代码
size = 3
capacity = 8

执行:

复制代码
push_back(100);

通常不需要重新申请内存。

但如果:

复制代码
size == capacity

空间已经满了:

复制代码
push_back
   ↓
触发扩容

因此如果提前知道大概需要存:

复制代码
100000个元素

可以:

复制代码
std::vector<int> nums;
nums.reserve(100000);

然后再不断:

复制代码
nums.push_back(...);

这样可以减少中间多次扩容。

三个接口可以简单记成:

操作 size capacity 主要作用
reserve(n) 一般不改变 至少准备到 n 提前预留空间
resize(n) 改变 必要时扩大 改变有效元素数量
push_back(x) +1 空间不足时扩大 尾部增加元素

五、为什么扩容会导致迭代器失效

这是 vector 面试中非常经典的问题。

例如:

复制代码
std::vector<int> nums = {1, 2, 3};

auto it = nums.begin();

这时:

复制代码
it
 ↓
┌───┬───┬───┐
│ 1 │ 2 │ 3 │
└───┴───┴───┘

假设继续:

复制代码
nums.push_back(4);

并且这次恰好触发扩容。

vector 会:

复制代码
申请新的内存
      ↓
搬迁所有元素
      ↓
释放原来的内存

也就是说:

复制代码
原地址:

0x1000

可能变成:

复制代码
新地址:

0x5000

原来的:

复制代码
it

还保存:

复制代码
0x1000

但是:

复制代码
0x1000

那块内存已经被释放了。

因此:

复制代码
*it

就变成了未定义行为。

这就是:

复制代码
迭代器失效

实际上不仅是迭代器。

原来指向 vector 元素的:

复制代码
指针
引用

也可能一起失效。

例如:

复制代码
std::vector<int> nums = {1, 2, 3};

int *p = &nums[0];
int &ref = nums[1];

nums.push_back(4);

如果:

复制代码
push_back触发扩容

那么:

复制代码
p
ref

也可能全部失效。

所以可以理解成:

复制代码
vector扩容
    ↓
底层内存地址改变
    ↓
原来的iterator
pointer
reference
    ↓
全部失效

这里还要区分:

push_back有没有发生扩容。

如果:

复制代码
size < capacity

没有重新分配底层存储,一般不会因为单纯的尾插导致已有元素的引用、指针和迭代器全部失效;不过原来的 end() 会失效。

如果:

复制代码
size == capacity

触发重新分配:

复制代码
所有指向原元素的迭代器、指针和引用都会失效

另外:

复制代码
erase();

也会导致部分迭代器失效。

例如:

复制代码
std::vector<int> nums = {10, 20, 30, 40};

auto it = nums.begin() + 1;

nums.erase(it);

删除:

复制代码
20

以后,后面的元素需要向前移动:

复制代码
原来:

10 20 30 40

删除20:

10 30 40

因此:

复制代码
被删除位置
以及它后面的迭代器

都会失效。

所以面试时可以这样总结 vector 的迭代器失效:

如果 vector 发生重新分配,原来指向元素的迭代器、指针和引用都会失效;如果没有重新分配,push_back 通常不会使已有元素的引用和指针失效,但原来的 end() 会变化。erase 会使被删除位置以及其后的迭代器失效,因为后面的元素需要向前移动。

把整篇内容串起来,vector 的底层逻辑其实可以概括成:

复制代码
vector
   ↓
连续动态内存
   ↓
size记录元素数量
capacity记录容量
   ↓
空间不足
   ↓
申请更大的连续内存
   ↓
移动或拷贝旧元素
   ↓
释放旧内存
   ↓
底层地址发生变化
   ↓
原来的迭代器可能失效

如果面试官问:

vector底层是怎么实现的?

可以直接回答:

vector 底层使用一块连续的动态内存存储元素,因此支持 O(1) 随机访问。它内部需要维护当前元素数量和容量,当 size 达到 capacity 后继续插入元素,就需要申请一块更大的连续内存,再通过拷贝或移动构造把旧元素搬过去,最后释放旧内存。具体扩容倍数由标准库实现决定,并不是 C++ 标准固定要求的 1.5 倍或 2 倍。由于重新分配后底层地址发生变化,所以原来的迭代器、指针和引用都会失效。

如果继续追问:

为什么push_back平均是O(1),明明扩容是O(N)?

可以回答:

单次扩容确实需要搬迁 N 个元素,是 O(N),但 vector 一般采用增长容量而不是每次只增加一个位置,所以扩容不会每次发生。把多次 push_back 的总成本平均下来,其均摊时间复杂度是 O(1)。

如果继续问:

reserve有什么用?

可以回答:

reserve() 可以提前申请足够的容量,减少后续连续插入过程中反复扩容和搬迁元素的次数。在能够预估元素数量时,提前 reserve 通常能够降低不必要的内存重新分配开销。

所以 vector 面试真正需要掌握的核心并不是只会:

复制代码
push_back();

而是理解下面这一条完整链路:

复制代码
连续内存
   ↓
size / capacity
   ↓
容量不足
   ↓
扩容
   ↓
拷贝或移动
   ↓
noexcept与异常安全
   ↓
旧内存释放
   ↓
迭代器失效

这也是为什么 vector 看起来只是一个简单容器,却能连续考察:

复制代码
动态内存
移动语义
异常安全
迭代器
时间复杂度

多个 C++ 基础知识点。

0voice · GitHub

相关推荐
朔北之忘 Clancy2 小时前
青少年软编等考七级题解目录
开发语言·c++·青少年编程·gesp·csp·信奥赛·noi
汉克老师2 小时前
GESP2026年9月认证C++七级( 第一部分选择题(8~15题)精讲
c++·gesp·小学生·学c++编程
Mr_Macallon2 小时前
C++回顾(基础)(01)
c语言·开发语言·c++·学习
考研保研资料分享3 小时前
电气工程保研经验:夏令营失利后上岸上交,科研追问与九推复习(2024)
面试
如意猴3 小时前
【C++】007 C/C++ 内存管理机制、malloc与new的区别及模板初阶
java·c++·算法
朝朝辞暮i3 小时前
C++ 第 32 课:main()、rclcpp::init()、spin()、shutdown()
开发语言·c++·算法
汉克老师3 小时前
GESP2026年9月认证C++一级( 第三部分编程题(1、新龟兔赛跑))精讲
c++·gesp·小学生·学c++编程
沫璃染墨3 小时前
《从零入门Linux系统篇(五十七):线程篇·十——生产者消费者模型进阶:从环形缓冲区到POSIX信号量》
linux·运维·服务器·开发语言·c++·系统架构·信号处理
时间的拾荒人3 小时前
Qt 窗口系统详解:从菜单栏到对话框的完整指南
开发语言·qt·面试