贪心算法基础

局部最优选择策略 -- 每一步都做出当前看来最优的选择

一、算法概述

贪心算法(Greedy Algorithm)是一种在每一步选择中都采取当前状态下最优(即最有利)的选择,从而希望导致全局最优解的算法设计范式。与动态规划不同,贪心算法并不从整体上考虑所有可能的选择,而是做出局部最优的决策,并期望通过一系列局部最优选择达到全局最优

核心思想

贪心算法的本质可以概括为一句话:"今朝有酒今朝醉" -- 在每个决策点,选择当前收益最大或代价最小的方案,不考虑未来的影响。

  • 贪心选择性质(Greedy Choice Property): 全局最优解可以通过一系列局部最优(贪心)选择来达到
  • 最优子结构(Optimal Substructure): 一个问题的最优解包含其子问题的最优解

"贪心算法是一种短视的算法策略,它只关注眼前的利益。但正是这种看似简单的策略,却能高效解决许多经典问题,如最小生成树、最短路径、哈夫曼编码等。"

贪心算法与日常生活

贪心策略在日常生活中随处可见:

二、算法设计步骤

设计一个贪心算法通常遵循以下四个步骤:

步骤一:确定问题的贪心策略

分析问题特征,确定每一步的选择标准。常见的贪心策略包括:

  • 最小化/最大化某个指标: 如选择最早结束的活动、选择最便宜的边等
  • 按某种优先级排序: 如按截止时间排序、按收益排序等
  • 考虑边界情况: 如选择重叠最多的区间、选择效益最高的方案等

步骤二:将问题转化为贪心选择形式

将原问题分解为一系列子问题,每个子问题都应用贪心策略进行选择。在做出贪心选择后,剩余的子问题仍然具有与原问题相同的结构。

  • 定义子问题的状态空间
  • 确定每一步的选择范围
  • 形成"选择 -- 子问题"的递归结构

步骤三:证明贪心策略的正确性

这是贪心算法设计中最关键也最困难的一步。常用的证明方法包括交换论证法、数学归纳法和反证法(详见第三节)。

  • 证明贪心选择性质成立
  • 证明最优子结构成立
  • 确保贪心选择不会排除全局最优解

步骤四:实现贪心算法

根据贪心策略实现具体的算法代码。通常包含排序和线性扫描两个阶段。

  • 预处理阶段: 对数据进行排序或预处理
  • 贪心选择阶段: 依次做出贪心选择
  • 更新阶段: 更新当前状态,为下一次选择做准备

贪心算法通用模板

// 贪心算法通用框架
Greedy(Data):
    1. 对数据按贪心策略进行排序
    2. 初始化结果集 result = {}
    3. For each item in Data (按排序顺序):
        a. 判断当前item是否可以加入结果集
        b. 如果可以,将item加入result
        c. 更新当前状态
    4. Return result

三、正确性证明方法

贪心算法正确性证明是算法设计的核心难点。以下介绍三种最常用的证明方法:

1. 交换论证法(Exchange Argument)

交换论证法是最强大的贪心证明工具之一。其核心思想是:假设存在一个最优解,通过一系列不改变最优性的交换操作,逐步将其转化为贪心解

交换论证法的证明步骤:

  1. 假设:OPT 是问题的一个全局最优解
  2. 找差异: 找出 OPT 与贪心解 GREEDY 的第一个不同选择位置
  3. 交换:OPT 中的该项替换为贪心选择
  4. 证明不劣: 证明交换后得到的解 OPT' 不会比 OPT
  5. 归纳: 重复上述过程,最终 OPT 可以转化为 GREEDY,且不损失最优性

交换论证法示例 -- 活动选择问题

问题: 给定 n 个活动,每个活动有开始时间 s[i] 和结束时间 f[i],要求选择尽可能多的互不重叠的活动。

贪心策略: 每次选择结束时间最早且不与已选活动重叠的活动。

