博主介绍:程序喵大人
- 35 - 资深C/C++/Rust/Android/iOS客户端开发
- 10年大厂工作经验
- 嵌入式/人工智能/自动驾驶/音视频/游戏开发入门级选手
- 《C++20高级编程》《C++23高级编程》等多本书籍著译者
- 更多原创精品文章,首发gzh,见文末
- 👇👇记得订阅专栏,以防走丢👇👇
😉C++基础系列专栏
😃C语言基础系列专栏
🤣C++大佬养成攻略专栏
🤓C++训练营
👉🏻个人网站
好文推荐:
你手里有一组学生成绩记录,每条记录包含学生名字和分数。现在你需要把它们存起来,然后遍历、按名字查找、按分数排序。你打开编辑器,下意识地写了 std::vector<Student> records。这个选择在大多数时候是对的,但如果你停下来想一秒:除了 vector,C++ 标准库还提供了 list、map、unordered_map、set、deque、array、forward_list、multimap、multiset、unordered_multiset、unordered_multimap 等等,为什么需要这么多容器?它们之间的区别到底在哪里?
答案藏在第一个问题上:元素放在哪里。容器的名字和接口只是表象,真正决定每个容器行为、性能、适用场景的,是它如何组织元素在内存中的位置,是一段连续数组、一组分散的节点、一棵按键排序的树,还是一张按哈希值分桶的表。同样的数据放进去,遍历出来的顺序不同,查找一个元素的方式不同,插入和删除的代价也不同。

这就是理解 STL 容器的起点:先放下"什么容器好"的口诀,回到物理现实,元素在内存里到底是什么形态。
顺序容器:按位置记住元素
顺序容器(Sequence Container)最直观:你把元素放进去,元素就按你放入的顺序排好,每个元素都有一个确定的位置。这个"位置"是容器内部的概念,不依赖元素本身的值,你可以把同一个分数放进多个位置,容器只认位置,不认内容。
STL 提供了六种顺序容器,各自在"位置"的实现上做了不同的选择。
std::vector<T> 把元素放在一段连续内存里。这意味着它像数组一样,可以通过指针偏移快速访问任意位置的元素,下标运算 v[i] 本质上就是 *(data() + i)。连续内存也意味着 CPU 缓存对它极其友好:当你遍历 vector 时,预取器可以提前把后续元素拉进缓存。代价是:当内存不够用时,vector 需要申请更大的空间,把旧元素搬过去,再释放旧空间,这个过程称为扩容(reallocation),扩容后所有指向旧位置的指针、引用和迭代器都会失效。另外,在中间插入或删除元素时,后续所有元素都要向前或向后移动,代价与被移动的元素数量成正比。
std::array<T, N> 是最接近 C 数组的容器,但它的长度 N 是编译期常量,写在类型里。array 不分配动态内存,元素直接嵌在对象内部,所以它的大小在编译后就彻底锁死了。它的优势是零开销、完全确定性的内存布局,适合固定大小的缓冲区、查找表、坐标数组等场景。
std::deque<T> 的名字来自 double-ended queue,但它实际上是一种分段连续存储的序列。常见实现把元素分散到多个固定大小的内存块(chunk)中,并通过一个索引数组来定位任意块。这种结构让 deque 可以在头部和尾部快速插入元素而不需要搬动所有数据,同时仍然支持随机访问,只是随机访问比 vector 多一层间接跳转,常数略大。需要注意的是,你不能把 deque 当成一整段连续内存传给 C 接口,它的元素物理上不是挨在一起的。
std::list<T> 是双向链表。每个元素住在自己独立的节点里,节点之间通过前驱和后继指针相连。链表的遍历不靠指针偏移,靠顺着指针链一步步走,所以它不支持随机访问,想找第 100 个元素,必须从头部(或尾部)逐个经过前 99 个。链表的优势在于:一旦你拿到了一个确定的位置,在这个位置前后插入或删除元素时,其他元素完全不受影响,不需要搬动任何东西,改几根指针就行。这个特性称为位置稳定性。代价是每个节点多出了两个指针的额外内存开销,而且节点分散在堆上,遍历时 CPU 缓存很难预判下一站,缓存命中率比 vector 差得多。
std::forward_list<T> 是单向链表,每个节点只有后继指针,没有前驱指针。它比 list 更轻量,但只能向前遍历,不能在任意位置之前插入元素(因为找不到前驱节点),适用于只需要正向扫描且对内存敏感的场景。
std::string 虽然名字不叫容器,但在底层它就是 std::basic_string<char>,一段连续存储的字符序列,提供和 vector<char> 类似的随机访问能力,同时附加了文本处理接口(find、substr、compare 等)。把 string 当作字符容器来理解,比把它当作某种特殊类型更有助于看清它与 vector<char> 的区别:后者是一个通用的字符数组,前者是一个带文本语义的字符序列。

