跳转到正文
zeno's blog
返回

C++ STL:迭代器如何解耦算法与容器

Table of contents

Open Table of contents

TL;DR

STL 的灵魂是 Alexander Stepanov 的泛型编程思想:算法只依赖迭代器,容器只提供 begin/end——N 个算法 × M 个容器从需要 N×M 份代码变成 N+M 份。vector 扩容用 move_if_noexcept 保证强异常安全,std::sort 用 introsort 保证最坏 O(n log n),unordered_map 因 ABI 包袱锁死为链地址法(比 absl::flat_hash_map 慢 2-3 倍)——这些实现选择背后都有性能与兼容性的深层权衡。


1. STL 的哲学:N+M 而非 N×M

1.1 Stepanov 的泛型编程思想

STL 来自 Alexander Stepanov,1995 年在 HP 实验室完成原型,被 ISO C++ 委员会采纳进 C++98。Stepanov 的核心主张:算法应该与数据结构解耦,两者之间通过”抽象的迭代器”作为契约。这个思想直接对抗当时主流的 OOP 思潮——Stepanov 公开说过 “I find OOP technically unsound… I find OOP philosophically unsound”(1995 Dr. Dobb’s 采访)。

1.2 六大组件

组件职责代表
Container存数据vectormapunordered_map
Iterator遍历抽象vector::iteratorback_insert_iterator
Algorithm操作数据sortfindtransform
Functor可调用对象std::less<T>、lambda
Adapter改变接口形态stackreverse_iterator
Allocator内存分配策略std::allocatorpmr::polymorphic_allocator

1.3 为什么是 STL 的标志性设计

传统 OOP 思路:每个容器类内部实现自己的 sort、find、transform。N 个算法 × M 个容器 = N×M 份代码。

STL 思路:算法只依赖迭代器,容器只需提供 begin() / end()。N 个算法 + M 个容器 = N+M 份代码。

std::vector<int> v{3, 1, 2};
std::deque<int> d{3, 1, 2};
int arr[] = {3, 1, 2};

std::sort(v.begin(), v.end());
std::sort(d.begin(), d.end());
std::sort(std::begin(arr), std::end(arr));

这种”胶水代码归零”的威力 Java 的 Collections.sort + Comparable、Python 的 list.sort 都做不到——它们把算法塞进类里或依赖运行时 duck typing,性能和通用性都有损失。

1.4 为什么继承多态不适合容器

  1. 性能:虚函数每次都经过 vtable 间接跳转,编译器无法内联。std::sort 的比较器如果是虚函数,10⁸ 次比较会慢 3-5 倍。模板实例化后比较器可以被内联成一条 cmp 指令
  2. 类型抽象问题Container<T>Container<U> 如果共享基类,push(void*) 就丢失了类型信息
  3. 值语义:STL 容器都是值语义(拷贝 = 深拷贝),符合 C++ 的 RAII 哲学

2. 容器体系总表

2.1 序列容器

容器底层随机访问尾部头部中间缓存友好
vector动态数组O(1)均摊 O(1)O(n)O(n)极高
array(C++11)固定数组O(1)极高
deque分段数组O(1)*O(1)O(1)O(n)中等
list双向链表O(1)O(1)O(1)(已有 iter)
forward_list(C++11)单链表O(1)O(1)

deque 随机访问严格说是 O(1) 但常数大——它由多个定长 chunk 组成,operator[] 需要两次指针跳转。

2.2 关联容器(有序,红黑树)

容器插入/查找/删除有序迭代器失效
set / multisetO(log n)只有被删的
map / multimapO(log n)只有被删的

为什么是红黑树而不是 AVL需验证:标准没规定具体实现):主流实现都用红黑树。红黑树旋转次数比 AVL 少(插入最多 2 次旋转),写性能更好;AVL 平衡更严格,只在读远多于写时有优势。

2.3 无序关联容器(哈希表,C++11)

容器平均最坏迭代器失效
unordered_map / unordered_setO(1)O(n)rehash 时全部失效

2.4 容器适配器

