n
个孩子站成一排。给你一个整数数组 ratings
表示每个孩子的评分。
你需要按照以下要求,给这些孩子分发糖果:
- 每个孩子至少分配到
1
个糖果。 - 相邻两个孩子评分更高的孩子会获得更多的糖果。
请你给每个孩子分发糖果,计算并返回需要准备的 最少糖果数目 。
示例 1:
输入:ratings = [1,0,2] 输出:5 解释:你可以分别给第一个、第二个、第三个孩子分发 2、1、2 颗糖果。
示例 2:
输入:ratings = [1,2,2] 输出:4 解释:你可以分别给第一个、第二个、第三个孩子分发 1、2、1 颗糖果。 第三个孩子只得到 1 颗糖果,这满足题面中的两个条件。
提示:
n == ratings.length
1 <= n <= 2 * 104
0 <= ratings[i] <= 2 * 104
难度:困难
我们可以用贪心算法解决这个问题,先给每个人发一个糖果保证每个人都能拿到一个糖果,然后在判断相邻孩子获取糖果的问题,分数高的孩子可以获得更多的糖果,但是题目要求要准备的最少糖果数,所以分数高的孩子比相邻分数低的孩子只能多分一个糖果。
首先我们需要了解一下贪心算法:
1. 局部最优选择:在每一步选择中,算法选择当前状态下最优的选项,而不考虑后续可能的影响。这意味着它只关注局部最优解,而不是整体最优。
2. 无回溯:贪心算法通常不回溯。一旦做出选择,就不再考虑之前的决策,也不重新评估选择。
3. 最优子结构:问题可以被分解为子问题,并且通过解决这些子问题,可以构建出原问题的最优解。
4. 适用场景:贪心算法适用于某些特定类型的问题,比如最小生成树、最短路径、活动选择、货币兑换等。在这些问题中,局部最优解可以导致全局最优解。
例如:
最小生成树:如 Kruskal 和 Prim 算法,通过逐步选择最小边,构建树。
硬币换零钱:选择面额最大的硬币,直到凑齐所需的金额。
活动选择问题:选择不重叠的活动,使得总时间最长。
贪心算法的优点在于实现简单、效率高,但缺点是并不适用于所有问题。选择贪心算法时,需要确保所解决的问题满足贪心选择性质和最优子结构。
了解到什么是贪心算法后,带入此题我们可以设计一些思路:
-
初始化数组:首先,为每个孩子准备一个糖果。我们使用一个数组
candies
,每个元素初始化为 1,表示每个孩子至少获得一个糖果。 -
第一次遍历(从左到右):
- 我们从左到右遍历
ratings
,对于每个孩子,如果当前孩子的评分比前一个孩子的评分高,则将当前孩子的糖果数量设为前一个孩子糖果数量加 1。
- 我们从左到右遍历
-
第二次遍历(从右到左):
- 这次我们从右到左遍历
ratings
,对于每个孩子,如果当前孩子的评分比后一个孩子的评分高,则将当前孩子的糖果数量设为当前孩子糖果数量和后一个孩子糖果数量加 1 的最大值。
- 这次我们从右到左遍历
-
计算总糖果:最后,对
candies
数组中的所有元素进行求和,得到总糖果数量。
public class CandyDistribution {
public int candy(int[] ratings) {
int n = ratings.length;
// 初始化每个孩子的糖果数量为 1
int[] candies = new int[n];
for (int i = 0; i < n; i++) {
candies[i] = 1;
}
// 从左到右遍历
for (int i = 1; i < n; i++) {
if (ratings[i] > ratings[i - 1]) {
candies[i] = candies[i - 1] + 1;
}
}
// 从右到左遍历
for (int i = n - 2; i >= 0; i--) {
if (ratings[i] > ratings[i + 1]) {
candies[i] = Math.max(candies[i], candies[i + 1] + 1);
}
}
// 计算总糖果数
int totalCandies = 0;
for (int candy : candies) {
totalCandies += candy;
}
return totalCandies;
}
public static void main(String[] args) {
CandyDistribution cd = new CandyDistribution();
int[] ratings1 = {1, 0, 2};
System.out.println(cd.candy(ratings1)); // 输出 5
int[] ratings2 = {1, 2, 2};
System.out.println(cd.candy(ratings2)); // 输出 4
}
}
代码解析:
- 初始化糖果数组:创建
candies
数组并将每个孩子的糖果初始为 1。 - 第一次遍历:
- 从左到右遍历
ratings
。对于每个孩子i
,如果ratings[i]
大于ratings[i - 1]
,则candies[i]
应为candies[i - 1] + 1
。
- 从左到右遍历
- 第二次遍历:
- 从右到左遍历
ratings
。对于每个孩子i
,如果ratings[i]
大于ratings[i + 1]
,则将candies[i]
更新为两者中的最大值,确保满足条件。
- 从右到左遍历
- 总糖果数:通过迭代
candies
数组并求和来计算总糖果的数量,最后返回结果。 - 时间复杂度:O(n),我们对
ratings
数组进行了两次遍历。 - 空间复杂度:O(n),需要额外的
candies
数组来存储每个孩子的糖果数量,但可以优化为 O(1) 只使用常数额外空间来保存累加值而不是数组。
在解决“分发糖果”这个问题时,我们需要确保遵循以下两个关键条件:
- 每个孩子至少要分配到 1 个糖果。
- 相邻两个孩子中,评分更高的孩子要获得更多的糖果。
采用双向遍历的方法,从左到右和从右到左各遍历一次,这是因为单方向遍历可能无法完全满足所有条件,尤其是在处理中间孩子的评分时可能会出现问题。
从左到右遍历的目的
- 主要目标:确保评分更高的孩子获得更多的糖果。
- 实现:当我们从左到右遍历时,可以很直观地比较当前孩子的评分和前一个孩子的评分。
- 如果当前孩子的评分
ratings[i]
高于前一个孩子的评分ratings[i-1]
,那么当前孩子的糖果数量应该是前一个孩子的糖果数量加 1,即:candies[i] = candies[i - 1] + 1
。
- 如果当前孩子的评分
- 例子:对于评分
[1, 2, 1]
,左到右遍历将会给出糖果分配[1, 2, 1]
,满足前两个孩子的条件。
从右到左遍历的目的
- 主要目标:确保评分更高的孩子获得更多的糖果,特别是当较高评分的孩子出现在右侧时。
- 实现:从右到左遍历时,我们可以比较当前孩子的评分和下一个孩子的评分。
- 如果当前孩子的评分
ratings[i]
高于后一个孩子的评分ratings[i + 1]
,则当前孩子的糖果数量可能需要更新:candies[i] = Math.max(candies[i], candies[i + 1] + 1)
。
- 如果当前孩子的评分
- 例子:对于评分
[1, 2, 1]
,右到左遍历将会检查最后一个孩子(评分为 1),并发现它需要保持 1 个糖果,前面的 2 评分要高于它,因此经过右遍历后的糖果分配可能需要调整,确保它的符合条件。
为什么需要双向遍历?
-
完整性:单向遍历不能保证在所有情况下都能满足相邻孩子的评分条件。比如在更复杂的情况下,可能有多个相邻孩子的评分关系需要同时满足。
-
交互依赖:孩子的评分与其左右相邻孩子的评分是相互依赖的。在左到右的第一遍可能会将一部分好的配置锁定,但在右到左的遍历中可能会发现一些配置是错误的。双向遍历能够确保所有孩子的糖果分配都符合条件。
-
优化局部决策:通过双向遍历,可以及时修正任何在单方向遍历时导致的错误,从而最终得到一个符合条件的全局解。