1. 项目概述:一道经典的贪心算法面试题

最近在准备华为OD机试或者类似算法面试的朋友,应该对“导师请吃火锅”这道题不陌生。这道题编号525,是华为OD机试E卷的真题,同时在C++、Java、Python等多个语言版本的题库中都有出现,属于高频考点。我第一次看到这个题目时,觉得它名字挺有意思,但仔细一读题,发现内核是一道非常典型的 贪心算法 应用题,考察的是在资源(时间、金钱)有限的情况下,如何做出最优选择,最大化满足感(或者说“吃到最多的菜”)。

简单来说,题目的场景是这样的:你和导师(或者朋友)一起去吃火锅,火锅店里有n种菜品,每种菜品都有一个“煮食时间”和一个“美味值”。你们有一个总的时间限制(比如火锅只能煮T分钟),目标是在这个时间限制内,选择一些菜品来煮,使得吃到的所有菜品的美味值总和最大。这听起来是不是很像经典的“0-1背包问题”?没错,它的本质就是背包问题的一个变种,但通常因为数据规模或特殊条件,会引导我们使用贪心或者动态规划来求解。在华为OD的机试环境中,这道题更倾向于考察对贪心策略“单位时间美味值优先”的理解和实现。

为什么这道题值得深究?因为它完美地将一个生活场景抽象成了一个算法模型。对于初学者,它是理解贪心算法“局部最优导致全局最优”思想的绝佳例题;对于准备面试的开发者,它综合考察了问题抽象、算法选择、边界条件处理以及代码实现能力。接下来,我将彻底拆解这道题,从问题分析、思路推导,到C++、Java、Python三种语言的代码实现与对比分析,最后分享一些机试中的实战技巧和避坑指南。

2. 核心需求与问题抽象

2.1 题目描述与输入输出格式

我们首先需要把题目描述具体化。根据常见的题库信息,“导师请吃火锅”题目的典型描述如下:

输入

  1. 第一行包含两个整数 n T ,分别表示菜品的数量,以及火锅总共可以煮的时间(单位:分钟)。
  2. 接下来 n 行,每行包含两个整数 time[i] taste[i] ,分别表示第 i 道菜需要煮的时间,以及其美味值。

输出 : 一个整数,表示在不超过总时间 T 的前提下,能够获得的最大美味值总和。

约束条件 (通常):

  • 1 <= n <= 10^3 或 10^4 (取决于具体卷次,但一般支持O(n*T)或O(n log n)的解法)
  • 1 <= T <= 10^3 或 10^4
  • 1 <= time[i] <= T
  • 1 <= taste[i] <= 10^4

示例 : 输入:

4 10
2 6
3 8
4 10
5 12

输出:

22

解释:选择第1道菜(时间2,美味6)和第4道菜(时间5,美味12),总时间7<=10,总美味值18。但更优解是选择第2道(时间3,美味8)和第4道(时间5,美味12),总时间8<=10,总美味值20。然而,是否存在更优解?我们稍后分析。

2.2 问题本质:0-1背包问题

拿到这个问题,有经验的开发者一眼就能看出,这是经典的 0-1背包问题 的一个现实变种。

  • 背包容量 -> 总煮食时间 T
  • 物品 -> 菜品
  • 物品重量 -> 菜品的煮食时间 time[i]
  • 物品价值 -> 菜品的美味值 taste[i]
  • 目标 -> 在不超过总重量(时间)的前提下,最大化总价值(美味值)。

在0-1背包问题中,每个物品(菜品)只能选择一次(要么煮,要么不煮),这完全符合吃火锅的场景。因此,最直接的思路就是使用 动态规划(DP) 来求解。定义 dp[j] 为在时间限制 j 内能获得的最大美味值。状态转移方程为: dp[j] = max(dp[j], dp[j - time[i]] + taste[i]) ,其中 i 遍历每个菜品, j T 逆向遍历到 time[i]

这是解决此问题的“万能钥匙”,时间复杂度为 O(n * T),空间复杂度为 O(T)。在华为OD机试中,如果 n T 的范围在几千以内,这个解法通常是完全可行的,并且是面试官期望看到的扎实解法。

2.3 贪心算法的可能性探讨

题目名字和场景可能会让人联想到“性价比”,即“单位时间的美味值”(taste[i] / time[i])。一个直觉的贪心策略是:优先煮“性价比”最高的菜。但这在0-1背包问题中 并不总是正确

