最近一周都在刷贪心算法,今天这组题是贪心里面特别典型的几种形态:LeetCode 122 的利润拆分、55 和 45 两道跳跃游戏的覆盖范围思路、还有 1005 的排序取反处理。按照代码随想录训练营第二十八天的节奏,四道题一口气做下来,最大的感受是:贪心算法的代码往往很短,但想明白“为什么这么贪”反而最花时间。这篇文章我会把每一道题的思考过程、推导逻辑和踩过的坑都摊开来讲,适合正在准备机试、面试或者专门刷贪心专题的朋友当作“做完一遍之后的复盘笔记”。
1. 今天在练什么:贪心算法的典型形态
1.1 四道题的共同逻辑
先说一个小结论:这四道题放在同一天,不是随机排的。它们刚好覆盖了贪心算法最常考的几种思考模式。
- 122 买卖股票的最佳时机 II 是“局部最优推导全局最优”的典型,把整段收益拆成每天的相邻差价,只取正数部分。
- 55 跳跃游戏是“覆盖范围逐步推进”的思路,维护一个当前能到达的最远位置,一边走一边更新。
- 45 跳跃游戏 II 是 55 的进阶版,在覆盖范围基础上再加一层“下一次跳跃覆盖范围”,用来数最少跳几次。
- 1005 K 次取反后最大化的数组和是“排序预处理 + 贪心调整”的组合题,先按绝对值排序,再决定要不要取反。
所以今天的题目虽然看起来分布在股票、数组、跳跃几个不同场景,但底层都跑在同一个思考框架下:每一步都做一个看起来局部最优的选择,然后证明这个选择能推出全局最优。这个“证明”不是写数学论文那种严格证明,而是想清楚“为什么不会有更优的情况被漏掉”。
1.2 贪心不是拍脑袋:什么时候能贪
很多初学者碰到贪心题,第一反应是“我猜一个策略,然后试”。这种试错法不能说完全没用,但效率太低。我自己的判断标准是三条:
- 局部选择会不会影响后面的选择空间。如果当前决策会限制未来的可能性,那大概率不适合贪心,可能需要回退到动态规划。
- 能否构造反例。如果我能立刻想出一种情况,让“局部最优”凑不出“全局最优”,那这题就不是贪心题。
- 题目是否要求“最值”。贪心题几乎都是在问最大值、最小值、能否达到——这些关键字出现时,才值得往贪心方向思考。
今天的四道题全都满足“无后效性”:每一步做完之后,前面的决策不会限制后面的可选范围。这一点很重要,后面讲每一道题的时候,我都会反复提到它。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 买卖股票的最佳时机 II:把利润拆开算
2.1 题目到底在说什么
LeetCode 122 的题意很简短:给定一个数组 prices,prices[i] 表示第 i 天的股票价格,你可以多次买入卖出,但手里最多持有一股,问最大利润是多少。
第一眼看上去,这道题容易想复杂:什么时候买入?什么时候卖出?要不要等一个大涨?但实际上,题目没有限制交易次数,也没有限制“必须隔一天再买”,这就给了贪心很大的空间。
我见过很多人在这个题上绕远路,去模拟“低买高卖”的完整过程:找到低谷买入、找到峰值卖出、然后继续找下一段。这个做法确实能过,但要维护的状态比较多,写起来容易出 bug。更优雅的做法是:把每一天与前一天的价格差算出来,只累加所有正数。
2.2 差值拆分:账要一天一天算
为什么“只加正数差值”就是答案?我来拆一下。
假设价格是 [7, 1, 5, 3, 6, 4]:
- 第 0 天到第 1 天:7 -> 1,跌了 6,不能赚。
- 第 1 天到第 2 天:1 -> 5,涨了 4,赚到。
- 第 2 天到第 3 天:5 -> 3,跌了 2,不能赚。
- 第 3 天到第 4 天:3 -> 6,涨了 3,赚到。
- 第 4 天到第 5 天:6 -> 4,跌了 2,不能赚。
把正数加起来,4 + 3 = 7。而如果按“低买高卖”的完整过程来做,是第 1 天买、第 2 天卖(赚 4),第 3 天买、第 4 天卖(赚 3),一共也是 7。结果完全一致。
关键在于:既然交易次数不限,那么“第 i 天买入、第 j 天卖出”的收益 (prices[j] - prices[i]),可以拆成连续多个相邻交易的利润和:
(prices[i+1] - prices[i]) + (prices[i+2] - prices[i+1]) + ... + (prices[j] - prices[j-1])。
中间每一项有正有负。如果我根本不做这笔跨多天的交易,而是改成“每天操作一次”,只保留所有正利润段,结果一定不会比一次性跨多天交易差。因为那些负利润段在一次性交易里会拉低总收益,而拆分后可以直接跳过。
用生活里的话说:你卖菜,土豆今天进价 3 块,明天能卖 4 块,那你今天就进货明天卖。后天涨到 6 块,你明天卖完再进,后天再卖。只要每天涨价都能赚到,就不要捂着不动——捂着的收益不会更高,反而可能被中间的跌价吃掉利润。
2.3 代码实现
贪心版本写出来非常短:
cpp复制class Solution {
public:
int maxProfit(vector<int>& prices) {
int profit = 0;
for (int i = 1; i < prices.size(); i++) {
if (prices[i] > prices[i - 1]) {
profit += prices[i] - prices[i - 1];
}
}
return profit;
}
};
如果写得更精简,可以直接写成 profit += max(0, prices[i] - prices[i - 1]);,一行累加。时间复杂度 O(n),空间复杂度 O(1)。
我一开始刷这道题的时候,总担心“今天买明天卖”会不会违反“手里最多持有一股”的约束。其实不会,因为你可以在同一天卖掉再买,这在题目语义里是允许的。代码里体现的就是“只要有正差价就收下”,不需要真的维护持仓状态。
2.4 常见误区:和 121 对比着看
LeetCode 121 是“只能买卖一次”,LeetCode 122 是“可以买卖多次”。这两个题目放一起对比特别有意义:
- 121 需要记录历史最低价,然后算当前卖出能赚多少,取最大值。它不能只加正差值,因为你只能选“买入一次、卖出一次”。
- 122 没有这个限制,所以才能把利润拆到天级别。
如果你做题的时候把两道题的解法搞混了,无非是两类错误:要么用 121 的“一次交易”思路去解 122,少算了很多;要么在 122 里强行模拟多段区间,把代码写复杂。
另外有一个延伸问题值得留意:如果把题目改成“包含手续费”或者“包含冷冻期”,贪心就不够了,需要切换到状态机 DP。所以 122 能贪心的前提条件是“交易无成本、无冷却”,这个前提一旦被破坏,解法就要换赛道。
3. 跳跃游戏:覆盖范围的妙用
3.1 理解“最大长度”和“可选长度”
LeetCode 55 跳跃游戏的题目:给定一个非负整数数组 nums,你一开始在第一个下标,每个元素代表你在该位置可以跳跃的最大长度,判断你能不能到达最后一个下标。
比如 nums = [2, 3, 1, 1, 4],从下标 0 出发,最大跳 2 步,可以先跳到下标 1(跳 1 步)再跳 3 步到终点;也可以直接跳 2 步到下标 2 再继续。总之能到,答案 true。
这里有一个容易误读的细节:“最大长度”不是“唯一长度”。你在下标 0 能跳 1 步、2 步甚至 0 步都可以,只是不能超过 nums[0]。很多初次接触这道题的人会去枚举“到底跳几步”,把问题理解成“路径选择”,然后就陷入 DFS 或者 BFS 的复杂度里出不来。
正确的打开方式是:不要关心具体怎么跳,只关心“最远能覆盖到哪里”。
3.2 为什么线性扫描就能判断可到达性
维护一个变量 cover,表示当前能够到达的最远下标。初始时 cover = nums[0]。然后从下标 0 开始,在 i <= cover 的范围内遍历数组,每到一个位置 i,更新 cover = max(cover, i + nums[i])。如果 cover >= nums.size() - 1,说明能到达终点,返回 true。
这个做法的正确性建立在一条重要性质上:从起点出发,可达的下标区域是连续的,不会出现“下标 3 可达、但中间的下标 2 不可达”这种空洞情况。
为什么不会有空洞?因为你的每一步都是从前一个可达位置跳出去的,你从下标 0 出发,跳 1 步能到 1,跳 2 步能到 2——所有小于等于 cover 的位置,都可以通过不超过 cover 的跳跃次数依次到达。这个连续性保证了线性扫描是完备的:我不会漏掉某个位置“虽然可达,但我没扫描到”。
这一步其实就是贪心里的“局部最优”:站在当前可达范围内,把可达边界向外推得越远越好。这个选择不会让结果变差,因为能覆盖更多位置的方案,绝不会比覆盖更少位置的方案更差。
3.3 代码实现与边界
cpp复制class Solution {
public:
bool canJump(vector<int>& nums) {
int cover = 0;
for (int i = 0; i <= cover; i++) {
cover = max(cover, i + nums[i]);
if (cover >= nums.size() - 1) {
return true;
}
}
return false;
}
};
有几个细节值得单独提:
- cover 的初始值不要写成 nums[0],写成 0 也可以。因为循环从 i = 0 开始,第一次遍历时 cover 会自动更新为 nums[0]。两种写法都能过,但理解上“初始为 0,通过扫描逐步扩大”更贴近思路。
- 循环条件是 i <= cover,不是 i < nums.size()。如果你写成 i < n,就会在中间某个位置 cover 停住之后继续往后遍历,但那些位置根本到不了,逻辑就错了。
- 边界情况:nums 长度为 1 时,不管 nums[0] 是多少,你已经在终点,直接返回 true。上面的代码也能正确处理,因为 cover >= 0 成立,循环第一轮就返回了。
我实际跑的时候还遇到过一种迷惑场景:nums = [3, 2, 1, 0, 4]。这个例子最后一个位置是 4,但下标 3 的跳跃长度是 0,没办法继续往前,最终覆盖范围卡在 3,到不了 4,返回 false。这种“中间出现 0 导致链条断裂”的情况,是这道题最常用来出反例的形态。
3.4 常见疑问:为什么不需要记录具体跳到了哪
有人会问:cover 更新成 i + nums[i] 之后,如果 i + nums[i] 比现在的 cover 小,会不会丢掉一些更远的可达点?
不会。cover 取的是最大值,不是替换。max(cover, i + nums[i]) 的意思是“在原来的覆盖范围基础上,尝试向外扩展”。如果当前位置能覆盖的范围没超出原来的边界,就维持原样;如果超出了,就扩大边界。整个过程是单调不减的,所以不会出现“越扫越退步”的情况。
这个问题也经常是面试官追问的点,回答清楚“cover 是不断取最大且只增不减”,基本上就算过关了。
4. 跳跃游戏 II:双覆盖范围算步数
4.1 从“能否到达”升级为“最少几次”
LeetCode 45 跳跃游戏 II 的题目几乎和 55 一样,只是多了一个要求:假设你总是可以到达数组的最后一个位置,求最少跳跃次数。
比如 nums = [2, 3, 1, 1, 4],最少跳 2 次:第一次从下标 0 跳到下标 1,第二次从下标 1 跳到终点。
55 只关心“能不能到”,45 关心“次数最少”。如果你还想用单层 cover 一直往后推,会发现很难数次数。因为你在一次“探索周期”内可能更新了很多次 cover,但这些更新都属于同一次跳跃的潜在选择范围,不能算成多步。
于是需要引入两个变量:curCover 表示当前这一步能到达的最远位置,nextCover 表示在当前这一步覆盖范围内的所有点,能往外推到的最远位置。当遍历的下标 i 碰到 curCover 时,说明这一步已经走到极限,必须跳下一步,此时步数加一,并把 curCover 更新为 nextCover。
4.2 双覆盖范围的推导与实现
我习惯把这个过程理解成“接力跑”:你当前能踩到的地面是一个范围,在这个范围内,你到处看哪里能让你下一步跳得更远,这个更远的位置就是下一棒的落点。只有当你真的走到当前范围的终点时,才被迫起跳。
代码可以这样写:
cpp复制class Solution {
public:
int jump(vector<int>& nums) {
int ans = 0;
int curCover = 0;
int nextCover = 0;
for (int i = 0; i < nums.size() - 1; i++) {
nextCover = max(nextCover, i + nums[i]);
if (i == curCover) {
ans++;
curCover = nextCover;
}
}
return ans;
}
};
注意循环条件是 i < nums.size() - 1,而不是 i < nums.size()。这个差异很关键:当 i 已经到达最后一个位置时,说明已经完成了跳跃,不需要再计入步数。如果你写成 i < nums.size(),在数组长度为 1 的情况下会多算一次跳跃,结果是 1 而不是 0。
走一遍 [2, 3, 1, 1, 4]:
- 初始:ans = 0,curCover = 0,nextCover = 0。
- i = 0:nextCover = max(0, 0 + 2) = 2。此时 i == curCover(0 == 0),触发一步,ans 变 1,curCover 更新为 2。
- i = 1:nextCover = max(2, 1 + 3) = 4。i 不等于 curCover(1 != 2),不触发。
- i = 2:nextCover = max(4, 2 + 1) = 4。i == curCover(2 == 2),触发一步,ans 变 2,curCover 更新为 4。
- i = 3:nextCover = max(4, 3 + 1) = 4。循环结束,返回 2。
这里有一个容易产生困惑的地方:i = 1 时其实已经发现了能到终点的路径(nums[1] = 3,1 + 3 >= 4),但答案并没有立刻返回。原因是贪心需要保证“最少次数”,你虽然从下标 1 可以直接到终点,但你需要先证明从下标 0 出发,第一步的覆盖范围已经包含了下标 1,而这一步是必须走的。所以步数不会减少,恰好是 2。
4.3 最容易写错的一行:循环边界
我在训练营里看到很多同学在 45 题上交出过“差一点”的代码,问题几乎都出在循环边界上。
如果把循环写成 for (int i = 0; i < nums.size(); i++),当 nums = [0] 时,i = 0,i == curCover,ans++,结果是 1,但正确答案是 0,因为你已经在终点,不需要跳。
解决方式有两种:要么循环写成 i < nums.size() - 1,要么在函数开头特判 if (nums.size() == 1) return 0。我推荐前者,因为更贴合“最后一个位置不需要再跳”的语义,也不容易在其他用例上出错。
另外还有一个细节:curCover 和 nextCover 的更新顺序不要写反。必须先更新 nextCover,再判断 i 是否到达 curCover。因为如果先判断再更新,就漏掉了当前这一步覆盖范围内最后一个点对 nextCover 的贡献。
4.4 两道跳跃题的对比
55 和 45 放在一起看,本质上是一道题的两种问法:
- 55 只做可达性判断,用单层 cover 扫描。
- 45 要求最少步数,在单层 cover 基础上再加一层 nextCover,表示“下一步的最远可达范围”。
45 其实可以看作 55 的“分层版本”:每一层是一次跳跃,curCover 是这一层能覆盖到的边界,nextCover 是下一层的边界。当 i 穿过 curCover 时,说明这一层探索完毕,进入下一层。这个过程和你用 BFS 求“最少层数”非常像——只不过 BFS 需要显式的队列,而这里用两个整数就能搞定,空间复杂度降到了 O(1)。
想通这一层之后,45 就不再是“背代码”的题目了。面试里如果考到这一题,你可以很自然地说:这本质上是在做一层一层的边界探测,每一层探测完后步数加一,最终到达终点时的层数就是最少跳跃次数。
5. K 次取反后最大化的数组和:排序的学问
5.1 贪心方向:负数优先取反
LeetCode 1005 的题目:给定一个整数数组 nums 和一个整数 k,你必须对这个数组执行恰好 k 次操作,每次操作选择一个元素并将其取反(正变负、负变正),求最后数组和的最大值。
这道题最容易想到的贪心策略是:每次取反当前最小的数。因为取反一个负数会让总和变大,取反一个正数会让总和变小,所以“优先照顾负数”是直觉上正确的方向。
但如果你真的每次都 sort 一遍再取最小值,复杂度是 O(k n log n),在 k 很大的时候会超时。更优的做法是:先排序,把负数尽可能取反掉,如果 k 还有剩余,再处理剩余 k 的奇偶性。
具体来说:
- 先对数组做一次升序排序。
- 从左到右遍历,遇到负数且 k > 0 就取反,k--。
- 遍历结束后,如果 k 还剩余(说明所有负数都已经变成正数了),且 k 是奇数,就把当前数组里最小的数取反一次。
- 最后累加所有元素。
为什么处理奇偶性就够了?因为同一个数取反两次会回到原值,所以偶数次操作等效于不操作。只有剩余 k 是奇数时,才需要让某个数变号。
5.2 按绝对值排序的真正原因
上面那版“升序排序 + 取反负数 + 处理剩余 k”是最容易理解的写法,但还有一个更优雅的版本:按绝对值从大到小排序,然后遍历。
按绝对值排序的做法是:
cpp复制class Solution {
public:
int largestSumAfterKNegations(vector<int>& nums, int k) {
sort(nums.begin(), nums.end(), [](int a, int b) {
return abs(a) > abs(b);
});
for (int i = 0; i < nums.size() && k > 0; i++) {
if (nums[i] < 0) {
nums[i] = -nums[i];
k--;
}
}
if (k % 2 == 1) {
nums[nums.size() - 1] = -nums[nums.size() - 1];
}
int ans = 0;
for (int num : nums) {
ans += num;
}
return ans;
}
};
按绝对值从大到小排序的好处是:当数组里有好几个负数时,你可以优先处理绝对值最大的负数,因为取反它带来的收益最大。比如 [-1, -5, -2],绝对值排序后是 [-5, -2, -1],你会先把 -5 变成 5,收益为 10;如果先变 -1,收益只有 2——虽然最终可能都能处理完,但在 k 有限的情况下,先处理“收益大的负数”才是真正的最优策略。
如果 k 恰好等于负数个数,两种写法结果一样。但如果 k 比负数个数少,按绝对值排序才能保证“把 k 次操作花在收益最大的负数上”。这也是这道题比较隐蔽的一个坑。
5.3 为什么剩余奇数 k 取反“最小绝对值”而不是“最小数”
按绝对值排序之后,数组的最后一个元素是绝对值最小的数。当剩余 k 为奇数时,把它取反能让总和的损失最小。
为什么不是“取反最小的数”?因为排序之后数组里可能已经没有负数了,所有元素都是非负的,此时“最小的数”和“绝对值最小的数”是同一个。但如果数组里仍然有负数(k 没用完就遍历结束的情况根本不会发生,因为只要遇到负数就会取反并消耗 k),所以这个分支里数组必然全非负。此时取反绝对值最小的数,就是让总和损失最少的方式。
注意这里的“损失”是相对于“不取反”而言的。取反一个正数 x,总和减少 2x,所以 x 越小,减少得越少。绝对值排序后最后一个元素正好满足这个条件。
5.4 这个题的排序场景在别处也常见
按“某种属性排序之后再做贪心”是力扣上一大类题的通用套路。比如“根据身高重建队列”是先按身高降序、按 k 值升序排序;“用最少数量的箭引爆气球”是按右端点排序;“分发饼干”是把两个数组都排序后双指针匹配。
1005 这道题的价值不只是题目本身,更在于让你熟悉这种“排序 + 状态调整”的贪心模板。以后遇到要“优先处理某类元素”的题,第一反应可以先想想:能不能通过自定义排序,把最重要的元素放到最前面处理?
6. 做完这组题的经验回顾
6.1 贪心题的四步自测
四道题刷完之后,我给自己总结了一套贪心题的自测流程,分享出来供参考:
- 第一步,题目问的是不是最值或可行性。是,才继续往下想。
- 第二步,尝试提出一个局部策略,比如“每次取最大”“只加正数”“遇到边界才跳”。
- 第三步,主动找反例。如果找不到反例,再想想能不能证明策略的单调性或最优性。
- 第四步,检查无后效性。当前决策会不会限制未来的选择空间。如果会,考虑 DP,如果不会,贪心大概率成立。
这套流程不能保证每道贪心题都能秒杀,但能帮你节约大量“试错”时间。很多贪心题一看就知道不是 DP,就是因为题目的状态空间没有“记忆”,选择之间不会互相干扰。
6.2 训练营这种节奏怎么跟进
代码随想录训练营的节奏是每天新题 + 复习旧题,第二十八天已经进入中后段,积累的题型越来越多。我的经验是:每天新题做完之后,不要急着赶进度,先花十分钟把今天的题做个“题型标签”,比如今天四道题我都贴上了“贪心-差值拆分”“贪心-覆盖范围”“贪心-双覆盖范围”“贪心-排序预处理”。等后面复习的时候,按标签翻题单,比按顺序重新刷一遍效率高很多。
另外,训练营的题单不是让你“只用一种语言”做题。我习惯用 C++ 写主解,但遇到思路特别妙的题,会用 Python 再写一遍,确认自己不是背下来某个 API,而是真的理解了算法流程。1005 的绝对值排序 lambda 表达式,我第一次用的时候还去查了语法,后来多写几遍就熟了。
6.3 后续可以接着练的题
如果今天这四道题你已经完全吃透,我建议接着刷下面这些同类型题目,保持手感:
- LeetCode 53 最大子数组和:也是“局部最优推导全局最优”的经典,和股票题的思路有相通之处。
- LeetCode 455 分发饼干:排序 + 双指针的入门题。
- LeetCode 376 摆动序列:需要稍微绕一下弯,贪心策略是“只算拐点”。
- LeetCode 406 根据身高重建队列:排序预处理的进阶款,按绝对值排序的影子在这道题里能看得到。
- LeetCode 452 用最少数量的箭引爆气球:区间贪心的代表,和跳跃游戏的“覆盖范围”有相似的思考方式。
贪心算法这块,做够二三十道之后基本就能找到手感。今天这四道题如果都弄明白了,后面再碰到新题,至少不会看到“能不能”“最多”“最少”这几个词就发怵。
最后再分享一个小技巧:贪心题写代码之前,永远先在草稿纸上画一下“覆盖范围”或者“收益变化”。像 55 和 45 这种题,画一条横轴,把每个位置的覆盖区间标出来,一眼就能看出 curCover 和 nextCover 的关系。我每次卡住的时候,回到画图这一步,基本都能自己走通,不需要急着看题解。
