1. 项目概述:从“零食奖励”看华为OD机试的算法思维

最近在帮几个准备冲刺华为OD机试的朋友做模拟训练,发现一个挺有意思的现象:很多同学一看到“动态规划”、“图论”这些词就头疼,觉得是“硬骨头”,反而对一些看似生活化的题目掉以轻心。比如这道“零食奖励”,标题听起来人畜无害,好像是个简单的分配问题,但真上手一写,各种边界条件和状态转移的逻辑陷阱就都冒出来了。这道题是2025C卷的新题,它考察的核心,远不止是写对一个循环或者判断,而是对 贪心策略的深刻理解 对问题本质的抽象能力 。今天,我就结合这道题,把C++、Java、Python、C语言和JS五种语言的实现思路和代码细节都掰开揉碎了讲一遍,更重要的是,分享一套面对这类“场景应用题”的通用拆解方法。无论你是正在备战OD,还是想巩固算法基础,相信这篇从实战出发的深度解析都能给你带来不一样的启发。

2. 核心需求与问题抽象

2.1 题目场景还原与核心诉求

我们先抛开代码,把题目用大白话翻译一下。想象一个班主任要奖励班上一排坐着的小朋友零食。每个小朋友有一个“表现分”。奖励规则是:

  1. 公平原则 :每个小朋友至少得到1份零食。
  2. 激励原则 :如果某个小朋友的表现分比相邻的小朋友高,那么他得到的零食必须比那个相邻的小朋友多。

我们的目标是,在满足以上两条规则的前提下,计算出 需要准备的零食总数最少是多少

这本质上是一个 约束优化问题 。约束是规则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) 最小。

看到“相邻比较”和“最小总数”,有经验的同学应该能立刻联想到经典的 “分发糖果” 问题。没错,“零食奖励”就是这道经典题目的一个变体或直接应用。其标准解法是 两次遍历的贪心算法

注意 :这里最容易犯的第一个错误是试图“一遍遍历搞定”。因为一个小朋友的零食数同时受左右两边邻居的影响,只从左到右扫一遍,无法处理“右边比左边高”但左边值还未确定的情况。必须把左右影响拆开处理。

算法核心思想 :把“比邻居高则零食多”这个约束,拆解成两个独立的子问题:

  1. 左规则 :当 scores[i] > scores[i-1] 时,保证 rewards[i] > rewards[i-1]
  2. 右规则 :当 scores[i] > scores[i+1] 时,保证 rewards[i] > rewards[i+1]

为了让总数最小,我们在满足每个规则时,都采取“最小幅度增加”的策略,即只比邻居多1个。最后,对于每个位置 i ,为了同时满足左规则和右规则,他最终得到的零食数 rewards[i] 应该是分别满足左规则和右规则时计算出的两个值的 最大值 。这个“取最大值”的操作,是保证两个约束同时被满足的关键。

3. 算法思路详解与多语言实现框架

3.1 标准两次遍历贪心算法拆解

理解了核心思想,我们来看具体的步骤,这是写出健壮代码的蓝图:

  1. 初始化 :创建一个长度与 scores 相同的数组 rewards ,将所有元素初始化为1。这满足了“每人至少1个”的基础约束。
  2. 第一次遍历:从左到右,处理“左规则”
    • 从下标 i = 1 开始,遍历到数组末尾。
    • 如果 scores[i] > scores[i-1] ,那么为了满足左规则, rewards[i] 至少要比 rewards[i-1] 多1。我们采取最小化策略,令 rewards[i] = rewards[i-1] + 1
    • 经过这次遍历,我们确保了对于任何位置,只要他比左边的邻居表现好,他的零食数就一定比左边多。但此时还没有考虑右边邻居的影响。
  3. 第二次遍历:从右到左,处理“右规则”
    • 从下标 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 。我们取两者中的较大者,才能同时满足左规则(如果存在)和当前的右规则。
  4. 计算结果 :遍历 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++实操要点

  1. 容器选择 :使用 vector<int> 是最通用和高效的选择。避免使用原生数组,除非有极致的性能要求且长度固定。
  2. 求和操作 :使用 <numeric> 中的 std::accumulate 是标准且安全的求和方式,比手写循环更清晰。第三个参数 0 是求和的初始值,也决定了返回类型(int)。
  3. 循环变量 :使用前缀 ++i 而非后缀 i++ ,对于内置类型虽然性能差异可忽略,但这是一个良好的习惯,尤其在涉及迭代器的泛型代码中。
  4. 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实操要点

  1. 空值判断 :Java中方法参数可能是 null ,所以优先进行 if (scores == null) 判断,这是健壮性编程的基本要求。
  2. 数组初始化 int[] rewards = new int[n]; 并不会自动初始化为1,必须显式循环赋值。也可以使用 Arrays.fill(rewards, 1) ,但手写循环更直观,且性能无差异。
  3. 求和 :Java中没有内置的数组求和工具方法(除非用Stream,但小题大做),手写循环求和是最直接的方式。
  4. 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. 列表初始化 [1] * n 是创建包含n个1的列表的最高效方式,清晰且快速。
  2. 反向遍历 range(n-2, -1, -1) 是经典的反向循环写法。起点是 n-2 (倒数第二个索引),终点是 -1 (意味着要包含0),步长为 -1 。也可以使用 reversed(range(n-1)) ,但不如直接指定 range 直观。
  3. 求和 sum(rewards) 是Python的内置函数,直接对可迭代对象求和,代码简洁高效。
  4. 条件判断 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语言实操要点

  1. 内存管理 :示例中使用了 malloc 动态分配 rewards 数组, 务必记得在函数返回前 free ,否则会造成内存泄漏。这是C语言编程的核心纪律之一。
  2. 数组长度传递 :C语言中数组作为参数会退化为指针,必须同时传递数组长度 scoresSize
  3. max 函数 :C标准库没有内置的 max 函数用于整型,通常需要自己定义宏或者像示例中一样使用三目运算符 (a > b) ? a : b
  4. 错误处理 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实操要点

  1. 数组初始化 new Array(n).fill(1) 是ES6中初始化固定值数组的推荐方式,简洁高效。
  2. 变量声明 :使用 const 声明不会改变的变量(如 n , rewards ),使用 let 声明循环变量 i ,符合现代JS规范。
  3. 求和 :使用数组的 reduce 方法求和是非常地道的函数式编程写法, (sum, current) => sum + current, 0 这个回调函数累加每个元素, 0 是初始值。
  4. 严格比较 :算法中比较分数用的是 > ,这没问题。在JS中,如果比较可能涉及不同类型,要特别注意 == === 的区别,但本题中都是数字,用 > 即可。