考虑这个反例: 总时间 T = 5 菜品1: time=4, taste=8 (性价比2.0) 菜品2: time=3, taste=6 (性价比2.0) 菜品3: time=2, taste=3 (性价比1.5)

如果按性价比贪心,会先选菜品1(时间4,美味8),剩余时间1,无法再选任何菜,总美味值8。但最优解是选择菜品2和菜品3,总时间5,总美味值9。所以,简单的按性价比贪心是错的。

那么,什么时候贪心有效?当题目具备“分数背包”的特性,即菜品可以只煮一部分时(这显然不符合火锅吃菜的常识),贪心按性价比选择才是最优的。但原题明确是“选择菜品”,即0-1选择。因此, 标准的解法应该是动态规划

然而,为什么网上很多讨论提到“贪心”呢?我分析有两种可能:

  1. 记忆偏差或简化讲解 :有些文章为了方便理解,先引入贪心思想,再指出其缺陷,进而引出动态规划。
  2. 存在特殊条件 :也许在某些版本的题目描述中,有额外的限制(如每种菜品数量无限,即完全背包问题),或者数据规模极大,需要用贪心结合其他技巧(如按性价比排序后使用搜索或DP优化)。但根据主流题库信息,按0-1背包求解是标准答案。

注意 :在机试中,最稳妥的方法是先确认题目的完整描述和约束。如果没有特别说明,一律按照0-1背包问题处理,使用动态规划求解。这是不会出错的保底策略。

3. 动态规划思路详解与实现

既然确定了使用0-1背包的动态规划解法,我们来深入细节。这里会分别给出C++、Java和Python的实现,并分析各自语言的特性和注意事项。

3.1 算法思路与状态设计

我们定义一维DP数组 dp ,长度为 T+1 dp[j] 表示在 恰好使用 j 分钟 (或不超过 j 分钟,取决于初始化)时能获得的最大美味值。 为了处理“不超过”总时间的情况,我们通常将 dp 数组初始化为0,最终 dp[T] 或者 max(dp[0...T]) 就是答案(因为可能最优解用不满T时间)。更常用的方法是,让 dp[j] 直接表示容量为 j 的背包能装的最大价值,最终答案就是 dp[T]

状态转移方程 : 对于第 i 个菜品(时间 t , 美味值 v ),我们更新 dp 数组: dp[j] = max(dp[j], dp[j - t] + v) , 其中 j T 递减到 t 。 为什么要递减?这是0-1背包空间优化的关键,保证每个物品只被使用一次。如果 j 递增,同一个菜品可能会被重复计算,那就变成了“完全背包”问题。

算法步骤

  1. 初始化一个大小为 T+1 的数组 dp ,所有元素为0。
  2. 遍历每一个菜品 (t, v)
  3. 对于每个菜品,内层循环 j T 向下遍历到 t
  4. 更新 dp[j] = max(dp[j], dp[j - t] + v)
  5. 遍历结束后, dp[T] 即为所求最大美味值。

3.2 C++代码实现与分析

C++版本注重效率和底层控制。这里提供两种常见风格:经典数组风格和vector风格。

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

int main() {
    int n, T;
    cin >> n >> T;
    
    vector<int> time(n), taste(n);
    for (int i = 0; i < n; ++i) {
        cin >> time[i] >> taste[i];
    }
    
    // 一维DP数组,dp[j]表示在时间j内能获得的最大美味值
    vector<int> dp(T + 1, 0);
    
    // 0-1背包核心过程
    for (int i = 0; i < n; ++i) {
        int t = time[i], v = taste[i];
        // 必须反向遍历,确保每个菜品只被计算一次
        for (int j = T; j >= t; --j) {
            dp[j] = max(dp[j], dp[j - t] + v);
        }
    }
    
    cout << dp[T] << endl;
    return 0;
}

C++实现要点与避坑

  1. 输入输出效率 :在华为OD机试平台,使用 cin/cout 通常足够。如果担心数据量极大,可以加入 ios::sync_with_stdio(false); cin.tie(nullptr); 来关闭同步,提升速度。
  2. 容器选择 :使用 vector 比原生数组更安全方便。 dp 数组初始化为0很重要。
  3. 遍历顺序 :内层 j 反向遍历 是0-1背包优化的精髓,务必牢记。写成正向就成了完全背包。
  4. 空间复杂度 :O(T),非常高效。
  5. 边界检查 :题目通常保证 time[i] <= T ,但严谨的代码可以在内层循环判断 if (j >= t) ,不过由于循环条件已经是 j >= t ,所以不需要。

