贪心算法实战:从“分发糖果”到华为OD“零食奖励”的多语言详解
1. 项目概述:从“零食奖励”看华为OD机试的算法思维
最近在帮几个准备冲刺华为OD机试的朋友做模拟训练,发现一个挺有意思的现象:很多同学一看到“动态规划”、“图论”这些词就头疼,觉得是“硬骨头”,反而对一些看似生活化的题目掉以轻心。比如这道“零食奖励”,标题听起来人畜无害,好像是个简单的分配问题,但真上手一写,各种边界条件和状态转移的逻辑陷阱就都冒出来了。这道题是2025C卷的新题,它考察的核心,远不止是写对一个循环或者判断,而是对 贪心策略的深刻理解 和 对问题本质的抽象能力 。今天,我就结合这道题,把C++、Java、Python、C语言和JS五种语言的实现思路和代码细节都掰开揉碎了讲一遍,更重要的是,分享一套面对这类“场景应用题”的通用拆解方法。无论你是正在备战OD,还是想巩固算法基础,相信这篇从实战出发的深度解析都能给你带来不一样的启发。
2. 核心需求与问题抽象
2.1 题目场景还原与核心诉求
我们先抛开代码,把题目用大白话翻译一下。想象一个班主任要奖励班上一排坐着的小朋友零食。每个小朋友有一个“表现分”。奖励规则是:
- 公平原则 :每个小朋友至少得到1份零食。
- 激励原则 :如果某个小朋友的表现分比相邻的小朋友高,那么他得到的零食必须比那个相邻的小朋友多。
我们的目标是,在满足以上两条规则的前提下,计算出 需要准备的零食总数最少是多少 。
这本质上是一个 约束优化问题 。约束是规则1和2,优化目标是“总数最小”。规则1给了我们一个基础值(每人至少1个),规则2则引入了变量,使得我们不能再简单地给每人发1个了事。
2.2 问题抽象与算法定位
为什么说这道题“坑”多?因为它完美地模拟了实际开发中,产品经理给的一个看似简单的业务需求,背后却需要精巧的设计。
首先,我们需要把“一排小朋友”抽象成一个整数数组 scores ,数组的每个元素就是对应位置小朋友的表现分。我们的输出是另一个等长的数组 rewards ,表示每个小朋友分到的零食数,并且要满足:
rewards[i] >= 1(公平原则)- 如果
scores[i] > scores[i-1],那么rewards[i] > rewards[i-1](左邻激励) - 如果
scores[i] > scores[i+1],那么rewards[i] > rewards[i+1](右邻激励) - 在满足以上所有条件的前提下,
sum(rewards)最小。
看到“相邻比较”和“最小总数”,有经验的同学应该能立刻联想到经典的 “分发糖果” 问题。没错,“零食奖励”就是这道经典题目的一个变体或直接应用。其标准解法是 两次遍历的贪心算法 。
注意 :这里最容易犯的第一个错误是试图“一遍遍历搞定”。因为一个小朋友的零食数同时受左右两边邻居的影响,只从左到右扫一遍,无法处理“右边比左边高”但左边值还未确定的情况。必须把左右影响拆开处理。
算法核心思想 :把“比邻居高则零食多”这个约束,拆解成两个独立的子问题:
- 左规则 :当
scores[i] > scores[i-1]时,保证rewards[i] > rewards[i-1]。 - 右规则 :当
scores[i] > scores[i+1]时,保证rewards[i] > rewards[i+1]。
为了让总数最小,我们在满足每个规则时,都采取“最小幅度增加”的策略,即只比邻居多1个。最后,对于每个位置 i ,为了同时满足左规则和右规则,他最终得到的零食数 rewards[i] 应该是分别满足左规则和右规则时计算出的两个值的 最大值 。这个“取最大值”的操作,是保证两个约束同时被满足的关键。
3. 算法思路详解与多语言实现框架
3.1 标准两次遍历贪心算法拆解
理解了核心思想,我们来看具体的步骤,这是写出健壮代码的蓝图:
- 初始化 :创建一个长度与
scores相同的数组rewards,将所有元素初始化为1。这满足了“每人至少1个”的基础约束。 - 第一次遍历:从左到右,处理“左规则” 。
- 从下标
i = 1开始,遍历到数组末尾。 - 如果
scores[i] > scores[i-1],那么为了满足左规则,rewards[i]至少要比rewards[i-1]多1。我们采取最小化策略,令rewards[i] = rewards[i-1] + 1。 - 经过这次遍历,我们确保了对于任何位置,只要他比左边的邻居表现好,他的零食数就一定比左边多。但此时还没有考虑右边邻居的影响。
- 从下标
- 第二次遍历:从右到左,处理“右规则” 。
- 从下标
i = n-2开始(倒数第二个),遍历到数组开头i = 0。 - 如果
scores[i] > scores[i+1],那么为了满足右规则,rewards[i]至少要比rewards[i+1]多1。同样,我们取最小增量:rewards[i] = max(rewards[i], rewards[i+1] + 1)。这里为什么要用max?因为rewards[i]在第一次遍历后可能已经有了一个值(比如它比左边高),这个值可能已经大于rewards[i+1] + 1。我们取两者中的较大者,才能同时满足左规则(如果存在)和当前的右规则。
- 从下标
- 计算结果 :遍历
rewards数组,求和,即为所需的最少零食总数。
这个算法的时间复杂度是 O(n),空间复杂度是 O(n)(用于存储 rewards 数组)。
3.2 关键边界与陷阱分析
在动手写代码前,必须想清楚以下几个边界情况,它们往往是测试用例的重点,也是面试时考察你思维严密性的地方:
- 空数组或单元素数组 :如果小朋友人数为0,总数应该是0。如果只有1个小朋友,根据规则,他至少得1个,总数就是1。代码中需要对输入长度进行判断。
- 分数相等的情况 :规则只说了“高于”才需要更多零食。如果分数相等,是没有强制要求的。因此,在两次遍历的判断条件中, 必须是严格大于
>,而不是大于等于>=。这是很多同学容易忽略的细节。 - 非严格递增/递减序列 :比如分数是
[1, 2, 2, 3]。对于第一个2,它比左边的1大,所以rewards[1]变成2。第二个2,它不比左边的2大,所以rewards[2]保持为1。在从右向左遍历时,第二个2比右边的3小,所以规则不触发。最终第二个2的小朋友只得到1个零食,而第一个2得到2个,这是符合规则的(分数相同,零食数可以不同)。这个例子很好地体现了算法的正确性。 - V型序列 :比如
[1, 3, 2]。从左到右后,rewards为[1, 2, 1]。从右到左时,发现scores[1] (3) > scores[2] (2),所以rewards[1]需要至少是rewards[2]+1=2,当前值已经是2,满足。最终rewards[1]为2,正确。
4. 多语言代码实现与细节剖析
接下来,我们分别用五种语言实现上述算法。我会重点指出每种语言在实现时需要注意的语法特性和性能细节。
4.1 C++ 实现
C++的实现需要关注容器选择、循环边界和标准库函数的使用。
#include <iostream>
#include <vector>
#include <algorithm> // 用于max函数
#include <numeric> // 用于accumulate求和
using namespace std;
int minRewards(vector<int>& scores) {
int n = scores.size();
if (n == 0) return 0;
if (n == 1) return 1;
vector<int> rewards(n, 1); // 初始化,每人至少1个
// 从左到右遍历,满足左规则
for (int i = 1; i < n; ++i) {
if (scores[i] > scores[i - 1]) {
rewards[i] = rewards[i - 1] + 1;
}
}
// 从右到左遍历,满足右规则,并取最大值
for (int i = n - 2; i >= 0; --i) {
if (scores[i] > scores[i + 1]) {
// 关键:取当前值和满足右规则所需值的较大者
rewards[i] = max(rewards[i], rewards[i + 1] + 1);
}
}
// 计算总和
int total = accumulate(rewards.begin(), rewards.end(), 0);
return total;
}
int main() {
// 示例测试
vector<int> scores1 = {1, 3, 2, 1, 4, 2}; // 可以自己计算验证
cout << "最少零食数: " << minRewards(scores1) << endl;
vector<int> scores2 = {1, 2, 2}; // 相等分数测试
cout << "最少零食数: " << minRewards(scores2) << endl;
return 0;
}
C++实操要点 :
- 容器选择 :使用
vector<int>是最通用和高效的选择。避免使用原生数组,除非有极致的性能要求且长度固定。 - 求和操作 :使用
<numeric>中的std::accumulate是标准且安全的求和方式,比手写循环更清晰。第三个参数0是求和的初始值,也决定了返回类型(int)。 - 循环变量 :使用前缀
++i而非后缀i++,对于内置类型虽然性能差异可忽略,但这是一个良好的习惯,尤其在涉及迭代器的泛型代码中。 -
max函数 :直接使用std::max,清晰明了。
4.2 Java 实现
Java的实现要注意数组操作、循环以及使用 Math.max 。
public class SnackRewards {
public static int minRewards(int[] scores) {
if (scores == null || scores.length == 0) {
return 0;
}
int n = scores.length;
if (n == 1) {
return 1;
}
int[] rewards = new int[n];
// 初始化
for (int i = 0; i < n; i++) {
rewards[i] = 1;
}
// 从左到右
for (int i = 1; i < n; i++) {
if (scores[i] > scores[i - 1]) {
rewards[i] = rewards[i - 1] + 1;
}
}
// 从右到左
for (int i = n - 2; i >= 0; i--) {
if (scores[i] > scores[i + 1]) {
rewards[i] = Math.max(rewards[i], rewards[i + 1] + 1);
}
}
// 求和
int total = 0;
for (int reward : rewards) {
total += reward;
}
return total;
}
public static void main(String[] args) {
int[] scores1 = {1, 3, 2, 1, 4, 2};
System.out.println("最少零食数: " + minRewards(scores1)); // 输出应为 10
int[] scores2 = {1, 2, 2};
System.out.println("最少零食数: " + minRewards(scores2)); // 输出应为 4
}
}
Java实操要点 :
- 空值判断 :Java中方法参数可能是
null,所以优先进行if (scores == null)判断,这是健壮性编程的基本要求。 - 数组初始化 :
int[] rewards = new int[n];并不会自动初始化为1,必须显式循环赋值。也可以使用Arrays.fill(rewards, 1),但手写循环更直观,且性能无差异。 - 求和 :Java中没有内置的数组求和工具方法(除非用Stream,但小题大做),手写循环求和是最直接的方式。
-
Math.max:使用Math.max进行两者取大。
4.3 Python 实现
Python的代码非常简洁,利用列表推导式和内置函数可以写得非常优雅。
def min_rewards(scores):
if not scores:
return 0
n = len(scores)
if n == 1:
return 1
rewards = [1] * n # 优雅的初始化
# 从左到右遍历
for i in range(1, n):
if scores[i] > scores[i - 1]:
rewards[i] = rewards[i - 1] + 1
# 从右到左遍历
for i in range(n - 2, -1, -1): # 注意range的用法:起点,终点(不含),步长
if scores[i] > scores[i + 1]:
# 使用max函数取较大值
rewards[i] = max(rewards[i], rewards[i + 1] + 1)
return sum(rewards) # 内置sum函数,非常方便
# 测试
if __name__ == "__main__":
scores1 = [1, 3, 2, 1, 4, 2]
print(f"最少零食数: {min_rewards(scores1)}") # 输出 10
scores2 = [1, 2, 2]
print(f"最少零食数: {min_rewards(scores2)}") # 输出 4
Python实操要点 :
- 列表初始化 :
[1] * n是创建包含n个1的列表的最高效方式,清晰且快速。 - 反向遍历 :
range(n-2, -1, -1)是经典的反向循环写法。起点是n-2(倒数第二个索引),终点是-1(意味着要包含0),步长为-1。也可以使用reversed(range(n-1)),但不如直接指定range直观。 - 求和 :
sum(rewards)是Python的内置函数,直接对可迭代对象求和,代码简洁高效。 - 条件判断 :
if not scores:可以同时判断None和空列表,是Pythonic的写法。
4.4 C语言实现
C语言的实现需要手动管理内存(本例中不需要动态分配)和循环,更接近底层。
#include <stdio.h>
#include <stdlib.h> // 为了使用动态内存分配(如果输入大小不定)
// 假设scores数组和长度n已知,rewards数组已分配好空间
int minRewards(int* scores, int scoresSize) {
if (scoresSize == 0) return 0;
if (scoresSize == 1) return 1;
// 在栈上分配奖励数组,如果数据量大可考虑动态分配
int* rewards = (int*)malloc(scoresSize * sizeof(int));
if (rewards == NULL) {
return -1; // 分配失败,返回错误码
}
// 初始化
for (int i = 0; i < scoresSize; i++) {
rewards[i] = 1;
}
// 从左到右
for (int i = 1; i < scoresSize; i++) {
if (scores[i] > scores[i - 1]) {
rewards[i] = rewards[i - 1] + 1;
}
}
// 从右到左
for (int i = scoresSize - 2; i >= 0; i--) {
if (scores[i] > scores[i + 1]) {
int newReward = rewards[i + 1] + 1;
rewards[i] = (rewards[i] > newReward) ? rewards[i] : newReward; // 三目运算符实现max
}
}
// 求和
int total = 0;
for (int i = 0; i < scoresSize; i++) {
total += rewards[i];
}
free(rewards); // 释放动态分配的内存
return total;
}
int main() {
int scores1[] = {1, 3, 2, 1, 4, 2};
int size1 = sizeof(scores1) / sizeof(scores1[0]);
printf("最少零食数: %d\n", minRewards(scores1, size1));
int scores2[] = {1, 2, 2};
int size2 = sizeof(scores2) / sizeof(scores2[0]);
printf("最少零食数: %d\n", minRewards(scores2, size2));
return 0;
}
C语言实操要点 :
- 内存管理 :示例中使用了
malloc动态分配rewards数组, 务必记得在函数返回前free,否则会造成内存泄漏。这是C语言编程的核心纪律之一。 - 数组长度传递 :C语言中数组作为参数会退化为指针,必须同时传递数组长度
scoresSize。 -
max函数 :C标准库没有内置的max函数用于整型,通常需要自己定义宏或者像示例中一样使用三目运算符(a > b) ? a : b。 - 错误处理 :
malloc可能失败,返回NULL。生产代码中必须检查并处理这种错误,示例中简单返回了-1。
4.5 JavaScript (Node.js) 实现
JavaScript在算法题中通常指Node.js环境,使用ES6+语法可以让代码更现代。
function minRewards(scores) {
if (!scores || scores.length === 0) {
return 0;
}
const n = scores.length;
if (n === 1) {
return 1;
}
const rewards = new Array(n).fill(1); // 使用fill方法初始化
// 从左到右
for (let i = 1; i < n; i++) {
if (scores[i] > scores[i - 1]) {
rewards[i] = rewards[i - 1] + 1;
}
}
// 从右到左
for (let i = n - 2; i >= 0; i--) {
if (scores[i] > scores[i + 1]) {
// 注意:这里需要更新rewards[i],可能覆盖从左到右的结果
rewards[i] = Math.max(rewards[i], rewards[i + 1] + 1);
}
}
// 使用reduce求和,函数式编程风格
const total = rewards.reduce((sum, current) => sum + current, 0);
return total;
}
// 测试
const scores1 = [1, 3, 2, 1, 4, 2];
console.log(`最少零食数: ${minRewards(scores1)}`); // 输出 10
const scores2 = [1, 2, 2];
console.log(`最少零食数: ${minRewards(scores2)}`); // 输出 4
JavaScript实操要点 :
- 数组初始化 :
new Array(n).fill(1)是ES6中初始化固定值数组的推荐方式,简洁高效。 - 变量声明 :使用
const声明不会改变的变量(如n,rewards),使用let声明循环变量i,符合现代JS规范。 - 求和 :使用数组的
reduce方法求和是非常地道的函数式编程写法,(sum, current) => sum + current, 0这个回调函数累加每个元素,0是初始值。 - 严格比较 :算法中比较分数用的是
>,这没问题。在JS中,如果比较可能涉及不同类型,要特别注意==和===的区别,但本题中都是数字,用>即可。
5. 算法扩展思考与优化空间
“零食奖励”的标准解法是两次遍历,时间复杂度O(n),空间复杂度O(n)。这已经是这个问题的最优解法之一。但在面试或深入学习时,我们还可以思考以下几个方向:
- 空间复杂度优化到O(1)? 理论上,如果只求总数而不需要知道每个孩子的具体奖励数,可能存在常数空间复杂度的解法。一种思路是寻找序列中的“波谷”(分数比两边都低的孩子),从波谷开始向两边爬坡分配。但实现起来比两次遍历复杂,且容易出错。在机试或面试中, 清晰正确的O(n)空间解法远优于复杂且易错的O(1)空间尝试 。
- 如果规则变化怎么办? 比如,规则变成“分数不低于邻居,则零食数不低于邻居”(即包含等于的情况)。我们只需要把代码中的判断条件
>改为>=即可。但要注意,这样可能会导致零食总数增加。这是一个很好的 follow-up 问题,考察你是否真正理解算法逻辑。 - 环形排列怎么办? 如果小朋友是围成一个圈坐的(首尾相邻),问题就变成了“环形分发糖果”。解决思路是:将环形拆解,在两次线性遍历后,还需要处理首尾相接处的约束。这可以作为一道更难的进阶题目。
6. 机试实战技巧与避坑指南
结合这道“零食奖励”,我总结了几点华为OD机试(乃至大多数算法机试)的通用技巧:
- 先理清思路,再动手编码 :花5-10分钟在草稿纸上画一画,走一遍样例。比如画出一排小朋友的分数,手动模拟分配过程,验证你的算法思路。 切忌看到题目就立刻开始敲代码 。
- 重视边界条件 :空输入、单元素、全相等、严格递增、严格递减、V型序列……在脑子里过一遍这些特殊情况,你的代码鲁棒性会大大提升。机试的测试用例一定会包含这些边界情况。
- 选择熟悉的语言 :华为OD通常支持多种语言。选择你最熟悉、编码速度最快的那一门。不要为了“炫技”而使用生疏的语言。本题中,Python在编码效率上优势明显。
- 注意输入输出格式 :机试平台通常是标准输入输出。练习时就要习惯从
sys.stdin(Python)、Scanner(Java)、cin(C++)读取数据,并按格式输出。 务必先本地测试通过样例再提交 。 - 时间复杂度与空间复杂度 :即使题目不明确要求,自己也应该清楚算法的时间复杂度。对于本题,O(n)的时间复杂度和O(n)的空间复杂度是完全可接受的。如果遇到数据规模
n达到10^5甚至更大,O(n^2)的暴力解法一定会超时。 - 调试与验证 :写完代码后,用题目给的样例、自己设计的边界样例(如空数组、
[1]、[3,2,1]、[1,2,2,1])都跑一遍。在本地IDE里设置断点或者打印中间变量(如每次遍历后的rewards数组),是快速定位逻辑错误的好方法。
回到“零食奖励”这道题,它就像一面镜子,照出的不仅是你的编码能力,更是将模糊的业务需求转化为清晰、可执行逻辑的抽象能力。这种能力,无论是在OD机试中,还是在真实的软件开发中,都至关重要。希望这篇详细的拆解,能帮你吃透这一类问题。下次再遇到“发糖果”、“评奖金”、“接雨水”这类有着相似内核的题目时,你就能一眼看穿本质,快速找到解题的钥匙。
更多推荐


所有评论(0)