5. 算法扩展思考与优化空间

“零食奖励”的标准解法是两次遍历,时间复杂度O(n),空间复杂度O(n)。这已经是这个问题的最优解法之一。但在面试或深入学习时,我们还可以思考以下几个方向:

  1. 空间复杂度优化到O(1)? 理论上,如果只求总数而不需要知道每个孩子的具体奖励数,可能存在常数空间复杂度的解法。一种思路是寻找序列中的“波谷”(分数比两边都低的孩子),从波谷开始向两边爬坡分配。但实现起来比两次遍历复杂,且容易出错。在机试或面试中, 清晰正确的O(n)空间解法远优于复杂且易错的O(1)空间尝试
  2. 如果规则变化怎么办? 比如,规则变成“分数不低于邻居,则零食数不低于邻居”(即包含等于的情况)。我们只需要把代码中的判断条件 > 改为 >= 即可。但要注意,这样可能会导致零食总数增加。这是一个很好的 follow-up 问题,考察你是否真正理解算法逻辑。
  3. 环形排列怎么办? 如果小朋友是围成一个圈坐的(首尾相邻),问题就变成了“环形分发糖果”。解决思路是:将环形拆解,在两次线性遍历后,还需要处理首尾相接处的约束。这可以作为一道更难的进阶题目。

6. 机试实战技巧与避坑指南

结合这道“零食奖励”,我总结了几点华为OD机试(乃至大多数算法机试)的通用技巧:

  1. 先理清思路,再动手编码 :花5-10分钟在草稿纸上画一画,走一遍样例。比如画出一排小朋友的分数,手动模拟分配过程,验证你的算法思路。 切忌看到题目就立刻开始敲代码
  2. 重视边界条件 :空输入、单元素、全相等、严格递增、严格递减、V型序列……在脑子里过一遍这些特殊情况,你的代码鲁棒性会大大提升。机试的测试用例一定会包含这些边界情况。
  3. 选择熟悉的语言 :华为OD通常支持多种语言。选择你最熟悉、编码速度最快的那一门。不要为了“炫技”而使用生疏的语言。本题中,Python在编码效率上优势明显。
  4. 注意输入输出格式 :机试平台通常是标准输入输出。练习时就要习惯从 sys.stdin (Python)、 Scanner (Java)、 cin (C++)读取数据,并按格式输出。 务必先本地测试通过样例再提交
  5. 时间复杂度与空间复杂度 :即使题目不明确要求,自己也应该清楚算法的时间复杂度。对于本题,O(n)的时间复杂度和O(n)的空间复杂度是完全可接受的。如果遇到数据规模 n 达到10^5甚至更大,O(n^2)的暴力解法一定会超时。
  6. 调试与验证 :写完代码后,用题目给的样例、自己设计的边界样例(如空数组、 [1] [3,2,1] [1,2,2,1] )都跑一遍。在本地IDE里设置断点或者打印中间变量(如每次遍历后的 rewards 数组),是快速定位逻辑错误的好方法。

回到“零食奖励”这道题,它就像一面镜子,照出的不仅是你的编码能力,更是将模糊的业务需求转化为清晰、可执行逻辑的抽象能力。这种能力,无论是在OD机试中,还是在真实的软件开发中,都至关重要。希望这篇详细的拆解,能帮你吃透这一类问题。下次再遇到“发糖果”、“评奖金”、“接雨水”这类有着相似内核的题目时,你就能一眼看穿本质,快速找到解题的钥匙。

Logo

码道开发者社区,聚焦华为云码道 CodeArts 代码智能体,沉淀 Agent、Skill、鸿蒙开发实战内容,供开发者查阅资料、交流技术、分享工程实践

更多推荐