我猜你肯定遇到过这样的场景:你写了一个程序,要往数组里装数据,可你根本不知道会装多少个。昨天可能只要装 5 个,今天来了 500 个,明天也许是 5000 个。用定长数组?不行,长度写死了,多了装不下,少了浪费空间。用指针手动 new 一块内存再不断 realloc?又慢又容易出错,还得自己记着释放。这正是 C++ 标准库里的 std::vector(软硬伤全给你治好的"会伸缩的数组")诞生的原因。

在这篇文章里,我会带你从"为什么需要它"讲起,一路深入它内部是怎么工作的:为什么它能在数组尾部插元素插得飞快、为什么按下标访问是 O(1)、为什么它时不时会偷偷把整块内存搬家、以及最让人头疼的"迭代器失效"到底是发生在什么时候。你放心,vector 虽然名字听起来有点唬人,但它就是你熟悉的"动态数组",学完这篇你就能做到"能用、明理,甚至能自己手写一个"——这正是 C++ 老手常说的学习 STL 的三个境界(能用 → 明理 → 能扩展)。

先提前打个预防针:vector 依赖的几个前置概念——动态数组、指针、模板、深浅拷贝、内存连续——我会在讲到对应位置时当场把它讲透,你只需要带着"它到底在内存里怎么存的"这个疑问往下读就好了。文章里所有代码都是完整可独立编译的 cpp 程序,你可以在自己的编译器上直接跑起来验证,别光看,动手跑一次比读十遍都管用。

先请看懂三个最小的名词(后面都会展开,这里先混个脸熟):

  • size(大小):vector 当前真正装了多少个有效元素。
  • capacity(容量):vector 向系统请求到的、当前能装的总容量。
  • 扩容(reallocation):当有效元素数撞满容量、塞不下时,vector 另外申请一块更大的内存,把旧元素搬过去,再释放旧内存的动作。

为什么需要 vector

想真正理解 vector,你得先体会一下原生数组(built-in array,也就是我们常说的"普通数组")用起来有多"憋屈"。下面这个程序用最直白的方式把原生数组的几个天生缺陷全摆出来:

#include <iostream>
using namespace std;
 
int main()
{
    // 原生数组:长度必须在编译期就定死
    int arr[10] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
 
    // 问题一:想再插一个数,放不下,只能放弃或者是手写扩容
    // int arr[11];   // 长度不能变
 
    // 问题二:我们根本不知道用户会输入多少个数据
    // int n;
    // cin >> n;
    // int score[n];  // 这种写法在标准 C++ 里是错的!(变量长度数组是 C99 才有的东西)
 
    // 问题三:用完了要自己记得 new[] + delete[]
    // int* p = new int[n];   // 万一忘了 delete[],内存泄漏
    // delete[] p;            // 手动管理,一不留神就漏
 
    // 问题四:"实际装了多少个"没人帮你记
    int cnt = 5;                 // 下标用它来登记
    cout << "arr[0]=" << arr[0]
         << ", 手动记的个数 cnt=" << cnt << endl;
    return 0;
}

你看,原生数组有几个绕不开的痛点:

  1. 长度写死。int arr[10] 一旦写出来,10 就是编译期定死的常量,之后永远不能变。想装 11 个?对不起,装不下,或者你得自己在外面重新开一块更大的再手动搬运。
  2. 长度跟着下标走,还容易算错。数组自己不记得自己有多少个有效元素,你只能额外用一个变量手动记。sizeof(arr) / sizeof(arr[0]) 这种"计算长度的 trick"只对数组名有效,一旦把数组"退化"成指针传给函数,算出来的就是一个指针大小,等于 8(在 64 位机器上),彻底失灵。
  3. 扩容全是体力活。"空间不够了"这件事,数组完全帮不上忙,你要自己 new 一块新的、手动逐个拷过去、再手动 delete 旧的,任何一步出错都可能内存泄漏或野指针。
  4. 忘记释放就泄漏。手写 new[] 之后如果忘了 delete[],那块内存永远不归还给系统,程序跑得越久漏得越多。

你能理解吧?普通业务里天天让你处理"未知数量的数据",用原始数组根本管不过来。于是标准库的工程师们想出了一个改进方案:把"一段连续的内存 + 数组大小 + 容量"这三样东西打包成一个类,让它在背后自动管理扩容,还提供一堆方便的接口。这个类就是 std::vector。你可以把 vector 理解为"会自己长大的数组"——它管你要多少个,它就买多少地,地不够了就自动翻新置换,你完全不用操心。而"自动长大"这种本事,专业的说法叫动态数组(dynamic array),它由"存数据的起始指针、当前有效元素个数 size、总容量 capacity"三种信息来组织。

顺带提一下,vector 是标准模板库(Standard Template Library,STL)的一部分。STL 是 C++ 标准库自带的"算法 + 容器的宝库",容纳了 vector、string、list、map 等一堆容器和 sort、find 等一堆算法。而 vector 也是一种模板类——模板,你就把它理解成"一个可以套到任何类型上的模具",用尖括号里的类型参数来指定它装什么。它可以装 int、装 double、装自己定义的 Student,甚至装一堆 vector 变成二维数组,全都靠那一对尖括号。

你可能会好奇:那为什么不继续手动 new/delete?其实关键差别就在于 vector 帮我们把"扩容 + 释放 + 记账"这三件最烧脑的事封装成了黑盒,并且把坑都堵死了。你只负责往里塞数据,内存的生命周期由 vector 的构造函数和析构函数(离开作用域时自动调用、自动释放)兜底。后面讲底层实现时你会亲眼看到这套逻辑是怎么落地的。

用生活类比来建立第一印象

把 vector 想象成一座停车场,就很好懂了:

  • size = 现在停着的车数量;
  • capacity = 现在画好的车位总数;
  • 车停满了 = 你还要进来一辆车,但每个车位都占上了,于是 vector 去旁边租一块更大的停车场,把所有车一辆辆挪过去,再给你腾出新空位来停新车;
  • 为什么车位总比车多 = 因为每次扩建都要成本(挪车很贵),所以 vector 宁可一次多画几个位子,省得每停一辆就挪一次。

把车换成元素,把停车场换成一段连续堆内存,微妙地对应上了。下面每个小节我们都会反复回到这个"车位 vs 车辆"的直觉上。

vector 与原生数组的对比

光说概念不过瘾,我们直接把两者摆到一起,用代码登场演示最直观。下面这段代码同时声明了一个原生数组和一个 vector<int>,然后完成同样的"装 5 个数再打印"操作:

#include <iostream>
#include <vector>
using namespace std;
 
int main()
{
    // 原生数组写法:长度固定,下标从 0 开始
    int arr[5];
    for (int i = 0; i < 5; ++i)
        arr[i] = i * 10;        // 往数组里填数据
 
    // vector 写法:不需要提前告诉它长度
    vector<int> v;
    for (int i = 0; i < 5; ++i)
        v.push_back(i * 10);    // push_back = 往尾部"追加"一个元素
 
    // 打印原生数组
    for (int i = 0; i < 5; ++i)
        cout << arr[i] << " ";
    cout << endl;
 
    // 打印 vector:size() 返回有效元素个数,下标访问和数组一模一样
    for (size_t i = 0; i < v.size(); ++i)
        cout << v[i] << " ";    // 输出:0 10 20 30 40
    cout << endl;
 
    return 0;
}

看到区别了吗?虽然两者都能用 v[i] 这种下标方式访问元素,但 vector 有四个明显的好处:

  • 无需手动指定长度:你用 push_back 往末尾塞就好了,vector 自动管理,想塞多少塞多少;
  • 自动记录大小:v.size() 直接告诉你现在有几个元素,不用像数组那样自己记一个 n;
  • 类型安全:vector 是模板,往 vector<int> 里塞 double 会在编译期就报错,数组不会(数组里塞别的类型顶多是编译器给你个警告,老实你还能硬塞);
  • 自动释放内存:vector 是局部对象,出了作用域自动析构释放,你不用 new/delete 操心。

你可能会问:那 vector 和原生数组的性能差很多吗?其实不用担心。vector 的内部就是一块连续的堆内存,元素按顺序紧紧挨着存放,对元素的访问、下标操作的效率跟原生数组基本持平。更具体地说,两者的元素在内存里的布局是几乎完全一样的——都是一块连续的空间,元素挨个排开。区别只在于:

维度原生数组vector
存放位置数组名处通常分配在栈上(局部数组)数据块分配在堆上,靠一个栈上的指针头指向它
长度编译期定死,不可变运行期可自由伸缩
大小信息得自己记,或 sizeof 技巧(局限多)size() 直接给
越界检查无(C 哲学:信任程序员)operator[] 无,at() 有
内存释放栈上自动,堆上手动自动
传给 C 库直接用数组名v.data() 拿到连续首地址

换句话说,vector 在做"内存管理"这件事上是替你了操了心,但用起来的手感还是那个你熟悉的数组。这段内容要到后面讲"底层实现思想"时才能真正理解,先留个印象。

一个重要的纠正:原生数组 vs C 风格 malloc 数组

很多教程把"原生数组"和"手写堆数组"混为一谈,这里有必要厘清一下。严格说,C++ 里的"原生数组"专指 int arr[5] 这种 编译期定长的栈对象(或全局/静态对象);而 int* p = new int[n] 那种是动态分配的堆数组,需要用 new[]/delete[] 配套管理,它本质上只是"一个指向连续内存的指针",没有 任何长度信息,你连它多大都查不到。vector 之所以比这两种都好,是因为它把"连续内存 + 长度 + 容量"三件套绑成一个自洽对象,两头(栈/堆)的麻烦都被它吸收掉了。

vector 的定义与构造

除了声明一个空的 vector,它还有好几种构造方式,就像同一个"模具"有不同玩法。我们先看一个涵盖几种常用写法的完整示例:

#include <iostream>
#include <vector>
using namespace std;
 
void print(const vector<int>& v)
{
    for (int e : v)          // 范围 for:C++11 引入的遍历新写法,等价于用迭代器遍历
        cout << e << " ";
    cout << endl;
}
 
