STL 算法与迭代器:从 for_each 到并行执行策略

C++ 标准模板库(STL)的核心设计哲学是将数据结构与算法解耦。迭代器作为两者之间的桥梁,使得同一套算法能够作用于向量、列表、数组甚至输入流等完全不同的容器。理解迭代器的分类体系,是正确使用 STL 算法、避免性能陷阱的第一步。

C++ 标准模板库(STL)的核心设计哲学是将数据结构与算法解耦。迭代器作为两者之间的桥梁,使得同一套算法能够作用于向量、列表、数组甚至输入流等完全不同的容器。理解迭代器的分类体系,是正确使用 STL 算法、避免性能陷阱的第一步。

本文从迭代器类别出发,系统梳理经典 STL 算法的复杂度和适用场景,随后深入 C++17 并行执行策略与 C++20 Ranges 库,展示现代 C++ 如何将声明式编程与多核性能融为一体。

一、迭代器类别与能力模型

STL 定义了五种迭代器标签,构成一个严格的能力递增层级。算法在编译期根据迭代器类型选择最优实现路径,这一点直接决定了时间复杂度。

迭代器类别支持操作典型容器
input_iterator_tag单次读取、自增(++std::istream_iterator
forward_iterator_tag可重复读取、多遍扫描std::forward_liststd::unordered_*
bidirectional_iterator_tag额外支持 --std::liststd::mapstd::set
random_access_iterator_tag额外支持 +n-n[]<std::vectorstd::deque、原始指针
contiguous_iterator_tag(C++20)元素物理连续,支持 std::to_addressstd::vectorstd::arraystd::string

std::vector<int>::iterator 属于随机访问迭代器,因此 std::sort 可以在其上执行基于内省排序的 O(N log N) 算法;而 std::list 的迭代器仅为双向,只能使用 std::list::sort 这个成员函数,因为通用 std::sort 要求随机访问能力。编译器通过 std::iterator_traits<It>::iterator_category 在模板实例化时完成分发,无需运行时开销。

C++20 引入的 contiguous_iterator_tag 进一步细化了随机访问迭代器的子集,允许算法利用内存连续性进行向量化或直接与 C 风格 API 交互。

#include <iterator>
#include <vector>
#include <list>
#include <type_traits>

static_assert(
    std::is_same_v<
        std::iterator_traits<std::vector<int>::iterator>::iterator_category,
        std::random_access_iterator_tag
    >
);

static_assert(
    std::is_same_v<
        std::iterator_traits<std::list<int>::iterator>::iterator_category,
        std::bidirectional_iterator_tag
    >
);

二、经典 STL 算法全景

STL 算法头文件 <algorithm><numeric> 提供了超过一百个函数模板。以下按功能分组,标注复杂度与典型应用场景。

2.1 非修改式序列操作

这类算法不改动元素值,只读取或查找。

  • find / find_if:线性查找,O(N)。find_if 接受一元谓词,是条件查找的首选。
  • count / count_if:统计满足条件的元素个数,O(N)。
  • equal:判断两个范围是否相等,O(N)。C++14 起支持不同容器类型的比较。
  • search:在序列中查找子序列,最坏 O(N * M)。
#include <algorithm>
#include <vector>
#include <iostream>

std::vector<int> v = {3, 1, 4, 1, 5, 9, 2, 6};

auto it = std::find_if(v.begin(), v.end(), [](int x){ return x > 5; });
if (it != v.end()) {
    std::cout << "First >5: " << *it << '\n';  // 9
}

std::size_t n = std::count(v.begin(), v.end(), 1);  // n == 2

2.2 修改式序列操作

  • copy / move:将源范围复制或移动到目标位置,O(N)。使用 std::back_inserter 可安全插入到空容器。
  • transform:对范围内每个元素应用一元或二元函数,结果写入输出迭代器,O(N)。这是函数式映射在 C++ 中的直接对应。
  • replace / replace_if:将满足条件的元素替换为新值,O(N)。
  • fill / generate:用固定值或生成器函数填充范围,O(N)。
#include <algorithm>
#include <iterator>
#include <vector>
#include <cctype>

std::vector<int> src = {1, 2, 3, 4, 5};
std::vector<int> dst;

// 平方映射
std::transform(src.begin(), src.end(),
               std::back_inserter(dst),
               [](int x){ return x * x; });
// dst = {1, 4, 9, 16, 25}

std::string s = "Hello";
std::transform(s.begin(), s.end(), s.begin(), ::tolower);

2.3 排序与有序操作

  • sort:内省排序(IntroSort),平均 O(N log N),最坏 O(N log N),非稳定。
  • stable_sort:稳定排序,保证相等元素相对顺序,最坏 O(N log^2 N) 或 O(N log N) 依实现而定。
  • partial_sort:只保证前 M 个元素有序,O(N log M)。适用于只关心 Top-K 的场景。
  • nth_element:保证第 n 个位置的元素是正确排序后的值,且左侧元素不大于右侧,平均 O(N)。这是快速选择算法的封装,用于中位数或百分位数计算。
#include <algorithm>
#include <vector>
#include <iostream>

std::vector<int> v = {3, 1, 4, 1, 5, 9, 2, 6, 5, 3};

// 第 4 大元素(0-indexed 位置 3)
std::nth_element(v.begin(), v.begin() + 3, v.end(), std::greater<int>());
std::cout << "4th largest: " << v[3] << '\n';  // 5

2.4 数值算法

头文件 <numeric> 提供了一系列常被低估的高效工具。

  • accumulate:范围归约,默认求和,O(N)。支持自定义二元操作,因此可实现连乘、字符串拼接等。
  • inner_product:计算两个范围的内积,O(N)。
  • partial_sum:计算前缀和,O(N)。C++17 起 inclusive_scanexclusive_scan 支持并行执行。
#include <numeric>
#include <vector>
#include <iostream>

std::vector<int> v = {1, 2, 3, 4, 5};
int sum = std::accumulate(v.begin(), v.end(), 0);  // 15
int prod = std::accumulate(v.begin(), v.end(), 1, std::multiplies<int>());  // 120

std::vector<int> prefix(v.size());
std::partial_sum(v.begin(), v.end(), prefix.begin());
// prefix = {1, 3, 6, 10, 15}

2.5 集合操作

假设两个范围均已按同一准则排序,以下算法均在线性时间 O(N + M) 内完成:

  • set_union:并集
  • set_intersection:交集
  • set_difference:差集
  • set_symmetric_difference:对称差集

输出迭代器必须具有足够的写入空间,或搭配 std::back_inserter 使用。

#include <algorithm>
#include <vector>
#include <iterator>

std::vector<int> a = {1, 2, 3, 5, 7};
std::vector<int> b = {2, 4, 5, 6};
std::vector<int> out;

std::set_intersection(a.begin(), a.end(), b.begin(), b.end(),
                      std::back_inserter(out));
// out = {2, 5}

三、算法与可调用对象的协作

STL 算法的第二个模板参数通常是一元或二元谓词。C++11 起,Lambda 表达式大幅降低了传递谓词的语法成本,使得算法的使用风格从命令式转向声明式。

std::vector<int> v = {1, 2, 3, 4, 5, 6};

// Lambda 作为谓词
even_count = std::count_if(v.begin(), v.end(),
    [](int x){ return x % 2 == 0; });

Lambda 捕获列表允许携带局部状态,这在需要维护计数器或缓存时极为方便。相比之下,定义一个完整的函数对象类(Functor)则更适合复用逻辑或需要在多个算法调用间保持复杂状态的场景。

struct ThresholdCounter {
    int threshold;
    int count = 0;
    explicit ThresholdCounter(int t) : threshold(t) {}
    bool operator()(int x) {
        if (x > threshold) { ++count; return true; }
        return false;
    }
};

ThresholdCounter tc(3);
std::for_each(v.begin(), v.end(), std::ref(tc));
// tc.count 记录了大于 3 的元素个数

注意:若算法可能复制谓词对象(如某些内部递归实现),应使用 std::ref 包装,以确保状态一致。

四、C++17 并行执行策略

C++17 在 <execution> 头文件中引入了执行策略,让标准算法具备开箱即用的并行能力。策略是一个标签类型,作为算法的第一个参数传入。

策略语义适用场景
std::execution::seq顺序执行,不并行不向量化默认fallback,保证确定性
std::execution::par并行执行,多线程分发数据量大、计算密集
std::execution::par_unseq并行 + 向量化 + 允许interleaving纯计算、无副作用
std::execution::unseq单线程向量化避免线程开销,利用SIMD
#include <execution>
#include <algorithm>
#include <vector>

std::vector<int> v(1'000'000);
std::generate(v.begin(), v.end(), std::rand);

// 并行排序
std::sort(std::execution::par, v.begin(), v.end());

// 并行变换
std::transform(std::execution::par_unseq,
               v.begin(), v.end(), v.begin(),
               [](int x){ return x * x + 1; });

并行算法并非总是更快。如果数据量很小、比较操作极轻量,线程调度与任务拆分的开销会超过收益。一般而言,长度在 10^4 以上的序列,或元素比较/计算代价较高时,并行策略才能显现优势。

更重要的是线程安全性:传递给并行算法的可调用对象必须是可重入的,不得修改共享可变状态,不得进行内存分配(除非线程安全),也不得持有锁。违反这些约束会产生数据竞争,导致未定义行为。

五、C++20 Ranges 库

C++20 Ranges 是对 STL 算法库的一次范式升级,核心概念是 Views(视图)惰性求值可组合管道。相关设施位于 <ranges><algorithm>std::ranges 命名空间中。

5.1 惰性视图与管道

视图不会立即分配新的容器,而是保存算法逻辑,在遍历时实时计算。这使得多个操作可以零拷贝地链式组合。

#include <ranges>
#include <vector>
#include <iostream>

std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

auto result = v
    | std::views::filter([](int x){ return x % 2 == 0; })
    | std::views::transform([](int x){ return x * x; })
    | std::views::take(3);

for (int x : result) {
    std::cout << x << ' ';  // 4 16 36
}

常用视图包括:filtertransformtakedropreverseenumerate(C++23)、zip(C++23)。管道操作符 | 的语义与 Unix Shell 管道一致,从左向右依次处理数据流。

5.2 约束算法

std::ranges::sort 等算法通过 Concepts 约束模板参数,在编译期提供更清晰的错误信息。例如,对一个非可排序范围调用 sort 会直接得到与 Concepts 相关的诊断,而不是深藏在模板实例化栈中的晦涩报错。

#include <ranges>
#include <vector>
#include <list>

std::vector<int> v = {3, 1, 4, 1, 5};
std::ranges::sort(v);  // OK

std::list<int> lst = {3, 1, 4};
// std::ranges::sort(lst);  // 编译错误:list 不满足 sortable range

5.3 Ranges 与经典算法对比

特性经典 STLC++20 Ranges
调用方式std::sort(v.begin(), v.end())std::ranges::sort(v)
组合能力需创建中间容器管道组合,惰性求值
投影支持无内置std::ranges::sort(v, {}, &Person::age)
错误诊断模板实例化深度报错Concepts 直接提示

Ranges 的投影(Projection)参数允许在不写 Lambda 的情况下按成员排序:

struct Person { std::string name; int age; };
std::vector<Person> people = {{"Alice", 30}, {"Bob", 25}};

// 按 age 升序,无需 Lambda
std::ranges::sort(people, std::less<int>{}, &Person::age);

六、性能考量与实践建议

STL 算法的时间复杂度由 ISO C++ 标准保证。例如 std::sort 必须是 O(N log N) 量级,std::nth_element 平均线性。这些保证意味着代码的可移植性与性能可预期性。

手写循环能否超越 STL?在绝大多数场景下答案是否定的。标准库实现针对特定平台进行了深度优化,包括循环展开、分支预测提示以及内联展开。只有当算法需要提前终止、跨元素依赖或需要对内存布局做极端定制时,手写循环才可能胜出。

对于向量化,std::execution::par_unseq 允许编译器将循环映射到 SIMD 指令集(如 AVX2、AVX-512)。需确保谓词内不包含分支、虚函数调用或异常抛出,否则会阻断自动向量化。配合 C++20 的 contiguous_iterator_tag,编译器能够生成更激进的向量化代码,因为连续内存意味着无需 gather/scatter 操作。

最后,Ranges 管道的惰性求值虽然减少了中间内存分配,但过度组合视图会导致多层间接调用,在调试模式或未内联时可能降低性能。对于性能瓶颈路径,建议先用 std::ranges::to<std::vector>()(C++23)或手工物化(materialize)中间结果,再进行分析。


从迭代器标签到并行执行策略,再到 Ranges 的惰性管道,STL 算法库的演进始终围绕一个核心目标:让程序员以声明式、可组合、可验证的方式表达数据转换,同时把底层优化留给标准库和编译器。掌握这些工具,意味着在正确性、可读性与性能之间找到了现代 C++ 的最优平衡点。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cpp」更多文章

  1. 模板元编程与编译期计算:TMP 实战指南
  2. STL 容器全解析与源码剖析
  3. CMake 工程化实战:从单文件到大型项目