一、贪心是什么,以及它为什么危险

贪心算法:每一步都做出当前看起来最好的选择,不回溯,不考虑全局。

它的诱人之处:实现简单、通常线性或 O(n log n)、直觉自然。

⚠️ 它的危险之处:绝大多数贪心策略是错的,而且错得不明显。 一个贪心解法可能通过 1000 个测试用例,在第 1001 个上失败。

因此本讲的重点不是"如何想出贪心策略",而是「如何证明它是对的」。 在算法课和面试中,一个没有证明的贪心解法等于没有解法。

两个必要条件

条件 含义
贪心选择性质(greedy-choice property) 局部最优选择能导向全局最优——存在一个最优解包含这个贪心选择
最优子结构(optimal substructure) 做出贪心选择后,剩下的子问题的最优解 + 这个选择 = 原问题的最优解

贪心 vs 动态规划:两者都需要最优子结构,区别在于——

DP:   考虑所有选择,取最好的        自底向上,需要子问题的解
贪心: 只考虑一个选择,不回头        自顶向下,不需要枚举

贪心是 DP 的特例:当"最优选择可以在不看子问题解的情况下确定"时,DP 退化成贪心。


二、两种证明模板

模板一:交换论证(exchange argument)

这是最通用、最常用的贪心证明方法。

① 设 OPT 是任意一个最优解,G 是贪心解
② 找到 OPT 与 G 第一处不同的地方
③ 证明:把 OPT 在该处的选择"交换"成贪心的选择,
   得到的解 OPT′ 仍然可行,且不比 OPT 差
④ 重复,最终把 OPT 变成 G,因此 G 也是最优的

⭐ 我们已经见过这个论证:第 24 讲割性质证明就是标准的交换论证(“加一条边形成环,再删掉环上另一条边”)。

模板二:贪心保持领先(greedy stays ahead)

① 定义一个"进度"度量
② 用归纳法证明:在每一步之后,贪心解的进度都不劣于任何其他解
③ 因此最终贪心解不劣于最优解

适合"选最多的东西"这类问题。


三、活动选择(区间调度)

问题:n 个活动,第 i 个占用时间 [sᵢ, fᵢ)。选出最多的互不重叠的活动。

候选贪心策略

策略 对吗
最早开始的先选
持续时间最短的先选
冲突最少的先选
最早结束的先选

反例(最早开始)

A: ████████████████████████    一个超长活动
B:   ███                        三个短活动
C:        ███
D:              ███
最早开始 ⟹ 只选到 A(1 个);最优是 B,C,D(3 个)

反例(最短持续)

A: ██████
B:      ███         ← 最短,但它同时挡住了 A 和 C
C:        ██████
最短优先 ⟹ 只选 B(1 个);最优是 A,C(2 个)

反例(冲突最少)

时间   0    2    4    6    8   10   12   14
       ├────┤                                   A [0,2]
                 ├────┤                         B [4,6]
                           ├─────┤              C [8,10]
                                      ├────┤    D [12,14]
         ├─────────┤ ×4                         四个 [1,5](彼此也重叠)
                             ├─────────┤ ×4     四个 [9,13](彼此也重叠)
                   ├─────────┤                  M [5,9]

各区间的冲突数:
  A:4   B:5   C:5   D:4   [1,5]:5   [9,13]:5   M:2  ← M 严格最少

冲突最少优先 ⟹ 先选 M,而 M 同时挡掉了 B 和 C
            ⟹ 之后只能再选 A 和 D,共 3 个
最优(A,B,C,D)                              ⟹ 4 个

这正是"局部最优消耗掉关键资源"的典型结构:M 自己看起来最无害(冲突最少),却卡在中间同时废掉两个互不冲突的活动。

type Activity struct{ Start, Finish int }

func ActivitySelection(acts []Activity) []Activity {
    slices.SortFunc(acts, func(a, b Activity) int {
        return cmp.Compare(a.Finish, b.Finish) // ⭐ 按结束时间排序
    })
    var chosen []Activity
    lastFinish := math.MinInt
    for _, a := range acts {
        if a.Start >= lastFinish {
            chosen = append(chosen, a)
            lastFinish = a.Finish
        }
    }
    return chosen
}

Θ(n log n)(排序主导)。

⭐ 正确性证明(交换论证)

设贪心第一个选的是结束最早的活动 a₁。设 OPT 是最优解,其第一个活动是 b。

  • 由于 a₁ 结束最早,f(a₁) ≤ f(b)
  • OPT′ = OPT − {b} + {a₁}。因为 a₁ 比 b 结束更早,它不会与 OPT 中 b 之后的任何活动冲突,所以 OPT′ 仍然可行。
  • |OPT′| = |OPT|,所以 OPT′ 也是最优解,且它包含贪心的第一个选择

这证明了贪心选择性质。剩下的是"在 a₁ 结束后的时间里选最多活动"这个同类子问题,归纳即可。∎

直觉选结束最早的活动,为后面留下的时间最多。 一句话概括整个证明。

