讲个真实经历。去年我帮人模拟面试,连续三个候选人,我都在同一道题上看到同一种现象:题目读懂了、例子跑通了、代码也能AC,可一问“为什么这么贪心是对的”,对面就卡住了。那道题不是别的,就是LeetCode 135,分发糖果,也被叫做“糖果【贪心】”。这题在各种刷题平台上的热度一直很高,主要是因为它的思考路径特别典型——明明题意很短,规则也只有两条,但做起来却能把一大堆人绕进去。有人用暴力模拟,有人写双向扫描,还有人试图用单调栈强行解,最后被边界条件折腾到怀疑人生。实际上,这道题考察的就是贪心算法里一个非常经典的思想:局部最优推全局最优。你要是能把这道题彻底讲明白,贪心里“拆条件、分方向、做合并”这套方法论基本就通了。这篇文章就把这题从题目规则、贪心推导、完整代码到面试追问全部拆开讲,顺带把同类型的“跳跃游戏 II”也串进来,帮你把贪心题型的识别能力和实现手感一起提上去。
1. 题目到底在说什么:先读懂题意与坑点
算法题最怕的不是不会做,而是没看懂题。糖果这道题的题干非常简短,正因为简短,很多人反而忽略了里面藏着的细节。先把规则一字一句拆开,后面所有推导都建立在这上面。
1.1 题目描述与规则拆解
原题大致是这样:有 N 个孩子站成一排,每个孩子有一个评分数组 ratings,现在要给他们发糖果,需要满足两个条件:
- 每个孩子至少分到 1 颗糖果。
- 相邻的两个孩子中,如果评分更高,那么他拿到的糖果必须比旁边评分低的孩子多。
要求返回最少需要准备多少颗糖果。
注意这里的关键词是“相邻”。评分比较只发生在相邻两个人之间,不存在跨位置比较,更不存在“全队最高分必须拿最多”这种说法。这个限制条件决定了后面解法里“只看左右邻居”的基本视角。
举个例子:ratings = [1, 0, 2],答案是 5。分配方案是 [2, 1, 2]:中间的孩子评分最低,给他 1 颗,两边的邻居评分都比他高,所以各拿 2 颗。总数 2 + 1 + 2 = 5,符合题目要求。
再比如说 ratings = [1, 2, 2],答案是 4。怎么分呢?有一种方案是 [1, 2, 1],第二个孩子评分 2 比第一个高所以拿 2 颗,第三个孩子评分也是 2,和第二个孩子评分相等,不满足“更高”的条件,所以拿 1 颗就够了。这里有个很容易被忽略的点:评分相等时,没有强制要求糖果数一定相等,也没有禁止相等,只有“更高”才必须更多。很多人在这里栽跟头,会把相等也当成需要递增处理的条件,结果答案偏大。
1.2 常见误读与隐含条件
这道题里最隐蔽的规则有两个。第一个是“每个孩子至少 1 颗”这个底线。有了这条,即使一个孩子左右两边的评分都比他低,他也只能拿 1 颗,不能因为“左边比右边高”就给他补一颗。许多人在第一次做这题时,习惯性把数组初始化成 0,然后往前推、往后推,最后忘了补初始值,导致结果比正确答案少 N。这就是典型的没吃透“至少 1 颗”的约束。
第二个隐含条件是“评分相等的相邻孩子,糖果数可以相等也可以不等,只要不违反‘更高必须更多’即可”。这意味着相等的情况在贪心推导中可以被当作“中断条件”来处理。你可以这样理解:相等评分的两个人之间没有强制的大小关系,就像一条链被剪断了,左边怎么分不影响右边。很多基于单调性的解法都要用到这个特点,把它当成断点处理,逻辑会清爽很多。
1.3 为什么说这题“贪心味”十足
判断一道题是不是贪心,我自己的经验是看它能不能把“全局目标”拆成“多个无后效性的局部决策”。糖果这道题,全局目标是糖果总数最小,而糖果总数等于每个人的糖果数之和。关键点在于:某个位置最终应该分到多少糖,只取决于他左边连续下降的评分长度和右边连续下降的评分长度。也就是说,这个局部决策不会因为更远位置的情况而改变,局部最优的累加可以直接拼出全局最优。
这正是贪心算法最典型的特征:每一步都做当前看起来最好的选择,并且这个选择不会被后面的步骤推翻。相比之下,如果是动态规划题,往往需要记录状态转移过程,子问题之间有依赖;而这题的每个点独立计算即可,不需要状态转移表。所以遇到这种“每个点的最优解只看局部、局部和整体一致”的题目,第一反应就应该是贪心,而不是一上来就套 DP 或搜索。我在实际刷题中总结出一个判断方法:先把问题缩小到三个点看,如果能确定中间点的最优值,再看看这个值是否不随更远范围变化。如果都不变,那它就是贪心题。
2. 贪心策略是怎么推出来的:从直觉到证明
说句实话,我第一次做这道题的时候也没想那么多,上来就写了个双指针往两边扫描,结果越写越乱。后来把“两个规则”拆开看,才发现这题的贪心特别精巧——它其实是把“同时满足左右两边”这个复杂条件,拆成了两个互不干扰的简单条件,再分别处理。
2.1 拆成两个独立规则的思路
原题要求同时满足“左边高则比左边多”和“右边高则比右边多”。如果同时考虑两个方向,确实很绕,因为你不知道某个位置的最终值应该被哪边抬高。但如果你只看一个方向,规则就变得非常简单了。
我们可以定义两个数组:
- left[i] 表示只考虑「左边相邻关系」时,第 i 个孩子至少需要多少颗糖。规则是:如果 ratings[i] > ratings[i - 1],那么 left[i] = left[i - 1] + 1,否则 left[i] = 1。
- right[i] 表示只考虑「右边相邻关系」时,第 i 个孩子至少需要多少颗糖。规则是:如果 ratings[i] > ratings[i + 1],那么 right[i] = right[i + 1] + 1,否则 right[i] = 1。
这里每一条规则都只考虑一侧的邻居,递推关系一目了然。左边规则从左往右扫一遍就能填完,右边规则从右往左扫一遍就能填完。你看,原先的二维冲突瞬间被降维成了一维递推。
那么最终每个孩子的糖果数怎么取?答案是取 left[i] 和 right[i] 的较大值,即 candies[i] = max(left[i], right[i])。为什么是取较大而不是相加或者取较小?因为两条规则都必须被满足,所以某个位置既要满足左边的约束,又要满足右边的约束,那么这个值就必须同时大于等于 left[i] 和 right[i],取两者的最大值就是满足两个约束的最小值。取较小会违反其中一侧,取两数之和虽然说也能满足,但明显不是最小,题目要求的是最少糖果,所以最大值的取法既安全又最优。
2.2 为什么两次遍历能保证全局最优
这一节可能是整题最重要的地方,也是面试官最爱追问的点:为什么 left 和 right 分别取最优,然后逐点取 max,最终全局就是最优的?
首先,任何满足题目条件的合法分配方案,它的每个位置 candies[i] 一定同时满足左侧约束和右侧约束。所以对任意 i,有 candies[i] ≥ left[i],同时 candies[i] ≥ right[i]。这推出 candies[i] ≥ max(left[i], right[i])。也就是说,任何合法方案的糖果总数都不可能小于 sum(max(left, right))。
接下来要证明 sum(max(left, right)) 本身是合法方案。一个直接的方法是逐点验证:对任意相邻位置 i 和 i + 1,假设 ratings[i] < ratings[i + 1],那么根据 left 的递推规则,left[i + 1] = left[i] + 1,所以 left[i + 1] > left[i],自然有 max(left[i + 1], right[i + 1]) ≥ left[i + 1] > left[i]。但我们需要的是 max(left[i + 1], right[i + 1]) > max(left[i], right[i])。这里可以分情况:如果 right[i] ≤ left[i],那么 max(left[i], right[i]) = left[i],上面不等式直接成立;如果 right[i] > left[i],此时需要进一步利用 right 序列的性质。考虑从 i 开始向右连续上升的评分段,在这个段内 right 值是严格递减的,但 left 值是严格递增的;两者会在某个位置交叉。由于 left[i + 1] 已经比 left[i] 大,而 right[i] 又比 right[i + 1] 大,可以证明 max(left[i], right[i]) 不会超过 max(left[i + 1], right[i + 1])。严格来说,这个证明如果用数学归纳或者反证法写会更严密,但直观理解是这样的:取 max 的操作,等价于把这个位置的“高度”抬高到两个约束的公共上界。相邻位置中评分更高的一侧,它在 left 维度上天然高 1,所以取完 max 之后它依然比另一侧高;评分更低的一侧,它唯一可能超过 left 的地方在于 right 维度,而 right 维度是随着远离高点递减的,在更近高点的位置反而更大。两股力量方向相反,但更高点的位置至少有一侧约束占优,所以整体依然保序。这个逻辑多说两句,其实是贪心正确性证明里比较标准的“交叉论证”,如果你在面试时能讲出这层,基本就过关了。
其次,逐点取 max 后得到的方案,总糖果数就是 sum(max(left, right)),这个值已经是所有合法方案的下界,因此它就是最小值。于是我们既证明了它合法,又证明了它最优。这个证明套路非常通用:先算出某个下界,再构造一个达到下界的合法方案,两步合一直接搞定最优性证明。很多贪心问题都可以这么证,建议你把这个模式记下来。
2.3 两种贪心实现路线对比
除了两次数组的“标准解法”,网上还有一种更省空间的写法,只需要一次从左到右的遍历加一个变量。那个做法本质上是在维护“连续上升段”的长度,遇到下降段就重置计数,遇到相等就重置为 1。这种写法对单调递增/递减的序列很直观,但实现时特别容易在峰值处理上翻车。我个人的建议是:第一遍做这题时老老实实用两个数组,把思路梳理清楚,等确认理解到位了再考虑常数空间的优化版。两个数组的写法和上面证明过程一一对应,代码也最不容易出错;常数空间版写起来短,但面试时如果讲不清边界,反而容易扣分。后面我会在第三章把两种写法都给出,并分析它们各自的适用场景。
3. 完整实现与代码逐行解读
这一章节进入实操环节。代码本身不难,难的是把每一步和前面讲的贪心推导一一对应起来。我会先给标准的两次遍历写法,再给一个省空间的优化版,最后补充常见的复杂度分析和面试追问。
3.1 两次遍历的标准实现
先说思路的落地。我们需要两个辅助数组,一个记录从左往右推的结果,一个记录从右往左推的结果。为了符合“每个孩子至少 1 颗”的规则,两个数组的初始值都设为 1。C++ 实现如下:
class Solution { public: int candy(vector<int>& ratings) { int n = ratings.size(); vector<int> left(n, 1); // 只考虑左边约束时的最少糖果数 vector<int> right(n, 1); // 只考虑右边约束时的最少糖果数 // 从左往右:只要右边评分更高,右边就至少比左边多拿 1 颗 for (int i = 1; i < n; ++i) { if (ratings[i] > ratings[i - 1]) { left[i] = left[i - 1] + 1; } } // 从右往左:只要左边评分更高,左边就至少比右边多拿 1 颗 for (int i = n - 2; i >= 0; --i) { if (ratings[i] > ratings[i + 1]) { right[i] = right[i + 1] + 1; } } int ans = 0; for (int i = 0; i < n; ++i) { ans += max(left[i], right[i]); } return ans; } };逐行说几个关键点。初始化为 1 这一步非常关键,它直接把“每个孩子至少 1 颗”的约束写死了。如果写成 0,后面递推时很多位置的 left 值会被算成 0,最后还要额外补 N,逻辑就乱了。从左往右的那一趟,只有当 ratings[i] > ratings[i - 1] 时才更新 left[i],等于或小于的情况都保持初始值 1。这意味着一个评分下降或者持平的孩子,在左侧约束下只需要拿 1 颗就够了。从右往左同理。最后合并时取 max,这个前面已经证明过,就是满足双侧约束的最小值。
我自己第一次写这段代码时,在第二个循环的边界上踩过坑。有人会把循环写成for (int i = n - 1; i >= 0; --i),然后在循环体里判断i + 1是否越界。那样也能跑,但多了一次无意义判断,而且容易因为手滑把n - 2写成n - 1,导致数组越界。更推荐的方式是让 i 从 n - 2 开始,循环体内不需要任何边界判断,逻辑更干净。
如果要进一步压缩内存,直接把 right 数组省掉也行。思路是用一个变量 prev 记录右边界推出的上一个位置的 right 值,然后左边位置实时计算。但需要注意,如果你只需要 max 合并,那确实没必要开完整数组;如果后面还想看具体分配方案,两个数组会更方便。实际工程里,如果数据量不大,我一般倾向于保留两个数组,代码可读性更好,面试时也更方便对着数组讲推导过程。
3.2 常数空间优化版:能不能少用一整个数组
上面标准解法的时间复杂度是 O(n),空间复杂度是 O(n)。面试时,面试官经常会追问一句:“能不能把空间优化到 O(1)?”这时候你需要给出下面的写法。
常数空间版本的思路是:在一次从左到右的遍历中,维护当前连续上升段的长度。它的核心观察是,整个分配方案可以看成若干段“先上升后下降”的山峰结构。在一个严格上升段里,每个位置的糖果数从 1 开始逐次 +1;进入下降段后,为了满足右边更高则更多,需要对下降段的每个位置重新调整,但它们调整后的值受到峰值高度的限制。
这个写法的代码在力扣题解区流传很广,但很多人照抄之后讲不出所以然,面试被追问就露馅。我给你一个带注释、我自己认可的版本:
class Solution { public: int candy(vector<int>& ratings) { int n = ratings.size(); if (n <= 1) return n; int candies = 1; // 第一个孩子先拿 1 颗 int up = 0; // 当前连续上升的长度(不包含当前点) int down = 0; // 当前连续下降的长度 int prevUp = 1; // 记录上一次峰值位置的糖果数 for (int i = 1; i < n; ++i) { if (ratings[i] > ratings[i - 1]) { // 进入上升段:重置下降计数,上升长度 +1 down = 0; ++up; // 上升段当前位置的糖果数 = 上升长度 + 1 candies += up + 1; prevUp = up + 1; } else if (ratings[i] == ratings[i - 1]) { // 相等:看作新起点,两侧互不影响 up = 0; down = 0; candies += 1; prevUp = 1; } else { // 进入下降段 up = 0; ++down; // 下降段每个位置先按“距离谷底的距离”补 1 颗 candies += down; // 如果下降长度已经超过峰值的糖果数,说明峰值自身也少算了一颗 if (down >= prevUp) { ++candies; } } } return candies; } };这个版本的核心难点在下降段的处理。下降段的第一个位置紧挨着峰值,它至少要比峰值少 1 颗。如果整个下降段长度为 down,那么这些位置从峰值往下依次递减 1,糖果数分别是 prevUp - 1, prevUp - 2, ..., 1,这一段的总和是 prevUp - 1 + prevUp - 2 + ... + 1。但代码里用的策略是每个下降位置先按“距离当前位置的深度”累加,也就是第一次遇到下降加 1,第二次再加 2,相当于直接累加了下降段位置的糖果数而不是先减峰值。这两种方式算出的总和对不对呢?仔细验证会发现,前者的总和是 prevUp × down - down(down+1)/2,后者是先按 down 累加,即 1 + 2 + ... + down,等于 down(down+1)/2,然后把峰值位置的糖果数也通过 prevUp 累计进去。这两套算法看着不一样,但最终在“峰值高度是否够用”这个判断上殊途同归——如果 down >= prevUp,说明下降段的深度已经超过了峰值的糖果数,峰值本身也要被抬高 1。很多博客讲到这里就含糊其辞,我建议你直接拿一组连续递减的用例手推一遍,例子里 prevUp 和 down 的变化就清楚了。
这个写法的优点是空间 O(1),缺点是逻辑不那么直观,解释起来费口舌。我的建议是:如果你面试时写这个版本,一定要先画几条 rat 曲线,把 up 和 down 的物理意义说出来,否则面试官很容易认为你在背模板。
3.3 复杂度分析与面试官追问
标准版的时间复杂度是 O(n),空间复杂度 O(n)。优化版的时间复杂度也是 O(n),但空间是 O(1)。这里的 n 是孩子个数。在面试中这是一个很好的加分点:标准版讲清楚正确性证明,优化版展示自己的边界思考能力。
还有个面试官爱问的变体:“如果孩子围成一圈,首尾相邻,怎么做?”这个变体不是原题,但思路可以延伸。环形情况下,头和尾也必须满足“更高则更多”的规则。处理方式通常有两种:一种是把数组复制一份变成 2n,然后对长度为 2n 的数组跑两次遍历,最后取中间 n 个位置的结果;另一种是分别处理最大值和最小值的位置,把环从某个局部极值处“剪开”变成链。第一种更容易实现,但空间翻倍;第二种常数更小,但代码容易出错。如果你在准备大厂面试,建议把环形变体也刷一遍,因为这道题的环形版本在不少公司的笔试题里出现过。
还有一道评论区常被提到的题是 LeetCode 的“跳跃游戏 II”。它和糖果题看起来风马牛不相及,一个是发糖,一个是跳跃,但核心都是贪心:跳跃游戏每次选择能跳得最远的位置更新边界,糖果题每次只看相邻约束取局部最优。我在第五章会单独展开。
4. 调试、边界与常见坑
写对一道题只算第一步。真正体现水平的,是能不能在出错之后快速定位问题。这一部分我把这道题最容易踩的坑和对应的调试方法整理出来,都是我实际刷题时反复踩过、或者帮别人 review 代码时见到的。
4.1 典型错误:只比较相邻一侧
最常见的错误写法是只做一次从左向右的遍历,遇到 ratings[i] > ratings[i - 1] 就加糖,其他情况直接给 1。这种写法在处理严格递增的评分时结果正确,但一旦出现递减段就出错。
举个例子:ratings = [3, 2, 1]。正确结果应该是 6,即 [3, 2, 1]。如果只做从左向右的一趟,结果是 [1, 1, 1],总数 3,明显错误。原因很简单:从左往右只能保证“右边比左边高时右边拿得多”,无法保证“左边比右边高时左边拿得多”。而这两个约束必须同时成立。
反面教训就是:这类题“一趟扫描”之所以不行,不是因为代码写错了,而是因为信息方向不够。你只在一个方向上看邻居,就丢掉了一半的约束。正确做法是两趟扫描,把两个方向的约束分别算清楚,再合并。这个“拆约束、分方向、再合并”的思路,其实可以推广到很多二维约束问题里,值得记住。
4.2 边界用例速查表
这道题的边界情况直接决定代码是否正确。我整理了一份速查表,建议刷题时贴在手边:
| 输入 ratings | 期望输出 | 说明 |
|---|---|---|
| [1] | 1 | 只有 1 个孩子,直接返回 1 |
| [1, 2] | 3 | 递增序列,分配 [1, 2] |
| [2, 1] | 3 | 递减序列,分配 [2, 1] |
| [1, 1, 1] | 3 | 完全相等,每个人 1 颗即可 |
| [1, 0, 2] | 5 | 经典用例,[2, 1, 2] |
| [1, 2, 2] | 4 | 中间高,右邻居相等,[1, 2, 1] |
| [1, 3, 2, 2, 1] | 7 | 混合升降,分配 [1, 2, 1, 2, 1] 或等价方案 |
| [5, 4, 3, 2, 1] | 15 | 严格递减,[5, 4, 3, 2, 1] |
最后一行严格递减的用例很多人会忽视。如果只做一遍从左往右的遍历,这里会全部输出 1,结果差了十万八千里。再加上“每个孩子至少 1 颗”,严格递减时每个位置都只能拿比后一位多 1 颗,所以从右往左推出来的 right 数组恰好是 [5, 4, 3, 2, 1]。这也是为什么两趟遍历缺一不可的原因。
4.3 如何验证你的算法是对的
如果你不想依赖在线评测,也可以在本地写个简单的暴力验证程序。方法很简单:枚举所有满足条件的分配方案,取糖果总数最小的那个,然后和你的贪心结果对比。对于 n 很小的随机数据,暴力枚举可以轻松跑完,能有效地帮你确认贪心策略没有写歪。
暴力思路也很好写:对每个位置赋予一个糖果数,范围从 1 到 ratings 数组长度上限,然后检查所有相邻关系是否合法,再记录合法方案中的最小总和。小型随机数据下,这个暴力程序能在毫秒级别跑完。对比结果可以帮你发现那些“自以为是对的但边界没处理对”的隐藏 bug,尤其是相等评分、长下降段这类容易翻车的场景。我在本地就长期保留着一个暴力小工具,遇到贪心题就随机生成数据对比,比反复看题解靠谱得多。
5. 从糖果到跳跃游戏:贪心的识别、验证与迁移
糖果题解决完,思路不应该就此止步。很多人在刷题时有个习惯,做完一道题就急着看下一道,从来不总结“这道题教会了我什么”。但实际上,把一道题吃透,再顺着它的题型脉络迁移到其他题目上,效率远比盲目刷十道新题高。这一章我就拿最近很热的“跳跃游戏 II”来和糖果题做个对照,帮你看清贪心题的通用解法套路。
5.1 跳跃游戏 II:同样“只盯局部”的经典题
“跳跃游戏 II”的题意是:给定一个非负整数数组 nums,你初始站在下标 0 的位置,nums[i] 表示你在位置 i 最多能往前跳多远。目标是到达最后一个位置,问最少需要跳几次。
这题也是贪心题的经典代表,而且和糖果题有很多神似之处。先说结论:每次跳跃时,在当前可达范围内,选择能让“下一步最远到达位置”最大的那个点来跳。这个策略就是典型的局部最优推全局最优。
核心实现只需要一次遍历,维护三个变量:当前这一跳能到达的最远位置 curEnd,整个过程中能到达的最远位置 far,以及跳跃次数 steps。遍历每个位置时,先更新 far = max(far, i + nums[i]),如果 i 到达了 curEnd,说明这一跳覆盖范围结束,必须跳一次,steps 加 1,同时把 curEnd 更新为 far。这里有个细节:当 i 等于最后一个位置时,我们不需要再跳,所以遍历范围可以控制在 n - 1 之前。
代码长这样:
class Solution { public: int jump(vector<int>& nums) { int n = nums.size(); int curEnd = 0; // 当前这一跳能达到的最远位置 int far = 0; // 从当前已经扫描过的位置里,能到达的最远位置 int steps = 0; for (int i = 0; i < n - 1; ++i) { far = max(far, i + nums[i]); if (i == curEnd) { ++steps; curEnd = far; } } return steps; } };为什么这个贪心是对的?因为每一步的目标是“用最少的跳跃次数覆盖到更远的位置”,而在某一跳范围内,你不需要决定“具体落在哪个位置”,只需要保证“从这个范围内某个点出发,下一步能到达更远”。这个“覆盖范围内的最优边界”思想和糖果题的“取左右约束的最大值”本质上是一致的:局部算出的最优解不会因为后面的数据而变得更差,所以可以直接用于全局累加。
5.2 贪心题的识别信号和验证方法
我自己的经验里,识别一道题能不能用贪心,主要看三个信号:
第一,题目问的是“最少”“最多”“最短”“最大”这类最值问题,而且没有明显的“选择组合”感觉。比如糖果题问最少糖果数,跳跃游戏 II 问最少跳跃次数。
第二,约束条件是局部性的。糖果题的约束只在相邻两个孩子之间,跳跃游戏的约束只在你当前可达范围内。如果约束涉及全局、且决策之间会互相影响,那大概率是 DP 或回溯,而不是贪心。
第三,可以试着先做局部最优选择,看它是否会导致后来的某个决策被“锁死”。如果锁死了,可能不是贪心;如果无论后面发生什么都不影响前面的决策已经给出的下界,那贪心大概率成立。
验证方法也很实用:如果一道题你怀疑可以用贪心,先写一个暴力搜索或者 DP 的基准版本,再用随机数据小规模对比。如果几百组随机数据下贪心结果和基准版本完全一致,那你的贪心策略基本可以放心推广到大规模情况。这个方法我在日常刷题中用得非常频繁,能省下大量纠结“这个贪心对不对”的时间。
5.3 什么时候不能贪心
讲完贪心的好处,也要泼盆冷水。并不是所有最值问题都能用贪心解决,经典的反例是“零钱兑换”——给定硬币面额和总金额,问最少需要多少枚硬币。如果硬币面额是 1, 3, 4,总金额 6,贪心会先拿 4 再拿两个 1,需要 3 枚;但最优解是 3 + 3,只需要 2 枚。这里的贪心失败,是因为局部选择会改变后续子问题的结构,局部最优推不出全局最优。
再比如经典的 0-1 背包问题,每一步选“性价比最高”的物品,也并不能保证总体价值最大,因为你可能为了一个高性价比物品占掉大量容量,反而装不下其他组合。
所以识别“能不能贪心”是比“怎么写贪心”更重要的一步。我的一个判断习惯是:如果某个决策会导致“状态”发生变化,而这个状态会影响后面的选择,那就要格外小心。糖果和跳跃游戏之所以能用贪心,是因为它们的决策不改变问题结构——糖果的“状态”只有位置,而位置天然就是递推顺序;跳跃游戏的状态是“当前位置”,而当前可达范围覆盖之后,下一步的状态范围也只由当前位置决定,不会出现“选了这个位置就失去了另一个位置的所有可能”的情况。
写在最后的一点心得
糖果这道题,很多人以为它就是一道“会写两次遍历就完事”的水题,但真正把它吃透,你能带走的远不止一段代码。我自己的体会是,它最大的价值在于逼你理解“局部最优如何拼接成全局最优”的证明过程。大多数人在刷题时只关注 AC,不关注为什么 AC,导致换一个类似题又不会了。如果你能把糖果题的证明逻辑讲清楚,跳跃游戏 II、加油站、摆动序列这类的贪心题,你基本都能照着同样的思路去分析:先找局部约束,再证明局部最优不劣于全局,最后实现。这套方法论在任何算法面试里都比背题解值钱得多。最后再分享一个小技巧:刷这种简单中等难度的经典题,建议你用自己的话把题解思路讲给别人听一遍。讲不出来或者讲着讲着自己卡住,说明你还没真懂。能讲明白的那一刻,这道题才算真正变成你自己的东西。