贪心算法最坑的地方:每一步都看起来很对,最后还是错了

贪心算法最容易坑人的地方,是它的代码通常太短。

一排序,一遍循环,样例过了。

然后错在第 37 个测试点。

真正的问题不是"会不会写贪心",而是你敢不敢丢掉其他分支。贪心每走一步,都在做一件很冒险的事:把当前看起来不如它的选择全部扔掉,并且再也不回头。

所以贪心的核心不是"每一步选最优"。

核心是:

这一步是不是安全选择。

安全选择的意思是:当前这个选择可以放进某个全局最优解里。能证明这一点,贪心才有资格继续往下走。证明不了,就算代码再顺,也只是直觉。

先用找零钱把坑打出来

硬币面额:

txt 复制代码
1, 3, 4

凑金额:

txt 复制代码
6

最自然的策略是每次拿不超过剩余金额的最大硬币:

txt 复制代码
6 -> 拿 4,剩 2
2 -> 拿 1,剩 1
1 -> 拿 1,剩 0

结果是 4 + 1 + 1,一共 3 枚。

最优解是 3 + 3,只要 2 枚。

这里的 4 是局部最优,但不是安全选择。它看起来最大,实际把后面更好的组合堵死了。

代码更直观:

ts 复制代码
function greedyChange(coins: number[], amount: number) {
  const sorted = [...coins].sort((a, b) => b - a);
  const used: number[] = [];
  let rest = amount;

  for (const coin of sorted) {
    while (rest >= coin) {
      used.push(coin);
      rest -= coin;
    }
  }

  return rest === 0 ? used : null;
}

console.log(greedyChange([1, 3, 4], 6));
// [4, 1, 1]

这段代码没 bug。它就是忠实执行了"每次拿大的"。

错的是策略。

写贪心前,先写一个 oracle

我现在判断一个贪心策略,习惯先写慢但正确的版本。小数据下,暴力或 DP 就是 oracle。

拿找零钱来说,DP 版本可以这样写:

ts 复制代码
function dpChange(coins: number[], amount: number) {
  const dp = Array(amount + 1).fill(Number.POSITIVE_INFINITY);
  const prev = Array(amount + 1).fill(-1);

  dp[0] = 0;

  for (let x = 1; x <= amount; x++) {
    for (const coin of coins) {
      if (x >= coin && dp[x - coin] + 1 < dp[x]) {
        dp[x] = dp[x - coin] + 1;
        prev[x] = coin;
      }
    }
  }

  if (!Number.isFinite(dp[amount])) return null;

  const used: number[] = [];
  for (let x = amount; x > 0; x -= prev[x]) {
    used.push(prev[x]);
  }

  return used;
}

console.log(dpChange([1, 3, 4], 6));
// [3, 3]

然后直接枚举金额,找第一个反例:

ts 复制代码
function greedyCount(coins: number[], amount: number) {
  return greedyChange(coins, amount)?.length ?? Number.POSITIVE_INFINITY;
}

function dpCount(coins: number[], amount: number) {
  return dpChange(coins, amount)?.length ?? Number.POSITIVE_INFINITY;
}

function findCoinCounterexample(coins: number[], limit: number) {
  for (let amount = 1; amount <= limit; amount++) {
    const greedy = greedyCount(coins, amount);
    const optimal = dpCount(coins, amount);

    if (greedy !== optimal) {
      return { coins, amount, greedy, optimal };
    }
  }

  return null;
}

console.log(findCoinCounterexample([1, 3, 4], 20));
// { coins: [1, 3, 4], amount: 6, greedy: 3, optimal: 2 }

这比空想有用。

很多错误贪心,小范围枚举就能打爆。刷题时先写 oracle,能帮你判断:这是代码实现问题,还是策略本身不成立。

反例的生成思路

反例不是靠运气撞出来的。

核心套路是:让当前最好的选择,挡住后面更好的组合。

如果策略是"选最大的",就造一个"最大后面只能接很差的东西"的例子。

如果策略是"选最小的",就造一个"最小虽然便宜,但会导致后续成本更大"的例子。

如果策略是"按某个字段排序",就造一个"排序靠前但会卡死后续"的例子。

Codecademy 的树路径例子就是这个结构:

txt 复制代码
        10
       /  \
     15    12
    /  \     \
  18   14     20
  /      \
25       30

每一步都往当前更大的子节点走:

txt 复制代码
10 -> 15 -> 18 -> 25 = 68

但全局更好的路径是:

