ACM中主要的贪心算法

贪心算法

转载改编自:
https://blog.csdn.net/qq_32400847/article/details/51336300#

算法感知

贪心算法的定义:

  • 贪心算法是指在对问题求解时,总是做出在当前看来是最好的选择。也就是说,不从整体最优上加以考虑,只做出在某种意义上的局部最优解。
  • 贪心算法不是对所有问题都能得到整体最优解,关键是贪心策略的选择,选择的贪心策略必须具备无后效性,即某个状态以前的过程不会影响以后的状态,只与当前状态有关。

解题的通用步骤

  1. 建立数学模型来描述问题;
  2. 把求解的问题分成若干个子问题;
  3. 对每一子问题求解,得到子问题的局部最优解;
  4. 把子问题的局部最优解合成原来问题的一个解。

与动态规划的相似之处:

  • 最优解问题大部分都可以拆分成一个个的子问题,把解空间的遍历视作对子问题树的遍历,则以某种形式对树整个的遍历一遍就可以求出最优解,大部分情况下这是不可行的。
  • 贪心算法和动态规划本质上是对子问题树的一种修剪,两种算法要求问题都具有的一个性质就是子问题最优性(组成最优解的每一个子问题的解,对于这个子问题本身肯定也是最优的)。
  • 动态规划方法代表了这一类问题的一般解法,通过自底向上构造子问题的解,对每一个子树的根,求出下面每一个叶子的值,并且以其中的最优值作为自身的值,其它的值舍弃。
  • 而贪心算法是动态规划方法的一个特例,可以证明每一个子树的根的值不取决于下面叶子的值,而只取决于当前问题的状况。换句话说,不需要知道一个节点所有子树的情况,就可以求出这个节点的值。由于贪心算法的这个特性,它对解空间树的遍历不需要自底向上,而只需要自根开始,选择最优的路,一直走到底就可以了。
  • 贪心算法往往没有一个解题的套路或者模板,而且贪心算法的题目大多隐蔽,在比赛中往往会被误认为是其他的算法类型题,非常考验问题分析能力。

各式例题

1. 活动选择问题(题目来源:《挑战程序设计竞赛》)

  • 题意:有n个需要在同一天使用同一个教室的活动a1,a2,…,an,教室同一时刻只能由一个活动使用。每个活动ai都有一个开始时间si和结束时间fi 。一旦被选择后,活动ai就占据半开时间区间[si,fi)。如果[si,fi]和[sj,fj]互不重叠,ai和aj两个活动就可以被安排在这一天。该问题就是要安排这些活动使得尽量多的活动能不冲突的举行。
  • 例如下图所示的活动集合S,其中各项活动按照结束时间单调递增排序。

  • 考虑使用贪心算法的解法。为了方便,我们用不同颜色的线条代表每个活动,线条的长度就是活动所占据的时间段,蓝色的线条表示我们已经选择的活动;红色的线条表示我们没有选择的活动。
  • 如果我们每次都选择开始时间最早的活动,不能得到最优解:

  • 如果我们每次都选择持续时间最短的活动,不能得到最优解:

  • 可以用数学归纳法证明,贪心策略应该是每次选取结束时间最早的活动。
  • 直观上也很好理解,按这种方法选择相容活动为未安排活动留下尽可能多的时间。这也是把各项活动按照结束时间单调递增排序的原因。

来源:
http://acm.sdut.edu.cn/onlinejudge2/index.php/Home/Index/problemdetail/pid/1298.html 山东理工OJ 1298

AC Code:

#include
#include
using namespace std;
///结构体存储事件的属性
struct node
{
    int start;  ///事件的开始时间
    int end;   ///事件的结束时间
    int select;  ///标记是否选择该事件
    int id;   ///事件的id
}a[100],temp;
bool cmp(node &m,node &n)
{
    return m.end<n.end;
}
int main()
{
    int n;
    int i,j;
    int s = 0;  ///用于标记上一次事件结束的事件

    ///输入n个事件
    scanf("%d",&n);
    for( i = 0; i < n; i++ )
    {
        scanf("%d%d",&a[i].start,&a[i].end);
        a[i].id = i + 1; ///每一个事件的id赋值
        a[i].select = 0; ///默认事件的选择状态为0(不选择)
    }

    sort(a,a+n,cmp);

    ///进行事件的选择
    for( i = 0; i < n; i++ )
    {
         ///如果当前事件的开始时间在上一个选择事件之后
         ///则选择当前事件
         if( a[i].start >= s )
         {
             a[i].select = 1; ///选择标记改为1(选中)
             s = a[i].end; ///记录当前事件的结束时间
         }
    }

    ///输出结果
    printf("%d",a[0].id);
    for( i = 1; i < n; i++ )
    {
        if( a[i].select == 1 )
        printf(",%d",a[i].id);
    }
    printf("\n");
    return 0;
}