交换论证:

  1. OPT 为最优解,GREEDY 为贪心解
  2. 比较 OPTGREEDY 中第一个不同的活动
  3. 假设 OPT 中选择了活动 a,而贪心解选择了活动 b(b 的结束时间 ≤ a 的结束时间)
  4. OPT 中的 a 替换为 b。由于 b 的结束时间不晚于 a,b 不会与已选活动重叠
  5. 替换后活动数量不变,且剩余时间更充裕,OPT 仍然是可行解
  6. 因此贪心选择 b 至少不劣于 a,通过反复交换,贪心解可以转化为最优解

2. 数学归纳法(Induction)

通过对算法执行步骤或问题规模进行归纳,证明贪心算法的每一步选择都是正确的。

归纳法证明步骤:

  1. 基础情形: 证明当问题规模为 1 时,贪心选择是正确的
  2. 归纳假设: 假设前 k 步贪心选择可以得到一个部分最优解
  3. 归纳步骤: 证明在第 k+1 步,贪心选择仍然正确,不会影响最终解的最优性

归纳法示例 -- 找零钱问题(特定币值)

问题: 给定面额为 25、10、5、1 的硬币(美元币值),用最少数量的硬币凑出金额 n。

贪心策略: 每次都选择面值不超过剩余金额的最大硬币。

归纳证明: 对金额 n 进行归纳。假设对于所有小于 n 的金额,贪心算法给出最优解。对于金额 n,贪心选择面值最大的硬币 c ≤ n,剩余金额为 n - c。由归纳假设,剩余部分的最优解可以由贪心算法得到,因此整体解最优。

3. 反证法(Contradiction)

假设贪心算法得到的解不是最优解,然后推导出矛盾。

反证法证明步骤:

  1. 假设: 贪心解 GREEDY 不是最优解
  2. 构造:OPT 是一个与 GREEDY 前 k 个选择相同、但在第 k+1 个选择处不同的最优解
  3. 推导: 分析贪心选择与 OPT 中对应选择的优劣
  4. 矛盾: 推导出与贪心策略的"最优性"相矛盾的结论,从而证明贪心解就是最优解

四种常见的贪心策略模式

策略模式 描述 典型问题
最早截止时间优先 选择结束/截止时间最早的 活动选择、区间调度
最短处理时间优先 选择处理时间最短的 最小化总完成时间、排队问题
最高密度优先 选择性价比/单位价值最高的 分数背包、哈夫曼编码
最大收益优先 选择当前收益最大的 Prim算法、Dijkstra算法

四、经典贪心例题

例题1:区间选点问题(Interval Point Selection)

难度:★☆☆☆☆ 频率:高

题目描述: 给定 n 个闭区间 [ai, bi],在数轴上选择尽量少的点,使得每个区间内至少包含一个选出的点(右端点也算区间内)。

贪心策略: 将所有区间按右端点从小到大排序,选择第一个未覆盖的区间的右端点。

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

struct Interval {
    int left, right;
};

// 贪心选点:按右端点排序
int selectPoints(vector<Interval>& intervals) {
    if (intervals.empty()) return 0;

    // 按右端点升序排序
    sort(intervals.begin(), intervals.end(),
         [](const Interval& a, const Interval& b) {
             return a.right < b.right;
         });

    int count = 1;                 // 至少需要一个点
    int currentPoint = intervals[0].right;  // 选第一个区间的右端点

    for (int i = 1; i < intervals.size(); i++) {
        // 如果当前区间不包含已选的点
        if (intervals[i].left > currentPoint) {
            count++;
            currentPoint = intervals[i].right;  // 选新区间的右端点
        }
    }
    return count;
}

复杂度分析: O(n log n) -- 排序 O(n log n),扫描 O(n)。

正确性证明: 设贪心选择的第一个点为第一个区间的右端点。对于任意一个不包含该点的区间,该区间的左端点必然大于第一个区间的右端点,因此必须另选新的点。按此方式递归处理,每个点都尽可能多地覆盖区间,因此总的选点数量最小。

