贪心 (Greedy)
TL;DR
贪心不是"每一步选看起来最好的",而是一种可证明的算法范式:如果问题有 最优子结构 + 无后效性,且每一步的局部最优决策"永远不会被未来的决策推翻",那么局部最优的拼接就是全局最优。判断一个题能不能贪心,靠的不是直觉而是交换论证 / 归纳证明。本章给出判据、证明模板、四组经典问题的严格推导,以及 Go / Python 双语言实现。
读完应能:
- 用"最优子结构 + 无后效性 + 贪心选择性质"三句话判断一个题能否贪心。
- 用交换论证证明区间调度和 Huffman 的最优性。
- 写出活动选择、部分背包、Huffman、任务调度的实现,并指出它们各自的"排序键"。
- 说出贪心与 DP 的分界线:0/1 背包为什么不能贪心、部分背包为什么能。
一、思想链
工程问题: 每一步选当前最优, 能不能保证全局最优?
└─► 必要条件1: 最优子结构 (子问题最优解能拼出全局最优)
└─► 必要条件2: 无后效性 (未来决策只看当前状态)
└─► 贪心选择性质: 存在一个"每一步都取局部最优"的最优解
└─► 证明武器: 交换论证 / 归纳 / 拟阵(matroid)公理
二、形式化判据
设每一步要做的选择集合为 $C_i$,贪心选 $g_i \in C_i$:
- 最优子结构:$OPT(S)$ 由 $g_1$ 与 $OPT(S_{g_1})$ 组合而成;
- 无后效性(马尔可夫性):$S_{g_1}$ 的选择与"怎么走到 $g_1$"无关;
- 贪心选择性质:存在最优解以 $g_1$ 为第一步——证明方式是:任取一个最优解,把它"交换"成以 $g_1$ 开头且不劣。
note
若问题满足**拟阵(matroid)**公理(遗传性 + 交换性),贪心在加权独立集问题上直接最优——这是"什么时候能贪心"的理论答案(如 Kruskal、最大权森林)。
三、经典问题与证明
3.1 活动选择(区间调度)
问题:$n$ 个区间 $[s_i, e_i)$,选最多互不重叠的区间。
贪心:按 end 升序排序,依次取不与已选重叠的最早结束区间。
交换论证:设贪心第一个区间是 $g_1=[s_{g}, e_{g}]$(全局最早结束),任意最优解 $O$ 的第一个区间是 $o_1=[s_o, e_o]$。因为 $e_g \le e_o$,把 $O$ 中的 $o_1$ 换成 $g_1$ 后,$O$ 的剩余区间仍然不重叠(它们都在 $e_o$ 之后开始,也在 $e_g$ 之后开始),所以存在以 $g_1$ 开头的最优解。对 $S_{g_1}$ 归纳即可。
warning
错误排序:按 start 或区间长度排序都会翻车。反例:[1,5],[2,3],[4,6]——按 start 取 [1,5] 只剩 1 个,最优是 2 个。
3.2 部分背包(Fractional Knapsack)
贪心:按单位价值 $v_i/w_i$ 降序装,装不下时切碎。
为什么 0/1 背包不行:0/1 里"当前最值钱的物品"可能挤掉"组合后更优的两个物品",后效性破坏;部分背包里一切可切,贪心选择性质成立(单位价值最高的物品在某个最优解里一定被完全或部分取走,交换论证)。
3.3 Huffman 编码
贪心:每次合并频率最小的两棵树(优先队列)。
最优性证明(交换论证两步):
- 频率最小的两个符号 $x,y$ 在某棵最优树里深度最深且互为兄弟(否则交换到最深不增加 WPL);
- 合并 $x,y$ 为新符号 $z$($f_z=f_x+f_y$)后,原问题变成 $n-1$ 符号的子问题——由归纳,贪心持续最优。
3.4 SPT 调度
问题:单机、任务有处理时间 $p_i$,最小化平均完成时间。
贪心:按 $p_i$ 升序(Shortest Processing Time first)。
交换论证:若最优解中相邻两项 $p_a > p_b$(a 在 b 前),交换它们,只有这两项的完成时间变化:$p_a$ 延后 $p_b$、$p_b$ 提前 $p_a$,总完成时间减少 $p_a - p_b > 0$。所以最优解必按 $p_i$ 升序。
四、多语言实现
Go: 活动选择
type seg struct{ s, e int }
func maxActivities(a []seg) int {
sort.Slice(a, func(i, j int) bool { return a[i].e < a[j].e })
cnt, lastEnd := 0, -1
for _, x := range a {
if x.s >= lastEnd { // 不重叠
cnt++
lastEnd = x.e
}
}
return cnt
}
Python: Huffman(证明等价的最小合并)
import heapq
def huffman_cost(freqs):
heap = list(freqs)
heapq.heapify(heap)
total = 0
while len(heap) > 1:
a, b = heapq.heappop(heap), heapq.heappop(heap)
total += a + b # 每次合并的代价 = 树高累计
heapq.heappush(heap, a + b)
return total
五、工程里什么时候能用贪心
- 输入有强结构(区间、单机队列、树、双值)——先试交换论证;
- 能构造凸性:升级/定价问题里,代价函数凸时"最便宜优先"可证;
- 没有结构就老实 DP / 回溯(见 dp.md 与 backtracking.md)。
六、易错清单
- 排序键选错:区间调度按
end而非start/长度;任务调度按p_i而非截止期(截止期问题是 EDF,另一套判据)。 - "看起来对"当证明:每个贪心必须配交换论证或反例自检——Huffman 看似显然,实际要两步交换证明。
- 贪心 vs DP 分界:0/1 背包、带权区间调度(需 DP)、硬币找零(面额不整除时)都不能贪心。
七、一页速查
判据: 最优子结构 + 无后效性 + 贪心选择性质
证明: 交换论证(把最优解调成贪心解不劣) / 归纳 / 拟阵
模板: 选排序键 → 贪心选 → 交换论证验证
经典: 区间(按end) / 部分背包(按unit) / Huffman(最小堆) / SPT(按p)
分界: 可切分→贪心; 不可切分带后效→DP
下一篇: 动态规划 (DP)。