力扣算法解析按专题吃透,不做题海 速查 90 天路线 ⌂ 全部教程

专题 11贪心算法

贪心算法听起来很「聪明」:每一步都选当前看起来最优的,期望最后就是全局最优。 但贪心也是最容易写错的一类——因为「局部最优能推出全局最优」并不是理所当然的,很多题你以为能贪,其实一贪就错。 这一章的核心不是教你「怎么贪」,而是先教你「什么时候能贪、怎么用反例验证」,再用 4 道经典题把最常见的贪心套路固化下来。 学这章前建议先过 专题 10,因为「贪心 vs DP」的对比最能说明问题。

学习目标
能判断贪心是否成立,掌握区间调度套路
前置要求
专题 10 的 DP(用于对比)
本章产出
4 道贪心题 + 区间题通用套路表
建议学时
6–8 小时

11.1什么时候能贪,什么时候必须 DP

贪心算法的本质假设是:局部最优选择,累积起来就是全局最优。这个假设成立,贪心才正确;不成立,就得用 DP 枚举所有选择。

一个「贪心会错」的反例(必看)

拿 10.5 零钱兑换 改一下:硬币面额是 {1, 3, 4},要凑出金额 6,求最少硬币数。

如果「贪心」——每次都选不超过剩余金额的最大面额:6 先选 4,剩 2 再选 1、1。于是 4+1+1 = 6,用了 3 个硬币。

但最优解是 3+3 = 6,只用 2 个硬币!贪心给出的 3 个并不是最少。

贪心:6 -> 选4 -> 剩2 -> 选1 -> 选1   共 3 枚  (4+1+1)
最优:6 -> 选3 -> 选3                共 2 枚  (3+3)

为什么贪心错了?因为「先塞一个大面额」这个局部最优,可能让剩下的零头变得不好凑(剩 2 只能用 1+1)。 局部最优没有推出全局最优。这题的正确解法是 DP(完全背包),把所有组合都考虑一遍才安全。

反例验证法:你每想到一个贪心策略,先逼自己找一个「贪心失败」的小例子。 找不到反例,才敢用贪心;一旦找到,立刻改用 DP。这是避免「贪心写错」的唯一可靠办法, 比任何「直觉」都管用。

那贪心什么时候一定对

有两类情况贪心「通常」安全(但最好仍有证明或反例验证):

一句话总结本章立场:贪心不是「更优的 DP」,而是「在能证明成立的前提下,比 DP 更简单的写法」。 证明不了,就老老实实 DP。

贪心 vs DP:什么时候该用哪个(决策表)

拿到题如果犹豫「贪心还是 DP」,对照下面这张表快速定位:

信号倾向理由
问「最少/最多」且选择互相独立影响(如选了 A 会影响后面能不能选 B)DP局部最优不能保证全局,需要枚举
「区间调度」类(留最多不重叠)贪心「结束最早」有严格证明
「维护一个极值后推导」(最低价、最远可达)贪心极值单调,当前最优可传导
「选/不选」且选了有容量/相邻约束DP本质是 01/完全背包
硬币面额非标准、凑钱求最少DP贪心可能错(见 {1,3,4} 反例)
硬币是标准货币体系(1/2/5 或 1/5/10)贪心整除性好,贪心恰好正确

记不住没关系,反例验证法永远是第一位的:先假设能贪,逼自己找个反例;找不到再写贪心,找到就换 DP。这张表只是帮你在「找反例之前」有个预判方向。

面试时怎么「证明」你的贪心

面试官常会追问「你这个贪心为什么是对的」。三种层级的回答,由弱到强:

日常刷题能说到「反例验证 + 交换论证」基本就稳了;真在面试里被深究,至少有话可说,而不是一句「直觉上是对的」。


11.2买卖股票的最佳时机(简单,力扣 121)

简单 核心:维护历史最低价

题意:给定每天的价格数组 prices,你只能买卖一次(先买后卖),求最大利润。 例如 prices = [7,1,5,3,6,4],在第 2 天(价 1)买、第 5 天(价 6)卖,利润 5,是最大。

思路推导

暴力是枚举所有「买日 i < 卖日 j」的 pairs[i] - prices[i],O(n²)。能不能一次遍历搞定?

关键转换:利润 = 卖出价 − 买入价。对你经过的每一个「卖日 j」,想让利润最大,就要让「买入价」尽可能低—— 而最低的买入价,就在 j 之前出现过。所以我们一边走一边记录「到今天为止的历史最低价」minPrice, 对每一天 j 算 prices[j] - minPrice,维护最大差值即可。