int main()
{
    // 方式一:默认(无参)构造,得到一个空 vector
    vector<int> v1;
 
    // 方式二:构造 n 个元素,每个都是 val(这里 3 个 7)
    vector<int> v2(3, 7);    // v2 = {7, 7, 7}
 
    // 方式三:拷贝构造,用另一个 vector 复制出新的(深拷贝,各管各的)
    vector<int> v3(v2);      // v3 = {7, 7, 7}
 
    // 方式四:C++11 列表初始化,像数组字面量一样直接给元素
    vector<int> v4{ 1, 2, 3, 4 };   // v4 = {1, 2, 3, 4}
 
    // 方式五:用两个迭代器区间初始化(从数组指针头尾拷贝)
    int a[] = { 10, 20, 30, 40, 50 };
    vector<int> v5(a, a + 5);       // 用数组 a 的[地址,a+5地址)这一段构造
 
    // 方式六:C++11 移动构造,把另一个 vector 的资源整个"抢"过来(v6 里是 100 200)
    vector<int> temp{ 100, 200 };
    vector<int> v6(move(temp));     // temp 之后变成空壳,资源归 v6
 
    print(v1);   // 空行
    print(v2);   // 7 7 7
    print(v3);   // 7 7 7
    print(v4);   // 1 2 3 4
    print(v5);   // 10 20 30 40 50
    print(v6);   // 100 200
    print(temp); // 空行(被移动后,temp 不再持有数据)
 
    return 0;
}

(temp 在文件顶部还处于作用域内,能被 print,输出为空,说明移动构造把它的数据"掏空"了,稍后讲移动语义时会细说。)

这里重点讲几个概念。第一,拷贝构造——vector<int> v3(v2) 不是让 v3 和 v2 共享同一块内存,而是把 v2 的元素一个一个完整复制一份,给 v3。结果就是 v3 和 v2 各有一份数据,改 v3 不影响 v2。这种"复制数据本身"的操作叫深拷贝,后面讲深浅拷贝时还会重点展开。

第二,方式五里的"迭代器区间"引出了一个新的老朋友——迭代器(iterator)。迭代器你不用想得太玄乎,对 vector 来说,迭代器本质上就是原生指针 T*,也就是指向某个元素地址的指针。vector<int>::iterator 就是 int*,begin() 返回指向第一个元素的指针,end() 返回指向"最后一个元素后面的那个位置"的指针。所以 v5(a, a + 5) 的意思就是"从 a[0] 的地址取到 a[5] 的地址,这个区间里的元素全部拷进来"(注意是左闭右开,a+5 那个位置本身不包含)。

第三,方式六的移动构造是 C++11 才有的高级货。它不复制数据,而是把源对象手里的那块堆内存直接"过户"给自己,然后把源对象变成一个空壳(通常 size 置 0)。这在性能上比拷贝构造快得多——拷 100 万个元素要搬 100 万次,而"移动"只是换一个指针,O(1)。后面讲到扩容时会看到,vector 内部搬元素也尽量用移动而不是拷贝。

完整的构造清单(含 C++11/17 新增)

C++11 之后 vector 提供了非常丰富的构造重载,把它们都列出来,标注上版本与用途:

构造写法含义引入版本
vector<T> v;默认构造,空容器,capacity 通常为 0一直有
vector<T> v(n);构造 n 个默认构造的元素一直有
vector<T> v(n, val);构造 n 个值均为 val 的元素一直有
vector<T> v(other);拷贝构造(深拷贝)一直有
vector<T> v(begin_it, end_it);用迭代器区间 [begin,end) 初始化一直有
vector<T> v(std::move(other));移动构造(过户资源,O(1))C++11
vector<T> v{1, 2, 3};列表初始化(initializer_list)C++11
vector<T> v({1, 2, 3});也是列表初始化(等价的显式写法)C++11

注意 vector<T> v(n) 用的是圆括号,会构造 n 个元素;而 vector<T> v{n} 用的是花括号,在列表初始化语义下,它表示"只有 1 个元素、值等于 n"。这是著名的大坑,务必记牢:

#include <iostream>
#include <vector>
using namespace std;
 
int main()
{
    vector<int> a(3);    // 圆括号:3 个元素,值都是默认的 0
    vector<int> b{3};    // 花括号:只有 1 个元素,值是 3
 
    cout << "a 的元素个数 = " << a.size() << ",a[0] = " << a[0] << endl;  // 3, 0
    cout << "b 的元素个数 = " << b.size() << ",b[0] = " << b[0] << endl;  // 1, 3
    int arr[] = {1,2,3,4,5};
    vector<int> c(arr, arr + 3);   // 迭代器区间构造:1 2 3
    cout << "c 的元素个数 = " << c.size() << endl;  // 3
    return 0;
}

一句话记住:圆括号 (n) 是"个数",花括号 {n} 是"值"。当我们想"先开 n 个默认元素"时用圆括号,想"按字面量直接给值"时用花括号。

一个隐藏细节:迭代器区间构造 vs 列表初始化的歧义

vector<int> v5(a, a + 5) 把两个指针当成了"迭代器起点/终点"来构造。可如果第二个参数不是一个指针/迭代器而是一个整数,比如 vector<int> v(a, 5),编译器会怎么解释?千万别去赌——这种写法极其危险。规则是:只要两个参数能当迭代器用(是指针或迭代器类对象),就走区间构造;否则编译器会想办法匹配别的重载。日常写代码时,这种形式主要用于"把一块原生内存段拷进 vector",老老实实传两个指向同数组的指针即可。

vector 的容量与大小:size / capacity / reserve

这是 vector 最核心也最容易混淆的一组概念。请你把 vector 想成一座仓库:

  • size(大小 / 有效元素个数):仓库里"真正摆放"的货物数量;
  • capacity(容量):仓库"租下来的总面积"能容纳的货物数量。

关键来了:size <= capacity 恒成立。因为仓库不能只租你放货物的那一小块地,通常都会多租一点备用——一旦货物数等于总面积(size == capacity),vector 就会去租一块更大的新仓库,把旧货搬过去,这才叫扩容。

size() 和 capacity() 用来查看这两项;而 reserve() 和 resize() 则是两个截然不同的"改容量/改大小"接口,这是面试和秋招最爱考的坑:

  • reserve(n) 只负责预留容量:它改变的是 capacity,让仓库提前扩大,但不打搅 size,也不会初始化任何元素。它纯粹是为了"减少扩容次数"。而且 reserve 只会增、不会减——如果请求的 n 比当前 capacity 小,它什么都不做。
  • resize(n) 改变的是 size:它让有效元素个数变成 n;如果 n 比原来大,多出来的位置还会被初始化为默认值(或你给的 val),此时若超出容量会连带扩容;如果 n 比原来小,会直接削掉尾部多余元素(注意:size 变小不会让 capacity 变小,空出来的仓库场地仍然租着)。
#include <iostream>
#include <vector>
using namespace std;
 
void show(const char* tip, size_t s, size_t c)
{
    cout << tip << " -> size = " << s << ", capacity = " << c << endl;
}
 
int main()
{
    vector<int> v;
 
    show("刚声明", v.size(), v.capacity());       // size = 0, capacity = 0
 
    v.push_back(1);
    show("push 1 个", v.size(), v.capacity());    // size = 1, capacity = 1
 
    // reserve(10):只把 capacity 预留给 10,size 仍然是 1,没有空元素
    v.reserve(10);
    show("reserve(10)", v.size(), v.capacity());  // size = 1, capacity = 10
 
    // resize(3):把有效元素个数改为 3,多余的 2 个位置初始化为 0
    v.resize(3);
    show("resize(3)", v.size(), v.capacity());    // size = 3, capacity = 10
 
    // 现在 v 里的元素是:1, 0, 0
 
    // 再 resize 回 1:尾部多余的元素被丢弃,size 变小(capacity 仍为 10)
    v.resize(1);
    show("resize(1)", v.size(), v.capacity());    // size = 1, capacity = 10
 
    // empty():判断 size 是否为 0
    cout << "empty? " << (v.empty() ? "yes" : "no") << endl;  // no
 
    // max_size():理论能装的最大个数(通常大得离谱,别当真)
    cout << "max_size = " << v.max_size() << endl;
 
    return 0;
}

一句话记住区别:reserve 只改"总面积"(capacity),resize 改"实际货物数"(size)还可能初始化元素。前者是性能优化手段,后者是真正改变容器内容的操作。

resize 的三种情况掰开揉碎

v.resize(n) 到底做了什么,取决于 n 和当前 size 的关系:

  1. n == size:什么都不做,原地不动。
  2. n < size:删除从下标 n 到末尾的所有元素,size 变小,capacity 保持不变。
  3. n > size:在尾部补上 n - size 个新元素;如果补这么多会超出 capacity,就先扩容再补。新补的元素用默认值(对 int 是 0),或者用第二个参数指定的值 resize(n, val)。

最后一个细节非常容易踩:resize 变大的那个瞬间,会让迭代器失效(因为可能扩容搬了家);而 resize 变小只是删元素、不搬内存,不会让别的迭代器失效(被删掉的尾元素对应的迭代器当然失效了)。

reserve 的两个隐藏坑

  • 坑一:reserve 后别用 [] 去访问"预留的位置"。v.reserve(100) 只把场地画出来了,但 size 还是老的,v[i] 对那些"还没真正成为有效元素"的位置属于越界访问,是未定义行为。如果你想要"先开好位子再往里面填值",应该用 resize,而不是 reserve。
  • 坑二:reserve 一个天文数字会抛异常。v.reserve(9999999999999999) 这类调用,要么抛 std::bad_alloc(分配失败),要么抛 std::length_error(超出 max_size)。这是 std::length_error 少数几个真实出现的场合,记得放 try/catch 里或量力而为。
#include <iostream>
#include <vector>
using namespace std;
 
int main()
{
    vector<int> v;
    v.reserve(5);               // 画家地盘,size 仍为 0
    cout << "reserve(5) 后 size=" << v.size() << " capacity=" << v.capacity() << endl;
 
    v.push_back(1);             // 现在 size=1, capacity 仍为 5(没触发扩容)
    cout << "push 后 capacity 不变 = " << v.capacity() << endl;   // 5
 
    try
    {
        vector<int> big;
        big.reserve(v.max_size());   // 会抛 length_error 或 bad_alloc
    }
    catch (const std::length_error& e)
    {
        cout << "捕获到 length_error:" << e.what() << endl;
    }
    catch (const std::bad_alloc& e)
    {
        cout << "捕获到 bad_alloc:" << e.what() << endl;
    }
    return 0;
}

shrink_to_fit:把多余的场地还回去(C++11)

reserve 只进不出。如果你临时需要大容量、用完后又想让 vector 把多余的内存还给系统,C++11 提供了 shrink_to_fit():它请求把 capacity 收缩到和 size 一样大。注意是"请求(non-binding)",编译器厂商有权不执行,但主流实现基本都会收缩。它会触发一次搬移(把元素挪到更小的新块),所以也是 O(n),并且会令迭代器失效——别在遍历中随手调用。