适配器默认底层允许底层
stackdequevectorlist
queuedequelist
priority_queuevectordeque

priority_queue 底层必须支持随机访问(因为要用 make_heap),不能用 list


3. vector 深度剖析(面试重中之重)

3.1 三指针结构

template <class T, class Alloc = std::allocator<T>>
class vector {
    T* _begin;      // 数据起点
    T* _end;        // 最后一个元素之后
    T* _capacity;   // 已分配内存之后
};
// size() == _end - _begin
// capacity() == _capacity - _begin

3.2 扩容策略:为什么是 1.5x 或 2x

背后的数学:设扩容倍数为 k,每次分配内存依次 1, k, k², k³…。第 n 次分配时,前面已释放总内存 = (k^(n-1) - 1) / (k - 1)

如果要复用前面已释放的内存,需要 k^(n-1) > (k^(n-1) - 1) / (k - 1),化简得 k < φ ≈ 1.618(黄金比例)。

面试答案k 必须 < φ 才能复用已释放内存,1.5 是理论与实现的平衡点;GCC 选 2 因为实现简单且经验上性能差异不大

3.3 noexcept 移动构造:强异常保证的代价

C++11 之后 vector 扩容应该用移动构造(move),但 STL 要求 vector 提供强异常安全保证——扩容要么成功要么保持原状。

问题:移动第 5 个元素时抛异常,前 4 个已经被 move 走(原内存中是”被移动后状态”),既不能回滚也不能继续

STL 的规则std::move_if_noexcept):

class Widget {
public:
    Widget(Widget&& other) noexcept;  // ← 必须 noexcept
    // 否则 vector<Widget> 扩容会 fallback 到拷贝构造
};

面试陷阱:自己写的类忘了加 noexceptvector<MyClass> 在扩容时会莫名其妙从 O(n) 移动退化为 O(n) 拷贝——性能差几倍,你还以为是移动。

3.4 reserve vs resize

调用size 变化capacity 变化构造元素
reserve(n)不变至少变 n
resize(n)变 n可能增大✅(默认构造)

想预分配但不构造就用 reserve。

3.5 vector 是”设计失败”

vector<bool> 被特化为位压缩实现(1 bit 存 1 个 bool)。问题:

Herb Sutter 和 Scott Meyers 都多次批评过这个设计。建议用 vector<char>std::bitsetstd::deque<bool> 替代

3.6 emplace_back vs push_back

std::vector<std::pair<int, std::string>> v;
v.push_back({1, "hello"});         // 先构造临时 pair,再 move 进 vector
v.emplace_back(1, "hello");        // 直接在 vector 内存上就地构造

emplace_back完美转发 + placement new 把参数直接传给 T 的构造函数,省掉一次临时对象的构造与移动。对 move 代价高的类型区别明显;对 trivially copyable 类型(int)几乎无差。

3.7 shrink_to_fit 的非强制性

标准措辞是 “non-binding request”——允许实现忽略。真要释放用 swap trick

std::vector<int>(v).swap(v);  // 构造临时 vector 交换后临时析构

4. map vs unordered_map

4.1 对比

维度map(红黑树)unordered_map(哈希表)
插入/查找/删除O(log n)平均 O(1),最坏 O(n)
有序遍历
范围查询lower_bound / upper_bound不支持
内存布局节点分散,每节点 3 指针 + color桶数组 + 链表节点
迭代器失效插入不失效,只有被删的失效rehash 时全部失效
自定义比较operator< 或 comparator需要 hash + equal_to
最坏情况稳定 O(log n)退化为 O(n)

4.2 libstdc++ unordered_map 为什么被锁死为链地址法

GCC libstdc++ 的 unordered_map 采用链地址法 + 前向链表(单向省一个 prev 指针)。

为什么不能改成更快的开放寻址(open addressing):C++11 标准暴露了一些 API 事后看锁死了实现选择:

这些要求组合强迫实现必须是”桶 + 链表”。Google Abseil 的 absl::flat_hash_map 是开放寻址 + SIMD 探测,所有元素紧密排列在一个连续数组里,缓存命中率远高,性能通常 2-3 倍于 std::unordered_map

