引入
首先,我们先来看到一道例题,来引入贪心算法的学习。
最大乘积——例题引入
一个正整数可以表示为若干个互不相同的正整数之和,如:6 = 1 + 5 = 2 + 4 = 1 + 2 + 3 = 6有4种表示方法,这4种表示方法里,拆分出的数字的乘积分别为5 、8、6、6。
给出n,求n的所有表示方案中,拆分出的数字的乘积最大的方案,按升序依次输出拆分出的数字。
输入格式
从标准输入读入数据。
输入一个正整数n(n ≤ 10^6)。
输出格式
输出到标准输出。
按升序输出拆分出的数字。
样例输入
100
样例输出
2 3 5 6 7 8 9 10 11 12 13 14
运用贪心算法解决问题,就需要我们找到一种一以贯之的策略,这种策略就叫做贪心策略,这种策略能保证取得最优。也就是说,贪心算法就是在每一步操作中,都选择局部最优解(当然,你需要保证这样取能得到全局最优解)。
所以说,我们解决这一类问题,目标便是找到贪心策略。一般地,我们需要发现、挖掘性质得出最优解。
首先,非常显然地,如果n ≤ 4,那么直接输出n即可。对于n ≥ 5,首先为了乘积最大,是不可能拆出1来的(非常显然),又要为了乘积最大,所以拆出来的数就必须要多,所以说不难想出,最后的形式一定是2 + 3 + 4 + ... + (m - 1) + m + r,其中r ≤ m + 1。如果r = m + 1,直接输出即可,但是如果r < m + 1,就会出现重复,根据我们小学二年级就学过的“和一定,差小积大”,我们需要把r分配给m - r + 1到m的所有数,每个数分配1。(读者可以把1到10都试一遍,猜出策略)
这就是这道题的贪心策略(其实是非常简单的)。
下面,上代码!!!
AC代码
#include <iostream>
#include <algorithm> //为了用exit()函数
using namespace std;
int n;
int main() {
ios::sync_with_stdio(false), cin.tie(NULL);//给程序提提速
int tmp = 0, border;//临时变量,边界
cin >> n;
if (n <= 4) {//特判
cout << n;
exit(0);//game over
}
for (int i = 2;; i++) {
tmp += i;
if (tmp > n) {
border = i - 1;//寻找边界,即什么时候这一串数的连续性被打断,出现重复
break;
}
}
int tmp1 = n - ((border + 2) * (border - 1) / 2);
int border1 = border - tmp1;//多少个数可以正常输出(即形如2 3 ... 2 + border1 - 1)
if (border1 == 0) {//特判
for (int i = 3; i <= border; i++)
cout << i << ' ';
cout << border + 2;
exit(0);//game over
}
for (int i = 2; i <= border1; i++)
cout << i << ' ';
for (int i = border1 + 1; i <= border; i++)
cout << i + 1 << ' ';
return 0;//功德圆满
}
小结
贪心算法的目标是找到某个“一以贯之的策略”;
从小到大分析,或逐个元素分析,往往会得到有用的结论;
把结论联合起来,能得到最终的策略。
几道有趣的例题
纸牌游戏
有n堆纸牌,编号从1至n,每堆纸牌有若干张,每次操作可以在任一堆上取若干张纸牌,然后移动到相邻的纸牌堆中去。
目标局面有两种,第一种是第i(1 ≤ i ≤ n)堆纸牌恰好有i张纸牌(即纸牌数量依次递增),第二种是第i(1 ≤ i ≤ n)堆纸牌恰好有n + 1 - i张纸牌(即纸牌数量依次递减)。
求将初始局面移动到任意一个目标局面的最小操作次数,如果目标局面不可能达成,输出-1。
从标准输入读入数据。
输入格式
从标准输入读入数据。
第一行输入一个正整数n(n ≤ 1000)。
第二行输入n个非负整数ai(ai ≤ 10^6),表示初始局面下每堆牌的张数。
输出格式
输出到标准输出。
输出一个整数,表示移动到任意一个目标局面的最小操作次数,如果目标局面不可能达成,输出-1。
样例输入
6 1 1 0 8 6 5
样例输出
样例解释3
初始局面:(1,1,0,8,6,5)
第1次移动后:(1,1,0,8,5,6)
第2次移动后:(1,1,4,4,5,6)
第3次移动后:(1,2,3,4,5,6)
思路
首先,我们先要明确什么时候无法达成目标的两种局面。经过屏幕前的你两秒半的思考后,大概就会发现,当这n个数的和与1 + 2 + ... + n的和不相等时,那么无论怎么操作,都是达成不了的,所以直接输出-1即可。
那么,在去掉这种特殊情况后,我们就要去寻找这道题的贪心策略,而这道题的贪心策略也应该是比较好找的。请屏幕前的你自行思考两分半。。。
很好,相信在你的细心观察与思考下,不到两分半就找到了这道题的正确解法。
在样例解释的提醒下,真正的贪心策略如下:首先看升序情况,从后往前(其实从前往后也行,主要看你自己)依次判断第i个数是否等于i,如果是,就判断下一个数,如果不是,就把这个数变成i(就是加一个数tmp),那么下一个被判断的数就要减去这个数tmp,然后总操作数再加1,以此类推。。。这种情况模拟完之后,再同理模拟降序情况即可。
OK!!!这道题就基本做完了!!!
下面,上代码!!!(这份代码应该是最快的,不信你看)
AC代码
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 1e6 + 11;
int n, a[N], b[N], ans1, ans2;
int main(){
ios::sync_with_stdio(false), cin.tie(NULL);//给程序提提速
cin >> n;
int sum = 0;//标记总和
for(int i = 1; i <= n; i++){
cin >> a[i]; //读入,作为第一种升序情况
sum += a[i];//总和加上a[i]
b[i] = a[i];//额外记录第二种降序情况
}
if(n * (n + 1) != sum * 2){//用我们幼儿园就学过的知识判断总和
cout << "-1" << '\n';
exit(0);//game over
}
for(int i = n; i >= 2; i--){//升序
if(a[i] != i){
int ikun = a[i] - i;
a[i - 1] += ikun;//模拟一次操作
ans1++;
}
}
for(int i = n; i >= 2; i--){//降序
if(b[i] != n + 1 - i){
int ikun = b[i] - n - 1 + i;
b[i - 1] += ikun;//模拟一次操作
ans2++;
}
}
int ans = (ans1 < ans2) ? ans1 : ans2;//得到两种情况的最小值
cout << ans << '\n';//输出
return 0;//功德圆满:)
}
上面这道题其实很简单,相信对你没有什么挑战性。
那么我们接着看这道题(相信普通的过河问题对你来说太简单了)
过河问题2.0
河边有一只小船,小船最多能容纳3人。有n个人想要过河,每个人都拥有一个属性:划船时间ti 。
- 如果船上只有1人,那么只需要1人划船,小船从河的一岸到另一岸所需时间为划船者i的划船时间ti。
- 如果船上有不少于2人,那么需要2人划船,小船从河的一岸到另一岸所需时间为2个划船者i,j的划船时间的较大值max{ti,tj}。
小船过河后,必须有人将它划回来,以载下一批人过河。不计上下船所需时间,求n人全部过河的最短时间。
输入格式
从标准输入读入数据。
第一行输入一个正整数n(n ≤ 2000)。
第二行输入n个正整数ti(ti ≤ 100000),代表每个人的划船时间。
输出格式
输出到标准输出。
输出一个正整数,代表过河所需最短时间。
样例1输入
5 10 1 4 2 8
样例1输出
7
样例1解释
一种最优的过河策略为:
- 1号、2号、4号过河,时间为2
- 2号回来,时间为1
- 2号、3号、5号过河,时间为4
样例2输入
10 1 2 4 7 8 8 9 9 18 114514
样例2输出
27
样例2解释
一种最优的过河策略为:
- 1号、2号、10号过河,时间为2
- 1号、2号回来,时间为2
- 1号、2号、9号过河,时间为2
- 1号、2号回来,时间为2
- 1号、2号、 8号过河,时间为2
- 1号、2号回来,时间为2
- 1号、2号、 7号过河,时间为2
- 1号回来,时间为1
- 1号、3号、 5号过河,时间为4
- 1号回来,时间为1
- 1号、4号、 6号过河,时间为7
这道题还是很有难度的(不信你看↓)
(最左侧表示人数,下侧表示大于等于该分数的一共有多少人
(366人惨烈牺牲))
但是
相信屏幕前聪明的你一定能做出来!!!
思考片刻,拿起纸和笔,算一算吧!(核善...和善的微笑)
思路
首先,我们需要对所有时间ti按照升序做排序处理(比较显然,不再赘述):t1 ≤ t1 ≤ ... ≤ tn
基本推论
推论一
推论二
推论三
详细分析
直观分析
数学分析
比较与结论
特殊情况
AC代码
为了锻炼大家自己读代码的能力,作者本人就不写注释了。作者懒,不想写。
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 2011;
int n, t[N], ikun;
int main(){
ios::sync_with_stdio(false), cin.tie(NULL);
cin >> n;
for(int i = 1; i <= n; i++)
cin >> t[i];
sort(t + 1, t + 1 + n);
int border = 0;
for(int i = n; i >= 1; i--){
if(4 * t[2] > t[1] + t[i]){
border = i;
break;
}
}
if(2 * border - 1 >= n){
if(n % 2 == 0 && t[1] + t[n / 2 + 1] > 2 * t[2]){
ikun += 2 * t[2];
n--;
}
for(int i = 2; i <= n / 2 + 1; i++)
ikun += t[1] + t[i];
ikun -= t[1];
} else {
ikun += 2 * t[2] * (n - (2 * border - 1));
for(int i = 2; i <= border; i++)
ikun += t[1] + t[i];
ikun -= t[1];
}
cout << ikun << '\n';
return 0;
}
我们再来看到一道例题
金银岛
你来到了一座金银岛上,金银岛上有n块金属,重量分别为w1,w2,...,wn,价值分别为v1,v2,...,vn;金银岛上还有1块宝石,重量为a,价值为b。
每块金属可以被任意分割,分割后的价值与分割后的重量成正比关系;宝石不能分割。
你的背包最多能装下重量为y的东西,求背包能装下的最大价值。
输入格式
从标准输入读入数据。
第一行输入两个正整数n(n ≤ 1000)和y(y ≤ 10^6)。
第二行输入n个正整数wi(wi ≤ 1000)。
第三行输入n个正整数vi(vi ≤ 1000)。
第四行输入1个正整数a(a ≤ 10^4)。
第五行输入1个正整数b(b ≤ 10^4)。
输出格式
输出到标准输出。
输出背包能装下的最大价值,四舍五入保留3位小数。保证给出答案与真实答案的误差不超过10^-6。
样例输入
3 14 6 3 4 12 4 6 5 7
样例输出
23.500
样例解释
先选择装宝石(5,7) 、金属(6,12),此时背包空间剩余3,获得的价值为19。最后将金属(4,6)切割为原来的3/4大小,将背包装满,获得的总价值为23.500。
思路
考虑到宝石是一个非常烦人的东西,因为宝石不同于金属,它不能分割,只能整个一起拿走。但是正因为它的这个特点,就只会延伸出拿宝石与不拿宝石两种情况,第一种情况只需要把宝石的重量与价值分别计算即可。
因为题目给出的数据是混乱的,所以非常显然,我们需要排序。我们引入性价比的概念,也就是价值与重量之比,并按照性价比来进行升序排序(应该对屏幕前聪明的各位来说非常easy)。
那么接下来的过程,我们只需要依次模拟把金属装进背包的过程即可。比较这个金属的重量与背包剩余容量谁大谁小,然后依情况讨论即可。
总体来说,这道题的贪心策略比较简单,主要只需注意排序以及对于宝石这个特殊东西的处理就行了。
下面上代码!!!
AC代码
#include <iostream>
#include <algorithm>
#include <iomanip>//为了用fixed与setprecision()函数
using namespace std;
int k, w, s, aa, bb;
struct metal {
int weight;
int value;
} a[3005];
bool cmp(metal x, metal y){
return x.value * y.weight > y.value * x.weight; //按照性价比升序排列(交叉相乘得到)
}
int main() {
ios::sync_with_stdio(false), cin.tie(NULL);
cin >> s >> w;
for (int i = 1; i <= s; i++)
cin >> a[i].weight;
for (int i = 1; i <= s; i++)
cin >> a[i].value;
cin >> aa >> bb;
int t = w - aa;
sort(a + 1, a + s + 1, cmp);
double ans = 0, ans1 = bb;
for (int i = 1; i <= s; i++) {//不含宝石
if (w >= a[i].weight) {
ans += a[i].value;
w -= a[i].weight;
} else {
ans += w * (1.0 * a[i].value / a[i].weight);
break;
}
}
for (int i = 1; i <= s; i++) {//含宝石(“算两次”)
if (t >= a[i].weight) {
ans1 += a[i].value;
t -= a[i].weight;
} else {
ans1 += t * (1.0 * a[i].value / a[i].weight);
break;
}
}
cout << fixed << setprecision(3) << max(ans, ans1) << endl;//四舍五入保留三位
return 0;//功德圆满
}
小结
通过上面三道例题,我们可以知道,对于贪心算法来解决的题目,关键点便是贪心策略的寻找,排序的选择,以及一些特殊情况的考虑。在解决这些问题的时候,着重注意以上3种情况,那么你大概率就能收获一个大大的 WA AC!!!
临项交换法
我们先来看一道例题。
堆积木现在有 𝑁 ( 1 ≤ 𝑛 ≤ 1,000 )块积木,每块积木都有自重 𝑤 𝑖 ( 0 ≤ 𝑤 𝑖 ≤ 1,000)和正常状态下的承重能力 𝑣 𝑖 ( 0 ≤ 𝑣 𝑖 ≤ 1,000 ),现在要把这 𝑛 块积木垒在一起,但是有可能某块积木的负重超过了它在正常状态下的承重能力,那么这块积木就有被压坏的危险,请问应该如何堆这𝑛 块积木使得 𝑛 块积木中最大的压力指数 max{𝑝 1 , 𝑝 2 , … , 𝑝 𝑛 }最小。这里定义压力指数𝑝 𝑖 为该积木的负重与其在正常状态下的承重能力的差值,即有: 𝑝 𝑖 = 𝑤 1 +𝑤2 + ⋯ + 𝑤 𝑖−1 − 𝑣 𝑖 (从 1 开始编号)。输入格式(作者不想写了)
- 重量大的希望尽量放在下面,这样可以降低对别的积木的影响。
- 承重大的也希望尽量放在下面,因为他本身能承受更多的重量。
我们将1,2,3, … 𝑖 − 1, 𝑖, 𝑖 + 1, 𝑖 + 2, … , 𝑛变为1,2,3, 𝑖 − 1, 𝑖 + 1, 𝑖, 𝑖 + 2, … , 𝑛。交换前后,[1. . 𝑖 − 1] 和 [𝑖 + 1. . 𝑛] 这两个区间里面的所有编号的积木的压力指数不变。
也就是说,我们交换𝑖和𝑖 + 1两块积木,只影响交换的这两项,此时我们可以考虑使用临项交换法。
考虑交换前𝑖和𝑖 + 1的压力指数:
- 𝑝𝑖 = 𝑤1 + 𝑤2 + ⋯ + 𝑤𝑖−1 − 𝑣𝑖
- 𝑝𝑖+1 = 𝑤1 + 𝑤2 + ⋯ + 𝑤𝑖 − 𝑣𝑖+1
再考虑交换后𝑖和𝑖 + 1的压力指数:
- 𝑞𝑖+1 = 𝑤1 + 𝑤2 + ⋯ + 𝑤𝑖−1 − 𝑣𝑖+1
- 𝑞𝑖 = 𝑤1 + 𝑤2 + ⋯ + 𝑤𝑖−1 + 𝑤𝑖+1 − 𝑣𝑖
这一坨 𝑐 = 𝑤1 + 𝑤2 + ⋯ + 𝑤𝑖−1 是所有项共有的,那么我们有:
- 𝑝𝑖 = 𝑐 − 𝑣𝑖 .
- 𝑝𝑖+1 = 𝑐 + 𝑤𝑖 − 𝑣𝑖+1.
- 𝑞𝑖+1 = 𝑐 − 𝑣𝑖+1.
- 𝑞𝑖 = 𝑐 + 𝑤𝑖+1 − 𝑣𝑖
经过分析,当max{𝑞𝑖 , 𝑞𝑖+1} < max{𝑝𝑖 , 𝑝𝑖+1} ,即max{−𝑣𝑖+1, 𝑤𝑖+1 − 𝑣𝑖}< max{−𝑣𝑖 , 𝑤𝑖 − 𝑣𝑖+1}时,交换之后可能变优(仅仅是这两项变优,整体可能不变)。
那么我们便可以得到我们的初始算法:
只要有相邻两项满足这个条件,就一直交换,直到没有相邻两项满足这个条件。
考虑如下式子:
max{−𝑣𝑖+1, 𝑤𝑖+1 − 𝑣𝑖} < max{−𝑣𝑖 , 𝑤𝑖 − 𝑣𝑖+1}
狂暴的老师
有n个同学(从0开始编号)在学习启发式的信奥课,同学们排队向老师提问。每个同学问的问题不同,因此答疑时长不同,设第i个同学的答疑时长为ti;每个同学的耐心值也不同,设第i个同学的耐心为pi;排在队伍里没有得到答疑的同学会发出吵闹声,第i个同学的吵闹值为ci。
通过细致的观察,你发现了一个重要的事实:对于每个i(0 ≤ i<n),均成立ti ≤ ci。
如果一个同学等待太久,他会暴躁;如果老师答疑时后面的同学太吵闹,他也会暴躁。每个同学的暴躁程度gi等于排在他前面的同学的答疑时长之和、减去自身耐心、再加上排在他后面的同学的吵闹值之和,即:gi = t0 + t1 + ... + ti-1 - pi + ci+1 + ci+2 + ... + cn-1(作者实在是不会用LateX,求谅解) 。
如果同学们很暴躁,老师会狂暴。老师的狂暴程度r等于所有同学暴躁程度gi的最大值,即 r = max{g1, g2, ..., gn-1}。
改变n个同学的排队顺序,老师的狂暴程度可能会发生变化。求所有的排队顺序中,老师的狂暴程度的最小值 min r。
输入格式
从标准输入读入数据。
第一行为一个正整数 n(1 ≤ n ≤ 3,000),表示有 n 位同学。
第二行到第 n+1 行,每行两个整数,分别是 ti(0 ≤ ti ≤ 300)、 pi(0 ≤ pi ≤300)、ci(0 ≤ ci ≤ 300)。
输出格式
输出到标准输出。
输出共一行,表示老师狂暴程度 r 的最小值。
样例输入
5 1 100 100 10 99 101 44 97 103 68 96 102 97 90 111
样例输出
316
子任务
对于20%的数据,n ≤ 5。
另外有20%的数据,ti = ci = 0 对所有 i 均成立。
此外还有20%的数据,ti = pi = 0 对所有 i 均成立。
思路
我们将1,2,3, … 𝑖 − 1, 𝑖, 𝑖 + 1, 𝑖 + 2, … , 𝑛变为1,2,3, 𝑖 − 1, 𝑖 + 1, 𝑖, 𝑖 + 2, … , 𝑛。交换前后,[1. . 𝑖 − 1] 和 [𝑖 + 1. . 𝑛] 这两个区间里面的所有编号的同学的暴躁程度不变。
也就是说,我们交换𝑖和𝑖 + 1两位同学,只影响交换的这两个,此时我们可以考虑使用临项交换法(这道题你可以认为是搭积木,排队接水这一类题的一个变式)。
首先,我们还是需要找到排序的参数。于是,我们又得经过一大堆繁琐的数学推导(其实也没有太繁琐)。仿照上面那一道堆积木的题,我们可以得到过程基本如下:
考虑交换前𝑖和𝑖 + 1的暴躁程度:
- g𝑖 = t0 + t1 + ... + ti-1 - pi + ci+1 + ci+2 + ... + cn-1
- g𝑖+1 = t0 + t1 + ... + ti-1 + ti - pi+1 + ci+2 + ... + cn-1
再考虑交换后𝑖和𝑖 + 1的暴躁程度:
- h𝑖+1 = t0 + t1 + ... + ti-1 - pi+1 + ci + ci+2 + ... + cn-1
- h𝑖 = t0 + t1 + ... + ti-1 + ti+1 - pi + ci+2 + ... + cn-1
这一坨k = t0 + t1 + ... + ti-1 + ci+2 + ... + cn-1是所有项共有的,那么我们有:
- g𝑖 = k − pi + ci+1.
- g𝑖+1 = k + t𝑖 − p𝑖+1.
- h𝑖+1 = k − p𝑖+1 + ci.
- h𝑖 = k + t𝑖+1 − p𝑖
接下来,我们考虑 max{h𝑖 , h𝑖+1} < max{g𝑖 , g𝑖+1},即max{ti+1 - pi, -pi+1 + ci} < max{-pi + ci+1, ti - pi+1}(这里取不取等其实是无所谓的,只要这样操作不变差就行)。
我们先来看右边的最大值。如果是ti - pi+1,因为题目中已经说过ti ≤ ci,所以-pi+1 + ci ≥ ti - pi+1,那么左边的最大值只能是ti+1 - pi。那么我们可以推出ti+1 - pi < ti - pi+1,也就是ti+1 + pi+1 < ti + pi。在此基础上,又因为我们假设ti - pi+1是右边的最大值,即ti - pi+1 > -pi + ci+1,且-pi + ci+1 ≥ -pi + ti+1,那么ti - pi+1 > -pi + ti+1,ti + pi > ti+1 + pi+1,与前面矛盾!
所以说,右边的最大值只能是-pi + ci+1,也就是说,-pi + ci+1 > ti - pi+1,如果左边的最大值是ti+1 - pi的话,是显然成立的,它一定小于(等于)左边,那么我们再考虑-pi+1 + ci。于是,我们便得到了-pi+1 + ci < -pi + ci+1,移项得pi + ci < pi+1 + ci+1。
终于,在历经千辛万苦之后,我们得到了这么一串式子。于是乎,我们便可以知道这道题需要我们按照pi + ci升序排序,然后再从0号学生开始依次进行操作。
OK,最主要的部分已经讲完了,相信下面的部分一定难不倒大家。
上代码!!!
AC代码
#include <iostream>
using namespace std;
struct stu{
int t, p, c;//定义结构体
}ikun[3011];
int n;
void qswap(int x, int y){
stu akun = ikun[x];
ikun[x] = ikun[y];
ikun[y] = akun;
}//交换结构体函数
int main(){
ios::sync_with_stdio(false), cin.tie(NULL);//给程序提提速
cin >> n;
for(int i = 1; i <= n; i++)//作者本人还是习惯从1开始读入
cin >> ikun[i].t >> ikun[i].p >> ikun[i].c;//读入
bool flag1 = true;//特判,如果全是0那么跳过排序
for(int i = 1; i <= n; i++){
if(ikun[i].t != 0 || ikun[i].c != 0)
flag1 = false;
}
if(flag1)
goto FLAG;//跳过排序
for(int i = 1; i <= n; i++)
for(int j = i + 1; j <= n; j++){
if(ikun[i].c + ikun[i].p < ikun[j].c + ikun[j].p)//对pi + ci进行升序排序
qswap(i, j);
}
FLAG: int ckun = 0;
for(int i = 3; i <= n; i++)
ckun += ikun[i].c;
int ans = -ikun[1].p + ckun + ikun[2].c, tkun = ikun[1].t;
for(int i = 2; i <= n; i++){
ans = max(ans, tkun - ikun[i].p + ckun);
tkun += ikun[i].t;
ckun -= ikun[i + 1].c;
}//操作部分,不再赘述
cout << ans;
return 0;//功德圆满
}
总结
那么,以上就是本篇文章的全部内容。每一道例题的方法都不唯一,作者的方法也不一定是最快的,如果读者有兴趣,可以自行探索其它方法!这是作者本人在CSDN博客上的第一篇文章,希望大家能够多多点赞,收藏,支持一下!!!谢谢大家!!!
(作者本人还是萌新,有没有大佬教一教LateX的使用,感激不尽!!!)
完结撒花!!!