#include <iostream>
#include <vector>
using namespace std;
 
int main()
{
    vector<int> v;
    v.reserve(1000);
    for (int i = 0; i < 5; ++i) v.push_back(i);
    cout << "收缩前 capacity = " << v.capacity() << endl;  // 1000
 
    v.shrink_to_fit();
    cout << "收缩后 capacity = " << v.capacity() << " size = " << v.size() << endl;  // 5, 5
    return 0;
}

扩容机制:倍增的背后

现在到了最精彩的部分——vector 是怎么"偷偷长大"的。当你不断 push_back,直到 size 撞到 capacity 时,vector 会发生这样一件事:申请一块更大的新内存,把旧内存里的所有元素原封不动搬过去,然后释放旧内存,capacity 更新。这个过程就叫扩容(reallocation / grow)。

来看看扩容时容量到底怎么长的。注意:扩容系数是一个实现细节,C++ 标准并没有强制规定,只是要求"容量必须保证 push_back 均摊 O(1)"。在你的 Visual Studio(用的是微软 PJ 版本 STL)上容量大致按 1.5 倍增长;而在 Linux 下用 g++(即 SGI 版本 STL / libstdc++)则按 2 倍增长。这段代码你也可以自己跑,观察它的容量变化序列:

#include <iostream>
#include <vector>
using namespace std;
 
void TestVectorExpand()
{
    size_t sz = 0;
    vector<int> v;
    sz = v.capacity();
    cout << "making v grow:" << endl;
    for (int i = 0; i < 100; ++i)
    {
        v.push_back(i);                    // 尾插,可能触发扩容
        if (sz != v.capacity())            // 容量一旦变化就打印
        {
            sz = v.capacity();
            cout << "capacity changed: " << sz << endl;
        }
    }
}
 
int main()
{
    TestVectorExpand();
    return 0;
}

在 g++(2 倍扩容) 下你会看到 capacity 依次变成:1 → 2 → 4 → 8 → 16 → 32 → 64 → 128;而在 VS/PJ(约 1.5 倍) 下则是类似 1 → 2 → 3 → 4 → 6 → 9 → 13 → 19 → 28 → 42 → 63 → 94 → 141。所以千万不要"固执地认为 vector 扩容都是 2 倍"——具体多少完全由你用的 STL 实现说了算,不同平台跑出不同结果是很正常的。这既是考点,也是常识:不要依赖具体倍增系数,写代码时只当它"会扩容"即可。

为什么是"倍增",而不是"每次多加 100 个"?

你可能想:既然扩容贵,那干脆每次固定加 100 个位子,岂不省心?我们用复杂度算给你看。假设从空开始,总共要插入 n 个元素:

  • 固定增量(每次 +100):大约需要 n / 100 次扩容,第 k 次扩容要搬 k×100 个元素,总搬移量 = 100×(1 + 2 + ... + n/100) = O(n²)。也就是说,插入 100 万个元素,搬移成本是平方级,慢到没法用。
  • 倍增(约 2 倍):需要约 log₂n 次扩容,第 k 次搬 2^k 个元素,总搬移量 = 1 + 2 + 4 + ... + n ≈ 2n,是** O(n)**。把 O(n) 摊到 n 次插入,平均每次仅 O(1)。

这就是为什么必须用"几何增长"(乘以一个大于 1 的常数),而绝不能用"算术增长"(每次加固定值)。几何增长的总搬移量被限制在一个常数倍数的元素总数上,算术增长则会让它变成平方。顺手记一个结论:扩容系数只要 > 1,push_back 均摊就是 O(1);系数越大,扩容次数越少、单次越贵但总成本保持线性。

扩容是 O(n),但均摊下来是 O(1)

这里有个值得深挖的性能问题:每次扩容要把旧元素全部拷到新空间,所以单次扩容是 O(n) 的——元素越多,那一次扩容越疼。但为什么我们总说"push_back 是 O(1)"呢?这是均摊复杂度(amortized complexity)的功劳。

你可以这样理解:因为容量是按倍数增长的,前面插了很多次、存了很多货,才换来一次扩容。假设从 1 开始每次翻倍,一共插了 n 个元素,那扩容搬移的元素总数大约是 1 + 2 + 4 + ... + n ≈ 2n,也就是 O(n) 的量级。把这 O(n) 的开销摊到 n 次插入上去,每次插入平均也就 O(1)。换句话说,虽然某一次偶发的插入会"卡一下",但长期看起来每次插入都接近常数时间。这也是 vector 尾插"又快又稳"的根本原因。C++11 乃至后续标准都直接在标准文本里把这个"均摊 O(1)"写死成了硬性保证。

倍增为什么对硬件缓存友好?

扩容时新申请的内存要满足什么条件,其实影响性能。因为新容量通常是老的 2 倍(或 1.5 倍向上取整),新分配块往往恰好是 2 的幂的大小,比如 16、32、64、256……而底层内存分配器(malloc / new)习惯按 2 的幂对齐到缓存行、内存页边界。于是:

  • 扩容后的新块尽量与缓存行对齐,遍历时缓存命中率高,内存局部性好;
  • 避免频繁地"小步扩容"引发的抖动。

虽然这个效果不是绝对的、也依赖具体分配器实现(不同 STL、不同 malloc 策略结果不同),但它解释了一个经验现象:vector 的倍增策略在真实硬件上往往比"恰好够用不多不少"的方案更省心。别把它当成铁律拿去考试,当成"倍增背后的一层合理性"来理解就好。

1.5 倍还是 2 倍?内存复用的经典争论

为什么 VS 坚持用 1.5 倍而不学 g++ 用 2 倍?这里有个广为人知的"内存复用"论证,网上讨论很多,我把它讲透:

考虑用 2 倍增长。旧容量序列是 1, 2, 4, 8, 16, 32……当 vector 从 8 扩到 16 时,前面所有被释放的旧块大小是 1+2+4+8 = 15,小于新块 16——而这些旧块还是零零散散、不连续的。分配器就算把它们全捡起来,也拼不出一块连续 16 的空间给新 vector 住。于是每次扩容都得从系统那儿要全新更大的连续内存,旧块基本"白释放"。

再看 1.5 倍。旧序列约是 1, 2, 3, 4, 6, 9, 13, 19, 28, 42, 63, 94, 141……当 vector 从 63 扩到 94 时,前面所有旧块加起来 约等于 63+42+28+... 早已超过 94。某些内存管理策略下,"因为旧块总和大于新块需求"就存在把同一片内存反复利用起来的可能性,从而减少向操作系统频繁伸手的次数。这也就是为什么在一些实现里 1.5 倍在长期运行、内存紧张的服务器场景下表现更"圆滑"。

要强调的是:这个论证依赖具体的分配器策略(伙伴系统、first-fit/best-fit 等),不同系统结论可能不同,标准也不管这个。你就把它当成"为什么 1.5 跟 2 都有道理"的背景知识即可,别死记。

扩容前必须看清:只有当 size == capacity 才会搬家

很多人误以为"push_back 到 size 超过 capacity 才扩容",这是错的。事实是:只要 size == capacity,下一次 push_back 就必然触发扩容(因为没有空位放新元素)。换句话说,扩容的触发条件精确是"满员",而不是"超员"。

反过来,只要 size < capacity,push_back 就只会在现有空位上写一个值,完全不搬家,所有旧迭代器和引用都继续有效。这个区别在"迭代器失效"那节极其重要,先把结论埋在这里:判断一句"这次 push_back 有没有让底层搬家"的唯一标准,就是看 push_back 那一刻 size 是否等于 capacity。

#include <iostream>
#include <vector>
using namespace std;
 
int main()
{
    vector<int> v{ 1, 2, 3, 4, 5 };
    cout << "满员状态 size=" << v.size() << " capacity=" << v.capacity() << endl;
 
    int* p = v.data();                // 记下当前堆内存首地址
    v.push_back(6);                   // 此刻 size==capacity(5),必然扩容搬家
    cout << "push 之前首地址 = " << p << endl;
    cout << "push 之后首地址 = " << v.data() << endl;   // 大概率不同 -> 搬家了
    return 0;
}

运行你会发现两个首地址大概率不一样——瞬间搬家了。这就是"最后一次把容量撑满的 push_back 最贵"的原因。

扩容时的搬移:拷贝还是移动?(强异常保证)

vector 搬元素不是用 memcpy 生搬,而是逐个对每个元素调用它的拷贝构造或移动构造函数(此时留下的旧元素调用析构函数销毁)。现代实现会做一个聪明的选择:

  • 如果元素的移动构造是 noexcept(保证不会抛异常)——就优先用移动,很快;
  • 如果移动可能抛异常——就回退用拷贝构造,因为拷贝失败时旧数据还在,vector 能保证"要么成功、要么容器原样不变"(这叫强异常保证 / strong exception guarantee)。

这就是为什么你给自己写的类加上移动构造函数时会刻意写 noexcept——正是为了让 vector(以及其它容器)扩容时敢放心用移动而不是拷贝。对内置类型(int、double、指针)根本无所谓,它们本身就"可无异常移动"。

用 reserve 提前规避扩容

既然扩容要搬移那么多元素、那么贵,那能不能让它少发生几次?能,答案就是我们前面提到的 reserve。如果我们提前就知道大概要装多少,一次 reserve 就把容量备足,后面一路 push_back 再也不会扩容了,这点在读数据、批量构建数据时特别有用:

#include <iostream>
#include <vector>
using namespace std;
 
void TestVectorExpandOP()
{
    vector<int> v;
    size_t sz = v.capacity();
    v.reserve(100);          // 提前把容量准备好,一次扩容的成本花在刀刃上
 
    cout << "making bar grow:" << endl;
    for (int i = 0; i < 100; ++i)
    {
        v.push_back(i);
        if (sz != v.capacity())          // 全程不会扩容
        {
            sz = v.capacity();
            cout << "capacity changed: " << sz << endl;
        }
    }
    cout << "最终 size = " << v.size() << ", capacity = " << v.capacity() << endl;
}
 
int main()
{
    TestVectorExpandOP();   // 输出里不会出现"capacity changed"
    return 0;
}

注意 reserve 只能缓解扩容带来的开销,它不能消除所有成本——它只是让"备份空间"这一步提前做完了。所以经验法则就是:如果数量你能估计个大概,先 reserve 一下;估不准就交给 vector 自己翻倍。