3.3 Java代码实现与分析

Java版本需要注意输入读取的效率和容器的使用。

import java.util.Scanner;

public class Main {
    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        int n = scanner.nextInt();
        int T = scanner.nextInt();
        
        int[] time = new int[n];
        int[] taste = new int[n];
        for (int i = 0; i < n; i++) {
            time[i] = scanner.nextInt();
            taste[i] = scanner.nextInt();
        }
        
        // dp数组,dp[j]表示在时间j内能获得的最大美味值
        int[] dp = new int[T + 1];
        
        // 0-1背包DP过程
        for (int i = 0; i < n; i++) {
            int t = time[i];
            int v = taste[i];
            // 反向遍历,防止重复选择
            for (int j = T; j >= t; j--) {
                dp[j] = Math.max(dp[j], dp[j - t] + v);
            }
        }
        
        System.out.println(dp[T]);
        scanner.close();
    }
}

Java实现要点与避坑

  1. Scanner的使用 Scanner 对于机试输入足够用,但比 BufferedReader 稍慢。如果遇到超时,可以换用 BufferedReader
  2. 数组初始化 int[] dp = new int[T+1]; 会自动初始化为0,无需额外操作。
  3. 函数调用 :使用 Math.max 进行最大值比较。
  4. 内存与性能 :算法本身和C++版本无异,性能主要取决于JVM和输入输出。在OJ上,Java有时需要更注意常数优化。
  5. 关闭Scanner :养成好习惯,用完 Scanner 后关闭它。

3.4 Python代码实现与分析

Python版本以其简洁著称,但在算法竞赛中需要注意性能。

def main():
    import sys
    data = sys.stdin.read().strip().split()
    if not data:
        return
    it = iter(data)
    n = int(next(it))
    T = int(next(it))
    
    dishes = []
    for _ in range(n):
        t = int(next(it))
        v = int(next(it))
        dishes.append((t, v))
    
    # 初始化DP数组
    dp = [0] * (T + 1)
    
    # 0-1背包DP
    for t, v in dishes:
        # 必须反向遍历
        for j in range(T, t - 1, -1):
            if dp[j - t] + v > dp[j]:
                dp[j] = dp[j - t] + v
    # 另一种写法:dp[j] = max(dp[j], dp[j - t] + v)
    
    print(dp[T])

if __name__ == "__main__":
    main()

Python实现要点与避坑

  1. 输入读取 :使用 sys.stdin.read() 一次性读取所有输入再分割,比循环调用 input() 快很多,这在处理大量数据时至关重要。
  2. 列表推导与迭代 :使用 iter next 遍历数据比用索引更Pythonic。
  3. DP数组更新 :在循环内直接使用 if 判断和赋值,有时比调用 max 函数稍快一丁点,但可读性稍差。 max 函数的写法更清晰。
  4. 遍历范围 range(T, t - 1, -1) 确保了 j 能取到 t
  5. 性能警告 :Python的循环比C++/Java慢得多。如果 n * T 达到10^7量级,Python很可能超时。这时就需要考虑是否存在更优的算法(如基于性价比的贪心+剪枝),或者使用PyPy解释器(很多OJ支持)来运行。

4. 算法扩展与变种思考

“导师请吃火锅”作为一个模型,可以衍生出许多变种问题,这些变种可能在未来的机试或面试中出现。

4.1 变种一:完全背包问题(菜品无限供应)

如果题目变成“每种菜品可以点无限份”,那么就变成了 完全背包问题 。解法只需要将内层循环的遍历方向改为 正向 即可。

# 完全背包解法(Python示例)
dp = [0] * (T + 1)
for t, v in dishes:
    for j in range(t, T + 1):  # 正向遍历
        dp[j] = max(dp[j], dp[j - t] + v)
print(dp[T])

变化核心 :正向遍历 j 意味着在考虑当前菜品时, dp[j - t] 可能已经包含了本菜品,从而实现了重复选择。

4.2 变种二:多维费用背包(考虑预算)