为什么这样对?因为 minPrice 是「截至 j 的最低」,prices[j] - minPrice 就是「在 j 及之前某天买、j 卖」的最大利润。 遍历所有 j 取最大,就覆盖了所有合法 (买,卖) 组合。这一步没有漏掉任何组合,又没重复算——这就是贪心成立的根基:历史最低价永远是可买的最优点。

参考代码

class Solution {
    public int maxProfit(int[] prices) {
        int minPrice = Integer.MAX_VALUE;   // 历史最低买入价,初始化为极大值
        int maxProfit = 0;                   // 最大利润,最少为 0(不交易)
        for (int p : prices) {
            if (p < minPrice) {
                minPrice = p;                // 遇到更低价,更新最低买入价
            } else {
                // 否则尝试在今天卖出,看利润是否更大
                maxProfit = Math.max(maxProfit, p - minPrice);
            }
        }
        return maxProfit;
    }
}

也可以写成更直白的等价形式(每天先更新 minPrice,再算利润):

class Solution {
    public int maxProfit(int[] prices) {
        int minPrice = Integer.MAX_VALUE;
        int maxProfit = 0;
        for (int p : prices) {
            minPrice = Math.min(minPrice, p);          // 维护历史最低
            maxProfit = Math.max(maxProfit, p - minPrice);
        }
        return maxProfit;
    }
}

复杂度:时间 O(n)(一遍扫描),空间 O(1)。n ≤ 10⁵,稳。

亲手走一遍:prices = [7,1,5,3,6,4]

day0 p=7: minPrice=7,  profit=max(0,7-7)=0
day1 p=1: 1<7 -> minPrice=1,  profit=0
day2 p=5: profit=max(0,5-1)=4
day3 p=3: profit=max(4,3-1)=4
day4 p=6: profit=max(4,6-1)=5   <- 最大利润出现在第5天卖
day5 p=4: profit=max(5,4-1)=5
最终 maxProfit = 5(第2天买价1,第5天卖价6)。

注意 minPrice 只在「遇到更低价」时更新,所以它始终代表「截至今天的历史最低买入点」; profit 则在对每夭的「今天卖」做候选比较。两者配合,等价于把 O(n²) 的所有 (买,卖) 对压缩成了一次扫描。

关键点:「维护一个极值(最低价/最高价),再用它推导答案」是贪心里极常见的套路。 判断它成立的方法:问自己「对当前这天,历史最低价是不是我理论上能拿到的最优买入点?」——是的,所以贪心正确。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 买卖股票的最佳时机 II(力扣 122,中等)——与本题共用「在价格序列上做最优买卖决策」技巧;差异在可以进行多笔交易(多次买卖),只要后一天比前一天高就累加利润(提示:把「每天相对昨天的上涨」都赚到,等价于贪心收集所有上升段,无需再管最低价)。
  • 最佳买卖股票时机含冷冻期(力扣 309,中等)——与本题共用「在价格序列上求最大利润」场景;差异在卖出后有一天的冷冻期不能买,需引入「持有/已卖/冷冻」状态做 DP(提示:用三个状态 dp 分别表示当天结束时处于持有、刚卖出、冷冻,按天转移取最大)。

11.3跳跃游戏(中等,力扣 55)

中等 核心:维护「能到达的最远位置」

题意:数组 nums[i] 表示你在位置 i 最多能向右跳 nums[i] 步。问能否从下标 0 跳到最后一个下标。 例如 nums = [2,3,1,1,4] 可以(0→1→4);nums = [3,2,1,0,4] 不行(到 3 后跳 0 步,卡住)。

思路推导

不要去真的「跳」——真正要回答的只是「最后一个位置在不在可达范围内」。 我们维护一个变量 reach = 当前能到达的最远下标。从左到右扫描:

为什么这比 DP 简洁而且正确?DP 会算「每个位置能不能到」,但我们发现:只要「最远可达」一路追上最后下标,中间就一定有路—— 因为能到 reach 范围内的任意位置(每步至少走 1,可达性是连续的)。所以只需追一个 reach 就够了,不用存整个布尔数组。

参考代码

class Solution {
    public boolean canJump(int[] nums) {
        int reach = 0;                       // 当前能到达的最远下标
        for (int i = 0; i < nums.length; i++) {
            if (i > reach) return false;     // 连 i 都到不了,后面更到不了
            // 从 i 出发能到 i + nums[i],更新最远可达
            reach = Math.max(reach, i + nums[i]);
            // 若已覆盖最后一个下标,提前成功
            if (reach >= nums.length - 1) return true;
        }
        return true;
    }
}

