本次两个模块:
| 模块 | 内容 | 复用的旧讲义 | 本讲义新写 |
|---|---|---|---|
| M5 排序与贪心 | sort + 多关键字、稳定性、计数排序、三种 O(n²)
排序、三类贪心 |
L02 全部、S2、S3 |
贪心三类题型、"交换一下会不会更好" |
| M6 二分与倍增 | 二分查找、二分答案、lower_bound/upper_bound、快速幂 |
L03 全部 |
upper_bound
数个数、二分四个坑、倍增与快速幂 |
输入 n(n ≤ 10⁵)和 k,接下来 n 行,每行一个学生的学号和总分。按总分从高到低排序,总分相同按学号从小到大,输出前 k 名的学号和总分。
输入:
4 3 3 90 1 95 2 90 4 80输出:
1 95/2 90/3 90(每个一行)
#include <bits/stdc++.h>
using namespace std;
struct Stu {
int id, score;
};
bool cmp(const Stu& a, const Stu& b) {
if (a.score != b.score) return a.score > b.score; // 第一关键字:总分降序
return a.id < b.id; // 第二关键字:学号升序
}
Stu s[100005];
int main() {
int n, k;
cin >> n >> k;
for (int i = 0; i < n; i++) cin >> s[i].id >> s[i].score;
sort(s, s + n, cmp);
for (int i = 0; i < k; i++) cout << s[i].id << " " << s[i].score << endl;
return 0;
}⚠️ cmp 里只能用 < 或
>,绝不能用 <= 或
>=。 用了等号,遇到相等的元素 sort
可能越界崩溃——而且样例常常测不出来。
| 内容 | 在哪 |
|---|---|
struct + cmp
多关键字(排队接水、奖学金) |
L02 |
sort
不稳定、stable_sort、把原下标当最后一个关键字 |
S2 |
| 计数排序(值域小就用桶)、冒泡/选择/插入的写法 | S3 |
什么时候用哪种排序:
| 情况 | 用什么 |
|---|---|
| 一般情况 | sort,O(n log n) |
| 值域很小(比如分数 ≤ 600),n 很大 | 计数排序(桶),O(n + 值域) |
| 要保持相同元素的原顺序 | stable_sort,或者把原下标加进 cmp |
| 题目问"交换了几次" / "冒泡排序第几轮" | 手写冒泡(题目考的就是过程) |
贪心 = 每一步都选当前看起来最好的。难点不在写,在于判断"这样选到底对不对"。CSP-J 里的贪心题,九成是下面三类之一:
| 题型 | 典型说法 | 做法 |
|---|---|---|
| 排序后按顺序取 | "总等待时间最少""总代价最小" | 按某个量排序,从小到大(或从大到小)处理。例:排队接水(L02) |
| 区间选择 | "最多能参加几个活动""最少用几个点覆盖" | 按右端点从小到大排序,能选就选 |
| 两头配对 | "每组最多两个""最少分几组" | 排序后双指针,最大的配最小的。例:纪念品分组(L02) |
区间选择(最多选几个互不重叠的区间)手推一遍:
| 区间 | [1,3] | [2,5] | [4,6] | [6,8] | [5,9] |
|---|---|---|---|---|---|
| 右端点 | 3 | 5 | 6 | 8 | 9 |
按右端点排好(就是上表的顺序),维护"上一个选中区间的右端点"
last:
last = 3last = 6last = 8答案是 3 个。
⚠️ 为什么按右端点,不按左端点? 按左端点排序,[1,100] 排在最前面会被先选,一个区间就占满了。右端点小 = 结束得早 = 给后面留的空间多。
拿不准贪心对不对时,用这招检查:假设最优答案里有两个相邻的元素顺序和你的贪心不一样,把它们交换,答案会不会变差?
排队接水:两个人接水时间是 a < b。a 在前,两人总等待是 a + (a + b);b 在前是 b + (b + a)。前者更小,所以时间短的应该排前面。
📝 考场上不需要写证明,但要在纸上用两个数算一下。 算不出来就写暴力拿部分分,别赌。
lower_bound)输入 n 个从小到大排好的整数,再输入 m 个询问,每个询问一个 x,输出第一个 ≥ x 的数的下标(从 1 开始),没有则输出
-1。输入
5 3/1 3 3 5 7/3 4 8→ 输出2 4 -1
#include <bits/stdc++.h>
using namespace std;
int a[1000005];
int main() {
int n, m;
cin >> n >> m;
for (int i = 1; i <= n; i++) cin >> a[i];
while (m--) {
int x;
cin >> x;
int l = 1, r = n, ans = -1;
while (l <= r) {
int mid = l + (r - l) / 2;
if (a[mid] >= x) { ans = mid; r = mid - 1; } // 满足条件:记下来,继续往左找更靠前的
else l = mid + 1;
}
cout << ans << " ";
}
cout << endl;
return 0;
}📝 这个写法的好处:ans
专门记答案,循环条件永远是 l <= r,不用纠结
r = mid 还是 r = mid - 1。
二分答案也用同一个骨架(L03)。
| 内容 | 在哪 |
|---|---|
二分查找、lower_bound |
L03 前半 |
二分答案:"最大值最小 / 最小值最大"、写 check
函数(木材加工、跳石头) |
L03 后半 |
lower_bound 和
upper_bound| 函数 | 返回 | 例:在 1 3 3 5 7 里找 3 |
|---|---|---|
lower_bound(a, a+n, x) |
第一个 ≥ x 的位置 | 指向第一个 3(下标 1) |
upper_bound(a, a+n, x) |
第一个 > x 的位置 | 指向 5(下标 3) |
| 两者相减 | x 出现了几次 | 3 − 1 = 2 |
#include <bits/stdc++.h>
using namespace std;
int main() {
int a[] = {1, 3, 3, 5, 7};
int n = 5;
int x = 3;
int L = lower_bound(a, a + n, x) - a; // 减去数组名,得到下标
int R = upper_bound(a, a + n, x) - a;
cout << L << " " << R << " " << R - L << endl;
int y = 8;
int p = lower_bound(a, a + n, y) - a;
cout << (p == n ? "not found" : "found") << endl; // 找不到时返回 n(末尾后面一格)
return 0;
}⚠️ 两个前提:①数组必须已经排好序;②找不到时返回的是末尾后面那一格(下标 n),用之前要先判断,否则越界。
| 坑 | 症状 | 对策 |
|---|---|---|
mid = (l + r) / 2 溢出 |
l、r 接近 2×10⁹ 时变成负数 | 写 l + (r - l) / 2,或 l、r 用
long long |
| 答案范围定错 | 答案明明是 0 却输出别的 | 左边界从可能的最小答案开始(很多题是 0 或 1) |
check 方向反了 |
结果总是偏一个 | 先用样例手算 check(答案) 和
check(答案+1),应该一真一假 |
| 题目根本没有单调性 | 大部分数据错 | 二分答案前先问:答案变大,条件是不是一直满足/一直不满足? |
倍增的想法:要走 b 步,不一步一步走,而是按 1、2、4、8……
这些"2 的幂"来跳。 任何一个数都能拆成若干个 2
的幂之和(这就是二进制,S1),所以最多跳 log b 次。
最经典的应用是快速幂:算 a^b mod p,b 可以到 10⁹ 甚至 10¹⁸。
一步一步乘要 b 次,超时。快速幂的想法:
#include <bits/stdc++.h>
using namespace std;
// 计算 a^b mod p,O(log b)
long long qpow(long long a, long long b, long long p) {
long long ans = 1 % p; // p=1 时答案是 0,这个 % p 不能省
a %= p;
while (b > 0) {
if (b & 1) ans = ans * a % p; // 这一位是 1:乘进答案
a = a * a % p; // a 变成 a^2、a^4、a^8 ……
b >>= 1; // 看下一位
}
return ans;
}
int main() {
cout << qpow(2, 10, 1000) << endl;
cout << qpow(3, 1000000000, 1000000007) << endl;
cout << qpow(5, 0, 1) << endl;
return 0;
}⚠️ 两个坑:①ans * a 和
a * a 都可能到 p² ≈ 10¹⁸,必须
long long,而且 p 不能超过约
3×10⁹;②**1 % p 比 1 更保险**——如果题目允许 p
= 1,任何数模 1 都是 0,而 b = 0 时循环一次都不进,直接返回初值。(P1226
保证 p ≥ 2,但别的题不一定。)
📝 倍增和 K01 的优秀的拆分是同一件事:把一个数拆成 2 的幂之和,就是看它的二进制哪几位是 1。
| 环节 | 题目 | 要点 |
|---|---|---|
| 课堂题 1 | P1068 分数线划定(橙) | 多关键字排序;"150%"向下取整怎么写 |
| 课堂题 2 | P4995 跳跳!(橙) | 贪心:先在纸上用 3 个数试出规律,再写 |
| 课堂题 3 | P2249 查找(橙) | 就是二分盲写题;m 很大时读入用 scanf |
| 课堂题 4 | P1226 快速幂(橙) | 就是 2.4 节;输出格式是 a^b mod p=s,照抄题面 |
| 小测 1(20 分钟) | P1873 砍树(橙) | 二分答案:答案越大,砍下的木头越多还是越少? |
| 小测 2(20 分钟) | P7072 直播获奖(CSP-J 2020 T2) | 分数 ≤ 600 在暗示什么?(S0 第四节"闲话"表) |
| 档 | 必做(4 道) | 选做 |
|---|---|---|
| B 档 | ① P1059 明明的随机数(橙,排序去重) ② P1803 凌乱的 yyy / 线段覆盖(橙,1.2 节区间选择) ③ P3817 小 A 的糖果(橙,贪心) ④ P1102 A-B 数对(橙,2.2 节数个数) | P7910 插入排序(CSP-J 2021 T2,黄) |
| A 档 | ① P3817 小 A 的糖果(橙) ② P1106 删数问题(黄,贪心) ③ P1182
数列分段 Section II(黄,二分答案) ④ P7910 插入排序(CSP-J 2021
T2,黄,S2 稳定性) |
P1233 木棍加工(黄) |
cmp 里为什么不能写 <=?upper_bound - lower_bound 算的是什么?ans 的初值为什么写
1 % p?sort 可能越界崩溃,cmp
必须是严格的"小于"。本次打卡:M5 ①、M6 ①(两道盲写);作业 AC 后打 ②③。