【C++ vector 深度解析】从常用接口、扩容机制与迭代器失效到核心模拟实现

🔥 本文定位 :本文从 std::vector 的连续存储模型出发,系统讲解构造、遍历、容量管理、增删查改、迭代器失效、二维 vector、常见 OJ 用法,并通过一个教学版 MiniVector 理解扩容、元素迁移与对象生命周期。

💡 学习目标 :不仅要会调用 push_backreserveresizeerase,还要能够解释 sizecapacity 为什么分离、扩容为什么可能让全部迭代器失效、循环删除为何必须接收 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 通常是第一候选。原因不只是随机访问复杂度低,还包括:

  1. 连续存储对 CPU 缓存友好;
  2. 每个元素之外通常没有链表节点指针开销;
  3. 标准算法与范围接口支持完善;
  4. 工程经验丰富,编译器和标准库实现高度优化;
  5. 即使存在中间删除,实际数据规模较小时也可能比节点容器更快。

但"通常先考虑"不等于"永远选择"。如果业务依赖稳定地址、已知位置的频繁中间插删、双端操作或其他特殊语义,还应比较 dequelist、关联容器或专用数据结构。

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_tauto 接收。应避免写成 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() == 0clear() 销毁全部元素,使 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_boundstd::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 通常需要:

  1. 申请一块更大的原始存储空间;
  2. 在新空间中复制或移动构造已有元素;
  3. 在适当位置构造新元素;
  4. 销毁旧空间中的元素;
  5. 释放旧存储空间;
  6. 更新内部边界状态。

旧迭代器、指针和引用保存的是旧位置。旧空间被释放后,它们自然不能继续使用。

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 中的使用建议

  1. 已知答案上界时可以适度 reserve,但不要盲目预留巨大空间;
  2. 需要位置时用下标,不需要位置时优先范围 for;
  3. 循环中删除要考虑失效与复杂度,很多问题更适合双指针覆盖;
  4. 传参只读时使用 const std::vector<T>&
  5. 返回局部 vector 可以直接按值返回,让编译器利用返回值优化或移动语义;
  6. 二维 vector 的每行长度可以不同,不要误认为它一定是完整矩形。

八、底层模型、扩容过程与 memcpy 陷阱

8.1 vector 管理的是"原始存储 + 已构造对象"

实现容器时必须区分两个概念:

  • 存储空间 :一段足够放置若干 T 的、满足对齐要求的原始内存;
  • 对象 :已经在相应位置完成构造、其生命周期已经开始的 T

capacity() 范围里只有前 size() 个槽位包含有效对象,后面的备用空间还没有 T 对象。不能把整段容量都当作已经构造的数组。

这正是 reserveresize 的本质区别:前者扩大可用存储,后者改变有效对象数量。

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 远比上面的代码复杂,至少还要处理:

  • 完整的复制构造和复制赋值;
  • 分配器传播规则与有状态分配器;
  • inserteraseresize、区间构造;
  • 自插入、别名参数与范围重叠;
  • 精确的异常保证;
  • 最大容量、长度错误与算术溢出;
  • 迭代器类型、调试检查与常量重载;
  • 过度对齐类型;
  • 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 建议练习

  1. 分别使用默认增长和提前 reserve 插入十万个整数,统计容量变化次数;
  2. operator[]at() 编写同一个边界访问实验;
  3. 使用迭代器安全删除全部偶数;
  4. 使用 erase-remove 删除全部负数;
  5. 用双指针完成有序数组原地去重;
  6. 实现杨辉三角并画出外层、内层 vector 的关系;
  7. MiniVector 增加 clear()resize()
  8. MiniVector 实现拷贝构造,并保证构造中途抛异常时不泄漏;
  9. 设计一个记录构造、复制、移动、析构次数的类型,观察扩容过程;
  10. 比较 vector<int>list<int> 的顺序遍历时间,不只讨论复杂度;
  11. 将二维矩形 vector<vector<int>> 改成一维连续矩阵;
  12. 阅读一种现代标准库实现的 vector 源码,标明哪些是标准语义,哪些是实现策略。

11.5 参考资料


总结

vector 的完整主线可以压缩为:

text 复制代码
连续存储提供随机访问与缓存局部性
        ↓
size 表示已构造元素,capacity 表示可用槽位
        ↓
reserve 只预留空间,resize 改变元素数量
        ↓
尾部追加通常摊还 O(1),中间插删需要搬移
        ↓
容量不足时申请新空间并逐对象迁移
        ↓
重新分配让全部旧位置失效
        ↓
erase 返回下一位置,循环删除必须接收
        ↓
模拟实现必须管理存储、对象生命周期与异常清理

请牢记:

  1. vector 是动态连续顺序表,通常是动态序列的第一候选
  2. size() 是有效元素数,capacity() 是无需重新分配可容纳的元素数
  3. reserve 不创建元素,resize 才改变元素数量
  4. 扩容倍率属于实现细节,不能固化为统一的 1.5 倍或 2 倍
  5. 重新分配会使全部迭代器、指针和引用失效
  6. 没有重新分配也不等于完全不失效,插入点或删除点之后的位置仍可能失效
  7. 循环 erase 时要接收返回值,不能继续自增旧迭代器
  8. find 是算法,不是 vector 成员函数
  9. 资源管理对象不能依赖 memcpy 迁移,必须遵循构造与析构语义
  10. 二维 vector 是外层行对象加多块内层存储,不是一整块连续矩形数组
  11. 模拟 vector 的难点不在三指针,而在对象生命周期、异常安全和边界规则
  12. 真正理解容器,要同时掌握接口、复杂度、失效规则和底层资源模型。

如果本文对你有帮助,欢迎点赞、收藏。掌握 vector 的标志,不是能背出多少个成员函数,而是在看到一次插入、删除或扩容时,能够准确回答:当前有多少有效元素、还剩多少容量、是否会迁移对象、哪些位置会失效,以及为什么这段代码仍然满足对象生命周期规则。

相关推荐
今天AI了吗1 小时前
AI 数据安全治理框架:模型能力与数据权限的边界在哪里
java·linux·开发语言·人工智能·python·深度学习·机器学习
LCG元1 小时前
【实战】树莓派物联网开发:DHT11温湿度采集与MQTT云上报全流程
java·jvm·物联网
斯内普吖1 小时前
(开源)农产品电商系统实战指南 基于 Java + SpringBoot + Vue + MySQL
java·vue.js·spring boot·mysql·开源
SimonKing1 小时前
AI逆向实战:一个壁纸网站被我5分钟摸透了,你也能
java·后端·程序员
牛艺翔1 小时前
C++ 基础知识
开发语言·c++
天云数据1 小时前
从“LLM+工具”到Harness 工程:Lilian Weng新文的技术拆解,与一个生产级参考实现
java·前端·网络
让学习成为一种生活方式1 小时前
ASTRAL v5.7.1--生信工具110
java
2501_937860941 小时前
从JDBC到数据访问:Java数据库编程完全指南
java·开发语言·数据库
Interview Aid1122 小时前
Akuna OA 题目拆解|三题思路复盘
linux·运维·算法·面试·职场和发展