这些顺序容器共享一个关键特性:它们按位置组织元素,遍历顺序就是插入顺序(除非你手动调整)。但顺序容器并不回答"哪个元素符合某个条件",要查找特定值,你只能线性扫描,或者先排序再用二分查找。如果查找是核心操作,就需要关联容器。
关联容器:按键组织元素
当你的核心需求从"第几个"变成"名字叫什么"时,顺序容器的位置模型就不够了。你需要一种按元素本身的值(或值的一部分)来组织和查找元素的容器。STL 把这种容器称为关联容器(Associative Container),并分成有序和无序两个流派。
有序关联容器,std::map、std::set、std::multimap、std::multiset,按键(key)来组织元素。map 和 multimap 存储键值对(key-value pair),set 和 multiset 只存储键,值就是键本身。前缀 "multi" 表示允许重复键,不带前缀的版本每个键只能出现一次。
键的特殊之处在于:它决定元素在容器内部结构中的位置。有序关联容器常见的底层实现是平衡二叉树(通常是红黑树),每次插入新元素时,容器会比较新元素的键和已有节点的键,沿着树的路径找到应该挂载的位置。这个插入过程本身就在维护顺序,树结构保证了中序遍历(in-order traversal)得到的元素序列天然按键有序。
因此,当你遍历一个 map 或 set 时,你得到的是键的升序(或你指定的比较规则的顺序),插入先后不会决定最终遍历顺序。这个特性有明确的使用价值:比如你需要统计一段文本中每个单词的出现次数,最后按字典序输出,用 map<string, int> 统计完后直接遍历,结果就是排好序的,不需要额外排序。

有序关联容器的查找、插入、删除的复杂度通常是对数级别,树的高度大致与元素数量的对数成正比。这是平衡树结构的自然结果,标准关心复杂度和行为保证,并不把某一种具体树形结构写死。常见实现会使用红黑树这样的自平衡二叉树,但标准并不要求必须是红黑树,只要满足复杂度要求和迭代器稳定性规则即可。
std::map 支持 operator[],但这个操作有一个容易被忽视的语义:如果你用 m[key] 访问一个不存在的键,map 会插入一个带默认值的键值对然后返回这个默认值的引用。这不是一个纯查询操作,它会修改容器。如果你只想查询而不想插入,应该用 find 或带 const 的 at。
无序容器:用哈希换平均效率
如果说有序关联容器用"排序"换取了对数级别的查找能力,那么无序关联容器,std::unordered_map、std::unordered_set、std::unordered_multimap、std::unordered_multiset,走的是一条不同的路:哈希。
无序容器的核心思想是:把键通过哈希函数(hash function)映射到一个整数,然后用这个整数决定元素放在哪个桶(bucket)里。如果哈希函数设计得足够好,键会均匀地分散到各个桶中,每个桶里的元素数量大致相当。在这种条件下,查找一个键的成本大致等于计算哈希值的时间(常数)加上在单个桶内线性扫描的时间(如果桶很小也是常数),所以平均查找复杂度是 O(1)。
但这个 O(1) 前面有一长串前提条件。选择一个糟糕的哈希函数,或者负载因子(load factor,即元素总数除以桶数量的比率)太高,大量元素挤在同几个桶里,查找就会退化成线性扫描,复杂度滑向 O(n)。无序容器提供了 reserve、rehash、max_load_factor 等接口让你管理桶的数量和分布。rehash 是重排操作,桶数量变化后,所有元素需要重新计算归属桶并移动到新位置,这是开销很大的操作。