一句话总结这一节:向量扩容 = "满员就买新地、整栋搬、还旧地"。倍增系数 2 或 1.5 都是合法选择,均摊 O(1) 才是标准承诺的核心。

vector 的遍历与迭代器

遍历 vector 收数据,通常有几种方式。我们已经见过下标 [i] 和范围 for,这里重点讲讲迭代器——因为它是统一遍历各类容器的"万能钥匙"。

前面说过,vector 的迭代器本质就是原生指针 T*。迭代器提供了类似指针的一套操作:begin() 拿到首个元素地址,end() 拿到末尾元素之后一个位置(你手指着最后一个元素再往后的那个空位,别越界往里取值),用 *it 取出它指向的元素,用 ++it 让迭代器往后挪一个元素,用 != 判断是否到结尾。

#include <iostream>
#include <vector>
using namespace std;
 
int main()
{
    vector<int> v{ 1, 2, 3, 4, 5 };   // C++11 列表初始化
 
    // 方式一:原生迭代器 begin()/end(),范围左闭右开 [begin, end)
    vector<int>::iterator it = v.begin();   // 拿到指向第一个元素的迭代器
    while (it != v.end())                   // end 是"末尾 + 1",不到 end 就继续
    {
        cout << *it << " ";                 // 解引用拿到当前元素
        ++it;                               // 步进到下一个元素
    }
    cout << endl;                           // 输出:1 2 3 4 5
 
    // 方式二:auto 让编译器自己推断迭代器类型,省事
    for (auto it = v.begin(); it != v.end(); ++it)
        cout << *it << " ";
    cout << endl;                           // 输出:1 2 3 4 5
 
    // 方式三:反向迭代器 rbegin()/rend(),从尾部往头部走
    for (auto rit = v.rbegin(); rit != v.rend(); ++rit)
        cout << *rit << " ";                // 输出:5 4 3 2 1
    cout << endl;
 
    // 方式四:C++11 范围 for,本质就是迭代器遍历的"语法糖"
    for (int e : v)
        cout << e << " ";
    cout << endl;                           // 输出:1 2 3 4 5
 
    return 0;
}

一个关键点必须讲清:end() 指向的是"最后元素的下一个位置",这个位置没有元素,所以千万不要对 end() 解引用取元素,那会越界。整个范围是左闭右开的——[begin, end),左边包含、右边不包含。这正是为什么循环条件写成 it != v.end() 而不是 it <= v.end()(指针之间不能随便用 <= 比较一个不存在的元素位置,也不该取 end 的值)。

范围 for(for (int e : v))是 C++11 新增的语法糖,它内部就是"取 begin、循环 ++、解引用"这套完整流程。如果你想在循环里修改元素,记得把 e 声明成引用 int&;如果只需要读取不想误改,写成 const int&(或直接 int 值拷贝)都行。

迭代器的"随机访问"能力:it + n

因为 vector 内存连续,它的迭代器是标准的随机访问迭代器(random access iterator),也就是说除了 ++/--,你还能让迭代器瞬时跳转:it + 3 直接定位到从当前位置往后数第 3 个元素,it - 2 往前跳 2 个,it[3] 等价 *(it+3),两个迭代器还能直接相减得到元素个数。这些都是 O(1),因为本质上就是指针加减:

#include <iostream>
#include <vector>
using namespace std;
 
int main()
{
    vector<int> v{ 10, 20, 30, 40, 50 };
    auto it = v.begin();
    cout << "it+2 = " << *(it + 2) << endl;      // 30,随机跳
    cout << "it[3] = " << it[3] << endl;         // 40,等价 *(it+3)
    auto end = v.end();
    cout << "end - begin = " << (end - it) << endl; // 5,元素个数
    return 0;
}

正因为 vector 迭代器指针能随便加减,所有标准库里需要随机访问的算法(比如二分查找 lower_bound、排序 sort)都能用在 vector 上——这是链表 list 做不到的(链表迭代器只能一步步走)。这一点在"随机访问为什么是 O(1)"那节还会回扣。

const_iterator 与各种 const 变体

迭代器也有"只读"版本。vector<int>::const_iterator 解引用出来是 const int&,只许读、不许改。cbegin()/cend()(C++11)返回 const_iterator,crbegin()/crend() 返回 const 反向迭代器。区分两个概念:

  • const 迭代器(const_iterator):不能通过它改元素,但迭代器本身可以走(能 ++)。
  • const 容器:当你用 const vector<int>& 拿引用时,它返回的就是 const_iterator,天然不允许改。
#include <iostream>
#include <vector>
using namespace std;
 
void readOnly(const vector<int>& v)   // 只读遍历:用 const 引用
{
    for (auto it = v.cbegin(); it != v.cend(); ++it)
        cout << *it << " ";
    cout << endl;
}
 
int main()
{
    vector<int> v{ 1, 2, 3 };
    readOnly(v);                       // 1 2 3
    // v[0] = 5;             // 若去掉注释,在只读场景中编译器会拦截
    return 0;
}

范围 for 修改元素与一个隐患

范围 for 解引用的时机是"每次循环取一次元素":for (int e : v) 每次把当前元素拷贝进 e,所以循环体里改 e 不影响 v;要影响 v 必须用 int&。更进一步,e 的类型可以是任意匹配类型,比如 long long e、const int&、int&:

#include <iostream>
#include <vector>
using namespace std;
 
int main()
{
    vector<int> v{ 1, 2, 3 };
    for (auto& e : v)      // 引用:能在循环里改数组元素
        e *= 10;
    for (const auto& e : v)  // 只读:不拷贝元素,避免大对象拷贝开销
        cout << e << " ";     // 10 20 30
    cout << endl;
    return 0;
}

注意:对于特别大的元素类型(比如一整个 struct),用 const auto& 只读遍历可以避免每次拷贝一个大对象的开销——这是遍历性能优化的惯用手法。

最后一个隐患必须强调:在范围 for / 迭代器遍历过程中,不要增删 vector 元素。因为范围 for 底层的迭代器是"之前就记好的",你一旦在循环里 push_back(可能扩容搬家)或 erase(元素位置全变),这个迭代器就失效了,循环几乎必崩或结果错乱。要逆序遍历删除,请看后面"迭代器失效"一节的标准姿势。

vector 的增删改查:push_back / insert / erase

容器最常干的事就是增、删、改、查。vector 的增删查改接口如下,我把重点接口都演示一遍。先记住这几个:push_back(尾插)、pop_back(尾删/没有返回值)、insert(任意位置插入)、erase(任意位置删除)、find(查找,注意:它是 <algorithm> 里的算法函数,不是 vector 的成员函数)、operator[](像数组那样按下标访问/修改)。

#include <iostream>
#include <vector>
#include <algorithm>     // find 在这个头文件里,是算法,不是 vector 成员!
using namespace std;
 
void print(const vector<int>& v)
{
    for (int e : v) cout << e << " ";
    cout << endl;
}
 
int main()
{
    vector<int> v{ 1, 2, 3, 4, 5 };
    print(v);                       // 1 2 3 4 5
 
    // 增:尾插 push_back
    v.push_back(6);                 // 结尾追加 6
    v.push_back(7);                 // 再追 7
    print(v);                       // 1 2 3 4 5 6 7
 
    // 增:任意位置插入 insert(迭代器, 值),插在 pos 之前
    v.insert(v.begin(), 0);         // 在开头插入 0
    print(v);                       // 0 1 2 3 4 5 6 7
 
    // 删:尾删 pop_back,把最后一个元素丢掉,不返回被删值
    v.pop_back();
    print(v);                       // 0 1 2 3 4 5 6
 
    // 查:使用标准库算法 find 找到值 3 的位置,返回一个迭代器
    vector<int>::iterator pos = find(v.begin(), v.end(), 3);
    if (pos != v.end())             // find 找不到会返回 end()
        cout << "找到 3:*pos = " << *pos << endl;
 
    // 删:erase(迭代器) 删除指定位置元素
    v.erase(pos);                   // 删掉刚才那个 3
    print(v);                       // 0 1 2 4 5 6
 
    // 改:operator[] 按下标访问并修改,超方便
    v[0] = 100;                     // 把第 0 个元素改成 100
    v[1] += 2;                      // 把第 1 个元素 +2
    print(v);                       // 100 4 4 5 6
 
    // 交换两个 vector 的数据空间 swap(O(1),只换指针)
    vector<int> other{ 9, 9, 9 };
    v.swap(other);                  // v 和 other 的数据直接互换
    print(v);                       // 9 9 9
 
    return 0;
}

这里有两个隐藏点值得深挖。

第一个,find 不是 vector 的成员函数。vector 自己提供的是查找能力很有限,它只知道"我有哪些元素",而"怎么在一堆元素里找目标"这种通用算法,被抽出来放进了 <algorithm> 里,成为独立的 find。它接收两个迭代器表示查找区间,返回找到位置(没找到返回 end())。所以记得 #include <algorithm>,这正是 STL 设计的高明之处——算法和容器解耦,一个 find 能用在 vector、list、string 等所有支持迭代器的容器上。

第二个,insert 和 erase 的代价。vector 里元素是紧紧挨着的,往中间某个位置 insert 一个元素,得把这个位置后面的所有元素挨个往后挪一位才能腾出空;同理 erase 中间的元素,后面的元素要往前挪一位填上。所以对 vector 来说,除了尾部操作,其他位置的插入/删除都是 O(n) 的,元素越多越慢。这时候你可能会想:那我要频繁在中间插删怎么办?那就该考虑链表 list 这样的容器了——这也正是"不同容器用不同算法"的意义。

insert 和 erase 的完整重载

insert 不止一种用法,常见的有:

  • v.insert(pos, val):在 pos 前插入一个 val,返回指向新插入元素的迭代器;
  • v.insert(pos, n, val):在 pos 前插入 n 个 val;
  • v.insert(pos, begin_it, end_it):在 pos 前插入一段迭代器区间 [begin,end)。

erase 也有两种:

  • v.erase(pos):C++11 起返回"被删元素的下一个位置的迭代器"(C++03 是返回空迭代器);
  • v.erase(first, last):删除 [first, last) 一段,返回指向被删区段之后第一个仍保留元素的迭代器。
#include <iostream>
#include <vector>
using namespace std;
 
void print(const vector<int>& v)
{
    for (int e : v) cout << e << " ";
    cout << endl;
}
 
