🔥 本文定位 :本文从
std::vector的连续存储模型出发,系统讲解构造、遍历、容量管理、增删查改、迭代器失效、二维vector、常见 OJ 用法,并通过一个教学版MiniVector理解扩容、元素迁移与对象生命周期。💡 学习目标 :不仅要会调用
push_back、reserve、resize和erase,还要能够解释size与capacity为什么分离、扩容为什么可能让全部迭代器失效、循环删除为何必须接收erase的返回值,以及资源管理类型为什么不能靠memcpy迁移。📌 阅读说明:本文以 C++11 及之后的主流用法为背景,模拟实现使用标准分配器表达对象生命周期。扩容倍率、内部指针名称和迭代器具体类型都属于实现细节,不应误写成 C++ 标准的统一规定。

文章目录
- [一、先建立全局视角:vector 到底是什么](#一、先建立全局视角:vector 到底是什么)
- [二、vector 的定义、构造与初始化](#二、vector 的定义、构造与初始化)
- 三、迭代器与多种遍历方式
- [四、size、capacity、reserve 与 resize](#四、size、capacity、reserve 与 resize)
- 五、增删查改与元素访问
- [六、迭代器失效:vector 最重要的边界](#六、迭代器失效:vector 最重要的边界)
- [七、vector 在算法题中的典型用法](#七、vector 在算法题中的典型用法)
- [八、底层模型、扩容过程与 memcpy 陷阱](#八、底层模型、扩容过程与 memcpy 陷阱)
- [九、教学版 MiniVector 的核心模拟实现](#九、教学版 MiniVector 的核心模拟实现)
- [十、二维 vector 的真实内存结构](#十、二维 vector 的真实内存结构)
- 十一、常见误区、高频面试题与练习
- 总结
一、先建立全局视角:vector 到底是什么
1.1 一句话认识 vector
std::vector 是 C++ 标准库中的动态连续序列容器。它像数组一样把元素连续存放,因此支持高效的随机访问;它又能根据需要自动申请更大的存储空间,因此元素个数不必在编译期固定。
可以先记住下面这句话:
vector本质上是一个能够自动管理动态连续存储空间的顺序表。
最小示例:
cpp
#include <iostream>
#include <vector>
int main() {
std::vector<int> numbers{10, 20, 30};
numbers.push_back(40);
for (int value : numbers) {
std::cout << value << ' ';
}
}
这里的 numbers 在逻辑上保存四个 int。它负责申请存储空间、构造元素、在不再需要时析构元素并释放空间,调用者不必手写一对 new[] 和 delete[]。

1.2 vector 的核心优势
vector 最重要的特征是连续存储。只要容器非空,元素就按下标顺序排列在一段连续地址中,因此可以获得以下能力:
operator[]、at()随机访问通常是O(1);- 可以通过
data()获得首元素地址,与需要连续缓冲区的接口协作; - 顺序遍历具有良好的缓存局部性;
- 尾部追加在多次操作的平均意义下通常是摊还
O(1); std::sort等要求随机访问迭代器的算法可以直接使用。
连续存储同时带来了边界:
- 中间插入和删除需要搬移后续元素,通常是
O(n); - 当前容量不足时,容器可能重新分配整块空间并迁移已有元素;
- 重新分配后,原先指向元素的迭代器、指针和引用都会失效;
- 在首部频繁插入并不是
vector的强项。
1.3 为什么动态序列通常先考虑 vector
如果需求只是"保存一组数量会变化的元素并频繁遍历",vector 通常是第一候选。原因不只是随机访问复杂度低,还包括:
- 连续存储对 CPU 缓存友好;
- 每个元素之外通常没有链表节点指针开销;
- 标准算法与范围接口支持完善;
- 工程经验丰富,编译器和标准库实现高度优化;
- 即使存在中间删除,实际数据规模较小时也可能比节点容器更快。
但"通常先考虑"不等于"永远选择"。如果业务依赖稳定地址、已知位置的频繁中间插删、双端操作或其他特殊语义,还应比较 deque、list、关联容器或专用数据结构。
1.4 vector 与原生数组的区别
| 对比项 | 原生数组 | std::array |
std::vector |
|---|---|---|---|
| 元素个数 | 通常固定 | 编译期固定 | 运行期可变 |
| 连续存储 | 是 | 是 | 是 |
| 自动管理资源 | 能力有限 | 是 | 是 |
| 保存当前长度 | 需结合上下文 | 是 | 是 |
| 可扩容 | 否 | 否 | 是 |
| 与标准算法协作 | 可以 | 很方便 | 很方便 |
如果长度在编译期固定,std::array 往往比裸数组更容易使用;如果长度会在运行时变化,std::vector 通常更自然。
二、vector 的定义、构造与初始化
2.1 基本定义
使用 vector 需要包含头文件:
cpp
#include <vector>
常见定义方式:
cpp
std::vector<int> scores;
std::vector<double> prices;
std::vector<std::string> names;
vector 是类模板,尖括号中的类型决定元素类型。std::vector<int> 与 std::vector<double> 是两个不同的具体类型。
2.2 常见构造方式
cpp
#include <iostream>
#include <vector>
int main() {
std::vector<int> v1; // 空 vector
std::vector<int> v2(5); // 5 个值初始化的 int,结果通常是 0
std::vector<int> v3(5, 7); // 5 个 7
std::vector<int> v4{1, 2, 3, 4}; // 初始化列表
std::vector<int> v5(v4); // 拷贝构造
std::vector<int> v6(v4.begin() + 1,
v4.end()); // 由迭代器区间构造:2 3 4
}
需要特别区分圆括号和花括号:
cpp
std::vector<int> a(5, 1); // 5 个 1
std::vector<int> b{5, 1}; // 两个元素:5 和 1
这两行代码外观相近,含义完全不同。前者调用"数量 + 初值"构造;后者优先匹配初始化列表构造。
2.3 区间构造采用左闭右开范围
cpp
std::vector<int> source{10, 20, 30, 40, 50};
std::vector<int> part(source.begin() + 1, source.begin() + 4);
part 得到 20、30、40。区间是 [first, last):包含 first,不包含 last。这种约定使空区间可以写成 [it, it),区间长度也能自然表达为 last - first(当迭代器支持减法时)。
传入的两个迭代器必须形成有效范围。把来自不同容器的迭代器拼成一个区间,或者让 first 位于 last 之后,都会破坏前置条件。
2.4 拷贝、移动与赋值
cpp
std::vector<std::string> a{"C++", "STL"};
std::vector<std::string> b = a; // 拷贝元素
std::vector<std::string> c = std::move(a); // 移动构造
b = {"vector", "list", "deque"};
c.assign(4, "hello");
拷贝构造会得到独立容器。移动构造通常能够接管原容器资源,但精确行为还受分配器等规则影响。被移动对象仍然有效,可以析构或重新赋值,但不应假设它一定为空,除非相应接口明确保证。
assign 会替换整个元素序列,而不是在末尾追加:
cpp
std::vector<int> v{1, 2, 3};
v.assign(5, 9); // 现在是 9 9 9 9 9
替换序列可能改变容量,也会使原有迭代器、指针和引用失去可用性。修改容器后不要依赖旧位置对象,应该按接口的失效规则重新获取。
三、迭代器与多种遍历方式
3.1 begin、end、rbegin 与 rend
vector 的常用迭代接口如下:
| 接口 | 含义 |
|---|---|
begin() |
指向第一个元素;空容器时等于 end() |
end() |
指向最后一个元素之后的尾后位置 |
cbegin() / cend() |
返回只读迭代器 |
rbegin() |
反向遍历的起点,对应最后一个元素 |
rend() |
反向遍历的尾后位置 |
crbegin() / crend() |
返回只读反向迭代器 |
最重要的边界是:
end()不是最后一个元素,不能解引用。
cpp
std::vector<int> v{1, 2, 3};
auto first = v.begin(); // 指向 1
auto last = v.end(); // 指向 3 后面的尾后位置
std::cout << *first << '\n';
// std::cout << *last; // 错误:不能解引用 end()
3.2 下标遍历
cpp
for (std::size_t i = 0; i < v.size(); ++i) {
std::cout << v[i] << ' ';
}
下标遍历适合确实需要位置 i 的场景。size() 返回无符号的 size_type,因此常用 std::size_t 或 auto 接收。应避免写成 i <= v.size() - 1:空容器时减一会发生无符号下溢。
3.3 迭代器遍历
cpp
for (auto it = v.begin(); it != v.end(); ++it) {
std::cout << *it << ' ';
}
迭代器把"如何定位元素"抽象成统一接口。算法通常不直接接收容器,而是接收 [first, last),因此可以处理 vector、数组和其他满足迭代器要求的范围。
讲义里经常把 vector 迭代器理解为指针,这个类比有助于入门,但不能进一步写成"标准保证它就是 T*"。标准要求的是随机访问并满足相应迭代器语义,具体实现可以使用裸指针,也可以在调试模式中使用带检查的包装类型。
3.4 范围 for
cpp
for (const auto& value : v) {
std::cout << value << ' ';
}
如果只读元素,优先使用 const auto&,既避免不必要复制,也不允许意外修改。若元素很小且复制便宜,按值读取也可以:
cpp
for (int value : v) {
std::cout << value << ' ';
}
需要修改元素时使用非常量引用:
cpp
for (auto& value : v) {
value *= 2;
}
3.5 反向遍历
cpp
for (auto it = v.rbegin(); it != v.rend(); ++it) {
std::cout << *it << ' ';
}
反向迭代器维护的是一种"反向视角"。它与普通迭代器之间存在基准位置关系,rbegin().base() 通常对应 end()。在混合正向、反向迭代器进行插入或删除时,要先理解 base() 的偏移语义,不能只凭地址直觉。
3.6 data 与连续存储
cpp
std::vector<int> v{10, 20, 30};
int* p = v.data();
std::cout << p[0] << ' ' << p[1] << ' ' << p[2];
data() 返回底层连续区域的指针。它常用于与 C 风格接口、系统调用或二进制协议协作。但只要发生重新分配,之前取得的 data() 指针就会失效。
此外,vector<bool> 是特殊化类型,可能使用位压缩表示,不能把它当作普通 bool 连续数组理解。需要稳定的字节级布尔缓冲区时,可考虑 std::vector<std::uint8_t> 等更明确的表示。
四、size、capacity、reserve 与 resize
4.1 size 与 capacity 为什么分离
size() 表示当前已经构造、可以正常访问的元素个数;capacity() 表示在不重新分配的前提下,当前存储空间最多能容纳多少个元素。
cpp
std::vector<int> v;
v.reserve(10);
std::cout << v.size() << '\n'; // 0
std::cout << v.capacity() << '\n'; // 至少 10
此时只有存储空间,没有 10 个 int 对象,所以下面是错误的:
cpp
// v[0] = 42; // 越界:size() 仍为 0
如果要创建 10 个元素,应使用 resize 或相应构造函数:
cpp
v.resize(10);
v[0] = 42;
4.2 一个常见的三边界心智模型
许多实现可以用三个逻辑边界来理解:
text
start finish end_of_storage
↓ ↓ ↓
[ 已构造的有效元素 ][ 尚未构造的备用空间 ]
于是:
text
size = finish - start
capacity = end_of_storage - start
这是一种解释模型,不代表标准强制实现必须使用这三个成员,也不保证成员名称和布局。标准关心的是接口行为、复杂度和失效规则,而不是私有字段长什么样。
4.3 reserve:只处理容量
cpp
std::vector<int> v{1, 2, 3};
v.reserve(100);
reserve(100) 的目标是让容量至少达到 100,但不改变 size(),也不会自动创建元素。它适合在能够预估最终元素数量时提前使用:
cpp
std::vector<int> values;
values.reserve(1000);
for (int i = 0; i < 1000; ++i) {
values.push_back(i);
}
这样通常可以减少多次重新分配与元素迁移。
但不要在每次 push_back 前都写 reserve(size() + 1)。这会破坏几何式增长带来的摊还复杂度优势,反而可能造成频繁分配。
reserve(n) 只在 n > capacity() 时需要扩展容量。若没有发生重新分配,已有迭代器通常不会仅因这次调用失效;一旦重新分配,全部相关迭代器、指针和引用都会失效。
4.4 resize:改变有效元素数量
cpp
std::vector<int> v{1, 2, 3};
v.resize(5); // 结果:1 2 3 0 0
v.resize(7, 9); // 结果:1 2 3 0 0 9 9
v.resize(2); // 结果:1 2,后面的元素被销毁
resize 改变 size():
- 扩大时构造新元素,容量不足则先重新分配;
- 缩小时销毁多余元素;
- 缩小
size()通常不会自动缩小capacity()。

4.5 empty 与 clear
cpp
if (v.empty()) {
std::cout << "no elements\n";
}
v.clear();
empty() 判断 size() == 0。clear() 销毁全部元素,使 size() 变为 0,但通常保留已经申请的容量,以便后续复用。
因此:
cpp
v.clear();
// v.capacity() 不要求变为 0
4.6 shrink_to_fit 不是强制命令
cpp
v.shrink_to_fit();
shrink_to_fit() 是缩减容量的请求,不是"必须把 capacity 精确变成 size"的强制保证。实现可以选择是否执行。若执行导致重新分配,原有迭代器、指针和引用会失效。
如果业务频繁清空后再次填充,保留容量可能更高效。是否缩容应结合峰值、复用概率和内存压力,而不是把 shrink_to_fit() 机械地放在每次 clear() 后。
4.7 扩容倍率不是标准保证
下面的程序可以观察当前实现的容量变化:
cpp
#include <iostream>
#include <vector>
int main() {
std::vector<int> v;
std::size_t old_capacity = v.capacity();
for (int i = 0; i < 100; ++i) {
v.push_back(i);
if (v.capacity() != old_capacity) {
old_capacity = v.capacity();
std::cout << old_capacity << '\n';
}
}
}
不同标准库、版本、元素类型和操作路径可能出现不同增长序列。常见实现会按某种几何方式增长,以维持尾插的摊还常数复杂度,但"固定 1.5 倍"或"固定 2 倍"都不是 C++ 标准的统一承诺。
工程代码不应依赖某个具体扩容倍率。若数量可以预估,显式 reserve;若无法预估,就让容器管理增长,并通过基准测试判断分配是否真的成为瓶颈。
五、增删查改与元素访问
5.1 push_back 与 emplace_back
cpp
std::vector<std::string> words;
std::string text = "vector";
words.push_back(text); // 拷贝 text
words.push_back(std::move(text)); // 移动 text
words.emplace_back(5, 'x'); // 原位构造 "xxxxx"
push_back 接收一个已经存在的值并将其复制或移动到容器尾部;emplace_back 接收构造参数并在尾部构造元素。
不要把 emplace_back 神化为永远更快。传入一个已经构造好的同类型对象时,它未必比 push_back 更有优势;清晰表达意图比机械替换更重要。
当容量不足时,尾插会触发重新分配并迁移旧元素。对可移动类型,实现通常会优先考虑移动;为了维持异常保证,也可能根据移动构造是否会抛异常而选择复制。
5.2 pop_back
cpp
if (!v.empty()) {
v.pop_back();
}
pop_back() 删除最后一个元素,不返回被删值。对空容器调用 pop_back() 不满足前置条件,必须先判断。
若需要保留最后一个值,可以先移动出来:
cpp
if (!words.empty()) {
std::string last = std::move(words.back());
words.pop_back();
}
5.3 operator\[\] 与 at
cpp
std::cout << v[2] << '\n';
std::cout << v.at(2) << '\n';
二者都支持常数时间随机访问,区别在于边界检查:
operator[]不进行标准要求的越界检查,越界访问产生未定义行为;at()越界会抛出std::out_of_range。
如果索引来自外部输入或复杂计算,at() 能更早暴露问题;在已经通过逻辑保证范围有效的热点路径中,operator[] 更常见。
5.4 front、back 与 data
cpp
if (!v.empty()) {
std::cout << v.front() << '\n';
std::cout << v.back() << '\n';
}
auto ptr = v.data();
front() 和 back() 分别访问首尾元素,空容器时不能调用。data() 提供连续存储区域的地址,但这个指针不拥有元素,也不能在容器重新分配后继续使用。
5.5 insert
cpp
std::vector<int> v{1, 3, 4};
auto pos = v.begin() + 1;
v.insert(pos, 2); // 1 2 3 4
insert 在指定位置之前插入元素。除了单个元素,还可插入多个相同值、一个迭代器区间或初始化列表。
cpp
v.insert(v.end(), 3, 9); // 尾部插入三个 9
v.insert(v.begin(), {7, 8}); // 首部插入 7、8
中间插入通常需要把后续元素向后移动。若容量不足,还要先重新分配,因此复杂度通常与插入点到末尾的距离相关。
5.6 erase
cpp
std::vector<int> v{10, 20, 30, 40};
auto next = v.erase(v.begin() + 1); // 删除 20
std::cout << *next; // 30
erase(pos) 删除指定位置并返回被删元素之后的新位置。若删除的是最后一个元素,返回 end()。
区间删除:
cpp
v.erase(v.begin() + 1, v.begin() + 3);
删除后,删除点及其后的迭代器、指针和引用都会失效,因为后续元素发生搬移。不能因为"容量没有改变"就认定所有旧迭代器仍然安全。
5.7 查找不是 vector 的成员函数
cpp
#include <algorithm>
auto it = std::find(v.begin(), v.end(), 30);
if (it != v.end()) {
std::cout << "found: " << *it << '\n';
}
find 属于算法库,不是 vector 成员。STL 的设计让算法依赖迭代器范围,而不是依赖具体容器。
对未排序 vector 的普通查找是线性复杂度。如果数据已经排序,可以考虑 std::lower_bound、std::binary_search 等二分算法;如果主要需求是大量精确键查找,还应评估关联容器或哈希结构。
5.8 swap
cpp
std::vector<int> a{1, 2};
std::vector<int> b{7, 8, 9};
a.swap(b);
// 或 std::swap(a, b)
交换通常可以通过交换内部资源状态完成,而不是逐元素复制。但迭代器与元素之间的关系、end() 的变化以及分配器规则仍需按标准接口说明理解,不能仅凭"三指针交换"概括所有情况。
5.9 erase-remove 惯用法
在 C++20 之前,按值或条件删除全部匹配元素常用 erase-remove:
cpp
#include <algorithm>
#include <vector>
std::vector<int> v{1, 2, 3, 2, 4, 2};
v.erase(std::remove(v.begin(), v.end(), 2), v.end());
std::remove 不会改变容器大小。它把需要保留的元素移到前面,并返回新的逻辑末尾;erase 再真正删除尾部冗余元素。
C++20 可以写:
cpp
std::erase(v, 2);
std::erase_if(v, [](int x) { return x % 2 == 0; });
六、迭代器失效:vector 最重要的边界
6.1 什么叫迭代器失效
迭代器失效并不只是"程序一定立刻崩溃"。更准确地说,是容器操作之后,原迭代器不再满足继续使用的条件。
后果可能包括:
- 调试库直接报错或终止;
- 解引用得到错误数据;
- 比较或自增产生未定义行为;
- 程序看起来暂时正常,换一次编译配置就出错;
- 在更远的位置才崩溃,使问题难以定位。
"我的环境没有崩溃"不能证明代码正确。未定义行为没有稳定结果。
6.2 重新分配为何让全部位置失效
当容量不足时,vector 通常需要:
- 申请一块更大的原始存储空间;
- 在新空间中复制或移动构造已有元素;
- 在适当位置构造新元素;
- 销毁旧空间中的元素;
- 释放旧存储空间;
- 更新内部边界状态。
旧迭代器、指针和引用保存的是旧位置。旧空间被释放后,它们自然不能继续使用。

6.3 常见操作的失效规则
下面是学习时应掌握的主线。精确细节仍应查对应标准库文档:
| 操作 | 典型失效规则 |
|---|---|
reserve |
若发生重新分配,全部失效;否则不因该调用失效 |
shrink_to_fit |
若发生重新分配,全部失效 |
push_back / emplace_back |
若重新分配,全部失效;否则原 end() 以及插入点相关位置需按规则判断,尾插时旧 end() 会变化 |
insert / emplace |
若重新分配,全部失效;否则插入点及其后的迭代器、指针和引用失效 |
erase |
删除点及其后的迭代器、指针和引用失效 |
pop_back |
被删元素和旧 end() 相关位置失效 |
resize |
扩大并重新分配时全部失效;缩小时被删部分及尾后位置相关对象失效 |
clear |
所有元素相关迭代器、指针和引用失效 |
需要注意:旧 end() 也是迭代器。即使尾插没有扩容,容器的逻辑尾后位置也已经改变,不能继续把旧 end() 当作新范围边界。
6.4 扩容后重新获取迭代器
错误示例:
cpp
std::vector<int> v{1, 2, 3};
auto it = v.begin();
v.reserve(100); // 会导致容量增长
std::cout << *it; // 错误:it 已失效
正确思路:
cpp
std::vector<int> v{1, 2, 3};
v.reserve(100);
auto it = v.begin();
std::cout << *it;
如果修改前需要记住"逻辑位置",可以在操作前保存索引,再在操作后重新计算:
cpp
std::size_t index = static_cast<std::size_t>(it - v.begin());
v.reserve(100);
it = v.begin() + static_cast<std::ptrdiff_t>(index);
前提是操作后该索引仍然落在有效范围中。
6.5 循环删除为什么必须接 erase 返回值
错误写法:
cpp
auto it = v.begin();
while (it != v.end()) {
if (*it % 2 == 0) {
v.erase(it);
}
++it; // 删除后继续使用旧 it
}
erase(it) 后旧 it 已失效,继续 ++it 是错误的,而且还可能跳过搬移到当前位置的新元素。
正确写法:
cpp
auto it = v.begin();
while (it != v.end()) {
if (*it % 2 == 0) {
it = v.erase(it);
} else {
++it;
}
}

删除分支使用返回值更新位置;未删除分支才自增。这种写法同时适用于"删除最后一个元素"的情况,因为返回值会是新的 end()。
6.6 不要依据调试模式差异写代码
一些标准库调试模式会给迭代器增加容器归属、版本或范围检查,因此错误代码可能立即触发断言;发布模式下的迭代器可能更接近轻量指针,错误访问看起来"还能运行"。
二者只是错误暴露方式不同,不代表发布模式允许使用失效迭代器。正确代码应该依赖标准语义,而不是依赖某个实现是否恰好检查。
6.7 存索引还是存指针
如果容器后续可能扩容,长期保存元素指针或引用通常很危险。可选策略包括:
- 保存索引,操作后重新通过索引访问;
- 提前
reserve足够容量,但仍要关注中间插删引起的位置变化; - 保存稳定 ID,使用映射重新定位元素;
- 如果地址稳定性是核心需求,重新评估容器或存储间接对象;
- 把对象放在独立稳定存储中,
vector只保存智能指针或句柄。
选择取决于"元素地址稳定"还是"顺序连续遍历"更重要。
七、vector 在算法题中的典型用法
7.1 只出现一次的数字
如果除一个数字外其他数字都出现两次,可以利用异或:
cpp
class Solution {
public:
int singleNumber(std::vector<int>& nums) {
int result = 0;
for (int value : nums) {
result ^= value;
}
return result;
}
};
核心性质:
text
x ^ x = 0
x ^ 0 = x
异或满足交换律和结合律
vector 在这里承担顺序存储与遍历,算法思想来自位运算。
7.2 杨辉三角
cpp
class Solution {
public:
std::vector<std::vector<int>> generate(int numRows) {
std::vector<std::vector<int>> triangle(numRows);
for (int i = 0; i < numRows; ++i) {
triangle[i].resize(i + 1, 1);
}
for (int i = 2; i < numRows; ++i) {
for (int j = 1; j < i; ++j) {
triangle[i][j] =
triangle[i - 1][j - 1] + triangle[i - 1][j];
}
}
return triangle;
}
};
每行先用 resize(i + 1, 1) 创建正确长度,并把首尾自然初始化为 1;再填写中间位置。
需要注意 numRows 的边界。外层循环在 0 行时不会执行,返回空容器;1 行和 2 行也不会进入内部递推循环。
7.3 删除有序数组中的重复项
cpp
class Solution {
public:
int removeDuplicates(std::vector<int>& nums) {
if (nums.empty()) {
return 0;
}
std::size_t write = 1;
for (std::size_t read = 1; read < nums.size(); ++read) {
if (nums[read] != nums[write - 1]) {
nums[write++] = nums[read];
}
}
return static_cast<int>(write);
}
};
这里使用双下标维护读写位置,避免循环中频繁 erase 造成 O(n²) 搬移。题目通常只要求前 k 个位置保存去重结果,不要求真的缩短容器。
7.4 电话号码的字母组合
cpp
class Solution {
public:
std::vector<std::string> letterCombinations(std::string digits) {
if (digits.empty()) {
return {};
}
static const std::vector<std::string> table{
"", "", "abc", "def", "ghi",
"jkl", "mno", "pqrs", "tuv", "wxyz"
};
std::vector<std::string> result;
std::string path;
dfs(digits, 0, table, path, result);
return result;
}
private:
static void dfs(const std::string& digits,
std::size_t index,
const std::vector<std::string>& table,
std::string& path,
std::vector<std::string>& result) {
if (index == digits.size()) {
result.push_back(path);
return;
}
const std::string& choices = table[digits[index] - '0'];
for (char ch : choices) {
path.push_back(ch);
dfs(digits, index + 1, table, path, result);
path.pop_back();
}
}
};
这段代码体现了两种常见角色:path 作为回溯路径在尾部压入和弹出,result 保存所有最终答案。
7.5 OJ 中的使用建议
- 已知答案上界时可以适度
reserve,但不要盲目预留巨大空间; - 需要位置时用下标,不需要位置时优先范围 for;
- 循环中删除要考虑失效与复杂度,很多问题更适合双指针覆盖;
- 传参只读时使用
const std::vector<T>&; - 返回局部
vector可以直接按值返回,让编译器利用返回值优化或移动语义; - 二维
vector的每行长度可以不同,不要误认为它一定是完整矩形。
八、底层模型、扩容过程与 memcpy 陷阱
8.1 vector 管理的是"原始存储 + 已构造对象"
实现容器时必须区分两个概念:
- 存储空间 :一段足够放置若干
T的、满足对齐要求的原始内存; - 对象 :已经在相应位置完成构造、其生命周期已经开始的
T。
capacity() 范围里只有前 size() 个槽位包含有效对象,后面的备用空间还没有 T 对象。不能把整段容量都当作已经构造的数组。
这正是 reserve 与 resize 的本质区别:前者扩大可用存储,后者改变有效对象数量。
8.2 扩容不是 realloc 一下那么简单
对于 vector<int>,人们容易把扩容想成"申请更大空间,再复制字节"。但 vector<T> 必须支持具有构造函数、析构函数和资源所有权的类型。
一个更准确的抽象过程是:
text
申请新原始存储
↓
在新存储逐个复制构造或移动构造元素
↓
若中途抛异常,销毁已经构造的新元素并释放新存储
↓
成功后销毁全部旧元素
↓
释放旧存储并提交新状态
为了提供异常安全,实现通常先把新状态完整准备好,再替换旧状态。若某个元素构造失败,旧 vector 应尽量保持可用,而不是只迁移了一半就丢失数据。
8.3 为什么 memcpy 不能通用迁移元素
memcpy 只复制对象表示中的字节,不会调用复制构造函数或移动构造函数。对于管理资源的对象,字节复制很可能得到两个内容相同的资源句柄。
例如一个简化 string 对象内部保存:
text
ptr → 动态字符缓冲区
size = 当前字符数
capacity = 当前容量
把这个对象的字节复制到新位置后,两个对象的 ptr 可能指向同一块缓冲区。旧对象析构并释放缓冲区后,新对象中的指针立即悬空;若两个对象都析构,还可能重复释放。

正确结论不是"自定义类型一律不能 memcpy",而是:
只有满足按字节复制语义要求的类型,例如
trivially copyable类型,才适合用memcpy复制对象表示;一般的资源管理类型必须通过构造语义复制或移动。
可以使用类型特征检查:
cpp
#include <type_traits>
static_assert(std::is_trivially_copyable_v<int>);
static_assert(!std::is_trivially_copyable_v<std::string>);
8.4 复制还是移动
扩容时,移动通常能避免深拷贝资源,但还要考虑异常安全。标准库实现常借助类似 std::move_if_noexcept 的策略:
- 如果移动构造不会抛异常,优先移动;
- 如果移动可能抛异常而复制可用,可能选择复制以保护旧状态;
- 如果类型只能移动且移动可能抛异常,容器能提供的保证会受相应规则影响。
因此,为资源管理类型提供正确的移动构造和合理的 noexcept 声明,会影响标准容器选择迁移路径的机会。noexcept 必须真实,不能只为了"让 vector 更快"而错误承诺。
8.5 扩容倍率的权衡
容量增长太慢会导致频繁重新分配,增长太快又会保留更多空闲内存。实现需要在下面几项之间折中:
- 分配次数;
- 元素复制或移动次数;
- 峰值内存占用;
- 分配器行为;
- 平台与 ABI 策略;
- 插入数量与当前规模。
这也是扩容倍率应被视为实现策略,而不是接口合同的原因。
九、教学版 MiniVector 的核心模拟实现
9.1 实现目标与边界
下面的实现只用于理解核心框架,包含:
- 三个存储边界;
size()、capacity()与随机访问;reserve();emplace_back()、两个push_back();pop_back();- 正确的逐对象构造、销毁和异常清理。
为了把重点放在扩容和生命周期上,示例暂时禁用拷贝,未实现完整迭代器类型、插入、删除、分配器传播、max_size 等生产级细节。
9.2 完整教学代码
cpp
#include <algorithm>
#include <cstddef>
#include <memory>
#include <stdexcept>
#include <string>
#include <utility>
template <class T>
class MiniVector {
public:
using value_type = T;
using size_type = std::size_t;
using iterator = T*;
using const_iterator = const T*;
MiniVector() = default;
MiniVector(const MiniVector&) = delete;
MiniVector& operator=(const MiniVector&) = delete;
MiniVector(MiniVector&& other) noexcept
: start_(other.start_),
finish_(other.finish_),
end_of_storage_(other.end_of_storage_) {
other.start_ = nullptr;
other.finish_ = nullptr;
other.end_of_storage_ = nullptr;
}
MiniVector& operator=(MiniVector&& other) noexcept {
if (this != &other) {
release();
start_ = other.start_;
finish_ = other.finish_;
end_of_storage_ = other.end_of_storage_;
other.start_ = nullptr;
other.finish_ = nullptr;
other.end_of_storage_ = nullptr;
}
return *this;
}
~MiniVector() {
release();
}
size_type size() const noexcept {
return start_ == nullptr
? 0
: static_cast<size_type>(finish_ - start_);
}
size_type capacity() const noexcept {
return start_ == nullptr
? 0
: static_cast<size_type>(end_of_storage_ - start_);
}
bool empty() const noexcept {
return start_ == finish_;
}
iterator begin() noexcept { return start_; }
iterator end() noexcept { return finish_; }
const_iterator begin() const noexcept { return start_; }
const_iterator end() const noexcept { return finish_; }
T& operator[](size_type index) noexcept {
return start_[index];
}
const T& operator[](size_type index) const noexcept {
return start_[index];
}
T& at(size_type index) {
if (index >= size()) {
throw std::out_of_range("MiniVector::at");
}
return start_[index];
}
const T& at(size_type index) const {
if (index >= size()) {
throw std::out_of_range("MiniVector::at");
}
return start_[index];
}
void reserve(size_type new_capacity) {
if (new_capacity <= capacity()) {
return;
}
reallocate(new_capacity);
}
template <class... Args>
T& emplace_back(Args&&... args) {
if (finish_ == end_of_storage_) {
reserve(next_capacity());
}
Traits::construct(
allocator_, finish_, std::forward<Args>(args)...);
++finish_;
return back();
}
void push_back(const T& value) {
emplace_back(value);
}
void push_back(T&& value) {
emplace_back(std::move(value));
}
void pop_back() {
if (empty()) {
throw std::out_of_range("MiniVector::pop_back");
}
--finish_;
Traits::destroy(allocator_, finish_);
}
T& back() noexcept {
return *(finish_ - 1);
}
const T& back() const noexcept {
return *(finish_ - 1);
}
private:
using Allocator = std::allocator<T>;
using Traits = std::allocator_traits<Allocator>;
size_type next_capacity() const {
const size_type current = capacity();
return current == 0 ? 1 : current * 2;
}
void reallocate(size_type new_capacity) {
T* new_start = Traits::allocate(allocator_, new_capacity);
T* new_finish = new_start;
try {
for (T* current = start_;
current != finish_;
++current, ++new_finish) {
Traits::construct(
allocator_,
new_finish,
std::move_if_noexcept(*current));
}
} catch (...) {
destroy_range(new_start, new_finish);
Traits::deallocate(allocator_, new_start, new_capacity);
throw;
}
const size_type old_capacity = capacity();
destroy_range(start_, finish_);
if (start_ != nullptr) {
Traits::deallocate(allocator_, start_, old_capacity);
}
start_ = new_start;
finish_ = new_finish;
end_of_storage_ = new_start + new_capacity;
}
void destroy_range(T* first, T* last) noexcept {
while (last != first) {
--last;
Traits::destroy(allocator_, last);
}
}
void release() noexcept {
if (start_ == nullptr) {
return;
}
const size_type old_capacity = capacity();
destroy_range(start_, finish_);
Traits::deallocate(allocator_, start_, old_capacity);
start_ = nullptr;
finish_ = nullptr;
end_of_storage_ = nullptr;
}
private:
Allocator allocator_;
T* start_ = nullptr;
T* finish_ = nullptr;
T* end_of_storage_ = nullptr;
};
简单测试:
cpp
#include <iostream>
int main() {
MiniVector<std::string> words;
words.reserve(2);
words.push_back("1111");
words.push_back("2222");
words.emplace_back(4, '3');
for (const auto& word : words) {
std::cout << word << '\n';
}
std::cout << "size=" << words.size() << '\n';
std::cout << "capacity=" << words.capacity() << '\n';
}
9.3 size 和 capacity 为什么要特殊处理空状态
从语言层面看,对两个空指针直接做指针减法并不是一个值得依赖的通用表达。因此教学实现先判断 start_ == nullptr,空状态直接返回 0:
cpp
return start_ == nullptr
? 0
: static_cast<size_type>(finish_ - start_);
在真实标准库中,内部表示与空状态策略可能不同。模拟实现时不能只画出三指针,就忽略空容器的语言规则。
9.4 reserve 的异常安全
reallocate 的关键不在"申请新空间",而在失败清理:
cpp
try {
// 在新空间逐个构造
} catch (...) {
// 只销毁已经成功构造的那一段
// 释放新空间
// 重新抛出异常
}
只有新空间中的全部元素都构造成功,才销毁旧元素并提交三个新边界。这个顺序避免了"一半元素已经迁移,旧数据又被提前释放"的破坏状态。
9.5 为什么销毁顺序常写成反向
cpp
while (last != first) {
--last;
Traits::destroy(allocator_, last);
}
容器通常按与构造相反的顺序销毁元素,这与数组对象的析构顺序一致,也更符合"后构造先销毁"的资源管理习惯。
9.6 教学版还缺少什么
真正的 std::vector 远比上面的代码复杂,至少还要处理:
- 完整的复制构造和复制赋值;
- 分配器传播规则与有状态分配器;
insert、erase、resize、区间构造;- 自插入、别名参数与范围重叠;
- 精确的异常保证;
- 最大容量、长度错误与算术溢出;
- 迭代器类型、调试检查与常量重载;
- 过度对齐类型;
vector<bool>特殊化;- 各种标准版本新增接口及约束。
模拟实现的目的,是理解容器设计的关键矛盾,而不是用几十行教学代码替换成熟标准库。
十、二维 vector 的真实内存结构
10.1 vector<vector> 是"外层容器 + 多个内层容器"
cpp
std::vector<std::vector<int>> matrix(5);
外层 vector 连续保存 5 个 std::vector<int> 对象。刚构造完成时,每个内层容器都是空的;内层元素存储通常还没有分配。
随后:
cpp
for (std::size_t i = 0; i < matrix.size(); ++i) {
matrix[i].resize(i + 1, 1);
}
每个内层 vector 独立申请自己的存储空间,于是行长度分别为 1、2、3、4、5。

10.2 为什么它不一定是矩形
cpp
std::vector<std::vector<int>> jagged{
{1, 2},
{3, 4, 5, 6},
{7}
};
每行都是独立容器,因此可以拥有不同的 size() 和 capacity()。这种结构也叫锯齿数组。
访问前应分别检查行列范围:
cpp
if (row < jagged.size() && col < jagged[row].size()) {
std::cout << jagged[row][col];
}
不能只检查外层长度就默认每行都有同样多的列。
10.3 二维 vector 不是一整块连续元素
外层的行对象通常连续,但各行元素分别由自己的动态存储管理。于是:
&matrix[0][0]不能作为整个二维结构的连续首地址;- 一行内的元素连续,不同两行之间不保证相邻;
- 某一行扩容通常不会直接改变其他行的元素存储;
- 外层容器扩容会移动内层
vector对象,但内层资源是否随移动保持地址,要按相应移动与分配器语义理解。
如果图像、矩阵运算或硬件接口要求完整二维数据连续,可以改用一维 vector:
cpp
class Matrix {
public:
Matrix(std::size_t rows, std::size_t cols)
: rows_(rows), cols_(cols), data_(rows * cols) {}
int& operator()(std::size_t row, std::size_t col) {
return data_[row * cols_ + col];
}
private:
std::size_t rows_;
std::size_t cols_;
std::vector<int> data_;
};
这种表示保证所有元素位于一块连续区域,也减少了多次小分配,但不适合天然变长行的数据。
10.4 构造杨辉三角时发生了什么
cpp
std::vector<std::vector<int>> triangle(n);
先创建 n 个空的行对象。然后:
cpp
triangle[i].resize(i + 1, 1);
第 i 行单独创建 i + 1 个元素。填充递推值时,triangle[i - 1] 与 triangle[i] 分别访问两块行存储。逻辑上是三角形,物理上是"外层行描述 + 多块内层连续区域"。
十一、常见误区、高频面试题与练习
11.1 常见误区
误区一:reserve(100) 之后可以直接访问 v99
错误。reserve 只保证容量,不创建元素。合法下标仍然必须小于 size()。
误区二:resize 只改容量
错误。resize 改变有效元素个数,扩大时构造元素,缩小时销毁元素。
误区三:vector 每次固定按 2 倍扩容
错误。增长策略属于实现细节,不能写进业务逻辑假设。
误区四:vector 的迭代器在标准中就是 T*
错误。它满足随机访问与连续迭代器相关要求,但具体类型由实现决定,调试模式尤其可能使用包装类型。
误区五:erase 不改 capacity,所以旧迭代器都有效
错误。删除点及其后的元素会搬移,相应迭代器、指针和引用失效。
误区六:失效迭代器没有崩溃就还能用
错误。未定义行为可能表现为错误输出、偶发崩溃或暂时正常。
误区七:emplace_back 永远比 push_back 快
错误。优势取决于参数与构造路径,传入现成对象时未必更快。
误区八:clear 会把 capacity 变成 0
没有这个保证。clear 销毁元素,通常保留存储以便复用。
误区九:二维 vector 的所有 int 连续存储
错误。通常只有每一行内部连续,各行分别管理存储。
误区十:对象扩容可以统一用 memcpy
错误。资源管理类型必须通过正确构造语义复制或移动;按字节复制只适合满足相应类型要求的对象。
11.2 高频面试题
问题 1:vector 的底层数据结构是什么?
它是动态连续顺序表。常见实现用若干边界状态表示已构造元素范围和存储容量,但具体成员布局属于实现细节。
问题 2:size 与 capacity 有什么区别?
size 是有效元素个数,capacity 是无需重新分配即可容纳的元素数。size <= capacity。
问题 3:reserve 与 resize 有什么区别?
reserve 调整容量下限,不改变元素个数;resize 改变元素个数,必要时也会扩容并构造新元素。
问题 4:push_back 的复杂度为什么是摊还 O(1)?
大多数尾插只在已预留槽位构造一个元素;少数容量不足的插入需要迁移全部元素。几何式增长使多次迁移成本能摊到一系列插入上。
问题 5:什么情况下 vector 迭代器会失效?
重新分配会使全部迭代器、指针和引用失效;未重新分配时,插入、删除等操作仍会按位置使部分对象失效。
问题 6:erase 为什么返回迭代器?
删除后原位置迭代器失效,返回值提供下一有效位置,便于安全循环删除。
问题 7:vector 与 list 如何选择?
vector 连续、随机访问快、缓存局部性好;list 节点分散、不支持随机访问,但在持有有效位置时插删节点通常无需搬移其他元素。选择应结合实际访问模式和测量。
问题 8:vector 与 deque 有什么区别?
vector 提供单块连续存储,尾部操作最自然;deque 通常由分段存储组成,支持高效双端插入,但不保证所有元素位于一整块连续内存中。
问题 9:为什么 vector 扩容不能 memcpy?
string 可能管理动态资源,按字节复制只复制内部指针等对象表示,不建立独立资源所有权,会导致悬空指针、重复释放等问题。
问题 10:clear 后如何释放多余容量?
可以请求 shrink_to_fit(),但它不是强制保证;也可在适合的分配器条件下通过与空容器交换表达释放意图。是否值得释放要看后续是否复用及内存压力。
问题 11:vector 的元素地址稳定吗?
不稳定。重新分配会整体迁移;即使不重新分配,中间插删也会改变相应位置之后的元素地址。
问题 12:为什么 vector 常比 list 遍历快?
连续存储带来更好的缓存局部性和更少的指针追踪;算法常数、预取和内存带宽会显著影响真实性能,不能只比较大 O。
11.3 排查清单
vector 代码出现越界、崩溃或性能问题时,可以按下面顺序检查:
text
访问下标是否严格小于 size()?
↓
是否对空容器调用 front/back/pop_back?
↓
是否把 reserve 当成创建元素?
↓
容器修改后是否继续使用旧迭代器、指针或引用?
↓
erase 循环是否接收了返回值?
↓
是否在循环中频繁中间 erase 导致 O(n²)?
↓
是否反复 reserve(size()+1) 破坏摊还增长?
↓
二维 vector 是否错误假设每行等长或整体连续?
↓
自定义模拟实现是否区分原始存储与对象生命周期?
11.4 建议练习
- 分别使用默认增长和提前
reserve插入十万个整数,统计容量变化次数; - 用
operator[]与at()编写同一个边界访问实验; - 使用迭代器安全删除全部偶数;
- 使用 erase-remove 删除全部负数;
- 用双指针完成有序数组原地去重;
- 实现杨辉三角并画出外层、内层
vector的关系; - 给
MiniVector增加clear()与resize(); - 给
MiniVector实现拷贝构造,并保证构造中途抛异常时不泄漏; - 设计一个记录构造、复制、移动、析构次数的类型,观察扩容过程;
- 比较
vector<int>与list<int>的顺序遍历时间,不只讨论复杂度; - 将二维矩形
vector<vector<int>>改成一维连续矩阵; - 阅读一种现代标准库实现的
vector源码,标明哪些是标准语义,哪些是实现策略。
11.5 参考资料
- cppreference:std::vector
- cppreference:vector::reserve
- cppreference:vector::resize
- cppreference:vector::insert
- cppreference:vector::erase
- cppreference:std::erase / std::erase_if
- cppreference:std::is_trivially_copyable
- C++ Core Guidelines:SL.con.2 - Prefer using STL vector by default
总结
vector 的完整主线可以压缩为:
text
连续存储提供随机访问与缓存局部性
↓
size 表示已构造元素,capacity 表示可用槽位
↓
reserve 只预留空间,resize 改变元素数量
↓
尾部追加通常摊还 O(1),中间插删需要搬移
↓
容量不足时申请新空间并逐对象迁移
↓
重新分配让全部旧位置失效
↓
erase 返回下一位置,循环删除必须接收
↓
模拟实现必须管理存储、对象生命周期与异常清理
请牢记:
vector是动态连续顺序表,通常是动态序列的第一候选;size()是有效元素数,capacity()是无需重新分配可容纳的元素数;reserve不创建元素,resize才改变元素数量;- 扩容倍率属于实现细节,不能固化为统一的 1.5 倍或 2 倍;
- 重新分配会使全部迭代器、指针和引用失效;
- 没有重新分配也不等于完全不失效,插入点或删除点之后的位置仍可能失效;
- 循环
erase时要接收返回值,不能继续自增旧迭代器; find是算法,不是vector成员函数;- 资源管理对象不能依赖
memcpy迁移,必须遵循构造与析构语义; - 二维
vector是外层行对象加多块内层存储,不是一整块连续矩形数组; - 模拟
vector的难点不在三指针,而在对象生命周期、异常安全和边界规则; - 真正理解容器,要同时掌握接口、复杂度、失效规则和底层资源模型。
如果本文对你有帮助,欢迎点赞、收藏。掌握
vector的标志,不是能背出多少个成员函数,而是在看到一次插入、删除或扩容时,能够准确回答:当前有多少有效元素、还剩多少容量、是否会迁移对象、哪些位置会失效,以及为什么这段代码仍然满足对象生命周期规则。