【面试】手撕 Vector

6 阅读8分钟

一、vector 是什么?

vector 本质上是一个:

支持动态扩容的连续内存数组容器。

它和普通数组最大的区别,不是“能下标访问”,而是:

  1. 连续存储
  2. 支持尾部高效插入删除
  3. 空间不够时自动扩容
  4. 自己管理对象的构造、析构、搬迁和释放

二、vector 的核心特征

手撕 vector 时,脑子里一定先有这几个标签。

1. 连续内存

vector 的元素在内存里是连续排布的,所以它支持:

  • operator[]
  • data()
  • 指针偏移访问
  • 很好的缓存局部性

这也是它和链表最大的区别。


2. size 和 capacity 分离

vector 一般至少维护两个概念:

  • size:当前已经构造好的元素个数
  • capacity:当前这块内存最多能容纳多少个元素

也就是说:

  • size 管的是“用了多少”
  • capacity 管的是“总共能放多少”

比如:

  • size = 3
  • capacity = 8

说明这块内存能放 8 个元素,但目前只真正构造了 3 个。


3. 尾插尾删高效

vector 最擅长的操作就是:

  • push_back
  • pop_back

因为尾部操作一般不需要整体搬移元素。

这也是面试里最常让你重点实现的部分。


4. 扩容会导致数据搬迁

size == capacity 时,再 push_back 就必须扩容。

扩容时一般要做这些事:

  1. 申请一块更大的原始内存
  2. 把旧元素逐个搬到新内存
  3. 销毁旧内存中的对象
  4. 释放旧内存
  5. 更新指针和容量

这里就会牵涉到:

  • 拷贝构造 / 移动构造
  • 异常安全
  • 回滚机制

这正是手撕 vector 最容易写错的地方。


5. 分离“内存分配”和“对象构造”

这一点非常关键。

vector 管理的不是简单字节,而是 对象

所以要分清:

内存分配

只拿到一块“能放对象的裸内存”,但对象还没构造出来。

比如:

::operator new(sizeof(T) * n);

对象构造

在某块已经分配好的内存上,真正构造对象。

比如:

new (address) T(args...);

对象析构

销毁对象本身,但不释放那块原始内存。

比如:

ptr->~T();

内存释放

把整块原始内存还给系统。

比如:

::operator delete(ptr);

所以你要始终记住一句话:

分配内存 != 构造对象
析构对象 != 释放内存


三、一个 vector 通常包含什么?

最小实现里,通常就这几个成员。

T* data_;
size_t size_;
size_t capacity_;

它们分别表示:

  • data_:指向连续存储区的起始地址
  • size_:当前有效元素个数
  • capacity_:当前容量

这三个字段基本就是 vector 的核心骨架。


四、手撕 vector 需要实现哪些基础功能?

如果是面试手撕,我建议分成 基础版进阶版 来记。


五、基础版 vector:先实现什么?

基础版的目标不是“完全对标 STL”,而是:

先把容器最核心的行为跑通。

基础版应实现的功能

1. 构造与析构

至少要有:

  • 默认构造
  • 析构函数

默认构造负责把容器初始化为空。

析构函数负责:

  1. 调用所有已构造元素的析构函数
  2. 释放整块内存

2. 基本访问接口

至少要有:

  • size()
  • capacity()
  • empty()
  • operator[]
  • back()

这些接口让这个容器先“像个容器”。


3. push_back

这是最核心的方法之一。

要处理两种情况:

情况 1:容量够

直接在尾部构造新元素。

情况 2:容量不够

先扩容,再把元素放进去。

这一步本质考的是你会不会:

  • 判断扩容
  • 搬迁旧元素
  • 构造新元素
  • 更新 size_

4. pop_back

这是另一个核心方法。

逻辑相对简单:

  1. 判断容器是否为空
  2. 对最后一个元素调用析构
  3. size_--

注意:

pop_back 一般 不缩容

因为频繁缩容会让性能很差。


5. reserve

reserve 的作用是:

提前申请足够大的容量,避免频繁扩容。

它只改变 capacity,不改变 size

这个函数通常是扩容逻辑的底层支撑。


6. clear

作用:

  • 析构所有已构造元素
  • size_ 置为 0
  • 一般不释放容量

这样后续还能继续复用内存。


六、基础版 vector 的核心方法有哪些?

如果让我列一个“最小可用版”的方法清单,我会写成这样:

Vector();
~Vector();

size_t size() const;
size_t capacity() const;
bool empty() const;

T& operator[](size_t index);
const T& operator[](size_t index) const;

T& back();
const T& back() const;

void push_back(const T& value);
void push_back(T&& value);
void pop_back();

void reserve(size_t new_capacity);
void clear();

这套接口已经足够支撑一个基础版手撕 vector


七、为什么 push_back 和 pop_back 是重点?

因为这两个函数几乎把 vector 的本质全覆盖了。


1. push_back 考什么?

push_back 表面是在尾部插入一个元素,实际上考的是:

