1. 开篇:训练营第二十八天,贪心算法的“定式思维”正在成型
今天是代码随想录算法训练营的第二十八天,专题依旧是贪心算法,而且是连续刷题以来“计算量最少、但思维要求最高”的一天。先看今天这四道题:LeetCode122. 买卖股票的最佳时机 II、55. 跳跃游戏、45. 跳跃游戏 II、1005. K 次取反后最大化的数组和。如果光看标题,你可能会觉得“股票、跳跃、取反”,领域跨度很大,但拆开之后你会发现,它们全都在干同一件事:用局部最优推导全局最优,同时把“贪心策略的证明”当作解题的核心环节。
这四道题非常适合用来建立贪心算法的判断力。市面上讲贪心的资料很多,但要么只给结论不说理由,要么一上来就推数学公式把新手劝退。代码随想录这批题目的编排顺序其实很讲究——122题让你理解“贪心怎么把看似复杂的问题拆成简单结构”,55题和45题让你掌握“区间覆盖”这一贪心经典模型,1005题则训练你“贪心策略需要排序配合”的套路。无论你是刚开始刷LeetCode的校招党,还是工作中需要快速找最优解的在职开发,今天这四道题都能帮你把贪心的底层直觉练到位。
我之前在训练营里走了不少弯路,最深的感受是:贪心算法最大的难点不是“写代码”,而是“敢不敢贪”。很多题目你其实已经摸到了正确的策略,但因为无法证明它是对的,就停在那不敢往下写。今天的四道题,我会顺着代码随想录的讲解思路,把每一步“为什么能这么贪”全部拆开讲透,再补上一些我在实际调试中踩过的坑,希望能帮你少走点弯路。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 四道题的整体设计思路拆解:为什么它们被安排在同一天
2.1 贪心算法的共性主线:局部最优到全局最优的验证循环
先退一步看大局。贪心算法和动态规划最大的区别在于:动态规划会枚举所有子状态,然后取最优;贪心算法只盯着当前这一步,选一个“看起来最优”的方案继续走,不再回头。正因为不回头,所以贪心算法的每一项策略都必须能被证明“局部最优能推出全局最优”,否则就是瞎猜。
今天这四道题,每一道都在训练不同的“贪心证明方式”:
- 122题:把总利润拆成每天相邻价差之和,只取正价差,证明方式是“数学恒等变形+单调性归纳”;
- 55题:维护一个覆盖范围,只要范围能覆盖到终点就返回true,证明方式是“覆盖区间单调扩展”;
- 45题:在55题基础上加了一个“最少步数”的限制,每走一步要计算下一步能覆盖的最大范围,证明方式是“区间嵌套下的最优性剪枝”;
- 1005题:先按绝对值排序,优先翻转绝对值最大的负数,证明方式是“交换论证法”。
这个排列顺序非常有意思。122题是最容易理解的贪心,因为它的数学本质其实就是一个恒等式;55题和45题是同一模型的两种问法,一个判断可行,一个求最优步数;1005题则需要你先做“排序预处理”,再套用贪心策略。一天刷完这四道题,相当于把贪心算法的四种常见场景都过了一遍。
2.2 我的做题路线建议:先推导、再编码、最后反例测试
代码随想录训练营的刷题节奏是“先看题、独立思考、再看讲解、最后总结”,这个流程我特别认同,但在贪心专题我会额外加一步:写代码之前,先在纸上用两个例子手工跑一遍贪心策略。这一步不是浪费时间,而是帮你确认策略是否在“所有局部选择”里都成立。
另外一个很重要的习惯是:写完AC之后,一定要尝试构造反例。比如122题,如果你想的策略是“每次找到局部最低点买入、局部最高点卖出”,那你去构造一个连续上涨的数组,比如[1, 2, 3, 4, 5],这个策略也能得到正确答案,但它的实现复杂度远高于“只收集正利润”。反过来,如果你用“只收集正利润”的策略去跑[7, 1, 5, 3, 6, 4],你会发现它天然就吸收了每一次上涨。反例测试不是为了否定正确代码,而是为了帮你理解“为什么别人的写法比你简单”。
3. LeetCode 122:买卖股票的最佳时机 II——把“低买高卖”翻译成“收集正利润”
3.1 题目本质与暴力思维的陷阱
先看题目:给定一个数组prices,prices[i]表示第i天的股票价格。你可以在任意一天买入,在之后的任意一天卖出,而且买入前必须保证手里没有股票。问能获得的最大利润是多少。注意,这里和“买卖股票的最佳时机 I”最大的区别是:交易次数不限。
我第一次做这道题,第一反应是模拟“低买高卖”——找到局部最低点买入,找到局部最高点卖出,然后循环。这个思路听着没问题,但实现起来很容易出边界bug。比如[7, 1, 5, 3, 6, 4],局部最低点是1(第2天),局部最高点是5(第3天),赚4;然后局部最低点是3(第4天),局部最高点是6(第5天),赚3;总共7。看着没问题,但你考虑[1, 2, 3]这种一直涨的数组时,“找局部最低点”会退化成一次买入,结果也对。真正会出问题的是一些边界交错的情况,比如[3, 3, 5, 0, 0, 3, 1, 4],用“找局部极值点”的方式实现,很容易漏掉“0到3”这段利润,或者在处理连续相同价格时产生多余交易。
其实这道题根本不用模拟“买卖”这个动作,只需要把每天的利润拆成相邻两天的差值。比如第0天买、第2天卖,利润是prices[2] - prices[0],它恒等于 (prices[1] - prices[0]) + (prices[2] - prices[1])。也就是说,任意一段跨越多天的交易,它的总利润一定等于每天相邻差值的累加。既然交易次数不限,那你就应该把每一天的正差值都吃掉,所有负差值全部跳过。这样实现一个循环就结束了。
3.2 贪心实现与常见误区
来看代码:
cpp复制class Solution {
public:
int maxProfit(vector<int>& prices) {
int result = 0;
for (int i = 1; i < prices.size(); i++) {
int diff = prices[i] - prices[i - 1];
if (diff > 0) {
result += diff;
}
}
return result;
}
};
这个代码只有六行,但它把贪心的核心表达得非常清晰:只看今天和昨天的价差,今天涨了就累加,今天跌了就跳过。不需要记录买入价,不需要处理卖出时机,更不需要考虑“手里有没有股票”——因为“不限制交易次数”意味着你可以把每天都当作一次独立的交易选择。
很多初学者会问:如果我第1天买、第3天卖,而第2天价格是跌的,那我跳过了第2天的负利润,实际算出来的是不是少了?并不是,因为你可以第1天买、第2天卖,然后第2天再买回来、第3天卖,这两次交易的总利润正好等于第1天到第3天的整体涨幅。这就是“拆成相邻差”的好处:它在数学上帮你自动完成了“多次交易”的合并。
3.3 实操心得:为什么直接累加正利润是“严格正确”的
注意:这道题虽然简单,但它背后的证明很容易被忽略。很多人只是背下了“累加正利润”这个结论,却说不清为什么它等价于“最大利润”。
这里我提供一个简单的证明思路:假设你在第i天买入、第j天卖出(j > i),那么这笔交易的利润是prices[j] - prices[i],它可以展开为 (prices[i+1]-prices[i]) + (prices[i+2]-prices[i+1]) + ... + (prices[j]-prices[j-1])。如果其中有一个相邻差是负数,你完全可以通过“先卖出再买入”的操作把它规避掉。所以最优策略一定不会持有任何一段下跌区间,它只会覆盖所有上升区间。因此,把每个正相邻差全部累加,就是能达到的最大总利润。这也是“局部最优(跳过下跌段)能推出全局最优(总利润最大)”的最直观例子。
4. LeetCode 55:跳跃游戏——用“覆盖范围”解决可达性问题
4.1 题目理解与贪心策略
第二道题是55. 跳跃游戏。题目给一个非负整数数组nums,你初始在下标0,nums[i]表示你在下标i位置最多能往后跳多远,问能否跳到最后一个下标。注意这里只看“能不能到”,不求“最少跳几次”。
这道题最直观的暴力思路是DFS:从每个位置尝试所有可能的跳跃步数,看能不能走到终点。但nums长度可能很长,DFS的复杂度会爆炸。贪心思路完全不同:不需要关注具体走哪一步,只需要维护一个“最远覆盖范围”。初始时,你在下标0,能覆盖到下标0 + nums[0];然后你遍历这个覆盖范围内的每个位置,不断用 nums[i] + i 去更新下一个覆盖范围;如果中途覆盖范围已经大于等于最后一个下标,就返回true;如果遍历完某个覆盖范围,最远距离仍然没有前进,说明卡住了,返回false。
代码实现如下:
cpp复制class Solution {
public:
bool canJump(vector<int>& nums) {
int cover = 0;
for (int i = 0; i <= cover && cover < nums.size() - 1; i++) {
cover = max(cover, i + nums[i]);
}
return cover >= nums.size() - 1;
}
};
这段代码有几个细节值得展开讲。第一,循环条件是 i <= cover,意思是“我只需要遍历当前能到达的范围,范围外的位置暂时不需要管”;第二,cover < nums.size() - 1 是提前退出条件,如果还没遍历完就已经能到终点了,直接返回;第三,cover = max(cover, i + nums[i]) 是贪心核心——每次遇到一个位置,都用它能跳到的最大距离来扩展全局覆盖范围,而不是拘泥于“下一步跳到哪里”。
4.2 为什么“覆盖范围”的思路是安全的:区间合并视角
我第一次看这道题的时候,总觉得这个解法太“偷懒”了,像是把跳跃能力打折了。后来我换了个角度理解才彻底放心:你可以把每个位置i看作一个区间 [i, i + nums[i]],表示从i出发能够到达的所有位置。题目问的是“从0出发,能否通过一系列连续的区间跳跃到达最后一个下标”。这些区间首尾相接,其实会形成一个更大的连续可达区间。你只需要维护这个连续可达区间的右端点,遍历区间内所有下标,不断把区间往外扩。这就是标准的“区间覆盖合并”贪心模型。
举个例子,nums = [3, 2, 1, 0, 4]。初始cover = 3,也就是下标0能覆盖到下标3。遍历i从0到3:i=0时,cover更新为max(3, 0+3)=3;i=1时,cover更新为max(3, 1+2)=3;i=2时,cover更新为max(3, 2+1)=3;i=3时,cover更新为max(3, 3+0)=3。循环结束,cover=3,小于4,返回false。你会发现在这个例子里,虽然前三个位置看起来都能走,但最远覆盖范围始终没有超过3,而下标4被一个值为0的位置隔开了,永远到不了。这就是覆盖范围视角的威力:它把“路径可行性”转化成了“区间扩展是否停滞”,一个for循环就解决了。
4.3 实操心得:注意cover的初始值
注意:cover的初始值一定是 nums[0],而不是0。因为你的起始位置是下标0,你一开始就能到达0 + nums[0]的位置。如果你把cover初始化为0,循环条件会变成 i <= 0,只遍历i=0一个位置,某些情况下也能得到正确答案(因为i=0会更新cover),但语义上是不准确的,也容易在边界case上出问题。
另外,当nums只有一个元素时,你已经站在终点了,直接返回true。这段代码用 cover < nums.size() - 1 做循环条件,正好能处理这个情况——cover初始是nums[0],如果nums只有一个元素,cover = 0,cover < 0不成立,循环不执行,返回0 >= 0为true。逻辑上是没问题的,但如果你单独处理一下“数组长度为1直接返回true”,别人读代码时会觉得更直观。
5. LeetCode 45:跳跃游戏 II——从“能不能到”升级为“最少几步到”
5.1 贪心策略升级:维护当前覆盖范围和下一步覆盖范围
第三道题跳跃游戏 II是上一道题的强化版:题目保证你一定能跳到最后,但要求你计算最少跳几次。这道题不是“可达性”问题,而是“最优化”问题,而且是在“一定可达”的前提下求最少步数,所以贪心策略会更复杂一点。
先说我的思考过程。如果延续55题的“覆盖范围”思路,你会发现一个关键现象:当你在当前覆盖范围内移动时,不管你怎么跳,都需要消耗一步才能走出这个范围。所以最优策略应该是:在当前覆盖范围内,选择一个能让你下一步覆盖范围最大的位置作为落脚点。这个思路不需要模拟具体的落脚点,只需要维护两个变量:
- curCover:当前这一步能覆盖到的最远距离;
- nextCover:遍历当前位置时,能计算出的下一步最远覆盖距离。
每遍历到一个位置i,就更新 nextCover = max(nextCover, i + nums[i])。一旦i到达了curCover的末尾,说明这一步能探索的位置已经全部看过了,必须跳一步。此时把curCover更新为nextCover,步数加1。如果curCover已经覆盖到最后一个位置,就结束循环。
代码实现如下:
cpp复制class Solution {
public:
int jump(vector<int>& nums) {
if (nums.size() <= 1) return 0;
int curCover = 0;
int nextCover = 0;
int step = 0;
for (int i = 0; i < nums.size(); i++) {
nextCover = max(nextCover, i + nums[i]);
if (i == curCover) {
step++;
curCover = nextCover;
if (curCover >= nums.size() - 1) {
break;
}
}
}
return step;
}
};
5.2 逐步模拟:用示例数组理解“一层一层扩展”
只看代码可能还有点抽象,我拿一个具体例子跑一遍。nums = [2, 3, 1, 1, 4],期望输出是2(第一步从0跳到1,第二步从1跳到4)。
初始化:curCover = 0,nextCover = 0,step = 0。
- i=0,下标0的跳跃能力是2,nextCover = max(0, 0+2) = 2。此时i == curCover(0 == 0),说明当前覆盖范围已经遍历完,step++变为1,curCover = nextCover = 2。此时curCover = 2 < 4,不退出。
- i=1,下标1的跳跃能力是3,nextCover = max(2, 1+3) = 4。i=1不等于curCover(1 != 2),继续。
- i=2,下标2的跳跃能力是1,nextCover = max(4, 2+1) = 4。此时i == curCover(2 == 2),说明当前步能到达的位置已经遍历完,必须跳下一步,step++变为2,curCover = nextCover = 4。此时curCover >= 4,退出循环。
返回2,完美。
这个模拟过程能看出一个非常重要的细节:step的增长时机是“当前覆盖范围遍历完,必须迈出下一步时”,而不是“每走到一个位置就+1”。这样就能避免重复计数,也保证了“每一步都尽量让覆盖范围最大化”。从这个角度说,45题和55题的核心逻辑是一致的,都是区间覆盖,只不过45题额外记录了一个“步进次数”和“下一步最远覆盖”。
5.3 代码细节与边界处理
注意:45题有一个最容易踩的坑——循环里要不要在 i == nums.size() - 1 时提前退出?答案是不需要,甚至不能随便退出。因为当i走到了数组末尾,说明当前覆盖范围已经被完全探索,此时如果curCover还没覆盖到最后一个位置,就说明中间出了问题(但题目保证可达),如果curCover已经覆盖到了,会在i == curCover的判断里触发break。所以正常流程下,循环不会越界访问。
另一个细节是:这道题的标准解法里,curCover 初始值是0,而不是 nums[0],这和55题不一样。原因在于:45题中“步数”从一开始就是0,你还没有起跳,所以当前覆盖范围应该是起点0,下一跳的覆盖范围需要通过遍历第一个位置来更新。如果你把curCover初始化为nums[0],步数语义会混乱,可能导致答案比实际少1。我在训练营里看到好几个同学在这个初始值上翻车,特地拿出来提醒。
6. LeetCode 1005:K 次取反后最大化的数组和——排序预处理+贪心策略的经典结合
6.1 题目要求与常见错误策略
最后一道题是1005. K 次取反后最大化的数组和。题目给一个整数数组nums和一个整数k,你需要对数组中的任意一个元素执行“取反”操作恰好k次,可以选择同一个下标多次,要求最终数组和最大。
先看一个很容易踩进去的错误策略:如果k小于负数个数,那就把所有绝对值最大的负数翻成正数,这样数组和提升最大;如果k还有剩余,再考虑怎么处理剩余次数。这个想法的大方向是对的,但有一个关键细节很多人会漏掉:如果所有负数都翻完了,剩余k次应该翻哪个数?
最简单的想法是翻绝对值最小的数,这样损失最小。但如果剩余次数是偶数,翻同一个数偶数次等于没翻,所以你要判断剩余k的奇偶性:偶数次直接忽略,奇数次才需要翻转绝对值最小的数。还有一个更细腻的办法:把所有负数翻正之后,如果剩余k是奇数,就直接翻转整个数组中绝对值最小的那个数。因为翻转两次等于没翻,所以只需要处理一次。
6.2 正确解法:按绝对值排序,一次遍历解决负数翻转
代码随想录的标准解法是按绝对值从大到小排序,然后遍历一次,遇到负数且k大于0就把这个数取反,k减1。遍历结束后,如果k还大于0且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 result = 0;
for (int num : nums) {
result += num;
}
return result;
}
};
为什么排序规则是 abs(a) > abs(b),也就是绝对值大的排在前面?原因是:当我们翻转负数时,翻转绝对值越大的负数,数组和的提升越大。比如[-5, -1, 2],k=2,如果你不按绝对值排序,直接遇到负数就翻,先翻-1再翻-5,结果变成了[5, 1, 2];但如果你按绝对值排序,先翻-5再翻-1,结果是[5, 1, 2]——看起来结果一样,但实际上是因为这里两个负数都被翻了。如果你换个例子,比如[-8, -3, 2, 7],k=1,按绝对值排序会先翻-8,得到[8, -3, 2, 7],和为14;不按绝对值排序直接遍历,可能先翻-3,得到[-8, 3, 2, 7],和为4。差别非常大。
6.3 实操心得:剩余k次的偶数性判断要放在“取绝对值最小”之前
这道题我自己在做的时候栽过一次跟头,原因是只处理了“剩余k是奇数就翻转绝对值最小的数”,但忘了在翻转之前先判断“数组中是否已经没有负数了”。其实这个判断不需要单独写,因为第一次遍历时k大于0才会去翻负数,如果k最终没有变成0,说明所有负数已经全部被翻成正数了,剩下的k只能用于翻转非负数。此时再判断k的奇偶性就非常安全。
注意:
nums[nums.size() - 1]在按绝对值降序排序后,确实是整个数组中绝对值最小的元素。但如果你在遍历过程中修改了某些元素的值,绝对值最小的元素理论上可能发生变化,不过因为修改操作是把负数变成它的相反数,绝对值大小并没有变,所以排序位置仍然正确。这一个性质保证了最后一步取nums[nums.size() - 1]是正确的。
另外,我推荐一个更稳妥的写法:第一次遍历时记录一个 minAbsIndex,每次更新绝对值最小值的下标。这样即使排序规则变了,也不会影响最后一步的正确性。虽然代码多了几行,但可读性更强。
7. 常见问题与排查技巧实录:四道题串讲后的避坑速查表
7.1 贪心题目的“策略正确性”怎么验证
刷完这四道题,很多人会陷入一个困惑:怎么确认自己的贪心策略是对的?我的经验是三个步骤。第一,构造一个多段上升/多段下降的复杂示例,手工模拟贪心过程,确认每个局部选择都符合直觉。第二,用暴力解法做交叉验证,尤其是刚开始练贪心时,不要嫌暴力解法浪费时间,它能帮你快速发现策略bug。第三,想清楚“为什么反例不存在”,这一步不是写代码,而是训练思维。
具体到今天的题:122题为什么反例不存在?因为任意交易都可以拆成相邻差之和,跳过负差永远占优;55题为什么反例不存在?因为覆盖范围的扩展只取决于当前位置能到达的最大距离,任何“绕路”都不会比直接扩展更优;45题为什么反例不存在?因为每一步都选择“能让下一步覆盖范围最大”的落脚点,不可能存在比它更优的下一步选择;1005题为什么反例不存在?因为翻转绝对值大的负数收益更高,如果剩余次数为奇数则必须牺牲绝对值最小的数,这就是所有方案里损失最小的。
7.2 四道题的常见错误整理成表格
| 题号 | 常见错误 | 原因 | 正确做法 |
|---|---|---|---|
| 122 | 模拟低买高卖,写一堆判断局部极值的代码 | 局部极值边界多,容易漏算 | 累加所有正相邻差 |
| 122 | 忘记相邻差可能为负 | 负差跳过即可,不影响总利润 | 判断 diff > 0 |
| 55 | cover初始值设为0 | 起点本身的覆盖范围被忽略 | 初始化为nums[0] |
| 55 | 循环条件写成 i < nums.size() | 可能访问到当前覆盖不到的位置 | 循环条件 i <= cover |
| 45 | curCover初始值设为nums[0] | 步数含义混乱,结果可能偏小 | curCover初始为0,先更新nextCover |
| 45 | 在i==nums.size()-1时提前退出 | 可能漏掉最后一次步数更新 | 依赖i==curCover触发步数更新 |
| 1005 | 不按绝对值排序,直接翻转 | 绝对值大的负数可能被漏掉 | 按绝对值降序排序 |
| 1005 | 没判断剩余k的奇偶性 | 翻转偶数次等于没翻 | 剩余k为奇数时翻转绝对值最小的数 |
7.3 我在训练营里踩过的调配坑:数组越界与死循环
写45题的时候,我有一个非常典型的错误版本:在循环里写了 for (int i = 0; i < nums.size(); i++),然后判断 if (i == curCover) 时更新curCover和step。表面看没问题,但有一个极端case:nums = [1, 2]时,i=0,nextCover更新为1,i==curCover(0==0),step变为1,curCover更新为1,但此时curCover = 1,还没有覆盖到数组末尾(下标1),所以不退出。i=1,nextCover更新为3,i==curCover(1==1),step变为2,curCover更新为3,此时覆盖末尾,退出。输出是2。但正确答案是1——因为从0跳一步就能到1。问题出在哪?因为我忽略了“当前覆盖范围已经能覆盖到最后一个位置时,就不需要再起跳一次”的条件。正确的解法是在更新curCover后立刻检查它是否已经覆盖到末尾,如果是就break,而不是等到最后一次遍历。我的代码里其实已经有这个break了,但很多初学者会在自己写的时候漏掉它,特此提醒。
1005题还有一个容易踩的坑:如果nums里全是正数,k又是奇数,那你应该翻转绝对值最小的正数;但如果nums里全是负数,k又是奇数,按绝对值排序后先翻绝对值最大的负数,这本身没问题,但要注意如果负数的个数少于k,剩余的k次要小心处理。这些边界case看起来不起眼,实际面试时可是扣分点。
8. 总结与个人体会:贪心算法的“直觉”是可以刻意训练的
回顾第二十八天的四道题,我个人最大的收获并不是“学会了四道题的解法”,而是逐渐建立了一种判断贪心策略的直觉:在写代码之前,先用“局部选择能否无冲突地推出全局最优”这个标准去审视自己的策略。122题教会我拆解;55题教会我维护范围;45题教会我范围的分层推进;1005题教会我排序和贪心的配合。这四道题拼在一起,就是一套非常完整的贪心入门训练。
如果你之前也觉得“贪心算法靠运气”,我建议你把自己做过的贪心题拿出来做一次归类:哪些是“差值/利润拆解型”,哪些是“区间覆盖型”,哪些是“排序预处理型”。分类分完之后你会发现,贪心的套路其实比动态规划少得多,它考验的更多是“你能否看穿问题的数学结构”。
最后分享一个小技巧:训练营的刷题打卡,我每天都会在题解里写“这题为什么能贪”和“这题的反例怎么构造”,而不是只贴代码。几周下来,我的贪心题正确率提升非常明显。你们也可以试试,把“证明策略正确”变成刷题的一部分,而不是把AC当作终点。毕竟面试官问“为什么这么做是对的”时,你总不能回答“因为AC了”吧。
