好的,我们来系统地梳理一下C++ STL中那些最常用的容器。我会从“是什么”、“底层怎么实现”、“核心特性与复杂度”以及“最佳适用场景”这几个维度来总结,帮你一次性吃透它们。

一、顺序容器:线性存储,讲究顺序

这类容器的元素按插入顺序线性排列,核心差异在于内存布局和访问/修改的效率。

1. std::vector(动态数组)
  • 底层实现:一段连续的内存空间。内部维护三个指针(或迭代器):start(数据起始)、finish(已用空间末尾)、end_of_storage(总容量末尾)。
  • 核心特性
    • 随机访问:O(1),缓存局部性极好,遍历速度最快。
    • 尾部插入/删除:均摊 O(1)。当容量不足时,会触发扩容(通常申请原容量1.5~2倍的新内存,然后将旧元素移动或拷贝过去)。
    • 中间/头部插入/删除:O(n),因为需要移动后续所有元素。
  • 迭代器失效:扩容时,所有迭代器、指针、引用全部失效。中间插入/删除时,从操作位置到末尾的迭代器失效。
  • 适用场景默认首选。需要频繁随机访问、尾部增删、排序、批量处理的场景。
2. std::deque(双端队列)
  • 底层实现分段连续空间。由一个中央控制器(map,本质是一个指针数组)管理多个固定大小的连续内存块(buffer)。逻辑上连续,物理上分段。
  • 核心特性
    • 双端操作push_front / pop_frontpush_back / pop_back 均为 O(1)。
    • 随机访问:O(1),但比 vector 稍慢(需要先定位到段,再计算段内偏移)。
    • 中间插入/删除:O(n)。
  • 迭代器失效:两端插入/删除时,迭代器可能失效,但引用和指针通常保持有效(指向已存在元素)。中间操作会导致所有迭代器失效。
  • 适用场景:需要在两端频繁操作,同时还需要随机访问的场景(如滑动窗口算法、任务调度队列)。
3. std::list(双向链表)
  • 底层实现双向链表。每个节点包含数据、前驱指针和后继指针,节点在堆上独立分配。
  • 核心特性
    • 任意位置插入/删除:O(1)(前提是已知目标位置的迭代器)。
    • 随机访问:O(n),不支持下标操作。
    • 额外开销:每个节点需要存储两个指针,内存开销较大。
  • 迭代器失效稳定性最好。插入操作不会使任何已有迭代器失效;删除操作仅使被删除节点的迭代器失效。
  • 适用场景:需要在容器中间位置频繁进行插入和删除操作,且不关心随机访问的场景(如编辑器的撤销/重做栈、音乐播放列表)。
4. std::forward_list(单向链表)
  • 底层实现单向链表。每个节点只包含一个后继指针。
  • 核心特性:比 list 更节省内存,但只能单向遍历,没有 size() 方法(获取大小需要遍历)。
  • 适用场景:内存极度受限,且仅需单向遍历的场景。
5. std::array(固定大小数组)
  • 底层实现:封装了C风格原生数组,大小在编译期确定。
  • 核心特性:栈上分配,连续内存,零额外开销。不支持动态增删。
  • 适用场景:大小固定、对性能要求极高且无需动态扩容的场景(如嵌入式开发、作为函数返回值的固定大小缓冲区)。

二、关联容器:有序存储,快速查找

这类容器基于键(Key)来组织元素,内部自动排序,查找效率稳定。

1. std::set / std::multiset
  • 底层实现红黑树(Red-Black Tree),一种自平衡的二叉搜索树。
  • 核心特性
    • 自动排序:元素按 Key 升序排列(可自定义比较器)。
    • 去重set 中键唯一,multiset 允许重复。
    • 所有操作:查找、插入、删除的时间复杂度均为 O(log n)
  • 迭代器失效:插入操作通常不会使其他迭代器失效;删除操作仅使被删节点的迭代器失效。
  • 适用场景:需要元素自动排序唯一set),或需要稳定的 O(log n) 查找性能。
