C++ 竞赛入门
STL 算法
算法通过迭代器处理区间:看输入范围、返回值、是否修改数据三个入口。
STL 算法
读完能做什么
- 能看懂
算法名(起点, 终点)这种调用形式,说出它要处理的是哪一段数据。 - 能用“输入范围、返回值、是否修改数据”三个入口读懂一个没见过的算法。
- 能预测只排序一段区间时,哪些位置会变、哪些位置保持原样。
- 能按任务(查找、排序、去重、第 k 小)说出该用哪个算法,并找到本章对应的页面。
为什么需要它
同样一件事——把一组数排好序——数据可能放在普通数组里,也可能放在 vector、deque 里。如果为每种数据各写一份排序代码,同一份逻辑就要重复好几遍。
STL 的做法是:让算法只认位置,不认数据存在哪里。你只要说清“从哪个位置到哪个位置”,算法就能处理。于是同一句 sort(...) 既能排数组,也能排 vector:
sort(a, a + n); // 普通数组:起点 a,终点 a + n
sort(v.begin(), v.end()); // vector:起点 begin(),终点 end()
这两行片段(省略了头文件和 main)形状几乎一样,只是“起点、终点”换了个写法。本页就把这种调用形式讲清楚,并告诉你本章已经讲过哪些算法、分别解决什么问题。
它是什么
一句话:STL 算法是一批函数模板,它们不关心数据存在哪里,只按“从哪个位置到哪个位置”处理一段数据。
函数模板。 “模板”指同一份代码可以给不同类型使用,编译时按你实际传入的类型生成对应的函数。sort 是函数模板,所以它能排 int、double、string,你不必为每种类型各写一份。
迭代器是算法和容器之间的桥梁。 迭代器是“指向容器中某个位置的游标”:v.begin() 停在第一个元素上,v.end() 停在最后一个元素后面的位置(尾后位置,那里没有元素)。算法只拿到两个位置,就能从头走到尾处理这段数据——它不需要知道数据存在数组还是链表里。
区间 [first, last)。 两个位置组成一个左闭右开区间:包含 first 指向的元素,不包含 last 指向的元素。所以 [v.begin(), v.end()) 正好是整个 vector,[v.begin() + 1, v.begin() + 3) 只含下标 1、2 两个位置。这套约定在容器、迭代器与区间里详细讲过,本页所有算法都遵守它。
普通数组也能用。 数组名可以当成指向首元素的指针,指针在算法眼里就是一种迭代器:a 是起点,a + n 是终点。所以数组不需要任何包装就能交给算法。
graph LR
data["数据 4 3 1 2"] --> first["起点 first"]
data --> last["终点 last"]
first --> algo["算法"]
last --> algo
algo --> out["处理后的区间"]
最小可运行示例
用 sort 把 [4, 3, 1, 2] 从小到大排好:
#include <bits/stdc++.h>
using namespace std;
int main() {
vector<int> v = {4, 3, 1, 2};
sort(v.begin(), v.end()); // 处理整个区间 [begin, end)
for (int x : v) cout << x << ' ';
cout << '\n';
return 0;
}
输出:
1 2 3 4
逐行看:
sort(v.begin(), v.end())里两个参数就是区间的起点和终点:v.begin()指向 4,v.end()是 2 后面那个尾后位置,所以这段区间覆盖全部 4 个元素。sort直接修改v里的元素顺序,它不返回新的容器,也不需要你把结果接住。- 排完之后按顺序打印,得到
1 2 3 4。 - 算法都在
<algorithm>里声明,本页示例用#include <bits/stdc++.h>一次包含全部标准头文件。
一步步看变化
还是这组数据 [4, 3, 1, 2],这次先只排中间两个位置,再排全体。局部排序用区间 [v.begin() + 1, v.begin() + 3),即下标 1、2:
| 时刻 | 下标 0 | 下标 1 | 下标 2 | 下标 3 | 谁参与这次排序 |
|---|---|---|---|---|---|
| 初始 | 4 | 3 | 1 | 2 | — |
sort(begin+1, begin+3) 之后 |
4 | 1 | 3 | 2 | 只有下标 1、2 |
再 sort(begin, end) 之后 |
1 | 2 | 3 | 4 | 全部 4 个位置 |
#include <bits/stdc++.h>
using namespace std;
int main() {
vector<int> v = {4, 3, 1, 2};
sort(v.begin() + 1, v.begin() + 3); // 只处理下标 1、2 这两个位置
cout << "局部排序后:";
for (int x : v) cout << x << ' ';
cout << '\n';
sort(v.begin(), v.end()); // 再处理全体
cout << "全体排序后:";
for (int x : v) cout << x << ' ';
cout << '\n';
return 0;
}
输出:
局部排序后:4 1 3 2
全体排序后:1 2 3 4
v.begin() + 1 指向下标 1 的 3,v.begin() + 3 指向下标 3 的 2。因为区间是左闭右开的,终点取不到,所以真正参与排序的只有下标 1 和 2 上的 3、1。它们排成 1、3 之后,下标 0 的 4 和下标 3 的 2 一动没动,得到 4 1 3 2。
这也解释了为什么“终点写在哪里”特别重要:把终点从 v.begin() + 3 改成 v.begin() + 4(也就是 v.end()),参与排序的位置就多了一个,结果会完全不同。
逐项扩展核心操作
同一个算法,不同的数据来源
sort 不关心数据放在哪里,所以数组和 deque 可以用同一句调用:
#include <bits/stdc++.h>
using namespace std;
int main() {
int a[4] = {4, 3, 1, 2};
deque<int> d = {4, 3, 1, 2};
sort(a, a + 4); // 普通数组:起点 a,终点 a + 4
sort(d.begin(), d.end()); // 容器:起点 begin(),终点 end()
cout << "数组:";
for (int i = 0; i < 4; i++) cout << a[i] << ' ';
cout << '\n';
cout << "deque:";
for (int x : d) cout << x << ' ';
cout << '\n';
return 0;
}
输出:
数组:1 2 3 4
deque:1 2 3 4
同一份 sort 代码为什么能处理两种数据?因为算法只要求迭代器支持它需要的操作,不要求数据存放在哪里。sort 需要随机访问迭代器(能随意跳、能相减),数组指针、vector、deque 的迭代器都满足;list 的迭代器只能 ++ / --,不满足,所以 sort(l.begin(), l.end()) 编译不过,要改用成员函数 l.sort()(见 list 链表)。
读懂一个算法的三个入口
遇到一个没见过的算法,先问三个问题:
- 输入范围是什么? 从哪个位置到哪个位置?对数据有没有额外要求(比如必须已经有序)?
- 返回值是什么? 什么都不返回、返回一个位置、返回
true/false,还是返回一个个数? - 会不会修改数据? 只是读一遍,还是会把元素重新排列?
用本章的算法对照一遍:
| 算法 | 输入范围 | 返回值 | 是否修改数据 |
|---|---|---|---|
sort(first, last) |
随机访问迭代器的区间(vector、deque、数组) |
无 | 会(重排元素) |
stable_sort(first, last) |
同上,也要求随机访问迭代器 | 无 | 会(重排,相等元素保持原顺序) |
find(first, last, x) |
任意区间 | 位置;没找到就是 last |
不会 |
find_if(first, last, pred) |
任意区间 + 一个判断条件 | 位置;没找到就是 last |
不会 |
binary_search(first, last, x) |
有序区间 | bool |
不会 |
lower_bound(first, last, x) |
有序区间 | 第一个不小于 x 的位置 |
不会 |
upper_bound(first, last, x) |
有序区间 | 第一个大于 x 的位置 |
不会 |
unique(first, last) |
任意区间 | 新的逻辑末尾位置 | 会(覆盖元素,容器大小不变) |
nth_element(first, mid, last) |
随机访问迭代器的区间 | 无 | 会(只保证 mid 位置正确) |
输入范围:只处理一段
find 是“找到某个值的位置”,用它看区间怎么起作用。只在后两个位置里找 3:
#include <bits/stdc++.h>
using namespace std;
int main() {
vector<int> v = {8, 3, 5, 3};
auto it = find(v.begin() + 2, v.end(), 3); // 只在后两个位置里找
cout << "从下标 2 开始找 3,命中下标 " << (it - v.begin()) << '\n';
bool inFront = (find(v.begin(), v.begin() + 2, 5) != v.begin() + 2);
cout << "前两个位置里有 5 吗:" << inFront << '\n';
return 0;
}
输出:
从下标 2 开始找 3,命中下标 3
前两个位置里有 5 吗:0
- 第一个
find的区间是[begin + 2, end),只覆盖下标 2、3 上的5、3,所以命中下标 3 的那个 3,而不是下标 1 的 3。 - 第二个
find的区间是[begin, begin + 2),只覆盖下标 0、1 上的8、3。里面没有 5,于是它返回区间的终点begin + 2;把它和终点比较,得到false,打印 0。 - 没找到时返回的是“你给的终点”,不是“容器的终点”。 判断有没有找到,一定要拿返回值和同一个终点比较。
返回值:位置、真假
find 返回一个位置,这个位置不能直接拿来用:
#include <bits/stdc++.h>
using namespace std;
int main() {
vector<int> v = {8, 3, 5, 3};
auto it = find(v.begin(), v.end(), 3); // 输入区间 [begin, end),要找 3
if (it != v.end()) { // 返回值是位置,先和终点比较
cout << "找到 3,下标 " << (it - v.begin()) << '\n';
} else {
cout << "没找到 3\n";
}
cout << "找完之后 v 的内容没变:";
for (int x : v) cout << x << ' ';
cout << '\n';
return 0;
}
输出:
找到 3,下标 1
找完之后 v 的内容没变:8 3 5 3
it != v.end()是必须的一步:find找不到时返回终点,直接解引用会越界。这属于“解引用之前先确认位置有效”的通用习惯。it - v.begin()把位置换算成下标。这个减法只有随机访问迭代器(vector、deque、数组)支持,list、set上不能这样算。- 输出最后一行说明
find没有修改v:它只是读了一遍。
和它相反的是 sort:sort 没有返回值,但它会改动数据本身。返回值、是否修改数据,就是这两个算法的区别所在。
按任务找页面
本章已经讲过的算法只有下面这些,遇到对应任务时直接翻那一页:
| 任务 | 用什么 | 去哪个页面 |
|---|---|---|
| 找一个值在不在、找第一个满足条件的元素 | find、find_if |
find 查找 |
| 把数据按规则排好序 | sort、stable_sort |
sort 排序 |
| 在有序数据里判断存在、找边界位置 | binary_search、lower_bound、upper_bound |
二分查找 |
| 相邻去重、把重复值真正删掉 | unique |
unique 去重 |
| 只要第 k 小的元素,不必全部排序 | nth_element |
nth_element 第 k 小 |
<algorithm> 头文件里还有几十个别的函数。本页只列本章已经讲过的部分,等你需要时再查手册——不要因为一个函数“名字看着眼熟”就以为已经掌握它的前提条件和返回值。
选择自测:下面四个任务分别该翻哪一页、用哪个算法?先自己回答,再展开核对。
- 一组数已经按从小到大排好,只要判断
7在不在里面。 - 读入一堆整数,要去重后按从小到大输出。
- 只想知道第 5 小的数是多少,不要求把整组数排好。
- 数据存在
list里,想排序。
选择自测的答案与解释
- 二分查找页,用
binary_search(first, last, 7)。它要求区间已经有序,返回bool,不修改数据。想同时知道“第一个 7 在哪”,就用lower_bound。 - unique 页,用
sort+unique+erase三步:先排序把相等的值聚到一起,unique合并相邻相等元素,最后erase把尾段真正删掉。只调用unique而不erase,容器大小不会变,后面按size()遍历就会读到无效的尾段。 - nth_element 页,用
nth_element(v.begin(), v.begin() + 4, v.end())。第 5 小对应下标5 - 1 = 4,调用后这个位置上的就是答案,两侧各自不保证有序。 - 不能用
std::sort:它需要随机访问迭代器,list的迭代器只能++/--,编译不过。要用成员函数l.sort(),见 list 链表。
什么时候选它、哪里容易错
区间是左闭右开的。 [first, last) 不包含 last。所以“处理前 3 个元素”写 [begin, begin + 3),“处理整个容器”写 [begin, end())。终点写多一格、写少一格,结果就变了。
注意算法的前提条件。 binary_search、lower_bound、upper_bound 都要求区间已经按同一规则有序;sort 要求随机访问迭代器。前提不满足时,要么编译不过,要么结果不可靠:
// 错误示例:二分查找要求区间已经有序,下面这段在乱序数据上调用,结果不可靠
#include <bits/stdc++.h>
using namespace std;
int main() {
vector<int> v = {3, 1, 4, 2}; // 没有排序
cout << binary_search(v.begin(), v.end(), 2) << '\n'; // 结果不可靠,不要照着写
return 0;
}
这段能编译,但打印出来的真假没有意义:二分查找每步都要“砍掉一半”,数据没序就砍错了地方。要先用 sort 排好,再查。
返回值要检查再用。 find、lower_bound、upper_bound 返回的是位置,没找到时返回终点;解引用之前先和终点比较。binary_search 返回的是 bool,不能拿它当下标用。
复杂度差别很大。
| 算法 | 复杂度 | 说明 |
|---|---|---|
find、find_if |
O(n) | 从头到尾一个个看 |
sort、stable_sort |
O(n log n) | 需要随机访问迭代器 |
binary_search、lower_bound、upper_bound |
O(log n) | 前提是有序 |
unique |
O(n) | 顺着区间走一遍 |
nth_element |
平均 O(n) | 只定位,不全部排序 |
算法不调用容器的成员函数。 它只拿到两个位置,然后通过迭代器一步步走。所以算法不知道容器里现在有多少元素、会不会扩容,它只负责“把这段区间处理好”——这也是它能同时服务数组和各种容器的原因。
操作速查表
| 写法 | 作用 | 备注 |
|---|---|---|
sort(first, last) |
把区间升序排序 | 需要随机访问迭代器,会修改数据 |
stable_sort(first, last) |
稳定排序 | 相等元素保持原顺序 |
find(first, last, x) |
找第一个等于 x 的位置 |
没找到返回 last |
find_if(first, last, pred) |
找第一个满足条件的位置 | 没找到返回 last |
binary_search(first, last, x) |
判断 x 是否存在 |
要求有序,返回 bool |
lower_bound(first, last, x) |
第一个不小于 x 的位置 |
要求有序 |
upper_bound(first, last, x) |
第一个大于 x 的位置 |
要求有序 |
unique(first, last) |
合并相邻相等元素 | 返回新的逻辑末尾 |
nth_element(first, mid, last) |
让 mid 位置放上第 k 小 |
需要随机访问迭代器;两侧不保证有序 |
sort(a, a + n) |
对普通数组使用 | 指针就是迭代器 |
[first, last) |
左闭右开区间 | 不含 last |
动手检查
第 1 题:预测输出
下面是一段片段(省略了头文件和 main),请先手推出输出,再运行核对:
vector<int> v = {5, 2, 4, 3, 1};
sort(v.begin() + 1, v.begin() + 4); // 只处理下标 1、2、3
for (int x : v) cout << x << ' ';
第 2 题:修改代码
下面这段片段现在把 [4,3,1,2] 全体排序。请改成只排后 3 个位置(下标 1、2、3),让输出变成 4 1 2 3:
vector<int> v = {4, 3, 1, 2};
sort(v.begin(), v.end()); // 现在排的是全体
for (int x : v) cout << x << ' ';
验证条件:先说出新的区间起点、终点分别指向哪个位置,以及下标 0 的元素为什么不会动,再运行核对。
参考结果与解释
第 1 题:输出是 5 2 3 4 1 。区间是 [begin + 1, begin + 4),覆盖下标 1、2、3 上的 2、4、3,排成 2、3、4;下标 0 的 5 和下标 4 的 1 不参与,保持原样,所以结果是 5 2 3 4 1。完整程序与实测输出:
#include <bits/stdc++.h>
using namespace std;
int main() {
vector<int> v = {5, 2, 4, 3, 1};
sort(v.begin() + 1, v.begin() + 4); // 只处理下标 1、2、3
for (int x : v) cout << x << ' ';
cout << '\n';
return 0;
}
输出:
5 2 3 4 1
第 2 题:把调用改成 sort(v.begin() + 1, v.end());。起点 v.begin() + 1 指向下标 1 的 3,终点 v.end() 是尾后位置,所以区间覆盖下标 1、2、3。下标 0 的 4 在区间之外,算法根本不会碰它,它就一直留在第一位。3、1、2 排成 1、2、3,最终输出 4 1 2 3。
#include <bits/stdc++.h>
using namespace std;
int main() {
vector<int> v = {4, 3, 1, 2};
sort(v.begin() + 1, v.end()); // 只排后 3 个位置
for (int x : v) cout << x << ' ';
cout << '\n';
return 0;
}
输出:
4 1 2 3
立即练习
本页是算法入口课,重点是读懂“区间 + 返回值 + 是否修改数据”这套调用形式,所以不安排 OJ 题目:本章每一类算法都有自己的页面,等你学完 sort、查找、去重、nth_element 之后,再到各页的「立即练习」做题更合适。