区间问题家族

问题 贪心策略
最多不重叠区间 结束时间排序,能选就选
最少移除多少区间使不重叠 n − 上一题的答案
用最少的点戳中所有区间 按结束时间排序,在结束点放点
最少需要几个会议室 ⭐ 按开始时间排序 + 最小堆维护各会议室的结束时间
区间覆盖(用最少区间覆盖 [0,T]) 每次选"能覆盖当前起点且伸得最远"的

⚠️ 注意"最少会议室"用的是不同策略——它不是贪心选择问题,而是"求任意时刻的最大重叠数"。用堆维护即可:

func MinMeetingRooms(acts []Activity) int {
    slices.SortFunc(acts, func(a, b Activity) int { return cmp.Compare(a.Start, b.Start) })
    h := &IntHeap{} // 最小堆,存各房间的结束时间
    heap.Init(h)
    for _, a := range acts {
        if h.Len() > 0 && (*h)[0] <= a.Start {
            heap.Pop(h) // 有房间空出来了,复用
        }
        heap.Push(h, a.Finish)
    }
    return h.Len()
}

四、Huffman 编码

问题:给定字符集及各字符的出现频率,构造前缀码(无码字是另一码字的前缀),使编码总长度最小。

为什么要前缀码? 因为它可以无歧义地解码,不需要分隔符。前缀码对应一棵二叉树,字符在叶子上,路径(左 0 右 1)就是码字。

目标:最小化 Σ freq(c) × depth(c)

贪心策略每次合并频率最小的两个节点。

频率: a:45  b:13  c:12  d:16  e:9  f:5

① 合并 f(5) 与 e(9) → 14
② 合并 c(12) 与 b(13) → 25
③ 合并 14 与 d(16) → 30
④ 合并 25 与 30 → 55
⑤ 合并 a(45) 与 55 → 100

                    (100)
                  ╱       ╲
              a:45        (55)
                        ╱      ╲
                    (25)        (30)
                   ╱    ╲      ╱     ╲
                c:12   b:13  (14)    d:16
                            ╱    ╲
                          f:5    e:9

编码:a=0, c=100, b=101, f=1100, e=1101, d=111
总长度 = 45×1 + 12×3 + 13×3 + 5×4 + 9×4 + 16×3 = 224 位
定长编码需要 3 位 × 100 = 300 位 ⟹ 节省 25%
type HuffNode struct {
    Freq        int
    Char        rune
    Left, Right *HuffNode
}

func BuildHuffman(freq map[rune]int) *HuffNode {
    h := &nodeHeap{}
    for c, f := range freq {
        heap.Push(h, &HuffNode{Freq: f, Char: c})
    }
    for h.Len() > 1 {
        a := heap.Pop(h).(*HuffNode)
        b := heap.Pop(h).(*HuffNode)
        heap.Push(h, &HuffNode{Freq: a.Freq + b.Freq, Left: a, Right: b})
    }
    return heap.Pop(h).(*HuffNode)
}

复杂度 O(n log n)(n 次堆操作)。

⭐ 最优性证明(交换论证)

引理:设 x、y 是频率最小的两个字符,则存在一棵最优前缀码树,其中 x 和 y 是深度最大的兄弟节点

证明:设 T 是最优树,a、b 是 T 中深度最大的一对兄弟。不妨设 freq(x) ≤ freq(y)freq(a) ≤ freq(b)。由 x、y 频率最小,freq(x) ≤ freq(a)freq(y) ≤ freq(b)

交换 x 与 a、y 与 b,得到 T″。代价变化:

cost(T) − cost(T″) = (freq(a) − freq(x))·(depth(a) − depth(x)) + (类似项) ≥ 0

因为 freq(a) ≥ freq(x)depth(a) ≥ depth(x)(a 在最深层),两个因子都非负。所以 cost(T″) ≤ cost(T),而 T 是最优的,故 T″ 也最优。∎

再加上最优子结构(把 x、y 合并成一个频率为 freq(x)+freq(y) 的新字符,原问题的最优解对应新问题的最优解),归纳即得 Huffman 的最优性。∎

⚠️ Huffman 只在"逐符号编码"的框架内最优。现代压缩器(zstd、brotli)用算术编码 / ANS,可以突破"每个符号至少 1 位"的限制,配合上下文建模效果远好于 Huffman。但 Huffman 仍然广泛存在于 DEFLATE(gzip、zip、PNG)和 JPEG 中,因为它解码极快。


五、背包问题:贪心的分界线

分数背包:贪心正确

物品可以切分。按"单位价值" vᵢ/wᵢ 从大到小取,取满为止。

func FractionalKnapsack(items []Item, capacity float64) float64 {
    slices.SortFunc(items, func(a, b Item) int {
        return cmp.Compare(b.Value/b.Weight, a.Value/a.Weight) // 单位价值降序
    })
    total := 0.0
    for _, it := range items {
        if capacity <= 0 { break }
        take := math.Min(it.Weight, capacity)
        total += it.Value * take / it.Weight
        capacity -= take
    }
    return total
}