复杂度:时间 O(n),空间 O(1)。n ≤ 10⁴,轻松。

亲手走一遍:nums = [2,3,1,1,4] 和 [3,2,1,0,4]

能到达的例子:

i=0: i<=reach(0) ok, reach=max(0,0+2)=2
i=1: i<=reach(2) ok, reach=max(2,1+3)=4  -> 已>=4(末位),return true
剩余不用看,已经覆盖终点。

不能到达的例子 nums=[3,2,1,0,4]:

i=0: reach=max(0,0+3)=3
i=1: 1<=3 ok, reach=max(3,1+2)=3
i=2: 2<=3 ok, reach=max(3,2+1)=3
i=3: 3<=3 ok, reach=max(3,3+0)=3   <- 到位置3后最多还是到3,卡住
i=4: 4>reach(3) -> 连位置4都到不了,return false

关键就在 i=3 那一步:nums[3]=0 意味着从位置 3 一步都走不了,而 reach 停在 3,后面的 i=4 自然 i > reach 直接失败。 这个变量 reach 把「能不能到终点」整个压成了一行扫描,非常典型地体现了贪心「只追一个极值」的简洁。

关键点:跳跃游戏系列的灵魂就是 reach = max(reach, i + nums[i])。 它把「能不能到达终点」这个看似要搜的问题,压成了一次线性扫描。和 DP 对比: 这里「最远可达」单调不减、且能覆盖中间所有点,所以一个变量足够;DP 反而多余。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 跳跃游戏 II(力扣 45,中等)——与本题共用「维护 reach 最远可达」技巧;差异在本体保证能到终点、要的是最少跳跃次数(提示:用 curEnd / curFarthest 两个边界做「逐层跳」,走到层边界才计数 +1 步,等价于 BFS 层序)。
  • 跳跃游戏 IV(力扣 1345,困难)——与本题共用「在数组上跳格求可达性/最少步」思想;差异在每个位置可跳到 i±1 以及所有值相同的下标,用 BFS 分层求到目标的最少步(提示:先建「同值下标」索引,BFS 每层扩展三种跳法,首次到目标即最少步)。

11.4跳跃游戏 II(中等,力考 45)

中等 核心:「当前层边界」式贪心,步数最少

题意:同跳跃游戏,但保证一定能到达终点,求最少跳跃次数。 例如 nums = [2,3,1,1,4],最少跳 2 次(0→1→4)。

思路推导

既然要「次数最少」,就不能走到哪算哪,得一层一层地跳。想象 BFS:第 1 跳能覆盖一个范围,第 2 跳在它基础上再往外扩。 我们用两个边界:

当我们走到 curEnd 时,说明「当前这层已经走完」,必须再跳一步才能继续往前——于是步数 +1, 并把下一层的边界更新为 curFarthest。这样每一层只跳一步,且每步都尽量远,自然步数最少(这是贪心:每层都选能到的最远,避免提前浪费跳跃)。

参考代码

class Solution {
    public int jump(int[] nums) {
        int n = nums.length;
        if (n == 1) return 0;                // 已经在终点,0 跳
        int jumps = 0;                       // 已跳次数
        int curEnd = 0;                      // 当前层的边界(这一跳能到的最远)
        int curFarthest = 0;                 // 扫描当前层能到达的下一层最远
        for (int i = 0; i < n - 1; i++) {    // 注意只需到 n-1,到达即停
            curFarthest = Math.max(curFarthest, i + nums[i]);
            if (i == curEnd) {               // 走到当前层边界,必须再跳一步
                jumps++;
                curEnd = curFarthest;        // 下一层的边界 = 本次能到的最远
                if (curEnd >= n - 1) break;  // 已覆盖终点,提前结束
            }
        }
        return jumps;
    }
}

复杂度:时间 O(n),空间 O(1)。每个位置只扫一次。

亲手走一遍:nums = [2,3,1,1,4]

i=0: curFarthest=max(0,0+2)=2;  i==curEnd(0) -> jumps=1, curEnd=2
i=1: curFarthest=max(2,1+3)=4;  i(1)!=curEnd(2)
i=2: curFarthest=max(4,2+1)=4;  i(2)==curEnd(2) -> jumps=2, curEnd=4
i=3: curFarthest=max(4,3+1)=4;  i(3)!=curEnd(4)
i=4: 循环到 n-1=4 结束(其实到 i=3 后 curEnd=4 已 >= n-1,break)
结果 jumps = 2。

