【C++ 面试真题】聊聊 C++ 的序列容器
序列容器是标准库的开胃菜,也是工程里用得最多的一族。背得出"vector 是动态数组"只是及格,真考你的是"string 怎么和 C 字符串打交道、vector 扩容为什么倍增、reserve 和 resize 差在哪、list 的插删 O(1) 有什么猫腻、deque 凭什么两端 O(1)、stack/queue 算不算容器"。本文按 string → vector → list → deque → 适配器的顺序,把底层和选型一次讲清。
一、开场:序列容器有哪些?
❓ 介绍一下标准库的序列容器?
✅ 标准库容器分四大类:序列容器 (按插入顺序排)、关联容器 (按 key 有序)、无序关联容器 (哈希)、容器适配器(包装改造)。序列家族一张表:
| 容器 | 底层 | 强项 | 弱项 |
|---|---|---|---|
| string | 字符数组 | 文本处理、C 互操作 | 同 vector |
| vector | 动态数组 | 随机访问 O(1) | 中间插删 O(n) |
array [C++11] |
定长数组 | 零堆分配 | 长度编译期定死 |
| deque | 分段连续 | 两端插删 O(1) | 中间插删 O(n) |
| list | 双向链表 | 持迭代器插删 O(1) | 随机访问 O(n) |
forward_list [C++11] |
单向链表 | 比 list 省一个指针 | 不能回退、无 size |
| stack/queue | 适配器 | 限定 LIFO/FIFO 语义 | 接口受限 |
回答思路:按"连续内存(string/vector/array)→ 分段(deque)→ 链表(list/forward_list)→ 适配器"报一遍,每类点出底层和强项------面试官就知道你心里有张容器地图,接下来多半会挑一个深入底层。
怎么选?按顺序问自己三个问题:
- 两端频繁进出? → deque(滑窗、任务队列);
- 已持有迭代器、频繁中间插删、元素拷贝贵? → list;
- 其余一律 vector------哪怕是中间插删。
cpp
// 大多数场景的正确答案
vector<Widget> widgets;
// 中间插删 O(n) 但搬的是字节,
// 连续内存下常比 list 跳节点更快
🎯 反直觉但重要 :小元素场景,vector 的"O(n) 搬移"经常跑赢 list 的"O(1) 链表跳转"------缓存一次搬几十个字节,比追一次指针便宜。别只看复杂度选容器,要看访存模式。这也是"默认 vector"成为共识的原因。
二、string:与 C 字符串的互操作
❓ string 和 C 字符串(const char*)怎么互操作?
✅ 两个方向,各有一手:
C 串 → string :构造和赋值直接吃,遇 \0 停止:
cpp
string s = "hello"; // 直接构造
s += " world"; // 追加
string → C 串 :c_str() / data() 返回 const char*,保证以 \0 结尾,可以喂给任何 C API:
cpp
printf("%s", s.c_str());
FILE* f = fopen(s.c_str(), "r");
⚠️ 三个坑:
① 内嵌
\0会截断 ------string s("a\0b")长度只有 1,要指定长度string("a\0b", 3);② c_str() 的指针会失效 ------string 一旦扩容、修改、析构,旧指针就悬空。别存下来复用,每次要用现取;
③ string_view 不保证
\0结尾[C++17]------sv.data()只是无主视图,喂 C API 前老老实实调c_str()。
❓ string s = NULL; 会发生什么?
✅ 编译能过,运行崩 。NULL(或 0)隐式转成 const char* 空指针,匹配 string(const char*) 构造------它从指针逐字符读到 \0,对空指针就是解引用 0 地址,未定义行为,典型表现是段错误。
cpp
string s1 = NULL; // 编译过,运行崩
string s2 = 0; // 同样崩
// 想要空字符串:
string s3; // ✅ 默认就是空
💡 根因:该构造的契约是"指针指向合法
\0结尾串",传 nullptr 违反前置条件,直接 UB。[C++23]堵了半个口子:string s = nullptr;编译报错(构造被= delete);NULL展开为 0 时仍走老路。实战守则:变量可能为空就先判空------
string s = p ? p : "";,别让空指针有机会溜进构造函数。
❓ string 和 vector<char> 底层一样吗?
✅ 骨架都是连续字符数组,但 string 多了C 字符串互操作语义 (\0 结尾)和 SSO(小字符串优化):短串直接存对象内部、不碰堆------而短串是绝对主流。
💡 加分点 :SSO 是实现细节,不是标准规定------不同标准库的容量和对象大小都不一样:
| 标准库 | sizeof(string) | SSO 容量 |
|---|---|---|
| libstdc++(GCC) | 32 字节 | 15 字符 |
| libc++(Clang) | 24 字节 | 22 字符 |
| MSVC STL | 32 字节 | 15 字符 |
(64 位平台常见值,以具体实现为准。)
面试聊 SSO 的正确姿势:短串存对象内、免堆分配;阈值因实现而异------知道平台差异,比背一个数字更加分。
三、vector:连续内存与高频考点
❓ vector 的底层结构是什么?
✅ 一块连续内存 + 三根指针(示意):
cpp
// vector 内部(示意)
T* start; // 数据起点
T* finish; // 已用末尾
T* end_stg; // 存储末尾
// size = finish - start
// capacity = end_stg - start
size 是已有元素数,capacity 是已开好的总容量。push_back 时若两者相等(满了),触发扩容:开一块更大的内存(约 2 倍或 1.5 倍,实现相关),把旧元素搬过去------C++11 后优先移动而非拷贝------再释放旧内存。
💡 为什么倍增? 摊还分析的经典结论:n 次
push_back的总搬运量是 O(n)(1+2+4+...+n),摊到每次是 O(1)。若每次只加固定 k 个,总代价 O(n²),push 越多越亏。
💡 搬运靠移动 :扩容搬旧元素优先用移动构造------前提是它标了noexcept,否则 vector 为保异常安全只能退回拷贝。这也是"移动构造要 noexcept"最直接的兑现场景。
❓ reserve 和 resize 有什么区别?迭代器什么时候失效?
✅ reserve 只开容量,resize 真造对象:
cpp
vector<int> v;
v.reserve(1000);
// capacity=1000,size=0
// 没有元素被构造
v.resize(1000);
// size=1000
// 1000 个元素被值初始化
迭代器失效(必考):
- 扩容 :旧内存整块释放,所有迭代器、指针、引用全部失效;
insert/erase:插删点之后的迭代器失效(元素被搬动)。
cpp
vector<int> v{1, 2, 3, 4};
auto it = v.begin();
v.push_back(5); // 可能扩容
// *it; // ❌ it 可能已悬空
⚠️ 典型翻车:循环里边
push_back边持旧迭代器,扩容即悬空。先reserve够大,或改用新迭代器。
边遍历边删 :erase 返回被删元素之后的新迭代器,接住它继续走:
cpp
// ✅ 正确:用 erase 的返回值
for (auto it = v.begin();
it != v.end(); )
if (*it % 2) it = v.erase(it);
else ++it;
// ❌ 错误:erase 后还 ++it,悬空
❓ 如何删除元素?如何释放内存?
✅ 删元素 :erase 删单个或区间、pop_back 尾删、clear 清空;按条件批量删交给 erase-remove 惯用法。释放内存是另一回事 ------clear 只析构元素,容量原封不动:
cpp
vector<int> v;
v.reserve(1000);
v.clear(); // size=0,capacity 仍 1000
// [C++11] 请求收缩(非强制)
v.shrink_to_fit();
// 经典强制释放:和空对象交换
vector<int>().swap(v);
💡 vector 的容量只增不减 (除非换内存),clear 也不缩------容量是"以防万一"的蓄水池,缩了下次又得重新分配。
shrink_to_fit是请求不是命令,实现可以拒绝;swap 技巧靠临时对象析构释放,是强制手段。
四、list:插删 O(1) 的"真相"
❓ list 随处插删 O(1),为什么还常被 vector 打败?
✅ 因为 O(1) 的前提是"已经拿到迭代器"------找位置本身就是 O(n)。
cpp
// 节点(示意)
struct Node {
Node* prev;
Node* next;
T data;
};
list 的真实代价账单:
- 没有 operator\[\],随机访问只能从头走,O(n);
- 每个元素多两个指针的内存开销;
- 节点散落堆上,缓存不友好------跳一个节点就是一次潜在 cache miss;
- 插删本身 O(1),但先得 O(n) 找到位置。
所以 list 只在"已持有迭代器 + 频繁中间插删 + 元素大拷贝贵"时才真正划算。
它也有独门绝技 splice------把另一个 list 的整段节点直接摘下来接上,不拷贝、不分配,真 O(1):
cpp
list<int> a{1, 2}, b{3, 4};
// 把 b 整段接到 a 末尾,O(1)
a.splice(a.end(), b);
五、deque:分段连续,两端 O(1)
❓ deque 凭什么头尾插删都是 O(1)?
✅ 因为它不是一整块内存,而是一串固定大小的缓冲区,由一个中控数组索引:
text
中控 map: [p0, p1, p2, ...]
↓ ↓ ↓
[buf][buf][buf] ...
- 头部插删:只动最前面的 buf;满了就在另一端挂一块新 buf,O(1);
- 随机访问:两级寻址(先算第几块 buf,再算块内偏移),仍是 O(1),但常数比 vector 大;
- 中间插删:仍要搬元素,O(n)。
💡 失效更宽松 :头尾插删只失效指向被插删元素的迭代器,其余不动(不像 vector 扩容全失效)。代价是结构复杂、缓存局部性不如 vector。
六、stack 与 queue:容器适配器
❓ stack、queue 算容器吗?
✅ 严格说是容器适配器------不自己管数据,包装一个底层容器、只暴露对应接口:
| 适配器 | 语义 | 默认底层 | 核心接口 |
|---|---|---|---|
| stack | LIFO | deque | push / pop / top |
| queue | FIFO | deque | push / pop / front / back |
| priority_queue | 大顶堆 | vector | push / pop / top |
cpp
stack<int> s; // 默认 deque 打底
s.push(1);
s.pop();
// 也可以指定底层容器
stack<int, vector<int>> s2;
💡 标准库自己面对"两头进出"(stack/queue)也是拿 deque 打底------deque 两端 O(1) 的直接背书。适配器的价值在于收窄接口、限定语义:stack 就不该有中间插入,类型系统直接禁掉,误用进不了编译。
七、面试高频追问
❓ Q1:vector 为什么倍增扩容,而不是每次加固定个数?
✅ 摊还分析。倍增时 n 次插入总搬运 O(n),摊还每次 O(1);固定增量则是 O(n²)。1.5 倍和 2 倍各有取舍(2 倍浪费更多),实现自选。
❓ Q2:vector 和 array 什么区别?
✅ array 是编译期定长的裸数组包装:无堆分配、无扩容、无容量概念。长度固定且小,用 array 更省。
❓ Q3:forward_list 和 list 呢?
✅ forward_list 是单向链表 :每个节点省一个 prev 指针,只能前向遍历,连 size() 都没有(为了不存计数)。是更省内存的极致版 list。
❓ Q4:push_back 什么时候使迭代器失效?
✅ 仅在触发扩容时------旧内存整块搬家,全部失效;容量足够时 push_back 不失效任何迭代器。
❓ Q5:deque 为什么随机访问也是 O(1)?
✅ 中控数组记录每块 buf 的地址,给下标 n 可直接算出"第几块 buf + 块内偏移",两级乘加寻址,常数大于 vector 但量级 O(1)。
❓ Q6:emplace_back 和 push_back 的区别?
✅ emplace_back(args...) 在尾部原地构造 ,省去临时对象和一次移动;push_back(x) 先有 x 再搬进去。能 emplace 就 emplace。
❓ Q7:vector<bool> 是怎么回事?
✅ 它是特化版本 :不真存 bool 数组,而是按位压缩 (1 个 bool 只占 1 bit)。代价是 operator[] 返回代理对象而非真正的引用,也没法取元素地址。要"正常的 bool 数组",用 vector<char> 或 bitset。
八、总结速查表
| 考点 | 一句话结论 |
|---|---|
| 选型三问 | 两端进出→deque;持迭代器插删→list;默认 vector |
| string ↔ C 串 | c_str() 现取现用,防悬空 |
| string SSO | 实现细节,阈值随标准库而异 |
| vector 底层 | 连续内存 + 三指针 |
| 扩容策略 | 倍增(2x/1.5x),摊还 O(1) |
| reserve/resize | 只开容量 / 真造对象 |
| vector 失效 | 扩容全失效;插删点之后失效 |
| clear 与内存 | 只清元素不清容量;swap 真释放 |
| list | 插删 O(1) 需先有迭代器;无 \[\] |
| deque | 分段连续 + 中控,两端 O(1) |
| stack/queue | 适配器,默认 deque 打底 |
一句话回顾
string 先过 C 互操作关 (c_str 现取现用、防
\0截断);vector 是连续数组 ,倍增扩容摊还 O(1),扩容即全失效、clear 不缩容;list 是链表 ,插删 O(1) 但先得 O(n) 找位置;deque 分段连续,两端 O(1);stack/queue 是适配器,deque 打底。默认 vector------小元素时它连中间插删都常赢 list。
如果您觉得本篇内容对你有帮助,欢迎点赞 👍、收藏 ⭐、转发 📢。下期我们继续标准库篇,聊关联容器------map/set 的红黑树和 unordered_map 的哈希表到底差在哪、怎么选,敬请关注 👋