进阶算法 第一课:贪心

· · 算法·理论

本文遵循 CC BY-NC-ND 4.0 协议,作者:\texttt{U•ェ•*U},转载请获得作者授权。

欢迎大家来到进阶算法第一课:贪心;我会分为以下几点为大家讲解贪心:

什么是贪心

贪心的定义:在问题决策的过程中,不考虑全局最优解,而只考虑局部最优解的算法。

但是,在部分题目中会得不到全局最优解,因此 \texttt{OI} 中的贪心多指 \color{red}\texttt{可以得到全局最优解的贪心算法}

贪心的本质是利用题目性质的思维方法(有点类似于 骗分),而不是一个固定实现方法的算法,需要选手能够随机应变。

与动态规划的区别

贪心算法与动态规划的不同在于它对每个子问题的解决方案都做出选择,不能回退。动态规划则会保存以前的运算结果,并根据以前的结果对当前进行选择,有回退功能。

贪心的性质

贪心的分类

贪心相关算法模型

模型一:找最优

顾名思义,就是只需要找最优值(最大、最小等)的贪心模型,比较常见。

来道例题:P1208 混合牛奶。

相信大家已经会做这道题目了,我们来分析一下它:

显然,应该购买单价最低的牛奶,单价低的牛奶卖完了,再买次低价的。

先按单价从小到大排序,依次购买这个供应商的牛奶,直到买完或者买够为止。

完美 \texttt{AC},其实,贪心还是比较简单易懂的。

