LeetCode 135. 分发糖果:双向约束下的最小糖果分配方案

0 阅读5分钟

在算法题目中,“分发糖果”是一道经典的贪心算法应用题,核心难点在于处理“相邻孩子评分更高者获更多糖果”的双向约束。本文将从题目分析、代码拆解、原理推导到测试验证,完整解析这道题的最优解法。

一、题目回顾

题目描述

n 个孩子站成一排,每个孩子有一个评分 ratings。需要按照以下规则分发糖果:

  1. 每个孩子至少分配到 1 个糖果;

  2. 相邻两个孩子中,评分更高的孩子必须获得更多的糖果。

要求返回需要准备的最少糖果数目。

核心难点

本题的关键的是“双向约束”—— 一个孩子的糖果数不仅受左侧邻居影响,还受右侧邻居影响。若仅从单一方向遍历处理,会导致一侧约束不满足,最终结果错误。

二、最优解法代码实现

本文采用“两次遍历法”,时间复杂度 O(n),空间复杂度 O(n),是该题的最优解法之一,代码如下(TypeScript 版本):


function candy(ratings: number[]): number {
  const rL = ratings.length;
  if (rL === 0) return 0; // 边界处理:无孩子时返回0
  const candies = new Array(rL).fill(1); // 初始化:每个孩子至少1颗糖果

  // 第一次遍历:从左到右,满足“右侧比左侧评分高时,糖果更多”
  for (let i = 1; i < rL; i++) {
    if (ratings[i] > ratings[i - 1]) {
      candies[i] = candies[i - 1] + 1;
    }
  }

  // 第二次遍历:从右到左,满足“左侧比右侧评分高时,糖果更多”(取最大值兼顾双向)
  for (let i = rL - 2; i >= 0; i--) {
    if (ratings[i] > ratings[i + 1]) {
      candies[i] = Math.max(candies[i], candies[i + 1] + 1);
    }
  }

  // 求和得到最少糖果总数
  return candies.reduce((sum, num) => sum + num, 0);
};

三、代码逐行拆解

1. 边界处理与初始化


const rL = ratings.length;
if (rL === 0) return 0;
const candies = new Array(rL).fill(1);
  • 先获取孩子总数 rL,若没有孩子(rL=0),直接返回 0,避免后续遍历报错;

  • 定义 candies 数组存储每个孩子的糖果数,初始值全为 1,满足“每个孩子至少1颗糖果”的基础规则。

2. 第一次遍历:左到右处理左侧约束


for (let i = 1; i < rL; i++) {
  if (ratings[i] > ratings[i - 1]) {
    candies[i] = candies[i - 1] + 1;
  }
}
  • 遍历从 i=1 开始(跳过第一个孩子,无左侧邻居),对比当前孩子与左侧孩子的评分;

  • 若当前孩子评分更高,则其糖果数 = 左侧孩子糖果数 + 1,确保“左侧约束”满足(右侧比左侧评分高时,糖果更多)。

示例:若 ratings = [1,2,3],第一次遍历后 candies = [1,2,3],符合连续递增评分的分配需求。

3. 第二次遍历:右到左处理右侧约束


for (let i = rL - 2; i >= 0; i--) {
  if (ratings[i] > ratings[i + 1]) {
    candies[i] = Math.max(candies[i], candies[i + 1] + 1);
  }
}
  • 遍历从 i=rL-2 开始(跳过最后一个孩子,无右侧邻居),对比当前孩子与右侧孩子的评分;

  • 若当前孩子评分更高,不能直接赋值为 candies[i+1]+1,需取“当前糖果数”与“右侧糖果数+1”的最大值——这是为了兼顾第一次遍历的左侧约束,避免覆盖已满足的左侧规则。

示例:若 ratings = [3,2,1],第一次遍历后 candies = [1,1,1];第二次遍历后,i=1 时 ratings[1]>ratings[2],candies[1] = max(1,1+1)=2;i=0 时 ratings[0]>ratings[1],candies[0] = max(1,2+1)=3,最终 candies = [3,2,1],符合连续递减评分的分配需求。

4. 求和返回结果


return candies.reduce((sum, num) => sum + num, 0);

通过 reduce 方法对 candies 数组求和,得到满足所有规则的最少糖果总数。

四、原理推导:为什么需要两次遍历?

贪心算法的核心是“局部最优推导全局最优”,本题的局部最优需满足“双向约束”,单次遍历无法覆盖:

  1. 仅左到右遍历:无法处理“左侧评分高于右侧”的情况。例如 ratings = [1,3,2],左到右遍历后 candies = [1,2,1],但 ratings[1]>ratings[2],此时 candies[1] 应为 2(已满足),无需调整;若 ratings = [2,1,3],左到右遍历后 candies = [1,1,2],无问题。但遇到 ratings = [3,1,2] 时,左到右遍历后 candies = [1,1,2],但 ratings[2]>ratings[1] 已满足,而 ratings[0]>ratings[1] 未处理,需右到左遍历补全。

  2. 仅右到左遍历:无法处理“右侧评分高于左侧”的情况,与上述逻辑相反。

两次遍历后,每个孩子的糖果数同时满足“左邻约束”和“右邻约束”,局部最优叠加后得到全局最优(最少糖果数)。

五、测试用例验证

通过多个测试用例验证代码正确性,覆盖基础情况、边界情况和复杂情况:


// 测试用例1:基础混合情况
console.log(candy([1,0,2])); // 输出 5(分配:2,1,2)

// 测试用例2:连续递增
console.log(candy([1,2,3])); // 输出 6(分配:1,2,3)

// 测试用例3:连续递减
console.log(candy([3,2,1])); // 输出 6(分配:3,2,1)

// 测试用例4:峰谷结构
console.log(candy([1,3,2,1])); // 输出 9(分配:1,3,2,1)

// 测试用例5:所有评分相同
console.log(candy([2,2,2])); // 输出 3(每个孩子1颗)

// 测试用例6:单个孩子
console.log(candy([5])); // 输出 1

// 测试用例7:空数组
console.log(candy([])); // 输出 0

所有测试用例均输出正确结果,证明代码逻辑无误。

六、常见错误与优化方向

1. 常见错误点

  • 单次遍历简单判断:如“当前评分高于左/右则加1”,忽略双向约束,导致结果错误;

  • 第二次遍历未取最大值:直接赋值 candies[i] = candies[i+1]+1,覆盖左侧约束的结果;

  • 边界处理遗漏:未考虑空数组、单个孩子的情况。

2. 优化方向

本题可将空间复杂度优化至 O(1)(无需额外 candies 数组),通过变量记录前后糖果数关系实现,但逻辑更复杂,可读性降低。对于大多数场景,O(n) 空间的两次遍历法是“性价比最优”的选择。

七、总结

LeetCode135 分发糖果的核心是理解“双向约束”,通过两次贪心遍历分别满足左侧和右侧规则,最终得到最少糖果数。该题的解题思路可迁移到类似“双向约束”的贪心问题中,核心是拆分局部最优条件,分步实现后叠加得到全局最优。