int main()
{
    vector<int> v{ 1, 2, 3 };
    v.insert(v.begin() + 1, 99);          // 在 index 1 前插入 99
    print(v);                              // 1 99 2 3
 
    v.insert(v.end(), 2, 7);              // 在末尾插入 2 个 7
    print(v);                              // 1 99 2 3 7 7
 
    int extra[] = { 50, 60 };
    v.insert(v.begin(), extra, extra + 2); // 在开头插入区间 [extra,extra+2)
    print(v);                              // 50 60 1 99 2 3 7 7
 
    v.erase(v.begin(), v.begin() + 2);     // 删掉最前面两个
    print(v);                              // 1 99 2 3 7 7
    return 0;
}

front / back / data:读头部、尾部、底层指针

  • front():返回(首元素的)引用,O(1);
  • back():返回末元素的引用,O(1);
  • data():返回指向底层连续数组首地址的原始指针 T*,O(1)。
#include <iostream>
#include <vector>
using namespace std;
 
int main()
{
    vector<int> v{ 10, 20, 30 };
    cout << "front = " << v.front() << endl;   // 10
    cout << "back  = " << v.back()  << endl;   // 30
 
    int* raw = v.data();
    raw[1] = 222;                 // 直接通过底层指针改数据
    cout << v[1] << endl;         // 222
 
    // 把 vector 的数据送给 C 风格函数(比如 memcpy)也很方便
    return 0;
}

data() 特别适合与 C 库函数对接:std::memcpy(dst, v.data(), v.size() * sizeof(int))、把 vector<char> 传入 printf("%s", vch.data()) 等等。因为 vector 内存连续,这层互操作天衣无缝。

emplace_back:就地构造,省一次拷贝(C++11)

push_back 的流程是:构造一个临时对象(可能还要先做各种转换)→ 拷贝/移动进 vector → 销毁临时对象。emplace_back 则是拿着构造参数直接在新位置上"就地构造"元素,省掉那个临时对象和一次拷贝。当元素是"构造成本不低的自定义类型"时,可能带来可观的性能提升。

#include <iostream>
#include <vector>
#include <string>
using namespace std;
 
int main()
{
    vector<pair<string, int>> v1;
    v1.push_back(make_pair("Tom", 18));          // 先 make_pair 再做一次拷贝
    v1.emplace_back("Jerry", 20);                // 直接在容器里构造 pair
 
    for (const auto& p : v1)
        cout << p.first << ":" << p.second << " ";
    cout << endl;                                // Tom:18 Jerry:20
    return 0;
}

