LeetCode Hot 100 · 贪心篇
贪心算法的核心套路只有一句话:每一步都做「当前看起来最优」的选择,并证明局部最优能推出全局最优。
在面试手撕场景里,贪心题真正的难点从来不是代码(贪心代码通常都很短),而是你凭什么敢这么贪——为什么这样选一定对?为什么不会漏掉更优解?以及那些一贪就错的边界。
Hot 100 里贪心分类共 4 道题,正好覆盖了贪心最常见的四种形态:
- 121. 买卖股票的最佳时机 —— 一次遍历维护「历史极值」
- 55. 跳跃游戏 —— 贪心维护「最远可达位置」(可行性判断)
- 45. 跳跃游戏 II —— 贪心按「轮次」扩展(最少步数)
- 763. 划分字母区间 —— 贪心做「区间合并/切分」
下面逐题拆解,每道题都从题意、难点易错点,到暴力解法、最优解、多解法对比、CodeTop 真实变体,一路走到底。
#121 买卖股票的最佳时机
题意
给定每天股票价格数组 prices,你只能完成一笔交易(一次买入、一次卖出),返回最大利润;若不能盈利则返回 0。
输入:prices = [7,1,5,3,6,4]
输出:5 (第 2 天买入价格 1,第 5 天卖出价格 6,利润 6-1=5)
难点与易错点
- 只能交易一次,不能反复买卖。利润必须是「某个卖出日 - 某个更早的买入日」,所以本质是找一个
(买入日 i, 卖出日 j)且i < j,使prices[j] - prices[i]最大。很多人一上来就想「低买高卖多次累加」,那是 122 题,不是这题。 - 为什么局部最优能推出全局最优:这是本题最关键的论证点。假设我们已经遍历到第
i天,历史最低买入价是minPrice。对于「在第i天卖出」这个决策,最大利润必然等于prices[i] - minPrice(因为卖出价已定,买入价越低越好,而能选的最低价就是历史最低)。于是全局答案就是所有「在第 i 天卖出的最优利润」的最大值。贪心「每个卖出日都搭配历史最低买入价」之所以不漏解,是因为任何一个最优解的卖出日 j,其对应的最优买入日一定是 [0, j) 里的最低价——这正是遍历时维护minPrice覆盖到的。 - 边界/易错细节:
minPrice初值必须设成Integer.MAX_VALUE或prices[0],否则第一天会被误判。maxProfit初值为 0(题目要求「不能盈利则返回 0」,所以不能初始化成Integer.MIN_VALUE)。minPrice只能来自「今天及更早」的价格,遍历顺序天然保证了不会把明天的价格算进买入价。
解法一:暴力 / 直观
枚举所有「买入日 i、卖出日 j(j > i)」的二元组,取利润最大值。
class Solution {
public int maxProfit(int[] prices) {
int n = prices.length;
int maxProfit = 0;
// 枚举买入日 i 和卖出日 j,要求 i < j
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
maxProfit = Math.max(maxProfit, prices[j] - prices[i]);
}
}
return maxProfit;
}
}
复杂度推导:
- 外层循环
i从0到n-1,共n次;内层循环j从i+1到n-1,对每个i平均执行约(n-1-i)次。总执行次数为(n-1) + (n-2) + ... + 1 = n(n-1)/2,即 O(n²)。 - 空间复杂度:只用常数个变量,O(1)。
瓶颈:对每个卖出日 j,都从头重新扫了一遍历史最低价,重复计算严重。观察发现「第 j 天的历史最低价」完全可以由「第 j-1 天的历史最低价」递推得到,不需要重新扫一遍——这就是下一步优化的切入点。
解法二:优化 / 最优(贪心)
题目本质:一次遍历维护「最低买入价」。记录历史最低价 minPrice,每天计算「今天卖出的利润」并更新最大值。
现实类比:炒股策略——每天看当前价,如果比历史最低价还低就更新「最佳买入点」,否则计算「今天卖出能赚多少」,取全局最大值。
class Solution {
public int maxProfit(int[] prices) {
int minPrice = Integer.MAX_VALUE; // 历史最低买入价
int maxProfit = 0; // 全局最大利润
for (int price : prices) {
minPrice = Math.min(minPrice, price); // 更新历史最低买入价
maxProfit = Math.max(maxProfit, price - minPrice); // 今天卖出 vs 历史最优
}
return maxProfit;
}
}
> **💭 思考**:为什么买卖股票一次交易能贪心,核心的「维护历史最低价」是怎么来的?一步步想——暴力枚举所有 (买入日, 卖出日) 的瓶颈是「对每个卖出日都从头重扫历史最低价」,重复计算严重。观察发现「第 j 天的历史最低价」可以由「第 j-1 天的」递推得到:`minPrice = min(minPrice, price)`。于是把「求前缀最小值」从每次 O(n) 降到增量 O(1),一趟扫描就得到全局最大利润。贪心的依据是:卖出日一旦固定,最优买入日必然是它的前缀最低价,所以维护 `minPrice` 不会漏解。
复杂度逐步推导:
- 时间:只有一个
for循环,循环体对每个元素执行常数次比较/赋值,遍历n个元素,所以是O(n)。相比暴力 O(n²),本质是把「求前缀最小值」从每次 O(n) 降到 O(1) 的增量维护。 - 空间:只用
minPrice、maxProfit两个变量,不随n增长,O(1)。
多解法对比
| 解法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 暴力枚举二元组 | O(n²) | O(1) | 仅用于验证思路;n > 1e4 就超时 |
| 贪心(一次遍历维护最低价) | O(n) | O(1) | 本题最优;Hot 100 标准答案 |
为什么选贪心:本题约束 n 可达 1e5,暴力 O(n²) 会超时(约 1e10 次运算)。贪心只需一遍扫描,且无额外空间。更重要的是它给出了「最优解结构」的证明——全局最优 = 每个卖出日搭配其前缀最低价的最大值,这个结构让贪心既是充分又是必要的。
CodeTop 变体
本题是「股票买卖系列」的入门题,字节/阿里高频追问方向是交易次数限制的推广:
- 122. 买卖股票的最佳时机 II(可多次交易):改为「累加所有上坡」——
sum(max(0, prices[i]-prices[i-1])),本质是把每段上涨都赚走。 - 123. 买卖股票的最佳时机 III(最多两笔):引入「状态机 DP」——
buy1/sell1/buy2/sell2四个状态,一次遍历转移。 - 188. 买卖股票的最佳时机 IV(最多 k 笔):上面 DP 泛化到 k 维;k 很大时可用「大根堆合并相邻区间」优化。
- 309. 最佳买卖股票时机含冷冻期、714. 含手续费:在状态机里加「冷冻/手续费」约束即可。
面试官真实追问通常是:「如果允许两次交易呢?」→ 直接手写 123 的状态机;「为什么贪心不能直接套到两次交易?」→ 因为两次交易的最优决策不满足「只维护一个前缀最低价」这种一维极值结构,需要 DP。
#55 跳跃游戏
题意
给定数组 nums,nums[i] 表示在位置 i 最多能跳跃的步数(可以跳 1..nums[i] 任意步)。判断能否到达最后一个位置。
输入:nums = [2,3,1,1,4] → true
输入:nums = [3,2,1,0,4] → false(卡在位置 3,nums[3]=0 无法前进)
难点与易错点
- 「最多跳 nums[i] 步」≠「必须跳 nums[i] 步」。这是最容易理解错的地方。每个位置是一个范围(
[i, i+nums[i]]内任意点都可到达),而不是只能跳到i + nums[i]这一个点。理解成「范围」后,贪心才有依据。 - 为什么局部最优能推出全局最优:维护
maxReach= 目前所有可达位置能延伸出的最远下标。关键不变量:[0, maxReach]区间内的每一个位置都必然可达。证明:能到达maxReach的那条路径经过某个位置p,而p可达意味着它之前也有一条完整路径,归纳下去整条路径都在[0, maxReach]内。于是只要遍历过程中i始终不超过maxReach,终点n-1一旦落入maxReach就可达。贪心每次取「能跳得最远」的选择,不会漏解——因为跳到更近的位置,其「未来能到的最远」一定不超过「当前最远 + 该点步数」的更新值,而maxReach已经把这个最大值收进了区间的并集。 - 边界/易错细节:
- 判断
i > maxReach必须在更新之前,否则可能用「尚未到达」的位置去扩展。 - 空数组 / 单元素数组(
nums=[0])返回 true(已经在终点)。 - 有人写成「必须恰好跳到 n-1」,但实际只要
maxReach >= n-1即可提前返回 true,无需遍历到最后一格。
- 判断
解法一:暴力 / 直观(回溯 / BFS / DP)
思路:把每个位置看成图的节点,从 i 能连到 [i+1, i+nums[i]] 的所有节点,问 0 能否到 n-1。用 BFS 最直观。
import java.util.*;
class Solution {
public boolean canJump(int[] nums) {
int n = nums.length;
boolean[] visited = new boolean[n];
Deque<Integer> queue = new ArrayDeque<>();
queue.offer(0);
visited[0] = true;
while (!queue.isEmpty()) {
int cur = queue.poll();
if (cur == n - 1) return true; // 到达终点
// 尝试从 cur 跳到它能到的每个位置
for (int next = cur + 1; next <= Math.min(n - 1, cur + nums[cur]); next++) {
if (!visited[next]) {
visited[next] = true;
queue.offer(next);
}
}
}
return false; // 队列空仍未到终点,说明不可达
}
}
复杂度推导:
- 最坏情况下每个位置
nums[i]都很大,cur会向其后的所有点连边,边数可达 O(n²)(例如nums = [n-1, n-2, ..., 1]时,从位置 0 一次就入队 n-1 个节点,后续每个节点又几乎向所有后续节点重复入队)。时间 O(n²),空间(队列 + visited)O(n)。 - 瓶颈:BFS 对每个节点都要枚举其可达的每个后继点,产生大量重复扩展。但其实我们只关心「最远能到哪」,不需要逐点展开。
解法二:优化 / 最优(贪心)
题目本质:贪心维护「最远可达位置」。遍历时维护当前能到达的最远下标 maxReach,若当前下标超出 maxReach 则无法继续。
现实类比:过河跳石头——从每块石头能跳一段距离,维护目前能踩到的最远石头位置;走到某块石头时若它超出最远位置,就说明掉水里了。
class Solution {
public boolean canJump(int[] nums) {
int maxReach = 0; // 当前能到达的最远下标
for (int i = 0; i < nums.length; i++) {
if (i > maxReach) return false; // 当前位置超出可达范围,无法继续
maxReach = Math.max(maxReach, i + nums[i]); // 更新最远可达位置
if (maxReach >= nums.length - 1) return true; // 提前到达终点
}
return true;
}
}
> **💭 思考**:为什么「能否到达」用一个 `maxReach` 就够,而不是 BFS 逐点展开?一步步想——BFS 把每个位置看成节点、枚举每个可达后继,瓶颈是「只关心最远到哪,却逐点枚举所有后继」,边数可达 O(n²)。想清楚问题只问「能不能到」,只需维护一个上界:`[0, maxReach]` 内每个位置都可达(连续区间),所以每到一个位置,只需用它更新 `maxReach = max(maxReach, i + nums[i])`。一旦 `i > maxReach` 就是断点,`maxReach >= n-1` 就是到达。把「逐点搜索」压缩成「维护一个覆盖上界」,就是可行性判断的贪心。
复杂度逐步推导:
- 时间:一个
for循环,每个元素只访问一次,循环体是 O(1) 的比较/赋值,所以总时间 O(n)。相比 BFS 的 O(n²),贪心把「逐点枚举后继」压缩成了「只维护一个上界」,每个点贡献一次更新。 - 空间:仅
maxReach一个变量,O(1)。相比 BFS 的 visited 数组 + 队列 O(n),降到了常数级。
多解法对比
| 解法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| BFS(图搜索) | O(n²) | O(n) | 需要打印「具体一条路径」时 |
| 贪心(最远可达) | O(n) | O(1) | 只判断「能否到达」,Hot 100 标准答案 |
为什么选贪心:本题只问「能不能到」,不问「怎么到、走几步」。可行性判断天然适合贪心——只要维护一个「覆盖上界」就能回答。BFS 的额外信息(具体路径)在这里是浪费。约束下 n 可达 1e4,BFS 的 O(n²) 可能超时,贪心 O(n) 秒出。
CodeTop 变体
- 45. 跳跃游戏 II(下一题):从「能不能」升级到「最少几步」,同一个贪心框架加一个「批次边界」变量。
- 1306. 跳跃游戏 III:跳法改成「只能跳
i+arr[i]或i-arr[i]」,要判断能否到值为 0 的位置——这题不能贪心(可能回跳),得用 BFS/DFS 标记访问。 - 1345. 跳跃游戏 IV、1871. 跳跃游戏 VII、403. 青蛙过河:都是「可达性」的变种,分别加「同值传送」「DP 前缀和」「石子间隔」约束,面试官常用它们考察「什么时候贪心失效、什么时候必须 BFS/DP」。
面试常见追问:「为什么 55 能贪心,1306 不能?」→ 因为 55 的移动范围是单调向前的连续区间,可达集合始终保持 [0, maxReach] 的连续形态;而 1306 允许回跳,可达集合会「断断续续」,必须逐点搜索。
#45 跳跃游戏 II
题意
给定 nums(题目保证一定可以到达最后位置),返回到达最后一个位置所需的最少跳跃次数。
输入:nums = [2,3,1,1,4]
输出:2(0 -> 1 -> 4)
难点与易错点
- 贪心不能「每一步都选当前最远」。这是本题最大的坑。反例
nums = [3,4,2,1,0,4]:从 0 若直接跳 3 步到位置 3(nums[3]=1),再到 4,步数反而更多;正确是从 0 跳 1 步到 1(nums[1]=4)再直达。所以「当前点跳得最远」的局部贪心是错的——正确做法是先看清当前这一跳能覆盖的范围,再在范围内选「下一跳能延伸最远」的点。 - 为什么「按批次扩展」的局部最优能推出全局最优:把跳跃过程分层(类似 BFS 按层)。设
end为「上一跳能覆盖到的边界」,maxPos为「在 [当前批, end] 范围内,所有点能延伸出的下一跳最远位置」。任何到达终点的路径,其每一跳的落点都落在某一层内;要步数最少,等价于每一层都「跳到能让我下一层覆盖最远的那个落点」。因为题目保证可达,按「层边界」计数就能保证每一步都不浪费。这是贪心(取最大延伸)与 BFS(按层)的杂交,正确性来源是可达区间连续且单调向前。 - 边界/易错细节:
- 遍历只到
n-1,不包含最后一个元素:因为到达n-1时已经结束,不需要再跳。若把最后元素也纳入循环,会在i == end时多计一次ans。 - 更新
end = maxPos的时机是i == end,此时「当前批」已扫完,才把下一批边界替换进来。 - 单元素数组
nums=[0]应返回 0(已经在终点,无需跳),循环for i < 0直接不执行,ans=0,天然正确。
- 遍历只到
解法一:暴力 / 直观(DP)
dp[i] 表示跳到位置 i 的最少步数,用「谁能在一步内跳到 i」来转移。
import java.util.*;
class Solution {
public int jump(int[] nums) {
int n = nums.length;
int[] dp = new int[n];
Arrays.fill(dp, Integer.MAX_VALUE);
dp[0] = 0; // 起点 0 步
for (int i = 1; i < n; i++) {
// 找所有 j < i 且 j + nums[j] >= i,取 dp[j] 最小
for (int j = 0; j < i; j++) {
if (j + nums[j] >= i) {
dp[i] = Math.min(dp[i], dp[j] + 1);
}
}
}
return dp[n - 1];
}
}
复杂度推导:
- 外层
i从 1 到 n-1,共 n 次;内层j从 0 到 i-1,平均约 i/2 次。总次数0 + 1 + ... + (n-1) = n(n-1)/2,即 O(n²)。 - 空间:
dp数组长度 n,O(n)。 - 瓶颈:对每个
i都回头扫了所有j,而「能一步到 i 的 j」其实是一段连续前缀,可以用更高效的方式维护。
解法二:优化 / 最优(贪心 + BFS 分层)
题目本质:贪心按轮次扩展。将跳跃过程分成「轮次」(类似 BFS 按层),维护本轮覆盖终点 end 和下一轮终点 maxPos,每次到达 end 时必须跳(ans++)。
现实类比:公交路线接驳——第一趟车覆盖最远距离,第一趟覆盖范围内所有站都可以换乘第二趟,选覆盖最远的那趟换乘。
class Solution {
public int jump(int[] nums) {
int end = 0; // 当前跳跃批次的终点
int maxPos = 0; // 下一跳能到达的最远位置
int ans = 0; // 跳跃次数
// 不遍历最后一个元素(到达最后一步时无需再跳)
for (int i = 0; i < nums.length - 1; i++) {
maxPos = Math.max(maxPos, i + nums[i]); // 更新下一批次可到最远
if (i == end) { // 到达当前批次终点,必须跳一次
ans++;
end = maxPos; // 更新下一批次终点
}
}
return ans;
}
}
> **💭 思考**:为什么 45 题不能「每步都跳最远」,正确贪心是什么?一步步想——DP 的瓶颈是「对每个 i 回头找最优前驱」,O(n²)。想一次扫描,直觉是「每步跳最远」,但反例 `[3,4,2,1,0,4]` 说明跳到当前最远可能落到 `nums` 很小、后续无力的点。正确做法是把跳跃分层(像 BFS 按层):用 `end` 标记当前这一跳能覆盖的边界,`maxPos` 记录该范围内所有点能延伸出的最远位置,扫到 `end` 时就必须跳一次、更新 `end = maxPos`。这贪的是「在当前覆盖范围内选能让下一层最远的点」,层数就是最少步数——可达区间连续且单调向前,是它能贪的根因。
复杂度逐步推导:
- 时间:单层
for循环,循环体只有Math.max和if判断,均为 O(1),遍历n-1个元素,所以总时间 O(n)。相比 DP 的 O(n²),把「对每个 i 回头找最优前驱」转化为「一次线性扫描中动态维护下一批边界」,每个位置只被扫一次。 - 空间:仅
end、maxPos、ans三个整型变量,O(1)。相比 DP 的 O(n) 数组,降到常数级。
多解法对比
| 解法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
DP(dp[i] 最少步数) | O(n²) | O(n) | 通用、好理解,但 n 大时超时 |
| 贪心(按批次扩展) | O(n) | O(1) | 本题最优;Hot 100 标准答案 |
为什么选贪心:约束 n 可达 1e4,DP 的 O(n²) 约 1e8,勉强或超时;贪心 O(n) 干净利落。且贪心把「最少步数」翻译成「最少层数」,这个分层视角是面试官最想听到的洞察——它同时解释了对错两个贪心版本的差异。
CodeTop 变体
- 55. 跳跃游戏:去掉「最少」、只问「能不能到」,退化成只维护
maxReach。 - 1024. 视频拼接、1326. 灌溉花园的最少水龙头数目:同为「最少区间覆盖」——把每个位置看成区间
[i, i+nums[i]],求覆盖[0, n-1]的最少区间数。这是 45 的「区间覆盖」抽象,字节、腾讯高频。 - 45 的变体追问:面试官常问「如果把『最多跳 nums[i] 步』改成『恰好跳 nums[i] 步』呢?」→ 贪心失效,得用 DP/BFS,因为可达位置不再连续。
#763 划分字母区间
题意
字符串 s 由小写字母组成,将其划分为尽可能多的片段,同一字母只能出现在一个片段中,返回每个片段的长度。
输入:s = "ababcbacadefegdehijhklij"
输出:[9,7,8]
解释:划分为 "ababcbaca"、"defegde"、"hijhklij" 三段,同一字母不会跨段出现。
难点与易错点
- 「同一字母只出现在一个片段」是硬约束。这意味着如果一个字母首次出现在片段 A、最后一次出现在很后面,那么 A 必须一直延伸到那个最后出现位置,中途所有字母都被卷入。这是「区间必须闭合」的连锁反应,也是贪心的核心。
- 为什么局部最优能推出全局最优:先记录每个字符的最后出现位置
last[]。从左到右扫,维护当前片段右边界end = max(end, last[s[i]])。当i == end时切分。正确性:end是「当前片段内所有已出现字符的最后位置最大值」,在i < end时切分会导致某个已出现的字符在后续片段重复出现(违约);在i == end时切分则当前片段内所有字符都不会再出现,此时立即切分能保证「片段数最多」(尽早闭合 = 给后续片段留更多切分机会,这正是「尽可能多」的贪心点)。 - 边界/易错细节:
last[]大小固定 26(小写字母),索引用s.charAt(i) - 'a'。- 切分后
start = i + 1,片段长度是end - start + 1(用更新前的start算)。 - 最后一段一定会在
i == end时闭合(因为最后一个字符的last就是它自己),不会漏。 - 返回的是每段长度的列表,不是片段字符串本身。
解法一:暴力 / 直观(区间合并)
思路:把每个字母抽象成一个区间 [first, last](该字母首次和最后出现位置)。题目约束「同字母不分片」等价于「这些区间不能交叉切割」,因此可以先收集所有字母区间,再按「合并重叠区间」的方式合并,最后输出每个合并区间的长度。
import java.util.*;
class Solution {
public List<Integer> partitionLabels(String s) {
int n = s.length();
// 收集每个字母的 [first, last] 区间
int[] first = new int[26];
int[] last = new int[26];
Arrays.fill(first, -1);
for (int i = 0; i < n; i++) {
int c = s.charAt(i) - 'a';
if (first[c] == -1) first[c] = i; // 首次出现
last[c] = i; // 最后出现
}
// 收集所有出现过的字母的区间
List<int[]> intervals = new ArrayList<>();
for (int c = 0; c < 26; c++) {
if (first[c] != -1) intervals.add(new int[]{first[c], last[c]});
}
intervals.sort((a, b) -> a[0] - b[0]); // 按起点排序
// 合并重叠区间
List<Integer> result = new ArrayList<>();
int start = intervals.get(0)[0];
int end = intervals.get(0)[1];
for (int[] it : intervals) {
if (it[0] > end) { // 无重叠,闭合上一段
result.add(end - start + 1);
start = it[0];
end = it[1];
} else { // 有重叠,扩展右边界
end = Math.max(end, it[1]);
}
}
result.add(end - start + 1); // 最后一段
return result;
}
}
复杂度推导:
- 时间:一次 O(n) 扫描求
first/last;排序最多 26 个区间(固定小写字母),O(26 log 26) = O(1);合并 O(26)。所以整体 O(n)。这里因为字母表固定 26,排序不构成瓶颈。 - 空间:
first、last、intervals都是固定大小,O(1)(相对 n 而言)。 - 瓶颈:方法本身对本题已是 O(n),但它引入了「排序 + 合并」的额外逻辑,代码更长、更易错;而且它做了很多「收集区间、排序」的冗余动作——其实我们完全可以在一次线性扫描里边扩展边切分。
解法二:优化 / 最优(贪心,一次扫描维护右边界)
题目本质:贪心区间合并。先记录每个字符最后出现位置 last[],再遍历字符串,每个片段右边界为当前所有已出现字符的最晚位置的最大值,遇到右边界就切分。
现实类比:装箱分组——要把同类型物品放入同一箱子,先记好每种物品最后一个在哪;当前箱子装到某位置时,若所有已装物品都在这里截止,就封箱。
import java.util.*;
class Solution {
public List<Integer> partitionLabels(String s) {
// 预处理:记录每个字母最后出现的下标
int[] last = new int[26];
for (int i = 0; i < s.length(); i++) {
last[s.charAt(i) - 'a'] = i;
}
List<Integer> result = new ArrayList<>();
int start = 0, end = 0;
for (int i = 0; i < s.length(); i++) {
end = Math.max(end, last[s.charAt(i) - 'a']); // 扩展当前片段终点
if (i == end) { // 到达片段终点,切分
result.add(end - start + 1);
start = i + 1; // 下一片段开始
}
}
return result;
}
}
> **💭 思考**:为什么这题能跳过「收集区间再合并」,直接一次扫描边扩展边切分?一步步想——区间合并法先把每个字母抽象成 `[first, last]` 再排序合并,虽然也 O(n),但多了排序和区间构造的冗余。关键洞察是:扫描顺序天然就是「区间起点有序」,所以只要先记好每个字符的最后出现位置 `last[]`,遍历时维护 `end = max(end, last[s[i]])`,当 `i == end` 时当前片段内所有字符都不再出现,立刻切分——既满足「同字母不分片」的硬约束,又因为「尽早闭合」而保证片段数最多。边扫边扩展边切分,一步到位。
复杂度逐步推导:
- 时间:第一趟循环 O(n) 建
last;第二趟循环 O(n) 扫描并切分。两趟各自线性,相加仍为 O(n)。相比解法一的「排序 + 合并」,省掉了排序和区间对象的构造,常数更小。 - 空间:
last是固定 26 的数组,result是输出(不计入额外空间)。额外空间 O(1)(字母表大小固定 26,与 n 无关)。
多解法对比
| 解法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 区间合并(排序后 merge) | O(n + 26 log 26) | O(1) | 需要显式给出每个字母区间时 |
| 贪心(一次扫描维护右边界) | O(n) | O(1) | 本题最优;Hot 100 标准答案 |
为什么选贪心:解法一虽然也是 O(n)(因字母表固定),但它把问题抽象成「区间合并」再绕回来,代码冗长且易在边界出错。贪心解法直接利用「扫描顺序天然就是区间起点有序」这一性质,边扫边扩展边切分,一次线性扫描完成,是这题最优雅、也最不容易写错的写法。
CodeTop 变体
本题是「区间贪心」家族的一员,字节/美团高频的延伸方向是区间调度类:
- 56. 合并区间:给出一堆区间,合并所有重叠区间——与本题「收集区间再合并」完全同构。
- 57. 插入区间:给定已排序的互不重叠区间,插入一个新区间并合并。
- 435. 无重叠区间:求「移除最少区间使剩余区间互不重叠」——经典贪心,按右端点排序。
- 452. 用最少数量的箭引爆气球:一箭可刺穿重叠的若干气球(区间),求最少箭数——按右端点排序贪心,与 435 对称。
面试常见追问:「763 和 452 的贪心区别在哪?」→ 763 贪的是「尽早切分」(维护区间右边界,扫到就切),452/435 贪的是「按右端点排序后尽量共用」(排序 + 比较下一段起点)。两者都是「右边界」驱动,但一个做切分、一个做选点。
以上四题覆盖了贪心在 Hot 100 里的四种典型套路:维护历史极值(121)、维护可达上界(55)、按批次分层扩展(45)、区间右边界驱动切分(763)。手撕时牢记两点即可:一是先用一句话说清「局部最优为何能推出全局最优」,二是把边界(初值、循环范围、更新时机)抠死。
章末提问
- #121 凭什么「每个卖出日配历史最低买入价」就能得到全局最优?——结论:因为卖出日一旦固定,买入价越低利润越大,而能选的最低价就是前缀最低价;全局最优解里,任意一个最优卖出日的买入日必然是其前缀最低价,所以遍历时维护
minPrice不会漏解。 - #45 为什么不能「每步都跳最远」,正确贪的是什么?——结论:因为局部最远可能跳到
nums很小、后续无力的点;正确贪的是「在当前这一跳覆盖的范围内,选能让下一跳延伸最远的点」,即按「层边界」分批扩展,层数就是最少步数。 - #763 为什么「扫到 end 就切」能保证片段数最多?——结论:因为在
i < end时切,会让某个已出现字符在后续片段重复出现(违约);在i == end时切,当前片段内所有字符都不再出现,此时立即闭合能给后续片段留最多切分机会,符合「尽可能多」。