1

一.贪心算法

1.导语

  • “良禽择木而栖,贤臣则主而事”,“窈窕淑女,君子好逑”,我们似乎永远在追求美而优的东西。仔细去想,你会发现“人之初,性本贪”。小孩子吃糖果,总想要最多的;吃水果,总想吃最大的;买玩具,总想要最好的,这种“贪”不是大人教的,是与生俱来的。然而现实中的很多事情,正是因为这种趋优性使我们的生活一步步走向美好。
  • 然而,事情都有两面性,一把菜刀可以做出美味的佳肴,也能变成杀人工具。在这里,我们只谈论好的“贪心”。

2.贪心本质

  • 关于贪心,《算法导论》中这样说:“一个贪心算法总是做出当前最好的选择,也就是说,它期望通过局部最优选择得到全局最优的解决方案”。
  • 我们经常会听人说这样的话:“人要活在当下”,“看清楚眼前”。其实贪心算法正是“活在当下,看清楚眼前”的办法,从问题的初解出发,一步步做出当前最好的选择,逐步逼近目标。
  • 贪心算法在解决问题的策略上甚至有些“目光短浅”,只根据当前已有的信息做出选择。一旦做出了选择,不管将来有什么结果,这个选择都不会改变。也就是说,贪心算法并不是从整体最优考虑,它所做出的选择只是在某种意义上的局部最优。
  • 然而,神奇的是,贪心算法能得到许多问题的整体最优解或整体最优解的近似解。因此,贪心算法在实际中得到大量应用。
  • 在贪心算法中,选择什么样的贪心策略,直接决定算法的好坏。

3.贪心适用情况

并不是我们遇到的每个问题都可以用贪心策略求解,很多时候我们往往分不清,但是能利用贪心算法求解的问题往往具有两个重要性质:贪心选择性质和最优子结构性质。如果满足这两个性质就可以放心使用贪心算法了。

(1)贪心选择性质

贪心选择性质是指原问题的整体最优解可以通过一系列的局部最优的选择得到。应用同一规则,将原问题变为一个相似的但规模更小的子问题,而后每一步都是当前最佳的选择。在求解过程中没有回溯。

(2)最优子结构性质

当一个问题的最优解包含其子问题的最优解时,称此问题具有最优子结构性质。最优子结构性质是该问题是否可用贪心算法求解的关键。例如,原问题S = {a1, a2, a3, ... , an},通过贪心选择出一个当前最优解{ai}之后,转化为求解子问题S - {ai},如果原问题的最优解包含子问题的最优解(即原问题的最优解包含ai,也包含S-{ai}算出的最优解),则说明该问题满足最优子结构的性质。如下图所示:
图片.png

4.贪心算法解题策略

上面我们已经知道了具有贪心选择性质和最优子结构性质就可以使用贪心算法,下面介绍使用时的具体策略。

(1)贪心策略

首先要确定贪心策略,选择当前看上去最好的一个方案。例如,挑选苹果,你可以挑选个最大的作为贪心策略,那你每次只选一个最大的。当然你也可以每次选择颜色最红的,只是贪心策略不同而已。

(2)局部最优解

根据贪心策略,一步一步选择得到局部最优解。例如,第一次选最大的苹果放起来,记为a1,第二次再从剩下的苹果堆中选择一个最大的苹果放起来,记为a2,以此类推。

(3)全局最优解

把所有的局部最优解合并,成为原问题的一个最优解(a1,a2,...)。
看起来是不是有点儿像冒泡排序?

"不是六郎似荷花,而是荷花似六郎"*!不是贪心算法像冒泡排序,而是冒泡排序使用了贪心算法。它的贪心策略就是每次从剩下的序列中选一个最小的数,把这些挑出来的数放在一起,就得到了从小到大的结果,第一个阶段如下图所示:
图片.png


二.会议安排问题

1.问题分析

问题的场景是在有限的时间内有很多会议要召开,每个会议有开始和结束时间如表1所示,要求任何两个会议不能同时进行。会议安排问题要求就是在所给的会议集合中选出最大的相容活动子集,即尽可能在有限的时间内召开更多的会议。

表1 会议时间表
会议i 1 2 3 4 5 6 7 8 9 10
开始时间bi 8 9 10 11 13 14 15 17 18 16
结束时间ei 10 11 15 14 16 17 17 18 20 19

从图2中可以看出,会议{1,4,6,8,9}和会议{2,4,7,8,9}都是能安排最多的会议集合。(手工画图,还请见谅~)
图片.png
我们需要选出最多的不相交的时间段,可以尝试以下的贪心策略:
(1)每次从剩下未安排会议中选出最早开始且与已安排会议不冲突的会议;
(2)每次从剩下未安排会议中选出持续时间最短且与已安排会议不冲突的会议;
(3)每次从剩下未安排会议中选出最早结束且与已安排会议不冲突的会议;
我们简单地分析一下:
如果选择最早开始时间,如果会议持续时间很长,假如8点开始,却要持续12小时,这样每天只能安排一个会议,肯定不合理;如果选择持续时间最短,则可能开始时间很晚,也不合理。因此我们最好选择开始最早而且结束时间短的会议,即最早开始时间+持续时间最短,这不就等价于选最早结束时间嘛!是不是有点儿意思~。因此,我们采用第(3)种贪心策略。