证明(交换论证):若最优解没有取满单位价值最高的物品,就可以用它替换掉一部分单位价值较低的物品,总价值不减。∎

0-1 背包:贪心错误

物品不可切分。同样的贪心策略会失败

容量 10
物品 A: 重 6,价值 30    单位价值 5.0
物品 B: 重 5,价值 20    单位价值 4.0
物品 C: 重 5,  价值 20    单位价值 4.0

贪心:取 A(价值 30),剩余容量 4,装不下任何东西 ⟹ 30
最优:取 B + C ⟹ 40

为什么一个可分一个不可分就差别这么大? 因为分数背包中"剩余容量"总能被填满,贪心选择不会浪费任何容量;而 0-1 背包中一个选择可能留下用不掉的"碎片",局部最优的代价要到很后面才显现

0-1 背包必须用动态规划第 31 讲)。事实上它是 NP-难的(第 34 讲),DP 的 O(nW) 是伪多项式


六、拟阵:贪心正确性的统一理论

有没有一个统一的判据,告诉我们什么时候贪心一定对? 有——拟阵(matroid)

拟阵 M = (S, I),其中 S 是有限集,I 是 S 的子集族(称为"独立集"),满足:

  1. 遗传性:若 A ∈ I 且 B ⊆ A,则 B ∈ I
  2. 交换性质:若 A, B ∈ I 且 |A| < |B|,则存在 x ∈ B−A 使 A ∪ {x} ∈ I

定理(Rado-Edmonds):对加权拟阵(每个元素有权重),按权重从大到小贪心选取(保持独立)总能得到最大权独立集

例子

拟阵 S 独立集 贪心算法
图拟阵 图的边集 不含环的边集(森林) Kruskal第 24 讲
均匀拟阵 任意集合 大小 ≤ k 的子集 取最大的 k 个
线性拟阵 向量组 线性无关组 高斯消元贪心
划分拟阵 分组的元素 每组至多取 kᵢ 个 分组贪心

Kruskal 的正确性可以完全不用割性质来证明——只需说明"图的森林构成一个拟阵",然后引用 Rado-Edmonds 定理。 这是数学抽象带来威力的典型例子。

⚠️ 但拟阵不是万能的:活动选择、Huffman、Dijkstra 的贪心正确性都不能用拟阵解释(它们不是拟阵结构)。拟阵覆盖的是"选一个独立集使权重最大"这一类问题。


七、常见贪心问题速查

问题 贪心策略 证明方法
活动选择 按结束时间 交换论证
最少会议室 开始时间排序 + 堆 下界论证
Huffman 编码 合并最小两个频率 交换论证
分数背包 单位价值降序 交换论证
MST(Kruskal/Prim) 最小边 / 最近顶点 割性质 / 拟阵
Dijkstra 最近的未确定顶点 归纳(第 25 讲
找零钱(特定币制 最大面额优先 ⚠️ 对任意币制错误
任务调度最小延迟 按截止时间(EDF) 交换论证
加油站问题 能开多远开多远 贪心保持领先

⚠️ 找零钱是最好的反面教材:美元币制 {1,5,10,25} 下贪心正确,但币制 {1, 3, 4} 下求 6 元,贪心给 4+1+1(3 枚),最优是 3+3(2 枚)。“这个贪心在我试的例子上都对"不构成证明。


八、怎么判断一个贪心是否正确

⭐ 实用检查清单:

  1. 能写出交换论证吗? 写不出来,多半是错的。
  2. 能构造反例吗? 花 5 分钟专门构造反例,比花 1 小时凑测试用例值得。特别注意:极端值、“大物品挡住多个小物品”、“局部最优消耗掉关键资源"这三类结构。
  3. 它是拟阵吗? 是的话直接引用定理。
  4. 子问题会不会互相影响? 会的话大概率要用 DP。

一条经验如果一个问题的贪心策略有多个"看起来都合理"的候选(如活动选择的四种策略),那么至少有三个是错的,且必须证明选中的那个。


随堂自测

  1. 贪心算法需要哪两个性质?它与动态规划的关系是什么?
  2. 写出交换论证的四个步骤。它与"贪心保持领先"各适合什么问题?
  3. 活动选择的四种候选策略中,为什么"最早结束"是对的?给出另外三种的反例。
  4. 完整证明活动选择的贪心选择性质。
  5. “最少会议室"为什么不能用同样的贪心?它的正确解法是什么?
  6. 证明 Huffman 引理:频率最小的两个字符可以放在最深的兄弟位置。
  7. Huffman 是最优的前缀码,为什么现代压缩器还要用算术编码?
  8. 分数背包贪心正确而 0-1 背包错误,本质原因是什么?给出 0-1 背包的贪心反例。
  9. 拟阵的两条公理是什么?说明"图的森林"满足它们,从而 Kruskal 正确。
  10. 币制 {1,3,4} 下求 6 元的最少硬币数,贪心给出什么?最优是什么?这说明了什么?