贪心法(一):贪心法的基本思想
在实际问题中,经常会遇到求一个问题的可行解和最优解的问题,这就是所谓的最优化问题。每个最优化问题都包含一组限制条件和一个优化函数,符合条件的解决方案称为可行解,使优化函数取得最佳值的可行解称为最优解。
贪心法是求解这类问题的一种常用算法,它从问题的某一个初始解出发,采用逐步构造最优解的方法向给定的目标前进。
贪心法在每个局部阶段,都做出一个看上去最优的决策(即某种意义下的、或某个标准下的局部最优解),并期望通过每次所做的局部最优选择产生出一个全局最优解。
做出贪心决策的依据称为贪心准则(策略)。
想象这样一个场景:一个小孩买了价值少于10元的糖,并将10元钱交给了售货员。售货员希望用数目最少的人民币(纸币或硬币)找给小孩。假设提供了数目不限的面值为5元、2元、1元、5角以及1角的人民币。售货员应该这样找零钱呢?售货员会分步骤组成要找的零钱数,每次加入一张纸币或一枚硬币。选择要找的人民币时所采用的准则如下:每一次选择应使零钱数尽量增大。为保证不多找,所选择的人民币不应使零钱总数超过最终所需的数目。
假设需要找给小孩6元7角,首先入选的是一张5元的纸币,第二次入选的不能是5元或2元的纸币,否则零钱总数将超过6元7角,第二次应选择1元的纸币(或硬币),然后是一枚5角的硬币,最后加入两个1角的硬币。
这种找零钱的方法就是贪心法。选择要找的人民币时所采用的准则就是采取的贪心标准(或贪婪策略)。
贪心法(又称贪婪算法)是指在求最优解问题的过程中,依据某种贪心标准,从问题的初始状态出发,通过若干次的贪心选择而得出最优解或较优解的一种阶梯方法。从贪心法“贪心”一词便可以看出,在对问题求解时,贪心法总是做出在当前看来是最好的选择。也就是说,贪心法不从整体最优上加以考虑,它所做出的仅是在某种意义上的局部最优解。
贪心法主要有以下两个特点:
(1)贪心选择性质:算法中每一步选择都是当前看似最佳的选择,这种选择依赖于已做出的选择,但不依赖于未作出的选择。
(2)最优子结构性质:算法中每一次都取得了最优解(即局部最优解),要保证最后的结果最优,则必须满足全局最优解包含局部最优解。
利用贪心法求解问题的一般步骤是:
(1)产生问题的一个初始解。
(2)循环操作,当可以向给定的目标前进时,就根据局部最优策略,向目标前进一步。
(3)得到问题的最优解(或较优解)。
实现该算法的程序框架描述为:
从问题的某一初始解出发;
while (能朝给定总目标前进一步)
{
求出可行解的一个解元素;
}
由所有解元素组合成问题的一个可行解;
贪心法的优缺点主要表现在:
优点:一个正确的贪心算法拥有很多优点,比如思维复杂度低、代码量小、运行效率高、空间复杂度低等。
缺点:贪心法的缺点集中表现在他的“非完美性”。通常我们很难找到一个简单可行并且保证正确的贪心思路,即使我们找到一个看上去很正确的贪心思路,也需要严格的正确性证明。这往往给直接使用贪心算法带来了较大的困难。
尽管贪心算法不是对所有问题都能得到整体最优解,但对范围相当广泛的许多问题它能产生整体最优解或者是整体最优解的近似解。
【例1】租独木舟
一群大学生到东湖水上公园游玩,在湖边可以租独木舟,各独木舟之间没有区别。一条独木舟最多只能乘坐两个人,且乘客的总重量不能超过独木舟的最大承载量。为尽量减少游玩活动中的花销,需要找出可以安置所有学生的最少的独木舟条数。编写一个程序,读入独木舟的最大承载量、大学生的人数和每位学生的重量,计算并输出要安置所有学生必须的最少的独木舟条数。
(1)编程思路。
先将大学生按体重从大到小排好序。由于一条独木舟最多只能乘坐两个人,因此基于贪心法安排乘船时,总是找到一个当前体重最大的人,让它尽可能与当前体重最小的人同乘一船,如此循环直至所有人都分配完毕,即可统计出所需要的独木舟数。
(2)源程序及运行结果。
#include <iostream>
using namespace std;
int main()
{
int maxweight,n,i,j,t,num;
int w[300];
cout<<"请输入每条独木舟的载重量和大学生的人数:";
cin>>maxweight>>n;
cout<<"请输入每位学生的体重:"<<endl;
for(i = 0; i < n; i++)
cin>>w[i];
for (i=0;i<n-1;i++) // 用冒泡排序法将体重按从大到小排序
for (j=0;j<n-i-1; j++)
if (w[j]<w[j+1])
{
t=w[j]; w[j]=w[j+1]; w[j+1]=t;
}
i = 0,num = 0;
while(i <= n-1)
{
if(w[i] + w[n -1] <= maxweight)
{
i++; n--; num++;
}
else
{
i++; num++;
}
}
cout<<"最少需要独木舟"<<num<<"条。"<<endl;
return 0;
}
编译并执行以上程序,得到如下所示的结果。
请输入每条独木舟的载重量和大学生的人数:100 12
请输入每位学生的体重:
45 48 52 56 64 61 60 58 56 40 44 50
最少需要独木舟8条。
Press any key to continue
需要特别注意的是,贪心法在求解最优化问题时,对大多数优化问题能得到最优解,但有时并不能求得最优解。
【例2】0/1背包问题
有一个容量为c的背包,现在要从n件物品中选取若干件装入背包中,每件物品i的重量为w[i]、价值为p[i]。定义一种可行的背包装载为:背包中物品的总重不能超过背包的容量,并且一件物品要么全部选取、要么不选取。定义最佳装载是指所装入的物品价值最高,并且是可行的背包装载。
例如,设c= 12,n=4,w[4]={2,4,6,7},p[4]={ 6,10,12,13},则装入w[1]和w[3],最大价值为23。
- 问题分析
若采用贪心法来解决0/1背包问题,可能选择的贪心策略一般有3种。每种贪心策略都是采用多步过程来完成背包的装入,在每一步中,都是利用某种贪心准则来选择将某一件物品装入背包。
(1)选取价值最大者。
贪心策略为:每次从剩余的物品中,选择可以装入背包的价值最大的物品装入背包。这种策略不能保证得到最优解。例如,设C=30,有3个物品A、B、C,w[3]={28,12,12},p[3]={30,20,20}。根据策略,首先选取物品A,接下来就无法再选取了,此时最大价值为30。但是,选取装B和C,最大价值为40,显然更好。
(2)选取重量最小者。
贪心策略为:从剩下的物品中,选择可以装入背包的重量最小的物品装入背包。其想法是通过多装物品来获得最大价值。这种策略同样不能保证得到最优解。它的反例与第(1)种策略的反例差不多。
(3)选取单位重量价值最大者
贪心策略为:从剩余物品中,选择可装入背包的p[i]/w[i]值最大的物品装入。这种策略还是不能保证得到最优解。例如,设C=40,有3个物品A、B、C,w[3]={15,20,28},p[3]={15,20,30}。按照策略,首先选取物品C(p[2]/w[2]>1),接下来就无法再选取了,此时最大价值为30。但是,选取装A和B,最大价值为35,显然更好。
由上面的分析可知,采用贪心法并不一定可以求得最优解。学习了动态规划算法后,这个问题可以得到较好的解决。采用动态规划编写的源程序及运行结果如下。
#include <iostream>
#include <iomanip>
using namespace std;
#define N 50
int main()
{
int p[N],w[N],m[N][5*N];
int i,j,c,cw,n,sw,sp;
cout<<"请输入 n 值:"; cin>>n;
cout<<"请输入背包容量:"; cin>>c;
cout<<"请依次输入每种物品的重量:";
for (i=1;i<=n;i++)
cin>>w[i];
cout<<"请依次输入每种物品的价值:";
for (i=1;i<=n;i++)
cin>>p[i];
for (j=0;j<=c;j++) // 首先计算边界条件m[n][j]
if (j>=w[n])
m[n][j]=p[n];
else
m[n][j]=0;
for(i=n-1;i>=1;i--) // 逆推计算m[i][j] (i从n-1到1)
for(j=0;j<=c;j++)
if(j>=w[i] && m[i+1][j]<m[i+1][j-w[i]]+p[i])
m[i][j]= m[i+1][j-w[i]]+p[i];
else
m[i][j]=m[i+1][j];
cw=c;
cout<<"背包所装物品如下:"<<endl;
cout<<" i w(i) p(i) "<<endl;
cout<<"----------------------"<<endl;
for(sp=0,sw=0,i=1;i<=n-1;i++) // 以表格形式输出结果
if(m[i][cw]>m[i+1][cw])
{
cw-=w[i];sw+=w[i];sp+=p[i];
cout<<setw(3)<<i<<setw(8)<<w[i]<<setw(8)<<p[i]<<endl;
}
if(m[1][c]-sp==p[n])
{
sw+=w[n];sp+=p[n];
cout<<setw(3)<<n<<setw(8)<<w[n]<<setw(8)<<p[n]<<endl;
}
cout<<"装载物品重量为 "<<sw<<" , 最大总价值为 "<<sp<<endl;
return 0;
}
编译并执行以上程序,得到如下所示的结果。
请输入 n 值:6
请输入背包容量:60
请依次输入每种物品的重量:15 17 20 12 9 14
请依次输入每种物品的价值:32 37 46 26 21 30
背包所装物品如下:
i w(i) p(i)
----------------------
2 17 37
3 20 46
5 9 21
6 14 30
装载物品重量为 60 , 最大总价值为 134
Press any key to continue
【例3】取数游戏
给出2n个(n<=100)个自然数。游戏双方分别为A方(计算机方)和B方(对弈的人)。只允许从数列两头取数。A先取,然后双方依次轮流取数。取完时,谁取得的数字总和最大即为取胜方;若双方的和相等,属于A胜。试问A方可否有必胜的策略?
(1)编程思路。
设n=4时,8个自然数的序列为:7 10 3 6 4 2 5 2。
若设计这样一种原始的贪心策略:让A每次取数列两头较大的那个数。由于游戏者也不会傻,他也会这么干,所以,在上面的数列中,A方会顺序取7、3、4、5,B方会顺序取10、6、2、2,由此得出:A方取得的数和为7+3+4+5=19,B方取得的数和为10+6++2+2=20,按照规则,判定A方输。
因此,如果按上述的贪心策略去玩这个游戏,A并没有必胜的保证。仔细观察游戏过程,可以发现一个事实:由于是2n个数,A方取走偶位置上的数以后,剩下两端数都处于奇位置;反之,若A方取走的是奇位置上的数,则剩下两端的数都处于偶位置。
也就是说,无论B方如何取法,A方既可以取走奇位置上的所有数,也可以取走偶位置上的所有数。
由此,可以得出一种有效的贪心策略:若能够让A方取走“数和较大的奇(或偶)位置上的所有数”,则A方必胜。这样,取数问题便对应于一个简单的问题了:让A方取奇偶位置中数和较大的一半数。
程序中用数组a存储2*n个自然数的序列,先求出奇数位置和偶数位置的数和sa及sb。设置tag为A取数的奇偶位置标志,tag=0表示偶位置数和较大,A取偶位置上的所有数;tag=1表示奇位置上的数和较大,A取奇位置上的所有数。
lp、rp为序列的左端位置和右端位置;ch为输入的B方取数的位置信息(L或R)。
(2)源程序及运行结果。
#include <iostream>
using namespace std;
int main()
{
int a[201],n,i,sa,sb,tag,t,lp,rp;
char ch;
cout<<"请输入 n 的值:";
cin>>n;
cout<<"请依次输入 "<<2*n<<" 个自然数:"<<endl;
for (i=1;i<=2*n;i++)
cin>>a[i];
sa=sb=0;
for(i=1;i<=n;i++)
{
sa=sa+a[2*i-1];
sb=sb+a[2*i];
}
if (sa>=sb) tag=1;
else { tag=0; t=sa; sa=sb; sb=t; }
lp = 1; rp = 2*n;
for(i=1;i<=n;i++) // A方和B方依次进行n次对弈
{
if ((tag==1 && lp % 2==1) || (tag==0 && lp % 2 == 0))
// 若A方应取奇位置数且左端位置为奇,或者A方应取得偶位置数且
// 左端位置为偶,则A方取走左端位置的数
{ cout<<" A方左端取数 "<<a[lp]; lp = lp + 1; }
else
{ cout<<" A方右端取数 "<<a[rp]; rp = rp - 1; }
do {
cout<<" B方取数,输入L(取左边的数)或R(取右边的数):"; cin>>ch;
if (ch =='L' || ch=='l')
{ cout<<" B方左端取数 "<<a[lp]<<endl; lp = lp + 1; }
if (ch == 'R' || ch=='r')
{ cout<<" B方右端取数 "<<a[rp]<<endl; rp = rp - 1; }
} while (!(ch == 'L' || ch =='R' || ch=='l' || ch=='r'));
}
cout<<" A方取数的和为 "<<sa<<" ,B方取数的和为"<<sb<<endl;
return 0;
}
编译并执行以上程序,得到如下所示的结果。
请输入 n 的值:4
请依次输入 8 个自然数:
7 10 3 6 4 2 5 2
A方右端取数 2 B方取数,输入L(取左边的数)或R(取右边的数):L
B方左端取数 7
A方左端取数 10 B方取数,输入L(取左边的数)或R(取右边的数):L
B方左端取数 3
A方左端取数 6 B方取数,输入L(取左边的数)或R(取右边的数):R
B方右端取数 5
A方右端取数 2 B方取数,输入L(取左边的数)或R(取右边的数):L
B方左端取数 4
A方取数的和为 20 ,B方取数的和为19
Press any key to continue
【例4】删数问题
从键盘输入一个高精度正整数num(num不超过200位,且不包含数字0),任意去掉S个数字后剩下的数字按原先后次序将组成一个新的正整数。编写一个程序,对给定的num和s,寻找一种方案,使得剩下的数字组成的新数最小。
例如,输入:51428397 5,输出:123。
(1)编程思路。
由于键盘输入的是一个高精度正整数num(num不超过200位,且不包含数字0),因此用字符串数组来进行存储。
为了尽可能地逼近目标,选取的贪心策略为:每一步总是选择一个使剩下的数最小的数字删去,即按高位到低位的顺序搜索,若各位数字递增,则删除最后一个数字,否则删除第一个递减区间的首字符。然后回到串首,按上述规则再删除下一个数字。重复以上过程s次,剩下的数字串便是问题的解了。
也就是说,删数问题采用贪心算法求解时,采用最近下降点优先的贪心策略:即x1<x2<…<xi<xj;如果xk<xj,则删去xj,得到一个新的数且这个数为n-1位中为最小的数Nl,可表示为x1x2…xixkxm…xn。对N1而言,即删去了1位数后,原问题T变成了需对n-1位数删去k-1个数的新问题T′。新问题和原问题相同,只是问题规模由n减小为n-1,删去的数字个数由k减少为k-1。基于此种删除策略,对新问题T′,选择最近下降点的数进行删除,如此进行下去,直至删去k个数为止。
(2)源程序及运行结果。
#include<iostream>
using namespace std;
int main()
{
char num[200]={'\0'};
int times,i,j;
cout<<"Please input the number:";
cin>>num;
cout<<"input times:";
cin>>times;
while(times>0) // 循环times,每次删除一个数字
{
i=0; // 每次删除后从头开始搜寻待删除数字
while (num[i]!='\0' && num[i]<=num[i+1])
i++;
for(j=i;j<strlen(num);j++)
num[j]=num[j+1]; // 将位置i处的数字删除
times--;
}
cout<<"Result is "<<num<<endl;
return 0;
}
编译并执行以上程序,得到如下所示的结果。
Please input the number:1235498673214
input times:5
Result is 12343214
Press any key to continue
【例5】过河问题
在一个月黑风高的夜晚,有一群旅行者在河的右岸,想通过唯一的一根独木桥走到河的左岸。在这伸手不见五指的黑夜里,过桥时必须借助灯光来照明,不幸的是,他们只有一盏灯。另外,独木桥上最多承受两个人同时经过,否则将会坍塌。每个人单独过桥都需要一定的时间,不同的人需要的时间可能不同。两个人一起过桥时,由于只有一盏灯,所以需要的时间是较慢的那个人单独过桥时所花的时间。现输入n(2≤n<100)和这n个旅行者单独过桥时需要的时间,计算总共最少需要多少时间,他们才能全部到达河的左岸。
例如,有3个人甲、乙、丙,他们单独过桥的时间分别为1、2、4,则总共最少需要的时间为7。具体方法是:甲、乙一起过桥到河的左岸,甲单独回到河的右岸将灯带回,然后甲、丙再一起过桥到河的左岸,总时间为2+1+4=7。
(1)编程思路。
假设n个旅行者的过桥时间分别为t[0]、t[1]、t[2]、…、t[n-1](已按升序排序),n个旅行者过桥的最快时间为sum。
从简单入手,如果n= 1,则sum =t[0];如果n = 2,则sum = t[1];如果n = 3,则sum = t[0]+t[1]+t[2]。如果n > 3,考虑将单独过河所需要时间最多的两个人送到对岸去,有两种方式:
1)最快的(即所用时间t[0])和次快的(即所用时间t[1])过河,然后最快的将灯送回来;之后次慢的(即所用时间t[N-2])和最慢的(即所用时间t[N-1])过河,然后次快的将灯送回来。
2)最快的和最慢的过河,然后最快的将灯送回来;之后最快的和次慢的过河,然后最快的将灯送回来。
这样就将过河所需时间最大的两个人送过了河,而对于剩下的人,采用同样的处理方式,接下来做的就是判断怎样用的时间最少。
方案1)所需时间为:t[0]+2*t[1]+t[n-1]
方案2)所需时间为:2*t[0]+t[n-2]+t[n-1]
如果方式1)优于方式2),那么有t[0]+2*t[1]+t[n-1]<2*t[0]+t[n-2]+t[n-1] 化简得2*t[1]<t[0]+t[n-2]
即此时只需比较2*t[1]与t[0]+t[n-2]的大小关系即可确定最小时间,此时已经将单独过河所需时间最多的两个人送过了河,那么剩下过河的人数为n=n-2,采取同样的处理方式。
(2)源程序及运行结果。
#include <iostream>
using namespace std;
int main()
{
int n,t[100],i,sum,t1,t2;
cout<<"请输入需要过河的人数:";
cin>>n;
cout<<"请按升序排列的顺序依次输入每人过河的时间:"<<endl;
for (i=0;i<n;i++)
cin>>t[i];
if (n==1) // 一个人过河
sum=t[0];
else // 多个人过河
{
sum = 0;
while(1)
{
if(n == 2) // 剩两个人
{
sum += t[1];
break;
}
else if(n == 3) // 剩三个人
{
sum += t[0] + t[1] + t[2];
break;
}
else
{
t1 = t[0] + t[1] + t[1] + t[n-1]; // 方案1
t2 = t[0] + t[0] + t[n-1] + t[n-2]; // 方案2
sum += (t1 > t2 ? t2 : t1);
n -= 2;
}
}
}
cout<<"最少需要的时间为:"<<sum<<endl;
return 0;
}
编译并执行以上程序,得到如下所示的结果。
请输入需要过河的人数:5
请按升序排列的顺序依次输入每人过河的时间:
1 2 4 5 10
最少需要的时间为:22
Press any key to continue