贪心基础
最优化问题是指,在给定的限制条件下,寻找一个方案,使得目标结果尽可能最优。例如,要从学校到北京天安门,有很多种不同的交通方案,如何选择一个最省钱的方案?
很多最优化问题,都可以看成多步决策问题,即把解决问题的过程分成若干步,每一步有若干种决策方案。在每一步做出一个决策,最终解决整个问题。
比如,以从学校到天安门的问题为例,假设分成 3 个阶段:
- 从学校到杭州东站,
- 从杭州东站到北京南站。
- 从北京南站到天安门广场。
第一个阶段,从学校到杭州东站,有 3 种不同方案,分别是:骑共享单车,花费 1.5 元;乘坐地铁,花费 5 元;打车,花费 30 元。
第二个阶段,从杭州东站到北京南站,有 2 种不同方案,分别是:二等座,花费 594 元;一等座,花费 959 元。
第三个阶段,从北京南站到天安门,有 3 种不同方案,分别是:坐公交车,花费 2 元;打车,花费 25 元;走路,花费 0 元。
对于这一多步决策问题,我们采取贪心策略,既然想要总的花费最小,那么每一步决策都采取最便宜的方案,最后得到的结果也就是全局的最优解了。所以我们选择骑共享单车去杭州东站,坐二等座去北京南站,走路去天安门,总的花费是 \(4 + 594 + 0 = 598\) 元。
事实上,贪心策略不一定永远是最优的。比如在这个例子中,如果我们因为第一步选择骑车,可能导致体力不够最后的走路,只能坐公交车去天安门。这样总的花费会变成 \(4 + 594 + 2 = 600\) 元,反而不如第一步坐地铁划算。
贪心算法不是对所有问题都能得到整体最优解,关键是贪心策略的选择,选择的贪心策略必须具备无后效性,即某个状态以前的过程不会影响以后的状态,只与当前状态有关。只有贪心策略是正确的,才能使用贪心算法正确地解决该问题。在很多情况下,贪心的合理性并不是显然的,但如果能找到一个反例,就可以证明这样的贪心不正确。
从另一个角度而言,信息学竞赛不同于数学竞赛,信息学竞赛更关注最终实现的程序。所以,有时候可以采取猜结论的策略(当然最好是能证明正确性,贪心策略的证明分析可以借助一些专门的方法),考场上如果无法证明正确性,但直觉认为是对的,又无法构造反例,不妨先大胆实现。不过训练中还是应该尽量搞清楚每一个贪心问题的策略证明。
例题:P2676 [USACO07DEC] Bookshelf B
分析:根据题目描述可以将题意理解为,尽可能使奶牛数量最少,还可以达到书架的高度 B。我们需要先将奶牛的高度从大到小进行排序,让高个子奶牛尽可能先上场,这样贪心地选择奶牛,可以使最后奶牛数量尽可能少。
排序完成之后,从身高最高的奶牛开始,累加当前所有参与“奶牛塔”的奶牛身高。如果达到了暑假高度 B,直接输出此时的奶牛数量,并跳出循环;相反,如果身高总和小于暑假高度 B,则要继续使用下一头奶牛,……,反复累加,反复判断。
#include <cstdio>
#include <algorithm>
using std::sort;
const int N = 20005;
int h[N];
int main()
{
int n, b; scanf("%d%d", &n, &b); // 输入奶牛数量和书架高度
for (int i = 1; i <= n; i++) scanf("%d", &h[i]); // 依次输入每只奶牛的身高
sort(h + 1, h + n + 1, [](int x, int y) {
return x > y; // 按从大到小排序奶牛的身高
});
int ans = 0, sum = 0;
for (int i = 1; i <= n; i++) {
sum += h[i]; ans++; // 累加每一只奶牛的身高和奶牛的数量
if (sum >= b) { // 如果达到了书架高度
printf("%d\n", ans); // 输出此时的奶牛数量
break; // 结束循环
}
}
return 0;
}
例题:P3742 umi的函数
Special Judge是指当一道题有多组解时,评测系统用一个验证程序来验证解的正确性。标有 Special Judge 的题目说明你的输出方案不一定要和样例中给出的一模一样,只需符合题目要求即可。
考虑每一个字符,我们构造的字符串 z 中的这个字符与 x 字符串中的对应字符取最小值后要对应 y 字符串中的对应字符。首先,如果结果 y 里的字符比 x 里的对应字符还要大了,那这就说明无解。而如果有解,我们可以分类讨论:如果最后结果 y 里的那个字符跟 x 里的一样,说明我们构造的 z 里这个位置上的字符不比 x 里的要小;如果最后 y 里的那个字符跟 x 里的不一样,说明是通过我们构造的 z 让结果变成 y 里的样子的,那就说明我们构造的字符就是 y 里的对应字符。综上,我们发现,如果要构造方案,最方便的就是直接令整个 z=y,因为这样既满足第一种情况(不比 x 里的对应字符更小),又满足第二种情况(就是 y 里的字符)。
#include <cstdio>
char x[105], y[105];
int main()
{
int n;
scanf("%d%s%s", &n, x, y);
bool ok = true;
for (int i = 0; i < n; i++) {
if (x[i] < y[i]) {
ok = false;
break;
}
}
if (ok) printf("%s\n", y);
else printf("-1\n");
return 0;
}
例题:P2240 [深基12.例1] 部分背包问题
分析:因为背包的承重量有限,如果能拿走相同重量的金币,当然是优先拿走单位价格最贵的金币。所以正确的做法是将金币的单价从高往低排序,然后按照顺序将整堆金币都放入包里。如果整堆放不进背包,就分割这一堆金币直到刚好能装下为止。
直觉是对的,但是最好证明一下。首先,所有的东西价值都是正的,因此只要金币总数足够,背包就必须要装满而不能留空;其次,利用反证法:假设没有在背包中放入单价高的金币而放入了单价更低的金币,那么就可以用等重量的更高价值金币替换掉背包里的低价值金币,那总价值反而变高了,说明原来的不是最优解,所以贪心算法成立。
#include <cstdio>
#include <algorithm>
using std::sort;
const int N = 105;
struct Coin {
int m, v; // 重量和价值
};
Coin a[N];
int main()
{
int n, t; scanf("%d%d", &n, &t);
for (int i = 1; i <= n; i++) {
scanf("%d%d", &a[i].m, &a[i].v);
}
sort(a + 1, a + n + 1, [](Coin c1, Coin c2) {
return c1.v * c2.m > c2.v * c1.m; // 按单价降序排序
});
double ans = 0;
for (int i = 1; i <= n; i++) {
if (a[i].m > t) { // 如果不够拿走整堆金币,则这就是最后一次拿
// 装下部分金币
ans += 1.0 * a[i].v / a[i].m * t;
break;
} else {
ans += a[i].v; t -= a[i].m; // 拿走整堆
}
}
printf("%.2f\n", ans);
return 0;
}
为了方便排序,定义 Coin
结构体来存储金币堆的重量和价值——性价比不需要存下来,而是在调用 sort
的时候进行判断。比较性价比时本来是判断 1.0*c1.v/c1.m>1.0*c2.v/c2.m
,但是为了规避可能的浮点数计算误差,转化成乘法之后更精确,也能加快速度。
这里用的是证明贪心策略正确性的一种典型方法:假设要选择的方案不按照贪心策略,证明用这种贪心策略替换掉非贪心策略后,结果会更好(至少不会更差)。
例题:P1223 排队接水
分析:让平均时间最短等价于让所有人的等待时间和最短。由于排队接水是一个接着一个的,也就是只允许最多一个人同时打水,所以某一个人打水的时候其身后的人的等待时间总和就是每个人单独打水时间的和。第一个人不需要等待,第二个人需要等待第一个人打水的时间,第三个人需要等待前两个人打水的时间。假设经过安排后,第 \(i\) 个人的打水时间是 \(t_i\)。则所有打水人的等待时间总和为 \(s = (n-1)t_1 + (n-2)t_2 + \cdots + 1 \times t_{n-1} + 0 \times t_n\)。
可以发现,\(t_1\) 的系数较大,\(t_n\) 的系数比较小。所以可以猜到,\(t_1\) 到 \(t_n\) 应该从小到大排序,可以使时间总和 \(s\) 最小。
当然,这是可以证明的。假设最佳方案中,\(t_1\) 到 \(t_n\) 不是从小到大排序,假设存在当 \(i<j\) 时,\(t_i>t_j\)。那么这两项对于总时间的贡献是 \(s_1 = at_i + bt_j\),其中系数 \(a>b\)。若将 \(t_i\) 和 \(t_j\) 调换,那么对总时间的贡献会变成 \(s_2 = at_j + bt_i\),两者相减有 \(s_1 - s_2 = a(t_i - t_j) - b(t_i - t_j) = (a-b)(t_i - t_j) > 0\),说明这样调换后总时间会缩短,也就说明原本的方式不是“最佳方案”,所以贪心算法成立。
#include <cstdio>
#include <algorithm>
using std::sort;
const int N = 1005;
struct Person {
int t, id;
};
Person a[N];
int main()
{
int n; scanf("%d", &n);
for (int i = 1; i <= n; i++) {
scanf("%d", &a[i].t); a[i].id = i;
}
sort(a + 1, a + n + 1, [](Person p1, Person p2) {
return p1.t < p2.t;
});
double s = 0;
for (int i = 1; i <= n; i++) {
s += a[i].t * (n - i);
printf("%d ", a[i].id);
}
printf("\n%.2f\n", s / n);
return 0;
}
代码中使用了结构体来存储每个人的信息,排序时按照接水时间从小到大排序,最后计算耗时总长然后除以人数得到平均值。
例题:P4995 跳跳!
分析:不难发现,这里的贪心策略是从初始点(最左边的 0 点)跳到最右边,再跳到第二左的位置,再跳到第二右的位置,……,以此类推。
#include <cstdio>
#include <algorithm>
using std::sort;
using ll = long long; // 用ll替代long long
const int N = 305;
int h[N];
int main()
{
int n;
scanf("%d", &n);
for (int i = 1; i <= n; ++i) scanf("%d", &h[i]);
sort(h + 1, h + n + 1);
int p = 0, q = n;
int dir = 0;
ll ans = 0; // ll的范围大约是-9e18~9e18
while (p < q) {
ans += (h[q] - h[p]) * (h[q] - h[p]);
// 注意,如果相乘的两项本身都是int类型,而乘积结果可能超出int范围
// 可以写成 1ll * ... * ...,这里1ll表示ll类型下的1,它与第一项先乘可以使结果变成ll类型
// 这样再与第二项相乘时也会在ll范围下进行
if (dir == 0) p++;
else q--;
dir = 1 - dir; // 改变跳跃方向
}
printf("%lld\n", ans); // ll的输出格式符为%lld
return 0;
}
例题:P1803 凌乱的yyy / 线段覆盖
分析:如果所有的比赛时间都不冲突,那么问题就变简单了,全部参加即可。所以问题在于有些比赛时间会冲突,考虑两种情况:
- 一个比赛被另一个比赛包含:这两个比赛冲突了,要选择比赛 1,因为比赛 1 先结束,这样可能后续比赛被占用时间的可能就少一些。
- 一个比赛和另一个比赛相交:还是应该选择比赛 1,同样的道理,早结束可以减少对后续比赛的时间占用。
最先选择参加哪一场比赛呢?根据分析,应该选择最先结束的那一场比赛。接下来,要选择能够参加的比赛中,最早结束的比赛(既然已经决定参加上一场比赛了,那么所有和上一场冲突的比赛都不能参加了),直到没有比赛可以参加为止。这样可以保证不管在什么时间点之前,能够参加比赛的数量都是最多的,因此贪心算法成立。
这是证明贪心的另一种方法——数学归纳法:每一步的选择都是到当前为止的最优解,一直到最后一步就成为了全局的最优解。
#include <cstdio>
#include <algorithm>
using std::sort;
const int N = 1e6 + 5;
struct Contest {
int bg, ed;
};
Contest a[N];
int main()
{
int n;
scanf("%d", &n);
for (int i = 1; i <= n; i++) scanf("%d%d", &a[i].bg, &a[i].ed);
sort(a + 1, a + n + 1, [](Contest c1, Contest c2) {
return c1.ed < c2.ed;
});
int ans = 1;
int cur = a[1].ed; // 上一个选择的比赛的结束时间
for (int i = 2; i <= n; i++)
if (a[i].bg >= cur) { // 和上一个选择的比赛不冲突
cur = a[i].ed;
ans++;
}
printf("%d\n", ans);
return 0;
}
本题中将所有比赛的结束时间排序,然后依次进行贪心:如果能够参加这场比赛,就报名参加;如果这场比赛和上一场冲突,就放弃。贪心过程的算法时间复杂度是 \(O(n)\),但是排序的算法复杂度可达 \(O(n \log n)\),所以时间复杂度的瓶颈在排序上。
例题:P1012 [NOIP1998 提高组] 拼数
排序策略的证明:考虑两个字符串 A 和 B
如果我们把字符串对应的十进制数看成 a 和 b,则 \(\overline{AB} > \overline{BA}\) 等价于
\(a \times 10^{\lvert B \rvert} + b > b \times 10^{|A|} + a\) 等价于
\(\frac{a}{10^{\lvert A \rvert}-1} > \frac{b}{10^{\lvert B \rvert}-1}\) 其中 |A| 和 |B| 代表字符串的长度
这说明这种比较策略具备传递性:即如果 AB 这种拼数方式优于 BA,BC 优于 CB,则最终顺序应为 ABC
#include <iostream>
#include <string>
#include <algorithm>
using std::cin;
using std::cout;
using std::sort;
using std::string;
const int N = 25;
string a[N];
int main()
{
int n;
cin >> n;
for (int i = 1; i <= n; i++) cin >> a[i];
sort(a + 1, a + n + 1, [](string s1, string s2) {
return s1 + s2 > s2 + s1;
});
for (int i = 1; i <= n; ++i) cout << a[i];
return 0;
}
习题:P3817 小A的糖果
解题思路
先考虑单独一个糖果盒,如果一个糖果盒里糖果数量就超出限制了,那当然首先要将其吃到剩 x 个。接下来考虑相邻两个糖果盒,从第一个糖果盒和第二个糖果盒开始考虑,假如两个相邻糖果盒中糖果数量加起来超了,不管是吃第一个糖果盒里的还是第二个糖果盒里的,需要吃的数量是一样的。如果我们吃第一个糖果盒中的糖果,那么它只能保证前两个糖果盒中糖果总量不超限,对第二和第三个糖果盒的情况没有影响,而如果我们选择吃第二个糖果盒中的糖果,那么既能使得前两个糖果盒糖果总数不超限,又能够同时减少一点第二盒与第三盒形成的总数。所以我们吃靠后的那个。将这个处理方式继续往右边类推即可。
#include <cstdio>
using ll = long long;
const int N = 1e5 + 5;
int a[N];
int main()
{
int n, x; scanf("%d%d", &n, &x);
ll ans = 0;
for (int i = 1; i <= n; i++) {
scanf("%d", &a[i]);
if (a[i] > x) {
ans += a[i] - x;
a[i] = x;
}
}
for (int i = 2; i <= n; i++) {
int diff = a[i - 1] + a[i] - x;
if (diff > 0) {
ans += diff;
a[i] -= diff;
}
}
printf("%lld\n", ans);
return 0;
}
习题:P1478 陶陶摘苹果(升级版)
解题思路
相当于前面“奶牛塔”那题,优先摘花费力气少的,只不过是要建立在能够到的基础上。
#include <cstdio>
#include <algorithm>
using std::sort;
const int N = 5005;
struct Apple {
int x, y;
};
Apple ap[N];
int main()
{
int n, s, a, b; scanf("%d%d%d%d", &n, &s, &a, &b);
for (int i = 1; i <= n; i++) {
scanf("%d%d", &ap[i].x, &ap[i].y);
}
sort(ap + 1, ap + n + 1, [](Apple a1, Apple a2) {
return a1.y < a2.y;
});
int ans = 0;
for (int i = 1; i <= n; i++) {
if (ap[i].y > s) break; // 剩余的力气已经不足以摘接下来的苹果了
if (a + b >= ap[i].x) { // 够得到才能摘
ans++; s -= ap[i].y;
}
}
printf("%d\n", ans);
return 0;
}
习题:P1208 [USACO1.3] 混合牛奶 Mixing Milk
解题思路
优先采购单价便宜的牛奶,如果把某款牛奶采购进来还没达到需要的量,就全买进,如果全买进来超了,就买还缺的部分然后结束。
#include <cstdio>
#include <algorithm>
using std::sort;
const int M = 5005;
struct Milk {
int p, a;
};
Milk a[M];
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= m; i++) {
scanf("%d%d", &a[i].p, &a[i].a);
}
// 按单价从小到大排序
sort(a + 1, a + m + 1, [](Milk m1, Milk m2) {
return m1.p < m2.p;
});
int ans = 0;
for (int i = 1; i <= m; i++) {
if (a[i].a >= n) { // 买最后一点就够了
ans += n * a[i].p; break;
} else { // 整批买进
ans += a[i].a * a[i].p;
n -= a[i].a;
}
}
printf("%d\n", ans);
return 0;
}
习题:P1094 [NOIP2007 普及组] 纪念品分组
解题思路
按纪念品价格从小到大排序,考虑到每个分组最多两件,尽量考虑搭配一大一小。
- 如果当前最便宜的和最贵的打包到一起超限了,那么最贵的只能自成一组(连最便宜的都不能合一组,更不用说其他的了)
- 如果最便宜的和最贵的可以一起打包,那就让它们两个打包一组(理论上此时最贵的可能可以不找最便宜的一起打包,但是不影响)
重复以上过程,完成对所有纪念品的分组。
#include <cstdio>
#include <algorithm>
using std::sort;
const int N = 30005;
int a[N];
int main()
{
int w, n; scanf("%d%d", &w, &n);
for (int i = 1; i <= n; ++i) scanf("%d", &a[i]);
sort(a + 1, a + n + 1);
int l = 1, r = n;
int ans = 0;
while (l <= r) {
if (l == r) { // 还剩最后一件
ans++;
break;
} else {
// 给最贵的分组,如果能搭个最便宜的就搭一下,不然就自成一组
if (a[l] + a[r] <= w) l++;
r--; ans++;
}
}
printf("%d\n", ans);
return 0;
}
习题:P9749 [CSP-J 2023] 公路
解题思路
从左到右考虑,当行驶到下一个加油站时发现剩余的油不够了,相当于之前油没加够,由于油箱容量是无限大的,因此可以追溯到到目前为止的加油站里最便宜的那个,把缺的油在那个站里补上。
注意,因为买油只能买整数升,所以不妨在处理过程中维护当前油箱中的油还够车开多少里程,这样能够规避掉涉及小数的运算。
参考代码
#include <cstdio>
#include <algorithm>
using std::min;
using ll = long long;
const int N = 100005;
int v[N], a[N];
int main()
{
int n, d; scanf("%d%d", &n, &d);
for (int i = 1; i <= n - 1; i++) scanf("%d", &v[i]);
for (int i = 1; i <= n; i++) scanf("%d", &a[i]);
int rest = 0, min_price = 0; // rest表示油箱中的油够开多少距离
ll ans = 0;
for (int i = 1; i <= n - 1; i++) {
// 到目前这个加油站为止,最便宜的油价是多少
if (min_price == 0) min_price = a[i];
else min_price = min(min_price, a[i]);
if (rest >= v[i]) {
rest -= v[i];
} else {
int need = (v[i] - rest + d - 1) / d; // 计算需要买几升油
ans += 1ll * need * min_price;
rest = rest + need * d - v[i];
}
}
printf("%lld\n", ans);
return 0;
}
习题:P5019 [NOIP2018 提高组] 铺设道路
参考代码
#include <cstdio>
#include <algorithm>
using std::max;
const int N = 100005;
int d[N];
int main()
{
int n;
scanf("%d", &n);
for (int i = 1; i <= n; ++i) {
scanf("%d", &d[i]);
}
int ans = 0;
for (int i = 1; i <= n; ++i) {
ans += max(d[i] - d[i - 1], 0);
}
printf("%d\n", ans);
return 0;
}
习题:CF463C Gargari and Bishops
解题思路
假设某个象在白格,按照题意可以发现,此时其他的白格也无法放另一个象。因此实际上这两个象放在两种不同颜色的格子上。要求得分最大,相当于求两种颜色的格子中可以沿两对角线方向可取到的总和最大。
因此可以预处理每条主对角线、反对角线上的总和,从而在两种颜色的格子中各自找总得分最大的结果。
参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
using ll = long long;
const int N = 2005;
int a[N][N];
// d1为主对角线,d2为反对角线
ll d1[N * 2], d2[N * 2], ans[2];
int x[2], y[2];
int main()
{
int n; scanf("%d", &n);
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++) {
scanf("%d", &a[i][j]);
d1[i - j + n] += a[i][j];
d2[i + j] += a[i][j];
}
x[0] = 1; y[0] = 1; x[1] = 1, y[1] = 2;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++) {
int cur = (i + j) % 2;
ll sum = d1[i - j + n] + d2[i + j] - a[i][j];
if (sum > ans[cur]) {
ans[cur] = sum; x[cur] = i; y[cur] = j;
}
}
printf("%lld\n%d %d %d %d\n", ans[0] + ans[1], x[0], y[0], x[1], y[1]);
return 0;
}
习题:P1650 田忌赛马
参考代码
#include <cstdio>
#include <algorithm>
using std::sort;
const int N = 2005;
int a[N], b[N];
int main()
{
int n;
scanf("%d", &n);
for (int i = 1; i <= n; i++) scanf("%d", &a[i]);
for (int i = 1; i <= n; i++) scanf("%d", &b[i]);
sort(a + 1, a + n + 1);
sort(b + 1, b + n + 1);
int ans = 0;
int p1 = 1, q1 = n;
int p2 = 1, q2 = n;
for (int i = 1; i <= n; i++) {
if (a[p1] > b[p2]) {
ans++; p1++; p2++;
} else if (a[q1] > b[q2]) {
ans++; q1--; q2--;
} else {
if (a[p1] < b[q2]) ans--;
else if (a[p1] > b[q2]) ans++;
p1++; q2--;
}
}
printf("%d\n", ans * 200);
return 0;
}