但 libstdc++ 改不了——一旦改成 flat 布局,现有 ABI 就破了(引用稳定性、bucket API 都会违反)。这就是 ABI stability 的包袱

来源:Matt Kulukundis CppCon 2017 “Designing a Fast, Efficient, Cache-friendly Hash Table”。

4.3 自定义 hash 的坑

struct Point { int x, y; };
struct PointHash {
    size_t operator()(const Point& p) const noexcept {
        // 典型错误:^ 组合会让 (x,y) 和 (y,x) 碰撞
        // return std::hash<int>{}(p.x) ^ std::hash<int>{}(p.y);

        // 更好:Boost hash_combine 风格
        size_t h = std::hash<int>{}(p.x);
        h ^= std::hash<int>{}(p.y) + 0x9e3779b9 + (h << 6) + (h >> 2);
        return h;
    }
};

0x9e3779b9 是黄金比例的 2³² 分数部分。


5. 迭代器

5.1 五种类别(层级递进)

Input ──┐
        ├── Forward ── Bidirectional ── RandomAccess
Output ─┘
类别能力代表
Input单次读,++*==istream_iterator
Output单次写,++*=ostream_iteratorback_insert_iterator
Forward多次遍历forward_list::iterator
Bidirectional+ --list::iteratormap::iterator
RandomAccess+ +=[]、算术vector::iteratordeque::iterator

C++17 加了 ContiguousIterator(保证内存连续),C++20 用 concepts 重写了整个分类。

5.2 迭代器失效规则(致命陷阱)

容器插入删除
vector容量不够 → 全部失效;够 → 插入点之后失效删除点之后全部失效
deque中间:全部失效;头尾:迭代器失效但引用稳定中间:全部;头尾:对应端
list / forward_list不失效只有被删的
map / set不失效只有被删的
unordered_*不 rehash 时不失效;rehash 时全部失效只有被删的

内存布局原因

5.3 经典 erase 错误

// ❌ 错误
for (auto it = v.begin(); it != v.end(); ++it) {
    if (*it == target) v.erase(it);  // 失效后 ++it 是 UB
}

// ✅ 正确
for (auto it = v.begin(); it != v.end(); ) {
    if (*it == target) it = v.erase(it);
    else ++it;
}

// ✅ 更好(C++20)
std::erase(v, target);
std::erase_if(v, [](int x){ return x % 2 == 0; });

6. 算法

6.1 分类与复杂度

类型代表复杂度
非修改findcountsearchO(n)
修改copytransformfillO(n)
划分partitionstable_partitionO(n) / O(n log n)
排序sortstable_sortpartial_sortnth_element见下
二分lower_boundupper_boundO(log n)(需排序随机访问)
make_heappush_heapO(n) / O(log n)
数值accumulatepartial_sumO(n)
集合set_unionset_intersection(需排序)O(n)

6.2 erase-remove 惯用法

// C++17 及以前
v.erase(std::remove(v.begin(), v.end(), 3), v.end());

// C++20
std::erase(v, 3);

为什么需要这个奇葩写法std::remove 不是”删除”,它只是把不等于 3 的元素向前移动覆盖,返回新的逻辑末尾。容器实际 size 不变。算法只操作范围,不操作容器本身——这是 STL 的根本设计。

6.3 std::sort 的 introsort 实现

std::sort 的复杂度要求是 最坏 O(n log n)(C++11 之后加强,之前只要求平均)。单纯快排最坏是 O(n²),怎么保证?

答案:introsort(Introspective Sort, David Musser 1997):

  1. 先用快速排序
  2. 递归深度超过 2 * log2(n) 时切换堆排序(稳定 O(n log n))
  3. 小范围(通常 < 16)用插入排序(常数小)

libstdc++ 的源码在 bits/stl_algo.h__introsort_loop

6.4 stable_sort、partial_sort、nth_element


7. 仿函数:为什么不用函数指针

bool less_func(int a, int b) { return a < b; }
struct Less { bool operator()(int a, int b) const { return a < b; } };