2. std::map / std::multimap
  • 底层实现红黑树。存储的是 pair<const Key, Value> 键值对。
  • 核心特性
    • 自动排序:按键排序。
    • 键唯一性map 中键唯一,multimap 允许重复键。
    • 所有操作:查找、插入、删除均为 O(log n)
  • 适用场景:需要构建有序的字典或映射关系,且对查找、插入、删除效率有稳定要求(如电话簿、配置项管理)。

三、无序关联容器:哈希加速,平均最快

C++11 引入,基于哈希表实现,不保证元素顺序,但平均性能最高。

1. std::unordered_set / std::unordered_multiset
  • 底层实现哈希表。通常采用“数组 + 链表”(拉链法)实现。一个桶数组(Bucket Array),每个桶指向一个链表(当链表过长时,某些实现会将其转换为红黑树以优化最坏情况)。
  • 核心特性
    • 无序:元素不排序。
    • 平均性能:查找、插入、删除均为 O(1)
    • 最坏情况:当哈希冲突严重时,可能退化为 O(n)
  • 适用场景:对元素顺序无要求,追求极致查找速度的场景(如缓存、去重检查)。
2. std::unordered_map / std::unordered_multimap
  • 底层实现哈希表。存储键值对。
  • 核心特性:与 unordered_set 类似,平均 O(1) 的查找、插入、删除。
  • 适用场景最常用的字典类型。需要快速通过键查找值,且不关心元素顺序(如统计词频、建立ID到对象的映射)。

总结与选型决策

容器类别 容器名称 底层数据结构 查找复杂度 插入/删除复杂度 核心特点 首选场景
顺序 vector 动态数组 O(1) 随机访问 尾部O(1),中间O(n) 连续内存,缓存友好 默认首选,随机访问+尾部操作
deque 分段连续数组 O(1) 随机访问 头尾O(1),中间O(n) 双端高效 双端队列、滑动窗口
list 双向链表 O(n) 已知位置O(1) 迭代器稳定,插入删除快 中间频繁增删
关联 set / map 红黑树 O(log n) O(log n) 自动排序,稳定高效 需要有序映射/集合
无序 unordered_* 哈希表 平均 O(1) 平均 O(1) 无序,查找最快 默认字典选择,追求速度

