从这一章开始,你不用再自己写容器了。 STL(Standard Template Library,标准模板库)= 容器(装东西的盒子)+ 算法(干活的手法)+ 迭代器(两者的通用插头)。本章第一站是序列容器——按"排队顺序"存东西的三个盒子:vector(能长大的数组)、deque(两头都能进出的队列)、list(双向链表)。它们解决的是同一个问题:数组一旦定长就改不了。三者的差别只在"你的数据主要在哪一端动"。
第十二章的模板是这一章的地基:vector<int> 就是 12.2 那个"类模板",<> 里的类型就是模板参数;sort、find 这些算法本身就是 12.1 的函数模板。
也就是说:上一章你学会了"照着图纸造工具箱",这一章你直接用别人造好的、工业级的那个工具箱。
往回看,也是把旧账清掉的一章:
- 7.1 一维数组 定长、越界无人管 → vector 能长大、at() 会检查;
- 7.3 字符数组 要 strcpy、要自己管 \0 → string 是一个真正的容器;
- 7.5.1 指针与一维数组 讲的"数组名退化成指针" → 就是把数组塞给算法的通道;
- 7.8 引用、10.2.2 复制构造函数、10.3 析构函数 → vector 内部替你管内存、替你深拷贝,三法则它早就写好了;
- 12.2.5 有界数组模板 是"固定长度"的容器 → 这一章的容器都能长大,正是 12.4 小结欠的第一笔账。
一、STL 到底是什么:三个零件
学了十二章,你已经会造类型了。现在的问题不是"能不能造",而是"要不要什么都自己造"。
答案是不用。 C++ 的标准里自带一套已经写好、已经调优、已经用了三十年的工具箱,叫 STL。它由三个零件拼成:
| 零件 | 是什么 | 例子 |
|---|---|---|
| 容器(container) | 装数据的盒子 | vector、deque、list、set、map |
| 迭代器(iterator) | 盒子和算法之间的通用插头 | v.begin()、v.end()、it++ |
| 算法(algorithm) | 在盒子上干活的手法 | sort、find、count、accumulate |
关键在"通用插头"这四个字。 如果没有迭代器,sort 就得给每种容器各写一份(sortVector、sortList、sortDeque……)。有了迭代器,sort 只写一份,谁插上都能用:
vector<int> v; sort(v.begin(), v.end()); // 同一个 sort
list<int> l; l.sort(); // ★ list 特殊,见 13.1.3
int a[5]; sort(a, a + 5); // ★ 连裸数组都能用
这就是第十二章模板的威力真正兑现的地方:
sort本身是个函数模板,vector本身是个类模板,两者通过"迭代器"这个约定接上——谁都不认识谁,却配合得很好。
二、什么叫"序列容器"
容器分两大类,本章只讲第一类:
| 大类 | 存东西的规矩 | 成员 |
|---|---|---|
| 序列容器(13.1) | 元素按位置排队,位置有意义,可以重复 | vector、deque、list |
| 关联式容器(13.2) | 元素按键排序,靠键找值,位置由键决定 | set、multiset、map、multimap |
"序列"的意思是:元素有先后、有次序。 v[0] 和 v[1] 是两个不同的位置,你按下标就能取——和 7.1 的数组一个思路,只是它会自己长大。
三个序列容器的差别,一句话就能说清(细节分别在 13.1.1~13.1.3):
| 容器 | 内存长什么样 | 一句话 | 快在哪 | 慢在哪 |
|---|---|---|---|---|
vector |
一整块连续内存 | 能自动扩容的数组 | 按下标取、尾部加、遍历 | 中间插入/删除要搬家 |
deque |
若干块连续内存拼起来 | 两头都能进出的队列 | 头尾插入删除都快 | 中间插入删除仍慢 |
list |
一个个节点用指针串起来 | 双向链表 | 任意位置插入删除都快 | 不能按下标取(没有 []) |
怎么记:不是"哪个更好",是"你的数据主要在哪一端动"。
- 一直往后加、经常按下标读 →
vector(默认就用它,90% 的场合够了) - 两头都要进出(像排队买票,两头都能排) →
deque - 不停在中间插/删(像通讯录随时增删联系人) →
list
三、动手 Demo:同一个任务,三种盒子
我们做一个"记录一天的停车进出"的小程序——分别用三个容器各做一遍,看看代码差别在哪。
#include <iostream>
#include <vector>
#include <deque>
#include <list>
#include <string>
using namespace std;
int main() {
// ① vector:只在尾部加,按顺序读
vector<string> v;
v.push_back("鄂A12345"); // ★ 尾部加
v.push_back("鄂B67890");
v.push_back("鄂C11111");
cout << "vector 第 2 辆:" << v[1] << endl; // ★ 能按 [] 取
cout << "vector 共 " << v.size() << " 辆" << endl;
// ② deque:两头都能加(前门进的车、后门进的车)
deque<string> d;
d.push_back("后门来的");
d.push_front("前门来的"); // ★ 头部加(vector 干这个很慢)
d.push_front("又一辆前门");
cout << "deque 顺序:";
for (size_t i = 0; i < d.size(); i++) cout << d[i] << " "; // ★ deque 也有 []
cout << endl;
// ③ list:要在中间插队,只能靠迭代器
list<string> l;
l.push_back("第三");
l.push_back("第四");
list<string>::iterator it = l.begin();
l.insert(it, "第一"); // ★ 在开头插入
cout << "list 顺序:";
for (it = l.begin(); it != l.end(); ++it) cout << *it << " "; // ★ 遍历必须用迭代器
cout << endl;
// cout << l[0] << endl; // ❌ 错:list 没有 operator[]
return 0;
}
看到什么算成功:
vector 第 2 辆:鄂B67890
vector 共 3 辆
deque 顺序:又一辆前门 前门来的 后门来的
list 顺序:第一 第三 第四
读结果(这三行的差别就是三个容器的全部性格):
| 现象 | 说明 |
|---|---|
v[1] 直接取到第 2 个 |
vector 支持随机访问——像数组一样按下标取 |
d[i] 也能按下标取 |
deque 也支持随机访问,所以它同样能用 sort |
list 只能 it++ 一个个挪 |
list 不支持随机访问——l[0] 直接编译不过 |
list为什么不能有[]:它的内存是"散落的节点用链子串起来"(7.4 讲指针时你就见过这个结构)。想找第 5 个,只能从头顺着链子走 5 步——所以"按下标取"這件事它做不到,也没有+运算。这一条差异会在 13.4.5 迭代器那里得到正式解释(vector的迭代器是"随机访问迭代器",list的只有"双向迭代器")。顺带一个必踩的坑:
l.insert(it, "第一")之后,it还指着原来的位置——list插入不会让别的迭代器失效(这是它相对vector的一大优势)。
四、动手 Demo 2:三个容器一起干活(一个真实的小场景)
任务:维护一份"待办清单",能加、能删、能按顺序看出还有几件。
#include <iostream>
#include <vector>
#include <algorithm>
#include <string>
using namespace std;
void show(const char *title, const vector<string> &t) {
cout << title << "(" << t.size() << " 件):";
for (size_t i = 0; i < t.size(); i++) cout << t[i] << " / ";
cout << endl;
}
int main() {
vector<string> todo;
todo.push_back("写作业");
todo.push_back("洗碗");
todo.push_back("回消息");
show("初始", todo);
// ① 按下标读 —— vector 的看家本领
cout << "第 1 件是:" << todo[0] << endl;
// ② 完成一件 —— 中间删除,vector 要搬家
todo.erase(todo.begin() + 1); // 删掉"洗碗"
show("洗碗完成后", todo);
// ③ 在指定位置插入
todo.insert(todo.begin(), "晨跑"); // 插到最前面
show("插入晨跑后", todo);
// ④ 问一句"有没有这件事"
cout << "有\"写作业\"吗?"
<< (find(todo.begin(), todo.end(), "写作业") != todo.end() ? "有" : "没有") << endl;
// ⑤ 清空
todo.clear();
cout << "清空后 empty() = " << (todo.empty() ? "true(空)" : "false") << endl;
return 0;
}
看到什么算成功:
初始(3 件):写作业 / 洗碗 / 回消息 /
第 1 件是:写作业
洗碗完成后(2 件):写作业 / 回消息 /
插入晨跑后(3 件):晨跑 / 写作业 / 回消息 /
有"写作业"吗?有
清空后 empty() = true(空)
两处值得盯着看:
todo.begin() + 1是"第 2 个位置的迭代器"——begin()能+1,说明vector的迭代器是随机访问的(13.4.5)。find一出现,"迭代器就是插头"这件事就看得见了:find是<algorithm>里的算法,todo是容器,两者唯一的联系就是那两个迭代器。find从来没听说过vector这个名字。
五、动手 Demo 3:为什么"能长大"值得单独讲一章
回忆 7.1:int a[3] = {1,2,3}; 之后想加第 4 个,只能重新声明一个更大的数组,把数据搬过去。这是 C 语言数组的硬伤。
vector 把这件事封进了类型里:
#include <iostream>
#include <vector>
using namespace std;
int main() {
vector<int> v;
cout << "开始时 size / capacity = " << v.size() << " / " << v.capacity() << endl;
for (int i = 0; i < 20; i++) {
v.push_back(i * 10);
cout << "压入 " << i * 10 << " 后:size=" << v.size()
<< ",capacity=" << v.capacity() << endl;
}
return 0;
}
看到什么算成功(注意 capacity 不是每次 +1,而是"成倍地跳"):
开始时 size / capacity = 0 / 0
压入 0 后:size=1,capacity=1
压入 10 后:size=2,capacity=2
压入 20 后:size=3,capacity=4
...
压入 70 后:size=8,capacity=8
压入 80 后:size=9,capacity=16
...
读结果:size 是你实际装了几个,capacity 是它已经申请了多大。快要装不下时,capacity 翻倍——扩容这件事它自己干,你只管 push_back。
为什么值得单独一章:这就是"把不变式封进类型里"(10.1.1 好类的三条标准)在标准库里的样子。你写业务代码时不该再关心"数组够不够大"。 但代价要知道:扩容时要申请新内存 + 搬全部数据 + 释放旧内存,所以你提前 reserve 一下会更省事(13.1.1 细讲)。
30 秒实验
- 把 Demo 3 的
for (int i = 0; i < 20; i++)改成i < 3,看capacity的最后一行是不是 4。 - 在 Demo 3 里加一句
v.reserve(100);(写在循环前),重新跑 → 第一行capacity变成 100,之后再也不跳了。 - 把 Demo 1 里注释掉的
cout << l[0] << endl;取消注释,编译 → 认一下list的报错脸(no match for 'operator[]')。 - 在 Demo 1 末尾加
cout << l.front() << " " << l.back() << endl;→list虽然没有[],但front()/back()是有的(三个容器都有这四个:front/back/size/empty)。
常见误区
- "
vector就是数组,没区别"——三个关键区别:① 能自动扩容;② 知道自己的长度(.size(),不用 sizeof 相除);③ 越界可以用at()检查(at()会抛异常,[]不会,见 13.1.1)。但[]依然不检查——这一点没变。 - "
vector最好,都用它"——但也别硬套。要在中间频繁插入删除时,vector每次都要搬家,这时list才合适。默认用vector,出现性能问题再换是现实里的正确姿势。 - "
list是链表,所以什么都快"——恰恰相反:list找第 100 个元素要顺着链子走 100 步,也不能用sort(它没有随机访问迭代器,得用成员函数l.sort())。"快"只指"在已知位置上插入删除"。 - "
size()返回int"——返回的是size_t(无符号)。所以for (int i = 0; i < v.size(); i++)会有有符号/无符号比较警告;写成size_t i就不会。 - "用
while (i <= v.size() - 1)也一样"——危险。当v为空时,v.size() - 1会回绕成一个巨大的无符号数,循环直接飞出去。用< v.size()或范围 for。 - "容器里必须存基本类型"——什么都能存:
vector<string>、vector<Student>(10 章的类)、vector<vector<int>>(注意 C++11 前要写> >)。存自定义类时它要求这个类"能拷贝"——这就回到了 10.2.2 的复制构造函数和 10.3 的析构函数(三法则)。
自测三问(点开对答案)
1. **STL 的三个零件是什么?它们靠什么拼在一起?**——**容器(装数据的盒子)、算法(干活的手法)、迭代器(通用插头)**。算法不知道容器的名字,只认两个迭代器(`begin` / `end`),**所以一份 `sort` 能服务所有容器**。这也是第十二章模板的价值所在:`sort` 是函数模板,`vector` 是类模板。 2. **`vector`、`deque`、`list` 各快在哪?选谁?**——`vector` 一整块连续内存,**按下标取 / 尾部加 / 遍历快**;`deque` 若干块内存拼起来,**头尾插入删除都快**(也有 `[]`);`list` 是双向链表,**任意已知位置的插入删除都快,但不能 `[]`、不能 `+n`、不能 `sort`(要用成员 `l.sort()`)**。判断标准:**你的数据主要在哪一端动?** 3. **`size` 和 `capacity` 有什么区别?扩容时发生什么?**——`size` 是**实际装了几个**,`capacity` 是**已经申请了多大**。快满时 `capacity` **翻倍**(申请新内存 → 搬全部数据 → 释放旧的)。所以**已知大概数量时先 `reserve(n)`** 可以避免反复搬家。- ☐ 下一节(13.1.1 向量类模板)