如果吃火锅不仅有时间限制,还有预算限制(每道菜有价格),那么就变成了 二维费用背包问题 。状态需要升维, dp[k][j] 表示使用 k 元钱和 j 分钟能获得的最大美味值。状态转移需要两层内循环。

// 二维费用背包C++示例(假设有预算M)
vector<vector<int>> dp(M + 1, vector<int>(T + 1, 0));
for (int i = 0; i < n; ++i) {
    int cost = price[i], t = time[i], v = taste[i];
    for (int k = M; k >= cost; --k) { // 金钱维度反向遍历
        for (int j = T; j >= t; --j) { // 时间维度反向遍历
            dp[k][j] = max(dp[k][j], dp[k - cost][j - t] + v);
        }
    }
}
cout << dp[M][T] << endl;

4.3 变种三:输出具体方案

如果题目要求输出选择了哪些菜品,而不仅仅是最大美味值,我们需要在DP过程中记录选择。通常使用一个额外的 path 数组或回溯法。

思路 :在更新 dp[j] 时,如果发现 dp[j - t] + v > dp[j] ,说明选择了第 i 个菜品达到了更优解。我们可以用一个二维布尔数组 choose[i][j] 来记录这个选择,或者更节省空间地,在更新 dp[j] 时,记录是由哪个状态转移过来的(即 j - t )。最后从 dp[T] 倒推回去,就能得到选择的菜品列表。

实操心得 :在机试中,除非题目明确要求,否则不要主动输出方案,以免画蛇添足导致输出格式错误。优先保证核心功能(计算最大价值)的正确性。

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

基于多次参与和辅导机试的经验,我总结了一些针对此类算法题的实战技巧。

5.1 输入处理与边界条件

这是机试中最容易失分的地方之一。

  1. 多组测试数据 :题目有时会说“输入包含多组测试数据”,直到文件结束(EOF)。你的代码需要能处理这种情况。C++/Java可以用 while(cin >> n >> T) ,Python可以用 try-except 或判断输入是否为空。
  2. 数据范围与溢出 :仔细看题目给的 n , T , taste[i] 的范围。如果 n*taste 可能超过 int 范围(约21亿),就需要使用 long long (C++) 或 long (Java/Python int自动支持大数)。在示例代码中,美味值总和可能很大,使用 int 可能溢出,用 long long 更安全。
    vector<long long> dp(T + 1, 0); // 使用long long防止溢出
    
  3. 时间或容量为0 :考虑 T=0 的情况,此时任何菜都不能煮,结果应为0。我们的DP数组初始化后, dp[0]=0 ,可以正确处理。

5.2 算法选择与复杂度估算

在动手前,快速估算复杂度,判断算法是否可行。

  • O(n * T) :如果 n * T <= 10^7 左右,在C++/Java中通常可以在1秒内通过。Python可能需要 n * T <= 10^6 才比较稳妥。
  • 如果 T 非常大(例如10^9) ,但 n 较小(例如100),O(n * T)的DP就不可行了。这时可能需要考虑:
    • Meet-in-the-Middle :将菜品分成两半,分别枚举所有子集的时间和美味值,然后双指针查找最优组合。复杂度降为 O(2^(n/2))。
    • 基于价值的DP :如果总美味值之和 V 不大,可以定义 dp[v] 为获得美味值 v 所需的最小时间,然后找满足 dp[v] <= T 的最大 v 。复杂度 O(n * V)。
    • 贪心+搜索 :先按性价比排序,然后使用深度优先搜索(DFS)加剪枝。

在华为OD机试中,“导师请吃火锅”这道题的约束通常会让 O(n * T) 的DP成为标准且正确的解法。 优先实现它。

5.3 调试与测试用例设计

自己设计几个测试用例来验证代码:

  1. 基础用例 :题目给的示例。
  2. 边界用例
    • n=1, T=0 ,菜品时间>0。结果应为0。
    • n=1, T=5 , 菜品时间=3,美味值=10。结果应为10。
    • n=2, T=3 , 菜品(2,5), (3,6)。只能选一个,应选价值大的6。
  3. 反贪婪用例 :如前文所述,用来验证你的DP算法比简单贪心更优。
  4. 大数值用例 :检查是否溢出。

