贪心算法:局部选择与全局最优
一句话概括:贪心算法在每一步只做当前看来最好的选择且永不回头,其正确性完全依赖于两个可证明的性质——贪心选择性质与最优子结构。二者缺一,算法失效。
阅读指引:第一章给出全景视图,第二章给出严格定义,第三章至第四章说明思想与流程,第五章用四个完整实例演算,第六章讨论复杂度,第七章给出 Python / Java / C++ 三份参考实现,第八章列出易错点,第九章与附录给出延伸内容与速查表。
目录
一、概述
1.1 贪心算法要解决的问题
贪心算法针对这样一类问题:给定一组输入,要求在满足若干约束的前提下使某个度量值达到最大或最小,并且这类问题的最优解能够通过「每一步都做出当前看起来最好的选择」得到。
| 典型问题 | 输入 | 优化目标 |
|---|---|---|
| 活动选择 | 若干带起止时间的活动 | 能安排的活动数量最多 |
| 分数背包 | 物品的重量与价值、背包容量 | 所取物品的总价值最大 |
| 任务调度 | 任务的截止时间与收益 | 按时完成的任务总收益最大 |
| 最小生成树 | 带权无向图 | 生成树的总权重最小 |
| 霍夫曼编码 | 字符的出现频率 | 平均编码长度最短 |
| 找零钱 | 硬币面值与待找金额 | 使用的硬币数量最少 |
这些问题的共同点有两条:
- 每一步的决策只依赖当前已确定的信息,不需要预知后续结果;
- 决策一旦作出即被固定,算法不会回头修改先前的选择。
第二点是贪心算法与动态规划最直观的区别:动态规划把每个候选的后果都算出来再做比较,贪心算法则在比较之前就已经排除了大部分候选。
1.2 关键性质速览
| 性质 | 含义 | 判定方式 |
|---|---|---|
| 贪心选择性质 | 存在一个最优解包含当前这一步的贪心选择 | 用交换论证法把贪心选择换进某个最优解 |
| 最优子结构 | 最优解的剩余部分是某个同类子问题的最优解 | 写出「最优解 = 子问题最优解 + 代价」并确认子问题只规模不同 |
| 约束单调收紧 | 每做一次决策,剩余问题的约束只会更紧 | 检查剩余可行域的变化方向 |
| 排序依据可计算 | 决定候选优先级的量在决策时已知且可比较 | 检查排序键是否只依赖已确定的信息 |
前两条是正确性判据,后两条是可行性判据。贪心算法的全部理论难点在于证明第一条成立;后两条在实践中往往更容易检查,但一旦不满足,算法同样会给出错误答案。
需要强调的是,贪心选择性质要求的是「存在一个最优解包含贪心选择」,而不是「所有最优解都包含贪心选择」。后者是更强的条件,很多问题不满足前者之外的情况也足以支撑贪心算法。
1.3 与其它算法范式的关系
| 贪心 | 动态规划 | 分治 | 回溯 | |
|---|---|---|---|---|
| 分解方式 | 每步选定一个候选并固定 | 拆成互相依赖的状态 | 拆成互相独立的子问题 | 在解空间树上逐层分支 |
| 子问题是否重叠 | 不适用 | 是 | 否 | 是,但不记录结果 |
| 中间记录什么 | 不记录 | 状态表 | 递归返回值 | 当前路径与标记 |
| 正确性依据 | 贪心选择性质与最优子结构 | 最优子结构与无后效性 | 归纳法 | 解空间的完备枚举 |
| 典型复杂度 | 多为 O(n log n) |
多为 O(n²) 及以上 |
依问题而定 | 指数级 |
- 贪心与动态规划:两者都以最优子结构为前提。区别在于动态规划的状态转移方程需要保留全部候选项,而贪心算法把候选项化简为唯一一个选择。相关讨论见第九章 9.2 节。
- 贪心与分治:分治把问题拆成互不重叠的子问题,每个子问题独立求解后再合并;贪心面对的是重叠的候选结构,一旦选定就排除了其余分支,因此必须额外证明被排除的分支不可能更优。
- 贪心与回溯:回溯枚举全部可行解,遇到矛盾时回退到上一个决策点;贪心不作回退,用正确性论证代替枚举。二者的取舍是「用证明换时间」。
二、问题形式化
2.1 输入、输出与约束
把一个具体问题抽象成统一的形式:
- 输入:数据
a[1..n]、参数C以及一组约束条件; - 输出:度量值
opt的最大值(或最小值),以及达到该最优值的一个方案; - 约束:判定「方案是否可行」的一组条件。
以活动选择问题为例:
- 输入:
n个活动,活动i的开始时间为s[i],结束时间为f[i]; - 约束:所选活动中任意两个在时间上不重叠。用半开区间
[s, f)表述即f[i] ≤ s[j]或f[j] ≤ s[i]; - 输出:所选活动数量的最大值。
这一形式化描述直接指出了贪心选择的构造方向:若按结束时间排序后逐个考察,则某个候选能否选入,只取决于它与上一个已选活动是否冲突。这是一个局部可判定的条件,不需要对后续活动做前瞻。
2.2 贪心选择性质
定义 2.1(贪心选择性质):设问题的候选集合为 C,最优解集合为 OPT。若存在 e ∈ C 使得 OPT 中存在某个元素包含 e,且去掉 e 之后的部分是规模更小的同类问题的最优解,则称该问题具有关于 e 的贪心选择性质。
等价且更便于使用的表述是:
存在一个最优解
S*,使得e ∈ S*;若e ∉ S*,则可以找到f ∈ S* \ {e},用e替换f之后仍得到一个可行解,且目标值不劣于S*。
这个替换过程就是交换论证(详见 3.3 节),它把「最优解中是否存在 e」这一存在性命题,转化为可逐步执行的构造。
例 2.1(活动选择):设 e 是结束时间最早的活动。证明存在一个最优解包含 e。
设 OPT 是任意一个最优解。
- 若
e ∈ OPT,结论成立。 - 若
e ∉ OPT:取OPT中结束时间最早的活动g。由于e的结束时间不晚于g的结束时间,而OPT中除g外的所有活动都开始于g的结束时间之后,它们也都开始于e的结束时间之后,因此与e不冲突。令OPT' = (OPT \ {g}) ∪ {e},则OPT'仍然可行,且活动数量与OPT相同,故OPT'也是最优解且包含e。∎
2.3 最优子结构
定义 2.2(最优子结构):若问题的最优解可以分解为「若干子问题的最优解 + 本次决策的代价」,并且这些子问题与原问题属于同一类问题(仅有规模上的差别),则称该问题具有最优子结构。
「属于同一类问题」这一限定是关键:只有规模变小的子问题才能递归地交还给同一个贪心算法处理。若分解出的子问题是另一类问题,原算法便无法复用。
例 2.2(活动选择):选入活动 e 之后,剩余问题仍然是活动选择问题,只是候选集合缩小了,且新增约束「所选活动必须在 e 的结束时间之后开始」。因此活动选择问题具备最优子结构。
2.4 贪心与动态规划的界限
最优子结构是贪心算法与动态规划的共同前提,二者的分界线在于状态转移方程的候选项能否化简为唯一一个选择。
| 问题性质 | 适用算法 | 原因 |
|---|---|---|
| 具备最优子结构,且具备贪心选择性质 | 贪心 | 候选项可化简为唯一选择,状态表退化为单一路径 |
| 具备最优子结构,但不具备贪心选择性质 | 动态规划 | 必须保留全部候选项并逐一比较 |
| 不具备最优子结构 | 分治或回溯 | 无法拆成同类子问题递归求解 |
例 2.3(0-1 背包):0-1 背包具备最优子结构——取出若干物品后,剩下的仍是 0-1 背包,这正是它可以用动态规划求解的原因。但它不具备贪心选择性质——按价值密度选取的物品不在任何最优解中(见 5.2 节的反例)。这两个性质的分离正是判断该用哪种算法的关键。
三、核心思想
3.1 从求解过程到贪心选择
回溯与动态规划的通用求解框架可以写成如下形式:
solve(参数):
if 参数已是终止状态: return 取值
答案 = 不可行
for 每个候选 in 候选集合(参数):
子答案 = solve(参数 + 候选)
答案 = 取最优(答案, 子答案 + 代价(候选))
return 答案
贪心算法把这段框架改写为:
solve(参数):
if 参数已是终止状态: return 取值
候选 = 选一个(候选集合(参数))
return solve(参数 + 候选) + 代价(候选)
改动集中在两处:
for循环变成单次选取,候选不再逐个尝试;答案 = 取最优(...)的合并步骤被删除,单条路径上没有可比较的对象。
代价的转移依然保留,这是贪心算法能够递归求解的前提。删掉比较步骤之后,算法必须回答两个问题:
- 选哪个候选? —— 由排序依据决定,这部分属于算法设计。
- 为什么这个选择不会排除最优解? —— 由正确性论证回答,这部分属于算法分析。
贪心算法的全部工程量集中在第一问,全部理论量集中在第二问。
3.2 贪心选择的三种构造方式
| 构造方式 | 排序依据 | 数据结构 | 典型问题 |
|---|---|---|---|
| 静态排序 + 线性扫描 | 一个可预先算出的标量 | 数组 + 排序 | 活动选择、分数背包、区间合并 |
| 动态优先级 | 随状态变化而更新 | 小顶堆 / 优先队列 | 任务调度、Dijkstra 最短路 |
| 结构化选择 | 由问题结构直接给出 | 堆 / 并查集 | 霍夫曼编码、Kruskal 最小生成树 |
静态排序的排序键必须在决策时即可算出,且与后续选择无关。价值密度、结束时间、区间长度都属于这一类。
动态优先级的排序键依赖当前状态:任务调度中「淘汰哪个任务」取决于当前堆中已有任务的收益分布;Dijkstra 中「下一个扩展哪个顶点」取决于当前已确定的距离。这类问题需要支持「取出最小」与「插入新元素」两种操作,通常由小顶堆实现。
结构化选择不依赖显式排序,依据问题本身的结构直接给出候选:霍夫曼编码每次合并权值最小的两个节点;Kruskal 每次选择连接两个不同连通分量的最小权边。
3.3 交换论证法
交换论证法的标准步骤如下:
- 令
G为贪心算法在当前状态下的选择,S*为一个最优解; - 若
G ∈ S*,转入步骤 3;否则在S*中找到元素X,使得(S* \ {X}) ∪ {G}仍然可行且目标值不劣; - 在
(S* \ {X}) ∪ {G}上继续讨论,对剩余问题重复步骤 1。
第 2 步是全部难点所在:必须具体指出 X 是谁,并证明替换后仍然可行。常用的选取策略有三种:
- 取
S*中与G冲突最多的元素(活动选择中取结束时间最早的活动); - 取
S*中代价最小的元素(任务调度中取收益最小的任务); - 取
S*中被G的选择直接挤占的那个元素(分数背包中取被密度更高的物品占去容量的物品)。
3.4 贪心与动态规划的对照
把动态规划的状态转移方程写成如下形式:
dp[i] = 取最优( dp[i-1] + c1, dp[i-1] + c2, ..., dp[i-1] + ck )
若所有候选项都指向同一个前驱状态 dp[i-1],且增量满足恒成立的不等式 c1 ≥ c2 ≥ ... ≥ ck,则 dp[i] = dp[i-1] + c1,动态规划退化为贪心。
这个「恒成立」正是贪心选择性质的数学表述:不是「这一次比较恰好 c1 最大」,而是「无论前驱状态是什么,c1 都是最大的」。
| 动态规划 | 贪心 | |
|---|---|---|
| 候选项 | 全部保留 | 只保留一个 |
| 比较准则 | 依赖前驱状态 | 与前驱无关的固定优先级 |
| 记录内容 | 状态表 | 无 |
| 正确性来源 | 状态转移方程的正确性 | 贪心选择性质与最优子结构 |
| 典型代价 | 状态数 × 转移数 | 排序或堆操作的总代价 |
左图是贪心算法的决策路径,每层只保留优先级最高的候选,其余分支被直接排除;右图是动态规划的状态表,每个候选的结果都写入表中,后续步骤直接引用。
四、算法流程
4.1 变量约定
| 符号 | 含义 |
|---|---|
n |
输入数据的规模 |
C |
资源总量(容量、时间槽数量等) |
opt |
最优值 |
cand |
当前状态下的候选集合 |
key(x) |
候选 x 的优先级数值,数值越小越优先 |
sel |
已选集合 |
4.2 通用模板
模板 A:排序 + 线性扫描
输入:问题数据、参数
输出:最优值,以及可选的最优方案
1. 确定候选集合,以及决定候选优先级的排序依据
2. 按优先级顺序排列候选
3. 依次考察每个候选:
若与已作出的选择冲突,跳过
否则将其选入,并更新已用资源
4. 输出最优值;若需要方案,同时记录选中的候选
模板 B:堆 + 动态更新
输入:问题数据、参数
输出:最优值,以及可选的最优方案
1. 确定候选集合与优先级依据
2. 初始化优先队列
3. 循环,直到队列为空或已选数量达到上界:
取出优先级最高的候选
若与已作出的选择冲突,跳过
否则选入,并把因本次决策而新出现的候选加入队列
4. 输出最优值
模板 C:排序 + 并查集
输入:问题数据
输出:最优值,以及最优方案
1. 按权重升序排列所有边
2. 初始化并查集,每个顶点自成一个连通分量
3. 依次考察每条边:
若两端已在同一连通分量中,跳过
否则选入该边,并合并两端所在的连通分量
4. 输出最优值与选中的边
4.3 流程图
4.4 三种典型实现形态
| 形态 | 数据结构 | 排序依据 | 典型问题 | 时间复杂度 |
|---|---|---|---|---|
| 排序 + 扫描 | 数组 + 排序 | 静态标量 | 活动选择、分数背包 | O(n log n) |
| 堆 + 动态更新 | 小顶堆 | 随状态变化 | 任务调度、Dijkstra | O((n + m) log n) |
| 排序 + 并查集 | 排序 + 并查集 | 边权 | Kruskal 最小生成树 | O(m log m) |
三种形态的共同点是:排序(或堆维护)之后必然跟一次线性扫描,扫描过程中不再回头修改已作出的选择。
五、演算示例
5.1 示例一:活动选择问题
问题描述:给定 n 个活动,每个活动有开始时间与结束时间,要求选择尽可能多的互不冲突的活动。
活动按编号排列如下(编号从 1 开始):
| 编号 | 开始 | 结束 | 编号 | 开始 | 结束 |
|---|---|---|---|---|---|
| 1 | 1 | 3 | 7 | 6 | 10 |
| 2 | 3 | 4 | 8 | 8 | 11 |
| 3 | 0 | 6 | 9 | 8 | 12 |
| 4 | 5 | 7 | 10 | 2 | 14 |
| 5 | 3 | 9 | 11 | 12 | 16 |
| 6 | 5 | 9 |
贪心策略:按结束时间从小到大排序,每次选取不与已选活动冲突的第一个活动。
左图是活动条形图,绿色为贪心选中的活动,灰色为因与已选活动冲突而跳过的活动;右图逐行给出了按结束时间升序扫描时的判定依据。
逐步判定(半开区间 [s, f),因此起点等于上一个活动的结束时间时不算冲突):
| 步骤 | 考察活动 | 区间 | 结束时间 | 判定 | 依据 |
|---|---|---|---|---|---|
| 1 | 1 | [1, 3) | 3 | 选入 | 首个活动,无约束 |
| 2 | 2 | [3, 4) | 4 | 选入 | 起点 3 不早于上一个结束时间 3 |
| 3 | 3 | [0, 6) | 6 | 跳过 | 起点 0 早于上一个结束时间 4 |
| 4 | 4 | [5, 7) | 7 | 选入 | 起点 5 不早于上一个结束时间 4 |
| 5 | 5 | [3, 9) | 9 | 跳过 | 起点 3 早于上一个结束时间 7 |
| 6 | 6 | [5, 9) | 9 | 跳过 | 起点 5 早于上一个结束时间 7 |
| 7 | 7 | [6, 10) | 10 | 跳过 | 起点 6 早于上一个结束时间 7 |
| 8 | 8 | [8, 11) | 11 | 选入 | 起点 8 不早于上一个结束时间 7 |
| 9 | 9 | [8, 12) | 12 | 跳过 | 起点 8 早于上一个结束时间 11 |
| 10 | 10 | [2, 14) | 14 | 跳过 | 起点 2 早于上一个结束时间 11 |
| 11 | 11 | [12, 16) | 16 | 选入 | 起点 12 不早于上一个结束时间 11 |
最终选入活动 1、2、4、8、11,共 5 个活动。穷举全部 2¹¹ 个子集得到的最优值同样是 5。
正确性要点:贪心选择性质见例 2.1;最优子结构见例 2.2。两者合起来,即构成贪心算法正确性的完整论证。
复杂度:排序 O(n log n),扫描 O(n),总计 O(n log n);空间 O(n)。
5.2 示例二:分数背包与 0-1 背包
这一组示例的价值在于:同一组数据下,三种算法的结果各不相同,恰好暴露出贪心算法的适用边界。
问题描述:背包容量 C = 5,有四件物品:
| 编号 | 重量 | 价值 | 价值密度 |
|---|---|---|---|
| 1 | 3 | 4 | 4/3 ≈ 1.333 |
| 2 | 2 | 3 | 3/2 = 1.5 |
| 3 | 2 | 3 | 3/2 = 1.5 |
| 4 | 4 | 5 | 5/4 = 1.25 |
情形一:分数背包(物品可分割)。贪心策略:按价值密度降序装入,容量不足时取该物品的一部分。
装入过程:物品 2 全部装入(重量 2,价值 3),剩余容量 3;物品 3 全部装入(重量 2,价值 3),剩余容量 1;物品 1 需重量 3,剩余容量不足,取其中 1 单位,价值 1 × 4/3;物品 4 需重量 4,剩余容量为 0,不再装入。
总价值 = 3 + 3 + 4/3 = 22/3 ≈ 7.333333。
正确性要点:设最优解中存在密度低于 4/3 的物品,同时还有未装入的高密度物品,则把前者的一部分换成后者的一部分可以在不增加重量的前提下提高价值——这与最优性矛盾。密度相同的两件物品(物品 2 与物品 3)无论先取哪件都不影响结果。
情形二:0-1 背包(物品不可分割)。同样的贪心策略——按价值密度降序、装得下就装——给出:
- 物品 2 装入,剩余容量 3;
- 物品 3 装入,剩余容量 1;
- 物品 1 需重量 3,剩余容量 1,装不下,跳过;
- 物品 4 需重量 4,剩余容量 1,装不下,跳过。
总价值 = 3 + 3 = 6。
而用动态规划求解同一问题,最优解是物品 1 与物品 2,总重量 3 + 2 = 5,总价值 4 + 3 = 7。贪心解比最优解差 1。
情形三:两种解法的对照:
左图是贪心装入过程,价值密度 3/2 的两件物品先被装入,剩余容量只剩 1,价值 4 的物品装不进去,总价值为 6;右图是动态规划的二维状态表,最优解为物品 1 与物品 2,总价值为 7。
失效原因分析:贪心解选中的物品 2 与物品 3 占满了容量,物品 1 价值更高却因装不下而被排除。把物品 2 换成物品 1 需要额外 1 单位重量,无法通过调整顺序解决——问题的关键在于物品不可拆分这一约束使得「高密度」与「高价值」不再等价。
结论:0-1 背包具备最优子结构(因此可以用动态规划),但不具备贪心选择性质(因此不能用贪心)。分数背包的物品可拆分这一条件,恰好保证了密度准则的正确性。
5.3 示例三:任务调度
问题描述:n 个任务,每个任务耗时 1 单位时间,各有截止时间 d[i] 与收益 p[i],在单机上执行,任务必须在截止时间之前完成。目标是使按时完成的任务的总收益最大。
| 编号 | 截止时间 | 收益 | 编号 | 截止时间 | 收益 |
|---|---|---|---|---|---|
| 1 | 2 | 100 | 4 | 1 | 25 |
| 2 | 1 | 19 | 5 | 3 | 15 |
| 3 | 2 | 27 | 6 | 1 | 22 |
贪心策略:按截止时间升序处理任务;每处理一个任务就把它加入已选集合,若已选任务数超过当前任务的截止时间,则淘汰收益最小的那个任务。
左图是最终的时间槽安排,任务 5、1、3 分别占据槽 3、2、1,总收益 142;右图逐行记录了小顶堆中剩余的收益与被淘汰的任务。
逐步判定(表中「堆中剩余收益」是本步骤处理完毕后的堆内容):
| 步骤 | 处理任务 | 截止时间 | 收益 | 堆中剩余收益 | 淘汰 | 已选数量 |
|---|---|---|---|---|---|---|
| 1 | 2 | 1 | 19 | 19 | 无 | 1 |
| 2 | 4 | 1 | 25 | 25 | 任务 2,收益 19 | 1 |
| 3 | 6 | 1 | 22 | 25 | 任务 6,收益 22 | 1 |
| 4 | 1 | 2 | 100 | 25、100 | 无 | 2 |
| 5 | 3 | 2 | 27 | 27、100 | 任务 4,收益 25 | 2 |
| 6 | 5 | 3 | 15 | 15、27、100 | 无 | 3 |
最终选入任务 1、3、5,总收益 100 + 27 + 15 = 142。
时间槽分配:把选中的任务按截止时间降序处理,依次占据不超过其截止时间的最靠后空槽。任务 5(截止时间 3)占据槽 3,任务 1(截止时间 2)占据槽 2,任务 3(截止时间 2)占据槽 1:
| 时间槽 | 1 | 2 | 3 |
|---|---|---|---|
| 执行的任务 | 任务 3 | 任务 1 | 任务 5 |
三个任务均在其截止时间之内完成。用动态规划求解同一问题,最优收益同样是 142。
正确性要点:
- 贪心选择性质:按截止时间升序处理时,当前任务
i与已选任务中收益最小的那个任务j二选一。由交换论证,取i舍j不劣——因为i的截止时间不早于j的截止时间,把j换成i不会使任何其它任务超出截止时间限制,而p[i] ≥ p[j]保证收益不降。 - 最优子结构:淘汰一个任务后,剩余问题仍是同类任务调度问题。
另一种实现:按收益降序处理任务,用并查集查找「不超过截止时间的最靠后空槽」,若不存在空槽则放弃该任务。该实现与上述堆实现等价,时间复杂度同为 O(n log n)。
复杂度:排序 O(n log n),每个任务入堆、出堆各一次共 O(n log n),总计 O(n log n);空间 O(n)。
5.4 示例四:最小生成树
问题描述:给定带权无向图,求一棵总权重最小的生成树。
| 边 | 端点 | 权重 | 边 | 端点 | 权重 |
|---|---|---|---|---|---|
| e1 | v0–v1 | 4 | e6 | v2–v4 | 2 |
| e2 | v0–v2 | 3 | e7 | v3–v4 | 3 |
| e3 | v1–v2 | 1 | e8 | v3–v5 | 2 |
| e4 | v1–v3 | 2 | e9 | v4–v5 | 3 |
| e5 | v2–v3 | 4 |
Kruskal 算法:按权重升序考察每条边,若该边两端已在同一连通分量中则跳过,否则选入并合并两个分量。
选边过程:e3(权重 1)→ e4(权重 2)→ e6(权重 2)→ e8(权重 2)→ e2(权重 3)。此时已选 5 条边,恰好连接 6 个顶点,总权重 1 + 2 + 2 + 2 + 3 = 10。其余各边都会形成环,被跳过。
Prim 算法:从任一顶点出发,每次选取连接「已选顶点集合」与「未选顶点集合」的最小权边。用优先队列维护候选边,结果同样是总权重 10。
左图是带权无向图,绿色为选中的生成树边,灰色为未选边,总权重 10;右图是 Kruskal 按权重升序取边的过程,被拒绝的边两端已在同一连通分量中。
正确性要点:最小生成树满足割性质——对任意一个把顶点集合划分为两部分的割,横跨该割的最小权边必定属于某棵最小生成树。Kruskal 选出的每条边都是当时某个割上的最小权边(该割由边的两个端点所在的连通分量划分而成),因此由割性质可知所得生成树最优。
复杂度:Kruskal 为 O(m log m)(排序主导),并查集操作总计 O(m α(n));Prim 用堆实现为 O((n + m) log n)。空间均为 O(n + m)。
六、复杂度分析
6.1 时间复杂度
贪心算法的时间开销由两部分构成:
| 组成部分 | 常见实现 | 复杂度 |
|---|---|---|
| 候选排序 | 比较排序 | O(n log n) |
| 候选维护 | 二叉堆 | 单次插入或删除 O(log n),共 O(n log n) |
| 连通性判定 | 并查集 | 单次 O(α(n)),共 O(m α(n)) |
| 线性扫描 | 直接遍历 | O(n) |
由于排序或堆操作的存在,贪心算法的复杂度下限通常是 O(n log n)(除非问题结构允许线性选择,例如用桶排序的计数排序式贪心)。
6.2 空间复杂度
贪心算法通常不需要状态表,这是它相对动态规划的主要优势。
| 实现形态 | 额外空间 |
|---|---|
| 排序 + 扫描 | O(n),用于存储排序后的候选 |
| 堆 + 动态更新 | O(n),用于存储堆 |
| 排序 + 并查集 | O(n + m),用于存储排序后的边与并查集结构 |
6.3 与动态规划复杂度的对比
| 问题 | 贪心 | 动态规划 | 差距来源 |
|---|---|---|---|
| 活动选择 | O(n log n) |
O(n²) |
动态规划按结束时间逐个枚举全部可行活动 |
| 任务调度 | O(n log n) |
O(n·D) |
动态规划需要「任务数 × 时间槽数」的状态表 |
| 0-1 背包 | O(n log n)(结果错误) |
O(nC) |
动态规划保留全部候选组合 |
| 最小生成树 | O(m log m) |
O(n²) |
动态规划需枚举所有顶点集合 |
差距的本质在于:动态规划要为每个中间状态保留一个数值,贪心算法只需保留一条路径。当状态数远大于路径数时,差距极为显著。
七、参考实现
以下实现与第五章的演算示例一一对应。三份实现的代码末尾均以注释给出对应示例数据的运行结果,三者数值一致。
7.1 Python
import heapq
from fractions import Fraction
# ---------- 示例一:活动选择问题 ----------
def activity_order(activities):
"""按结束时间升序排列活动下标,结束时间相同时按开始时间再按下标排序。"""
return sorted(range(len(activities)),
key=lambda t: (activities[t][1], activities[t][0], t))
def activity_greedy(activities):
"""贪心选择:按结束时间升序扫描,能接在已选活动之后就选入。"""
picked = []
last_end = None
for t in activity_order(activities):
start, end = activities[t]
if last_end is None or start >= last_end:
picked.append(t)
last_end = end
return picked
def activity_bruteforce(activities):
"""穷举全部子集,返回最多能安排的活动数,用于核对贪心解。"""
n = len(activities)
best = 0
for mask in range(1 << n):
chosen = [t for t in range(n) if mask >> t & 1]
chosen.sort(key=lambda t: activities[t][1])
ok = True
for k in range(1, len(chosen)):
if activities[chosen[k]][0] < activities[chosen[k - 1]][1]:
ok = False
break
if ok and len(chosen) > best:
best = len(chosen)
return best
# ---------- 示例二:背包 ----------
def density_order(weights, values):
"""按价值密度降序排列下标;用分数比较避免浮点误差,同密度按下标升序。"""
return sorted(range(len(weights)),
key=lambda t: (-Fraction(values[t], weights[t]), t))
def fractional_knapsack(weights, values, capacity):
"""分数背包:按价值密度降序装入,允许取物品的一部分。"""
order = density_order(weights, values)
remaining = Fraction(capacity)
total = Fraction(0)
for t in order:
if remaining <= 0:
break
take = min(Fraction(weights[t]), remaining)
total += take * Fraction(values[t], weights[t])
remaining -= take
return total
def greedy_01_knapsack(weights, values, capacity):
"""0-1 背包的贪心解:按价值密度降序,能装下就装,不能拆分。"""
picked = []
remaining = capacity
for t in density_order(weights, values):
if weights[t] <= remaining:
picked.append(t)
remaining -= weights[t]
return picked
def dp_01_knapsack(weights, values, capacity):
"""0-1 背包的二维状态表,用于与贪心解对照。"""
n = len(weights)
dp = [[0] * (capacity + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
w, v = weights[i - 1], values[i - 1]
for j in range(capacity + 1):
dp[i][j] = dp[i - 1][j]
if j >= w:
dp[i][j] = max(dp[i][j], dp[i - 1][j - w] + v)
return dp
def dp_01_knapsack_pick(dp, weights, capacity):
"""从二维状态表回溯出所选物品。"""
picked = []
i, j = len(weights), capacity
while i > 0:
if dp[i][j] > dp[i - 1][j]:
picked.append(i - 1)
j -= weights[i - 1]
i -= 1
picked.reverse()
return picked
# ---------- 示例三:任务调度 ----------
def schedule_greedy(deadlines, profits):
"""任务调度贪心:按截止时间升序处理,超出容量时淘汰收益最小的任务。"""
order = sorted(range(len(deadlines)), key=lambda t: (deadlines[t], t))
heap = []
selected = set()
for t in order:
heapq.heappush(heap, (profits[t], t))
selected.add(t)
if len(selected) > deadlines[t]:
_, drop = heapq.heappop(heap)
selected.discard(drop)
return sorted(selected)
def schedule_slots(deadlines, selected):
"""给选中的任务分配时间槽:占据不超过其截止时间的最靠后空槽。"""
max_d = max(deadlines)
slots = [None] * (max_d + 1)
for t in sorted(selected, key=lambda x: (-deadlines[x], x)):
s = deadlines[t]
while s > 0 and slots[s] is not None:
s -= 1
if s > 0:
slots[s] = t
return slots
def schedule_dp(deadlines, profits):
"""任务调度的动态规划解,用于与贪心解对照。
dp[i][j] 表示只考虑按截止时间排序后的前 i 个任务、时间槽不超过 j 时的最大收益。
"""
order = sorted(range(len(deadlines)), key=lambda t: (deadlines[t], t))
n = len(order)
max_d = max(deadlines)
dp = [[0] * (max_d + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
t = order[i - 1]
d = deadlines[t]
p = profits[t]
for j in range(1, max_d + 1):
dp[i][j] = max(dp[i - 1][j], dp[i - 1][min(j, d) - 1] + p)
return dp[n][max_d]
# ---------- 示例四:最小生成树 ----------
def kruskal(vertex_count, edges):
"""Kruskal 最小生成树:按权重升序取边,用并查集判定是否成环。
edges 中每个元素为 (起点, 终点, 权重)。
"""
parent = list(range(vertex_count))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
order = sorted(range(len(edges)),
key=lambda t: (edges[t][2], edges[t][0], edges[t][1]))
picked = []
total = 0
for t in order:
u, v, w = edges[t]
ru, rv = find(u), find(v)
if ru == rv:
continue
parent[ru] = rv
picked.append(t)
total += w
return total, picked
def prim(vertex_count, edges):
"""Prim 最小生成树:优先队列实现,每次取连接当前集合与外部的最小权边。"""
adjacency = [[] for _ in range(vertex_count)]
for t, (u, v, w) in enumerate(edges):
adjacency[u].append((v, w, t))
adjacency[v].append((u, w, t))
visited = [False] * vertex_count
visited[0] = True
heap = [(w, v, t) for v, w, t in adjacency[0]]
heapq.heapify(heap)
picked = []
total = 0
while heap and len(picked) < vertex_count - 1:
w, v, t = heapq.heappop(heap)
if visited[v]:
continue
visited[v] = True
picked.append(t)
total += w
for nxt, nw, nxt_edge in adjacency[v]:
if not visited[nxt]:
heapq.heappush(heap, (nw, nxt, nxt_edge))
return total, picked
# ---------- 主程序 ----------
ACTIVITIES = [(1, 3), (3, 4), (0, 6), (5, 7), (3, 9), (5, 9),
(6, 10), (8, 11), (8, 12), (2, 14), (12, 16)]
WEIGHTS = [3, 2, 2, 4]
VALUES = [4, 3, 3, 5]
CAPACITY = 5
DEADLINES = [2, 1, 2, 1, 3, 1]
PROFITS = [100, 19, 27, 25, 15, 22]
MST_EDGES = [(0, 1, 4), (0, 2, 3), (1, 2, 1), (1, 3, 2), (2, 3, 4),
(2, 4, 2), (3, 4, 3), (3, 5, 2), (4, 5, 3)]
print("活动选择 =", activity_greedy(ACTIVITIES),
" 穷举 =", activity_bruteforce(ACTIVITIES))
greedy_picks = greedy_01_knapsack(WEIGHTS, VALUES, CAPACITY)
print("背包 贪心 =", greedy_picks,
" 值 =", sum(VALUES[t] for t in greedy_picks))
dp = dp_01_knapsack(WEIGHTS, VALUES, CAPACITY)
print("背包 DP 值 =", dp[len(WEIGHTS)][CAPACITY],
" 选中 =", dp_01_knapsack_pick(dp, WEIGHTS, CAPACITY))
print("分数背包 =", fractional_knapsack(WEIGHTS, VALUES, CAPACITY))
selected = schedule_greedy(DEADLINES, PROFITS)
print("任务调度 =", selected,
" 收益 =", sum(PROFITS[t] for t in selected),
" 时间槽 =", schedule_slots(DEADLINES, selected))
print("任务调度 DP =", schedule_dp(DEADLINES, PROFITS))
print("Kruskal =", kruskal(6, MST_EDGES))
print("Prim =", prim(6, MST_EDGES))
# 运行结果:
# 活动选择 = [0, 1, 3, 7, 10] 穷举 = 5
# 背包 贪心 = [1, 2] 值 = 6
# 背包 DP 值 = 7 选中 = [0, 1]
# 分数背包 = 22/3
# 任务调度 = [0, 2, 4] 收益 = 142 时间槽 = [None, 2, 0, 4]
# 任务调度 DP = 142
# Kruskal = (10, [2, 3, 5, 7, 1])
# Prim = (10, [1, 2, 3, 5, 7])
#
# 结果解读:
# 1. 活动选择:贪心选中活动 1、2、4、8、11 共 5 个,与穷举得到的最优值 5 相同。
# 2. 0-1 背包:贪心选中物品 2、3,价值 6;动态规划最优价值 7,选中物品 1、2。
# 贪心解小于最优解,说明 0-1 背包不具备贪心选择性质,见 2.4 节的例 2.3。
# 3. 分数背包:允许拆分后最优值 22/3 高于 0-1 背包的最优值 7,
# 差距正来源于「物品可拆分」这一条件。
# 4. 任务调度:贪心选中任务 1、3、5,收益 142,与动态规划最优值 142 相同;
# 时间槽分配为槽 1 任务 3、槽 2 任务 1、槽 3 任务 5。
# 5. 最小生成树:Kruskal 与 Prim 的总权重同为 10,
# 本例中两者选中的边集合相同,仅取用顺序不同。
#
# 以上下标均从 0 开始,与代码中的下标一致;活动、物品、任务的编号在正文中从 1 开始,
# 两者相差 1。
7.2 Java
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.PriorityQueue;
/**
* 贪心算法参考实现:活动选择、分数背包与 0-1 背包、任务调度、最小生成树。
*/
public class GreedyDemo {
// ---------- 示例一:活动选择问题 ----------
/** 按结束时间升序排列活动下标。 */
public static int[] activityOrder(int[][] activities) {
Integer[] idx = new Integer[activities.length];
for (int i = 0; i < idx.length; i++) {
idx[i] = i;
}
Arrays.sort(idx, Comparator.comparingInt((Integer t) -> activities[t][1])
.thenComparingInt(t -> activities[t][0])
.thenComparingInt(t -> t));
int[] order = new int[idx.length];
for (int i = 0; i < idx.length; i++) {
order[i] = idx[i];
}
return order;
}
/** 贪心选择:按结束时间升序扫描,能接在已选活动之后就选入。 */
public static List<Integer> activityGreedy(int[][] activities) {
List<Integer> picked = new ArrayList<>();
Integer lastEnd = null;
for (int t : activityOrder(activities)) {
int start = activities[t][0];
int end = activities[t][1];
if (lastEnd == null || start >= lastEnd) {
picked.add(t);
lastEnd = end;
}
}
return picked;
}
/** 穷举全部子集,返回最多能安排的活动数,用于核对贪心解。 */
public static int activityBruteforce(int[][] activities) {
int n = activities.length;
int best = 0;
for (int mask = 0; mask < (1 << n); mask++) {
List<Integer> chosen = new ArrayList<>();
for (int t = 0; t < n; t++) {
if ((mask >> t & 1) != 0) {
chosen.add(t);
}
}
chosen.sort(Comparator.comparingInt((Integer t) -> activities[t][1]));
boolean ok = true;
for (int k = 1; k < chosen.size(); k++) {
if (activities[chosen.get(k)][0] < activities[chosen.get(k - 1)][1]) {
ok = false;
break;
}
}
if (ok && chosen.size() > best) {
best = chosen.size();
}
}
return best;
}
// ---------- 示例二:背包 ----------
/** 按价值密度降序排列下标;用交叉相乘比较避免浮点误差,同密度按下标升序。 */
public static int[] densityOrder(int[] weights, int[] values) {
Integer[] idx = new Integer[weights.length];
for (int i = 0; i < idx.length; i++) {
idx[i] = i;
}
Arrays.sort(idx, (x, y) -> {
long left = (long) values[x] * weights[y];
long right = (long) values[y] * weights[x];
if (left != right) {
return left > right ? -1 : 1;
}
return Integer.compare(x, y);
});
int[] order = new int[idx.length];
for (int i = 0; i < idx.length; i++) {
order[i] = idx[i];
}
return order;
}
/** 分数背包:按价值密度降序装入,允许取物品的一部分。 */
public static double fractionalKnapsack(int[] weights, int[] values, int capacity) {
double remaining = capacity;
double total = 0;
for (int t : densityOrder(weights, values)) {
if (remaining <= 0) {
break;
}
double take = Math.min(weights[t], remaining);
total += take * values[t] / weights[t];
remaining -= take;
}
return total;
}
/** 0-1 背包的贪心解:按价值密度降序,能装下就装,不能拆分。 */
public static List<Integer> greedy01Knapsack(int[] weights, int[] values, int capacity) {
List<Integer> picked = new ArrayList<>();
int remaining = capacity;
for (int t : densityOrder(weights, values)) {
if (weights[t] <= remaining) {
picked.add(t);
remaining -= weights[t];
}
}
return picked;
}
/** 0-1 背包的二维状态表。 */
public static int[][] dp01Knapsack(int[] weights, int[] values, int capacity) {
int n = weights.length;
int[][] dp = new int[n + 1][capacity + 1];
for (int i = 1; i <= n; i++) {
int w = weights[i - 1];
int v = values[i - 1];
for (int j = 0; j <= capacity; j++) {
dp[i][j] = dp[i - 1][j];
if (j >= w) {
dp[i][j] = Math.max(dp[i][j], dp[i - 1][j - w] + v);
}
}
}
return dp;
}
/** 从二维状态表回溯出所选物品。 */
public static List<Integer> dp01KnapsackPick(int[][] dp, int[] weights, int capacity) {
List<Integer> picked = new ArrayList<>();
int i = weights.length;
int j = capacity;
while (i > 0) {
if (dp[i][j] > dp[i - 1][j]) {
picked.add(i - 1);
j -= weights[i - 1];
}
i--;
}
Collections.reverse(picked);
return picked;
}
// ---------- 示例三:任务调度 ----------
/** 任务调度贪心:按截止时间升序处理,超出容量时淘汰收益最小的任务。 */
public static List<Integer> scheduleGreedy(int[] deadlines, int[] profits) {
Integer[] order = new Integer[deadlines.length];
for (int i = 0; i < order.length; i++) {
order[i] = i;
}
Arrays.sort(order, Comparator.comparingInt((Integer t) -> deadlines[t])
.thenComparingInt(t -> t));
PriorityQueue<int[]> heap = new PriorityQueue<int[]>(
Comparator.comparingInt((int[] pair) -> pair[0])
.thenComparingInt(pair -> pair[1]));
boolean[] selected = new boolean[deadlines.length];
int count = 0;
for (int t : order) {
heap.add(new int[] {profits[t], t});
selected[t] = true;
count++;
if (count > deadlines[t]) {
int[] top = heap.poll();
selected[top[1]] = false;
count--;
}
}
List<Integer> result = new ArrayList<>();
for (int t = 0; t < selected.length; t++) {
if (selected[t]) {
result.add(t);
}
}
return result;
}
/** 给选中的任务分配时间槽:占据不超过其截止时间的最靠后空槽。 */
public static int[] scheduleSlots(int[] deadlines, List<Integer> selected) {
int maxDeadline = 0;
for (int d : deadlines) {
maxDeadline = Math.max(maxDeadline, d);
}
int[] slots = new int[maxDeadline + 1];
Arrays.fill(slots, -1);
List<Integer> byDeadlineDesc = new ArrayList<>(selected);
byDeadlineDesc.sort(Comparator.comparingInt((Integer t) -> -deadlines[t])
.thenComparingInt(t -> t));
for (int t : byDeadlineDesc) {
int s = deadlines[t];
while (s > 0 && slots[s] != -1) {
s--;
}
if (s > 0) {
slots[s] = t;
}
}
return slots;
}
/** 任务调度的动态规划解。 */
public static int scheduleDp(int[] deadlines, int[] profits) {
Integer[] order = new Integer[deadlines.length];
for (int i = 0; i < order.length; i++) {
order[i] = i;
}
Arrays.sort(order, Comparator.comparingInt((Integer t) -> deadlines[t])
.thenComparingInt(t -> t));
int n = order.length;
int maxDeadline = 0;
for (int d : deadlines) {
maxDeadline = Math.max(maxDeadline, d);
}
int[][] dp = new int[n + 1][maxDeadline + 1];
for (int i = 1; i <= n; i++) {
int t = order[i - 1];
int d = deadlines[t];
int p = profits[t];
for (int j = 1; j <= maxDeadline; j++) {
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][Math.min(j, d) - 1] + p);
}
}
return dp[n][maxDeadline];
}
// ---------- 示例四:最小生成树 ----------
/** 最小生成树的结果:总权重与选中的边下标。 */
public static class MstResult {
public final int total;
public final List<Integer> edges;
MstResult(int total, List<Integer> edges) {
this.total = total;
this.edges = edges;
}
}
/** 并查集:查找代表元并压缩路径。 */
private static int findRoot(int[] parent, int x) {
while (parent[x] != x) {
parent[x] = parent[parent[x]];
x = parent[x];
}
return x;
}
/** Kruskal 最小生成树:按权重升序取边,用并查集判定是否成环。 */
public static MstResult kruskal(int vertexCount, int[][] edges) {
Integer[] order = new Integer[edges.length];
for (int i = 0; i < order.length; i++) {
order[i] = i;
}
Arrays.sort(order, Comparator.comparingInt((Integer t) -> edges[t][2])
.thenComparingInt(t -> edges[t][0])
.thenComparingInt(t -> edges[t][1]));
int[] parent = new int[vertexCount];
for (int i = 0; i < vertexCount; i++) {
parent[i] = i;
}
List<Integer> picked = new ArrayList<>();
int total = 0;
for (int t : order) {
int u = edges[t][0];
int v = edges[t][1];
int w = edges[t][2];
int ru = findRoot(parent, u);
int rv = findRoot(parent, v);
if (ru == rv) {
continue;
}
parent[ru] = rv;
picked.add(t);
total += w;
}
return new MstResult(total, picked);
}
/** Prim 最小生成树:优先队列实现,每次取连接当前集合与外部的最小权边。 */
@SuppressWarnings("unchecked")
public static MstResult prim(int vertexCount, int[][] edges) {
List<int[]>[] adjacency = new List[vertexCount];
for (int i = 0; i < vertexCount; i++) {
adjacency[i] = new ArrayList<int[]>();
}
for (int t = 0; t < edges.length; t++) {
adjacency[edges[t][0]].add(new int[] {edges[t][1], edges[t][2], t});
adjacency[edges[t][1]].add(new int[] {edges[t][0], edges[t][2], t});
}
PriorityQueue<int[]> heap = new PriorityQueue<int[]>(
Comparator.comparingInt((int[] e) -> e[1])
.thenComparingInt(e -> e[0])
.thenComparingInt(e -> e[2]));
boolean[] visited = new boolean[vertexCount];
visited[0] = true;
for (int[] e : adjacency[0]) {
heap.add(e);
}
List<Integer> picked = new ArrayList<>();
int total = 0;
while (!heap.isEmpty() && picked.size() < vertexCount - 1) {
int[] e = heap.poll();
if (visited[e[0]]) {
continue;
}
visited[e[0]] = true;
picked.add(e[2]);
total += e[1];
for (int[] next : adjacency[e[0]]) {
if (!visited[next[0]]) {
heap.add(next);
}
}
}
return new MstResult(total, picked);
}
// ---------- 主程序 ----------
public static void main(String[] args) {
int[][] activities = {{1, 3}, {3, 4}, {0, 6}, {5, 7}, {3, 9}, {5, 9},
{6, 10}, {8, 11}, {8, 12}, {2, 14}, {12, 16}};
System.out.println("活动选择 = " + activityGreedy(activities)
+ " 穷举 = " + activityBruteforce(activities));
int[] weights = {3, 2, 2, 4};
int[] values = {4, 3, 3, 5};
int capacity = 5;
List<Integer> greedyPicks = greedy01Knapsack(weights, values, capacity);
int greedyValue = 0;
for (int t : greedyPicks) {
greedyValue += values[t];
}
System.out.println("背包 贪心 = " + greedyPicks + " 值 = " + greedyValue);
int[][] dp = dp01Knapsack(weights, values, capacity);
System.out.println("背包 DP 值 = " + dp[weights.length][capacity]
+ " 选中 = " + dp01KnapsackPick(dp, weights, capacity));
System.out.printf("分数背包 = %.6f%n", fractionalKnapsack(weights, values, capacity));
int[] deadlines = {2, 1, 2, 1, 3, 1};
int[] profits = {100, 19, 27, 25, 15, 22};
List<Integer> selected = scheduleGreedy(deadlines, profits);
int gain = 0;
for (int t : selected) {
gain += profits[t];
}
System.out.println("任务调度 = " + selected + " 收益 = " + gain
+ " 时间槽 = " + Arrays.toString(scheduleSlots(deadlines, selected)));
System.out.println("任务调度 DP = " + scheduleDp(deadlines, profits));
int[][] edges = {{0, 1, 4}, {0, 2, 3}, {1, 2, 1}, {1, 3, 2}, {2, 3, 4},
{2, 4, 2}, {3, 4, 3}, {3, 5, 2}, {4, 5, 3}};
MstResult kr = kruskal(6, edges);
MstResult pr = prim(6, edges);
System.out.println("Kruskal = " + kr.total + " " + kr.edges);
System.out.println("Prim = " + pr.total + " " + pr.edges);
}
}
/*
* 运行结果(Python 中的分数以 Fraction 精确表示,Java 中按六位小数输出,三者数值一致):
* 活动选择 = [0, 1, 3, 7, 10] 穷举 = 5
* 背包 贪心 = [1, 2] 值 = 6
* 背包 DP 值 = 7 选中 = [0, 1]
* 分数背包 = 7.333333
* 任务调度 = [0, 2, 4] 收益 = 142 时间槽 = [-1, 2, 0, 4]
* 任务调度 DP = 142
* Kruskal = 10 [2, 3, 5, 7, 1]
* Prim = 10 [1, 2, 3, 5, 7]
*
* 结果解读:
* 1. 活动选择:贪心选中活动 1、2、4、8、11 共 5 个,与穷举得到的最优值 5 相同。
* 2. 0-1 背包:贪心选中物品 2、3,价值 6;动态规划最优价值 7,选中物品 1、2。
* 贪心解小于最优解,说明 0-1 背包不具备贪心选择性质,见 2.4 节的例 2.3。
* 3. 分数背包:允许拆分后最优值 22/3 高于 0-1 背包的最优值 7,
* 差距正来源于「物品可拆分」这一条件。
* 4. 任务调度:贪心选中任务 1、3、5,收益 142,与动态规划最优值 142 相同;
* 时间槽分配为槽 1 任务 3、槽 2 任务 1、槽 3 任务 5。
* 5. 最小生成树:Kruskal 与 Prim 的总权重同为 10,
* 本例中两者选中的边集合相同,仅取用顺序不同。
*
* 以上下标均从 0 开始,与代码中的下标一致;活动、物品、任务的编号在正文中从 1 开始,
* 两者相差 1。
*/
7.3 C++
#include <algorithm>
#include <functional>
#include <iostream>
#include <numeric>
#include <queue>
#include <tuple>
#include <utility>
#include <vector>
// ---------- 示例一:活动选择问题 ----------
// 按结束时间升序排列活动下标
std::vector<int> activityOrder(const std::vector<std::pair<int, int>>& activities) {
std::vector<int> order(activities.size());
std::iota(order.begin(), order.end(), 0);
std::sort(order.begin(), order.end(), [&](int x, int y) {
if (activities[x].second != activities[y].second) {
return activities[x].second < activities[y].second;
}
if (activities[x].first != activities[y].first) {
return activities[x].first < activities[y].first;
}
return x < y;
});
return order;
}
// 贪心选择:按结束时间升序扫描,能接在已选活动之后就选入
std::vector<int> activityGreedy(const std::vector<std::pair<int, int>>& activities) {
std::vector<int> picked;
bool hasLast = false;
int lastEnd = 0;
for (int t : activityOrder(activities)) {
int start = activities[t].first;
int end = activities[t].second;
if (!hasLast || start >= lastEnd) {
picked.push_back(t);
lastEnd = end;
hasLast = true;
}
}
return picked;
}
// 穷举全部子集,返回最多能安排的活动数,用于核对贪心解
int activityBruteforce(const std::vector<std::pair<int, int>>& activities) {
int n = static_cast<int>(activities.size());
int best = 0;
for (int mask = 0; mask < (1 << n); ++mask) {
std::vector<int> chosen;
for (int t = 0; t < n; ++t) {
if ((mask >> t & 1) != 0) {
chosen.push_back(t);
}
}
std::sort(chosen.begin(), chosen.end(), [&](int x, int y) {
return activities[x].second < activities[y].second;
});
bool ok = true;
for (int k = 1; k < static_cast<int>(chosen.size()); ++k) {
if (activities[chosen[k]].first < activities[chosen[k - 1]].second) {
ok = false;
break;
}
}
if (ok && static_cast<int>(chosen.size()) > best) {
best = static_cast<int>(chosen.size());
}
}
return best;
}
// ---------- 示例二:背包 ----------
// 按价值密度降序排列下标;用交叉相乘比较避免浮点误差,同密度按下标升序
std::vector<int> densityOrder(const std::vector<int>& weights,
const std::vector<int>& values) {
std::vector<int> order(weights.size());
std::iota(order.begin(), order.end(), 0);
std::sort(order.begin(), order.end(), [&](int x, int y) {
long long left = static_cast<long long>(values[x]) * weights[y];
long long right = static_cast<long long>(values[y]) * weights[x];
if (left != right) {
return left > right;
}
return x < y;
});
return order;
}
// 分数背包:按价值密度降序装入,允许取物品的一部分
double fractionalKnapsack(const std::vector<int>& weights,
const std::vector<int>& values, int capacity) {
double remaining = capacity;
double total = 0;
for (int t : densityOrder(weights, values)) {
if (remaining <= 0) {
break;
}
double take = std::min(static_cast<double>(weights[t]), remaining);
total += take * values[t] / weights[t];
remaining -= take;
}
return total;
}
// 0-1 背包的贪心解:按价值密度降序,能装下就装,不能拆分
std::vector<int> greedy01Knapsack(const std::vector<int>& weights,
const std::vector<int>& values, int capacity) {
std::vector<int> picked;
int remaining = capacity;
for (int t : densityOrder(weights, values)) {
if (weights[t] <= remaining) {
picked.push_back(t);
remaining -= weights[t];
}
}
return picked;
}
// 0-1 背包的二维状态表
std::vector<std::vector<int>> dp01Knapsack(const std::vector<int>& weights,
const std::vector<int>& values,
int capacity) {
int n = static_cast<int>(weights.size());
std::vector<std::vector<int>> dp(n + 1, std::vector<int>(capacity + 1, 0));
for (int i = 1; i <= n; ++i) {
int w = weights[i - 1];
int v = values[i - 1];
for (int j = 0; j <= capacity; ++j) {
dp[i][j] = dp[i - 1][j];
if (j >= w) {
dp[i][j] = std::max(dp[i][j], dp[i - 1][j - w] + v);
}
}
}
return dp;
}
// 从二维状态表回溯出所选物品
std::vector<int> dp01KnapsackPick(const std::vector<std::vector<int>>& dp,
const std::vector<int>& weights, int capacity) {
std::vector<int> picked;
int i = static_cast<int>(weights.size());
int j = capacity;
while (i > 0) {
if (dp[i][j] > dp[i - 1][j]) {
picked.push_back(i - 1);
j -= weights[i - 1];
}
--i;
}
std::reverse(picked.begin(), picked.end());
return picked;
}
// ---------- 示例三:任务调度 ----------
// 任务调度贪心:按截止时间升序处理,超出容量时淘汰收益最小的任务
std::vector<int> scheduleGreedy(const std::vector<int>& deadlines,
const std::vector<int>& profits) {
int n = static_cast<int>(deadlines.size());
std::vector<int> order(n);
std::iota(order.begin(), order.end(), 0);
std::sort(order.begin(), order.end(), [&](int x, int y) {
if (deadlines[x] != deadlines[y]) {
return deadlines[x] < deadlines[y];
}
return x < y;
});
std::priority_queue<std::pair<int, int>, std::vector<std::pair<int, int>>,
std::greater<std::pair<int, int>>> heap;
std::vector<bool> selected(n, false);
int count = 0;
for (int t : order) {
heap.push(std::make_pair(profits[t], t));
selected[t] = true;
++count;
if (count > deadlines[t]) {
int drop = heap.top().second;
heap.pop();
selected[drop] = false;
--count;
}
}
std::vector<int> result;
for (int t = 0; t < n; ++t) {
if (selected[t]) {
result.push_back(t);
}
}
return result;
}
// 给选中的任务分配时间槽:占据不超过其截止时间的最靠后空槽
std::vector<int> scheduleSlots(const std::vector<int>& deadlines,
const std::vector<int>& selected) {
int maxDeadline = *std::max_element(deadlines.begin(), deadlines.end());
std::vector<int> slots(maxDeadline + 1, -1);
std::vector<int> order(selected);
std::sort(order.begin(), order.end(), [&](int x, int y) {
if (deadlines[x] != deadlines[y]) {
return deadlines[x] > deadlines[y];
}
return x < y;
});
for (int t : order) {
int s = deadlines[t];
while (s > 0 && slots[s] != -1) {
--s;
}
if (s > 0) {
slots[s] = t;
}
}
return slots;
}
// 任务调度的动态规划解
int scheduleDp(const std::vector<int>& deadlines, const std::vector<int>& profits) {
int n = static_cast<int>(deadlines.size());
int maxDeadline = *std::max_element(deadlines.begin(), deadlines.end());
std::vector<int> order(n);
std::iota(order.begin(), order.end(), 0);
std::sort(order.begin(), order.end(), [&](int x, int y) {
if (deadlines[x] != deadlines[y]) {
return deadlines[x] < deadlines[y];
}
return x < y;
});
std::vector<std::vector<int>> dp(n + 1, std::vector<int>(maxDeadline + 1, 0));
for (int i = 1; i <= n; ++i) {
int t = order[i - 1];
int d = deadlines[t];
int p = profits[t];
for (int j = 1; j <= maxDeadline; ++j) {
dp[i][j] = std::max(dp[i - 1][j], dp[i - 1][std::min(j, d) - 1] + p);
}
}
return dp[n][maxDeadline];
}
// ---------- 示例四:最小生成树 ----------
// 带权边:两个端点与权重
struct Edge {
int u;
int v;
int w;
};
// 邻接表条目:相邻顶点、权重、边下标
struct AdjEntry {
int to;
int w;
int edgeIndex;
};
// 最小生成树的结果:总权重与选中的边下标
struct MstResult {
int total;
std::vector<int> edges;
};
// 并查集:查找代表元并压缩路径
int findRoot(std::vector<int>& parent, int x) {
while (parent[x] != x) {
parent[x] = parent[parent[x]];
x = parent[x];
}
return x;
}
// Kruskal 最小生成树:按权重升序取边,用并查集判定是否成环
MstResult kruskal(int vertexCount, const std::vector<Edge>& edges) {
int m = static_cast<int>(edges.size());
std::vector<int> order(m);
std::iota(order.begin(), order.end(), 0);
std::sort(order.begin(), order.end(), [&](int x, int y) {
if (edges[x].w != edges[y].w) {
return edges[x].w < edges[y].w;
}
if (edges[x].u != edges[y].u) {
return edges[x].u < edges[y].u;
}
return edges[x].v < edges[y].v;
});
std::vector<int> parent(vertexCount);
std::iota(parent.begin(), parent.end(), 0);
MstResult result{0, {}};
for (int t : order) {
int ru = findRoot(parent, edges[t].u);
int rv = findRoot(parent, edges[t].v);
if (ru == rv) {
continue;
}
parent[ru] = rv;
result.total += edges[t].w;
result.edges.push_back(t);
}
return result;
}
// Prim 最小生成树:优先队列实现,每次取连接当前集合与外部的最小权边
MstResult prim(int vertexCount, const std::vector<Edge>& edges) {
std::vector<std::vector<AdjEntry>> adjacency(vertexCount);
for (int t = 0; t < static_cast<int>(edges.size()); ++t) {
adjacency[edges[t].u].push_back({edges[t].v, edges[t].w, t});
adjacency[edges[t].v].push_back({edges[t].u, edges[t].w, t});
}
using HeapItem = std::tuple<int, int, int>; // 权重、相邻顶点、边下标
std::priority_queue<HeapItem, std::vector<HeapItem>, std::greater<HeapItem>> heap;
std::vector<bool> visited(vertexCount, false);
visited[0] = true;
for (const AdjEntry& e : adjacency[0]) {
heap.emplace(e.w, e.to, e.edgeIndex);
}
MstResult result{0, {}};
while (!heap.empty() && static_cast<int>(result.edges.size()) < vertexCount - 1) {
HeapItem top = heap.top();
heap.pop();
int to = std::get<1>(top);
if (visited[to]) {
continue;
}
visited[to] = true;
result.total += std::get<0>(top);
result.edges.push_back(std::get<2>(top));
for (const AdjEntry& e : adjacency[to]) {
if (!visited[e.to]) {
heap.emplace(e.w, e.to, e.edgeIndex);
}
}
}
return result;
}
// 运行结果(本文件不含主程序,以下为各函数以主程序中的示例数据为输入时的返回值):
// activityGreedy -> { 0, 1, 3, 7, 10 } 活动 1、2、4、8、11
// activityBruteforce -> 5
// greedy01Knapsack -> { 1, 2 } 物品 2、3,价值 6
// dp01Knapsack -> 最优值 dp[4][5] = 7
// dp01KnapsackPick -> { 0, 1 } 物品 1、2
// fractionalKnapsack -> 7.333333 精确值 22/3
// scheduleGreedy -> { 0, 2, 4 } 任务 1、3、5,收益 142
// scheduleSlots -> { -1, 2, 0, 4 } 槽 1 任务 3、槽 2 任务 1、槽 3 任务 5
// scheduleDp -> 142
// kruskal -> total 10, edges { 2, 3, 5, 7, 1 }
// prim -> total 10, edges { 1, 2, 3, 5, 7 }
//
// 结果解读:
// 1. 活动选择:贪心选中活动 1、2、4、8、11 共 5 个,与穷举得到的最优值 5 相同。
// 2. 0-1 背包:贪心选中物品 2、3,价值 6;动态规划最优价值 7,选中物品 1、2。
// 贪心解小于最优解,说明 0-1 背包不具备贪心选择性质,见 2.4 节的例 2.3。
// 3. 分数背包:允许拆分后最优值 22/3 高于 0-1 背包的最优值 7,
// 差距正来源于「物品可拆分」这一条件。
// 4. 任务调度:贪心选中任务 1、3、5,收益 142,与动态规划最优值 142 相同。
// 5. 最小生成树:Kruskal 与 Prim 的总权重同为 10,
// 本例中两者选中的边集合相同,仅取用顺序不同。
//
// 以上下标均从 0 开始,与代码中的下标一致;活动、物品、任务的编号在正文中从 1 开始,
// 两者相差 1。
八、易错点与边界
易错点一:把「当前最优」直接当作「全局最优」。 贪心算法成立的前提是问题具备贪心选择性质,这一点无法从直觉中看出,必须显式论证。0-1 背包的直觉「先装密度最高的」看起来合理,但只对分数背包成立。
易错点二:忽略了区间端点的开闭。 活动选择使用半开区间 [s, f),因此起点等于上一个活动结束时间时不冲突。若按闭区间实现,5.1 节的例子会少选一个活动。
易错点三:排序依据的比较方式引入浮点误差。 价值密度是分数,用浮点数排序可能因精度问题把相等的密度判为不等。7.1 节的 Python 实现用 fractions.Fraction 精确比较,Java 与 C++ 实现用交叉相乘 values[x] * weights[y] 与 values[y] * weights[x] 比较整数乘积。
易错点四:堆中的淘汰对象选错。 任务调度中超出容量时必须淘汰收益最小的任务。淘汰收益最大的会使结果变差,而淘汰刚加入的任务在部分实例上反而可能更优,但一般情况下不成立——正确做法是淘汰堆顶(收益最小者)。
易错点五:只写贪心策略而不写正确性论证。 贪心算法的正确性依赖于问题的结构性质,不写证明就无法区分「这个问题恰好贪心可行」与「这个问题的贪心恰好在这组数据上对了」。
易错点六:把动态规划退化为贪心时漏掉条件。 3.4 节的判据是增量 c1 ≥ c2 ≥ ... ≥ ck 恒成立,而不是「在当前状态恰好 c1 最大」。
边界情况清单:
| 边界情形 | 处理方式 |
|---|---|
| 输入为空 | 返回空集与 0,循环不执行 |
| 容量为 0 | 任何物品都装不下,直接返回 |
| 所有候选都冲突 | 返回空集 |
| 排序键全部相等 | 结果与具体顺序无关,但需保证实现稳定以便复现 |
| 浮点除法产生相等密度 | 改用分数或交叉相乘比较 |
| 时间槽数量远大于任务数 | 堆实现的复杂度为 O(n log n),与时间槽数量无关 |
九、拓展与应用
9.1 经典贪心问题清单
| 问题 | 贪心准则 | 正确性依据 |
|---|---|---|
| 活动选择 | 结束时间最早 | 交换论证 |
| 分数背包 | 价值密度最高 | 交换论证 |
| 任务调度 | 截止时间升序,淘汰收益最小者 | 交换论证 |
| 最小生成树 | 权重最小的边 | 割性质 |
| 最短路(Dijkstra) | 当前距离最小的顶点 | 割性质 |
| 霍夫曼编码 | 权值最小的两个节点 | 交换论证 |
| 区间调度 | 结束时间最早 | 交换论证 |
| 合并区间 | 起始时间最早的区间 | 交换论证 |
| 找零钱 | 面值最大的硬币 | 反证法 |
9.2 动态规划退化为贪心的条件
回顾 3.4 节的判据:状态转移方程的候选项都指向同一前驱状态,且增量之间存在恒成立的比较准则。将其落实为可操作的步骤:
- 写出正确的动态规划状态转移方程;
- 检查所有候选项是否指向同一个前驱状态——若不是,通常无法简化为贪心;
- 检查增量的大小关系是否与前驱状态无关——若无关,则可简化为单一选择;
- 简化为贪心后,重新用交换论证或归纳法给出证明,替代「状态表正确」这一依据。
以任务调度为例,动态规划的转移为:
dp[i][j] = max( dp[i-1][j], dp[i-1][min(j, d_i) - 1] + p_i )
两个候选项的前驱状态不同(一个是 j,一个是 min(j, d_i) - 1),无法直接简化为贪心。但通过重新组织问题——改为「按截止时间升序处理并在超出容量时淘汰收益最小者」——可以得到等价的贪心解。这一转换本身就是贪心算法设计的关键步骤。
9.3 贪心在图算法中的应用
图算法是贪心算法最集中的领域,其根本原因是割性质。
割性质:对任意一个把顶点集合划分为两部分的割,横跨该割的最小权边必定属于某棵最小生成树。
这一性质直接支撑了三个经典算法:
| 算法 | 贪心准则 | 割的取法 |
|---|---|---|
| Kruskal | 权重最小的边 | 该边两端所在连通分量构成的割 |
| Prim | 连接已选集合与未选集合的最小权边 | 已选顶点集合与未选顶点集合构成的割 |
| Dijkstra | 当前最短距离最小的顶点 | 已确定顶点集合与未确定顶点集合构成的割 |
三者的差别仅在于「割如何选取」,这说明贪心算法的多样性往往来自同一个底层性质的不同用法。
最大生成树同样由割性质支撑,只需把「最小」换成「最大」。
9.4 拟阵与贪心
拟阵(matroid)是一个集合族 (M, I),其中 M 是有限集合,I 是 M 的子集族,满足两条公理:
- 遗传性质:若
A ∈ I且B ⊆ A,则B ∈ I; - 交换性质:若
A, B ∈ I且|A| < |B|,则存在b ∈ B \ A使得A ∪ {b} ∈ I。
在满足这两条公理的集合族上,「每次选取可行的最小权元素」的贪心算法对所有线性目标函数都能求得最优解。
经典例子:
| 拟阵 | 独立集 | 对应的贪心问题 |
|---|---|---|
| 图拟阵 | 不含环的边集(森林) | 最小生成树 |
| 线性拟阵 | 线性无关的向量组 | 最大权独立集 |
| 划分拟阵 | 每个分组至多取一个元素 | 任务分组选择 |
拟阵理论的价值在于给出了一条充分条件:若问题的可行解集合构成拟阵,则贪心必然正确。这为「判断某个问题能否用贪心」提供了一条不用逐案证明的捷径。
需要指出的是,并非所有贪心问题都来自拟阵。任务调度的可行解集合构成的是拟码(matroid)而非拟阵,其贪心算法依然正确,但正确性论证的形式不同。
9.5 局部最优与全局最优
局部最优指在当前候选集合中优先级最高的那个选择;全局最优指整个问题的最优解。贪心算法把这两者等同起来,这一等同成立的充要条件就是贪心选择性质。
这一等同的失败有三种典型形态:
- 0-1 背包:局部最优(密度最高)不等于全局最优(价值最高),因为物品不可拆分;
- 区间覆盖问题:每次覆盖最远的区间,可能造成重叠浪费,而最优方案需要考虑整体覆盖;
- 字典最短路径问题:逐个选择字典序最小的边,得到的路径未必是全局最短的。
与爬山法(hill climbing)的区别值得说明:爬山法每一步沿梯度方向移动,允许走过弯路,但可能停在局部最优;贪心算法一步到位地固定决策且永不回退,因此不存在「陷入局部最优」的过程——它要么得到全局最优,要么一开始就用错了准则。贪心算法的正确性完全依赖先验证明,而非搜索过程的运气。
这一差别在工程上的取舍是:贪心算法「用证明换时间」,适用于可以论证性质的问题;无法论证时,动态规划(用空间换时间)或回溯(用时间换正确性)是更稳妥的选择。
附录
术语中英文对照
| 中文 | 英文 | 说明 |
|---|---|---|
| 贪心算法 | Greedy Algorithm | 每步固定当前最优选择、不回退的算法 |
| 贪心选择性质 | Greedy Choice Property | 存在一个最优解包含当前贪心选择 |
| 最优子结构 | Optimal Substructure | 最优解可分解为同类子问题的最优解 |
| 交换论证法 | Exchange Argument | 用替换操作把贪心选择换进最优解的证明方法 |
| 反证法 | Proof by Contradiction | 假设结论不成立并导出矛盾 |
| 归纳证明 | Proof by Induction | 对问题规模归纳的证明方法 |
| 拟阵 | Matroid | 满足遗传性质与交换性质的集合族 |
| 拟码 | Matroid / Gammoid | 与拟阵相关但交换性质形式不同的结构 |
| 割性质 | Cut Property | 最小生成树中关于割的边的性质 |
| 优先队列 | Priority Queue | 支持取出优先级最高元素的队列 |
| 小顶堆 | Min-Heap | 每次可取出最小值的堆 |
| 并查集 | Union-Find / Disjoint Set | 维护不相交集合的数据结构 |
| 路径压缩 | Path Compression | 并查集查找中的优化 |
| 按秩合并 | Union by Rank | 并查集合并中的优化 |
| 价值密度 | Value Density | 背包问题中价值与重量之比 |
| 交叉相乘 | Cross Multiplication | 比较两个比值大小的整数方法 |
| 局部最优 | Local Optimum | 单点意义下的最优选择 |
| 全局最优 | Global Optimum | 整个问题的最优解 |
| 状态表 | DP Table | 动态规划中保存各状态最优值的表格 |
| 无后效性 | Principle of Optimality / No After-Effect | 已确定状态不影响后续决策 |
复杂度速查表
| 问题 | 贪心时间 | 贪心空间 | 动态规划时间 |
|---|---|---|---|
| 活动选择 | O(n log n) |
O(n) |
O(n²) |
| 分数背包 | O(n log n) |
O(n) |
不适用(贪心即可) |
| 0-1 背包 | O(n log n)(结果错误) |
O(n) |
O(nC) |
| 任务调度 | O(n log n) |
O(n) |
O(nD) |
| 最小生成树(Kruskal) | O(m log m) |
O(n + m) |
O(n²) |
| 最小生成树(Prim,堆实现) | O((n + m) log n) |
O(n + m) |
O(n²) |
| 单源最短路(Dijkstra) | O((n + m) log n) |
O(n + m) |
O(n²) |
| 霍夫曼编码 | O(n log n) |
O(n) |
不适用 |
| 区间合并 | O(n log n) |
O(n) |
O(n²) |

浙公网安备 33010602011771号