第七讲. 经典算法之贪心选择

1. 简介

贪心算法,顾名思义,就是通过“贪婪”的策略,尽可能地达到最大的收益,或者说将一个问题分成很多次的贪心策略选择,每次的策略都需要保证能达到最好的收益,即局部最优,然后最终获得最大的收益,即全局最优。

2. 从一个简单例题开始

例:
问题描述:
现在有很多物品(它们是可以分割的),我们知道它们每个物品的单位重量的价值v和重量w(1<=v,w<=10);如果给你一个背包它能容纳的重量为m(10<=m<=20),你所要做的就是把物品装到背包里,使背包里的物品的价值总和最大。

输入格式:
第一行输入一个正整数n(1<=n<=5),表示有n组测试数据;
随后有n测试数据,每组测试数据的第一行有两个正整数s,m(1<=s<=10);s表示有s个物品。接下来的s行每行有两个正整数v,w。

输出格式:
输出每组测试数据中背包内的物品的价值和,每次输出占一行。
输入样例:

1
3 15
5 10
2 8
3 9

输出样例:

65

这是一道十分简单的背包问题,一看到题,其实都能很容易想到,只要每次都拿性价比(单位重量的价值)最高的物品就行了,这也就是这一题的贪心选择策略。所以思路是,首选对所有物品按照性价比排个序,在背包没满的情况下每次拿当前性价比最高的物品,直到背包装满即可,代码如下:

#include <iostream>
#include <algorithm>
#include <stdio.h>
using namespace std;

struct Goods
{
    int v,w;
} x[15];
int com(Goods x1, Goods x2) //定义结构体比较函数
{
    return x1.v>x2.v;
}
int main()
{
    int n,s,w,tmp;
    scanf("%d",&n);
    while(n--)
    {
        scanf("%d%d",&s,&w);
        for(int i=0; i<s; i++)scanf("%d%d",&x[i].v,&x[i].w);
        sort(x,x+s,com);//利用自定义的比较函数对结构体排序,即按.v降序排序
        tmp = 0.0;
        for(int i=0; i<s; i++)
        {
            if(w>x[i].w)//背包还有剩余空间,能完全装下物品i
            {
                tmp += x[i].w*x[i].v;
                w -= x[i].w;
            }
            else//背包不能或刚好能完全装下物品i
            {
                tmp += w*x[i].v;//切分物品i
                w -= w;
                break;
            }
        }
        printf("%d\n",tmp);
    }
    return 0;
}

这个地方其实就是每次做选择拿哪个物品的时候都是拿当前性价比最高的物品,以使得该次决策得到的收益最大,即局部最优解。最终当背包装满的时候,由于物品是可分的,所以由所有的局部最优解合成的也是全局最优解,这也就是所谓的贪心算法,应该是很好理解的。
注意:某些情况下,每次选择局部最优解最终得到的并不是全局最优解,这个时候贪心算法就没有用了,需要使用动态规划(有兴趣的可以了解了解经典的0-1背包问题)。

3. 一个稍难的题目

例:
问题描述:
今天Ckp打算去约会。大家都知道Ckp是超级大帅哥,所以和他约会的MM也超级多,她们每个人都和Ckp订了一个约会时间。但是今天Ckp刚打算出门的时候才发现,某几个MM的约会时间有冲突。由于Ckp不会分身,还不能和多个MM同时约会,他只能忍痛割爱拒绝掉某些MM。但是Ckp这个花心大萝卜还是不死心,他想知道,他最多可以和多少个MM约会。

输入格式:
输入的第一行包含一个正整数N(0<N<=1000),表示和Ckp约会的MM数。
接下去N行,每行描述一个MM,格式为: Name starttime endtime,表示在[starttime,endtime)这个半开区间是这个MM的约会时间,starttime < endtime。名字由大写或小写字母组成,最长不超过15个字母,保证没有两个人拥有相同的名字,所有时间采用24小时制,格式为XX:XX,且在06:00到23:00之间。

输出格式:
输出的第一行是一个整数M表示Ckp最多可以和多少个MM约会。
接下来那一行就是M个MM的名字,用空格隔开。您可以按照任意的顺序输出。如果存在多个答案,您可以任选一个输出。

