1. 写在前面:为什么还会碰STL
手痒开个“STL专题练习”,标题后头还缀着“未完不续”,这四个字对我来说基本是常态了。翻了翻收藏夹里躺了一年的C++ Primer、侯捷先生的《STL源码剖析》PDF,再看看这两年写的业务代码,说实话真正自己手写过一遍的容器和算法少得可怜。大多数人包括我在内,平时工作都是vector一把梭,map偶尔用用,真到要用deque、list、priority_queue的时候,还得现场翻文档。
这次为什么想重新整理一遍STL?原因很直接:一是我发现团队里不少新人对容器的选择几乎没有概念,碰到“需要频繁在头部插入”这种需求,照样用vector,然后被性能问题折磨半天;二是面试的时候STL几乎是必问的一块,问深一点,比如迭代器失效、unordered_map的底层结构、emplace_back和push_back的区别,能答利索的人真不多。这个专题里我会按自己的练习节奏,把常用容器、算法、迭代器、仿函数这些核心东西逐一过一遍,用最直白的代码和踩坑记录来呈现。
这篇主要适合几类人:想系统补STL基础的中级开发者、正在备战面试需要把STL讲清楚的求职者、以及写C++写了好几年但一直停留在“会用vector和map”阶段的同学。如果你是刚接触C++没多久的新手,建议先把类、模板、指针这些基础补一补再来看,否则后面讲allocator、traits这些东西会很吃力。接下来按我实际练习的顺序走,先聊容器选型,再逐个容器展开,然后过算法和迭代器,最后把平时最容易踩的坑集中列一遍。
2. 容器选型:先搞明白你手里的工具是干什么的
2.1 容器分类背后的设计逻辑
STL容器大致可以分成三大类:序列式容器、关联式容器、无序关联式容器。这个分类不是随便分的,每种容器背后对应着一组完全不同的数据结构,数据结构又决定了操作的复杂度,而复杂度直接决定业务场景下的取舍。
序列式容器包括vector、deque、list、forward_list、array。它们的特点是元素按插入顺序线性排列,你要自己关心元素的位置。关联式容器包括set、multiset、map、multimap,底层基于红黑树实现,元素自动按key排序,查找、插入、删除的平均时间复杂度都是O(log n)。无序关联式容器包括unordered_set、unordered_multiset、unordered_map、unordered_multimap,底层是哈希表(bucket + 链地址法或者开放寻址,不同标准库实现有差异),查找平均O(1)。
我见过太多人搞不清楚map和unordered_map的适用场景,上来就问“哪个快”。这问题本身就是错的,哈希表平均O(1)确实比红黑树的O(log n)快,但前提是数据量够大、哈希函数分布均匀。如果你的数据量只有几十上百个元素,两个容器压根分不出差别。而且unordered_map的迭代顺序是不确定的,你要是依赖元素的顺序特性,那直接就翻车了。因此选容器之前,先回答三个问题:是否需要有序?是否需要按下标访问?插入和删除发生在哪个位置?
2.2 核心选择依据:操作复杂度与内存布局
很多C++开发者有一个误区——觉得STL容器就是封装好的黑盒子,用就完事了。实际上每个容器的底层实现方式直接决定你应该怎么用它,举几个最常见的例子。
vector底层是一块连续内存,所以随机访问O(1),尾部插入均摊O(1),但头部或中间插入就是O(n)级别,因为要搬移元素。deque底层是分段连续缓冲区,可以头尾双侧O(1)插入删除,随机访问也是O(1),但因为多了一层映射,实际访问速度比vector略慢。list底层是双向链表,任意位置插入删除O(1),但代价是无法随机访问,要找一个元素只能O(n)遍历。
内存布局方面,vector和array是连续内存,cache命中率最高;deque分段连续,居中;list和关联容器就是一个个分散节点,内存碎片化严重,遍历起来cache命中率感人。高性能场景下别光看时间复杂度,cache miss带来的性能损耗有时候比算法本身复杂度还大。我做过一个简单的benchmark,顺序遍历一个100万int的vector比遍历同样数据的list快了一个数量级还多,就是cache命中率的差距。
所以容器选型口诀其实挺简单:默认vector,要有序用map/set,只要存在性判断用unordered_set,频繁头尾操作用deque,频繁中间插入且数据量大用list,固定大小且不想有堆分配用array。
3. 序列式容器逐层拆解:从vector到deque再到list
3.1 vector:最常用的容器,坑也最多
vector是使用频率最高的容器,没有之一。它的本质就是一个动态数组,内部维护三个指针:start指向分配内存的起始位置,finish指向当前已使用内存的末尾,end_of_storage指向分配内存的末尾。当finish == end_of_storage时再插入元素,就要重新分配一块更大的内存(通常是原来的两倍),然后把旧元素搬过去,再释放旧内存。
这段逻辑看着简单,实际用起来有几个非常关键的注意点。第一,vector扩容导致迭代器全部失效。很多初学者写代码的时候把一个指向vector元素的指针存下来,然后继续push_back,后面再解引用这个指针,得到的是未定义行为。第二,insert和erase操作也会让迭代器失效——insert会把插入位置及其之后的迭代器全部作废,erase会把删除位置及其之后的迭代器全部作废。第三,连续erase多个元素的时候,我见过有人写这种代码:
for (auto it = vec.begin(); it != vec.end(); ++it) { if (*it % 2 == 0) { vec.erase(it); } }这代码看起来逻辑没毛病,实际跑起来就是典型的迭代器失效bug,甚至可能在Debug模式下直接断言失败。正确写法是:
for (auto it = vec.begin(); it != vec.end();) { if (*it % 2 == 0) { it = vec.erase(it); } else { ++it; } }用erase的返回值更新迭代器,这是写C++的人必须刻在脑子里的肌肉记忆。不过到C++20之后,更推荐用std::erase_if这个专门函数,一行搞定,还不会犯错。
再聊聊emplace_back和push_back。emplace_back是C++11引入的,它的优势在于可以直接传构造参数,在容器内部原地构造对象,省掉一次移动构造或拷贝构造。像这种场景,区别就很明显:
struct Person { string name; int age; Person(string n, int a) : name(std::move(n)), age(a) {} }; vector<Person> v; v.push_back(Person("Alice", 25)); // 构造临时对象,然后移动进vector,可能有额外开销 v.emplace_back("Alice", 25); // 直接在vector内存里构造,零拷贝但要注意,emplace_back也不是万能的。如果你已经有一个现成的对象,emplace_back传对象进去和push_back传对象进去性能上没有本质区别,因为都会调用一次移动构造。而且emplace_back有隐式构造的风险,比如vector v; v.emplace_back(1)会直接构造bool,不会做你可能期待的类型转换,这种隐式行为有时候会掩盖掉类型错误,建议还是谨慎使用。
3.2 deque:被低估的双端队列
deque(double-ended queue)在业务代码里出现频率远低于vector,但它某些场景下比vector好用得多。底层是分段连续内存,由一个map(注意这个map不是std::map,是一块指针数组)管理各段缓冲区。因此deque可以做到头尾插入删除都是O(1),随机访问O(1),但中间插入还是O(n)。
什么时候该用deque?典型场景是任务队列,既要往尾部塞任务,又要从头部取任务执行,并且某些时候需要按下标直接访问第N个任务做优先级调整。用list当然也可以,但如果要经常随机访问,list就废了;用vector的话头部的pop_front是O(n),数据量大根本扛不住。deque正好两头兼顾。再比如滑动窗口类算法题,头尾都会频繁操作,deque是天然适配的数据结构。
实际使用中的注意点有两个。第一,deque的迭代器是随机访问迭代器,但它的operator[]比vector要慢一些,因为需要先计算在哪一段缓冲区,再做偏移。所以性能敏感的内层循环,如果访问模式是遍历,用vector会更快。第二,deque的插入操作会导致迭代器失效的情况比vector复杂,标准规定deque在任何位置插入元素都会使所有迭代器失效;删除元素时,如果删除位置在头部或尾部,那只有被删除元素的迭代器失效,但如果删除中间元素,所有迭代器都会失效。这个规则和vector不一样,写代码时别套vector的经验。
3.3 list和forward_list:用空间换操作灵活性
list是双向链表,forward_list是C++11加入的单向链表。链表的优势是任意位置插入删除O(1),而且插入删除不会导致已有迭代器失效——这是它和vector、deque最大的不同。链表最尴尬的地方是没法随机访问,只能从头遍历,所以凡是涉及频繁查找的场景,链表都不是好选择。
链表的另一个优势是拼接操作。std::list::splice可以把一个list的一部分直接拼到另一个list上,时间复杂度O(1),不需要拷贝元素。这特性在某些业务场景下非常有用,比如游戏引擎里管理渲染对象的活跃列表,或者网络库里管理连接对象,经常要把节点从一个队列挪到另一个队列。如果用vector,这类操作就是O(n)拷贝,完全不是一个量级。
forward_list更节省内存,每个节点只保存一个next指针,不像list还要保存prev指针。但它只支持单向遍历,而且它的insert和erase操作位置和标准list略有不同——因为要找到前一个节点,所以forward_list提供了insert_after和erase_after。写代码时容易搞混,注意区分。我个人的习惯是:除非明确内存非常吃紧,否则直接list就行,forward_list的操作心智负担高一点,收益不明显。
3.4 序列式容器练习:实现一个简易任务调度器
光说不练假把式,练习序列式容器最好的方式就是拿一个真实需求来写。我这次写了一个简易任务调度器,注册若干任务,每个任务有优先级和延迟时间,调度器按优先级和延迟顺序执行。核心数据结构选择了小顶堆做任务队列,这个后面讲priority_queue时会细说,这里先看怎么用序列容器管理任务存储。
#include <iostream> #include <vector> #include <deque> #include <string> #include <algorithm> struct Task { int id; int priority; int delay_ms; string name; Task(int i, int p, int d, string n) : id(i), priority(p), delay_ms(d), name(std::move(n)) {} }; class TaskScheduler { public: void addTask(const Task& task) { pending_.push_back(task); } void processDue() { std::sort(pending_.begin(), pending_.end(), [](const Task& a, const Task& b) { if (a.priority != b.priority) return a.priority > b.priority; return a.delay_ms < b.delay_ms; }); for (auto& task : pending_) { std::cout << "Executing task: " << task.name << " (priority " << task.priority << ")\n"; } pending_.clear(); } private: std::vector<Task> pending_; };这里用vector存任务,每次processDue时按优先级和延迟排序,然后依次执行。这个方案实现简单,但如果任务很多且需要频繁插入删除,就不太合适。这时候可以用deque+手动sort,也可以用priority_queue。练习的对比点在于:同样是实现任务调度,使用不同容器会得到完全不同的代码结构和性能表现,要理解每个容器的取舍,而不是死记API。
4. 关联式容器实战:map、set与unordered系列
4.1 map/set的红黑树底座
set和map的底层是红黑树。红黑树是一种自平衡二叉查找树,它保证任何路径上黑色节点数目相同,红色节点不相邻,因此树的高度始终维持在O(log n),查找、插入、删除都是O(log n)。红黑树的实现细节非常复杂,左旋右旋、变色、插入修复、删除修复,每个操作都有一堆case要处理,这也是为什么《STL源码剖析》里红黑树那一章让人看得头皮发麻。
但作为使用者,我们不需要自己实现红黑树,只需要理解它的特性。set是key和value合一的有序集合,元素不可重复;multiset允许重复key;map是key-value对,key不可重复;multimap允许key重复。默认按key的less 升序排列,也可以自己传仿函数指定排序规则。
map的[]操作符有个隐藏行为值得注意:如果用operator[]访问一个不存在的key,它会自动插入一个默认构造的值,然后返回引用。这个行为有时候很方便,但有时候是个大坑。比如只判断key在不在map里,用if (mp[key]),如果key不存在,它就先插入了一个默认值,map凭空多了一个元素。正确做法是:
if (mp.find(key) != mp.end()) { // key exists } // C++20以后还可以用contains if (mp.contains(key)) { // key exists }另一个经验是,遍历map时如果要删除某些元素,同样要注意迭代器失效问题。map的insert和erase不会使其他元素的迭代器失效(这是红黑树节点式存储的优势),但erase当前元素的迭代器之后,不能再使用被erase的迭代器。所以写法上要提前递增:
for (auto it = mp.begin(); it != mp.end();) { if (it->second <= 0) { it = mp.erase(it); // C++11之后erase返回下一个迭代器 } else { ++it; } }关联容器的erase都返回下一个迭代器,这比之前C++98时代只能itRet = it++; 然后erase(itRet)方便多了。
4.2 unordered系列:哈希表的效率与陷阱
unordered_map、unordered_set底层是哈希表。C++标准库通常实现为桶数组+链表/红黑树(当单个桶的元素超过阈值时,某些实现会转成红黑树,例如GCC的libstdc++就把单桶超过8个元素转成红黑树结构,防止极端哈希冲突导致退化成O(n))。
unordered系列最大的优势就是平均O(1)查找,但前提是哈希函数分布均匀。C++标准库为内置类型和string类型都提供了默认哈希函数,一般够用。但如果key是自定义结构体,就得自己写哈希函数,这里非常容易翻车。比如一个错误示范:自定义类型的哈希函数写得过于简单,把所有元素映射到少数几个桶里,哈希表性能瞬间退化成链表遍历。
自定义哈希函数有两个要求:第一,相同key必须产生相同哈希值;第二,不同key尽量分散到不同桶。写法推荐组合哈希:
struct Person { string name; int age; bool operator==(const Person& other) const { return name == other.name && age == other.age; } }; struct PersonHash { size_t operator()(const Person& p) const { size_t h1 = std::hash<string>{}(p.name); size_t h2 = std::hash<int>{}(p.age); // 经典组合方式,参考boost::hash_combine return h1 ^ (h2 + 0x9e3779b9 + (h1 << 6) + (h1 >> 2)); } }; std::unordered_map<Person, int, PersonHash> score_map;还要注意unordered_map的负载因子(load factor)和rehash。默认负载因子是1.0,当元素个数超过桶数*负载因子时,哈希表会rehash扩容,所有迭代器失效。如果提前知道要存很多元素,建议先调用reserve(预期数量),减少rehash次数,能显著提升性能。我实测过插入100万条数据,reserve之后比不reserve快大概30%左右。
4.3 关联式容器练习:词频统计与热门关键词排序
做词频统计是关联容器最好的练手项目。需求不复杂:给一篇英文文本,统计每个单词出现次数,按出现次数从高到低输出前10个。我用unordered_map统计频率,用vector做排序输出来对比map和unordered_map的性能差异,这个小练习对理解容器选择很有帮助。
#include <iostream> #include <fstream> #include <unordered_map> #include <map> #include <vector> #include <string> #include <algorithm> #include <sstream> void countWords(const std::string& filename) { std::ifstream file(filename); if (!file.is_open()) { std::cerr << "Failed to open file\n"; return; } std::unordered_map<std::string, int> freq; std::string word; while (file >> word) { // 简单清理标点 word.erase(std::remove_if(word.begin(), word.end(), [](char c) { return std::ispunct(static_cast<unsigned char>(c)); }), word.end()); // 统一转小写 std::transform(word.begin(), word.end(), word.begin(), [](char c) { return std::tolower(static_cast<unsigned char>(c)); }); if (!word.empty()) { ++freq[word]; } } // 拷贝到vector进行排序 std::vector<std::pair<std::string, int>> items(freq.begin(), freq.end()); std::partial_sort(items.begin(), items.begin() + std::min<size_t>(10, items.size()), items.end(), [](const auto& a, const auto& b) { if (a.second != b.second) return a.second > b.second; return a.first < b.first; }); for (size_t i = 0; i < std::min<size_t>(10, items.size()); ++i) { std::cout << items[i].first << ": " << items[i].second << "\n"; } }这段代码核心是unordered_map的[]操作符自增计数,加上vector拷贝出来排序。用partial_sort而不是sort,因为只需要前10个,partial_sort复杂度是O(n log m),m是前k个元素,比全排序O(n log n)快一些。数据量小看不出差距,但如果文本有几百万词,partial_sort的性能优势就出来了。
换成map做同样的事情,单词就会自动按字典序排列,但查找性能从O(1)降到O(log n)。在词频统计这个场景下,我们并不依赖有序性,因此unordered_map是更合理的选择。如果需求改成“按字典序输出所有词频”,那map反而更合适,连最后排序都不用做。这就是容器选择要跟场景匹配的体现。
5. 迭代器与算法库:STL的骨架和灵魂
5.1 迭代器分类与traits机制
迭代器是STL的连接器。容器提供数据存储,算法通过迭代器访问容器数据,两者互不感知对方的存在。迭代器按照能力从弱到强可以分成五类:输入迭代器(只能读、单向)、输出迭代器(只能写、单向)、前向迭代器(可读写、单向遍历)、双向迭代器(可读写、双向遍历)、随机访问迭代器(可读写、支持任意偏移)。
理解迭代器分类是有实际意义的,因为每个STL算法都对迭代器有明确的要求。比如std::sort要求随机访问迭代器,所以list不能用std::sort,而要用list自带的sort成员函数。std::reverse要求双向迭代器,所以单向链表forward_list也不能用std::reverse。
traits机制是STL内部用来根据迭代器类型做策略分派的模板技术,标准库通过iterator_traits提取迭代器的value_type、difference_type、iterator_category等属性。平时写业务代码不需要自己实现traits,但理解这个概念对读懂STL源码、排查编译错误非常有帮助。比如编译报错说“no matching function for call to 'sort'”,往往就是迭代器类型不满足要求,这就是traits机制在编译期拦截了错误调用。
5.2 常用算法速查与实操经验
算法库里的函数非常多,常用的其实就那一二十个。排序类有sort、stable_sort、partial_sort、nth_element;查找类有find、find_if、lower_bound、upper_bound、binary_search;修改类有transform、copy、fill、replace、remove;其他还有accumulate、count_if、unique、for_each等。
这些算法有几个使用要点值得注意。第一,remove和erase是两回事。std::remove不是真正删除元素,而是把满足条件的元素移到容器末尾,返回一个新的逻辑末尾迭代器,真正释放内存或缩短size需要配合erase:
vec.erase(std::remove_if(vec.begin(), vec.end(), [](int x) { return x % 2 == 0; }), vec.end());这就是著名的erase-remove惯用法,不这样写的话size不会变,末尾会残留重复的无效元素。
第二,lower_bound和upper_bound只适用于有序序列,map、set、vector排序后都可以用。lower_bound返回第一个不小于给定值的迭代器,upper_bound返回第一个大于给定值的迭代器。在有序序列里做查找,binary_search的时间复杂度是O(log n),但如果只需要判断存在性,我更推荐直接看lower_bound的返回值是否等于end且值相等,因为binary_search只返回bool,没法拿到元素的位置。
第三,for_each和范围for循环怎么选?我的经验是:如果只是遍历每个元素做点事,范围for循环更清晰;如果要在遍历时对元素做变换并写回,用std::transform更适合;如果要对容器做条件删除这种操作,for_each配合erase容易写出迭代器失效问题,直接用erase-remove惯用法更安全。
5.3 算法练习:基于lambda的管道式处理
C++11引入的lambda表达式让算法库的实用性直接翻倍,几乎每一个lambda都可以理解为是一个匿名的函数对象。我练习的时候写了一段处理学生成绩数据的代码,把lambda和STL算法组合成管道式的处理流程,读起来非常直观。
#include <iostream> #include <vector> #include <string> #include <algorithm> #include <numeric> struct Student { std::string name; int score; }; int main() { std::vector<Student> students = { {"Alice", 85}, {"Bob", 92}, {"Charlie", 67}, {"David", 78}, {"Eve", 95}, {"Frank", 55} }; // 1. 过滤掉不及格的 students.erase(std::remove_if(students.begin(), students.end(), [](const Student& s) { return s.score < 60; }), students.end()); // 2. 按分数降序排列 std::sort(students.begin(), students.end(), [](const Student& a, const Student& b) { if (a.score != b.score) return a.score > b.score; return a.name < b.name; }); // 3. 分数加5分调分 std::for_each(students.begin(), students.end(), [](Student& s) { s.score += 5; }); // 4. 计算平均分 double avg = std::accumulate(students.begin(), students.end(), 0.0, [](double acc, const Student& s) { return acc + s.score; }) / students.size(); std::cout << "Average score: " << avg << "\n"; for (const auto& s : students) { std::cout << s.name << ": " << s.score << "\n"; } return 0; }这段代码展示了lambda和算法库组合的威力,每行算法就对应一个明确的处理步骤。这里有一个细节值得展开:accumulate的初始值我写的是0.0,而不是0,这保证了累加结果是double类型。如果写0,会先按int累加,最后一次才转成double,整数溢出时结果就不对了。这种细节问题在真实的工程代码里经常出现,写的时候要留意。
lambda捕获方式也要注意。值捕获和引用捕获各有适用场景,多线程、回调函数中捕获引用要小心悬空引用。我的一般原则是:lambda生命周期不会超过当前作用域时用引用捕获没问题;如果lambda会被保存下来、异步执行或者传给别的线程,一定用值捕获或者显式拷贝需要的对象。
6. 容器适配器:stack、queue与priority_queue
6.1 适配器的本质是组合
stack、queue、priority_queue在STL里被称为容器适配器,是因为它们自己不实现数据结构,而是内部包装另一个容器,对外提供简化的接口。stack默认用deque做底层,queue也默认用deque,priority_queue默认用vector。
stack能改底层容器为list或vector,只要容器支持push_back、pop_back、back、empty、size这些操作。queue要求支持push_back、pop_front、front、back。这里有个有意思的点:queue默认用deque而不是list,原因是deque的底层内存分配方式让它在很多实现下比list更快,特别是缓存命中率更高。虽然两者对外的接口和复杂度看起来一样,实际性能差距却不小。
priority_queue默认是大顶堆,使用std::less作为比较函数。这个less容易让人误解——明明是“less”,结果出来的是大顶堆。原因是priority_queue把比较函数用在底层heap算法里,顶部元素是“最大”的元素,即compare下排最后面的元素。要得到小顶堆,需要传入std::greater:
std::priority_queue<int, std::vector<int>, std::greater<int>> min_heap;或者说如果想给priority_queue存自定义类型,需要提供比较仿函数。这里又是一个大坑:运算符重载的方向和自定义仿函数的方向容易搞混,写反之后会发现出队顺序完全不对。
6.2 三种适配器的典型应用场景
stack是经典的后进先出结构,典型的应用有括号匹配、函数调用栈模拟、表达式求值(后缀表达式)、浏览器前进后退等。queue是先进先出结构,典型应用有消息队列、任务队列、BFS宽度优先搜索。priority_queue在算法题里非常常见,比如Top-K问题、合并K个有序链表、Dijkstra最短路径等。
我之前写Dijkstra算法时,用了priority_queue作为节点优先队列,核心逻辑是每次从堆顶取出当前距离最小的节点进行松弛。这里有一个优化细节:priority_queue不能直接做decrease-key操作(即把某个已有节点的key改小),常见的替代做法是允许同一个节点重复入堆,出堆时跳过过期的节点。实现起来非常简洁:
while (!pq.empty()) { auto [dist, node] = pq.top(); pq.pop(); if (dist > min_dist[node]) continue; // 跳过过期记录 for (auto& edge : graph[node]) { int new_dist = dist + edge.weight; if (new_dist < min_dist[edge.to]) { min_dist[edge.to] = new_dist; pq.push({new_dist, edge.to}); } } }这种做法的时间复杂度是O(E log V)左右,虽然比理论上最优的斐波那契堆+decrease-key慢一点,但实现成本低得多,实际工程里绝大多数时候它就是最合适的方案。STL里没有斐波那契堆,自己实现一个正确且高效的斐波那契堆代价极高,所以务实一点用priority_queue就好。
6.3 priority_queue经典面试题:Top-K问题
Top-K问题是面试的高频考点,想找某组数据中前K大的元素,数据量很大时不能全排序。正确的做法是维护一个大小为K的小顶堆,遍历数据时如果当前元素比堆顶大,就弹出堆顶,插入当前元素。这样遍历结束后,堆里就是前K大的元素,时间复杂度O(n log K)。
std::vector<int> topK(const std::vector<int>& nums, int k) { if (k <= 0) return {}; std::priority_queue<int, std::vector<int>, std::greater<int>> min_heap; for (int num : nums) { if (min_heap.size() < static_cast<size_t>(k)) { min_heap.push(num); } else if (num > min_heap.top()) { min_heap.pop(); min_heap.push(num); } } std::vector<int> result(min_heap.size()); for (size_t i = 0; i < result.size(); ++i) { result[i] = min_heap.top(); min_heap.pop(); } return result; // 注意:这个是升序排列的 }很多讲Top-K的文章喜欢用std::nth_element,它在数学上确实能在O(n)平均时间内找到第K大元素,但它把元素重排了,而且不保证K个元素的相对顺序。如果数据量非常大、没法一次性全部加载到内存里,必须用流式处理,priority_queue版本明显更好。两个工具各有优劣,面试时如果能说出来“分情况使用”,会显得对问题有更全面的理解。
7. 踩坑记录:STL高频问题排查
7.1 迭代器失效问题
迭代器失效是STL最常见的坑,几乎每个容器都有自己的失效规则,整理一下方便查阅。
vector:插入元素会使插入位置之后的迭代器全部失效,扩容时所有迭代器失效;删除元素会使删除位置之后的迭代器全部失效。deque:在头尾插入不会使迭代器失效(但会使引用失效,这条规则比较绕),在中间插入会使所有迭代器失效;删除头尾元素只使被删迭代器失效,删除中间元素使所有迭代器失效。list、forward_list:插入删除仅使被删元素的迭代器失效,其他不受影响。关联式容器map、set、unordered_map、unordered_set:插入不会使任何迭代器失效;删除仅使被删元素的迭代器失效。
这个规则表最好打印出来贴屏幕旁边,我写代码时只要涉及循环内修改容器结构,都会在心里默默过一遍这些规则。另一个经验是尽量用标准算法替代手写循环,比如remove_if、copy_if、stable_partition等,这些算法对迭代器失效的处理是经过仔细设计的,比自己写for循环加erase安全很多。
7.2 erase、remove、size_t的坑
erase-remove惯用法前面已经提到了,这里再补充一个典型错误:在for循环里一边遍历一边push_back。vector的push_back如果触发扩容,所有迭代器全部失效,range for循环会直接出问题,甚至可能切片访问越界。如果在遍历过程中确实需要动态添加元素,建议先收集到临时容器,循环结束后再统一插入。
另一个非常隐蔽的坑是无符号整型和erase混用。vector的size()返回size_t(无符号),如果用int len = vec.size(),在极端情况下(容器为空)会隐式转换成无符号数,导致len变成非常大的数。写索引循环时我一般建议:
for (size_t i = 0; i < vec.size(); ++i) { ... }或者用auto。这个坑在写二分查找时尤其危险,mid = (left + right) / 2 如果left和right都是size_t,相加时溢出就是未定义行为,正确写法是 mid = left + (right - left) / 2。
7.3 异常安全与性能对比
STL容器大多提供了强异常安全保证,比如vector的push_back如果中途发生异常,容器状态不会被破坏。但前提是元素类型符合基本要求,即移动构造函数不能抛异常。如果你的自定义类型移动构造可能抛异常,vector在push_back时一旦扩容搬移元素出错,就会面临状态不一致的风险。解决方法是给自定义类型的移动构造函数加上noexcept,这不仅让代码更安全,还能让vector使用更高效的移动而不是拷贝。
性能方面几个简单经验:字符串拼接不要反复用+操作符,会导致频繁分配和拷贝,正确方式是使用std::string的append或者提前reserve。unordered_map的遍历性能实际上比map要差,因为哈希表的节点是分散存储的,内存访问不连续。如果有频繁遍历且不要求有序的需求,可以评估一下用vector + std::partition或者sort+lower_bound这套替代方案,在小数据量下有时候反而更快。
8. 事后复盘:练习STL的正确姿势
这个专题说“未完不续”,其实不是写不下去,而是STL可挖的内容实在太多。我这次练完容器、算法、迭代器、适配器这几块,剩下的还有allocator内存池、函数对象与绑定器、std::string的底层优化、C++20新增的ranges和concepts这些内容没有展开。每块单独拎出来都能写一篇长文,这些内容接下来值得继续钻研。
如果让我给一个学习路线,我会建议按这个顺序走:先把vector、deque、list这些序列容器用熟,特别是vector的迭代器失效规则和扩容机制必须要理解透;然后掌握map和unordered_map的区别以及各自适用的场景;再练算法库常用函数和lambda组合使用;最后才是容器适配器和自定义allocator这些进阶内容。每学一个部分就找一个真实的小项目练手——任务调度器、词频统计、Top-K海量数据处理,这些都是很好的实践载体。
最后一个忠告是:STL的核心在于“组合”而非“记忆”。容器、算法、迭代器三者组合起来的表达能力非常强,如果发现自己写了一大堆for循环做查找、排序、去重,那大概率是算法库没用好,可以回头翻一翻算法库的文档。真正熟练了之后,写C++代码会非常舒服——因为你不是在堆代码,而是在用标准组件搭积木。