```cpp #include <bits/stdc++.h> #define int long long using namespace std; int n, m, ans; struct node { int p, a; } s[1000010]; bool cmp(node a, node b) { return a . p < b . p; } signed main() { ios :: sync_with_stdio(false); cin >> n >> m; for (int i = 1; i <= m; i ++) { cin >> s[i] . p >> s[i] . a; } sort(s + 1, s + m + 1, cmp); for (int i = 1; i <= m; i ++) { if (n <= 0) break; if (s[i] . a >= n) { ans += s[i] . p * n; n = 0; } else ans += s[i] . p * s[i] . a, n -= s[i] . a; } cout << ans << endl; return 0; } ``` 再来道例题:[P1803 线段覆盖](https://www.luogu.com.cn/problem/P1803)。 也很简单,就不给代码了,讲讲思路: 我们只需要按照右端点递增的顺序排序,然后顺序枚举,能选就选,~~不能就算了~~。 但是为什么这么做是对的呢?我们来一起证明一下: 对于排序后的第 $i$ 个线段,以及后面的第 $j$ 个 线段,假设 $i,j$ 当前都可以选择,不选 $i$ 而选 $j$ 不可能更优。 证明还是很简单的,但这仅限于这种一眼看懂的题目。 --- #### 模型二:构造 构造相信大家再熟悉不过了,就是直接生成一个符合要求的方案即可。 - 特点:构造出符合题目要求的方案。 - 方法:直接构造一种最优决策,然后判断这种方案是不是满足要求,满足就直接返回结果。 例题:[ABC167F](https://www.luogu.com.cn/problem/AT_abc167_f) 其实,有一个很经典的括号匹配:用一个栈,遇到左括号就压进去,遇到右括号就把栈里的左括号弹出来,形成匹配。 但是,题目要求的是输出最后的匹配顺序,又该怎么做呢? 我们可以贪心的考虑这道题目,先将字符串全部排序,尽量把左括号放左边,右括号放右边,再进行匹配和计数。 证明也很简单:因为我们把左括号尽量放左边了,那么除非是无解的情况,左右括号都是能够一一配对的。 上 $\texttt{C++}$ 代码(但是不知道哪里有问题,只能得 $\texttt{50 pts}$,**欢迎在私信我指出问题,感谢** \~): ```cpp #include <bits/stdc++.h> #define int long long using namespace std; const int MAXN = 1e6 + 10; int n; string s[MAXN]; struct node { int x, y, z, id; } b[MAXN]; bool cmp(const node &a, const node &b) { if (a . z != b . z) return a . z < b . z; if (a . y != b . y) return a . y < b . y; return a . id < b . id; } void chuli(int x) { int cnt1 = 0, cnt2 = 0; for (int i = 0; i < s[x] . length(); i ++) { if (s[x][i] == '(') cnt1 ++; else { if (cnt1 > 0) cnt1 --; else cnt2 ++; } } b[x] = (node){cnt1, cnt2, 0, x}; if (cnt1 != 0 && cnt2 != 0) b[x] . z = 1; else if (cnt1 == 0 && cnt2 != 0) b[x] . z = 4; else if (cnt1 == 0 && cnt2 == 0) b[x] . z = 0; else if (cnt1 >= cnt2) b[x] . z = 2; else if (cnt2 > cnt1) b[x] . z = 3; } signed main() { ios :: sync_with_stdio(false); cin . tie(0); cin >> n; for (int i = 1; i <= n; i ++) cin >> s[i]; for (int i = 1; i <= n; i ++) chuli(i); sort(b + 1, b + n + 1, cmp); int cnt = 0; for (int i = 1; i <= n; i ++) { if (b[i] . z == 0) continue; else if (b[i] . z == 1) cnt += b[i] . x; else { if (cnt < b[i] . y) { cnt = -1; break; } cnt -= b[i] . y; cnt += b[i] . x; } } if (cnt > 0 || cnt == -1) cout << -1 << endl; else { for (int i = 1; i <= n; i ++) { cout << b[i] . id << " "; } cout << endl; } return 0; } ``` --- #### 模型三:反悔 又称反悔贪心: - 特点:方案具有后效性。 - 方法:不立刻决策,等待决策发生影响时再进行。 $2023$ 年 $\texttt{CSP-J}$ 就有反悔贪心题,来一起看看:[P9749 \[CSP-J 2023\] 公路](https://www.luogu.com.cn/problem/P9749) 经典、简单的返回贪心思想题。 我们从左到右考虑,如果行驶到某个加油站,这时刚好缺油了,就从之前经过的最便宜的加油站加油。 我们考虑维护变量 $f$ 表示当前的状态,若 $f\lt 0$,则代表的是当前还能走多少公里的油;如果 $f\ge 0$,表示当前需要加油,加的油量为 $\left \lceil \frac{s}{d} \right \rceil$。 注意本题的数据范围大,需要开 `long long`。 给出本题的 $\texttt{C++}$ 代码(不是我写的): ```cpp #include <bits/stdc++.h> using namespace std; using LL = long long; const int N = 1e5 + 10; int v[N], a[N]; int n, d; int main() { scanf("%d%d", &n, &d); for (int i = 1; i < n; i++) scanf("%d", &v[i]); int mi = INT_MAX; LL ans = 0, s = 0; for (int i = 1; i < n; i++) { scanf("%d", &a[i]); s += v[i]; mi = min(mi, a[i]); if (s > 0) { ans += (s + d - 1) / d * mi; s -= (s + d - 1) / d * d; } } printf("%lld\n", ans); return 0; } ``` 再来一道例题目:[P2949 \[USACO09OPEN\] Work Scheduling G](https://www.luogu.com.cn/problem/P2949) 思路:先按时间顺序选,如果遇到超截止日期的工作,再考虑要不要把之前的反悔掉。 但是我们如何考虑反悔呢?每次选之前选择的任务中最小的那个,并跟当前任务比较。如果比当前任务小就把他拿掉,把当前任务加进去。 那怎么实现呢?优先队列即可(也就是 `priority_queue`) 代码(题解代码,不是我写的): ```cpp #include<queue> #include<cstdio> #include<iostream> #include<algorithm> using namespace std; struct node{ int tim,mny; }w[100001]; int n,i; long long ans; priority_queue<int,vector<int>,greater<int> > q; bool cmp(node a,node b){ return a.tim<b.tim; } int main(){ scanf("%d",&n); for (i=1; i<=n; i++) scanf("%d%d",&w[i].tim,&w[i].mny); sort(w+1,w+n+1,cmp); for (i=1; i<=n; i++){ if (w[i].tim<=q.size()){ if (w[i].mny>q.top()){ ans-=q.top(); q.pop(); q.push(w[i].mny); ans+=w[i].mny; } } else{ q.push(w[i].mny); ans+=w[i].mny; } } printf("%lld",ans); return 0; } ``` 相信到现在大家已经掌握了反悔贪心的精髓,让我们一起学习下一种模型。 --- #### 模型四:交换 这是一种~~不大~~常见的贪心算法,一般用于针对序列的贪心。 - 特点:要求你对一个序列进行排序,来得到最优值。 - 方法:交换任意两个位置 $i$ 和 $j$,研究在什么情况下会让答案变大。 来道例题:[P1080 国王游戏](https://www.luogu.com.cn/problem/P1080) 经典贪心例题,许多人都做过。但为什么这么做是对的呢? 假设有相邻两个人 $(a_1,b_1)$, $(a_2, b_2)$,他俩前面人的 $a$ 之积为 $A$。 令 $$ \begin{array}{c}{{a n s_{1}=\operatorname \times {max}\{\left\lfloor\frac{A}{b_{1}}\right\rfloor,\left\lfloor\frac{A\cdot a_{1}}{b_{2}}\right\rfloor\}}}\\ {{a n s_{2}=\operatorname \times {max}\{\left\lfloor\frac{A}{b_{2}}\right\rfloor,\left\lfloor\frac{A\cdot a_{2}}{b_{1}}\right\rfloor\}}}\end{array} $$ 分别表示 $1$ 前 $2$ 后和 $1$ 后 $2$ 前的答案,然后分类讨论 $4$ 种结果。 第一种情况: $$ ans_1 = \left\lfloor \frac{A}{b_1} \right\rfloor,ans_2 = \left\lfloor \frac{A}{b_2} \right\rfloor $$ 则 $\left\lfloor \frac{A}{b_1} \right\rfloor\ge\left\lfloor\frac{A\times a_1}{b_2}\right\rfloor,\left\lfloor \frac{A}{b_2} \right\rfloor\ge\left\lfloor\frac{A\times a_2}{b_1}\right\rfloor

由于 \left\lfloor\frac{A}{b_1}\right\rfloor\le\left\lfloor\frac{A\times a_2}{b_1}\right\rfloor,\left\lfloor\frac{A}{b_2}\right\rfloor\le\left\lfloor\frac{A\times a_1}{b_2}\right\rfloor

所以可以得到:\left\lfloor\frac{A}{b_1}\right\rfloor=\left\lfloor\frac{A\times a_1}{b_2}\right\rfloor=\left\lfloor\frac{A}{b_2}\right\rfloor=\left\lfloor\frac{A\times a_2}{b_1}\right\rfloor

这个时候咋交换都没用

第二种情况:

ans_1 = \left\lfloor\frac{A\times a_1}{b_2}\right\rfloor,ans_2 = \left\lfloor\frac{A\times a_2}{b_1}\right\rfloor

这时候如果 a_1 \times b_1 \le a_2 \times b_2,则有:\left\lfloor\frac{A\times a_1}{b_2}\right\rfloor \le \left\lfloor\frac{A\times a_2}{b_1}\right\rfloor,反之亦然

第三种情况:

ans_1 = \left\lfloor\frac{A}{b_1}\right\rfloor,ans_2 = \left\lfloor\frac{A\times a_2}{b_1}\right\rfloor

显然,ans_1\le ans_2

第四种情况:

ans_1 = \left\lfloor\frac{A\times a_1}{b_2}\right\rfloor,ans_2 = \left\lfloor\frac{A}{b_2}\right\rfloor

显然,ans_1\ge ans_2

而且我们不难发现,如果 a_1\times b_1 \le a_2\times b_2,则 \frac{a_2\times b_2}{b_1} \ge a_1 \ge 1,那么 \frac{a_2}{b_1} \ge \frac{1}{b_2},满足条件 23

a_1\times b_1 \ge a_2\times b_2 时,满足条件 24

证毕。

稍微再提示一下,在所有序列中,只有最终的有序序列无法交换,其余序列均可交换。即所有方案中只存在一个极大值,那么此时这个极大值就是最大值。

注意:本题必须使用高精度算法。

代码就不放了,可以参考题解的代码。

芜湖,终于把贪心算法的基本模型讲完了,接下来讲讲贪心的证明方法。

贪心的证明

众所周知,贪心的证明方法有三种:

  1. 反证法
  2. 归纳法
  3. 调整法

方法一:反证法

贪心算法的正确性可以通过反证法来证明。假设贪心算法无法得到全局最优解,那么一定存在一个反例,即存在一个问题的实例,贪心算法无法得到最优解。但是,由于贪心算法在每一步都采取了当前最优的选择,因此不可能存在这样的反例。因此,贪心算法一定是正确的。

例:P1208 混合牛奶

大家可以用这道题目练练反证法。

方法二:归纳法

已知结论对于 s 正确,且如果对于 x 正确则对 x + 1 也正确,就可以推出对于任意不小于 s 的整数都正确。

例:P1803 线段覆盖

方法三:调整法

以任意一个解出发进行调整,直到无论如何调整都不能使答 案更优。 例:P1223 排队接水、P1080 国王游戏

贪心的要点

常见题型

在提高组难度以下的题目中,最常见的贪心有两种。

二者的区别在于一种是离线的,先处理后选择;一种是在线的,边处理边选择。

非常感谢你能够看到这里,如果你觉得这篇文章对你有帮助,欢迎点赞收藏 + 关注,你的支持是我更新的最大动力,好了,我们下篇文章再见~

参考资料(排名不分先后)