输入样例:

4
Lucy 06:00 10:00
Lily 10:00 17:00
HanMeimei 16:00 21:00
Kate 11:00 13:00

输出样例:

3
Lucy Kate HanMeimei

这个题目的贪心策略比起前一题不怎么直观,一般人可能很容易想到完成尽可能不让时间空闲下来,一直和妹子约会就可以达到最优的情况,其实不然。这类题目需要的是数量的最大化,而非空闲时间的最小化,所以若是按每个妹子的约会起始时间来升序排序,这样是不对的,因为有的妹子约个会要花你好多好多时间,而你完全可以用这好多好多时间和多个妹子约会○( ^皿^)っHiahia…。所以应该按每个妹子约会的终止时间升序排序,这样才能做到约会的妹子数量最多。想到了这一点其实这个题就很简单了,若是没想到就GG了。

#include <iostream>
#include <algorithm>
#include <stdio.h>
using namespace std;
int N,id[1005];
class Time
{
public:
    int HH,MM;
    Time():HH(0),MM(0) {}//无参构造函数
    Time(int hh, int mm)//有参构造函数
    {
        HH = hh,MM = mm;
    }
    bool operator<=(Time& t2)//重载<=运算符
    {
        return (this->HH*60+this->MM) <= (t2.HH*60+t2.MM);
    }
    bool operator==(Time& t2)//重载==运算符
    {
        return (this->HH*60+this->MM) == (t2.HH*60+t2.MM);
    }
    bool operator>=(Time& t2)//重载>=运算符
    {
        return (this->HH*60+this->MM) > (t2.HH*60+t2.MM);
    }
};
class MM
{
public:
    char name[20];
    Time st,et;
    bool operator<(MM& x2)//重载<运算符
    {
        if(this->et==x2.et)return this->st<=x2.st;
        return this->et<=x2.et;
    }
} mm[1005];
int main()
{
    int cnt=0;
    Time now(0,0);//记录上一个妹子的约会结束时间
    scanf("%d",&N);
    for(int i=0; i<N; i++)scanf("%s%d:%d%d:%d",mm[i].name,&mm[i].st.HH,&mm[i].st.MM,&mm[i].et.HH,&mm[i].et.MM);
    sort(mm,mm+N);//排序,由于已经重载了类MM的运算符,所以不需要再定义新的比较函数
    for(int i=0; i<N; i++)
    {
        if(mm[i].st>=now)//第i个妹子的约会开始时间比上一个妹子约会结束时间晚,约!
        {
            id[cnt++] = i;
            now = mm[i].et;
        }
    }
    printf("%d\n",cnt);
    for(int i=0; i<cnt; i++)
    {
        if(i>0)printf(" ");
        printf("%s",mm[id[i]].name);
    }
    printf("\n");
    return 0;
}

上述代码有许多C++的成分,主要用到了运算符的重载,当然完全可以不用这些而全部通过C的函数声明来实现,有兴趣的自行了解C++的运算符重载,说实话还是挺好用的,能大大提高代码的可读性以及灵活性。

4. 最重要贪心算法(可作模板)

下面是几种在比赛中出现最多也是最经典的几种图论的算法,都是基于贪心思想的,这里就不详细讲解贪心的过程了,想不太明白的请自行去百度其它人的细致讲解。

4.1 最小生成树

最小生成树即给你一个图,让你求出一个包含所有点的连通子图,且该子图的边权和最小。

问题描述:
现有村落间道路的统计数据表中,列出了有可能建设成标准公路的若干条道路的成本,求使每个村落都有公路连通所需要的最低成本。

输入格式:
输入数据包括城镇数目正整数N(≤1000)和候选道路数目M(≤3N);随后的M行对应M条道路,每行给出3个正整数,分别是该条道路直接连通的两个城镇的编号以及该道路改建的预算成本。为简单起见,城镇从1到N编号。

输出格式:
输出村村通需要的最低成本。如果输入数据不足以保证畅通,则输出−1,表示需要建设更多公路。

