华为OD机试高频题解析:贪心与动态规划在背包问题中的应用
1. 项目概述:一道经典的贪心算法面试题
最近在准备华为OD机试或者类似算法面试的朋友,应该对“导师请吃火锅”这道题不陌生。这道题编号525,是华为OD机试E卷的真题,同时在C++、Java、Python等多个语言版本的题库中都有出现,属于高频考点。我第一次看到这个题目时,觉得它名字挺有意思,但仔细一读题,发现内核是一道非常典型的 贪心算法 应用题,考察的是在资源(时间、金钱)有限的情况下,如何做出最优选择,最大化满足感(或者说“吃到最多的菜”)。
简单来说,题目的场景是这样的:你和导师(或者朋友)一起去吃火锅,火锅店里有n种菜品,每种菜品都有一个“煮食时间”和一个“美味值”。你们有一个总的时间限制(比如火锅只能煮T分钟),目标是在这个时间限制内,选择一些菜品来煮,使得吃到的所有菜品的美味值总和最大。这听起来是不是很像经典的“0-1背包问题”?没错,它的本质就是背包问题的一个变种,但通常因为数据规模或特殊条件,会引导我们使用贪心或者动态规划来求解。在华为OD的机试环境中,这道题更倾向于考察对贪心策略“单位时间美味值优先”的理解和实现。
为什么这道题值得深究?因为它完美地将一个生活场景抽象成了一个算法模型。对于初学者,它是理解贪心算法“局部最优导致全局最优”思想的绝佳例题;对于准备面试的开发者,它综合考察了问题抽象、算法选择、边界条件处理以及代码实现能力。接下来,我将彻底拆解这道题,从问题分析、思路推导,到C++、Java、Python三种语言的代码实现与对比分析,最后分享一些机试中的实战技巧和避坑指南。
2. 核心需求与问题抽象
2.1 题目描述与输入输出格式
我们首先需要把题目描述具体化。根据常见的题库信息,“导师请吃火锅”题目的典型描述如下:
输入 :
- 第一行包含两个整数
n和T,分别表示菜品的数量,以及火锅总共可以煮的时间(单位:分钟)。 - 接下来
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选择。因此, 标准的解法应该是动态规划 。
然而,为什么网上很多讨论提到“贪心”呢?我分析有两种可能:
- 记忆偏差或简化讲解 :有些文章为了方便理解,先引入贪心思想,再指出其缺陷,进而引出动态规划。
- 存在特殊条件 :也许在某些版本的题目描述中,有额外的限制(如每种菜品数量无限,即完全背包问题),或者数据规模极大,需要用贪心结合其他技巧(如按性价比排序后使用搜索或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 递增,同一个菜品可能会被重复计算,那就变成了“完全背包”问题。
算法步骤 :
- 初始化一个大小为
T+1的数组dp,所有元素为0。 - 遍历每一个菜品
(t, v)。 - 对于每个菜品,内层循环
j从T向下遍历到t。 - 更新
dp[j] = max(dp[j], dp[j - t] + v)。 - 遍历结束后,
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++实现要点与避坑 :
- 输入输出效率 :在华为OD机试平台,使用
cin/cout通常足够。如果担心数据量极大,可以加入ios::sync_with_stdio(false); cin.tie(nullptr);来关闭同步,提升速度。 - 容器选择 :使用
vector比原生数组更安全方便。dp数组初始化为0很重要。 - 遍历顺序 :内层
j的 反向遍历 是0-1背包优化的精髓,务必牢记。写成正向就成了完全背包。 - 空间复杂度 :O(T),非常高效。
- 边界检查 :题目通常保证
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实现要点与避坑 :
- Scanner的使用 :
Scanner对于机试输入足够用,但比BufferedReader稍慢。如果遇到超时,可以换用BufferedReader。 - 数组初始化 :
int[] dp = new int[T+1];会自动初始化为0,无需额外操作。 - 函数调用 :使用
Math.max进行最大值比较。 - 内存与性能 :算法本身和C++版本无异,性能主要取决于JVM和输入输出。在OJ上,Java有时需要更注意常数优化。
- 关闭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实现要点与避坑 :
- 输入读取 :使用
sys.stdin.read()一次性读取所有输入再分割,比循环调用input()快很多,这在处理大量数据时至关重要。 - 列表推导与迭代 :使用
iter和next遍历数据比用索引更Pythonic。 - DP数组更新 :在循环内直接使用
if判断和赋值,有时比调用max函数稍快一丁点,但可读性稍差。max函数的写法更清晰。 - 遍历范围 :
range(T, t - 1, -1)确保了j能取到t。 - 性能警告 :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 输入处理与边界条件
这是机试中最容易失分的地方之一。
- 多组测试数据 :题目有时会说“输入包含多组测试数据”,直到文件结束(EOF)。你的代码需要能处理这种情况。C++/Java可以用
while(cin >> n >> T),Python可以用try-except或判断输入是否为空。 - 数据范围与溢出 :仔细看题目给的
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防止溢出 - 时间或容量为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 调试与测试用例设计
自己设计几个测试用例来验证代码:
- 基础用例 :题目给的示例。
- 边界用例 :
n=1, T=0,菜品时间>0。结果应为0。n=1, T=5, 菜品时间=3,美味值=10。结果应为10。n=2, T=3, 菜品(2,5), (3,6)。只能选一个,应选价值大的6。
- 反贪婪用例 :如前文所述,用来验证你的DP算法比简单贪心更优。
- 大数值用例 :检查是否溢出。
5.4 代码风格与提交前检查
- 类名与主函数 :在华为OD平台,Java的类名必须是
Main。C++的main函数返回int。Python的入口是if __name__ == '__main__':。 - 不要包含包/头文件之外的内容 :不要打印调试信息(如
cout << "debug" << endl;)。确保最终提交的代码只包含解决题目所必须的部分。 - 时间复杂度与注释 :虽然不要求,但在关键算法步骤旁写一句简洁的注释(如
// 0-1背包DP),有助于阅卷人理解,也便于自己复查。 - 使用更快的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. 从解题到举一反三:背包问题的核心思维
“导师请吃火锅”这道题的价值远不止于通过一次考试。它为我们打开了一扇门,即“背包问题”的建模思维。很多看似不同的问题,都可以归结为背包问题。
识别背包问题的关键点 :
- 有限资源 :有一个或多个限制条件(如时间T、预算M)。
- 一系列选择 :有一组物品(如菜品),每个物品消耗一定资源,并带来一定收益。
- 最大化/最小化目标 :在资源限制下,最大化总收益或最小化总成本。
- 选择规则 :物品通常是“选”或“不选”(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循环。然后多找几个变种题练习,比如“完全背包”、“二维费用背包”。这样在考场上,无论题目怎么变化,你都能迅速识别模型,写出正确的代码。火锅很好吃,算法也很美味,关键在于你如何分配你的“时间”这道最有限的资源,去“煮”会最多的知识。
更多推荐

所有评论(0)