贪心算法 — 从零精通算法与数据结构——Google 面试系统备战 第12篇

第12章:贪心算法

本章目标

读完本章你会:

  • 判别一个问题是否适合贪心策略——贪心选择性质 + 最优子结构
  • 手写活动选择、Huffman 编码、区间调度等经典贪心
  • 精确解释为什么贪心在某些问题上给出最优解,而在另一些问题上失效
  • 建立贪心 vs DP 的决策框架

知识讲解

从一个生活例子开始

你有一个背包,容量 15kg。以下物品可选:

物品 重量 价值 价值/重量
A 10kg 60元 6 元/kg
B 5kg 25元 5 元/kg
C 8kg 48元 6 元/kg

贪心 A(选最贵的): 选 A(60) → 剩 5kg → 选 B(25) → 总价值 85。可用重量 15kg = 10+5,恰好全用,似乎很好。

贪心 B(选性价比最高的): 选 A(6元/kg) → 剩 5kg → 选 B(5元/kg) → 总价值 85。结果一样。

但如果物品变成:

物品 重量 价值 价值/重量
X 10kg 50元 5 元/kg
Y 6kg 36元 6 元/kg
Z 5kg 25元 5 元/kg

贪心选性价比最高的 Y → 剩 9kg → 选 Y 不能重复 → ... → 36+25=61。但最优解是 X+Z=75!

贪心在这个背包问题上失败了。 因为贪心做了不可逆的选择(选了 Y),封锁了更优的组合。这就是贪心 vs DP 的边界:有些决策需要"向后看",只看局部最优是不够的。

工作原理

12.1 贪心选择性质

贪心算法正确性的充要条件:存在一个最优解,包含当前的贪心选择。

证明贪心选择性质的标准框架:

  1. 假设某个最优解 OPT 不包含贪心选择 g
  2. 将 OPT 修改为包含 g,而不降低其价值
  3. 因此存在一个包含 g 的最优解
  4. 递归应用 → 贪心策略最优

活动选择问题是证明的经典案例:

n 个活动,每个有开始时间 s_i 和结束时间 f_i。选最多的互不相交活动。

贪心策略:每次选结束时间最早的活动。

证明(剪贴法):

  • 假设 OPT 的最早结束活动不是贪心选的(贪心选了 g,OPT 选了 o)
  • f(g) ≤ f(o)(因为贪心选最早结束的)
  • 把 OPT 中的 o 替换成 g → 新的解仍然有效(g 不晚于 o 结束,不会冲突)
  • 活动数相同 → 替换后的解依然最优

12.2 Huffman 编码

问题: 给定字符频率表,构造最优前缀码,使得编码后总长度最短。

贪心策略: 每次取两个频率最低的节点,合并为新节点(频率 = 两者之和),重复直到只剩一棵树。

示例:a:45, b:13, c:12, d:16, e:9, f:5

1. 取 f(5) + e(9) = 14  → 新节点
2. 取 c(12) + b(13) = 25
3. 取 14 + d(16) = 30
4. 取 25 + 30 = 55
5. 取 a(45) + 55 = 100

结果编码:
a: 0          (1 bit)
c: 100        (3 bits)
b: 101        (3 bits)
f: 1100       (4 bits)
e: 1101       (4 bits)
d: 111        (3 bits)

总长度 = 45×1 + 12×3 + 13×3 + 5×4 + 9×4 + 16×3 = 224 bits

Huffman 编码的最优性证明用到了贪心选择性质 + 最优子结构: 最小频率的两个字符在最优解中一定是深度最大的叶子(否则交换它们和更深的叶子,总代价不会增加)。

12.3 贪心 vs DP 决策框架

特征 适合 DP 适合贪心
子问题最优能否组合 ✅ 能 ✅ 能
贪心选择是否安全 ❌ 不确定 ✅ 可证明
是否需要"后悔" ✅ 可能需要回头 ❌ 无需后悔
典型问题 背包、编辑距离、LCS 活动选择、Huffman、最小生成树

面试中判别贪心的方法:

  1. 暴力/DP 的递推中有没有一种选择永远是"最优"的?
  2. 如果有,尝试证明"交换论证"
  3. 如果证明失败——很可能贪心不成立,用 DP

常见的贪心成立条件: 问题具有拟阵(Matroid)结构——如最小生成树(Kruskal 算法)。详见第 14 章。


