算法库初见:写 for 循环之前,先想想标准库
前面两章,咱们把 vector 和关联容器的基本操作都过了一遍。现在问题来了:当您需要对一堆数据做排序、查找、过滤、统计的时候,第一反应是不是写一个 for 循环?
很多人的直觉确实是手写循环。但咱们看 C++ 标准库的 <algorithm> 头文件里躺着上百个经过反复优化和测试的通用算法,用 STL 算法替代手写循环,代码更短、bug 更少、意图更清晰,很多时候性能还更好。(毕竟久经考验)
这一章咱们从实际需求出发,把最常用的一批算法全部上手实操一遍。过程中会频繁用到 lambda 表达式——它是配合 STL 算法的最佳搭档,所以咱们先花一点时间把它搞明白。
先认识咱们的搭档——lambda 表达式
咱们看 STL 算法经常需要一个"判断条件"或者"操作方式"作为参数,比如"按什么规则排序"、"什么条件的元素要找出来"。在 C++11 之前,这个角色由函数指针或函数对象来承担,写起来啰嗦又不直观。lambda 表达式彻底改变了这个局面。
咱们看 lambda 的完整语法是 [capture](parameters) -> return_type { body },其中返回类型可以省略(编译器自动推导),所以最常见的写法就是 [capture](params) { body }。方括号里的 capture 决定了 lambda 如何访问外部变量,这是最容易出错的地方。
咱们看 [=] 表示以值捕获的方式把所有用到的外部变量拷贝一份进来,修改它们不会影响外部;[&] 表示以引用捕获的方式拿进来,操作的就是外部变量本身;[x, &y] 是混合捕获,x 按值拷贝,y 按引用传递。实际开发中,最推荐的做法是显式列出要捕获的变量,而不是用 [=] 或 [&] 一把梭,代码意图更清晰,也不容易意外修改外部状态。
std::vector<int> data = {5, 3, 1, 4, 2};
int threshold = 3;
// 按值捕获 threshold
auto is_above = [threshold](int x) { return x > threshold; };
int count = std::count_if(data.begin(), data.end(), is_above);
// count == 2
// 引用捕获,累加到外部变量
int sum = 0;
std::for_each(data.begin(), data.end(), [&sum](int x) { sum += x; });
// sum == 15当 lambda 以引用方式捕获局部变量时,如果 lambda 的生命周期超过了该局部变量,就会产生悬垂引用,引用指向的内存已经被释放了。这种情况在异步回调和存储 lambda 的场景中尤其常见。咱们的 lambda 要是需要被存储或者传递到其他线程,优先用值捕获或者显式列出要按值捕获的变量。
排个序——std::sort 与 std::stable_sort
咱们看排序,大概是算法库里出场率最高的操作了。std::sort 接受两个迭代器,默认按升序排列。想直接传整个容器?那是 std::ranges::sort,C++20 起才有(std::ranges::sort(v))。底层是 Introsort,结合了快排、堆排和插入排序的优点,平均和最坏时间复杂度都是 O(n log n):
std::vector<int> v = {5, 2, 8, 1, 9, 3};
// 默认升序
std::sort(v.begin(), v.end());
// v: {1, 2, 3, 5, 8, 9}
// 降序——传第三个参数,一个比较 lambda
std::sort(v.begin(), v.end(), [](int a, int b) { return a > b; });
// v: {9, 8, 5, 3, 2, 1}第三个参数就是一个 lambda:它接收两个元素,返回 true 表示第一个参数应该排在第二个参数前面。这就是"自定义排序规则"的标准写法,后面您会反复看到这个模式。
std::stable_sort 和 sort 的区别在于"稳定性"——当两个元素比较结果相等时,stable_sort 保证它们保持原来的相对顺序。比如咱们先按成绩排序,再按班级排序,第二次排序时同一个班级内的学生仍然保持成绩从高到低的顺序。stable_sort 的代价是时间和空间开销略大,但对于需要保持排序稳定性的场景来说无可替代。
咱们给 sort 传入的比较函数必须满足"严格弱序"(strict weak ordering)。简单说就是:comp(a, a) 必须返回 false,如果 comp(a, b) 为 true 则 comp(b, a) 必须为 false,传递性也必须成立。咱们要是写了 <= 而不是 <,在某些标准库实现上会导致未定义行为——可能死循环,可能崩溃,也可能只是排序结果不对。所以比较函数永远用 <(升序)或 >(降序),不要用 <= 或 >=。
找东西——std::find 家族与二分查找
线性查找
咱们看 std::find 在范围内线性搜索等于指定值的第一个元素,返回指向它的迭代器;找不到就返回 end()。std::find_if 类似,但判断条件由一个 lambda 决定:
std::vector<std::string> names = {"Alice", "Bob", "Charlie", "David"};
// find:查找等于指定值的元素
auto it1 = std::find(names.begin(), names.end(), "Charlie");
// it1 指向 "Charlie"
// find_if:查找满足条件的第一个元素
auto it2 = std::find_if(names.begin(), names.end(),
[](const std::string& s) { return s.size() > 4; });
// it2 指向 "Alice"咱们看线性查找的时间复杂度是 O(n),不管数据有没有排序都能用。
二分查找
您手上的数据要是已经排好序了,用二分查找效率高得多——O(log n)。std::binary_search 返回一个 bool,告诉您这个值存不存在,但不会告诉您它在哪。您要是需要知道具体位置,用 std::lower_bound,它返回指向第一个大于等于目标值的元素的迭代器:
std::vector<int> v = {1, 3, 5, 7, 9, 11};
bool found = std::binary_search(v.begin(), v.end(), 7); // true
auto it = std::lower_bound(v.begin(), v.end(), 6);
// *it == 7,即第一个 >= 6 的元素咱们在无序数据上调用 lower_bound 或 binary_search 不会报错,但结果是未定义的,属于那种"编译能过、运行不崩、但结果不可信"的 bug,调试起来格外折磨人。
改一改——拷贝、变换、替换、删除
咱们看 std::copy 把一个范围内的元素拷贝到目标位置。std::transform 更强大,它在拷贝的同时对每个元素应用一个变换函数。std::replace 把范围内等于某个值的元素替换成另一个值:
std::vector<int> src = {1, 2, 3, 4, 5};
// copy
std::vector<int> dst;
std::copy(src.begin(), src.end(), std::back_inserter(dst));
// dst: {1, 2, 3, 4, 5}
// transform:每个元素乘以 10
std::vector<int> multiplied;
std::transform(src.begin(), src.end(), std::back_inserter(multiplied),
[](int x) { return x * 10; });
// multiplied: {10, 20, 30, 40, 50}
// replace:把所有 3 替换成 99
std::vector<int> v = {1, 3, 5, 3, 7};
std::replace(v.begin(), v.end(), 3, 99);
// v: {1, 99, 5, 99, 7}咱们这里遇到一个新面孔 std::back_inserter:它是一个插入迭代器,对它赋值就等于调用容器的 push_back。这样 copy 和 transform 就不需要目标容器预先分配好空间了。
再谈 remove-erase
上一章讲 vector 的时候咱们用过 remove-erase 惯用法。现在咱们把原理讲得更透一些。std::remove 把范围内所有不等于目标值的元素挪到前面,然后返回一个迭代器指向"新逻辑末尾"——这个过程不改变容器的大小,也不调用析构函数,纯粹是在已知的内存上移动元素。之后咱们再用容器的 erase 把新末尾到旧末尾之间的元素真正删掉,两步走才算完成:
std::vector<int> v = {1, 2, 3, 2, 4, 2, 5};
auto new_end = std::remove(v.begin(), v.end(), 2);
// v 的内容可能是: {1, 3, 4, 5, ?, ?, ?}
// ^new_end ^v.end()
v.erase(new_end, v.end());
// v: {1, 3, 4, 5}咱们看 std::remove_if 是同样的套路,只是判断条件由 lambda 决定。从 C++20 开始,std::erase(v, value) 和 std::erase_if(v, pred) 一步到位,编译器支持 C++20 的话直接用新写法就好。
算一算——累积、计数、极值
最后一组常用算法做的是"把一堆数据归纳成一个值"。咱们看 std::accumulate(需要 <numeric> 头文件)对范围内的元素逐个累加,初始值由您指定,它还可以接受一个自定义的二元操作来求乘积、拼接字符串等。std::count / std::count_if 统计等于指定值或满足条件的元素个数。std::min_element / std::max_element 分别返回指向最小和最大元素的迭代器:
std::vector<int> v = {3, 1, 4, 1, 5, 9, 2, 6};
int sum = std::accumulate(v.begin(), v.end(), 0); // 31
int product = std::accumulate(v.begin(), v.end(), 1, // 6480
std::multiplies<int>());
int ones = std::count(v.begin(), v.end(), 1); // 2
int above_4 = std::count_if(v.begin(), v.end(), // 3
[](int x) { return x > 4; });
auto min_it = std::min_element(v.begin(), v.end()); // *min_it == 1
auto max_it = std::max_element(v.begin(), v.end()); // *max_it == 9注意 accumulate 的初始值类型决定了整个计算的返回类型。传 0 得到 int,传 0.0 得到 double,传 0LL 得到 long long。咱们的 vector 里要是存的是大整数,初始值传 0 就有溢出风险——这是一个经典的踩坑点。
上号——综合实战:学生成绩处理
现在咱们把这一章涉及的所有算法和 lambda 表达式揉到一个实战程序里。场景很简单:处理一批学生的成绩数据,完成排序、查找优秀学生、计算平均分、过滤不及格这几项操作。
展开代码收起代码共 65 行
#include <algorithm>
#include <iostream>
#include <numeric>
#include <string>
#include <vector>
struct Student {
std::string name;
double score;
};
void print_student(const Student& s)
{
std::cout << " " << s.name << ": " << s.score << "\n";
}
int main()
{
std::vector<Student> students = {
{"Alice", 92.5},
{"Bob", 58.0},
{"Charlie", 76.0},
{"Diana", 88.5},
{"Eve", 45.0},
{"Frank", 95.0},
{"Grace", 71.5},
};
// --- 1. 按成绩从高到低排序 ---
std::sort(students.begin(), students.end(),
[](const Student& a, const Student& b) { return a.score > b.score; });
std::cout << "=== Ranking (high to low) ===\n";
for (const auto& s : students) { print_student(s); }
// --- 2. 查找最高分的学生 ---
auto top = std::max_element(students.begin(), students.end(),
[](const Student& a, const Student& b) { return a.score < b.score; });
std::cout << "\nTop student: " << top->name
<< " (" << top->score << ")\n";
// --- 3. 计算平均分 ---
double sum = std::accumulate(students.begin(), students.end(), 0.0,
[](double acc, const Student& s) { return acc + s.score; });
std::cout << "Average score: "
<< sum / static_cast<double>(students.size()) << "\n";
// --- 4. 统计及格和不及格的人数 ---
int passing = std::count_if(students.begin(), students.end(),
[](const Student& s) { return s.score >= 60.0; });
std::cout << "Passing: " << passing
<< ", Failing: " << static_cast<int>(students.size()) - passing
<< "\n";
// --- 5. 过滤不及格的学生(remove-erase)---
std::vector<Student> filtered = students;
auto it = std::remove_if(filtered.begin(), filtered.end(),
[](const Student& s) { return s.score < 60.0; });
filtered.erase(it, filtered.end());
std::cout << "\n=== Passing students ===\n";
for (const auto& s : filtered) { print_student(s); }
return 0;
}这份完整代码就在下面,点「动手试一试」直接跑,不用开终端:
Compiler Explorer
上号:学生成绩处理 algo_demo.cpp
在线运行学生成绩处理,全程没有一个手写的数据循环。试着把排序比较器里的 > 换成 <,看排名整个倒过来。
咱们看整个程序从排序到统计到过滤,全程没有出现手写的 for 循环做数据操作——这就是 STL 算法的力量。每个操作的意图一眼就能看出来:sort 就是排序,max_element 就是找最大,count_if 就是按条件计数,remove_if + erase 就是按条件删除。对比手写循环,意图表达要清晰得多。
动手试试——练习题
练习 1:多字段排序
请您定义一个结构体 Employee,包含 name(std::string)、department(std::string)和 salary(int)。创建一个包含若干员工的 vector,实现先按部门名字典序排序,同一个部门内按薪资降序排序。提示:lambda 里先比较部门,部门相同时再比较薪资。
struct Employee {
std::string name;
std::string department;
int salary;
};练习 2:文本处理管道
给定一个 std::vector<std::string> 表示若干行文本,请您用 STL 算法实现一个简单的文本处理管道:把所有空行删掉(remove_if),把每一行转成全小写(std::transform 逐字符处理),然后按字典序排序并去重(std::unique + erase)。每一步都用一个独立的算法调用完成,不要写手动的 for 循环。
std::vector<std::string> lines = {
"Hello World", "", "hello world", "Goodbye", "GOODBYE", "", "Alice"
};参考资源