txt 复制代码
10 -> 15 -> 14 -> 30 = 69

1814 大,但 14 后面接了 30。贪心看不到这个后续结构。

这就是局部最优和全局最优之间的断点。

区间调度:三个贪心策略,两个会错

区间调度是贪心最经典的正例。

题目:给一组区间,选最多个互不重叠的区间。端点相接算不冲突。

先把几个候选策略都写出来:

ts 复制代码
type Interval = { start: number; end: number };

function pickBy(
  intervals: Interval[],
  compare: (a: Interval, b: Interval) => number,
) {
  const sorted = [...intervals].sort(compare);
  const selected: Interval[] = [];
  let currentEnd = Number.NEGATIVE_INFINITY;

  for (const item of sorted) {
    if (item.start >= currentEnd) {
      selected.push(item);
      currentEnd = item.end;
    }
  }

  return selected;
}

const byStart = (items: Interval[]) =>
  pickBy(items, (a, b) => a.start - b.start);

const byLength = (items: Interval[]) =>
  pickBy(items, (a, b) => a.end - a.start - (b.end - b.start));

const byEnd = (items: Interval[]) => pickBy(items, (a, b) => a.end - b.end);

再写一个暴力 oracle。小数据直接枚举所有子集:

ts 复制代码
function isCompatible(items: Interval[]) {
  const sorted = [...items].sort((a, b) => a.start - b.start);

  for (let i = 1; i < sorted.length; i++) {
    if (sorted[i].start < sorted[i - 1].end) {
      return false;
    }
  }

  return true;
}

function bruteForceMaxCount(intervals: Interval[]) {
  const n = intervals.length;
  let best = 0;

  for (let mask = 0; mask < 1 << n; mask++) {
    const picked: Interval[] = [];

    for (let i = 0; i < n; i++) {
      if (mask & (1 << i)) {
        picked.push(intervals[i]);
      }
    }

    if (isCompatible(picked)) {
      best = Math.max(best, picked.length);
    }
  }

  return best;
}

按开始时间选,会被长区间卡死:

ts 复制代码
const caseStartBad = [
  { start: 1, end: 10 },
  { start: 2, end: 3 },
  { start: 3, end: 4 },
  { start: 4, end: 5 },
];

console.log({
  byStart: byStart(caseStartBad).length,
  byLength: byLength(caseStartBad).length,
  byEnd: byEnd(caseStartBad).length,
  optimal: bruteForceMaxCount(caseStartBad),
});

// { byStart: 1, byLength: 3, byEnd: 3, optimal: 3 }

按区间长度选,也会错:

ts 复制代码
const caseLengthBad = [
  { start: 1, end: 4 },
  { start: 4, end: 7 },
  { start: 3, end: 5 },
];

console.log({
  byStart: byStart(caseLengthBad).length,
  byLength: byLength(caseLengthBad).length,
  byEnd: byEnd(caseLengthBad).length,
  optimal: bruteForceMaxCount(caseLengthBad),
});

// { byStart: 2, byLength: 1, byEnd: 2, optimal: 2 }

剩下那个策略才是安全的:按结束时间选。

为什么?

假设最优解第一段不是结束最早的区间 E,而是另一个区间 X。因为 E.end <= X.end,把 X 换成 E 以后,后面能接在 X 后面的区间,也一定能接在 E 后面。答案不会变差。

这就是交换论证。

代码只是 sort((a, b) => a.end - b.end),但真正让它成立的是这句交换。

证明贪心,别只会说"局部最优"

贪心题的证明常用三种。

第一种是交换论证。

区间调度就是它。把最优解里的第一步换成贪心选择,合法性不变,答案不变差,所以存在一个包含贪心选择的最优解。

第二种是领先证明。

适合区间覆盖、跳跃游戏这类问题。你证明贪心在第 k 步以后,覆盖范围、结束时间、剩余空间等指标始终不比任意最优方案差。

第三种是结构归纳或反证。

Huffman 编码每次合并频率最小的两个字符,就是这个味道:在某棵最优前缀编码树里,频率最小的两个字符可以放在最深层并互为兄弟。先合并它们,不破坏最优结构;合并后问题规模减一,还是同类问题。

Kyon Huang 那篇文章里提到的"贪心选择性质 + 最优子结构",落到写题时就是两句话:

txt 复制代码
当前选择能不能放进某个最优解?
选完以后,剩下的问题还是不是同一种问题?

说不清这两句,贪心就只是猜。

分数背包能贪,0-1 背包不能贪

