贪心
1.简单贪心
1.货仓选址
解法:贪心
#include<iostream>
#include<algorithm>
using namespace std;
typedef long long LL;
const int N = 1e5 + 10;
int n;
LL a[N];
int main()
{
cin >> n;
for(int i = 1; i <= n; i++) cin >> a[i];
sort(a + 1, a + 1 + n);
LL ret = 0;
//利用中间值计算
//for(int i = 1; i <= n; i++)
//{
// ret += abs(a[i] - a[n / 2]);
//}
//利用结论计算
for(int i = 1; i <= n / 2; i++)
{
ret += a[n - i + 1] - a[i];
}
cout << ret << endl;
return 0;
}
2.最大子段和
解法:贪心
贪心想法:从前往后累加,我们会遇到下面两种情况:
- 目前的累加和 ≥ 0:那么当前累加和还会对后续的累加和做出贡献,那我们就继续向后累加,然后更新结果。
- 目前的累加和 < 0:对后续的累加和做不了一点贡献,直接大胆舍弃计算过的这一段,把累加和重置为 0,然后继续向后累加。
这样我们在扫描整个数组一遍之后,就能更新出最大子段和。
其实只需要证明我们在累加的过程中,出现负数时,为什么可以大胆的舍去这一段区间,然后重新开始。证明以下三点,就可以「大胆舍弃」了:
在累加的过程中算出一段区间和 sum[a, b] < 0,如果不舍弃这一段,那么 [a, b] 段之间就会存在一点,「以某个位置为起点」就会「更优」,分为下面两种情况:
#include<iostream>
using namespace std;
typedef long long LL;
const int N = 2e5 + 10;
int n;
LL a[N];
int main()
{
cin >> n;
for(int i = 1; i <= n; i++) cin >> a[i];
LL sum = 0, ret = -1e4 - 10;
for(int i = 1; i <= n; i++)
{
sum += a[i];
ret = max(ret, sum);
if(sum < 0) sum = 0;
}
cout << ret << endl;
return 0;
}
3.纪念品分组
解法:贪心
先将所有的纪念品排序,每次拿出当前的最小值 x 与最大值 y:
- 如果 x + y ≤ w:就把这两个放在一起。
- 如果 x + y > w:说明此时最大的和谁都凑不到一起,y 单独分组,x 继续留下在进行下一次判断。
直到所有的物品都按照上述规则分配之后,得到的组数就是最优解。
综上所述,我们可以通过不断的「调整」,使的最优解在「不改变其最优性」的前提下,变得和贪心解一致。那我们的贪心策略就等价于最优策略。
#include<iostream>
#include<algorithm>
using namespace std;
const int N = 3e4 + 10;
int w, n;
int a[N];
int main()
{
cin >> w >> n;
for(int i = 1; i <= n; i++) cin >> a[i];
sort(a + 1, a + 1 + n);
int ret = 0, left = 1, right = n;
while(left <= right)
{
if(a[left] + a[right] <= w)
{
left++;
right--;
}
else
{
right--;
}
ret++;
}
cout << ret << endl;
return 0;
}
4.排座椅
解法:贪心
由题意可得,我们会发现一些性质:
- 设置横向通道的时候,并「不影响」左右相邻的同学。
- 设置纵向通道的时候,并「不影响」上下相邻的同学。
因此我们可以「分开」处理横向通道和纵向通道。
处理横向通道(纵向同理):
- 收集每一行如果放上通道之后,会解决多少个交头接耳的同学。
- 对收集的信息「从大到小」排序,选最大的 k 行就是最优结果。
#include<iostream>
#include<algorithm>
using namespace std;
const int N = 1e3 + 10;
int m, n, k, l, d;
struct node
{
int index; //行/列下标
int cnt; //在该下标处划分通道,减少交头接耳的学生对数
}row[N], col[N];
bool cmp1(node& x, node& y)
{
return x.cnt > y.cnt;
}
bool cmp2(node& x, node& y)
{
return x.index < y.index;
}
int main()
{
cin >> m >> n >> k >> l >> d;
//初始化数组的index
for (int i = 1; i <= m; i++) row[i].index = i;
for (int j = 1; j <= n; j++) col[j].index = j;
while (d--)
{
int x, y, p, q; cin >> x >> y >> p >> q;
if (x == p) col[min(y, q)].cnt++; //行坐标相同:列所在的学生数量++
if (y == q) row[min(x, p)].cnt++; //列坐标相同:行所在的学生数量++
}
//两个数组按照 cnt 从大到小排序
sort(row + 1, row + 1 + m, cmp1);
sort(col + 1, col + 1 + n, [&](node& x, node& y) { return x.cnt > y.cnt; });
//对 row 数组,前 k 个元素,按照下标从小到大排序
sort(row + 1, row + 1 + k, cmp2);
//对 col 数组,前 l 个元素,按照下标从小到大排序
sort(col + 1, col + 1 + l, [&](node& x, node& y) { return x.index < y.index; });
for (int i = 1; i <= k; i++) cout << row[i].index << " ";
cout << endl;
for (int j = 1; j <= l; j++) cout << col[j].index << " ";
cout << endl;
return 0;
}
5.矩阵消除游戏
解法:贪心
错误的贪心:每次都选当前看起来最好的一行或者一列,然后选完之后,修改原数组,继续重复刚刚的操作。
直接贪心的错误在于,我们每次选完「一行」或者「一列」之后,会对接下来的「列」或者「行」的选择造成影响。比如上面的反例:我们先贪心的选了第一列。站在「行」的视角上,在「列」没选之前,我分明能贪心的选「第一行」,但是你把「第一列」拿走哦之后,就会对「第一行」造成影响。
既然行的选择会影响列,那我们干脆直接把「所有行的选法」枚举出来,然后针对「每一种行的选法」再处理列,这样就会把「所有情况」都考虑进去。因此,最优解是先暴力枚举所有行的选法,在行的选择都确定之后,再去贪心的处理列。这个思想我们是不陌生的,参考二进制枚举里面《费解的开关》。
#include<iostream>
#include<algorithm>
#include<cstring>
using namespace std;
const int N = 20;
int n, m, k;
int a[N][N];
int col[N]; //统计列和
//计算x的二进制表示中1的个数
int calc(int x)
{
int ret = 0;
while(x)
{
ret++;
x &= x - 1;
}
return ret;
}
int main()
{
cin >> n >> m >> k;
for(int i = 0; i < n; i++)
for(int j = 0; j < m; j++)
cin >> a[i][j];
int ret = 0;
//暴力枚举出行的所有选法
for(int st = 0; st < (1 << n); st++)
{
int cnt = calc(st);
if(cnt > k) continue; //不合法的状态
memset(col, 0, sizeof(col)); //每一次计算前都需要清空
int sum = 0; //记录当前选法中的和
for(int i = 0; i < n; i++)
{
for(int j = 0; j < m; j++)
{
if((st >> i) & 1) sum += a[i][j];
else col[j] += a[i][j];
}
}
//从大到小排序列
sort(col, col + m, greater<int>());
//选k-cnt列
for(int j = 0; j < k - cnt; j++) sum += col[j];
ret = max(ret, sum);
}
cout << ret << endl;
return 0;
}
2.推公式
如果细说的话,这个专题应该叫推公式+排序。其中推公式就是寻找排序规则,排序就是在该排序规则下对整个对象排序。
在解决某些问题的时,当我们发现最终结果需要调整每个对象的先后顺序,也就是对整个对象排序时,那么我们就可以用推公式的方式,得出我们的排序规则,进而对整个对象排序。
正确性证明:
- 利用排序解决问题,最重要的就是需要证明"在新的排序规则下,整个集合可以排序"。这需要用到离散数学中"全序关系"的知识。
- 但是证明过程很麻烦,后续题目中我们只要发现该题最终结果需要排序,并且交换相邻两个元素的时候,对其余元素不会产生影响,那么我们就可以推导出排序的规则,然后直接去排序,就不去证明了。
1.拼数
解法:贪心
第一时间的想法是利用字典序进行排序,但是该想法是错误的(反例:53和534)
我们发现,任取序列里面相邻的两项 a[i], a[i + 1],交换它们的顺序,并不影响 [1, i - 1] 与 [i + 1, n] 之间每一位的权值。因此我们可以找一种比较方式,对整个数组排序,最终的结果就是最优序列。
设两个相邻的数对应的字符串形式为 x, y ,因为要的是最大值,所以自定义比较方式:
- x + y > y + x:x 放在前面,y 放在后面。
- x + y < y + x:y 放在前面,x 放在后面。
- x + y = y + x:谁前谁后无所谓。
#include<iostream>
#include<string>
#include<algorithm>
using namespace std;
const int N = 25;
int n;
string a[N];
bool cmp(string& x, string& y)
{
return x + y > y + x;
}
int main()
{
cin >> n;
for(int i = 1; i <= n; i++) cin >> a[i];
sort(a + 1, a + 1 + n, cmp);
for(int i = 1; i <= n; i++) cout << a[i];
return 0;
}
2.Protecting the Flowers
P2878 [USACO07JAN] Protecting the Flowers S
解法:贪心
我们发现,在一个序列中,任意交换相邻两头牛 a[i], a[i + 1] 的顺序之后,区间 [1, i - 1] 以及 [i + 1, n] 内所有牛吃草的总量不变。因此我们可以找一种比较方式,对整个数组排序,最终的结果就是最优序列。
#include<iostream>
#include<algorithm>
using namespace std;
typedef long long LL;
const int N = 1e5 + 10;
int n;
struct node
{
int t;
int d;
}a[N];
bool cmp(node& x, node& y)
{
return x.t * y.d < y.t * x.d;
}
int main()
{
cin >> n;
for(int i = 1; i <= n; i++) cin >> a[i].t >> a[i].d;
sort(a + 1, a + 1 + n, cmp);
LL ret = 0, time = 0;
for(int i = 1; i <= n; i++)
{
ret += a[i].d * time;
time += 2 * a[i].t;
}
cout << ret << endl;
return 0;
}
3.奶牛玩杂技
解法:贪心
我们发现,在一个序列中,任意交换相邻两头牛 a[i], a[i + 1] 的顺序之后,区间 [1, i - 1] 以及 [i + 1, n] 内每一头牛的压扁指数都是不变。因此我们可以找一种比较方式,对整个数组排序,最终的结果就是最优序列。
#include<iostream>
#include<algorithm>
using namespace std;
typedef long long LL;
const int N = 1e5 + 10;
int n;
struct node
{
LL w;
LL s;
}a[N];
bool cmp(node& i, node& j)
{
return max(-i.s, i.w - j.s) < max(-j.s, j.w - i.s);
}
int main()
{
cin >> n;
for(int i = 1; i <= n; i++) cin >> a[i].w >> a[i].s;
sort(a + 1, a + 1 + n, cmp);
LL ret = -1e9 - 10, w = 0;
for(int i = 1; i <= n; i++)
{
ret = max(ret, w - a[i].s);
w += a[i].w;
}
cout << ret << endl;
return 0;
}
3.哈夫曼编码
- 树的带权路径长度:从树的根到任意结点的路径长度与该结点上权值的乘积,称为该结点的带权路径长度。树中所有叶结点的带权路径长度之和称为该树的带权路径长度,记为:
其中,wi 是第 i 个叶结点所带的权值,li 是该叶结点到根结点的路径长度。
-
哈夫曼树:在含有 n 个带权叶结点的⼆叉树中,其中带权路径长度最小的二叉树称为哈夫曼树,也称最优二叉树。
-
哈夫曼算法:哈夫曼树的构建过程,是根据贪心策略得到的算法。主要流程为:
- 初始化:将所有叶子结点看做一棵棵树,那么刚开始我们有一片森林。
- 贪心:每次选择根节点权值最小的两棵树作为左右子树合并成一棵新的二叉树,这棵新的二叉树 根节点的权值为左右子树的权值之和。
- 重复 2 过程,直到森林中所有的树合并成一棵树。
在构建哈夫曼树的合并操作中,就可以计算出带权路径长度:
- 在合并的过程中,每一棵树的根节点的权值其实等于该树所有叶子结点的权值之和。
- 在每次合并的时候,由于多出来两条路径,此时累加上左右子树的根节点权值,相当于计算了一 次叶子结点到这两条路径的长度。
- 每次合并都把左右子树的权值累加起来,就是最终的带权路径长度。
- 哈夫曼编码:一种被广泛应用而且非常有效的数据压缩编码,其构造步骤如下:
- 统计待编码的序列中,每一个字符出现的次数。
- 将所有的次数当成叶结点,构建哈夫曼树。
- 规定哈夫曼树的左分支为 0,右分支为 1,那么从根节点走到叶子结点的序列,就是该叶子结点对应字符的编码。
1.【模板】哈夫曼编码
解法:贪心
每次拿出权值最小的两颗树合并,然后将合并后的树继续放回集合中,直到集合中只剩下一棵树。
#include<iostream>
#include<vector>
#include<queue>
using namespace std;
typedef long long LL;
int n;
priority_queue<LL, vector<LL>, greater<LL>> heap;
int main()
{
cin >> n;
for(int i = 1; i <= n; i++)
{
int x; cin >> x;
heap.push(x);
}
//构建哈夫曼树
LL ret = 0;
while(heap.size() > 1)
{
LL x = heap.top(); heap.pop();
LL y = heap.top(); heap.pop();
ret += (x + y);
heap.push(x + y);
}
cout << ret << endl;
return 0;
}
2.字符编码
解法:贪心
#include<iostream>
#include<queue>
#include<unordered_map>
#include<string>
#include<vector>
using namespace std;
string s;
int main()
{
while(cin >> s)
{
int n = s.size();
unordered_map<char, int> hashMap;
for(int i = 0; i < n; i++)
{
hashMap[s[i]]++;
}
priority_queue<int, vector<int>, greater<int>> heap;
for(auto e : hashMap)
{
heap.push(e.second);
}
int ret = 0;
while(heap.size() > 1)
{
int x = heap.top(); heap.pop();
int y = heap.top(); heap.pop();
heap.push(x + y);
ret += (x + y);
}
cout << ret << endl;
}
}
3.合并果子
解法:贪心
#include<iostream>
#include<queue>
#include<vector>
using namespace std;
typedef long long LL;
int n;
priority_queue<LL, vector<LL>, greater<LL>> heap;
int main()
{
cin >> n;
for(int i = 0; i < n; i++)
{
LL x; cin >> x;
heap.push(x);
}
LL ret = 0;
while(heap.size() > 1)
{
LL x = heap.top(); heap.pop();
LL y = heap.top(); heap.pop();
ret += (x + y);
heap.push(x + y);
}
cout << ret << endl;
return 0;
}
4.区间问题
- 区间问题是另一种比较经典的贪心问题。题目面对的对象是一个一个的区间,让我们在每个区间上做出取舍。
- 这种题目的解决方式一般就是按照区间的左端点或者是右端点排序,然后在排序之后的区间上,根据题目要求,制定出相应的贪心策略,进而得到最优解。
- 具体是根据左端点还是右端点排序?升序还是降序?一般是假设一种排序方式,并且制定贪心策略,当没有明显的反例时,就可以尝试去写代码。
1.线段覆盖
解法:贪心
按照区间左端点从小到大排序,当两个区间「重叠」的时候,我们必须要舍弃一个。为了能够「在移除某个区间后,保留更多的区间」,我们应该把「区间范围较大」的区间移除。
因此以第一个区间为基准,遍历所有的区间:
- 如果重叠,选择「最小的右端点」作为新的基准;
- 如果不重叠,那么我们就能多选一个区间,以「新区间为基准」继续向后遍历。
#include<iostream>
#include<algorithm>
using namespace std;
const int N = 1e6 + 10;
int n;
struct node //存储左右端点
{
int left;
int right;
}a[N];
int main()
{
cin >> n;
for(int i = 1; i <= n; i++) cin >> a[i].left >> a[i].right;
sort(a + 1, a + 1 + n, [&](node& x, node& y){ return x.left < y.left; });
int ret = 1;
int r = a[1].right; //以第一个区间为基准,向后选择
for(int i = 2; i <= n; i++)
{
int left = a[i].left, right = a[i].right;
if(left < r) //有重叠区间
{
r = min(r, right); //取右端点为最小值
}
else //无重叠区间
{
ret++;
r = right; //更新右端点
}
}
cout << ret << endl;
return 0;
}
2.Radar Installation
解法:贪心
如图所示,当一个岛屿的「坐标」已知,其实可以计算出:当雷达放在 x 轴的「哪段区间」时,可以覆盖到这个岛屿。
根据「勾股定理」得:ax 的长度为 l = sqrt(d * d - y * y),那么雷达所处的范围就是 [x - l, x + l]。因此,针对每一个岛屿,我们都可以算出以个「能够覆盖它的区间」。原问题就变成给定一些区间,所有互相重叠的区间以共有多少组。
按照区间「左端点从小到大」排序,当两个区间「重叠」的时候,为了后面能够「尽可能多的选出互相重叠的区间」,我们应该把「区间范围较大」的区间移除,因为选择较大区间会造成选出来的区间「不是互相重叠」的。因此以第一个区间为基准,遍历所有的区间:
- 如果重叠,选择「最小的右端点」作为新的基准;
- 如果不重叠,那么我们就能多选一个区间,以「新区间为基准」继续向后遍历。
虽然这道题和《线段覆盖》这道题处理区间的方式是一样的,但是「贪心的思路」是不一样的。
#include<iostream>
#include<algorithm>
#include<cmath>
using namespace std;
const int N = 1e3 + 10;
int n;
double d;
struct node //存储左右端点
{
double left;
double right;
}a[N];
bool cmp(node& x, node& y)
{
return x.left < y.left;
}
int main()
{
int cnt = 0;
while(cin >> n >> d, n && d) //逗号表达式
{
cnt++;
bool flag = true;
for(int i = 1; i <= n; i++)
{
double x, y; cin >> x >> y;
if(y > d) //该情况下雷达无法覆盖岛屿
{
flag = false;
break;
}
double l = sqrt(d * d - y * y);
a[i].left = x - l, a[i].right = x + l;
}
cout << "Case" << cnt << ": ";
if(flag == false) cout << -1 << endl;
else
{
sort(a + 1, a + 1 + n, cmp);
int ret = 1;
double r = a[1].right;
for(int i = 2; i <= n; i++)
{
double left = a[i].left, right = a[i].right;
if(left <= r) //有重叠区间
{
r = min(r, right);
}
else
{
ret++;
r = right;
}
}
cout << ret << endl;
}
}
return 0;
}
3.Sunscreen
P2887 [USACO07NOV] Sunscreen G
解法:贪心
思考具体解法,从区间左端点/右端点、从小到大/从大到小排序,共8种情况中,筛选出没有特别明显的反例的组合分别是:
- 区间按照「左端点从大到小」排序,防晒霜从大到小排序,「优先选择较大」的防晒霜。
- 区间按照「右端点从小到大」排序,防晒霜从小到大排序,「优先选择较小」的防晒霜。
实际上两种情况都是正确的,我们取其一证明,另一种证明方式类似。
#include<iostream>
#include<algorithm>
using namespace std;
const int N = 2510;
int n, m; //奶牛数量、防晒霜种类
struct node
{
int x; //左端点 / 阳光强度
int y; //右端点 / 防晒霜数量
}a[N], b[N];
bool cmp(node& x, node& y)
{
return x.x > y.x;
}
int main()
{
cin >> n >> m;
for(int i = 1; i <= n; i++) cin >> a[i].x >> a[i].y;
for(int i = 1; i <= m; i++) cin >> b[i].x >> b[i].y;
sort(a + 1, a + 1 + n, cmp); //按照左端点从大到小排序
sort(b + 1, b + 1 + m, cmp); //按照阳光强度从大到小排序
int ret = 0;
for(int i = 1; i <= n; i++)
{
int left = a[i].x, right = a[i].y;
//防晒霜按照从大到小排序分配给奶牛
for(int j = 1; j <= m; j++)
{
int strength = b[j].x, &cnt = b[j].y;
if(cnt == 0) continue;
if(strength < left) break;
if(strength > right) continue;
//left <= strength <= right
ret++;
cnt--;
break;
}
}
cout << ret << endl;
return 0;
}
4.Stall Reservations
P2859 [USACO06FEB] Stall Reservations S
解法:贪心
按照「起始时间」对所有奶牛「从小到大」排序,然后「从前往后」依次安排每一头奶牛,设这头奶牛的产奶的时间区间是 [a, b]:
- 在已经有牛的所有牛棚里,如果「结束时间小于 a 」,就可以把这头奶牛放在这个牛棚里面。如果有很多符合要求的,可以随便找一个。因为我们是按照起始时间从小到大排序,只要这些牛棚都符合要求,对于后面的奶牛而言也都符合要求。不妨找结束时间最早的,方便判断。
- 如果所有已经有牛的牛棚的「结束时间都大于 a 」,那么这头牛只能自己单独开一个牛棚。
#include<iostream>
#include<queue>
#include<algorithm>
using namespace std;
const int N = 5e4 + 10;
int n;
struct node
{
int x; //起始时间 / 结束时间
int y; //终止时间 / 牛棚编号
int z; //奶牛编号
bool operator<(const node& a) const
{
return x > a.x;
}
}a[N];
int ret[N]; //存储结果:奶牛编号为i时,奶牛所在的牛棚编号j (ret[i] = j)
bool cmp(node& x, node& y)
{
return x.x < y.x;
}
int main()
{
cin >> n;
for (int i = 1; i <= n; i++)
{
cin >> a[i].x >> a[i].y;
a[i].z = i;
}
sort(a + 1, a + 1 + n, cmp); //按照奶牛的起始时间从小到大进行排序
priority_queue <node> heap; //按照结束时间创建小堆
ret[a[1].z] = 1; //起始时间最小的那头奶牛放置早编号为1的牛棚里面
heap.push({ a[1].y, 1 });
int num = 1; //当前牛所在的牛棚编号
for (int i = 2; i <= n; i++)
{
int left = a[i].x, right = a[i].y;
if (left <= heap.top().x) //重新分配新的牛棚
{
num++;
ret[a[i].z] = num;
heap.push({ right, num });
}
else //可以放在已经分配奶牛的牛棚里面
{
node t = heap.top(); heap.pop();
ret[a[i].z] = t.y;
heap.push({ right, t.y });
}
}
cout << num << endl;
for (int i = 1; i <= n; i++) cout << ret[i] << endl;
return 0;
}