可以看到:第 1 跳(jumps=1)把边界推到 2,覆盖下标 0~2;第 2 跳(jumps=2)把边界推到 4,正好到终点。 两层对应两条跳跃路径,层数 = 最少步数,和 BFS 层序完全吻合。

为什么步数最少:我们每次都在「不得不跳」时才跳(走到层边界),且每次把下一层尽可能推远。 这等价于 BFS 的层序遍历——BFS 天然给出「到达每个点的最少步数」,这里用两个边界变量把 BFS 压成了 O(1) 空间。 所以「最少」不是碰巧,是 BFS 层序的必然结果。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 跳跃游戏 IV(力扣 1345,困难)——与本题共用「最少步数 = BFS 分层」思路;差异在跳法升级为「i±1 或跳到任意同值下标」,需先建同值索引再做 BFS(提示:用 visited 去重,每层把当前位置三种跳法全部入队,首达目标即答案)。
  • 视频拼接(力扣 1024,中等)——与本题共用「用最少段覆盖到终点」的贪心;差异在候选是给定片段区间 [start,end],要选最少的拼出整段 [0,T](提示:按片段起点排序,每次在能接上的片段里选终点最远的,和本题「每层推到最远」同理)。

11.5无重叠区间(中等,力扣 435)

中等 核心:按结束时间排序的区间调度

题意:给若干闭区间 intervals,删掉最少数量的区间,使剩下的互不重叠。返回要删的个数。 例如 intervals = [[1,2],[2,3],[3,4],[1,3]],删掉 [1,3] 即可,返回 1。

思路推导

「删最少」等价于「留下最多且不重叠的区间」。这就是经典区间调度问题。 贪心策略(有严格证明):每次选「结束最早」的区间。为什么?

结束得越早,后面留给其他区间的空间就越大。选了一个早结束的,就排除了所有与它重叠的(这些晚结束的本来也占更多空间), 于是剩下的可选区间更多——局部最优(早结束)确实推出全局最优(留得最多)。

做法:

参考代码

class Solution {
    public int eraseOverlapIntervals(int[][] intervals) {
        // 按区间右端点升序排序:贪心选「结束最早」的
        Arrays.sort(intervals, (a, b) -> Integer.compare(a[1], b[1]));
        int count = 0;                  // 被删(重叠)的区间个数
        int end = intervals[0][1];      // 上一个保留区间的结束时间
        for (int i = 1; i < intervals.length; i++) {
            if (intervals[i][0] < end) {
                // 当前区间左端点 < 上一个个结束 -> 重叠,删掉当前
                count++;
            } else {
                // 不重叠,保留,并更新结束时间
                end = intervals[i][1];
            }
        }
        return count;
    }
}

复杂度:时间 O(n log n)(排序主导),空间 O(1)(忽略排序栈)。n ≤ 10⁵。

亲手走一遍:intervals = [[1,2],[2,3],[3,4],[1,3]]

先按右端点排序,得到 [[1,2],[2,3],[1,3],[3,4]](右端点 2,3,3,4)。注意 [1,3] 和 [2,3] 右端点都是 3,顺序无关紧要:

保留 [1,2],end=2
下一个 [2,3]:left=2 >= end=2 -> 不重叠,保留,end=3
下一个 [1,3]:left=1 < end=3  -> 重叠,删掉(count=1)
下一个 [3,4]:left=3 >= end=3 -> 不重叠,保留,end=4
最终 count = 1,删 [1,3] 即可。

为什么保留 [1,2] 而不是 [1,3]?因为 [1,2] 结束更早(2 < 3),给后面 [2,3]、[3,4] 腾出了位置; 若贪心选了 [1,3],那 [2,3] 和 [3,4] 都会因重叠被删,留下的更少。「结束最早 → 留得最多」在这个例子里一目了然。

关键点:区间调度「选结束最早的」有证明,是贪心稳赢的少数题型之一。 注意重叠判定用 左端点 < 上一个个结束:本题是闭区间,端点相接(如 [1,2] 与 [2,3])不算重叠,所以用严格小于 <。 若题意把端点相接也算重叠,要改成 ≤。见 11.7 坑二。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 用最少数量的箭引爆气球(力扣 452,中等)——与本题共用「按右端点升序 + 区间调度」套路;差异在本题是「删重叠留最多」,452 是「互相重叠的气球归到同一支箭」,做法相同只是把「保留」改成「合并到同箭」(提示:按右端点排序,新区间左端点 ≤ 当前箭 end 就共用一箭,end 取较小右端点收紧)。
  • 合并区间(力扣 56,中等)——与本题共用「区间排序处理」;差异在要的是把重叠区间合并成一个大区间,而非计数删几个(提示:按左端点升序,遍历时若当前区间与结果集最后一个重叠就合并右端点取较大,否则直接加入)。

