在算法面试或实际的系统设计中,我们经常遇到看似简单却暗藏玄机的逻辑问题。今天,我们将深入探讨一个经典的数组处理问题——“分发糖果”。这个问题不仅考验我们对边界条件的处理能力,更是展示贪心算法思想的绝佳案例。
在这篇文章中,我们将一起探索如何在满足苛刻约束条件的前提下,计算出分发糖果所需的最小数量。我们将从问题本质出发,逐步构建最优解,并讨论代码实现中的各种细节和陷阱。
问题描述:平衡规则的分配艺术
想象一下,你是一位幼儿园老师,手里有一堆糖果,需要分发给排成一排的孩子们。每个孩子都有一个基于表现的评分(由一个整型数组 arr[] 表示)。你的分配必须严格遵守以下两条规则:
- 公平保底:每个孩子至少必须得到一颗糖果。
- 等级森严:如果某个孩子的评分比他相邻的邻居高,那么他必须比那个邻居拿到更多的糖果。
我们的目标是找到能够满足上述所有条件的糖果分配方案,使得所需的总糖果数量最小。
直观的例子
为了更好地理解,让我们先看两个简单的示例。
示例 1:严格递增序列
> 输入:arr[] = [1, 2, 3, 4, 5]
> 输出:15
分析:
在这个例子中,每个孩子的评分都严格高于前一个。根据规则,每个孩子都必须比左边的邻居多拿一颗糖。这形成了一个等差数列:
- 孩子 0 (评分 1) -> 1 颗
- 孩子 1 (评分 2) -> 2 颗 (比左边多)
- 孩子 2 (评分 3) -> 3 颗 (比左边多)
- 孩子 3 (评分 4) -> 4 颗 (比左边多)
- 孩子 4 (评分 5) -> 5 颗 (比左边多)
总和:1 + 2 + 3 + 4 + 5 = 15。
示例 2:评分相等的情况
> 输入:arr[] = [9, 9, 9, 9]
> 输出:4
分析:
这里没有哪个孩子的评分严格高于邻居。规则只要求“高则多”,没说“一样也要多”。因此,我们不需要额外增加糖果,每个人只需满足保底规则即可。
总和:1 + 1 + 1 + 1 = 4。
问题核心难点
你可能会觉得这很简单,只要做一次遍历不就行了吗?但问题在于“相邻”这个词具有双向性。
如果我们只处理左邻居,可能会漏看右邻居的规则。反之亦然。更棘手的是,局部最优解(例如给中间某个孩子很多糖)可能会导致全局分配不均。我们需要一种策略,能够同时兼顾左右两边的约束,同时保持总数最小。
解决方案:双向遍历的贪心策略
为了解决上述冲突,我们可以将问题分解。最优雅且高效的方法是使用贪心算法配合两次遍历。这种方法的时间复杂度是 O(n),空间复杂度也是 O(n)。
核心思想
我们可以认为每个孩子的最终糖果数,取决于他在两个局部方向上的最大需求:
- 相对于左边的需求:如果我比左边高,我的糖果数必须 = 左边的 + 1。
- 相对于右边的需求:如果我比右边高,我的糖果数必须 = 右边的 + 1。
为了同时满足这两个条件,一个孩子的最终糖果数应该是这两个方向计算结果的较大值(最大值)。
为什么取最大值?
这是最关键的一点。假设小明左边的同学评分较低,右边的同学评分也较低。
- 如果只看左边,小明可能只需要 2 颗糖。
- 如果只看右边,小明可能需要 5 颗糖(因为右边有一个很长的递减序列连着他)。
如果我们只给 2 颗,就违反了右边的规则;如果给 5 颗,虽然多于左边的需求,但并未违反左边规则(只是糖更多了而已)。为了保证两边都不违规,且总数量尽可能小,取两者的最大值是唯一正确的选择。
算法步骤分解
- 初始化:创建两个数组 INLINECODEf4d16585 和 INLINECODE35d50d9d,长度与孩子数量相同,并将所有元素初始化为 1(满足保底规则)。
- 左到右遍历:从索引 1 开始到 n-1。比较当前孩子和左边邻居。如果 INLINECODEc04546c1,则更新 INLINECODE759902bf。这确保了左规则被满足。
- 右到左遍历:从索引 n-2 开始到 0。比较当前孩子和右边邻居。如果 INLINECODE5944a7e2,则更新 INLINECODE397c8202。这确保了右规则被满足。
- 合并结果:遍历所有孩子,计算
sum += max(leftCandy[i], rightCandy[i])。
代码实现与深度解析
让我们来看看在不同编程语言中如何优雅地实现这一逻辑。我们将包含详细的注释,帮助你理解每一步的意图。
C++ 实现
C++ 是处理算法竞赛和高性能系统的首选,这里我们使用 vector 来动态管理数组。
#include
#include
#include // 用于 max 函数
using namespace std;
/*
* 计算最小糖果数的核心函数
* 参数:arr - 存储孩子评分的数组引用
* 返回值:满足条件的总糖果最小数
*/
int minCandy(vector& arr) {
int n = arr.size();
// 特殊情况处理:如果没有孩子,糖果数为0
if (n == 0) return 0;
// 步骤 1: 初始化两个辅助数组,每个人都先分 1 颗糖
vector leftCandy(n, 1);
vector rightCandy(n, 1);
// 步骤 2: 从左向右遍历
// 确保如果评分比左边高,糖果数一定比左边多
for (int i = 1; i arr[i - 1]) {
leftCandy[i] = leftCandy[i - 1] + 1;
}
// 如果评分小于或等于左边,保持初始值 1 即可
}
// 步骤 3: 从右向左遍历
// 确保如果评分比右边高,糖果数一定比右边多
for (int i = n - 2; i >= 0; i--) {
if (arr[i] > arr[i + 1]) {
rightCandy[i] = rightCandy[i + 1] + 1;
}
}
// 步骤 4: 计算最终结果
int totalCandies = 0;
for (int i = 0; i < n; i++) {
// 取两者较大值,确保同时满足左右两边的约束
totalCandies += max(leftCandy[i], rightCandy[i]);
}
return totalCandies;
}
int main() {
// 测试用例 1: 递增序列
vector ratings1 = {1, 2, 3, 4, 5};
cout << "Test 1 Output: " << minCandy(ratings1) << endl; // 期望输出: 15
// 测试用例 2: 相同评分
vector ratings2 = {9, 9, 9, 9};
cout << "Test 2 Output: " << minCandy(ratings2) << endl; // 期望输出: 4
return 0;
}
Java 实现
在 Java 中,我们利用 Arrays.fill 来快速初始化数组,使代码更加简洁。
import java.util.Arrays;
public class CandyDistribution {
public static int minCandy(int[] arr) {
int n = arr.length;
if (n == 0) return 0;
// 初始化左、右糖果计数数组,默认值为1
int[] leftCandy = new int[n];
int[] rightCandy = new int[n];
Arrays.fill(leftCandy, 1);
Arrays.fill(rightCandy, 1);
// 左向右扫描:处理评分升高的情况
for (int i = 1; i arr[i - 1]) {
leftCandy[i] = leftCandy[i - 1] + 1;
}
}
// 右向左扫描:处理评分升高(相对于右边邻居)的情况
for (int i = n - 2; i >= 0; i--) {
if (arr[i] > arr[i + 1]) {
rightCandy[i] = rightCandy[i + 1] + 1;
}
}
// 汇总结果
int ans = 0;
for (int i = 0; i < n; i++) {
ans += Math.max(leftCandy[i], rightCandy[i]);
}
return ans;
}
public static void main(String[] args) {
int[] arr = { 1, 2, 3, 4, 5 };
System.out.println("Total candies required: " + minCandy(arr));
}
}
Python 实现
Python 的语法糖让代码非常紧凑。我们使用 array 模块来模拟更低层的数组操作,或者直接使用 list 更加方便。为了符合性能优化的最佳实践,这里演示使用列表推导式的简洁写法。
def min_candy(arr):
n = len(arr)
if n == 0:
return 0
# 初始化两个数组,每个孩子默认 1 颗糖
left_candy = [1] * n
right_candy = [1] * n
# 从左向右遍历
for i in range(1, n):
if arr[i] > arr[i - 1]:
left_candy[i] = left_candy[i - 1] + 1
# 从右向左遍历
for i in range(n - 2, -1, -1):
if arr[i] > arr[i + 1]:
right_candy[i] = right_candy[i + 1] + 1
# 使用 zip 和 sum 函数计算最终总和
# 取对应位置的最大值并相加
total = sum(max(l, r) for l, r in zip(left_candy, right_candy))
return total
if __name__ == "__main__":
ratings = [1, 2, 3, 4, 5]
print(f"Minimum candies required: {min_candy(ratings)}")
深入剖析:为什么这种方法有效?
作为开发者,我们不仅要知其然,还要知其所以然。让我们从数学和逻辑的角度验证一下这种贪心策略的正确性。
无后效性
贪心算法的关键在于“无后效性”。当我们进行第一遍从左到右的扫描时,我们实际上是在说:“只要我比左边邻居高,我就必须至少比他多一个糖,至于右边邻居是谁,我暂时不管。” 这建立了一组基于左侧约束的基准解。
局部最优导向全局最优
第二遍扫描从右向左,此时我们面临一个选择:当前孩子 i 的糖果数应该是多少?
- 如果 INLINECODE9720d3a8,那么 INLINECODE53613d67 被设为
rightCandy[i+1] + 1。这个值是基于右边连续递减序列计算出来的绝对最小值。 - 如果此时
leftCandy[i](来自第一遍扫描)更大,说明左边有一个很长的上升坡度,要求孩子 i 必须拥有更多的糖果。
取 max(left, right) 实际上是在寻找一个能同时覆盖左右两边“坡度”要求的最小高度。这就像在山谷中修路,既要满足左边的坡度,又要满足右边的坡度,路面的高度必须至少是两者要求中的最大值,这样车才能开得过去(满足约束),且土方量最少(总数最小)。
复杂度分析与应用场景
复杂度
- 时间复杂度:O(N)。我们遍历了数组三次(一次左到右,一次右到左,一次求和),都是线性操作。
- 空间复杂度:O(N)。我们需要两个额外的数组来存储中间结果。虽然我们可以尝试优化空间(只用一个数组 + 一个变量),但代码的可读性和维护成本会增加,而在大多数工程场景下,O(N) 的空间开销是完全可接受的。
实际应用场景
除了分糖果,这种算法逻辑还适用于很多资源分配场景:
- 绩效薪资计算:员工按工位排列,绩效高的员工工资必须比相邻的绩效低的员工高,如何计算总薪资包的预算?
- 服务器资源调度:在环形或线性拓扑的服务器集群中,根据负载动态调整优先级或资源配额。
- 地形渲染:在图形学中,根据高度图的限制生成最小化的网格数据。
常见错误与陷阱
在实现这个算法时,新手容易犯以下错误:
- 混淆逻辑判断:有人在单向遍历时试图同时处理左右两边,导致逻辑极其复杂且容易出错。记住:分而治之是解决复杂问题的金钥匙。
- 忽略评分相等的情况:题目条件是 INLINECODEf42ee08e (严格大于),而不是 INLINECODE0060d1df。如果评分相等,不需要多给糖。如果写成了
>=,会导致结果偏大,不符合“最小数量”的要求。 - 整数溢出:虽然在这个问题中数据规模通常不会导致溢出,但在处理类似的加法问题时,始终要注意数据类型的范围(例如 C++ 中使用 INLINECODE409f5888 而不是 INLINECODE84ffbfab)。
- 初始化遗漏:忘记将数组初始化为 1,导致糖果数为 0,违反了第一条规则。
性能优化建议
如果你在面试中被问到“能否降低空间复杂度?”,你可以这样回答:
我们可以只用一个数组 INLINECODE2cd2a577 和一个变量 INLINECODE294de75e。
- 先进行左到右扫描,更新
candies[]。 - 然后进行右到左扫描。在扫描过程中,我们不存储整个 INLINECODE9b7667c2 数组,而是用一个临时变量记录当前的右侧需求,并直接更新 INLINECODE05164cbc。
这样可以将空间复杂度降低到 O(1)(不计入输出数组),但在实际工程中,为了代码清晰度,双向数组法依然是首选。
总结
通过这篇文章,我们不仅解决了一个有趣的算法问题,更重要的是学习了如何将复杂的约束拆解为简单的单向规则。这种“双向扫描 + 取最大值”的模式是处理数组相邻约束问题的通用模板。
下次当你遇到类似的“既要顾左,又要顾右”的问题时,不妨试试这种分而治之的策略。希望这篇文章能帮助你在面试或工作中更加游刃有余!
如果你喜欢这种深度的技术解析,欢迎继续关注更多算法与工程实践的分享。