CC++ & Algorithm

贪心算法思想:每次选最好的,然后呢?

中等21
语言版本:C++Python
概述:贪心算法就像在糖果堆里每次都挑最大块,虽然不一定最终得到最多的糖,但很多问题这样就能解决。

贪心算法:每一步都选最好的,真的能赢吗?

你是否遇过这样的场景:面前有一堆糖果,你每次都伸手去拿最大的那一块,最后觉得自己吃得最多?这种“每一步都选当前看起来最好”的做法,就是 贪心算法 的核心思想。贪心算法(Greedy Algorithm)是一种简单直接的解题策略:在解决问题的每一步,都选择当前看来最有利的选择,而不考虑这个选择会不会影响后面的步骤。它就像你吃自助餐时,先拿最贵的海鲜,而不是先喝汤填饱肚子——你觉得海鲜最划算,就先吃它。很多实际问题(比如找零、排课、背包打包)都能用贪心快速搞定,但贪心不一定永远正确——有时你拿了最大的糖,却错过了藏在下面的巧克力。

贪心的核心思想:局部最优,期望全局最优

贪心算法的本质是“短视”:只看眼前,不管未来。它假设每一步的最优选择能堆出整体的最优解。比如你去超市买零食,手里的钱只够买三样东西,你每次都挑最想吃的那个,最终组合可能就是你觉得最满意的。但在数学上,这种“局部最优加起来等于全局最优”不是天然成立的,需要问题本身有特殊性质。

生活中的贪心例子

  • 拿糖果:一堆糖果中,每次都拿最大的,最后你拿的总重量不一定最大,但如果你拿的是巧克力(每颗大小不同但价值相同),那贪心就对了。
  • 排队买冰淇淋:看到好几条队伍,你选了最短的那条排队。这是贪心——你希望最快买到,但万一短队伍后面来了很多人,而长队伍反而更快呢?不过大多数时候,选最短队是合理的。
  • 人民币找零:比如要找63元,你会先给一张50元,再给一张10元,最后给3个1元。每次都用最大面额,最终硬币张数最少。这是因为人民币的面额(1、5、10、20、50、100)是特制的,让贪心有效。
  • 安排周末活动:你有好几个想看的电影,每个电影有开始和结束时间,如何选最多的电影?贪心策略是:每次选结束时间最早的那个,然后跳过所有冲突的电影。这样你就能看尽可能多的电影。

贪心不一定总是对的——举个反例

假设你有一堆奇怪的硬币,面额分别为1元、3元、4元。现在要找你6元钱。如果用贪心先拿最大的4元,剩下2元,只能再拿两个1元,总共3枚硬币。但最优解是拿两个3元,只用2枚硬币!所以贪心在这里失败了。这就是为什么使用贪心前必须确认问题是否适合“每次选最好的”。

什么时候贪心有效?——两个关键性质

要让贪心算法保证得到全局最优解,问题一般需要满足两个性质(你不必背定义,但理解它们有助于判断):

  1. 最优子结构:一个问题的最优解,包含它的子问题的最优解。比如找零,如果全局最少硬币数是6,那么去掉一个硬币后,剩下的金额也应该能用最少的硬币数。
  2. 贪心选择性质:每一步的局部最优选择,最终能成为全局最优解的一部分。在找零中,每次都选最大面额,正好符合这个性质(前提是面额设计合理)。

典型应用一:硬币找零问题(保留原内容)

下面这个例子来自生活:你有1元、5元、10元、25元硬币,需要找零63元,如何用最少的硬币?贪心做法是每次都选不超过剩余金额的最大硬币:

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

// 硬币面额,从大到小排序,确保每次先取最大
vector<int> coins = {25, 10, 5, 1};

int greedyChange(int amount) {  // 参数:要找零的金额
    int count = 0;              // 记录硬币总数
    for (int coin : coins) {    // 从大到小遍历每一面额
        // 尽可能多用当前最大面额
        int num = amount / coin;   // 计算可以用多少枚这种硬币
        count += num;               // 累加硬币数
        amount -= num * coin;       // 更新剩余金额
    }
    return count;
}

