🔷 第十三章 STL
📄 一句话概括

从这一章开始,你不用再自己写容器了。 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 一个个节点用指针串起来 双向链表 任意位置插入删除都快 不能按下标取(没有 [])

怎么记:不是"哪个更好",是"你的数据主要在哪一端动"。

三、动手 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(空)

两处值得盯着看:

  1. todo.begin() + 1 是"第 2 个位置的迭代器"——begin() 能 +1,说明 vector 的迭代器是随机访问的(13.4.5)。
  2. 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 秒实验

  1. 把 Demo 3 的 for (int i = 0; i < 20; i++) 改成 i < 3,看 capacity 的最后一行是不是 4。
  2. 在 Demo 3 里加一句 v.reserve(100);(写在循环前),重新跑 → 第一行 capacity 变成 100,之后再也不跳了。
  3. 把 Demo 1 里注释掉的 cout << l[0] << endl; 取消注释,编译 → 认一下 list 的报错脸(no match for 'operator[]')。
  4. 在 Demo 1 末尾加 cout << l.front() << " " << l.back() << endl; → list 虽然没有 [],但 front() / back() 是有的(三个容器都有这四个:front / back / size / empty)。

常见误区

  1. "vector 就是数组,没区别"——三个关键区别:① 能自动扩容;② 知道自己的长度(.size(),不用 sizeof 相除);③ 越界可以用 at() 检查(at() 会抛异常,[] 不会,见 13.1.1)。但 [] 依然不检查——这一点没变。
  2. "vector 最好,都用它"——但也别硬套。要在中间频繁插入删除时,vector 每次都要搬家,这时 list 才合适。默认用 vector,出现性能问题再换是现实里的正确姿势。
  3. "list 是链表,所以什么都快"——恰恰相反:list 找第 100 个元素要顺着链子走 100 步,也不能用 sort(它没有随机访问迭代器,得用成员函数 l.sort())。"快"只指"在已知位置上插入删除"。
  4. "size() 返回 int"——返回的是 size_t(无符号)。所以 for (int i = 0; i < v.size(); i++) 会有有符号/无符号比较警告;写成 size_t i 就不会。
  5. "用 while (i <= v.size() - 1) 也一样"——危险。当 v 为空时,v.size() - 1 会回绕成一个巨大的无符号数,循环直接飞出去。用 < v.size() 或范围 for。
  6. "容器里必须存基本类型"——什么都能存: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)`** 可以避免反复搬家。

相关笔记