11.6区间题的通用套路

区间/线段类题在面试里出现频率很高,而且套路集中。下面这张表把「按什么排序、适合什么问题」一次性列清:

排序方式适合的问题典型题
按右端点升序最多不重叠区间 / 删最少使不重叠(区间调度)435 无重叠区间、452 用最少箭引爆气球
按左端点升序合并重叠区间、插入区间后合并56 合并区间、57 插入区间
按左端点升序 + 维护堆会议室最多能同时进行的会议数(区间最多重叠点)253 会议室 II
按区间长度 / 起点差某些特殊贪心(较少,需具体验证)视题意而定
速记:「要留最多 → 按右端点排;要合并 → 按左端点排」。 拿到区间题先想清楚要「调度」还是「合并」,再决定排序键,套路就出来了。

11.7本专题常见坑

  • 坑一:「能贪」没验证就写。 最典型的就是 11.1 的零钱反例。任何贪心策略先找反例,找不到才用;找到立刻改 DP。
  • 坑二:端点相接算不算重叠看题意。 本题 [1,2] 与 [2,3] 闭区间相接不算重叠,用 left < end;有的题把相接也算重叠,要改 ≤。 写之前一定看清楚「闭区间还是开区间、端点共享是否允许」。
  • 坑三:比较器写法错。 Arrays.sort(a, (x,y) -> x[1]-y[1]) 在数值大时会整数溢出。务必用 Integer.compare(a[1], b[1]) 或 Comparator.comparingInt。
  • 坑四:跳跃游戏 II 循环边界错。 jump 只需遍历到 n-1(到达即停);若遍历到 n 会把终点也当成「要跳一步」的起点,或导致 curEnd 越界逻辑乱。
  • 坑五:买卖股票忘了「先买后卖」。 minPrice 必须先于利润计算被更新(或用 else 分支),否则会用到「未来更低价」当买入点,逻辑错。
  • 坑六:把贪心当 DP 的优化直接套。 有些题贪心看似能写但无证明,宁可多花 O(n) 写 DP 保对。面试里「稳」比「巧」重要。
自检:贪心题写完,问自己——我这个策略找到反例了吗?端点相接的边界我核对题意了吗? 比较器用了安全的写法吗?三问过了,基本稳。

11.8自测题

Q1.【动手题】零钱兑换面额 {1,3,4} 凑 6,贪心给 3 枚而最优是 2 枚。如果面额是 {1,2,5}(美元体系),贪心还错吗?为什么?

对「整除性良好」的面额(如 1/2/5 或 1/5/10/25),贪心恰好正确——这是货币学的结论,每种面额都 ≥ 下一档的 2 倍时贪心必胜。 但这不是普遍成立,所以仍建议先用反例验证;遇 {1,3,4} 这种非标准面额就立刻换 DP(完全背包)。

Q2. 跳跃游戏 II 里,为什么用「走到层边界才跳」能保证步数最少,而不是「能跳就跳」?

「能跳就跳」会提前消耗跳跃次数,可能把一步能覆盖的范围拆成两步。分层法等价于 BFS 层序: 每一层代表「跳 k 步能到的最远范围」,到达某点的最少步数就是它的层号。我们在每层末尾才 +1 步并把边界推到最远, 所以每步都物尽其用,步数必然最少。

Q3.【动手题】力扣 452「用最少数量的箭引爆气球」:一堆重叠区间(气球),一支箭能引爆所有互相重叠的气球,求最少箭数。它和无重叠区间(435)有何异同?

同:都按右端点升序排序。异:435 是「删重叠区间留最多」,452 是「把互相重叠的归到同一箭」。 做法:按右端点排序后,维护当前箭的 end;若新区间左端点 ≤ end(与当前箭覆盖重叠)就共用一箭(end 取两者较小右端点,越收越紧), 否则新发一支箭。本质仍是「结束最早」贪心,只是「保留」变「合并到同一支箭」。

学完这章,你应该能回答:「怎么判断一道题能不能用贪心?贪心写错了最可能栽在哪?」 答案:先想「局部最优能否推出全局最优」,并主动找反例(如 {1,3,4} 凑 6); 找不到反例才贪,找到就改 DP。最高频的坑是:没验证就贪、端点相接边界看错题意、比较器整数溢出。 能讲清反例验证法和区间调度套路,本专题过关。下一步是 专题 12 · 图论入门。