前四天我们学会了"管资源"(RAII)、"转移资源"(移动语义)、"传递行为"(lambda)、"抽象类型"(模板)。今天把这一切装进工具箱:STL 容器与算法。为什么说"选对容器"是 C++ 性能的第一课?vector 和 list 的差距为什么不是"一点点"?为什么 JavaScript 开发者最容易在这里翻车?
STL(Standard Template Library)是 C++ 标准库的精华:约 20 个容器、60 多个算法、5 类迭代器,共同构成一套"数据结构 + 算法"的完整体系。它是前四天所学一切(模板、RAII、移动语义、lambda)的集大成者——std::vector 是模板 + RAII + 移动语义的完美结晶,std::sort 是模板 + 迭代器的经典应用。
今天学习 STL 容器与算法,是因为:第一,它是 Qt 开发者的日常——Qt6 的 QList、QHash 与 STL 容器同源同构,理解 STL 等于理解 Qt 容器的一半;第二,它是性能优化的第一战场——Chrome、微信、QQ 这些大型客户端的性能问题,十有八九能追溯到"选错了容器";第三,它是最容易暴露 Web 思维惯性盲区的地方——JavaScript 的数组和对象让你养成了"数据结构随便用"的习惯,而 C++ 中一个错误选择就是 100 倍的性能差距。
一句话说清 STL:一套"用模板写成的、以迭代器为粘合剂"的通用数据结构与算法库。容器管"存储",算法管"处理",迭代器管"遍历"——三者解耦,却因为统一的设计而能自由组合。这是软件工程"高内聚、低耦合"的教科书级范例。
STL 容器按底层数据结构分为三大类:
vector(动态数组)、deque(双端队列)、list(双向链表)、forward_list(单向链表)、array(定长数组)、basic_string(字符串本质也是容器)。set、map、multiset、multimap。查找、插入、删除均为 O(log n)。unordered_set、unordered_map。平均 O(1) 查找,最坏 O(n)。关键认知:STL 容器存储的是"值"(value semantics),不是引用。往 std::vector<MyClass> 里 push 一个对象,容器里存的是它的拷贝(或移动后的新对象),而不是指向原对象的指针。这和 JavaScript 的"一切皆引用"有本质区别——稍后在 Web 对比部分展开。
std::vector 的底层是一块连续内存上的动态数组。它有三个关键机制:
push_back 均摊(amortized)复杂度是 O(1)。vec[i] 就是一次指针算术:*(data + i)。// vector 的基本用法:RAII + 移动语义的完美协作
std::vector<std::string> names;
names.reserve(1000); // 预分配,避免反复扩容移动
names.emplace_back("Alice"); // emplace_back:原地构造,省一次移动
names.push_back("Bob");
// 范围 for:底层就是迭代器(第3天学过 auto 与范围语义)
for (const auto& name : names) {
std::cout << name << '\n';
}
// 访问元素:operator[] 不做边界检查(快);at() 抛异常(安全)
const auto& first = names[0]; // O(1) 随机访问
迭代器是 STL 设计中最精妙的一环。它把"怎么遍历"抽象成统一接口,让 std::sort 既能排序 vector 又能排序 deque(甚至原生的 C 数组):
// 同一个算法,作用在不同容器上
std::vector<int> v = {3, 1, 4, 1, 5};
int c_array[] = {9, 2, 7};
std::sort(v.begin(), v.end()); // 排序 vector
std::sort(std::begin(c_array), std::end(c_array)); // 排序 C 数组!
迭代器按能力分为五类(由弱到强):input / output → forward → bidirectional → random-access。能力越强,能用的算法越多。例如 std::sort 要求随机访问迭代器——所以它不能排序 std::list(list 只有双向迭代器),list 只能用自己的 list::sort() 成员函数。这个限制不是缺陷,而是设计:算法对迭代器能力的要求,暴露了数据结构的真实能力。
对容器执行结构性操作(插入、删除、扩容)后,旧迭代器/引用/指针可能失效——继续使用就是未定义行为(UB)。规则随容器而异:
vector:扩容使所有迭代器失效;中间插入/删除使"该位置之后"的迭代器失效。
deque:两端操作使迭代器失效,但引用不一定失效(实现相关)。
list / forward_list:插入/删除不使其他迭代器失效(链表天然优势)。
unordered_map:rehash 时全部失效;map/set:插入/删除不影响其他迭代器。
在 JS 里 push/splice 数组,旧引用依然有效(因为数组元素是引用);在 C++ 里扩容后旧迭代器就是悬空指针——这是 Web 开发者最需要警惕的思维转换。
<algorithm> 提供了 60+ 个算法,按用途分类:
find、find_if、binary_search(要求有序)、lower_bound / upper_boundsort(快排,不稳定)、stable_sort(归并,稳定)、partial_sort、nth_element、reverse、rotatecopy、transform、fill、replace、remove、uniqueaccumulate、inner_product、iotastd::ranges::sort、views::filter、views::transform——管道语法,像极了 JavaScript 的链式调用最重要的惯用法是 erase-remove 惯用法(remove-erase idiom)——std::remove 并不真正删除元素,它只是把"要保留的元素"往前搬,返回新的逻辑末尾,真正的删除要靠 erase:
// ❌ 新手直觉:remove 就完事了
// std::remove(v.begin(), v.end(), 3); // 元素还在!只是被搬到后面
// ✅ erase-remove 惯用法:真正删除
v.erase(std::remove(v.begin(), v.end(), 3), v.end());
// ✅ C++20 的 erase 自由函数:一行搞定(推荐)
std::erase(v, 3); // C++20 新增,容器 + 值
std::erase_if(v, [](auto x) { return x % 2 == 0; });
为什么 remove 不直接删除?因为算法不知道容器如何释放内存——算法只操作迭代器,不拥有容器。这再次体现了"算法与容器解耦"的设计哲学:remove 可以作用于数组、vector、list 的任何一段区间,代价就是它只能"搬运"不能"删除"。
| 维度 | std::map(红黑树) | std::unordered_map(哈希表) |
|---|---|---|
| 查找/插入/删除 | O(log n),稳定可预期 | 平均 O(1),最坏 O(n) |
| 遍历顺序 | 按键有序(升序) | 无序(哈希顺序) |
| 内存 | 每个节点 3 个指针开销 | 桶数组 + 冲突链,通常更省 |
| 缓存友好度 | 差(节点分散) | 较好(桶连续) |
| 需要键类型 | operator<(可比大小) | 哈希函数 + operator== |
| 典型场景 | 需要有序遍历、范围查询 | 纯按键查找(绝大多数场景) |
经验法则:默认用 unordered_map,需要"按键有序遍历"或"范围查询"时才用 map。但要注意:数据量小时(几百个元素),map 的红黑树可能反而更快——因为哈希函数计算也有开销,且小数据集下缓存优势不明显。
Qt6 的容器(QList、QVector、QMap、QHash、QSet、QStringList)与 STL 容器设计同源:QMap 对应 std::map(红黑树),QHash 对应 std::unordered_map(哈希表)。但 Qt 容器有一个 STL 没有的杀手锏——隐式共享(implicit sharing)/ 写时复制(copy-on-write):
// Qt 容器:拷贝是 O(1) 的!真正复制发生在"写"的时候
QList<int> a = {1, 2, 3};
QList<int> b = a; // 只拷贝一个指针 + 引用计数,O(1)
b.append(4); // 此时才深拷贝(detach)
这解释了为什么 Qt 老代码到处"按值传 QList"而不心疼——Qt6 中 QList 与 QVector 已合并(QList 即 QVector 的别名),都基于连续存储的 QArrayData。而 std::vector 拷贝就是实打实的深拷贝。理解这一点,你才能读懂为什么 Qt 的信号槽参数建议"按 const 引用传对象、按值传小对象",以及为什么跨线程用 Qt::QueuedConnection 时容器要拷贝。
Chromium 的代码规范(C++ Style Guide)明确要求:能用 std::vector 就不用链表;能用 flat_map 就不用 std::map。Chromium 的 base::flat_map 是一个"排序 vector + 二分查找"的结构:查找 O(log n),但内存完全连续、缓存友好,在元素量 < 1000 时实测比 std::map 快 2~5 倍。为什么不直接用 std::map?因为浏览器每帧都要遍历 DOM 属性、样式映射等大量小映射表——缓存命中的收益远超红黑树的渐进复杂度优势。
同样的思想也体现在 V8 引擎中:JavaScript 对象属性存储用了"隐藏类 + 描述数组"的布局优化,本质上就是把"哈希表"改造成"类数组的连续结构"——与 flat_map 异曲同工。行业共识:在数据量小的场景,连续内存 + 线性/二分查找往往胜过"理论上更优"的复杂结构。
桌面 IM(如微信 PC 版、QQ)的会话列表是一个经典场景:需要按时间排序、频繁插入新消息、按会话 ID 快速定位。如果天真地用 std::vector 存会话对象、每次新消息来了 sort 一下,就是 O(n log n) × 消息数——会话一多就卡。工程上的典型方案:用 std::unordered_map<QString, SessionItem> 存会话(O(1) 定位),同时维护一个"按时间排序的 id 数组",插入新消息时只需局部调整(二分查找插入点 + 局部移动),把 O(n log n) 降为 O(n)。这就是"两个容器协同"解决单一容器性能瓶颈的经典套路——也解释了为什么大型客户端里"索引 + 数据分离"是标配(数据库、搜索引擎都是这个思想)。
// ❌ 扩容后引用失效 —— UB!
std::vector<int> v = {1, 2, 3};
auto& ref = v[0]; // 引用指向元素 0
v.push_back(4); // 扩容!内存被重新分配
std::cout << ref; // ⚠ ref 已悬空 —— 读野内存
为什么:vector 扩容 = 换一块更大的内存,旧地址失效。JS 里数组扩容是"悄悄换内部 buffer",但因为元素是引用所以外部引用不受影响;C++ 存的是值本身,引用就是地址,地址变了引用就废了。避免:先 reserve() 预分配;或在结构性操作之后重新获取迭代器/引用。
// ❌ 删除元素后迭代器失效,++it 是 UB
for (auto it = v.begin(); it != v.end(); ++it) {
if (*it % 2 == 0) v.erase(it); // ⚠ erase 后 it 失效
}
// ✅ 正确:erase 返回下一个有效迭代器
for (auto it = v.begin(); it != v.end(); ) {
if (*it % 2 == 0) it = v.erase(it);
else ++it;
}
// ✅ 更现代:erase_if(C++20)或 remove-erase 惯用法
std::erase_if(v, [](auto x) { return x % 2 == 0; });
JS 里 splice 插删自如,于是新手在 C++ 里也倾向用 std::list 存一切。但 std::list 的每个元素是一个独立分配的节点——遍历时每次都要跳指针,缓存全部打空,实际性能可能比 vector 慢 10~50 倍。绝大多数场景,vector 才是正确答案:push_back 均摊 O(1)、缓存友好、随机访问。只有"需要频繁在中间插入/删除 + 大量元素 + 不需要随机访问"时,list 才值得考虑(而且通常 deque 或分段结构更优)。
// ❌ 一次循环两次哈希查找(operator[] 找不到还会插入默认值!)
for (const auto& item : items) {
count_map[item.key] = count_map[item.key] + 1; // 查找 + 可能插入 + 再查找
}
// ✅ 一次查找
for (const auto& item : items) {
auto [it, inserted] = count_map.try_emplace(item.key, 1);
if (!inserted) ++it->second; // C++17 try_emplace
}
另一个隐蔽坑:map[key] 在键不存在时会插入一个默认构造的值——如果你只是想"查一下在不在",这会在 map 里留下垃圾键。查找请用 find() 或 contains()(C++20)。
str = str + "a" 每次都要重新分配并拷贝整个字符串。循环 10 万次就是灾难。避免:用 std::string::reserve 预分配 + append/+=,或 std::ostringstream。这和 JS 里"数组 join 优于字符串 += "是同一个教训,只是 C++ 里差距更夸张(因为 string 的 COW 已被标准禁止,拷贝是实打实的)。
| 场景 | 推荐 | 理由 |
|---|---|---|
| 默认选择(90% 场景) | std::vector | 缓存友好、随机访问、均摊 O(1) 尾部插入 |
| 频繁在头部插入/删除 | std::deque | 两端 O(1),且保留随机访问(list 不能) |
| 需要在中间频繁插删且元素很大 | std::list(少见) | 插入不移动其他元素,但缓存差、无随机访问 |
| 纯按键查找、无顺序要求 | std::unordered_map | 平均 O(1),默认哈希容器 |
| 需要按键有序遍历/范围查询 | std::map | 红黑树有序,支持 lower_bound 范围查询 |
| 去重 + 快速判断存在 | std::unordered_set | 哈希集合,O(1) |
| 小数据量 + 高性能 | 排序 vector + binary_search | 连续内存,Chromium flat_map 同思路 |
| Qt 界面层传递数据 | QList / QStringList | 隐式共享,跨模块拷贝廉价,与 Qt 信号槽无缝 |
reserve(),消灭反复扩容(一次扩容 = 一次分配 + 全员移动)。vector<unique_ptr<T>> 只在该类型不可拷贝/移动代价高时用。std::find_if 表达意图比 for + if 更清晰,且编译器优化更好。C++20 ranges 让代码更像声明式。const auto&;要修改用 auto&;小对象(int、指针)按值 auto。存值(vector<T>):✅ 内存连续、缓存友好、无泄漏风险、拷贝/移动由 RAII 自动管理。❌ 重对象拷贝昂贵(但移动语义已大幅缓解);❌ 多态对象不能直接存值(需要指针)。
存智能指针(vector<unique_ptr<Base>>):✅ 支持多态、指针稳定(插入删除不移动对象本身)。❌ 内存分散(堆上逐个分配)、缓存差、多一次间接寻址。
结论:默认存值;只有"多态对象集合"或"对象地址必须稳定"时才用 unique_ptr。团队规范里应写明这一条,避免新人无脑 vector<shared_ptr<T>>(最差组合:缓存差 + 原子计数开销)。
性能验证优于直觉:所有容器选择都应建立在 std::chrono 微基准或 profiler 数据之上。C++ 社区共识:复杂度(Big-O)决定规模增长后的命运,缓存行为决定同一量级下的胜负——两者都要看。
JavaScript 的 Array 和 std::vector 表面上都是"动态数组",但语义天差地别:
arr[0] = 1; arr[1] = "x" 都合法。V8 底层为了优化,会尝试用连续的同构存储(PACKED_SMI_ELEMENTS 等),但一旦混入不同类型就会"降级"为慢速的字典模式。sizeof(vector) 是固定的(3 个指针),元素就在内存里紧挨着。对 Web 开发者的迁移提示:你在 JS 里用 arr.push(obj) 存的其实是"指向 obj 的引用",后续 obj.xxx = 1 数组里的"对象"也跟着变;在 C++ 里 v.push_back(obj) 存的是拷贝,改原对象不影响容器里的副本——除非你 push 的是指针/引用。这是最容易踩的语义坑。
JS 的 Object 本质是一个"字符串键的哈希表"(V8 用隐藏类优化),最接近的 C++ 对应物是 std::unordered_map<std::string, V>。JS 的 Map(任意键、保持插入序)更像 std::unordered_map + 顺序索引的组合。
而 std::map(红黑树,按键有序)在 JS 里没有直接对应物——JS 的 Object 键总是按整数优先、插入序其次排列,你无法得到一个"按键排序"的哈希表。所以当你需要"有序遍历键"时,C++ 的 std::map 是唯一原生答案,JS 里你得自己 Object.keys().sort()。
Vue/React 开发者熟悉 arr.filter(x => ...).map(x => ...).reduce(...) 的链式管道。C++20 的 ranges 几乎就是同一个东西:
// JS(你熟悉的世界)
// const result = arr.filter(x => x > 0).map(x => x * 2).sort((a,b) => a - b);
// C++20 Ranges(同一个世界,另一种语法)
namespace rv = std::ranges::views;
auto result = arr
| rv::filter([](auto x) { return x > 0; })
| rv::transform([](auto x) { return x * 2; })
| rv::take(10); // 惰性求值:像极了 RxJS 的管道
区别与联系:JS 的 filter/map 是立即求值——每步都生成一个全新数组(内存开销);C++20 ranges 是惰性求值——管道只是"视图",遍历时才真正计算,零中间分配。思想上完全同构(函数式管道),工程上 C++ 更抠内存。你已经会了思想,只需学语法。
| C++ | Web (JS/TS) | 关键差异 |
|---|---|---|
| std::vector<T> | Array(同构时) | C++ 存值、连续内存;JS 存引用 |
| std::array<T,N> | 固定长度数组(少用) | C++ 栈上定长,零开销 |
| std::list / deque | LinkedList(几乎不用) | JS 链表场景极少,C++ 也建议少用 |
| std::unordered_map | Object / Map | JS Object 键限字符串;Map 保插入序 |
| std::map | (无对应物) | 有序键遍历是 C++ 独有优势 |
| std::set | Set | C++ set 有序(树),JS Set 无序(哈希) |
| std::string | String(不可变) | C++ 可原地修改,拼接要 reserve |
| std::sort(不稳定) | Array.prototype.sort(稳定) | 需要稳定排序用 stable_sort |
| std::ranges 管道 | filter/map/reduce 链 | C++ 惰性求值;JS 立即求值 |
最重要的迁移认知:在 Web 里你几乎不用关心"数据存在哪、怎么排布"——V8 替你优化。在 C++ 里,内存布局(memory layout)就是性能本身。学会在写代码前问自己一句:"这些数据该怎么排布?谁拥有谁?"——这是从"会写 JS"到"会设计系统"的分水岭。
作为 Team Leader,容器与算法规范是团队代码质量和性能的第一道防线——因为容器选型错误会在代码评审时一眼看穿,却会在线上放大百倍。
std::list 就追问"为什么不用 vector/deque?";看到 std::map 而代码只做按键查找,提示换成 unordered_map;看到 vector<shared_ptr<T>> 而 T 不是多态基类,直接打回(应存值)。map[key] 双查找、vector.erase(it) 且未使用返回值、字符串 += 拼接——都是需要指出的问题。std::vector / std::array,与第 1 天 RAII 规范呼应。std::chrono 分别测 vector/list/map/unordered_map 在 10 万次操作下的耗时,亲眼看到 100 倍差距——体验比说教深刻十倍。QListView 的 model 缓存),理解"真实项目如何选型"。std::vector 的 push_back 是均摊 O(1),但最坏情况(扩容)是 O(n)。如果在一个实时性要求极高的场景(比如音频渲染线程每帧只能花 1ms),你会怎么设计?reserve 能彻底解决吗?还有哪些"预分配"思路?
Qt 的隐式共享(写时复制)让 QList 拷贝是 O(1),但 Qt 官方文档却建议跨线程传递时用 QList 值传递。为什么 COW 在多线程下会退化?(提示:引用计数的原子操作与 detach 竞争)这对你设计"跨线程数据传递"有什么启发?
JS 的 Map 保持插入顺序,而 std::unordered_map 不保证顺序。如果 C++ 需要一个"保持插入序 + O(1) 查找"的容器(LRU 缓存就是这种需求),你会怎么组合现有容器实现?
为什么 std::sort 要求随机访问迭代器而 std::list 只能用自己的成员 sort()?如果让你给 list 写一个通用的 std::sort 特化,时间复杂度会变成多少?这个例子说明了"算法接口设计"的什么原则?
V8 引擎把 JS 对象属性存储优化成"隐藏类 + 连续描述数组",本质上是在把哈希表改造成 flat_map。结合 Chromium 的 base::flat_map,你认为"什么时候哈希表不如排序数组"?这个阈值(元素个数)由什么决定?
任务:用 C++17 实现一个文本高频词统计工具,综合运用今天的全部知识点:unordered_map 计数、vector 排序、lambda、erase-remove、字符串处理。
要求:
std::string_view + std::isalpha)std::unordered_map<std::string, size_t> 统计词频(用 try_emplace 一次查找)std::vector<std::pair<std::string, size_t>>,用 std::sort + lambda 按词频降序排列std::chrono 对比 unordered_map 和 std::map 的耗时,感受差距验证:找一篇英文文章(比如《The Old Man and the Sea》的开头,或任意英文新闻),运行程序,检查:"the"、"and" 等高频词应出现在前列;编译开启 -Wall -Wextra -O2,用 -fsanitize=address 确认无内存错误。
// 参考框架
#include <bits/stdc++.h> // 练习用;生产环境请逐个 include
using namespace std;
int main(int argc, char* argv[]) {
if (argc < 2) { cerr << "用法: freq <filename>\n"; return 1; }
// 1. 读入整个文件(string 也是容器!)
ifstream fin(argv[1]);
string text((istreambuf_iterator<char>(fin)), {});
for (auto& c : text) c = (char)tolower(c); // 统一小写
// 2. 切词 + 计数(一次查找的 try_emplace)
unordered_map<string, size_t> freq;
string_view sv(text);
size_t pos = 0;
while (pos < sv.size()) {
// 跳过非字母,提取连续字母作为单词 ...(你来完成)
}
// 3. 转 vector + 排序(lambda 比较器)
// vector<pair<string, size_t>> items(freq.begin(), freq.end());
// sort(items.begin(), items.end(), [](auto& a, auto& b) { ... });
// 4. 输出 Top 20 与统计信息
return 0;
}
编译:g++ -std=c++17 -O2 -Wall -Wextra -fsanitize=address -o freq freq.cpp