背包是最容易误判的地方。

同一组数据:

txt 复制代码
物品 1: value=60,  weight=10, 单位价值=6
物品 2: value=100, weight=20, 单位价值=5
物品 3: value=120, weight=30, 单位价值=4
背包容量: 50

分数背包可以切物品,按单位价值拿:

ts 复制代码
type Item = { value: number; weight: number };

function fractionalKnapsack(items: Item[], capacity: number) {
  const sorted = [...items].sort(
    (a, b) => b.value / b.weight - a.value / a.weight,
  );

  let rest = capacity;
  let total = 0;

  for (const item of sorted) {
    if (rest === 0) break;

    const take = Math.min(rest, item.weight);
    total += (take / item.weight) * item.value;
    rest -= take;
  }

  return total;
}

console.log(
  fractionalKnapsack(
    [
      { value: 60, weight: 10 },
      { value: 100, weight: 20 },
      { value: 120, weight: 30 },
    ],
    50,
  ),
);
// 240

这里能贪,因为最后一个物品可以切。每一单位容量都拿当前价值密度最高的东西,不会因为"装不下整个物品"浪费选择。

0-1 背包没有这个余地。

按单位价值贪心会拿物品 1 和物品 2:

txt 复制代码
value = 60 + 100 = 160
weight = 10 + 20 = 30
剩余容量 20,物品 3 重 30,拿不下

最优解是物品 2 和物品 3:

txt 复制代码
value = 100 + 120 = 220
weight = 20 + 30 = 50

直接写个错误贪心和正确 DP 对照:

ts 复制代码
function greedy01ByRatio(items: Item[], capacity: number) {
  const sorted = [...items].sort(
    (a, b) => b.value / b.weight - a.value / a.weight,
  );

  let rest = capacity;
  let total = 0;

  for (const item of sorted) {
    if (item.weight <= rest) {
      rest -= item.weight;
      total += item.value;
    }
  }

  return total;
}

function knapsack01(items: Item[], capacity: number) {
  const dp = Array(capacity + 1).fill(0);

  for (const item of items) {
    for (let c = capacity; c >= item.weight; c--) {
      dp[c] = Math.max(dp[c], dp[c - item.weight] + item.value);
    }
  }

  return dp[capacity];
}

const items = [
  { value: 60, weight: 10 },
  { value: 100, weight: 20 },
  { value: 120, weight: 30 },
];

console.log({
  greedy: greedy01ByRatio(items, 50),
  dp: knapsack01(items, 50),
});

// { greedy: 160, dp: 220 }

分数背包和 0-1 背包只差一个"能不能切",算法就从贪心变成 DP。

这类边界比背题型更有用。

股票题:条件一变,贪心也变

LeetCode 122,买卖股票 II,允许多次交易,同一时间只能持有一支股票。

这个条件下,所有上涨段都可以吃掉:

ts 复制代码
function maxProfitUnlimited(prices: number[]) {
  let profit = 0;

  for (let i = 1; i < prices.length; i++) {
    if (prices[i] > prices[i - 1]) {
      profit += prices[i] - prices[i - 1];
    }
  }

  return profit;
}

console.log(maxProfitUnlimited([7, 1, 5, 3, 6, 4]));
// 7: 1->5 赚 4,3->6 赚 3

原因是上涨段可拆。

txt 复制代码
1 -> 3 -> 6

6 - 1 = 5
(3 - 1) + (6 - 3) = 5

拆开交易和整段持有,收益一样。只要明天比今天贵,今天到明天的差值就可以安全拿走。

但如果只能交易一次,还是这组价格:

ts 复制代码
function maxProfitOnce(prices: number[]) {
  let minPrice = Number.POSITIVE_INFINITY;
  let best = 0;

  for (const price of prices) {
    minPrice = Math.min(minPrice, price);
    best = Math.max(best, price - minPrice);
  }

  return best;
}

console.log(maxProfitOnce([7, 1, 5, 3, 6, 4]));
// 5

只能交易一次时,1->53->6 不能同时拿。交易次数这个约束,直接改变了可贪的结构。

如果再加手续费,就更不能简单累加所有正差值:

ts 复制代码
function maxProfitWithFee(prices: number[], fee: number) {
  let cash = 0;
  let hold = -prices[0];

  for (let i = 1; i < prices.length; i++) {
    const nextCash = Math.max(cash, hold + prices[i] - fee);
    const nextHold = Math.max(hold, cash - prices[i]);

    cash = nextCash;
    hold = nextHold;
  }

  return cash;
}