2.钱币找零问题

这个问题在日常生活中很普遍。假设1元、2元、5元、10元、20元、50元、100元的纸币分别有c0, c1, c2, c3, c4, c5, c6张。现在要用这些钱来支付K元,至少要用多少张纸币?

用贪心算法的思想,很显然,每一步尽可能用面值大的纸币即可。在日常生活中我们自然而然也是这么做的。

#include
#include
using namespace std;
const int N=7; 
int Count[N]={6,6,6,6,6,6};//每一张纸币的数量 
int Value[N]={1,2,5,10,20,50,100};//每一张的面额  
int solve(int money) 
{
	int num=0;
	for(int i=N-1;i>=0;i--) 
	{
		int c=min(money/Value[i],Count[i]);//每一个所需要的张数 
		money=money-c*Value[i];
		num+=c;//总张数 
	}
	if(money>0) num=-1;
	return num;
}
int main() 
{
	int money;
	cin>>money;
	int res=solve(money);
	if(res!=-1) cout<<res<<endl;
	else cout<<"NO"<<endl;
}

3. 再论背包问题

  • 在学动态规划时已经谈过三种最基本的背包问题:01背包,完全背包,多重背包。很容易证明,背包问题不能使用贪心算法。
  • 然而考虑这样一种背包问题:在选择物品i装入背包时,可以选择物品的一部分,而不一定要全部装入背包。这时便可以使用贪心算法求解了。
  • 计算每种物品的单位重量价值作为贪心选择的依据指标,选择单位重量价值最高的物品,将尽可能多的该物品装入背包,依此策略一直地进行下去,直到背包装满为止。
  • 在01背包问题中贪心选择之所以不能得到最优解原因是贪心选择无法保证最终能将背包装满,部分闲置的背包空间使每公斤背包空间的价值降低了。
#include   
using namespace std;   
const int N=4;  
void knapsack(float M,float v[],float w[],float x[]);  
  
int main()  
{  
    float M=50;
	//背包所能容纳的重量   
    float w[]={0,10,30,20,5};
	//每种物品的重量  
    float v[]={0,200,400,100,10};  
  	//每种物品的价值 
    float x[N+1]={0};  
    //记录结果的数组 
    knapsack(M,v,w,x);  
    cout<<"选择装下的物品比例:"<<endl;  
    for(int i=1;i<=N;i++) cout<<"["<<i<<"]:"<<x[i]<<endl;  
}  
  
void knapsack(float M,float v[],float w[],float x[])  
{  
    int i;  
    //物品整件被装下  
    for(i=1;i<=N;i++)
    {  
        if(w[i]>M) break;   
        x[i]=1;  
        M-=w[i];  
    }   
    //物品部分被装下  
    if(i<=N) x[i]=M/w[i];   
} 

4.多机调度问题

  • n个作业组成的作业集,可由m台相同机器加工处理。要求给出一种作业调度方案,使所给的n个作业在尽可能短的时间内由m台机器加工处理完成。作业不能拆分成更小的子作业;每个作业均可在任何一台机器上加工处理。
  • 可以用贪心选择策略设计出较好的近似算法:
    • 当n<=m时,只要将作业时间区间分配给作业即可;
    • 当n>m时,首先将n个作业从大到小排序,然后依此顺序将作业分配给空闲的处理机。也就是说从剩下的作业中,选择需要处理时间最长的,然后依次选择处理时间次长的,直到所有的作业全部处理完毕,或者机器不能再处理其他作业为止。
    • 为什么优先选择时间最长的呢,我画一幅图来表示。
优先选择短的

优先选择长的

  • 哪个花费的时间短一目了然(图不准确,大概表示一下)

  • 如果我们每次是将需要处理时间最短的作业分配给空闲的机器,那么可能就会出现其它所有作业都处理完了,只剩所需时间最长的作业在处理的情况,这样势必效率较低。

