算法入门——2 贪心算法

导引问题:硕鼠的交易

  • 任务:硕鼠准备了M 磅猫粮与看守仓库的猫交易奶酪。仓库有 N 个房间,每只猫看守一个房间。第 i 个房间有 J[i] 磅奶酪并需要 F[i] 磅猫粮交换,硕鼠可以交换得到这个房间的全部奶酪,也可以按比例来交换,不必交换所有的奶酪。计算硕鼠最多能得到多少磅奶酪。先输入 M 和 N 表示猫粮数量和房间数量,随后输入 N 个房间,每个房间包括奶酪数量和要交换的猫粮数量;

    • Sample Input:

      5 3 // 表示有5磅猫粮和3个房间
      7 2 // 第一个房间有7磅奶酪,要2磅猫粮才可以交换
      4 3 // 第一个房间有4磅奶酪,要3磅猫粮才可以交换
      5 2 // 第一个房间有5磅奶酪,要2磅猫粮才可以交换
      -1 -1
      
    • Sample Output:

      13.333
      
  • 解法:

    • 选择性价比最高的房间去做交易,用 可以交换的奶酪数 / 需要交换的猫粮数 即可计算出:单位猫粮可以得到的奶酪,
    • 显然性价比:第一个房间 > 第三个房间 > 第二个房间;
    • 计算过程:
      • 用 2 磅猫粮换得到第一个房间的所有 7 磅奶酪,还剩 3 磅猫粮,目前所得奶酪磅数:7 磅;
      • 用 2 磅猫粮换得到第三个房间的所有 5 磅奶酪,还剩 1 磅猫粮,目前所得奶酪磅数:12 磅;
      • 用 1 磅猫粮换得到第二个房间的所有 4 / 3 * 1 磅奶酪,还剩 0 磅猫粮,目前所得奶酪磅数:13.333 磅。

初识“贪心算法”

  • 在对问题求解时,总是作出在当前看来是最好的选择;
  • 也就是说,不从整体上加以考虑,所作出的仅仅是在某种意义上的局部最优解;
  • 但是否是全局最优,需要证明;
  • 所以若要用贪心算法求解某问题的整体最优解,必须首先证明贪心思想在该问题的应用结果就是最优解!

例1:田忌赛马

  • 田忌与齐威王赛马,谁输了谁给对方两百块钱;

    • 绿色圆圈中的数字是田忌的马的战力,红色圆圈中的数字是齐威王的马的战力;
    • 如果田忌按照左边的顺序选择马匹出战,是把把都输;
    • 如果田忌按照右边的顺序选择马匹出战,还可以赢200块钱;

    在这里插入图片描述

  • 注意:不可以按照以下的想法去解题

    • 用自己最差的和对方最好的比;
    • 用自己最好的和对方第二好的比;
    • 用自己第二好的和对方第三好的比;
    • ……
      • 但实际上:只要能比对方好,就没必要退而求其次;
  • 用贪心算法捋一遍赛马的思路:假设齐威王的马匹出战顺序保持不变,都是 95,87,74

    • 齐威王派出战力为 95 的马,田忌发现自己最好的马都打不过,就用最差的和齐威王的比;
      • 因为反正都是输,就用自己最差的马去输,进而可以保留更好的战力;
    • 齐威王派出战力为 87 的马,田忌发现自己最好的马可以打过,那就派出战力最好的马;
    • 齐威王派出战力为 74 的马,田忌发现自己最好的马可以打过,那就继续派出战力最好的马。