输入样例:

6 15
1 2 5
1 3 3
1 4 7
1 5 4
1 6 2
2 3 4
2 4 6
2 5 2
2 6 6
3 4 6
3 5 1
3 6 1
4 5 10
4 6 8
5 6 3

输出样例:

12

典型的最小生成树题目,最小生成树即使得整个图联通需要的最小代价。最小生成树一般而言有两种贪心策略,一种是 prim 算法,一种是 kruskal 算法,下面简单说一下两种算法的思路。

prim(普利姆)算法:
为了描述方便,定义两个点集 AB ,A表示已连通的点的集合,B表示未连通的所有点的集合,初始情况下, A 为空, B 包含图上所有的点,我们的目标就是通过增加边,将 B 中的点都加到 A 里面去,同时使得增加边的代价最小。

初始条件:A = ∅, B = {所有点}, cost = 0 。(其中 cost 表示当前总代价)
① 由点出发,将任意一个点 a 加入 A ,即 A = A + {a}, B = B - {a} ,跳至②;
② 遍历从 A 中点出发的边,找到最小的边 e 同时保证该边的另一个端点 b 在集合 B 中,即 b∈B ,将该点加入集合 A 中,同时更新 cost ,即 A = A + {b}, B = B - {b}, cost = cost + coste (其中 coste 表示添加边 e 的代价),跳至③;若找不到最小的边,或者说已不存在边了,则令 cost = -1 ,并跳至⑤。
③ 判断 B 是否为空。若B为空,则跳至④;若 B 不为空,则跳至②。
cost 即为所求的最小代价,结束。
⑤ 当前图不连通,故不存在最小生成树,结束。

代码实现如下:

#include <stdio.h>
#include <string.h>
int dist[1005][1005];//dist[i][j]表示点i和点j之间的代价边权
int D[1005];//D[i]表示集合A中的点到点i的最小边权
bool p[1005]= {false}; //标记点i是否在集合A中
int main()
{
    int N,M;
    int a,b,c;
    memset(dist,-1,sizeof(dist));//-1表示两个点之间不可达或边权为无穷,初始将所有的边初始化为不可达
    memset(D,-1,sizeof(D));//同上
    scanf("%d%d",&N,&M);
    p[1] = true;//先将点1加入集合A
    for(int i=0; i<M; i++)
    {
        scanf("%d%d%d",&a,&b,&c);
        if(dist[a][b]>c||dist[a][b]==-1)
            dist[a][b] = dist[b][a] = c;
        if(a==1&&(D[b]>c||D[b]==-1))
            D[b] = c;
        if(b==1&&(D[a]>c||D[a]==-1))
            D[a] = c;
    }
    int k,mi,cost=0;
    for(int j=1; j<N; j++)
    {
        k = mi = -1;
        for(int i=1; i<=N; i++)//遍历从集合A中出发的点的边,寻找最小的边
        {
            if(!p[i]&&D[i]>0&&(mi==-1||D[i]<mi))
            {
                mi = D[i];
                k = i;
            }
        }
        if(k==-1)//找不到最小的边,说明图不连通
        {
            cost = -1;
            break;
        }
        p[k] = true;//将点k加入集合A
        cost += mi;//更新cost
        for(int i=1; i<=N; i++)//集合A中多了一个点k,利用从k中出发的边更新数组D
        {
            if(!p[i]&&dist[k][i]>0&&(D[i]==-1||D[i]>dist[k][i]))
                D[i] = dist[k][i];
        }
    }
    printf("%d\n",cost);
}

kruskal(克鲁斯卡尔)算法:
为了描述方便,定义两个边集 UVU 表示已经加入生成树的边的集合, V 表示未加入生成树的边的集合,初始情况下, U 为空, V 包含图上所有的边,我们的目标是通过增加边,将 V 中的 n-1 条不会形成环路的边都加入到 U 里面去,同时使得增加边的代价最小,或者说这 n-1 条边权之和最小。( n 表示图中点的总数,不难证明,要连通一个含有 n 个点的图,至少需要 n-1 条边)