一句话决策指南

  • 要数组,随机访问vector
  • 要字符串string(本质是 vector<char>
  • 要键值对,快速查找unordered_map
  • 要键值对,且需要有序map
  • 要双端操作deque
  • 要中间频繁插入删除list
  • 要去重setunordered_set

map和set的区别

这两个容器在 C++ STL 里关系很近(底层都是红黑树),但用途和存储结构完全不同。

一、存储内容不同(最本质的区别)

容器 存储的是什么 形象理解
set 只有键(Key) 一个"集合",只关心某个元素存不存在
map 键值对(Key-Value) 一本"字典",通过键找到对应的值

代码对比:

// set:只存学号,判断某个学号是否存在
set<int> studentIds;
studentIds.insert(1001);

// map:存学号→姓名,通过学号查姓名
map<int, string> studentMap;
studentMap[1001] = "张三";

二、元素访问方式不同

容器 如何访问"值" 能否用 [] 下标
set 只能通过迭代器遍历,没有下标操作 ❌ 不行
map 通过 []at() 按键访问值 ✅ 可以(map[key]

特别注意: map[] 运算符有个"副作用"——如果键不存在,它会自动插入一个默认值。所以只做查询时,建议用 find()at() 避免意外插入。


三、修改权限不同

容器 键/元素能否修改 值能否修改
set 元素不可修改(const)
map 键不可修改(const) 值可以修改

为什么不能改?
因为红黑树是靠键来排序的,改了键会破坏树的结构。所以 set 的元素和 map 的键都是 const 的。如果想"修改",只能先删除旧的,再插入新的。


四、核心用途不同

容器 核心用途 典型场景
set 存在性检查 + 去重 + 有序遍历 记录已访问的URL、统计不重复元素、自动排序
map 键值映射 + 快速查找 字典(单词→释义)、学号→成绩、配置项管理

五、一句话总结

对比维度 set map
存储内容 只有 Key Key-Value 对
下标访问 ❌ 不支持 ✅ 支持 []at()
修改权限 元素不可改 键不可改,值可改
核心用途 去重、查存在 映射、查值
底层结构 红黑树 红黑树
时间复杂度 O(log n) O(log n)

选型口诀:

  • 只关心"有没有" → set
  • 关心"有什么对应的值" → map





std::list 的底层核心设计——带头节点的循环双向链表

一、核心结构:一个“环” + 一个“哨兵”

std::list 的底层可以拆解为三个关键词:双向循环带头节点

  1. 双向:每个节点(list_node)内部包含三个部分:

    • _data:存储的实际数据。
    • _next:指向下一个节点的指针。
    • _prev:指向上一个节点的指针。
      这使得链表可以双向遍历。
  2. 循环:整个链表首尾相连,形成一个环。

    • 最后一个有效节点的 _next 指向头节点
    • 头节点的 _prev 指向最后一个有效节点
  3. 带头节点(哨兵节点):这是最巧妙的设计。链表里有一个特殊的节点,称为头节点哨兵节点,它不存储任何有效数据,只作为链表的“锚点”。

二、为什么需要这个“哨兵节点”?

这是 STL list 设计的精髓所在,它的核心价值是统一逻辑,消除边界特判

想象一下,如果没有这个哨兵节点(即 head 指针直接指向第一个有效节点或为 nullptr),那么在进行插入、删除操作时,你需要写大量 if...else 来处理特殊情况:

  • 空链表插入if (head == nullptr) { head = newNode; }
  • 在头部插入if (pos == head) { ... }
  • 在尾部插入if (pos == nullptr) { ... }
  • 删除最后一个节点:需要特殊处理 head 指针。

有了哨兵节点后,一切变得简单而统一:

  • 空链表状态:哨兵节点的 _next_prev 都指向它自己begin() == end(),循环自然退出。
  • 插入操作:在任何位置(包括头部和尾部)插入的逻辑完全一致,都是“找到前驱节点,修改四个指针”,无需任何特判。
  • 删除操作:同样,删除任何节点(包括第一个和最后一个)的逻辑也完全一致。

三、迭代器与这个“环”的关系

list 的迭代器本质上是对 Node* 指针的封装。

  • begin():返回指向哨兵节点的下一个节点(即第一个有效节点)的迭代器。
  • end():返回指向哨兵节点本身的迭代器。这是一个“过去式”的结束标志,它不指向任何有效数据。

遍历过程
当你从 begin() 开始,不断 ++it,迭代器会沿着 _next 指针前进,经过所有有效节点,最终当 it == end() 时,意味着你绕了一圈又回到了哨兵节点,遍历结束。

四、一张图看懂

假设链表里有 1、2、3 三个元素,结构如下:

                    ┌───────────────────────────────────────────┐
                    │                                           ▼
          ┌─────────┴─────────┐   ┌──────────┐   ┌──────────┐   ┌──────────┐
          │   哨兵节点 (_head)  │   │  节点 1   │   │  节点 2   │   │  节点 3   │
          │  (不存数据)        │   │  data=1  │   │  data=2  │   │  data=3  │
          │  _next ──────────►│──►│  _next   │──►│  _next   │──►│  _next   │──►
          │  _prev ◄──────────│◄──│  _prev   │◄──│  _prev   │◄──│  _prev   │◄──
          └───────────────────┘   └──────────┘   └──────────┘   └──────────┘
                    ▲
                    └───────────────────────────────────────────┘
  • begin() 指向 节点1
  • end() 指向 哨兵节点
  • 空链表时:哨兵节点的 _next_prev 都指向自己,形成一个只有一个节点的环。

五、总结:这个设计的三大优势

  1. 代码简洁:插入、删除等所有操作逻辑完全统一,无需任何边界条件判断,代码量减少,错误率降低。
  2. 迭代器稳定list 的迭代器非常稳定。插入操作不会使任何已有迭代器失效;删除操作仅使被删除节点的迭代器失效。这在复杂逻辑中极其省心。
  3. 性能确定:任意位置的插入和删除都是纯粹的指针操作,时间复杂度恒为 O(1)(前提是你已经拿到了目标位置的迭代器)。

理解了这个“带头循环双向链表”的结构,你就真正理解了 std::list 的设计哲学——用空间(一个哨兵节点)换取逻辑的简洁和性能的稳定

更多推荐