例题2:区间覆盖问题(Interval Coverage)

难度:★★☆☆☆ 频率:高

题目描述: 给定一个目标区间 [S, T] 和 n 个小区间 [ai, bi],选择尽量少的小区间来覆盖目标区间。

贪心策略: 将所有区间按左端点从小到大排序,每次选择能覆盖当前起点且右端点最远的区间。

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

struct Interval {
    int left, right;
};

int minCoverage(vector<Interval> intervals, int S, int T) {
    // 按左端点升序排序
    sort(intervals.begin(), intervals.end(),
         [](const Interval& a, const Interval& b) {
             return a.left < b.left;
         });

    int count = 0;
    int curEnd = S;        // 当前已覆盖的右端点
    int i = 0;
    int n = intervals.size();

    while (curEnd < T) {
        int farthest = curEnd;

        // 在所有左端点 ≤ curEnd 的区间中,找右端点最大的
        while (i < n && intervals[i].left <= curEnd) {
            farthest = max(farthest, intervals[i].right);
            i++;
        }

        // 无法继续覆盖
        if (farthest == curEnd) return -1;

        count++;
        curEnd = farthest;  // 更新当前覆盖位置
    }
    return count;
}

复杂度分析: O(n log n) -- 排序 O(n log n),每个区间最多被访问一次 O(n)。

例题3:最大不相交区间数量

难度:★★☆☆☆ 频率:高

题目描述: 给定 n 个区间,选择尽量多的区间,使得这些区间两两不相交(端点重合也算相交)。

贪心策略: 按右端点从小到大排序,优先选择右端点最小的区间。

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

struct Interval {
    int left, right;
};

int maxNonOverlapping(vector<Interval> intervals) {
    if (intervals.empty()) return 0;

    // 按右端点升序排序
    sort(intervals.begin(), intervals.end(),
         [](const Interval& a, const Interval& b) {
             return a.right < b.right;
         });

    int count = 1;
    int lastRight = intervals[0].right;

    for (int i = 1; i < intervals.size(); i++) {
        // 如果当前区间左端点 ≥ 上一个选中区间的右端点(不相交)
        if (intervals[i].left >= lastRight) {
            count++;
            lastRight = intervals[i].right;
        }
    }
    return count;
}

注意: 本题与"区间选点"问题具有对偶性。实际上,最大不相交区间数量等于最少选点数。

例题4:加油站问题(Gas Station)

难度:★★★☆☆ 频率:中

题目描述: 一个环形路线上有 n 个加油站,第 i 个加油站有汽油 gas[i],从 i 到 i+1 需要消耗汽油 cost[i]。求是否存在一个起点,使得汽车能绕行一周。

贪心策略: 如果一个位置开始无法到达下一个加油站,则这个位置和中间经过的所有位置都不能作为起点。

#include <vector>
using namespace std;

int canCompleteCircuit(vector<int>& gas, vector<int>& cost) {
    int n = gas.size();
    int totalTank = 0;   // 总油量盈余
    int curTank = 0;     // 当前油量
    int start = 0;       // 起点

    for (int i = 0; i < n; i++) {
        int diff = gas[i] - cost[i];
        totalTank += diff;
        curTank += diff;

        // 当前起点无法到达 i+1
        if (curTank < 0) {
            // 从 i+1 重新开始尝试
            start = i + 1;
            curTank = 0;
        }
    }

    // 总油量 ≥ 总消耗,则一定能找到起点
    return totalTank >= 0 ? start : -1;
}

正确性分析: 关键性质 -- 如果从起点 start 出发到 i 时油量变为负,则起点 start 到 i 之间的任何节点都不能作为起点。这是因为从 start 出发到中间节点 k 时油量非负,所以从 start 到 k 积累的油量是正的,而从 k 出发等于在正油量基础上出发都到不了 i,直接从 k 出发更不可能。