(std::pair 需要 #include <utility>,它在 <vector> 里通常已被间接包含,但严谨起见可显式包含 #include <utility>。代码中 make_pair 从声明角度也依赖 utility。)

assign、clear 与比较

  • assign(n, val) / assign(begin_it, end_it):把整个容器的内容替换成 n 个 val 或某段区间。它会丢掉旧内容,可能触发扩容或缩小,旧迭代器全部失效。
  • clear():清空所有元素(size 变 0),但容量不变——场地还留着。所以 clear() 之后 v[0] 仍是越界未定义行为(size 已经是 0),你必须用 push_back 重新填充,且迭代器在 clear 后失效。
  • ==、!=、< 等:vector 重载了比较运算符,v1 == v2 会逐个元素比较(顺序、个数都要相同)。< 是字典序比较。
#include <iostream>
#include <vector>
using namespace std;
 
void print(const vector<int>& v)
{
    for (int e : v) cout << e << " ";
    cout << endl;
}
 
int main()
{
    vector<int> v{ 1, 2, 3 };
    v.assign(4, 9);               // 替换成 4 个 9
    print(v);                     // 9 9 9 9
 
    v.clear();
    cout << "clear 后 size = " << v.size()
         << ", capacity = " << v.capacity() << endl;  // 0, 4(容量没变)
 
    vector<int> a{ 1, 2 }, b{ 1, 2 }, c{ 2, 1 };
    cout << (a == b) << endl;     // 1(相等)
    cout << (a < c)  << endl;     // 1(字典序:a[0]=1 < c[0]=2,首位就分出先后,因此 a<c 为 true)
    return 0;
}

随机访问为什么是 O(1)

很多人背过"vector 按下标访问是 O(1)",但不一定知道为什么。这一节说清楚,包你会用一辈子。

关键在于 vector 保证元素在内存里是连续的。想象一栋门牌号连续的大楼,每个元素占固定大小的一块"房间"(比如 int 是 4 个字节)。vector 内部只记着一个"门牌起点"(起始地址,也就是它内部那个指向堆内存的指针)。那么,想访问第 i 个元素,只需要做一次简单的算术:

第 i 个元素的地址 = 起始地址 + i × 元素大小

这是一次加法 + 一次乘法(甚至被编译器优化成移位),跟 i 有多大毫无关系——不管 i 是 2 还是 2000000,都只做这一下,不需要从头逐个找。所以随机访问(按下标访问任意位置)是 O(1),也就是常量时间。这个能力叫"随机访问迭代器",是 vector 最大的底牌。

#include <iostream>
#include <vector>
using namespace std;
 
int main()
{
    vector<int> v{ 10, 20, 30, 40, 50 };
 
    // 按下标访问:O(1),直接定位
    cout << v[2] << endl;          // 30:起始地址 + 2*4 字节,一步到位
 
    // 用 at() 也是下标式访问,但会做边界检查,越界抛异常(比 [] 安全,略慢)
    cout << v.at(3) << endl;       // 40
 
    // 通过迭代器加上一个偏移量,同样是 O(1)
    cout << *(v.begin() + 4) << endl;  // 50
 
    // 拿到底层那块连续内存的首地址
    int* raw = v.data();
    cout << raw[0] << endl;        // 10
 
    return 0;
}

对比一下:链表 list 为什么按下标不行?因为链表不是连续的内存,要访问第 i 个得从头一个一个往后走,是 O(n)。所以"连续内存 + 首地址 + 下标换算 = O(1) 随机访问",这就是 vector 的核心优势。也正因为它内存连续,v.data() 能直接给你一块原生连续内存,这在某些要跟 C 库函数(比如 memcpy、printf("%s"))对接的场景特别有用。

operator[] 与 at():差在"要不要检查越界"

这两个接口行为差异极大,做个对比:

接口越界时行为开销安全建议
v[i](operator[])未定义行为(可能读到垃圾、崩溃、被利用)最快,纯指针运算适合"已确定 i 合法"的热路径
v.at(i)抛 std::out_of_range 异常多一次边界比较,稍慢适合边界不可控、宁可抛异常也不乱读
v.front() / v.back()空容器下未定义O(1)用前先判 !v.empty()

operator[] 不检查,是因为 C++ 的哲学是"信任程序员,性能至上"——你要 O(1) 就把检查这层省掉,代价是你得自己保证下标合法。at() 则是"帮你兜底"版本:

#include <iostream>
#include <vector>
#include <stdexcept>
using namespace std;
 
int main()
{
    vector<int> v{ 10, 20, 30 };
    cout << v[1] << endl;              // 20,合法访问
 
    try
    {
        cout << v.at(99) << endl;      // 越界!at() 会抛异常
    }
    catch (const std::out_of_range& e)
    {
        cout << "越界了:" << e.what() << endl;
    }
    return 0;
}

运行会捕获到 out_of_range。而如果你把上面的 v.at(99) 改成 v[99],程序是"未定义行为"——在本机可能不崩、打印垃圾值,也可能等你维护几个月后才在别的机器上莫名崩掉。这也是调试中最讨厌的一类 bug。

作为 for 循环怎么用更高效

因为 v[i] 是纯指针算数,编译器常把它优化成"一次性算好首地址 + 每次自增",所以 for (size_t i = 0; i < v.size(); ++i) sum += v[i]; 和指针迭代器遍历性能基本一样,各 STL 上都接近。刻意追求"用迭代器更快"其实意义不大。真正要警惕的是每次循环里重复调用昂贵的 v.size()——好在 size() 本身就是两次指针相减,O(1),即使每轮调用也没事。

浅拷贝、深浅拷贝与迭代器失效

这是 vector 学习里最绕、最容易翻车的部分,也是面试的高频题。我们拆成两件事分别讲透。

首先:深浅拷贝与扩容时的元素搬移

前面说扩容要把旧元素"搬"到新空间。这个"搬"具体是怎么搬的,藏着大坑。vector 搬家时,会用元素自己的拷贝构造/移动构造逐个转移到新空间。如果元素是 int、double 这类内置类型,复制一份就好了,没任何问题。可如果元素是自己管理资源的自定义类型——比如一个自己持有 char* 的 string——事情就麻烦了。

这里必须先分清两个概念:浅拷贝只复制"指针这个值",让两个对象指向同一块内存;深拷贝连内存里的数据一并复制一份,各管各的。标准库的 vector 是靠元素的拷贝构造来搬家的,这是深拷贝,安全。但如果你自己写一个 vector,图省事用 memcpy(它按二进制把一块内存原封不动地复制),那就是浅拷贝,是大坑:

#include <iostream>
#include <cstring>   // 用 memcpy
using namespace std;
 
// 模拟一个"自己管理资源"的类:持有堆上的字符串
class MyString {
public:
    MyString(const char* s = "")
        : _size(strlen(s)), _str(new char[_size + 1])   // 申请自己的空间
    {
        strcpy(_str, s);
    }
    // 若用编译器默认拷贝构造:_str 会被"复制指针",两个对象指向同一块堆内存(浅拷贝)
    // 这里我们显式不写拷贝构造,用它来演示浅拷贝的后果
    ~MyString()
    {
        if (_str)
        {
            delete[] _str;      // 析构时释放自己那份空间
            _str = nullptr;
        }
    }
private:
    size_t _size;
    char*  _str;      // 指向堆上的一块动态内存
};
 
int main()
{
    // 用 vectors 装这个自我管理资源的类型
    vector<MyString> v;
    v.push_back(MyString("hello"));
    v.push_back(MyString("world"));
    // 若 vector 扩容用 memcpy 搬元素,那旧块里的"_str 指针值"被整块复制,
    // 新旧两个 MyString 会指向同一块堆内存,程序结束时 double-free。
    cout << "浅拷贝共用内存,析构时 double-free,程序危险" << endl;
    return 0;
}

说明:上面这个例子故意用 vector<MyString> 来表达"如果底层搬家用 memcpy 会出事"的后果。真实的 std::vector 从不生搬,它会调用元素的拷贝/移动构造,所以标准库 vector 装这个类也没有问题。这个例子的作用,是让你理解"手写 vector 时为什么绝对不能拿 memcpy 搬元素"。为了不真正触发 double-free 崩溃把演示卡死,上面没有用 std::vector 去触发 memcpy(那本也不会发生),而是直接点明其中的隐患。想亲眼见到崩溃,可以自己把 push_back 的次数调大模拟扩容,或者手写一个用 memcpy 的 vector 版本去装它。

上面这段代码就暴露了 memcpy 作为浅拷贝的致命缺陷:对象一旦涉及资源管理(比如持有指针指向堆内存),浅拷贝会让两个对象指向同一块内存,析构时同一块内存被释放两次,轻则内存泄漏,重则直接崩溃。所以结论很明确:写 vector(以及任何深拷贝容器)时,元素搬移不能用 memcpy,必须调用元素自己的拷贝/移动构造(深拷贝)。这也是为什么标准库 vector 能安全存放各种用户自定义类型——因为它从不偷懒用 memcpy。

补充一个更常见的真实翻车现场:不写拷贝构造、只用默认生成的拷贝构造的自定义类。它的拷贝构造也是"复制所有成员变量",如果成员里有裸指针,一样是浅拷贝,一颗雷。所以给"持有资源"的类写深拷贝构造/拷贝赋值/移动构造/析构(俗称"五法则")是 C++ 编程基本功,这里不再展开,你只要记住 vector 的安全搬家依赖"元素的拷贝/移动构造正确实现"即可。

其次:迭代器失效

迭代器失效的意思是:你手里拿着一个迭代器(对 vector 来说就是那个 T* 指针),但它指向的那块内存已经被释放/变味了,你还拿着它去读写,就访问了失效内存,轻则结果不对、重则程序崩溃。对 vector 会导致迭代器失效的操作,可以归纳为两大类:

第一类:引起底层空间改变的操作 —— resize、reserve、insert、assign、push_back 等。这些操作很可能触发扩容,而扩容会申请新空间、释放旧空间。你扩容前拿到的旧迭代器,指向的旧空间已经被释放了,于是失效。

这里有个常见误区要专门划重点:push_back 不是一定失效,只有在 size == capacity(尾插把容量顶满了)触发了扩容时才失效。如果容量还够(push_back 前后 capacity 没变),那内存没搬家,迭代器依然有效。同理 reserve 如果传入的容量没超过当前 capacity(没有真正扩容),迭代器也不会失效。判断标准永远是一个:这次操作到底有没有让底层空间搬家?

但有一个微妙的例外必须补上:即使没有扩容,push_back 也会让 end() 迭代器失效。因为 end() 本来指向"last 元素之后那个位子",push_back 后这个位子成了"新最后一个元素"的位置,原来的 end 语义没了。所以"没扩容就不失效"这句话只对"指向已有元素的迭代器"成立,指向 end 的迭代器仍要重取。

#include <iostream>
#include <vector>
using namespace std;
 
int main()
{
    vector<int> v{ 1, 2, 3, 4, 5 };
    auto it = v.begin();                       // 先拿到一个迭代器,指向首元素
 
    cout << "扩容前 capacity = " << v.capacity() << endl;
 
    // reserve(100):会让底层容量变成 100,旧空间被换掉 => 迭代器失效
    v.reserve(100);
    cout << "扩容后 capacity = " << v.capacity() << endl;
 
    // 此时 it 还指向旧空间,在 VS 上解引用直接就崩溃(未定义行为)
    // 在 g++/Linux 上可能不崩,但打印出来的东西是错的(野指针乱读)
    // 所以必须"重新"让迭代器指向新的数据:
    it = v.begin();            // 重新赋值,让它指向新空间的首元素
 
    // 用更新后的迭代器正常遍历
    for (; it != v.end(); ++it)
        cout << *it << " ";    // 1 2 3 4 5
    cout << endl;
    return 0;
}

第二类:erase 删除元素。删除某个位置的元素后,该位置及其后面所有元素都会往前挪一位,所以被删位置以及它之后的迭代器都失效了(确切说是它们指向的元素位置变了)。你手上的旧迭代器可能指向了不属于它的元素,甚至越过了 end。VS 的 STL 对迭代器失效检测非常严格,很容易直接崩;而 g++(SGI STL)不那么严格,很多情况"居然能跑",但结果是错的——这恰恰是最危险的,因为它不报错。

erase 的正确安全用法是:接收它的返回值。C++11 之后 erase 会返回"被删元素的下一个位置的迭代器",这样你就能拿到一个新迭代器继续操作。典型场景是"删除 vector 里所有偶数":

#include <iostream>
#include <vector>
using namespace std;
 
int main()
{
    vector<int> v{ 1, 2, 3, 4, 5, 6 };
    auto it = v.begin();
 
    // 注意:删除元素会立刻让被删位置及其后的迭代器失效,
    // 但 erase 会返回"被删位置的下一个位置的迭代器",用它接力就没问题
    while (it != v.end())
    {
        if (*it % 2 == 0)           // 是偶数就删
            it = v.erase(it);       // 用返回值接住新位置,不要自己 ++(双重步进 bug)
        else
            ++it;                   // 不是偶数才往前推进
    }
 
    for (int e : v)
        cout << e << " ";           // 1 3 5
    cout << endl;
    return 0;
}

对比一下错误写法:if (偶数) v.erase(it); ++it;——你把 ++it 放在 if 外面,删除后 it 已经失效,再 ++ 就是在失效指针上操作,很可能崩溃,而且逻辑还会跳过一个元素(比如删掉 2 后 it 本来指着 3,再 ++ 就跳过 3 了)。只要记住一条:凡是可能让底层空间搬家的操作(扩容类)执行完,或删除了元素之后,统一把迭代器重新赋一遍值(it = v.begin() 或接收 erase 的返回值),就不会踩坑。

顺便说一句,这个规律同样适用于 string:你 resize、插入扩容、erase 之后,旧迭代器一样会失效,处理方式完全相同。

各操作导致迭代器失效一览表

把 vector 上"会/不会导致迭代器失效"的操作整理成一张表,考前背它:

操作失效范围说明
push_back / emplace_back(触发扩容,即 size==capacity)全部遍历/引用失效底层换新内存
push_back / emplace_back(未扩容,size<capacity)仅 end() 失效;已有元素迭代器仍有效end 的语义被改变
insert(触发扩容)全部失效底层搬迁
insert(未触发扩容)insert 位置及其之后失效后面元素整体后移
erase(pos) / erase(first,last)被删位置及其之后失效后面元素前移填坑
reserve(n)(n > capacity)全部失效扩容
reserve(n)(n <= capacity)无没搬家
resize(变大的那一步)若需扩容则全部失效扩容
resize(变小)被截掉的尾部迭代器失效其余不受影响
assign全部失效可能重建底层
clear全部失效元素清空
swap无(两容器内容互换,迭代器跟着新容器走)只是换指针

要点记法:会"重开新房"的 → 全废;只是"元素前后挪位"的 → 位置及以后废;只换指针的(swap)→ 不废。

VS(严格)与 g++(宽松)的行为差异

同一段"迭代器已失效还去用"的代码,在 VS 上可能当场崩,在 g++ 上却"刚好能跑"——原因是两者对迭代器的 debug 校验严格程度不同:

  • VS(MSVC):默认开启 STL 的迭代器 debug 校验,访问已失效迭代器会触发断言/崩溃,把你拽住。
  • g++(libstdc++):默认不做这种收紧校验,失效迭代器往往还能"读到旧数据"或"读到邻居",看起来能跑,其实结果是错的——尤其当它越过了 [begin,end) 范围时,照样可能崩。

所以千万不要因为"在 g++ 上没崩"就以为写法没问题。未定义行为(UB)的意思就是"编译器和运行库想怎么表现都行"。写代码时一律按"失效即废"的最严格标准来,这样在哪个平台都对。

vector 的底层实现思想

学到这里,你已经会"用"vector 了。最后我们"剥开外壳"看它的内脏——这样你才能达到"能扩展、能自己造"的境界。

vector 的底层其实非常朴素,它就靠三个指针撑起整个局面:

  • _start:指向堆上那块连续内存的起始地址(第一个元素);
  • _finish:指向最后一个有效元素的下一个位置(所以 size = _finish - _start);
  • _end_of_storage:指向整块容量的末尾(所以 capacity = _end_of_storage - _start)。
内存布局示意(连续堆内存,从左到右地址增大):
┌──────┬──────┬──────┬──────┬──────┬──────────────┐
│ elem0│ elem1│ elem2│*(保留空位)*                   │
└──────┴──────┴──────┴──────┴──────┴──────────────┘
  ▲             ▲              ▲
_start      size 处(_finish)  _end_of_storage

用这三个指针,所有核心操作都能用"指针相减"一步算出来:size() 就是 _finish - _start,capacity() 就是 _end_of_storage - _start,下标 v[i] 就是访问 *(_start + i)。而扩容,就是"检查有没有空间 → 没有就 new 一块更大的 → 把旧元素搬过去 → 释放旧的 → 更新三个指针"。所谓"手写一个简易 vector",本质上就是把这套三指针机械照搬出来:

#include <iostream>
using namespace std;
 
// 一个演示"三指针内部结构 + 倍增扩容"的最小 vector(这里只支持 int,为了讲原理)
namespace test {
class vector {
public:
    // 无参构造:三个指针全置空
    vector() : _start(nullptr), _finish(nullptr), _end_of_storage(nullptr) {}
 
    // 尾插
    void push_back(int val)
    {
        if (_finish == _end_of_storage)      // 没有多余空间了(size == capacity),需要扩容
        {
            // 新容量:从 0 起步时给 1,否则翻倍(g++ 风格)
            size_t newCap = capacity() == 0 ? 1 : capacity() * 2;
            reserve(newCap);                 // 兑换一块更大的空间并搬过去
        }
        *_finish = val;                      // 在新空间末尾写入值
        ++_finish;                           // 有效元素个数 +1(finish 后移)
    }
 
    // 预留容量:只改 capacity,不碰 size
    void reserve(size_t n)
    {
        if (n > capacity())                  // 目标比当前容量大才需要换空间
        {
            int* newBlock = new int[n];      // 申请新空间
            size_t oldSize = size();         // 记下当前元素个数
            for (size_t i = 0; i < oldSize; ++i)
                newBlock[i] = _start[i];     // 深拷贝:逐个搬运旧元素(此处是逐值拷贝)
            delete[] _start;                 // 释放旧空间
            _start = newBlock;               // 三个指针全部对上新空间
            _finish = _start + oldSize;      // 重要:finish 也要跟着搬到新块,别还指旧块
            _end_of_storage = _start + n;
        }
    }
 
    size_t size()     const { return _finish - _start; }       // 有效元素个数
    size_t capacity() const { return _end_of_storage - _start; } // 容量
    int&   operator[](size_t i) { return _start[i]; }            // O(1) 随机访问
    bool   empty()    const { return _start == _finish; }
 
private:
    int* _start;             // 起始地址
    int* _finish;            // 末元素后一个位置 => size
    int* _end_of_storage;    // 容量末尾 => capacity
};
} // namespace test
 
int main()
{
    test::vector v;                          // 一个空的简易 vector
    for (int i = 0; i < 5; ++i)
        v.push_back(i * 10);                 // 依次尾插
    for (size_t i = 0; i < v.size(); ++i)
        cout << v[i] << " ";                 // 0 10 20 30 40
    cout << endl;
    cout << "size = " << v.size()            // 5
         << ", capacity = " << v.capacity()  // 8(1->2->4->8)
         << endl;
    return 0;
}

上面这个简化版是为讲清原理而写的(真实 vector 是模板类,支持任意类型、有完整的构造/析构/拷贝/移动语义),但你注意观察它多么简洁——凭这三个指针,size、capacity、随机访问、扩容全都自然浮现。更重要的是,你把 newBlock[i] = _start[i] 这句"逐个拷贝"和上一节讲的"深拷贝/浅拷贝"串起来,就能彻底理解:为什么手写 vector 不能用 memcpy,为什么 ++ 扩容是"很少发生但一次很贵",底层的一切都指向同一句话——它是一块连续内存 + 三个指针,外加一套倍增扩容策略。

一个更真实的模板版手写 vector(含迭代器、拷贝、析构)

上面的版本为了讲透三指针,故意写成只支持 int。这里再给你一个更接近真实 std::vector 的模板版骨架,把"迭代器就是指针"、"深拷贝"、"析构释放"串起来,让你看到真实 vector 的筋骨(为可读性省略了移动语义/分配器等细节,但整体结构真实):

#include <iostream>
using namespace std;
 
template <typename T>
class myvector {
public:
    using iterator = T*;                          // 迭代器就是朴实无华的 T*
 
    myvector() : _start(nullptr), _finish(nullptr), _end(nullptr) {}
 
    // 拷贝构造:深拷贝 —— 不共享内存,各管各的
    myvector(const myvector& other)
        : _start(nullptr), _finish(nullptr), _end(nullptr)
    {
        reserve(other.size());                    // 先预备容量
        for (size_t i = 0; i < other.size(); ++i)
            push_back(other[i]);                  // 逐个深拷进去(用 T 的拷贝构造)
    }
 
    ~myvector() { delete[] _start; }              // 归还整块堆内存
 
    void push_back(const T& val)
    {
        if (_finish == _end)
        {
            size_t newCap = capacity() == 0 ? 1 : capacity() * 2;
            reserve(newCap);
        }
        *_finish = val;                           // 对模板 T 来说这是"赋值"
        ++_finish;
    }
 
    // 手动管理 T 的构造(真实 std::vector 用 placement new 就地构造,这里简化了)
    void reserve(size_t n)
    {
        if (n > capacity())
        {
            T* nb = new T[n];
            size_t oldSize = size();
            for (size_t i = 0; i < oldSize; ++i)
                nb[i] = _start[i];                // 逐个深拷贝(走 T 的拷贝构造语义)
            delete[] _start;                      // 释放旧内存
            _start = nb;
            _finish = _start + oldSize;
            _end = _start + n;
        }
    }
 
    iterator begin() { return _start; }
    iterator end()   { return _finish; }
 
    size_t size()     const { return _finish - _start; }
    size_t capacity() const { return _end - _start; }
    T& operator[](size_t i) { return _start[i]; }
 
private:
    T* _start;     // 首元素
    T* _finish;    // 末元素后一个位置 => size
    T* _end;       // 容量末尾 => capacity
};
 
struct Point {
    int x, y;
    Point(int a = 0, int b = 0) : x(a), y(b) {}
};
 
int main()
{
    myvector<int> vi;
    for (int i = 0; i < 5; ++i) vi.push_back(i);
    for (myvector<int>::iterator it = vi.begin(); it != vi.end(); ++it)
        cout << *it << " ";
    cout << endl;                                  // 0 1 2 3 4
 
    myvector<Point> vp;
    for (int i = 0; i < 3; ++i) vp.push_back(Point(i, i * 10));
    cout << "second point = ("
         << vp[1].x << ", " << vp[1].y << ")" << endl;   // (1, 10)
 
    myvector<int> vcopy(vi);                       // 深拷贝
    vcopy[0] = 999;
    cout << "vi[0]=" << vi[0] << " vcopy[0]=" << vcopy[0] << endl; // 0 vs 999(互不影响,深拷贝证据)
    return 0;
}

运行会发现:vcopy[0] = 999 不会动到 vi[0]——这就是深拷贝的直接证据。这也勾勒出为什么真实 std::vector 能做到"改一个容器不影响另一个"。

memcpy 版扩容为什么是地狱(完整演示)

为了让你彻底看清 memcpy 扩容 + 资源管理类的恐怖,这里用一个"故意用 memcpy 搬家"的最小 vector 去装自管理资源类,直观演示行为异常(为不崩溃起见,简化资源类并观察共享指针个数):

#include <iostream>
#include <cstring>
using namespace std;
 
// 用引用计数证明"是不是同一块内存"
struct Shared {
    static int live;      // 活着多少份
    int* data;
    Shared() : data(new int(42)) { ++live; }
    ~Shared() { delete data; --live; }
    // 故意不写拷贝构造 => 浅拷贝(复制指针)
};
int Shared::live = 0;
 
// 用 memcpy 扩容的"恶意简化 vector"
template <typename T>
class badvector {
public:
    badvector() : p(nullptr), n(0), cap(0) {}
    void grow() {
        T* np = new T[cap * 2 + 1];
        if (p) memcpy(np, p, n * sizeof(T));   // 浅拷贝:只拷指针值!
        delete[] p;                            // 旧块的元素析构会释放同一批堆内存
        p = np; cap = cap * 2 + 1;
    }
    void push_back(const T& v) {
        if (n == cap) grow();
        p[n++] = v;
    }
private:
    T* p; size_t n, cap;
};
 
int main()
{
    badvector<Shared> bv;
    bv.push_back(Shared());
    cout << "live = " << Shared::live << endl;   // 由于浅拷贝+旧析构,live 常为负数/错乱
    return 0;
}

真正跑这个程序你会得到荒谬甚至崩溃的结果。核心一句话:资源管理类的拷贝必须深拷贝,绝不能 memcpy。这也是为什么真实 std::vector 用元素自己的拷贝/移动构造搬迁。

动态二维数组:vector 套 vector

既然 vector 是模板,那元素也可以是 vector 本身——vector<vector<int>> 就构成了动态二维数组。用它解决经典的"杨辉三角"问题,最能体会 resize + operator[] 的威力:每一行的行数和内容都动态决定,天然适合 vector:

#include <iostream>
#include <vector>
using namespace std;
 
// 返回杨辉三角前 n 行
vector<vector<int>> generate(int n)
{
    // 准备工作:vv 有 n 行(每行先不填),类型是 vector<vector<int>>
    vector<vector<int>> vv(n);
 
    // 第一步:把每一行 resize 成"行号+1"个数,全部初始化为 1
    for (int i = 0; i < n; ++i)
        vv[i].resize(i + 1, 1);      // 第 i 行有 i+1 个 1
 
    // 第二步:从第 2 行起,中间元素 = 上一行对应位置两个数之和
    for (int i = 2; i < n; ++i)      // 从第 2 行开始(0 起算)
    {
        for (int j = 1; j < i; ++j)
        {
            // 第 i 行第 j 列 = 上一行的 j-1 列 + j 列
            vv[i][j] = vv[i - 1][j - 1] + vv[i - 1][j];
        }
    }
    return vv;                       // 返回整个二维结构
}
 
int main()
{
    auto tri = generate(5);
    for (const auto& row : tri)      // 遍历每一行:row 是 vector<int>
    {
        for (int val : row)          // 遍历行内元素
            cout << val << " ";
        cout << endl;
    }
    /*
     输出:
     1
     1 1
     1 2 1
     1 3 3 1
     1 4 6 4 1
    */
    return 0;
}

细看你会发现,vector<vector<int>> 里每一行都是一个独立的 vector<int>,可以各有各的长度——这在定长二维数组里根本做不到。你在 OJ(在线判题平台)上会经常撞见这种"vector 套 vector"的写法,它对应的就是"长度不定的二维表"这类问题。理解了单层 vector 的扩容和随机访问,二维的本质就一句话:外层是一个 vector,它的每个元素又是一个还能自动扩容的小 vector。

二维 vector 的更多真相

  • 每行是独立内存:vector<vector<int>> 的每一行都是各自堆上的一块连续内存,行与行之间不连续。所以"整个二维数组是一整块连续内存"的直觉是错的——只有"每一行内部连续"成立。这也导致对 vector<vector<int>> 整体做 memcpy 是错上加错(浅拷贝 + 内存不连续)。
  • vector<vector<int>> vv(n) 只做了 n 个空行:每行是空的 vector<int>,里面的元素个数是 0,你得再 vv[i].resize(...) 或 push_back。
  • 两种常用构造姿势:① vector<vector<int>> vv(n, vector<int>(m, 0)) 得到 n 行 m 列、全 0 的"矩形"矩阵(各行长固定),适合棋盘、图像这种规则二维数据;② 先 vector<vector<int>> vv(n) 再逐行 resize 成不同长度,适合杨辉三角这类"锯齿"二维结构。
  • 构造一个矩形二维数组:
#include <iostream>
#include <vector>
using namespace std;
 
int main()
{
    int rows = 3, cols = 4;
    // 3 行 4 列,全部初始化为 0
    vector<vector<int>> grid(rows, vector<int>(cols, 0));
    grid[1][2] = 7;
    for (const auto& row : grid) {
        for (int x : row) cout << x << " ";
        cout << endl;
    }
    return 0;
}
  • 性能提示:在外层 push_back 一个整行之前,可以先 row.reserve(预计列数) 再填充,避免每行反复扩容。如果矩阵行数列数都很大又讲究性能,可考虑"一维扁平 vector + 手动行列换算"(index = r * cols + c),这在内存连续性和缓存命中上优于 vector<vector<>>。

顺带认清 vector -- 唯一被特化的"不老实"的 vector

一个著名大坑:vector<bool> 在标准库中被专门特化,其内部不是存 1 个字节的 bool,而是把 8 个 bool 压缩进 1 个字节(按位存储),以省内存。代价是:它的 operator[] 返回的是一个位引用代理对象(bit reference proxy),而不是真正的 bool&。

#include <iostream>
#include <vector>
using namespace std;
 
int main()
{
    vector<bool> vb(8, false);
    vb[0] = true;                  // 能赋值:代理对象支持 = 与 bool 互转
    cout << "vb[0] = " << vb[0]    // 输出 1
         << ",注意 vector<bool> 按位压缩存储,每个元素并不占整个 sizeof(bool) 字节" << endl;
 
    // 正因为是代理对象,下面这种"取地址"是编译不过的:
    // bool* p = &vb[0];           // error:不能对代理取 bool*
    // bool& r = vb[0];            // error:不能用 bool& 绑定代理
 
    // 若需要普通的 bool 数组,改用它:
    vector<unsigned char> vc(8);   // 每个元素真占 1 字节
    return 0;
}

结论:如果你需要 vector<char>/vector<unsigned char> 那种"一个元素一个真字节"的语义(比如要和 C 数组无缝对接),就别用 vector<bool>,改用一个字节的类型。而 &vb[0] 取不到 bool* 这点,很容易让新手困惑,这里主动给你排掉这个雷。

小结这一节:vector 的"内脏"一句话

一块连续堆内存 + 三个指针(_start/_finish/_end_of_storage)+ 一组完整的内存生命周期管理(构造/析构/深拷贝/移动/倍增扩容)。理解了这三根柱子,前面所有"size/capacity/扩容/随机访问/迭代器失效"就汇成了一条线。

一串可持续思考的问题

学完这一篇,试着用刚才讲的知识独立回答下面几个问题,检验一下你消化了多少:

  1. 为什么说 vector 的 push_back 均摊是 O(1),但偶发某一次会特别慢?那一次慢发生在什么条件触达的时候?(提示:size == capacity 的那一刻)
  2. reserve(100) 和 resize(100) 之后,size() 和 capacity() 各是多少?此时 v[50] 能不能安全访问?(提示:一个改变了 capacity 但没建元素,一个真的建了 100 个元素)
  3. 你的编译器上跑一遍扩容那段的代码,capacity 的序列是几倍增长?VS 和 g++ 的差异说明了什么?
  4. 扩容之后为什么旧迭代器失效?erase 中间的元素为什么也会让迭代器失效?两种失效的本质区别是什么?(一个是"底层搬家全废",一个是"元素挪位位置及以后废")
  5. 如果让你手写 vector 并用 memcpy 搬家,存放 string 这类元素时会出什么问题?这里牵扯到深拷贝和浅拷贝的哪个道理?(共享指针 + double-free)
  6. 为什么 vector 扩容要倍增而不是每次加固定数量?如果把扩容从 2 倍改成每次 +1000,push_back 的均摊复杂度会怎样?(提示:从 O(n) 总量变成 O(n²) 总量)
  7. 一份已经耗尽容量、size==capacity 的 vector,继续 push_back 会怎样?反过来说,容量富余时 push_back 会不会让已有迭代器失效?(除了 end())?
  8. v[i] 和 v.at(i) 越界时表现有何不同?为什么标准库要同时提供两个?(性能 vs 安全检查)

参考答案与详解

1. 为什么 push_back 均摊是 O(1),但偶发某一次特别慢?

push_back 平时只是把新元素写到已有空余位置上,O(1)。但当数组的 size == capacity(容量恰好耗尽)那一刻,push_back 必须触发一次扩容:申请一块更大的新内存,把旧元素整体搬过去,再释放旧内存——这一趟要 O(n),其中 n 是当前元素个数。所以"某一次特别慢"就发生在 size == capacity、必须扩容的那一下。而因为容量是按倍增来的(每次都把 capacity 翻倍),这一下 O(n) 的开销被分摊到后面 n 次便宜的 push_back 上,于是均摊下来仍是 O(1)——如同盖房子的人隔很久才大动一次土、把花的钱平均到每一天就不贵了。

2. reserve(100) 和 resize(100) 之后,size() 和 capacity() 各是多少?v[50] 能安全访问吗?

  • reserve(100):只预留容量、不创建任何元素。假设原 size 是 6,调用后 size() 仍是 6,capacity() 变成 100。此时数组里只有 6 个元素,v[50] 是越界访问(那 50 个格子里根本没有构造出元素),是未定义行为——VS 下可能直接崩,g++ 下可能读到垃圾。
  • resize(100):真的构造出 100 个元素。调用后 size() 是 100,capacity() 至少是 100(具体看实现,可能超过 100)。此时元素确实存在,v[50] 可以安全访问。

一句话记法:reserve 只是"圈地"(改 capacity 不动 size),resize 是"建房子"(真正改 size)。v[50] 能不能碰,看的是 size() 而不是 capacity()。

3. 扩容的 capacity 序列是几倍增长?VS 与 g++ 的差异说明了什么?

在你的 VS 上跑扩容测试,capacity 序列大致是 1、2、3、4、6、9、13、19、28、42、63、94、141……(约 1.5 倍增长,必要时向上取整);在 Linux/g++(SGI/libstdc++)上是 1、2、4、8、16、32、64、128……(2 倍增长)。这个差异清晰说明:扩容系数是 STL 的"实现细节"而非标准强制规定——标准只要求"容量增长必须保证 push_back 均摊 O(1)"。所以以后再看见"扩容是几倍"的题,别直接回答 2 倍,正确的表述是"不同实现各不相同,常见有 1.5 与 2 倍"。

4. 扩容后旧迭代器为什么失效?erase 中间元素为什么也让迭代器失效?两者的本质区别?

  • 扩容后失效:扩容要"换新房"——申请全新内存、把元素搬走后释放旧内存。你手里的迭代器本质是一个"在旧内存里的指针",旧内存已被释放,用它就是访问已释放的空间,所有旧迭代器全部失效。
  • erase 中间元素失效:erase 不换底层空间,只是把被删元素后面的元素整体前移一位来填坑。于是"被删位置及其之后"的元素都换了新的内存位置,指向它们的旧迭代器自然失效了。

两者本质区别一句话:扩容 = "底层整体搬家",所有迭代器全废;erase = "底层没动、元素原地挪位",只废止被删位置及其之后的迭代器。所以后者理论上还有救(用 erase 返回的"下一位置"迭代器接力就行),前者凡是存过旧迭代器都得重新赋值。

5. 手写 vector 用 memcpy 搬家、存 string 历来会产生什么问题?

memcpy 是把一块内存按字节原样复制(二进制浅拷贝)。当元素是 string 这类自管理资源的类型时,它只把"内部的指针值"复制过去了,而不会真正复制字符串内容。于是新老 vector 里的元素(string)共享同一个底层字符缓冲区、引用计数却没跟上——两个对象的析构会对同一块资源各自释放一次,造成 double-free / 二重释放;两个对象还会互相影响改动。这正是"浅拷贝 vs 深拷贝"的道理:memcpy 是浅拷贝,标准库 vector 用的是元素的**拷贝构造/移动构造(深拷贝)**来搬家。结论:只要元素涉及资源管理,扩容搬家绝不能 memcpy,必须逐个走深拷贝。

6. 为什么扩容要倍增而不是每次加固定数量?若从 2 倍改成每次 +1000,均摊复杂度会怎样?

倍增的目标是让"搬家的总成本"加起来还是不相上下的:元素每搬家一次,总量翻倍后的新容器都能容纳大约一半以上新元素再翻倍,因此历史上所有元素的总搬移次数之和是 O(n),摊到 n 次 push_back 上每次 O(1)。

如果把扩容改成"每次容量 +1000",那么容量序列是 1000,2000,3000,…,每次 push 到恰好满员时都要把此前所有元素全部搬一次,搬移总次数是 1000 + 2000 + … ≈ n²/2000,即 O(n²),摊到 n 次 push_back 上,每次均摊变成 O(n)——从"每次接近常数"退化成了"平均要拷贝 n 个元素"。所以倍增不是随便选的,它正是让均摊保持 O(1) 的关键设计。

7. 容量耗尽(size==capacity)时 push_back 会怎样?容量富余时 push_back 会让已有迭代器失效吗?

  • size == capacity 时 push_back:会触发扩容搬家,底层换新内存,所有既有迭代器(含 begin()、end())全部失效。
  • 容量富余(size < capacity)时 push_back:不搬家,只是在已有连续空间的末尾写入一个新元素。已有元素的地址没变,所以指向它们的迭代器仍然有效;唯一变的是 end()——末尾又多了个元素,老 end() 指的位子现在是一个元素的位置了,所以**end() 失效**。这正是课件那张"失效一览表"里 push_back(未扩容)那行"仅 end() 失效"的由来。

8. v[i] 与 v.at(i) 越界时表现有何不同?为何两个都提供?

  • v[i] 越界:不做任何检查,直接按 *(data()+i) 去访问那块内存,属于未定义行为——VS 下大多当场崩,g++ 下可能"凑巧读到垃圾",不可依赖。
  • v.at(i) 越界:会做运行时越界检查,一旦越界就抛出 std::out_of_range 异常,可以用 try/catch 捕获、优雅处理。

标准库里两个都要,是"性能 vs 安全"的两难取舍:v[i] 最快(零检查),适合你确定不会越界的遍历/热循环;v.at(i) 稍慢但安全,适合边界不确定、值得兜底的场景。据使用习惯,绝大多数确定边界的代码用 v[i],只有以防万一才上 at。

从"为什么需要它"到"它内部怎么长大",从"size/capacity 的一字之差"到"扩容的均摊魔法",从"O(1) 随机访问的奥秘"再到"迭代器失效的雷区",最后亲手剥开三指针的内核看清它的骨骼——到这里,std::vector 你已经"能用"而且"明理"了。它其实没有一丝魔法:不过是一块连续的堆内存、三个指针、一套备好就翻新的扩容策略,再加一堆在那个年代被认为堪称奢侈的贴心接口。理解了这些,再去碰 list、set、map 这些兄弟容器,你会发现它们的顶层思维是相通的:容器都是"数据结构 + 内存管理"的封装,而迭代器是通向他们家的一把通用钥匙。下一段路,就从认识 vector 的邻居开始吧。