在下面示范代码中没有讨论n和m的大小关系,把这两种情况合二为一进行。

#include  
#include    
using namespace std;  
int speed[10010];  
int mintime[110];  
 
bool cmp( const int &x,const int &y)  
{  
    return x>y;  
}  
 
int main()  
{  
	int n,m;         
	memset(speed,0,sizeof(speed));  
 	memset(mintime,0,sizeof(mintime));  
  	cin>>n>>m;  
   	for(int i=0;i<n;++i) cin>>speed[i];  
    sort(speed,speed+n,cmp);  
    for(int i=0;i<n;++i)   
    { 
        //这一部非常巧妙,mintime存储着机器完成作业的剩余时间
        //其中值最小的,就会是最快完成接下来作业的机器
        //那么就再给他分配下一个任务
    	*min_element(mintime,mintime+m)+=speed[i];   
   	}   
    cout<<*max_element(mintime,mintime+m)<<endl; 
}

5. 小船过河问题( POJ1700 )

  • 题意:只有一艘船,能乘2人,船的运行速度为2人中较慢一人的速度,过去后还需一个人把船划回来,问把n个人运到对岸,最少需要多久。
  • 这题在贪心中算是一道非常有难度的题目,因为他有两种最好的贪心结构。
  • 要思考这道题,我们需要以运过去两个人为单位;
  • 什么是运过去两个人,就是运过去一个,一个人划回来,再运过去一个,再划回来,才能称为运过去两个人。
  • 先将所有人过河所需的时间按照升序排序,考虑把单独过河所需要时间最多的两个旅行者送到对岸去,有两种方式:
    1. 最快的和次快的过河,然后最快的将船划回来;次慢的和最慢的过河,然后次快的将船划回来,所需时间为:t[0]+2*t[1]+t[n-1];
    2. 最快的和最慢的过河,然后最快的将船划回来,最快的和次慢的过河,然后最快的将船划回来,所需时间为:2*t[0]+t[n-2]+t[n-1]。
  • 算一下就知道,除此之外的其它情况用的时间一定更多。每次都运送耗时最长的两人而不影响其它人,问题具有贪心子结构的性质。
#include
#include
using namespace std;
 
int main()
{
	int a[1000],t,n,sum;
	scanf("%d",&t);
	while(t--)
	{
		scanf("%d",&n);
		sum=0;
        for(int i=0;i<n;i++) scanf("%d",&a[i]);
        while(n>3)
		{
			sum=min(sum+a[1]+a[0]+a[n-1]+a[1],sum+a[n-1]+a[0]+a[n-2]+a[0]);
            n-=2;
        }
        if(n==3) sum+=a[0]+a[1]+a[2];
        else if(n==2) sum+=a[1];
        else sum+=a[0];
        printf("%d\n",sum);
	}
}

6.区间覆盖问题 POJ1328

  • 是一道经典的贪心算法例题。题目大意是假设海岸线是一条无限延伸的直线。陆地在海岸线的一侧,而海洋在另一侧。每一个小的岛屿是海洋上的一个点。雷达坐落于海岸线上,只能覆盖d距离,所以如果小岛能够被覆盖到的话,它们之间的距离最多为d。
  • 题目要求计算出能够覆盖给出的所有岛屿的最少雷达数目。对于每个小岛,我们可以计算出一个雷达所在位置的区间。
  • 问题转化为如何用尽可能少的点覆盖这些区间。
  • 先将所有区间按照左端点大小排序,初始时需要一个点。
    • 如果两个区间相交而不重合,我们什么都不需要做;
    • 如果一个区间完全包含于另外一个区间,我们需要更新区间的右端点;
    • 如果两个区间不相交,我们需要增加点并更新右端点。
  • 我在做这道题的时候栽了大跟头,在合并区间到他们重叠部分的时候,没有多种情况考虑。