std::sort(v.begin(), v.end(), less_func);  // 函数指针
std::sort(v.begin(), v.end(), Less{});     // 仿函数

性能差异

对 10⁷ 元素的 sort,差距能达到 2-3 倍。

Lambda 本质是仿函数

auto lam = [x](int a) { return a + x; };
// 编译器生成:
struct __lambda_12_34 {
    int x;
    int operator()(int a) const { return a + x; }
};

operator() 默认是 const,想修改捕获变量要用 mutable

std::function 的类型擦除代价std::function 通过类型擦除存任意可调用对象。代价是堆分配(若 SBO 不够)+ 虚调用(每次调用不可内联)。算法模板参数直接用 lambda,存储时不得已才用 std::function


8. 分配器与 pmr

8.1 为什么要暴露分配器

8.2 C++17 pmr(polymorphic memory resource)

C++11 的 allocator 是模板参数,vector<int, MyAlloc>vector<int> 是两个类型。C++17 std::pmr::polymorphic_allocator 通过运行时多态解决:

#include <memory_resource>

std::byte buffer[1024];
std::pmr::monotonic_buffer_resource pool{buffer, sizeof(buffer)};
std::pmr::vector<int> v{&pool};

for (int i = 0; i < 100; ++i) v.push_back(i);
// 所有内存从 buffer 里线性分配,没有 heap 调用

三种标准 memory_resource

性能收益:对 std::list<int> 插入 10⁶ 节点,默认 allocator 每次 new 节点(10⁶ 次 malloc);monotonic_buffer_resource 只几次 malloc。差距 5-10x。


9. 典型陷阱汇总

陷阱说明
迭代器失效见 5.2
map::operator[] 自动插入只读用 atfind
vector::push_back 使所有迭代器失效扩容时全部失效
unordered_map 碰撞攻击恶意输入退化为 O(n)
std::sort 严格弱序比较器写成 <= 是 UB(破坏反自反性)
string_viewstring生命周期坑
remove 不真删除必须配合 erase
list::size C++11 前可能 O(n)C++11 强制 O(1)
移动构造没 noexceptvector 扩容退化拷贝
vector<bool> 不是真容器代理对象破坏 operator[] 约定

10. 选型决策表

需求容器
随机访问 + 尾部增删vector
头尾都增删deque
中间频繁增删 + 不需要随机访问 + 指针稳定list
有序 + 范围查询map / set
纯查找 + 无序unordered_map / unordered_set(或 absl::flat_hash_map
栈语义stack
优先级队列priority_queue
固定大小、栈上分配array
字符串string / string_view
bitmapbitset / vector<char>不要vector<bool>

经验法则默认用 vector。只有 profile 发现瓶颈在连续内存的缺点(频繁中间插入、需要指针稳定),才换别的。vector 的缓存友好性经常能打败渐近复杂度更好的结构——10⁴ 元素的线性 find 可能比 set::find 更快,因为 cache line 命中率差异。


11. 深入的冷知识(面试加分)

  1. vector 的 noexcept move 要求是 C++11 强异常保证的直接后果——std::move_if_noexcept 这个 type trait 就是为 vector 扩容设计的
  2. std::sort 的 O(n log n) 最坏保证依赖 introsort,C++11 才加强的要求,C++98 只要求平均 O(n log n)
  3. unordered_map 的 ABI 锁死是开放寻址 flat hash map 进不了标准库的根本原因
  4. 迭代器失效规则由内存布局决定:连续内存容器插入就要 realloc,节点容器插入只改指针
  5. vector 扩容倍数 < φ(黄金比例)才能复用之前释放的内存——不是拍脑袋决定的
  6. list::size O(1) 是 C++11 的 ABI break——libstdc++ 当时被迫加字段
  7. pmr 解决了 C++11 allocator 的类型污染问题——同一 vector<int> 类型可以用不同内存策略

12. 关键信息来源

置信度说明:


分享这篇文章:

上一篇
C++ 竞赛:ACM 模式 I/O 的组合拳
下一篇
Go Redis:go-redis/v9 的连接池、Pipeline 与 Hook