2.算法设计

(1)初始化:将n个会议的会议编号,开始时间,结束时间存放在结构体数组中,然后按结束时间从小到大排序,如果结束时间相等,按开始时间从大到小(结束时间相等时,为了选出持续时间最短)
(2)根据贪心策略,选出第一个具有最早结束时间的会议,用last记录刚选中的会议的结束时间;
(3)选出第一个会议之后,依次从剩下未安排的会议中选择会议,记为i,如果会议i开始时间大于最后一个选中会议的结束时间last,那么会议i与已选中的会议不冲突,可以安排,同时更新last为刚选中会议的结束时间;否则,舍弃会议i,检查下一个会议是否可安排。

3.算法图解

需要提醒的是:为了看出排序效果,从表三开始选择了和表1不同的测试数据。
图片.png
第一次选择时,选择结束时间最早的会议2,如下表所示,同时last=4(会议2的结束时间)
图片.png
第二次选择时,要选择开始时间大于等于last(last=4),会议4,1不满足条件,舍弃,只能选择会议3(表中红色框表示选中,蓝色框表示舍弃),如下表所示,同时更新last=7。
图片.png
第三次选择,要选择开始时间大于等于last(last=7),会议6,5,8不满足条件,摄取,选择会议7,如下表所示,同时更新last=11。
图片.png
第四次选择,要选择开始时间大于等于last(last=11),会议9不满足条件,摄取,选择会议10,如下表所示,同时更新last=14。
图片.png
选择结束,整个算法执行完毕,最终选出的会议安排分别是:2,3,7,10.

4.代码实现

#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
using namespace std;

// 定义结构体,保存一次会议的相关信息
typedef struct Meeting {
    int id;   // 编号
    int start;   // 开始时间
    int end;   // 结束时间
};

// 定义排序规则
bool cmp(Meeting a, Meeting b) {
    if (a.end < b.end)
        return true;
    else if (a.end > b.end)
        return false;
    // 如果结束时间相等,就选开始时间最晚的
    else
        return a.start > b.start;
}

// 初始化,将所有会议信息保存到数组中并返回
vector<Meeting> InitMeetings() {
    int n, start, end;
    cout << "请输入会议总数:" << endl;
    cin >> n;
    cout << "请输入" << n << "个会议的开始时间和结束时间(以空格分开):" << endl;
    vector<Meeting> meets(n);
    for (int i = 0; i < n; i++) {
        cin >> start >> end;
        // 将第i次会议的相关信息存入结构体
        meets[i].id = i + 1;
        meets[i].start = start;
        meets[i].end = end;
    }
    return meets;
}

// 按贪心算法安排会议行程,返回安排好的会议的编号
vector<int> ArrangeMeetings(vector<Meeting>& meets) {
    // 对会议按结束时间进行从小到大排序
    std::sort(meets.begin(), meets.end(), cmp);
    // 记录当前被选中会议的结束时间
    int last = 0;
    // 用来保存已经选中会议的编号
    vector<int> res;

    for (int i = 0; i < meets.size(); i++) {
        if (meets[i].start >= last) {
            // 满足要求,可以选中
            res.push_back(meets[i].id);
            // 更新last
            last = meets[i].end;
        }
    }
    return res;
}

int main() {
    // 测试
    vector<Meeting> meets = InitMeetings();
    vector<int> res = ArrangeMeetings(meets);
    cout << "\n会议行程安排如下:" << endl;
    for (auto id : res) {
        cout << id << " ";
    }
}

测试结果

请输入会议总数:
10
请输入10个会议的开始时间和结束时间(以空格分开):
3 6
1 4
5 7
2 5
5 9
3 8
8 11
6 10
8 12
12 14

会议行程安排如下:
2 3 7 10
D:\projects\test\x64\Release\test.exe (进程 1004)已退出,返回代码为: 0。
按任意键关闭此窗口...

算法复杂度分析

  • 时间复杂度:该算法的时间复杂度主要体现在ArrangeMeetings函数上,排序评价时间复杂度为O(nlogn),随后会议选择的时间复杂度为O(n),总时间复杂度为O(n+nlogn) = O(nlogn).
  • 空间复杂度:需要结构体数组存入会议数据,空间复杂度为O(n).

参考文献

1.陈小玉《趣学算法》第二章相关内容。

我是lioney,年轻的后端攻城狮一枚,爱钻研,爱技术,爱分享。
个人笔记,整理不易,感谢阅读、点赞和收藏。
文章有任何问题欢迎大家指出,也欢迎大家一起交流后端各种问题!

lioney
133 声望14 粉丝

引用和评论

0 条评论