经典贪心:时间序列问题

  • 已知 N 个事件的发生时刻和结束时刻:

    • 一些在时间上没有重叠的事件,可以构成一个事件序列,如2号事件、8号事件和10号事件,这三个事件就构成了一个事件序列;

    • 事件序列中包含的事件数目,称为该事件序列的长度(前面讲的事件序列的长度就是 3)。请编程找出一个最长的事件序列

      在这里插入图片描述

    • 能不能每次选择时间最短的事件?

    • 假设只有三个事件:

      事件编号012
      发生时刻11012
      结束时刻131224
    • 显然,如果选择时间最短的事件——事件0,就不能选择事件1和事件2,但是事件1和事件2组合起来的事件序列才是最优解;

  • 那还有哪些贪心的思路?比如:至少存在一个最长事件序列,这个序列中一定包含最早结束的事件

    • 那么如何证明这个贪心思路是正确的呢?用反证法,即假设所有的最长事件序列都不包含最早结束的事件,下面就用反证法证明:
      • 现在任选一个所谓的最长事件序列{x1, x2,x3,x4,x5},这五个事件在时间上没有重叠;
      • 因为假设所有的最长事件序列都不包含最早结束的事件,那么 x1 就不是最早结束的事件,所以将其替换成最早结束的事件;
      • 可以发现,替换完后,该事件序列依旧是最长的,且事件之间在时间上不冲突,但事件序列中却已经有最早结束的事件,所以假设不成立;
    • 所以这个贪心思路就可以应用到解题中。每次选择一个和已经选择的事件在时间上不冲突的事件,且是最早结束的,流程如下:
      • 选择事件0;
      • 选择事假1;
      • 事件2冲突,事件3冲突,事件4冲突,选择事件5;
      • 事件6冲突,事件7冲突,选择事件8;
      • 事件9冲突,选择事件10;
      • 事件11冲突;
    • 最后得到的结果:{0, 1, 5, 8, 10}
      • 可以发现结果不唯一,因为最后一个事件选10或者11都可以。

例2:搬桌子

  • 一个公司在一个写字楼里租了一层楼,该楼层的平面图如下图。中间是走廊,左右各两百个房间,房间的编号一边是奇数,一边是偶数。现在这个公司的人要将桌子从一个房间搬到另一个房间,但是走廊很窄,同时只允许一张桌子搬运,每次搬运不管远近都需要十分钟的时间。现在给出几组数据,求出每组数据需要的最短搬运时间(其实也是求最少搬运次数);

在这里插入图片描述

  • Sample Input:

    3      // 3组数据
    4      // 要搬4张桌子
    10 20  // 从10号房搬到20号房
    30 40  // 从30号房搬到40号房
    50 60  // 从50号房搬到60号房
    70 80  // 从70号房搬到80号房
    2
    1 3
    2 200
    3
    10 100
    20 80
    30 50
    
  • Sample Output:

    • 忽略这种情况:10搬到100,当搬运的人经过20后,20就出发;
    • 这也是贪心策略提现:每次搬运桌子,不管其他搬运任务,先把当前搬运任务所经过的走廊段的占用次数记录下来。从贪心角度看,就是每一次只关注当前这个搬运任务对走廊的占用情况,不考虑整体的复杂调度,先把局部信息统计好;
    10
    20
    30
    
  • 解法:

    • 在一组数据中,列出每一次搬运的路线,路线重叠几次就要搬运几次;

    • 以下表为例:

      房间号1/23/45/67/8
      1->5,重叠次数1110
      3->7,重叠次数0221
  • 代码:

    #include <bits/stdc++.h>
    using namespace std;
    int main()
    { 
        int t, i, j, N, P[200];
        int s, d, temp, k, MAX;
        cin >> t; // 几组数据
        for(i = 0; i < t; i++)
        {
            for(j = 0; j < 200; j++) // 刚开始重叠此处赋初值为0
                P[j] = 0;
            cin >> N; // 要搬几张桌子
            for(j = 0; j < N; j++)
            {
                cin >> s >> d;
                s = (s - 1) / 2; // 让奇数房间和偶数房间可以共用一个索引
                d = (d - 1) / 2;
                if(s > d) // 如果是从大编号的房间搬到小编号的房间,调转一下“方向”
                {
                    temp = s;
                    s = d;
                    d = temp;
                }
                for(k = s; k <= d; k++) // 记录搬运过程中,在路线上的房间会被重叠几次
                    P[k]++;
            }
            MAX = -1;
            for(j = 0; j < 200; j++)
                if(P[j] > MAX)
                    MAX = P[j]; // 取出P数组中的最大值,就是最多搬运的次数
            cout << MAX * 10 << endl;
        }
        return 0;
    }
    