内存管理

  • 容量够不够
  • 什么时候扩容
  • 扩容后旧内存怎么处理

对象生命周期

  • 新元素如何构造
  • 旧元素如何搬迁
  • 旧对象何时析构

移动语义

  • 扩容时优先移动还是拷贝
  • 插入右值时是否用移动构造

异常安全

  • 元素搬迁过程中抛异常怎么办
  • 已构造的新对象如何回滚
  • 旧数据能不能保持不变

所以面试官让你写 push_back,本质是在看你会不会写一个“真正的容器”。


2. pop_back 考什么?

pop_back 看起来简单,但它考的是你有没有正确理解:

  • sizecapacity 的区别
  • 析构对象和释放内存的区别
  • 容器删除元素时只需要销毁对象,不一定要回收整块空间

它主要是在考你的基本功。


八、push_back 的标准思路

你可以把它记成一个固定模板。

情况一:还有空位

如果 size_ < capacity_

  1. data_ + size_ 的位置用 placement new 构造对象
  2. size_++

例如:

new (data_ + size_) T(value);
++size_;

情况二:没有空位,需要扩容

如果 size_ == capacity_

第一步:计算新容量

一般是:

  • 初始从 1 开始
  • 后续按 2 倍扩容

例如:

new_capacity = (capacity_ == 0 ? 1 : capacity_ * 2);

第二步:申请新内存

这里只分配原始内存,不构造对象。

第三步:把旧元素搬到新内存

通常逐个构造:

  • 优先移动构造
  • 不行再拷贝构造

第四步:在新内存尾部构造新元素

第五步:销毁旧元素并释放旧内存

第六步:更新 data_ / capacity_ / size_


九、pop_back 的标准思路

pop_back 的逻辑可以背下来:

if (size_ == 0) return;
data_[size_ - 1].~T();
--size_;

注意这里的顺序一般是:

  1. 找到最后一个元素
  2. 调析构
  3. size 减一

十、手撕 vector 时必须理解的几个底层点


1. 为什么不能直接用 new T[n] 代替所有逻辑?

因为 vector 需要的是:

  • 分配一块原始内存
  • 按需逐个构造元素
  • 按需逐个析构元素

new T[n] 会:

  • 一次性构造 n 个对象

这就不符合 vector 的语义,因为:

capacity_ 只是“能放多少”,不是“已经构造了多少”。

所以 vector 更适合用:

  • ::operator new 分配裸内存
  • placement new 按需构造对象

2. 为什么 pop_back 不释放内存?

因为 vector 的设计目标是:

减少频繁分配和释放带来的开销。

如果每删一个元素就缩容,那插入/删除频繁时性能会非常差。

所以标准思路是:

  • pop_back 只析构对象
  • 不动 capacity_

3. 为什么扩容通常按 2 倍增长?

因为这样可以把均摊复杂度控制在较低水平。

虽然某一次扩容很贵,但整体看:

push_back 的均摊时间复杂度是 O(1)


十二、vector 手撕时最容易写错的点


1. 把“分配内存”和“构造对象”混了

这是最常见错误。

记住:

  • operator new:只分配内存
  • placement new:真正构造对象

2. 忘记析构旧元素

扩容后旧内存不能直接 delete 就完了。

如果旧内存中已经有对象,必须先逐个析构,再释放原始内存。


3. size_ 和 capacity_ 更新时机写错

尤其是扩容时,顺序一乱就容易出 bug。


4. pop_back 只减 size,不析构对象

这对基础类型可能看不出来,但对带资源的对象就会出问题。


5. 扩容时直接 memcpy

这对非平凡类型是错的。

对象类型可能有:

  • 指针成员
  • 文件句柄
  • 智能指针
  • 自定义析构逻辑

这种类型必须走:

  • 移动构造
  • 或拷贝构造

不能粗暴字节拷贝。


6. 扩容异常时没有回滚

这是进阶题里很关键的点。


十三、面试里你可以怎么概括一个手撕 vector

你可以这样说:

vector 本质是一个动态连续数组,核心成员一般是 data_size_capacity_。它的关键在于分离内存分配和对象构造,通过 operator new 获取原始内存,再用 placement new 按需构造元素。基础版至少要实现构造析构、访问接口、push_backpop_backreserveclear。其中 push_back 是重点,因为它会涉及扩容、元素搬迁、移动语义和异常安全;pop_back 则主要体现对象析构和容量管理的基本思想。`

如果想再多说一句加分,可以补:

进阶版还可以继续补拷贝控制、移动语义、emplace_backresize 和异常安全保证。


十四、一个适合复习的“最小实现清单”

你复习时可以直接记这个版本。

成员变量

T* data_;
size_t size_;
size_t capacity_;

基础接口

Vector();
~Vector();

size_t size() const;
size_t capacity() const;
bool empty() const;

T& operator[](size_t index);
const T& operator[](size_t index) const;

T& back();
const T& back() const;

void push_back(const T& value);
void push_back(T&& value);
void pop_back();

void reserve(size_t new_capacity);
void clear();