console.log(maxProfitWithFee([1, 3, 2, 8, 4, 9], 2));
// 8

手续费会让"今天涨了就吃掉"变成坏策略,因为频繁交易会多付手续费。这里已经变成状态 DP:cash 表示不持股,hold 表示持股。

同样是股票题,题目条件变一个字,算法边界就变。

Candy:两遍扫描不是模板,是约束方向

LeetCode 135,Candy。

规则:

txt 复制代码
每个孩子至少 1 颗糖
rating 更高的孩子,要比相邻孩子糖更多

只从左到右扫,只能处理左邻居约束:

txt 复制代码
如果 ratings[i] > ratings[i - 1],candies[i] = candies[i - 1] + 1

但右邻居约束也存在:

txt 复制代码
如果 ratings[i] > ratings[i + 1],candies[i] 也要比 candies[i + 1] 大

所以要两遍:

ts 复制代码
function candy(ratings: number[]) {
  const candies = Array(ratings.length).fill(1);

  for (let i = 1; i < ratings.length; i++) {
    if (ratings[i] > ratings[i - 1]) {
      candies[i] = candies[i - 1] + 1;
    }
  }

  for (let i = ratings.length - 2; i >= 0; i--) {
    if (ratings[i] > ratings[i + 1]) {
      candies[i] = Math.max(candies[i], candies[i + 1] + 1);
    }
  }

  return candies.reduce((sum, x) => sum + x, 0);
}

console.log(candy([1, 3, 2, 2, 1]));
// 7,对应 [1, 2, 1, 2, 1]

第二遍的 Math.max 不是随手补丁。

它是在保留第一遍已经满足的左侧约束,同时补齐右侧约束。

这类题别硬套"一次扫描选最优"。先看约束来自几个方向,再决定扫几遍。

Dijkstra:贪心成立靠的是非负边权

Dijkstra 每次确定当前距离最小的未访问点。

这个选择为什么安全?

因为边权非负。只要所有边权都 >= 0,其他未确定路径绕一圈回来,只会更长,不可能把当前最小点变得更短。

负边会直接打破这个安全性:

txt 复制代码
A -> B: 2
A -> C: 5
C -> B: -4

A 出发,Dijkstra 会先确定 B = 2。但真实最短路是 A -> C -> B = 1

代码对照一下:

ts 复制代码
type Edge = { to: string; weight: number };
type Graph = Record<string, Edge[]>;

function dijkstra(graph: Graph, start: string) {
  const dist: Record<string, number> = {};
  const visited = new Set<string>();

  for (const node of Object.keys(graph)) {
    dist[node] = Number.POSITIVE_INFINITY;
  }
  dist[start] = 0;

  while (visited.size < Object.keys(graph).length) {
    let u = '';
    let best = Number.POSITIVE_INFINITY;

    for (const [node, value] of Object.entries(dist)) {
      if (!visited.has(node) && value < best) {
        u = node;
        best = value;
      }
    }

    if (!u) break;
    visited.add(u);

    for (const edge of graph[u]) {
      if (visited.has(edge.to)) continue;
      dist[edge.to] = Math.min(dist[edge.to], dist[u] + edge.weight);
    }
  }

  return dist;
}

const graphWithNegativeEdge: Graph = {
  A: [
    { to: 'B', weight: 2 },
    { to: 'C', weight: 5 },
  ],
  B: [],
  C: [{ to: 'B', weight: -4 }],
};

console.log(dijkstra(graphWithNegativeEdge, 'A'));
// { A: 0, B: 2, C: 5 }

Bellman-Ford 不提前锁死节点,它会反复松弛边:

ts 复制代码
function bellmanFord(graph: Graph, start: string) {
  const nodes = Object.keys(graph);
  const dist: Record<string, number> = {};

  for (const node of nodes) {
    dist[node] = Number.POSITIVE_INFINITY;
  }
  dist[start] = 0;

  for (let i = 0; i < nodes.length - 1; i++) {
    for (const from of nodes) {
      for (const edge of graph[from]) {
        if (dist[from] + edge.weight < dist[edge.to]) {
          dist[edge.to] = dist[from] + edge.weight;
        }
      }
    }
  }

  return dist;
}

console.log(bellmanFord(graphWithNegativeEdge, 'A'));
// { A: 0, B: 1, C: 5 }

Dijkstra 不是"最短路都能贪"。

它是"非负边权最短路可以贪"。