例3:删数字

  • 已知一个长度不超过240位的正整数n(其中不含有数字0),去掉其中任意s(s小于n的长度)个数字后,将剩下的数字按原来的左右次序组成一个新的正整数。给定n和s,请编程输出最小的新正整数;

    • Sample Input

      178543 4
      
    • Sample Output

      13
      
  • 解法:

    • 每次选一个最大的数删掉?反例:输入213 1,删掉3,得到结果是21。但是显然将2删掉,得到13是更好的结果;
    • 仔细思考一下可以发现,要使剩下的数字按原来的左右次序组成的一个新正整数最小,就要让左边的数越小越好,那么就可以将原来的数,从左到右依次两两比较,若左边的数大于右边的数,就删掉左边的数。以例题为例:
      • 原数:178543,第一次删,删掉8,得到17543;
      • 原数:17543,第二次删,删掉7,得到1543;
      • 原数:1543,第三次删,删掉5,得到143;
      • 原数:143,第四次删,删掉4,得到13;
  • 特殊情况:123 1

    • 左边没有比右边大的,怎么办?
    • 删最大(最右边那个)。

例4:青蛙的邻居

  • 一个城市中有若干个湖泊,每个湖泊都住着一只青蛙。只要两个湖泊之间有水路相连,就可以认为这两个湖泊中住的青蛙是邻居。现在给定一组数据,每组数据包含有几个湖泊,每个湖泊的青蛙有几个邻居,问能否绘制出这些湖泊的分布情况和水路连接情况;

    • 比如有三个湖泊:

      • 2 2 2,说明三个湖泊中的青蛙都互为邻居,可以绘制出一个三角形;
      • 2 2 1,就无法绘制出;
    • Sample Input

      3 // 3组数据
      7 // 有7个湖泊
      4 3 1 5 4 2 1 // 住在第1个湖泊的青蛙有4个邻居,住在第2个湖泊的青蛙有3个邻居……
      6
      4 3 1 4 2 0
      6
      2 3 1 1 2 1
      
    • Sample Output

      YES
      NO
      YES
      
  • 解法:

    • 用度数(一个顶点在图中的度为与这个顶点相连接的边的数目)的总和是否为奇偶来判断?
      • 偶数可绘制例子:2 2 2;
      • 偶数不可绘制(包含0)例子:2 2 0;
      • 偶数可绘制(包含0)例子:1 1 0;
    • 其实这道题的接法是离散数学的一个知识点——可图性判定
  • 先讲解两个概念:

    • 度序列:若把图 G 所有顶点的度数排成一个序列 S,则称 S 为图 G 的度序列;
    • 序列是可图的:一个非负整数组成的有限序列如果是某个无向图的度序列,则称该序列是可图的;
  • 可图性判定的算法有很多,此处讲解一个比较经典的——Havel-Hakimi定理

    • 非负整数组成的非增序列 S S S d 1 , d 2 , . . . , d n ( n ≥ 2 , d 1 ≥ 1 ) d_1, d_2,..., d_n (n \geq 2, d_1 \geq 1) d1,d2,...,dn(n2,d11) 是可图的,当且仅当序列 S 1 S_1 S1 d 2 − 1 , d 3 − 1 , . . . , d d 1 + 1 − 1 , d d 1 + 2 , . . . , d n d_2 - 1, d_3 - 1,..., d_{d1 + 1} - 1, d_{d1 + 2},..., d_n d21,d31,...,dd1+11,dd1+2,...,dn是可图的;
    • 其中,序列 S S S中有 n n n个非负整数,序列 S 1 S_1 S1中有 n − 1 n - 1 n1个非负整数, S S S序列中 d 1 d_1 d1后的前 d 1 d_1 d1个度数(即 d 2 ∼ d d 1 + 1 d_2 \sim d_{d1 + 1} d2dd1+1)减1后构成 S 1 S_1 S1中的前 d 1 d_1 d1个数
  • 用这个定理解例4:

    • 以第一组可图的数据:4 3 1 5 4 2 1为例;
      • 由于 S S S是非增序列,所以第一步先排序,得到5 4 4 3 2 1 1
      • 删掉第一个数,然后后面的前 5 个数依次减1,得到新序列:3 3 2 1 0 1
        • 根据上面定理中的第一句话,即只要3 3 2 1 0 1可图,那么5 4 4 3 2 1 1就可图;
      • 新序列排序得到3 3 2 1 1 0,删掉第一个数,然后后面的前 3 个数依次减1,得到新序列:2 1 0 1 0
      • 新序列排序得到2 1 1 0 0,删掉第一个数,然后后面的前 2 个数依次减1,得到新序列:0 0 0 0
      • 只要得到一个全 0 序列,那么说明这个序列可图;
    • 以第二组不可图的数据:4 3 1 4 2 0为例;
      • 由于 S S S是非增序列,所以第一步先排序,得到4 4 3 2 1 0
      • 删掉第一个数,然后后面的前 4 个数依次减1,得到新序列:3 2 1 0 0
      • 新序列排序得到3 2 1 0 0,删掉第一个数,然后后面的前 3 个数依次减1,得到新序列:1 0 -1 0
      • 只要序列中出现负整数,那么这个序列就不可图。