代码实战

include/algo/greedy.h

#ifndef ALGO_GREEDY_H_
#define ALGO_GREEDY_H_

#include <cstdint>
#include <queue>
#include <string>
#include <unordered_map>
#include <vector>

namespace algo {

// ========== 活动选择 ==========
struct Activity {
  int start;
  int finish;
};

// 返回最大可选活动数,O(n log n) — 瓶颈在排序
int MaxActivities(std::vector<Activity> activities);

// ========== Huffman 编码 ==========
struct HuffmanResult {
  std::unordered_map<char, std::string> codes;  // 字符 → 编码
  std::string Encode(const std::string& text) const;
  std::string Decode(const std::string& encoded) const;
};

// 根据文本构建 Huffman 树并返回编码
HuffmanResult BuildHuffmanCodes(const std::string& text);

}  // namespace algo

#endif  // ALGO_GREEDY_H_

include/algo/greedy_impl.h(核心实现)

namespace algo {

inline int MaxActivities(std::vector<Activity> activities) {
  if (activities.empty()) return 0;

  // 贪心:按结束时间排序
  std::sort(activities.begin(), activities.end(),
            [](const Activity& a, const Activity& b) {
              return a.finish < b.finish;
            });

  int count = 1;
  int last_finish = activities[0].finish;
  for (std::size_t i = 1; i < activities.size(); ++i) {
    if (activities[i].start >= last_finish) {  // 不冲突
      ++count;
      last_finish = activities[i].finish;
    }
  }
  return count;
}

inline HuffmanResult BuildHuffmanCodes(const std::string& text) {
  // 1. 统计频率
  std::unordered_map<char, int> freq;
  for (char c : text) ++freq[c];

  // 2. 优先队列建树
  using NodePtr = std::shared_ptr<struct HuffmanNode>;
  struct HuffmanNode {
    char ch = '\0';
    int freq = 0;
    NodePtr left;
    NodePtr right;
  };

  auto cmp = [](const NodePtr& a, const NodePtr& b) {
    return a->freq > b->freq;  // 最小堆
  };
  std::priority_queue<NodePtr, std::vector<NodePtr>, decltype(cmp)> pq(cmp);

  for (auto& [ch, f] : freq) {
    pq.push(std::make_shared<HuffmanNode>(ch, f));
  }

  // 3. 合并直到只剩一棵树
  while (pq.size() > 1) {
    auto left = pq.top(); pq.pop();
    auto right = pq.top(); pq.pop();
    auto parent = std::make_shared<HuffmanNode>('\0', left->freq + right->freq);
    parent->left = left;
    parent->right = right;
    pq.push(parent);
  }

  // 4. DFS 生成编码
  HuffmanResult result;
  if (pq.empty()) return result;

  std::function<void(const NodePtr&, const std::string&)> traverse =
      [&](const NodePtr& node, const std::string& code) {
        if (!node) return;
        if (!node->left && !node->right) {  // 叶子
          result.codes[node->ch] = code;
        }
        traverse(node->left, code + "0");
        traverse(node->right, code + "1");
      };

  traverse(pq.top(), "");
  return result;
}

inline std::string HuffmanResult::Encode(const std::string& text) const {
  std::string result;
  for (char c : text) result += codes.at(c);
  return result;
}

}  // namespace algo

本章小结

  1. 贪心正确的充要条件:贪心选择性质——存在一个最优解包含贪心选择
  2. 交换论证是证明贪心选择性质的标准方法
  3. 活动选择用"最早结束"贪心,证明:替换不增加冲突
  4. Huffman 每次合并频率最小的两个节点,最优性由交换论证 + 归纳法保证
  5. 贪心 vs DP:小例验证 + 交换论证尝试 → 如果贪心不成立,用 DP

关键术语

术语 释义
贪心选择性质 局部最优选择能导致全局最优解的性质
交换论证 证明贪心最优性的标准技术:将假设的最优解修改为包含贪心选择而不降低解质量
前缀码 任何字符的编码都不是另一字符编码的前缀——保证解码唯一性
Huffman 树 一种带权路径长度最短的二叉树,用于构建最优前缀码
posted @ 2026-06-22 01:09  Yobeeo  阅读(12)  评论(0)    收藏  举报