贪心的正确性,经常就藏在这种题目限制里。

加权区间调度:同一题型,目标变了就不能贪

无权区间调度,按结束时间选,求的是最多选几个。

如果每个区间有收益,目标变成收益最大,结束时间贪心就会翻车:

txt 复制代码
A: 1-3, value=50
B: 3-5, value=50
C: 1-5, value=120

结束时间贪心会选 A + B = 100

最优解是 C = 120

这时要 DP:

ts 复制代码
type WeightedInterval = {
  start: number;
  end: number;
  value: number;
};

function weightedIntervalScheduling(items: WeightedInterval[]) {
  const intervals = [...items].sort((a, b) => a.end - b.end);
  const dp = Array(intervals.length + 1).fill(0);

  function lastNonConflict(index: number) {
    let left = 0;
    let right = index - 1;
    let answer = -1;

    while (left <= right) {
      const mid = Math.floor((left + right) / 2);

      if (intervals[mid].end <= intervals[index].start) {
        answer = mid;
        left = mid + 1;
      } else {
        right = mid - 1;
      }
    }

    return answer;
  }

  for (let i = 1; i <= intervals.length; i++) {
    const current = intervals[i - 1];
    const prev = lastNonConflict(i - 1);
    const take = current.value + dp[prev + 1];
    const skip = dp[i - 1];

    dp[i] = Math.max(skip, take);
  }

  return dp[intervals.length];
}

console.log(
  weightedIntervalScheduling([
    { start: 1, end: 3, value: 50 },
    { start: 3, end: 5, value: 50 },
    { start: 1, end: 5, value: 120 },
  ]),
);
// 120

同样是区间,目标从"数量最多"变成"收益最大",原来的交换论证就没了。

不要把题型标签当算法答案。

我现在判断贪心的流程

我一般按这个顺序走:

txt 复制代码
1. 写一个候选贪心:按什么排序,每步选什么,维护什么状态
2. 写一个 oracle:小数据用暴力,稍大一点用 DP
3. 枚举小 case,先找反例
4. 找不到反例,再补证明
5. 证明时只问两件事:当前选择能不能进最优解,剩下问题还是不是同类问题

几类常见题可以先按这个表想:

txt 复制代码
问题形态             常见策略                  判断点
---------------------------------------------------------------
无权区间调度          按结束时间选              交换论证
区间覆盖              每次扩到最远右端点          领先证明
分数背包              按单位价值选              物品可切分
0-1 背包              单位价值贪心会错           DP
股票 II              累加正差值                上涨段可拆
股票含手续费          cash / hold 状态           DP
Dijkstra             当前最短点定型             边权非负
一般找零钱            最大面额优先              非 canonical 币制会翻车
加权区间调度          结束时间贪心会错           DP + 二分前驱

这张表不是答案表。

它只是提醒你:每个贪心策略,都要绑定一个能支撑它的结构。

没有结构,只有直觉。

最后

贪心不是少想一步。

贪心是先证明这一步不会害你,然后才敢删掉其他分支。

看到一道题像贪心,我现在会先写 oracle 找反例。小数据都过了,再想交换论证、领先证明、题目限制。

这个流程慢一点,但比拿一个看起来很顺的 sort + for 去赌强的多。

参考资料

相关推荐
代码北人生1 小时前
GitHub 日榜第一、月下载 110 万:supervision 出现之前,写计算机视觉代码是什么感觉
算法·claude
南宫萧幕1 小时前
HEV能量管理策略 Simulink 实战:从零搭建 Rule-based 与 A-ECMS 对比模型及排错指南
人工智能·算法·matlab·simulink·控制
WBluuue2 小时前
Codeforces 1095 Div2(ABCDE)
c++·算法
IT当时语_青山师__JAVA技术栈2 小时前
数组与链表深度解析:从内存布局到工业级实践
java·算法·面试
吃着火锅x唱着歌2 小时前
LeetCode 496.下一个更大元素I
算法·leetcode·职场和发展
不知名的忻2 小时前
关键路径(Java)
java·数据结构·算法·关键路径
大大杰哥2 小时前
2025ccpc南昌补题笔记(前六题)
c++·笔记·算法
手写码匠2 小时前
手写 AI 智能路由系统:从零构建多模型调度与负载均衡
人工智能·深度学习·算法·aigc
sheeta19982 小时前
LeetCode 每日一题笔记 日期:2026.05.14 题目:2784. 检查数组是否是好的
笔记·算法·leetcode