贪心算法的常见前提操作——排序

  • 关于sort()函数:

    • 头文件:#include<algorithm>
    • 参数:sort(首地址,尾地址+1,[cmp函数])
      • 第一个参数是要排序的区间首地址(一般是数组名);
      • 第二个参数是区间尾地址的下一地址(一般是数组名+数组长度);
        • 排序区间就是[数组首地址, 数组尾地址 + 1)
      • 第三个参数可以不写,若不写则默认为递增排序。
  • sort函数对int类型的数组排序(从大到小):

    int num[100];
    bool cmp(int a, int b) // 从大到小排序
    {
        return a > b;
    }
    sort(num, num + 100, cmp);
    
    • 对于定义了小于运算(< 运算符 )的数据类型,其数组使用 sort 函数进行排序的方式是类似的。比如上面中的 int 类型,通过自定义比较函数来指定排序规则;
    • 对于 string 类型(字符串类 )也可以类似地操作,因为 string 类重载了小于运算符,能直接用于比较大小;
    • 但字符数组(char[] )本身不支持直接用小于运算符来比较大小,所以在对字符数组进行排序时,需要使用 strcmp 函数来实现比较功能,进而完成排序;
  • sort函数对结构体类型的数组排序:

    struct node
    {
        int a;
        double b;
    }arr[100];
    
    // 先按a值升序排列,如果a值相同,再按b值降序排列
    bool cmp(node x, node y)
    {
        if(x.a != y.a)
            return x.a < y.a;
        return x.b > y.b;
    }
    
    sort(arr, arr + 100, cmp);
    
  • sort函数排序的一个综合示范:

    struct student
    {
        char name[11];
        int age;
        double score;
    }stu[100];
    
    bool cmp(student x, student y)
    {
        // 按x学生和y的学生的成绩降序排序
        if(fabs(x.score - y.score) > 0.00001) // 浮点型千万不要用双等号比大小
            return x.score > y.score;
        // 按x学生和y的学生的年龄升序排序
        if(x.age != y.age)
            return x.age < y.age;
        
        // 要用strcmp函数对字符数组排序
        return strcmp(x.name, y.name) < 0;
    }
    
  • 特别注意:如果数组的元素是从下标1的位置开始存储,不要忘记修改sort函数的第一个参数;

    • 同时引申出:sort函数可以对数组的某一端进行排序。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

失散13

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值