初始条件:U=∅, V={所有边}, cost=0 。(其中 cost 表示当前总代价)
① 由边出发,先将所有的边按权重(代价)升序排序,即对 V 按边权升序排序,且令每个点都是一个独立的连通图,跳至②。
② 在 V 中取出当前最小的边 e ,判断该边的两个端点 a,b 是否是不同的连通图。若是,则合并两个连通图,同时更新 costcnt ,即 cost=cost + coste, U=U + {e}, V=V - {e} ,并跳至③;若不是,则 V=V - {e} ,并跳至②。若 V 中已经没有边了,即 V=∅ ,则跳至⑤。
③ 判断 U 中的边的个数是否等于 n-1 ,若 |U| == n - 1,则跳至④;若 |U| < n - 1 ,则跳至②。
cost 即为所求的最小代价,结束。
⑤ 当前图不连通,故不存在最小生成树,结束。

代码实现如下:

#include <stdio.h>
#include <string.h>
#include <algorithm>
struct Edge
{
    int s,e,w;//从点s到点e的边权为w
} E[3005];
int f[1005];//f[i]表示点i的祖先
int com(Edge x1, Edge x2)
{
    return x1.w<x2.w;
}
int root(int x)//寻找点x的连通图编号,即寻找其祖先节点
{
    if(x==f[x])
        return x;
    return f[x] = root(f[x]);//路径压缩
}
void link(int a, int b)//连通点a和点b,即合并点a和点b所在的连通集
{
    int fa = root(a);
    int fb = root(b);
    f[fb] = fa;
}
int main()
{
    int N,M;
    scanf("%d%d",&N,&M);
    for(int i=0; i<M; i++)
        scanf("%d%d%d",&E[i].s,&E[i].e,&E[i].w);
    std::sort(E,E+M,com);//对所有的边按边权升序排序
    for(int i=1; i<=N; i++)
        f[i]=i;//初始化所有点的连通图编号,即令所有点都不连通
    int cost=0, cnt=0;//cost记录集合U中的边数
    for(int i=0; i<M&&cnt<N; i++)
    {
        if(root(E[i].s)!=root(E[i].e))//判断端点是否属于不同的连通图
        {
            link(E[i].s,E[i].e);//合并两个连通图
            cost += E[i].w;
            cnt++;//集合U中的边数加1
        }
    }
    if(cnt==N-1)
        printf("%d\n",cost);
    else
        printf("-1\n");
}

最后再来比较比较两种最小生成树的算法,不难看出,prim算法是从点出发,而kruskal算法是从边出发,这也意味着前者更擅长处理点少边多的问题,后者更删除处理点多边少的问题。

4.2 最短路

最短路,即给你一张图,让你求出点与点之间的最短距离。
最短路问题一般可分为两类情形:一类是求某一个点到其它所有点的最短路;另一类是求所有点到所有点的最短路。可以把第一类情形看作是第二类情形的子情况,因为只要分别对每一个点看作第一类情形处理,那么其实解决的也就是第二类情况。也因此,两类情形的算法时间复杂度肯定是不一样的,一般而言后者的时间复杂度比前者多乘一个 n ( n 表示图中节点的总数量)
第一类情况的解决思路一般有两种,Dijkstra(迪杰斯特拉)算法和SPFA算法;第二类情况的解决思路一般就是Floyd(弗洛伊德)算法了。其中本讲只介绍Dijkstra;SPFA是一种稍高级的算法,有兴趣的自行了解;Floyd是一种动态规划思想的算法,将在后一讲介绍。

题目链接
问题描述:
作为一个城市的应急救援队伍的负责人,你有一张特殊的全国地图。在地图上显示有多个分散的城市和一些连接城市的快速道路。每个城市的救援队数量和每一条连接两个城市的快速道路长度都标在地图上。当其他城市有紧急求助电话给你的时候,你的任务是带领你的救援队尽快赶往事发地,同时,一路上召集尽可能多的救援队。

输入格式:
输入第一行给出4个正整数N、M、S、D,其中N是城市的个数,顺便假设城市的编号为0 ~ N-1;M是快速道路的条数;S是出发地的城市编号;D是目的地的城市编号。