int main() {
    int amount = 63;
    int coinsUsed = greedyChange(amount);
    cout << "找零 " << amount << " 元需要 " << coinsUsed << " 枚硬币" << endl;
    // 输出:找零 63 元需要 6 枚硬币(2个25元+1个10元+3个1元)
    return 0;
}

这段代码简单易懂,但请记住它只在硬币面额为“正常”时有效。如果换成上面反例中的1、3、4元,它就会出错。

典型应用二:活动安排问题(选最多课程)

作为中学生,你一定选过课外兴趣班,或者看过电影排片表。假设你有一堆活动,每个活动有开始时间和结束时间,你想一口气参加尽可能多的活动,但同一时间只能参加一个。贪心策略很简单:每次选结束时间最早的那个活动,然后放弃所有与它冲突的活动,重复下去。

举一个具体的例子:活动列表如下(为了方便,用整数表示时间,比如9:00就是9):

活动开始时间结束时间
体育810
音乐911
美术1012
编程1113

按照贪心,先选结束时间最早的——体育(8-10)。然后所有开始时间早于10(体育结束)的活动都不能选,所以音乐(9-11)被跳过,因为它在体育结束前就开始了。下一个可选的是美术(10-12),选上。然后编程(11-13)开始时间11小于美术结束时间12,冲突,跳过。最终只选了体育和美术,共2个活动。实际上,最优解也是2个(比如音乐和编程,或体育和美术),贪心成功。

下面给出完整代码:

#include <iostream>
#include <vector>
#include <algorithm>  // 用于 sort 排序
using namespace std;

struct Activity {
    int start;   // 开始时间
    int finish;  // 结束时间
};

// 按结束时间从小到大排序的比较函数
bool compareByFinish(Activity a, Activity b) {
    return a.finish < b.finish;
}

int greedyActivities(vector<Activity> &act) {
    // 1. 先按结束时间排序
    sort(act.begin(), act.end(), compareByFinish);
    
    int count = 0;           // 选中的活动数量
    int lastFinish = -1;     // 上一个选中活动的结束时间,初始为最小
    
    for (int i = 0; i < act.size(); i++) {
        // 2. 如果当前活动开始时间 >= 上一个结束时间,就可以选
        if (act[i].start >= lastFinish) {
            count++;
            lastFinish = act[i].finish;  // 更新最后结束时间
            cout << "选中活动:开始=" << act[i].start 
                 << ", 结束=" << act[i].finish << endl;
        }
    }
    return count;
}

int main() {
    // 创建活动列表,包含开始和结束时间
    vector<Activity> activities = {
        {8, 10},   // 体育
        {9, 11},   // 音乐
        {10, 12},  // 美术
        {11, 13}   // 编程
    };
    
    cout << "最多能参加 " << greedyActivities(activities) << " 个活动" << endl;
    return 0;
}

运行输出:

选中活动:开始=8, 结束=10
选中活动:开始=10, 结束=12
最多能参加 2 个活动

这个例子非常贴近学生的日常:选课、社团活动、看电影排片——都可以用贪心快速找到最多场次。

新手常犯的错误

  1. 没有排序就贪心:在活动安排中,如果不按结束时间排序,直接遍历,就可能先选了一个很长的活动,导致后面很多活动冲突。贪心前常常需要排序或预处理。
  2. 拿起来就用,不验证正确性:看到问题就套贪心,结果遇到反例(比如奇怪的硬币)就错了。在考试或比赛中,如果时间允许,最好先尝试构造一个反例来测试贪心是否有效。
  3. 误解“当前最优”:比如找零时,有的人可能会先拿最多数量的硬币(比如1元),但那不是贪心(贪心要“最大面额”)。一定要明确“最优”的标准是什么。
  4. 忘记更新状态:在循环里,只顾着取最大,却忘了更新剩余金额或剩余资源。比如上面的硬币找零代码中,amount -= num * coin 这一步不能漏掉。

完整可运行代码示例(整合硬币找零和活动安排)

下面给你一个完整的程序,包含两个贪心应用,直接复制到DEV‑C++或在线编译器就能跑:

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