5.4 代码风格与提交前检查

  1. 类名与主函数 :在华为OD平台,Java的类名必须是 Main 。C++的 main 函数返回 int 。Python的入口是 if __name__ == '__main__':
  2. 不要包含包/头文件之外的内容 :不要打印调试信息(如 cout << "debug" << endl; )。确保最终提交的代码只包含解决题目所必须的部分。
  3. 时间复杂度与注释 :虽然不要求,但在关键算法步骤旁写一句简洁的注释(如 // 0-1背包DP ),有助于阅卷人理解,也便于自己复查。
  4. 使用更快的I/O :在C++中,如果担心输入数据量大,可以加上 ios::sync_with_stdio(false); cin.tie(0); 。在Python中,使用 sys.stdin.read()

6. 不同语言实现的性能对比与选择建议

在华为OD机试中,你可以自选编程语言。了解各语言在算法题上的特点很重要。

特性 C++ Java Python
执行速度 最快 ,贴近硬件,循环效率极高。 较快,JIT编译优化良好,但通常比C++慢一些。 较慢 ,解释型语言,循环是性能瓶颈。
代码简洁度 中等,需要管理内存和细节。 中等,语法稍显冗长。 最简洁 ,语法清晰,表达力强。
开发调试速度 中等,编译需要时间。 中等,编译需要时间。 最快 ,写完后直接运行,无需编译。
内存控制 精细,可以手动优化。 自动垃圾回收,方便但有时不可预测。 自动管理,开发者无需关心。
适用场景 n*T 很大(>10^7),对性能要求极高。 大部分场景都适用,平衡性好。 n*T 较小(<10^6),或题目逻辑复杂,追求快速实现。
本题推荐度 ★★★★★ (性能绝对优势) ★★★★☆ (平衡可靠) ★★★☆☆ (需注意性能边界)

个人建议

  • 如果你是 C++高手 ,毫不犹豫选C++。它的性能优势在关键时刻能帮你避免超时。
  • 如果你主要使用 Java ,用Java也很好。它的生态和库丰富,写起来也顺手,性能对于OD机试完全足够。
  • 如果你对算法思路很清晰,但编码速度想更快,且题目数据范围明确不大,可以用 Python 。用Python可以让你把更多时间花在思考算法上,而不是纠结语法细节。 但要切记,如果看到 n T 可能达到10^4,Python的 O(n*T) 双重循环(10^8次迭代)就非常危险了,很可能超时。

7. 从解题到举一反三:背包问题的核心思维

“导师请吃火锅”这道题的价值远不止于通过一次考试。它为我们打开了一扇门,即“背包问题”的建模思维。很多看似不同的问题,都可以归结为背包问题。

识别背包问题的关键点

  1. 有限资源 :有一个或多个限制条件(如时间T、预算M)。
  2. 一系列选择 :有一组物品(如菜品),每个物品消耗一定资源,并带来一定收益。
  3. 最大化/最小化目标 :在资源限制下,最大化总收益或最小化总成本。
  4. 选择规则 :物品通常是“选”或“不选”(0-1背包),或者可以选多个(完全背包)。

类似的问题

  • 分割等和子集 :给定一个数组,能否分成两个和相等的子集?可以转化为背包容量为总和一半的0-1背包问题,看能否恰好装满。
  • 零钱兑换 :用最少的硬币凑成总金额。是完全背包问题(求最小物品数)。
  • 目标和 :给数组中的数添加正负号,使得和为target。可以转化为背包问题。
  • 工作调度 :在截止时间前完成工作以获得最大报酬,是带权重的区间调度问题,有时也可用DP思想解决。

掌握0-1背包的一维DP模板,就掌握了一大类动态规划问题的解法基础。我的经验是,把 dp[j] = max(dp[j], dp[j - weight[i]] + value[i]) 这个状态转移方程刻在脑子里,然后去理解 j 的遍历顺序(反向是0-1,正向是完全),以及如何升维处理多约束条件。

最后,关于这道题,我个人的体会是,机试不仅考算法,更考细心和熟练度。一定要自己动手把代码敲几遍,直到能闭着眼睛写出0-1背包的DP循环。然后多找几个变种题练习,比如“完全背包”、“二维费用背包”。这样在考场上,无论题目怎么变化,你都能迅速识别模型,写出正确的代码。火锅很好吃,算法也很美味,关键在于你如何分配你的“时间”这道最有限的资源,去“煮”会最多的知识。

Logo

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

更多推荐