例题5:柠檬水找零(Lemonade Change)

难度:★☆☆☆☆ 频率:中

题目描述: 每杯柠檬水售价 5 美元,顾客排队购买,每位顾客只买一杯,支付 5、10 或 20 美元。初始没有零钱,判断能否给每位顾客正确找零。

贪心策略: 收到 20 美元时,优先用 10+5 找零(因为 5 美元更通用),其次用 5+5+5。

#include <vector>
using namespace std;

bool lemonadeChange(vector<int>& bills) {
    int five = 0, ten = 0;  // 分别记录 5 元和 10 元的数量

    for (int bill : bills) {
        if (bill == 5) {
            five++;
        } else if (bill == 10) {
            if (five == 0) return false;
            five--;
            ten++;
        } else {  // bill == 20
            // 贪心:优先用 10+5 组合找零
            if (ten > 0 && five > 0) {
                ten--;
                five--;
            } else if (five >= 3) {
                five -= 3;
            } else {
                return false;
            }
        }
    }
    return true;
}

/*
 * 为什么收到20时要优先用10元?
 * 因为5元在找零中扮演更关键的角色:
 * - 5元可以给10元和20元找零
 * - 10元只能给20元找零
 * 保留5元能应对更多找零场景,所以20元尽量先用10元找零。
 */

复杂度分析: O(n) -- 一次线性扫描即可。

例题6:分发饼干(Assign Cookies)

难度:★☆☆☆☆ 频率:中

题目描述: 每个孩子有一个胃口因子 g[i],每块饼干有大小 s[j]。当饼干大小 ≥ 孩子胃口时,孩子会满足。每个孩子最多给一块饼干,求最多能满足多少个孩子。

贪心策略: 将孩子胃口和饼干大小排序,用最小的饼干去满足胃口最小的孩子(每次给最接近胃口大小的饼干)。

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

int findContentChildren(vector<int>& g, vector<int>& s) {
    sort(g.begin(), g.end());   // 孩子胃口升序
    sort(s.begin(), s.end());   // 饼干大小升序

    int child = 0;  // 已满足的孩子数量
    int cookie = 0; // 当前考虑的饼干索引

    while (child < g.size() && cookie < s.size()) {
        if (s[cookie] >= g[child]) {
            child++;  // 该孩子得到满足
        }
        cookie++;  // 无论是否满足,当前饼干都被消耗
    }
    return child;
}

/*
 * 为什么贪心策略有效?
 * 对于胃口最小的孩子,用最小的可用饼干去满足,保留了更大的饼干。
 * 这样做不会影响更多孩子被满足的总数。证明:
 * 假设最优解中胃口最小的孩子用了更大的饼干,将其替换为最小的可用饼干后,
 * 更大的饼干可以留给胃口更大的孩子,总满足数不会减少。
 */

复杂度分析: O(n log n + m log m) -- 两次排序,一次线性扫描。

五、贪心算法 vs 动态规划

贪心算法和动态规划是解决最优化问题的两种重要方法。理解它们的区别至关重要。

核心区别对比

维度 贪心算法 动态规划
决策方式 只考虑当前最优,不做回溯 考虑所有可能选择,通过比较找出最优
子问题依赖 每个阶段只有一个子问题 子问题可能重叠,需要状态转移
时间复杂度 通常 O(n log n) 或 O(n) 通常 O(n²) 或更高
正确性要求 需要严格证明贪心选择性质 需要无后效性和最优子结构
适用范围 贪心选择性质成立的问题 适用范围更广,但效率可能较低
空间复杂度 通常 O(1) 额外空间 通常 O(n) 或 O(n²)

案例分析:区间调度问题的两种解法

活动选择问题(区间调度)是展示贪心和DP差异的经典例子。

DP解法 vs 贪心解法

问题: 给定 n 个活动,每个活动有开始时间 s[i] 和结束时间 f[i],选择最大数量的互不重叠活动。

1. 动态规划解法:

先将活动按结束时间排序。定义 dp[i] 表示前 i 个活动(已按结束时间排序)中能选出的最大活动数。

// DP解法:时间复杂度 O(n²)
int dpSchedule(vector<Interval> intervals) {
    int n = intervals.size();
    // 按结束时间排序
    sort(intervals.begin(), intervals.end(),
         [](auto& a, auto& b) { return a.right < b.right; });

    vector<int> dp(n + 1, 0);
    dp[0] = 0;

    for (int i = 1; i <= n; i++) {
        // 不选第 i 个活动
        dp[i] = dp[i - 1];

        // 选第 i 个活动:找到最后一个与 i 不冲突的活动
        int j = i - 1;
        while (j >= 1 && intervals[j - 1].right > intervals[i - 1].left) {
            j--;
        }
        dp[i] = max(dp[i], dp[j] + 1);
    }
    return dp[n];
}

// 时间复杂度:O(n²)

2. 贪心解法:

按结束时间排序,每次选择第一个与已选活动不冲突的活动。

// 贪心解法:时间复杂度 O(n log n)
int greedySchedule(vector<Interval> intervals) {
    sort(intervals.begin(), intervals.end(),
         [](auto& a, auto& b) { return a.right < b.right; });

    int count = 1;
    int lastEnd = intervals[0].right;

    for (int i = 1; i < intervals.size(); i++) {
        if (intervals[i].left >= lastEnd) {
            count++;
            lastEnd = intervals[i].right;
        }
    }
    return count;
}

// 时间复杂度:O(n log n) 排序 + O(n) 扫描

为什么贪心可以而DP也可以?

  • DP解法 适用于任何具有最优子结构的区间调度问题,但需要 O(n²) 时间
  • 贪心解法 利用了活动的特殊性质(结束时间最早的活动一定在某个最优解中),只需要 O(n log n) 时间
  • 贪心是DP在满足"贪心选择性质"时的特例优化版本
  • 并非所有DP问题都能用贪心求解 -- 关键差别在于问题是否满足贪心选择性质

如何判断用贪心还是DP?

决策指南

  • 尝试贪心: 如果能找到一种直观的贪心策略,并且能证明贪心选择性质成立,那么贪心是首选(高效、简单)
  • 如果贪心无法证明: 或者怀疑贪心策略不成立,使用DP作为保险方案
  • 经典区分:
    • 分数背包问题(可以取部分物品)→ 贪心(按单位价值排序)
    • 0-1背包问题(只能整个取或不取)→ DP(贪心不成立!)
  • 经验法则: 如果当前选择会限制未来选择的空间(如资源有限),通常需要DP;如果当前选择不影响未来的选择空间,可能可以贪心

六、应用场景与特征

适合使用贪心算法的场景

问题需要同时满足"贪心选择性质"和"最优子结构"

  1. 贪心选择性质(Greedy Choice Property): 全局最优解可以通过一系列局部最优选择来构造。换句话说,做出贪心选择后,原问题简化为一个规模更小的子问题,且该子问题与原问题类型相同。
  2. 最优子结构(Optimal Substructure): 问题的最优解包含子问题的最优解。如果子问题的解不是最优,那么无论如何组合都不会得到原问题的最优解。

典型应用领域

领域 经典问题 贪心策略
图论 最小生成树(Kruskal / Prim) 优先选择权值最小的边/顶点
图论 单源最短路径(Dijkstra) 优先选择距离源点最近的未处理顶点
数据结构 哈夫曼编码 合并频率最小的两个节点
调度问题 活动选择 / 区间调度 按结束时间最早排序
背包问题 分数背包 按单位价值降序装入
货币系统 找零钱(特定币值) 优先使用大额硬币
任务调度 最小化延迟 按截止时间排序(EDD规则)
字符串 拼接最小字符串 按自定义比较器排序拼接
其他 柠檬水找零 优先使用更通用的组合找零

不适合使用贪心算法的场景