// ========== 硬币找零(贪心) ==========
int coinChange(int amount, vector<int> coins) {
    // coins 已从大到小排序
    int count = 0;
    for (int coin : coins) {
        int num = amount / coin;   // 用当前面额硬币的个数
        count += num;
        amount -= num * coin;       // 减去已用掉的金额
    }
    return count;
}

// ========== 活动安排(贪心) ==========
struct Activity {
    int start;
    int finish;
};
bool cmpFinish(Activity a, Activity b) {
    return a.finish < b.finish;   // 按结束时间升序
}
int maxActivities(vector<Activity> &acts) {
    sort(acts.begin(), acts.end(), cmpFinish);
    int count = 0;
    int lastEnd = -1;
    for (auto &act : acts) {
        if (act.start >= lastEnd) {
            count++;
            lastEnd = act.finish;
        }
    }
    return count;
}

int main() {
    // 1. 硬币找零演示
    cout << "===== 硬币找零 =====" << endl;
    vector<int> coins = {25, 10, 5, 1};
    int money = 63;
    int coinsNeeded = coinChange(money, coins);
    cout << "找零 " << money << " 元,最少需要 " << coinsNeeded << " 枚硬币" << endl;

    // 2. 活动安排演示
    cout << "\n===== 活动安排 =====" << endl;
    vector<Activity> activities = {
        {8, 10},
        {9, 11},
        {10, 12},
        {11, 13}
    };
    int actCount = maxActivities(activities);
    cout << "最多能参加 " << actCount << " 个活动" << endl;

    return 0;
}

输出结果:

===== 硬币找零 =====
找零 63 元,最少需要 6 枚硬币

===== 活动安排 =====
最多能参加 2 个活动

总结与相关指引

贪心算法是一种“短视但高效”的技巧:速度快、代码短,但只适用于特定问题。你可以在以下场景大胆尝试贪心:

  • 找零问题(面额合理)
  • 活动安排(选最多不冲突的活动)
  • 哈夫曼编码(数据压缩)
  • 单源最短路径的Dijkstra算法(本质也是贪心)

如果一个问题不满足贪心性质,就需要考虑其他算法,比如动态规划(从小的子问题一步步构造最优解)或回溯法(暴力试所有可能性)。你可以进一步学习:

  • 动态规划入门:当贪心失效时,动态规划能通过记忆化搜索或递推得到正确结果。
  • 分治算法:把大问题拆成小问题分别解决,与贪心不同,分治会合并子问题的解。

记住:贪心之前,先问问自己——这次拿最大的,真的能一直赢到最后吗? 如果拿不准,可以用小数据试一下,或者尝试构造一个反例。掌握了贪心的思想,你就能在面对很多生活问题时快速找到不错的方案,甚至是最优解!

例题精讲

1单选题

贪心算法在每一步选择中都采取当前状态下最优的选择,这种选择是否一定能保证得到全局最优解?

A总是能
B取决于问题是否具有最优子结构和贪心选择性质
C只有满足无后效性的问题才能
D只要每一步都选最优,最终结果一定最优
2判断题

对于具有最优子结构的问题,贪心算法总是优于动态规划算法。

3填空题
以下是用贪心算法解决活动选择问题的代码片段,在___处填入正确的表达式(假设活动已按结束时间排序,s[]为开始时间,f[]为结束时间,n为活动数)。
int greedyActivity(int s[], int f[], int n) {
    int count = 1;
    int lastEnd = f[0];
    for (int i = 1; i < n; i++) {
        if (___ >= lastEnd) {
            count++;
            lastEnd = f[i];
        }
    }
    return count;
}
4单选题

对于找零钱问题,假设有面额为1元、5元、10元、20元、50元、100元的纸币(每种数量无限),要支付n元,使用贪心算法(每次选最大面额)找零,以下哪种说法是正确的?

A贪心算法一定得到最少纸币数
B贪心算法不一定得到最少纸币数
C只有当n是10的倍数时,贪心才最优
D贪心算法会优先使用小面额纸币
5判断题

在背包问题中,如果物品可以分割(分数背包),使用贪心算法按单位重量价值从大到小选择,一定能得到最优解。