从第 1 篇的 std::string 到上一篇的 std::unordered_map,我们一路拆解了 C++ 标准库最核心的模型。而这一篇,我们终于走到许多人既熟悉又陌生的地方:头文件------整个 STL 的灵魂中枢。
你可能以为自己"会用" sort、unique、remove_if,但当我问:
- 为什么
std::sort不能对list排序? - 为什么
remove_if不会真正删除? - 为什么
lower_bound必须是有序范围?它如何做到对数复杂度? - 为什么某些算法是线性的,有些却能编译期优化掉?
- 为什么 C++20 引入 Ranges,是因为旧算法有什么问题?
如果这些问题你回答得并不确定,那么你真正需要的,就是这一篇文章。
本篇将从最底层的"算法设计范式"开始讲起,贯穿迭代器分类、复杂度保证、隐藏陷阱、性能真相,以及 C++20 ranges 对算法的革命。你看完之后,不仅会用 <algorithm>,更能理解它的精神。
一、算法库的设计哲学:为什么 C++ 把算法与容器彻底分离?
C++ 标准库和其他语言最大的不同,就是它坚持一个核心理念:
算法与容器必须解耦,彼此不知道对方的存在。
这使得:
std::sort不能关心你用的是vector还是deque;std::find不知道你的节点在哪里,也不需要知道;- 任何能提供某种迭代器语义的东西,都可以被算法调用。
这种设计带来了两个巨大优势:
1. 最大化代码复用
同一个 sort 可以作用于:
- vector
- deque
- 数组
- string(排序字符,其实可以)
- 自己写的自定义容器(只要提供 random access iterator)
- C 风格数组(编译器优化成指针)
2. 性能可以媲美手写代码
因为算法是模板,它可以在编译期对迭代器能力做 静态分派:
- 如果是 RandomAccessIterator → 使用快速策略(如 introsort)
- 如果是 BidirectionalIterator → 使用 merge sort
- 如果是 ForwardIterator → 使用更降级的操作
"能力越强,算法越快" 由类型系统决定,而不是运行时判断。
3. 算法库是 STL 灵魂,而非容器
很多人以为 STL = vector/list/map 那些容器。
其实真正的核心是:
STL 的灵魂是迭代器 + 算法
容器只是为了让你有数据可以放进去。
二、迭代器分类:所有算法的使用边界都由迭代器决定
深入算法库之前,你必须完全掌握"迭代器五大分类",因为:
每一个算法对迭代器都有最低要求,不满足要求就不能调用。
五大迭代器能力如下:
1. InputIterator
- 只能读一次
- 单向前进
- 常用于流迭代器、文件迭代器
2. ForwardIterator
- 可多次读取(可复制)
- 单向前进
- 如:
forward_list迭代器
3. BidirectionalIterator
- 能前进也能后退
- 如:
list、map、set
4. RandomAccessIterator
- 能前后移动
- 能进行
it + n、it[n] - 如:vector、deque、原生数组
5. ContiguousIterator(C++20)
- 内存连续,可做指针算术、优化到 SIMD
比如:
std::sort要求 RandomAccessIteratorstd::rotate只需要 ForwardIteratorstd::reverse只需要 BidirectionalIteratorstd::lower_bound只需要 RandomAccessIterator 或至少是 ForwardIterator(但 random access 才能做到O(log n))
如果你知道算法的迭代器需求,你就知道它性能能达到什么级别。
三、排序算法的底层:std::sort 究竟做了什么?
std::sort 的底层是 introsort:一种结合 quicksort + heapsort + insertion sort 的混合算法。
过程如下:
-
快速排序(快)
大多数情况下使用 quicksort(分区效率高)。
-
堆排序兜底(稳)
当递归深度超过
log(n)的某个常数倍时,改用 heapsort,避免 O(n²) 最坏情况。 -
小数组插入排序(非常快)
当区间很小时使用插入排序,常数项小,cache 友好。
为何 list 不能 sort?
因为 list 只有 BidirectionalIterator。
而 quicksort 分区需要随机访问,否则无法实现。
所以 list 有自己的:
cpp
list::sort()
它用的是 merge sort,适合链表。
四、remove/unique 的隐藏机制:改变了什么?没改变什么?
你一定见过:
cpp
v.erase(std::remove(v.begin(), v.end(), x), v.end());
这行代码是 C++ 程序员的必修课。
remove 不会删除元素!
它只是:
- 把不被移除的元素往前搬
- 返回新的"逻辑末尾"迭代器
- 物理容量和尾部的"垃圾元素"还在
为什么不删除?
因为 remove 不能操作容器本身,只能操作迭代器指向的区域。
容器 delete 的动作必须容器自己完成,所以必须再调用:
cpp
v.erase(iterator)
unique 也一样
它只是把重复项搬走,不会删掉尾部无效元素。
五、神级算法:lower_bound / upper_bound / binary_search 的真正逻辑
你以为它们做的是"二分搜索"。
其实它们做的事更像:
在任意可二分的区间上,找到一个满足单调谓词的边界。
例如 lower_bound:
找到 第一个使得 pred(x) == true 的元素位置
如果 pred 是:x >= value,那么就是找"第一个 >= value 的位置"。
复杂度为什么是 O(log n)?
因为要求:
- 范围是有序的
- 能够做随机访问(或至少 forward 但性能更差)
- 每次都能将搜索区间砍掉一半
许多程序员常犯的错误
错误1:对无序数组用 lower_bound
cpp
lower_bound(vec.begin(), vec.end(), value);
如果 vec 不是有序的 → 结果未定义(不是错误,而是 UB)。
错误2:用 map.lower_bound 当成查找"是否存在"
lower_bound 找的是插入点,不是精确匹配!
你要判断存在性应该是:
cpp
auto it = m.lower_bound(x);
if (it != m.end() && it->first == x)
// found
六、并行算法:C++17 的 std::execution 如何发挥多核性能?
C++17 加入了 Execution Policy:
cpp
std::sort(std::execution::par, v.begin(), v.end());
允许算法选择:
seq:串行par:并行(线程)par_unseq:并行 + SIMDunseq:SIMD
影响非常巨大
比如并行排序:
- 数据量很大时可以获得数倍性能提升
- 会启用线程池或自建任务系统
- 要求用户保证无数据竞争
算法库第一次具备了"自动利用硬件能力"的能力。
七、Ranges:C++20 算法库最重要的升级
传统算法的痛点:
- 需要 pairs of iterators(
begin(), end()) - 可组合性差
- pipe 风格不支持
C++20 彻底改造算法库,让它进入现代语言水平。
ranges 做到了:
cpp
vector<int> v = ...;
auto r = v
| views::filter([](int x){ return x % 2 == 0; })
| views::transform([](int x){ return x * x; });
for (auto x : r) { ... }
不像 Python,那是解释式的;C++ 的 ranges 仍然是 编译期零开销。
ranges 算法更易用
旧的:
cpp
std::sort(v.begin(), v.end());
新的:
cpp
std::ranges::sort(v);
更直观,也提升组合性。
ranges 与旧算法的差异
- ranges 算法会检查"整个 range 是否满足要求"
- 参数顺序调整,更符合直觉
- 返回值更符合函数式链式调用
例如:旧 remove_if:
cpp
auto it = std::remove_if(v.begin(), v.end(), pred);
v.erase(it, v.end());
ranges 版本:
cpp
std::erase_if(v, pred);
这不是 convenience,而是更现代的 API 设计哲学:
"让最常见的写法变得最简单。"
八、算法库中的隐藏性能陷阱
算法的复杂度不是凭空写在文档里的,那是编译器 + 类型系统共同保证的。你理解以下几点后才能写出真正高性能的代码。
1. for_each 与 手写 for 的性能差异
cpp
std::for_each(v.begin(), v.end(), f);
编译器会内联 f,并展开循环,性能可与手写 for 持平。
但如果 f 捕获大量变量或复杂 lambda,可能导致内联失败。
2. remove/unique 需要额外 linear pass
你以为 remove 快?
但 remove 实际要做:
- 读取每个元素
- 判断是否保留
- 把保留元素往前移动
即使都是 O(n),但对 cache 有影响。
3. sort 复杂度保证是"均摊"
虽然文档写的是 O(n log n),但 worst-case 会 fallback 到 heap sort,确保没有 O(n²)。
这个保证非常重要。
4. lower_bound 在 ForwardIterator 上变慢
如果你对 forward_list 使用:
cpp
std::lower_bound(list.begin(), list.end(), x);
性能不是 O(log n),而是:
O(n)
原因简单:forward iterator 不支持随机访问,不能直接跳跃元素。
九、算法组合的秘诀:如何写出更优雅、更快的 C++ 代码?
强大的 C++ 程序员并不是掌握了更多的语言特性,而是掌握了:
用最少的工具组合出最强的效果。
以下是几条黄金法则。
1. 排序 + unique → 去重
你一定见过:
cpp
sort(v.begin(), v.end());
v.erase(unique(v.begin(), v.end()), v.end());
这是效率极高的去重方式。
因为 unique 要求相邻重复元素,排序保证了这一点。
2. remove_if + erase → 条件删除
几乎每个 C++ 项目里都有:
cpp
v.erase(remove_if(...), v.end());
你应该习惯这个 idiom,它是 C++ 容器删除的事实标准。
3. lower_bound + vector → map 的替代方案
当你需要:
- 可搜索
- 可排序
- 数据量不是巨大的
你完全可以用 vector 模拟 map:
cpp
vector<pair<int, int>> v;
sort(v.begin(), v.end());
auto it = lower_bound(v.begin(), v.end(), key,
[](auto& p, int k){ return p.first < k; });
很多游戏服务器就是这么做的,因为性能比 map 快太多。
4. partition → 快速按条件分组
比如:把偶数放前面,奇数放后面:
cpp
partition(v.begin(), v.end(), [](int x){ return x % 2 == 0; });
你得到:
- 前半段:符合条件
- 后半段:不符合条件
不需要额外内存,非常适合线上处理。
十、算法如何保证"零开销抽象"?
C++ 的算法库之所以速度极快,是因为所有东西都能被:
- inline
- constexpr 优化
- 模板展开
- 移除迭代器边界检查
- 移除虚函数开销
这一切加在一起,让算法几乎和手写代码一样快。
举例:for_each
你写:
cpp
std::for_each(v.begin(), v.end(), f);
其实编译成:
cpp
for (auto it = v.begin(); it != v.end(); ++it)
f(*it);
效率一样,但写法更抽象。
十一、算法库的不足:为什么后来要发明 ranges?
经典算法库最大的三个问题:
1. 参数顺序太难记
cpp
remove_if(begin, end, pred)
为什么 predicate 是第三项?
不符合人的习惯。
2. begin/end 一直写很烦
如果能自动获取 range 就好了 → ranges 实现了。
3. 组合性差
你不能连续写:
cpp
v | remove_if | sort | unique
ranges 就行。
十二、写给真正想掌控 C++ 的程序员
看似 API 巨大(200+ 个算法),但你不需要全部记住。
你真正应该把握的,是下面这六条总纲,它们构成了这篇文章的灵魂:
1. STL 的本质是:算法 + 迭代器
容器是"存储",算法是"行为",迭代器是"接口"。
2. 迭代器能力决定算法能否使用与性能上限
随机访问迭代器的容器 → 享受所有高性能算法。
3. 所有算法都实现为零开销抽象
不比手写代码慢,甚至更快。
4. remove/unique 不是删除------erase 才是真正删除
从此不再困惑。
5. lower_bound 是"单调谓词边界查找",不是简单查找
理解这一点才能写出正确代码。
6. C++20 ranges 是算法库的第二次觉醒
让组合更自然,让 API 更现代。