无序容器遍历元素的顺序没有业务语义。同一个程序、同一份数据、同一个编译器,两次运行可能产生不同的遍历顺序,因为哈希值的计算可能涉及随机种子,桶的内部排序也没有规定。如果你需要稳定有序的输出,用 map;如果你只需要按键快速查找、不关心输出顺序,用 unordered_map。这几乎是选择有序还是无序容器的全部决策依据。
迭代器:把容器变成可遍历范围
不管底层是连续数组、链表节点、树节点还是哈希桶,STL 容器都通过同一个概念对外暴露元素:迭代器(iterator)。
迭代器是一个对象,指向容器中的某个位置。你可以对它做两件事:解引用(*it)拿到它指向的元素,递增(++it)让它指向下一个元素。把 begin() 返回的迭代器作为起点,end() 返回的迭代器作为终点,你就得到了一个范围,[begin, end)。这个范围是半开区间:begin 指向第一个有效元素,end 指向最后一个有效元素之后的哨兵位置,它不指向任何真实元素,只是一个边界标记。
半开区间的设计有几个精妙之处。第一,空范围就是 begin == end,不需要额外的空标记。第二,范围的元素数量就是 end - begin(对于支持减法的随机访问迭代器)或 std::distance(begin, end)(对于不支持减法的迭代器),计算干净利落。第三,两个相邻范围的拼接直接把前者的 end 和后者的 begin 对齐即可,没有"尾元素重复"的麻烦。

迭代器是一个能力约定,不是一个单一类型。不同容器提供的迭代器能力不同:vector 的迭代器支持任意偏移(随机访问迭代器),list 的迭代器只能一步步移动(双向迭代器),forward_list 的迭代器只能向前走(前向迭代器)。算法(algorithm)通过迭代器来操作元素,不直接依赖容器类型,因此一个接受随机访问迭代器的算法(如 std::sort)可以用于 vector、deque、array、string,但不能用于 list。而一个只要求前向迭代器的算法(如 std::find)可以用于所有容器。
容器、迭代器、算法之间形成三层关系:容器负责存储,迭代器负责暴露访问路径,算法通过迭代器处理元素。这三层的解耦意味着你可以用同一段算法代码处理 vector 中的 int 和 list 中的 string,前提是迭代器能力匹配。反过来,如果你写了一个要求随机访问迭代器的处理逻辑,你就自动排除了一大批不支持随机访问的容器。这个限制不是坏事,它帮你明确了你真正需要什么能力。
容器选择的四个问题
把所有容器的结构差异压缩成选择指南,可以归纳成四个问题。每次面对一个新的数据集合,先问这四个问题,答案会自然指向合适的容器。
第一个问题:访问模式是什么? 如果你需要频繁的随机访问(按位置取元素、二分查找、排序),那么连续存储或至少支持随机访问迭代器的容器是必需的,vector、deque、array。如果只需要顺序遍历或基于位置的遍历,那么 list、forward_list 甚至 map 都能胜任。
第二个问题:在哪里插入和删除? 如果只在尾部追加,vector 的 push_back 分摊常数时间,几乎总是最佳选择。如果需要频繁在头部操作,deque 的 push_front 提供常数时间,vector 则根本做不到。如果需要在中间频繁插入和删除,而且位置是由迭代器精确指定的(不是每次重新查找),那么 list 或 forward_list 的位置稳定性才有价值。但如果每次插入前都要先花 O(n) 时间找到位置,那链表的 O(1) 插入优势就被查找成本吞没了。