贪心算法失效的典型案例

  • 0-1背包问题: 因为不能分割物品,贪心策略(按单位价值从大到小)不保证最优解。例如容量为 50,物品 A(价值 60,重量 10,单位价值 6),物品 B(价值 100,重量 20,单位价值 5),物品 C(价值 120,重量 30,单位价值 4)。贪心会选 A+B=160,但最优解是 B+C=220。
  • 最短路径(负权边): Dijkstra 算法在处理负权边时失效,因为当前局部最优选择可能在之后被更优路径覆盖。
  • 旅行商问题(TSP): 每次选择最近的城市并不能保证得到全局最短路径。
  • 人民币找零(非标准币值): 当硬币面额为 1, 3, 4 时,金额 6 的贪心解为 4+1+1(3枚),但最优解为 3+3(2枚)。

"所有能用贪心算法解决的问题,也都能用动态规划解决。但反之不成立。贪心算法的优势在于其简单性和高效性,但这种优势是有代价的 -- 它并不总是正确的。"

七、扩展与进阶思考

贪心算法的变体

近似贪心算法

当问题不能保证贪心最优解时,贪心策略可以作为近似算法使用:

  • 集合覆盖问题: 每次选择覆盖最多未覆盖元素的集合。贪心近似比为 ln(n)。
  • 最大割问题: 随机贪心策略可以达到 0.5 的近似比。
  • 在线算法: 数据以流的形式到达,必须立即做出决策,不能回溯。贪心策略是处理在线问题的常见方法。

常见面试题总结

LeetCode 经典贪心题目(按难度排序)

  • 简单: 455. 分发饼干 | 860. 柠檬水找零 | 122. 买卖股票的最佳时机 II | 392. 判断子序列
  • 中等: 55. 跳跃游戏 | 45. 跳跃游戏 II | 134. 加油站 | 406. 根据身高重建队列 | 435. 无重叠区间 | 452. 用最少数量的箭引爆气球
  • 困难: 135. 分发糖果 | 502. IPO | 630. 课程表 III | 757. 设置交集大小至少为2

贪心算法的实现技巧

编码注意事项

  1. 排序是核心: 大多数贪心算法都需要先排序。选择正确的排序规则是关键。
  2. 自定义比较器: 在 C++ 中使用 lambda 表达式或仿函数;在 Python 中用 key 参数或 cmp_to_key
  3. 边界条件处理: 注意空数组、只有一个元素、数值溢出等边界情况。
  4. 不可撤销: 贪心选择一旦做出就不能撤销,因此选择前要确信其正确性。
  5. 证明先行: 编码前先思考能否证明贪心策略的正确性。如果不能严格证明,应该考虑 DP 或其他方法。

八、核心要点总结

九、进一步思考

贪心算法的魅力在于其思想简单但应用广泛。从哈夫曼的压缩编码到 Dijkstra 的导航路线,从 Kruskal 的网络布线到任务调度的截止时间排序,贪心策略无处不在。

学习建议与进阶方向

  • 基础扎实: 熟练掌握区间调度、活动选择、分数背包等经典贪心问题,理解其证明过程
  • 刷题训练: 在 LeetCode 上完成贪心标签下的 20-30 道题目,覆盖不同题型
  • 证明训练: 每道题都尝试用交换论证法严格证明贪心正确性
  • 对比学习: 同一问题尝试分别用贪心和DP实现,理解两者的性能差异和适用条件
  • 理论进阶: 学习拟阵(Matroid)理论,从数学层面理解贪心算法的本质 -- 满足拟阵结构的问题一定可以用贪心算法求解
  • 实际应用: 思考在实际工程中哪些场景可以用贪心策略简化问题(如资源调度、缓存替换策略 LRU/LFU 等)

"贪心算法教会我们的是:有时候,不瞻前顾后、不权衡再三,只看眼前做出最好的选择,反而能得到最好的结果。这既是算法的智慧,也是一种人生哲学。"