
贪心算法最容易坑人的地方,是它的代码通常太短。
一排序,一遍循环,样例过了。
然后错在第 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
18 比 14 大,但 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->5 和 3->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 去赌强的多。