1. 问题背景与核心挑战
LeetCode 135题"分发糖果"是一个经典的贪心算法应用题,它模拟了现实中按表现分配奖励的场景。题目要求为一排孩子分配糖果,每个孩子至少得到一个糖果,且相邻孩子中评分更高的必须获得更多糖果。我们的目标是在满足这两个约束条件下,找出总糖果数最少的分配方案。
这个问题的难点在于约束条件的双向性。传统的单向贪心策略(如从左到右扫描)无法同时满足"左邻居"和"右邻居"的双向比较要求。举个例子,当评分序列为[1,3,2,1]时:
- 第一个孩子得1颗
- 第二个孩子(3)比左边高,得2颗
- 第三个孩子(2)比左边低,理论上得1颗,但这会导致它比右边的1分孩子糖果数相同,违反规则
2. 算法设计思路解析
2.1 双向扫描策略
解决这个问题的关键在于将双向约束拆解为两个单向处理:
- 从左到右扫描,确保每个孩子比左边评分高的邻居得到更多糖果
- 从右到左扫描,确保每个孩子比右边评分高的邻居得到更多糖果
- 对每个位置取两次扫描结果的较大值
这种分治思想将复杂问题简化为两个可独立处理的子问题。在第一次扫描时,我们只需要关注左侧约束;第二次扫描则专注于右侧约束。最终的分配方案就是同时满足两个约束的最小解。
2.2 贪心选择的正确性证明
为什么这种方法能得到最优解?可以从以下角度理解:
- 单向扫描得到的已经是该方向上的最小解
- 取最大值操作保证了两个约束同时满足
- 任何更小的分配都会至少违反一个方向的约束
用数学归纳法可以严格证明:假设前i-1个孩子已正确分配,那么第i个孩子的分配数只可能由左侧或右侧的较大约束决定,这正是贪心选择的最优子结构性质。
3. 详细实现步骤
3.1 初始化阶段
python复制def candy(ratings):
n = len(ratings)
left = [1] * n # 从左到右的分配数组
right = [1] * n # 从右到左的分配数组
每个孩子初始分配1颗糖果,这是满足"每人至少一颗"的基础条件。创建两个数组分别记录两个方向的分配结果。
3.2 左扫描处理
python复制for i in range(1, n):
if ratings[i] > ratings[i-1]:
left[i] = left[i-1] + 1
从左到右遍历,如果当前孩子评分高于左边邻居,则其糖果数比左边多1。这保证了从左看的单调递增关系。
例如评分[1,3,2,1]的left数组变化:
初始:[1,1,1,1]
处理后:[1,2,1,1] (3>1所以第二个变为2)
3.3 右扫描处理
python复制for i in range(n-2, -1, -1):
if ratings[i] > ratings[i+1]:
right[i] = right[i+1] + 1
从右到左遍历,如果当前孩子评分高于右边邻居,则其糖果数比右边多1。这保证了从右看的单调递增关系。
同一例子的right数组:
初始:[1,1,1,1]
处理后:[1,2,1,1] (倒数第二个2>1所以变为2)
3.4 合并结果
python复制total = 0
for i in range(n):
total += max(left[i], right[i])
return total
对每个位置取两个方向的最大值,这样能同时满足左右约束。总和即为最小糖果数。
最终分配:
left: [1,2,1,1]
right: [1,2,1,1]
max: [1,2,1,1] → 总和5
4. 复杂度分析与优化
4.1 时间复杂度
算法包含三次线性遍历:
- 左扫描:O(n)
- 右扫描:O(n)
- 合并结果:O(n)
总时间复杂度为O(n),这是最优的,因为至少需要检查每个元素一次。
4.2 空间优化
原始方法使用了两个辅助数组,空间复杂度O(n)。可以优化为只使用一个数组:
python复制def candy(ratings):
n = len(ratings)
candies = [1] * n
# 左扫描
for i in range(1, n):
if ratings[i] > ratings[i-1]:
candies[i] = candies[i-1] + 1
# 右扫描并合并
total = candies[-1]
for i in range(n-2, -1, -1):
if ratings[i] > ratings[i+1]:
candies[i] = max(candies[i], candies[i+1]+1)
total += candies[i]
return total
这种实现将空间复杂度降为O(1)(不考虑输入输出占用的空间),是更优的方案。
5. 边界情况与测试用例
5.1 常见边界情况
- 空列表:应返回0
- 单元素列表:返回1
- 所有评分相同:每人1颗,总和为n
- 严格递增序列:1,2,...,n颗,总和n(n+1)/2
- 严格递减序列:n,n-1,...,1颗,同上
- 波峰波谷序列:如[1,3,2,1]已讨论
5.2 测试用例设计
python复制test_cases = [
([], 0), # 空列表
([1], 1), # 单元素
([1,1,1], 3), # 全等
([1,2,3,4], 10), # 严格递增
([4,3,2,1], 10), # 严格递减
([1,3,2,1], 5), # 波峰
([1,2,3,2,1], 9), # 对称峰
([1,2,3,3,2,1], 12) # 平台峰
]
6. 实际应用与变种
6.1 现实场景映射
这个问题可以映射到许多实际场景:
- 员工绩效奖金分配
- 服务器资源配额分配
- 交通信号灯时长配置
- 任何需要满足局部相对约束的资源分配问题
6.2 问题变种
- 糖果数差值约束:相邻孩子糖果数差不超过k
- 多维排列:孩子排成矩阵,需比较上下左右邻居
- 带权分配:不同孩子的基础糖果需求不同
- 环形排列:首尾孩子也视为相邻
以环形变种为例,解决方法可以:
- 拆环为链:任选一处断开,处理两次取最优
- 特殊处理首尾:在合并阶段额外比较首尾关系
7. 常见错误与调试技巧
7.1 典型错误模式
-
单次扫描尝试同时处理左右约束:
- 会导致无法正确处理波峰/波谷
- 示例:[1,3,2,1]会错误分配为[1,2,1,1]总和5,但正确应为[1,2,1,1]
-
初始化糖果数为0:
- 违反每人至少一颗的基本约束
- 导致总和计算错误
-
比较符号方向错误:
- 把>写成<会导致完全相反的分配
- 特别容易在右扫描时出错
7.2 调试建议
-
打印中间数组:
python复制print("Left:", left) print("Right:", right) -
可视化分配结果:
code复制评分:[1, 3, 2, 1] Left:[1, 2, 1, 1] Right:[1, 2, 1, 1] Final:[1, 2, 1, 1] -
小规模测试:
- 从长度为2、3的简单案例开始
- 逐步增加复杂度
8. 算法比较与选择
8.1 其他可行方法
-
暴力搜索:
- 枚举所有可能的分配方案
- 检查约束条件
- 时间复杂度O(n*2^n),完全不实用
-
拓扑排序:
- 将比较关系视为有向边
- 按拓扑序分配糖果
- 时间复杂度O(n),但实现复杂
-
峰值检测:
- 先找出所有局部峰值
- 从峰值向两边递减分配
- 边界条件处理复杂
8.2 为什么选择双向扫描
- 实现简单直观
- 线性时间复杂度
- 空间可优化到O(1)
- 易于理解和证明正确性
- 适应大多数变种问题
9. 编码实现细节
9.1 Python实现技巧
python复制def candy(ratings):
n = len(ratings)
if n == 0: return 0
# 初始化糖果数组
candies = [1] * n
# 左扫描
for i in range(1, n):
if ratings[i] > ratings[i-1]:
candies[i] = candies[i-1] + 1
# 右扫描并累加
total = candies[-1]
for i in range(n-2, -1, -1):
if ratings[i] > ratings[i+1]:
candies[i] = max(candies[i], candies[i+1] + 1)
total += candies[i]
return total
关键点:
- 提前处理空列表特殊情况
- 合并右扫描和累加步骤
- 使用max保证不破坏左扫描结果
9.2 其他语言实现差异
-
C++:
- 使用vector替代列表
- 注意索引的符号类型(size_t)
- 反向遍历语法不同
-
Java:
- 数组初始化语法不同
- 需要显式类型声明
- 边界检查更严格
-
JavaScript:
- 数组处理方式类似
- 注意undefined与null的区别
- 可以使用reduce进行累加
10. 扩展思考与练习建议
10.1 相关题目推荐
-
LeetCode 42. 接雨水:
- 类似的峰值处理思想
- 也需要双向扫描
-
LeetCode 84. 柱状图中最大的矩形:
- 单调栈应用
- 处理局部极值
-
LeetCode 406. 根据身高重建队列:
- 贪心算法的另一种应用
- 处理双重约束
10.2 自我练习建议
-
手动模拟:
- 在纸上画出不同评分序列
- 逐步填写left/right数组
- 验证最终结果
-
变种实现:
- 尝试环形版本
- 实现差值约束版本
- 添加权重因素
-
性能测试:
- 生成大规模随机数据
- 测试不同实现的运行时间
- 分析时间/空间消耗
在实际编码面试中,建议先解释双向扫描的思路,再逐步实现基础版本,最后讨论优化方案。注意处理边界条件和特殊输入,保持代码清晰可读。