第三个问题:查找和排序需求是什么? 如果需要按键快速查找,而且输出顺序不重要,unordered_map 和 unordered_set 是首选。如果需要按键有序遍历或区间查询(如"找出所有键在 A 到 M 之间的元素"),有序关联容器 map 和 set 的 lower_bound 和 upper_bound 正好为此设计。如果只需要偶尔查找,在排好序的 vector 上做二分查找(std::binary_search)也可能更快,因为连续内存对缓存更友好。
第四个问题:引用/指针/迭代器稳定性有多重要? 如果你持有某个元素的引用或指针,并在之后继续修改容器,这个引用是否仍然有效?对于 vector,任何可能触发扩容的操作(push_back、insert)都可能让所有旧引用失效。对于 deque,头部和尾部插入通常不会让引用失效,但中间插入或删除会。对于 list 和 forward_list,只在被删除的节点上失效,其他元素的引用保持稳定。对于 map 和 set,被删除的节点失效,其他节点稳定。对于 unordered_map 和 unordered_set,rehash 会让所有引用失效。
这四个问题的权重在不同的程序里不同。没有哪一个容器能同时拿满分,理解差异的目的在于建立排除法:需求一旦明确,你能快速排除不合适的选项,再在少数候选容器之间做权衡。

回头看一眼同一组学生成绩在不同容器里的行为,差异就不是"语法不同"了,它们是结构不同、访问路径不同、插入删除代价不同、迭代器稳定性不同的几套方案。
cpp
#include <iostream>
#include <string>
#include <vector>
#include <list>
#include <map>
#include <unordered_map>
struct Student { std::string name; int score; };
int main() {
std::vector<Student> vec = {{"Bob",85},{"Alice",92},{"Tom",78}};
std::list<Student> lst = {{"Bob",85},{"Alice",92},{"Tom",78}};
std::map<std::string,int> mp = {{"Bob",85},{"Alice",92},{"Tom",78}};
std::unordered_map<std::string,int> ump = {{"Bob",85},{"Alice",92},{"Tom",78}};
std::cout << "vector 遍历:\n";
for (auto& s : vec) std::cout << s.name << ":" << s.score << " ";
std::cout << "\n\nlist 遍历:\n";
for (auto& s : lst) std::cout << s.name << ":" << s.score << " ";
std::cout << "\n\nmap 遍历:\n";
for (auto& [k,v] : mp) std::cout << k << ":" << v << " ";
std::cout << "\n\nunordered_map 遍历:\n";
for (auto& [k,v] : ump) std::cout << k << ":" << v << " ";
std::cout << "\n";
}
这段代码能看到的差异只是开始:vector 和 list 按插入顺序输出,map 按名字字典序输出,unordered_map 的输出顺序无规律。但真正的差异藏在代码背后:vec[1] 是 O(1),lst 根本不能用下标;在 mp 中查找一个名字是对数时间,在 ump 中查找是平均常数时间;在 vec 头部插入一个学生记录需要搬动所有元素,在 lst 中只需要改几根指针。
STL 容器的多样性是对内存形态和访问模式的诚实回应。接下来我们逐个展开每个容器的具体结构,先从最常用也最基础的 vector 开始,毕竟连续内存是几乎所有高性能 C++ 程序的起点。
在真正写业务代码时,容器选择还会受到接口边界的影响。一个函数如果暴露 std::vector<T>&,调用方就会自然地认为它可以下标访问、可以拿 data() 传给 C 接口、可以把连续存储当作性能前提。一个函数如果暴露 std::map<K, V>&,调用方会认为遍历结果带有键序语义,并且可能依赖 lower_bound 这类范围查询。容器类型一旦进入公共接口,它表达的就不只是实现细节,还表达了调用方可以依赖的行为。许多老项目后来很难替换容器,根源就在这里:容器选择过早泄露成了接口承诺。
更稳妥的做法是把容器的结构承诺限制在真正需要的位置。只需要遍历时,可以把接口写成迭代器范围或 std::span;只需要按键查询时,可以暴露一个 find_user(id) 这样的成员函数,而不是把整张 unordered_map 交出去;只需要输出有序结果时,可以返回一个已经整理好的范围,而不让外部知道内部到底是 map 还是 vector 加排序。这样做不会消除容器选择本身的成本,但能把未来替换容器的影响范围压小。
还要注意容器里的元素类型。存 int、double、小结构体时,vector 的移动成本很低,扩容和中间删除的搬动代价通常可以接受。存大型对象、不可移动对象、持有外部资源的对象时,扩容和搬动的语义就要仔细检查。很多工程代码会用 vector<std::unique_ptr<T>> 把"元素对象很重"的问题转化成"指针很轻",容器搬动的是指针,真正的对象留在堆上不动。这种写法牺牲了一部分局部性,却换来了对象地址稳定和较低的移动成本。它说明容器选择不是单独发生的,元素所有权、对象生命周期、接口承诺会一起塑造最终方案。
初学 STL 时容易把容器看成语法工具:vector 会 push_back,map 会 operator[],unordered_map 会 find。进入工程阶段后,容器要被看成数据结构承诺:这组元素是否连续,顺序是否有意义,查找靠位置还是靠键,修改是否会让旧位置失效,遍历是否有确定顺序。只要这几个问题想清楚,后面学习每个具体容器时就不会被 API 名字带着走,而能把每个成员函数放回它对应的结构背景里理解。
容器选择还有一个很实际的调试维度。连续容器的问题通常容易观察:下标越界、扩容后旧指针失效、尾部追加导致容量变化,这些现象可以通过打印 size()、capacity()、地址变化快速定位。节点容器的问题更隐蔽:遍历慢、内存碎片、节点分配次数过多、比较函数不满足严格弱序,这些问题往往不会立刻崩溃,却会让性能或逻辑慢慢偏离预期。无序容器的问题又是另一类:哈希质量差、负载因子过高、rehash 时机不稳定、遍历顺序被误用。不同容器带来的 bug 形态不同,这也是选择容器时需要考虑的维护成本。
因此,好的容器选择不是"理论复杂度最低",而是"结构、语义、性能、调试成本都与需求匹配"。一个只有几十个元素的集合,用 vector 线性扫描可能比维护一棵树更简单、更快、更容易调试;一个长期运行的索引表,用 unordered_map 可能很快,但你必须管理哈希质量和 rehash;一个必须输出排序结果的表,用 map 可能比每次临时排序更稳定。把这些判断放在同一张地图里,STL 容器就不再是一堆名字,而是一组可推导的工程工具。
这个地图还会影响后面所有标准库知识的学习方式。算法章节里会不断出现迭代器类别,模板章节里会不断出现容器的值类型、分配器和比较器,性能章节里会不断回到缓存局部性、分配次数和数据搬移成本。如果第一章没有先把"元素放在哪里"讲清,后面的 std::sort 为什么要求随机访问迭代器、lower_bound 为什么在 map 上和在 vector 上含义不同、erase 为什么有的返回下一个位置有的只让被删节点失效,就都会变成零散规则。把容器看成内存形态之后,这些规则会自然连起来。
所以本系列的阅读方法很简单:每学一个容器,先问它把元素摆成什么形状,再问这种形状支持什么访问路径,最后问修改结构时哪些外部位置会失效。接口名字可以慢慢熟,成员函数可以查文档,但这三问必须在脑子里形成稳定顺序。只要顺序稳定,面对一个没用过的容器适配器或后续标准里新增的工具时,也能从结构和访问模式出发推断它大致适合什么场景。

码字不易,欢迎大家点赞,关注,评论,谢谢!