#include
#include
#include
#include
using namespace std;
struct Interval{
    double lf,rg;
}interval[1004];
bool cmp(Interval &a,Interval &b)
{
    return a.lf<b.lf;
}
//思路:把海岛的位置映射到线段上,然后再合并重叠区间,找到剩下多少个区间
int main()
{
    int n,d,T = 0,flag;
    //freopen("poj 1382.txt","r",stdin);
    while(scanf("%d%d",&n,&d),n+d)
    {
        double x,y;flag = 0;
        for(int i = 0;i < n;++i)
        {
            scanf("%lf%lf",&x,&y);
            if(y>d) flag = 1;
            interval[i].lf = x-sqrt(d*d-y*y);
            interval[i].rg = x+sqrt(d*d-y*y);
        }
        if(flag) {printf("Case %d: %d\n",++T,-1);continue;}
        sort(interval,interval+n,cmp);
        int ans = 0;
        for(int i = 0;i < n-1;++i)
        {
            if(interval[i+1].lf<=interval[i].rg)//出现了重叠
            {
                interval[i+1].rg = min(interval[i+1].rg,interval[i].rg);
            }else
            {
                ++ans;
            }
        }
        printf("Case %d: %d\n",++T,ans+1);
    }
    return 0;
}

7.销售比赛(买卖贪心)

  • 假设有偶数天,要求每天必须买一件物品或者卖一件物品,只能选择一种操作并且不能不选,开始手上没有这种物品。现在给你每天的物品价格表,要求计算最大收益。
  • 首先要明白,第一天必须买,最后一天必须卖,并且最后手上没有物品。那么除了第一天和最后一天之外,每次取两天,小的买大的卖,并且把卖的价格放进一个最小堆。如果买的价格比堆顶还大,就交换。这样就保证了卖的价格总是大于买的价格,一定能取得最大收益。
#include
#include
#include
#include
#include
#include
#include
using namespace std;
long long int price[100010],t,n,res;
       
int main()
{
    ios::sync_with_stdio(false);
    cin>>t;
    while(t--)
    {
        cin>>n;
        priority_queue<long long int, vector<long long int>, greater<long long int> > q;
        res=0;
        for(int i=1;i<=n;i++)
        {
            cin>>price[i];
        }
        res-=price[1];
        res+=price[n];
        for(int i=2;i<=n-1;i=i+2)
        {
            long long int buy=min(price[i],price[i+1]);
            long long int sell=max(price[i],price[i+1]);
            if(!q.empty())
            {
                if(buy>q.top())
                {
                    res=res-2*q.top()+buy+sell;
                    q.pop();
                    q.push(buy);
                    q.push(sell);
                }
                else
                {
                    res=res-buy+sell;
                    q.push(sell);
                }
            }
            else
            {
                res=res-buy+sell;
                q.push(sell);
            }
        }     
        cout<<res<<endl;
    }
}

8. LeetCode 630 活动统筹进阶版

  • 题意: 给出课程的持续时间最晚结束时间,要求你尽可能多的上课。
  • 输入: [[100, 200], [200, 1300], [1000, 1250], [2000, 3200]]
  • 输出:3
  • 解释:共有4门课程,但最多可修3门:
    • 首先,上第一门课,要花100天,所以你将在第100天完成它,并准备在第101天上下一门课。
    • 第二,上第三门课,要花1000天的时间,所以你要在1100天完成,然后准备在1101天上下一门课。
    • 第三,上第二道课,要花200天,所以你要在1300天完成。
    • 第四节课现在不能上了,因为你将在第3300天完成,这超过了最晚结束日期。
  • **思路:**因为要尽量上多的课,首先想到肯定是短的优先,但是所谓的”短“该怎么判定呢?
  • 如果结束时间太早,再怎么短也无济于事。那不如按照结束时间排序,放得进去的就尽量放。
  • 但是如果放不下怎么办,那我们就把之前上过最长时间的课拎出来,然后再把现在的放进去。
  • 这个”之前上过最长时间的课“用一个优先堆维护。总体看来还是蛮方便的。
class Solution {
public:
    int scheduleCourse(vector<vector<int>>& courses) {
        sort(courses.begin(),courses.end(),[](vector<int>& src,vector<int>& des)
             {
                 return src[1]<des[1];
             });
        int now = 0;
        priority_queue<int> heap;
        for(int i=0;i<courses.size();++i)
        {
            heap.push(courses[i][0]);
            now+=courses[i][0];
            
            if(now>courses[i][1])
            {
                now -= heap.top();
                heap.pop();
            }
        }
        return heap.size();
    }
};

总结

贪心的算法主要可以概括为以下的这些步骤:
  1. 按照一定规则排序。
  2. 模拟题目要求的行为,其中主要包含,模拟的行为可能有:区间操作,坐标轴操作,放置操作等。
  3. 和动态规划一样,贪心同样要求每个行为的是最优化的子结构。

你可能感兴趣的:(算法)