第二行给出N个正整数,其中第i个数是第i个城市的救援队的数目,数字间以空格分隔。随后的M行中,每行给出一条快速道路的信息,分别是:城市1、城市2、快速道路的长度,中间用空格分开,数字均为整数且不超过500。输入保证救援可行且最优解唯一。

输出格式:
第一行输出最短路径的条数和能够召集的最多的救援队数量。第二行输出从S到D的路径中经过的城市编号。数字间以空格分隔,输出结尾不能有多余空格。

输入样例:

4 5 0 3
20 30 40 10
0 1 1
1 3 2
0 3 3
0 2 2
2 3 2

输出样例:

2 60
0 1 3

先看看Dijkstra算法的思路,与prim算法十分类似:

为了描述方便,定义两个点集 AB ,A表示已经求出最短路的点的集合,B表示未求出最短路的所有点的集合,初始情况下, A 为空, B 包含图上所有的点,我们的目标就是通过增加边,将 B 中的点都加到 A 里面去,同时使得从起点到其余点的路径最短。

初始条件:A = ∅, B = {所有点}
① 由点出发,将起点 s 加入 A ,即 A = A + {s}, B = B - {s} ,跳至②;
② 遍历从起点 s 经由集合 A 中某些点能够到达的所有未求出最短路的点,找到最近的一个点 bb∈B ,路径长度为 wsb ,将该点加入集合 A 中, 即 A = A + {b}, B = B - {b} ,不难证明, wsb 即为由点 s 出发到达点 b 的最短路径,跳至③;若找不到这样的点,则说明图不连通,即由点 s 不能到达其它所有点,跳至⑤。
③ 判断 B 是否为空。若B为空,则跳至④;若 B 不为空,则跳至②。
④ 结束。
⑤ 当前图不连通,存在不可达的点,结束。

代码如下:

#include <stdio.h>
#include <string.h>
int dist[505][505];
int D[505],v[505];
bool p[505]= {false};
int main()
{
    int N,M,s,e;
    int a,b,c;
    memset(dist,-1,sizeof(dist));//初始化为无穷
    memset(D,-1,sizeof(D));//同上
    scanf("%d%d%d%d",&N,&M,&s,&e);
    p[s] = true;//将起点S加入集合A
    for(int i=0; i<N; i++)
        scanf("%d",&v[i]);
    for(int i=0; i<M; i++)
    {
        scanf("%d%d%d",&a,&b,&c);
        if(dist[a][b]>c||dist[a][b]==-1)
            dist[a][b] = dist[b][a] = c;
        if(a==s&&(D[b]>c||D[b]==-1))
            D[b] = c;
        if(b==s&&(D[a]>c||D[a]==-1))
            D[a] = c;
    }
    int k,mi,ma;
    for(int j=1; j<N; j++)
    {
        k = mi = ma = -1;
        for(int i=0; i<N; i++)//找出从s出发能够到达的最短路
        {
            if(!p[i]&&D[i]>0&&(mi==-1||D[i]<mi))
            {
                mi = D[i];
                k = i;
            }
        }
        if(k==-1||k==e)
        {
            D[e]=mi;
            break;
        }
        p[k] = true;
        for(int i=0; i<N; i++)//判断是否能以k作为中间点,以更短的距离到达其它点
        {
            if(!p[i]&&dist[k][i]>0&&(D[i]==-1||D[i]>D[k]+dist[k][i]))
                D[i] = D[k] + dist[k][i];
        }
    }
    printf("%d\n",D[e]);
}

以上代码即是基本的Dijkstra算法,但这一题比较灵活,除了要求出最短路外,还需要记录最短路的条数以及最短路经过的节点,并且当存在多条最短路时,选取救援队数量最多的那一条,通过数组 pre[i] 记录由起点 s 到达点 i 的前驱节点既可,即由 s 到达点 i ,必须先经过点 pre[i] ;数组 cnt[i] 记录由起点 s 至点 i 的最短路径条数;数组 v[i] 表示点 i 的救援队数量。加上以上的细节处理后,代码变为如下形式:

