K03 排序与贪心 + 二分与倍增(第二轮系统课 · 第 3 次)

本次两个模块:

模块 内容 复用的旧讲义 本讲义新写
M5 排序与贪心 sort + 多关键字、稳定性、计数排序、三种 O(n²) 排序、三类贪心 L02 全部、S2、S3 贪心三类题型、"交换一下会不会更好"
M6 二分与倍增 二分查找、二分答案、lower_bound/upper_bound、快速幂 L03 全部 upper_bound 数个数、二分四个坑、倍增与快速幂

一、M5 排序与贪心

盲写(15 分钟,合上讲义)

输入 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 可能越界崩溃——而且样例常常测不出来。

1.1 复用:先翻旧讲义

内容 在哪
struct + cmp 多关键字(排队接水、奖学金) L02
sort 不稳定、stable_sort、把原下标当最后一个关键字 S2
计数排序(值域小就用桶)、冒泡/选择/插入的写法 S3

什么时候用哪种排序:

情况 用什么
一般情况 sort,O(n log n)
值域很小(比如分数 ≤ 600),n 很大 计数排序(桶),O(n + 值域)
要保持相同元素的原顺序 stable_sort,或者把原下标加进 cmp
题目问"交换了几次" / "冒泡排序第几轮" 手写冒泡(题目考的就是过程)

1.2 贪心:三类最常见的题型

贪心 = 每一步都选当前看起来最好的。难点不在写,在于判断"这样选到底对不对"。CSP-J 里的贪心题,九成是下面三类之一:

题型 典型说法 做法
排序后按顺序取 "总等待时间最少""总代价最小" 按某个量排序,从小到大(或从大到小)处理。例:排队接水(L02)
区间选择 "最多能参加几个活动""最少用几个点覆盖" 按右端点从小到大排序,能选就选
两头配对 "每组最多两个""最少分几组" 排序后双指针,最大的配最小的。例:纪念品分组(L02)

区间选择(最多选几个互不重叠的区间)手推一遍:

区间 [1,3] [2,5] [4,6] [6,8] [5,9]
右端点 3 5 6 8 9

按右端点排好(就是上表的顺序),维护"上一个选中区间的右端点" last:

  1. [1,3]:开头就选,last = 3
  2. [2,5]:左端点 2 < 3,重叠,跳过
  3. [4,6]:4 ≥ 3,选,last = 6
  4. [6,8]:6 ≥ 6(端点相接算不算重叠,要看题目),选,last = 8
  5. [5,9]:5 < 8,跳过

答案是 3 个。

⚠️ 为什么按右端点,不按左端点? 按左端点排序,[1,100] 排在最前面会被先选,一个区间就占满了。右端点小 = 结束得早 = 给后面留的空间多。

1.3 "交换一下会不会更好"

拿不准贪心对不对时,用这招检查:假设最优答案里有两个相邻的元素顺序和你的贪心不一样,把它们交换,答案会不会变差?

排队接水:两个人接水时间是 a < b。a 在前,两人总等待是 a + (a + b);b 在前是 b + (b + a)。前者更小,所以时间短的应该排前面。

📝 考场上不需要写证明,但要在纸上用两个数算一下。 算不出来就写暴力拿部分分,别赌。


二、M6 二分与倍增

盲写(15 分钟,合上讲义,不许用 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)。

2.1 复用:先翻旧讲义

内容 在哪
二分查找、lower_bound L03 前半
二分答案:"最大值最小 / 最小值最大"、写 check 函数(木材加工、跳石头) L03 后半

2.2 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),用之前要先判断,否则越界。

2.3 二分的四个坑

坑 症状 对策
mid = (l + r) / 2 溢出 l、r 接近 2×10⁹ 时变成负数 写 l + (r - l) / 2,或 l、r 用 long long
答案范围定错 答案明明是 0 却输出别的 左边界从可能的最小答案开始(很多题是 0 或 1)
check 方向反了 结果总是偏一个 先用样例手算 check(答案) 和 check(答案+1),应该一真一假
题目根本没有单调性 大部分数据错 二分答案前先问:答案变大,条件是不是一直满足/一直不满足?

2.4 倍增:一步跳 1、2、4、8……

倍增的想法:要走 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 木棍加工(黄)

五、30 秒自测

  1. cmp 里为什么不能写 <=?
  2. 区间选择为什么按右端点排序?
  3. upper_bound - lower_bound 算的是什么?
  4. 二分答案前要先确认题目有什么性质?
  5. 快速幂为什么是 O(log b)?ans 的初值为什么写 1 % p?
答案
  1. 相等元素时 sort 可能越界崩溃,cmp 必须是严格的"小于"。
  2. 结束得早,给后面留的空间最多。
  3. 等于 x 的元素个数(数组要先排序)。
  4. 单调性:答案变大时,条件一直满足或一直不满足。
  5. 每轮 b 除以 2,共 log b 轮;如果题目允许 p = 1,答案应该是 0。

本次打卡:M5 ①、M6 ①(两道盲写);作业 AC 后打 ②③。