#include <stdio.h>
#include <string.h>
#include <iostream>
int dist[505][505];
int D[505],pre[505],cnt[505]= {0},v[505],v_sum[505];
bool p[505]= {false};
void printPath(int i);
int main()
{
    int N,M,s,e;
    int a,b,c;
    memset(dist,-1,sizeof(dist));//初始化为无穷
    memset(D,-1,sizeof(D));//同上
    memset(pre,-1,sizeof(pre));//同上
    memset(v,-1,sizeof(v));//同上
    scanf("%d%d%d%d",&N,&M,&s,&e);
    p[s] = true;//将起点S加入集合A
    for(int i=0; i<N; i++)
        scanf("%d",&v[i]);
    cnt[s] = 1;
    v_sum[s] = v[s];
    for(int i=0; i<M; i++)
    {
        scanf("%d%d%d",&a,&b,&c);
        if(dist[a][b]>c||dist[a][b]==-1)
            dist[a][b] = dist[b][a] = c;
        if(b==s)
            std::swap(a,b);
        if(a==s&&(D[b]>=c||D[b]==-1))
        {
            if(D[b]==c)
            {
                if(v[s]+v[b]>v_sum[b])
                {
                    v_sum[b] = v[s]+v[b];//记录到达b点的救援队总人数
                    pre[b] = a;//记录前驱节点
                }
                cnt[b] += cnt[a];//可以从另外一条路到b
            }
            else
            {
                D[b] = c;//更新到达点b的最短路
                v_sum[b] = v[s]+v[b];//记录到达b点的救援队总人数
                pre[b] = a;//记录前驱节点
                cnt[b] = 1;//存在更短的路到b,重新赋为1
            }
        }
    }
    int k,mi;
    for(int j=1; j<N; j++)
    {
        k = mi = -1;
        for(int i=0; i<N; i++)
        {
            if(!p[i]&&D[i]>0&&(mi==-1||D[i]<mi))
            {
                k = i;
                mi = D[i];
            }
        }
        if(k==-1)
        {
            D[e] = -1;
            break;
        }
        p[k] = true;
        for(int i=0; i<N; i++)
        {
            if(!p[i]&&dist[k][i]>0&&(D[i]==-1||D[i]>=D[k]+dist[k][i]))
            {
                if(D[i]==D[k]+dist[k][i])
                {
                    if(v_sum[k]+v[i]>v_sum[i])
                    {
                        v_sum[i] = v_sum[k]+v[i];//记录到达点i的总救援队人数
                        pre[i] = k;//记录i的前驱节点为点k
                    }
                    cnt[i] += cnt[k];//能通过k到达i,故更新到达i的总路径条数
                }
                else
                {
                    D[i] = D[k] + dist[k][i];//更新由起点到达点i的最短路
                    v_sum[i] = v_sum[k]+v[i];//更新由起点到达点i的总救援队人数
                    pre[i] = k;//记录i的前驱节点为点k
                    cnt[i] = cnt[k];//经过k出现到达i的更短路,故重置cnt[i]=cnt[k]
                }
            }
        }
    }
    printf("%d %d\n",cnt[e],v_sum[e]);
    printPath(e);
    printf("\n");
}
void printPath(int i)
{
    if(i==-1)return;
    printPath(pre[i]);
    if(pre[i]!=-1)printf(" ");
    printf("%d",i);
}

这已经算一道较复杂的最短路题目了,其实只要理清了思路,还是不难的,只是稍显复杂。

5. 最后说几句

综上,其实贪心算法这个东西说简单也简单,说难也难,它其实就是通过一种很巧妙的方式来解决问题,就像脑筋急转弯一样,关键是要想到这个点,没有太多套路模板可言,想到了就很简单了,想不到就麻烦了。当然,像最短路、最小生成树等这些是十分经典的贪心算法,在比赛或者机试中考得很多所以十分重要,而且这些算法一般都是被归类在图论的算法里的,但其实不难看出,就是贪心算法。此外还有很多十分经典的算法,其实都有贪心的成分在里面。总之,算法之间其实本就没有明显的一个分类的界限,只是侧重点不同罢了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值