华为OD机试C卷:0/1背包动态规划解充电设备组合问题
1. 项目概述与核心需求解析
最近在准备华为OD机试C卷的同学,估计不少人都刷到了“查找充电设备组合”这道题。这道题在各大论坛和备考群里的讨论热度一直不低,因为它完美地融合了基础的算法思想和实际的工程应用场景,属于那种“看起来简单,但想拿满分需要仔细琢磨”的典型题目。题目大意是:给你一个充电设备功率数组和一个目标功率值,需要从数组中找出一个组合,使得其总功率最接近目标值但不能超过它。这本质上是一个 0/1背包问题 的变种,或者更具体地说,是“最接近目标值的子集和”问题。在机试的紧张环境下,如何快速、准确地用Java实现,并且处理好各种边界情况,是区分普通通过和高分的关键。
这道题的价值在于,它不仅仅是一道算法题。在实际的软件开发中,类似的场景比比皆是:资源分配、预算规划、负载均衡等,核心逻辑都是在一组约束条件下寻找最优或最接近最优的解。因此,吃透这道题,掌握其背后的 动态规划 思想,对于提升解决实际问题的能力大有裨益。无论你是正在备战华为OD,还是想巩固自己的Java算法功底,这篇从思路推导到代码实现,再到踩坑经验的全方位解析,都应该能给你带来直接的帮助。
2. 问题本质与算法思路拆解
2.1 问题重述与抽象建模
我们先抛开“充电设备”这个业务外壳,把问题抽象成一个纯粹的算法模型:
- 输入 :一个正整数数组
int[] powers,代表每个设备的功率;一个正整数int target,代表充电站的最大输出功率(目标值)。 - 输出 :一个整数,代表所选设备功率之和。这个和必须满足两个条件:1) 小于等于
target;2) 在所有可能的组合中,与target的差值最小。 - 核心约束 :每个设备最多只能被选择一次(0/1选择)。
这立刻让我们联想到经典的 0/1背包问题 。在背包问题中,我们有物品的重量和价值,背包有容量限制,目标是让背包内物品的总价值最大。在这里,我们可以做一个巧妙的映射:
- 设备功率 同时扮演了 “物品重量” 和 “物品价值” 的角色。
- 目标功率
target就是 “背包容量” 。 - 我们的目标 不再是最大化价值,而是让“重量”(也就是功率和)尽可能大,但不能超过容量。因为“重量”和“价值”是同一个数,所以“重量”最大即“价值”最大。
这样一来,问题就转化为了:在总重量不超过背包容量的前提下,尽可能装满背包。背包最后装了多少重量,就是我们要的答案。
2.2 动态规划方案选型与论证
对于这类组合优化问题,常见的思路有回溯(DFS)、枚举和动态规划(DP)。
- 回溯/DFS :思路直观,通过递归遍历所有可能的组合。但其时间复杂度是指数级的
O(2^n),当设备数量n稍大(比如超过30)时,运行时间会急剧膨胀,在机试的时限内几乎必然超时。因此,除非数据规模特别小,否则不予考虑。 - 动态规划(DP) :这是解决此问题的标准且高效的方法。其核心思想是“空间换时间”,将大问题分解为重叠的子问题,并存储子问题的解以避免重复计算。
- 为什么DP适合? 0/1背包问题具有最优子结构性质。即,考虑前
i个设备、容量为j的背包的最优解,可以由前i-1个设备的子问题推导出来。这正好契合DP的解题模式。 - DP的优势 :时间复杂度为
O(n * target),其中n是设备数量。在机试常见的数据范围内(n通常在100以内,target在1000以内),这个复杂度是完全可接受的,能够保证稳定运行。
- 为什么DP适合? 0/1背包问题具有最优子结构性质。即,考虑前
基于以上分析,我们确定采用 动态规划 作为本题的解决方案。下面我们将深入DP的状态定义、转移方程和具体实现细节。
3. 动态规划实现详解与Java代码
3.1 DP状态定义与数组设计
我们定义一个二维的布尔数组(或者整型数组,但布尔型更直观) dp[i][j] 。
-
i的含义:考虑前i个充电设备(即powers数组下标从0到i-1的设备)。 -
j的含义:当前背包的容量(即目标功率值)。 -
dp[i][j]的值:一个布尔值,表示 是否能够 从 前i个设备 中,选出一些设备,使得它们的总功率 恰好等于j。
这里有两个关键点需要理解:
- “恰好等于” vs “不超过” :我们定义的是“恰好等于”。为什么?因为最终我们要找的是最接近
target且不超过它的值。如果我们能知道所有“恰好等于”某个功率值j的可能性(dp[i][j] = true),那么只要从target开始向下遍历j,第一个遇到的dp[n][j]为true的j,就是我们要找的答案。这比直接处理“不超过”要更清晰。 - 数组大小 :
dp数组的长度应该是[n+1][target+1]。i从0到n,j从0到target。dp[0][0] = true表示不考虑任何设备时,功率和恰好为0是可能的(一个空组合)。
3.2 状态转移方程推导
状态转移是DP的核心,它描述了如何从已知的小问题解出大问题的解。
对于第 i 个设备(其功率为 power = powers[i-1] ,注意下标对应关系),在面对容量 j 时,我们有两种选择:
- 不选择这个设备 :那么能否凑出功率
j,就完全取决于前i-1个设备了。即dp[i][j] = dp[i-1][j]。 - 选择这个设备 :前提是这个设备的功率
power不能大于当前容量j。如果选择了它,那么剩下的容量j - power就需要由前i-1个设备来凑出。即dp[i][j] = dp[i-1][j - power]。
由于我们的目标是“能否凑出”,只要以上两种选择中有一种能成功,那么 dp[i][j] 就是可行的。因此,状态转移方程为: dp[i][j] = dp[i-1][j] || (j >= power && dp[i-1][j - power])
这个方程的意思是: dp[i][j] 为真,要么是因为不选第 i 个设备就能凑出 j ,要么是因为选了第 i 个设备后,前 i-1 个设备能凑出 j - power 。
3.3 完整Java代码实现与逐行解析
理解了状态定义和转移方程,我们就可以动手写代码了。以下是完整的、带有详细注释的Java实现。
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
// 读取设备数量(根据题目输入格式,有时第一行是设备数量,有时直接是数组,这里假设第一行是数组)
// 更通用的方式是直接读取一行,按空格分割。这里根据常见题型调整。
String[] powerStrs = scanner.nextLine().split(" ");
int n = powerStrs.length;
int[] powers = new int[n];
for (int i = 0; i < n; i++) {
powers[i] = Integer.parseInt(powerStrs[i]);
}
// 读取目标功率
int target = scanner.nextInt();
scanner.close();
// 调用核心解题函数
int result = findClosestCombination(powers, target);
System.out.println(result);
}
/**
* 查找最接近目标值的充电设备组合功率和
* @param powers 充电设备功率数组
* @param target 目标功率值
* @return 最接近且不超过target的功率和
*/
public static int findClosestCombination(int[] powers, int target) {
int n = powers.length;
// 1. 创建DP表。dp[i][j] 表示前i个设备能否恰好组成功率j
boolean[][] dp = new boolean[n + 1][target + 1];
// 2. 初始化基础状态
// 前0个设备(即没有设备)可以组成功率0
dp[0][0] = true;
// 对于其他任何大于0的功率j,前0个设备都无法组成,boolean数组默认就是false,无需显式设置。
// 3. 动态规划填表过程
for (int i = 1; i <= n; i++) {
int power = powers[i - 1]; // 当前设备的功率
for (int j = 0; j <= target; j++) {
// 情况一:不选当前设备
if (dp[i - 1][j]) {
dp[i][j] = true;
}
// 情况二:选当前设备(前提是当前设备功率不超过当前目标j)
// 注意:这里用的是“或等”,因为情况一可能已经将其设为true
if (j >= power && dp[i - 1][j - power]) {
dp[i][j] = true;
}
// 如果以上两种情况都不满足,dp[i][j]保持默认的false
}
}
// 4. 寻找答案:从target开始向下遍历,找到第一个dp[n][j]为true的j
for (int j = target; j >= 0; j--) {
if (dp[n][j]) {
return j;
}
}
// 理论上,dp[n][0]一定为true(全不选),所以这里不会走到,但为了代码完整性返回0
return 0;
}
}
代码关键点解析:
- 输入处理 :代码展示了两种常见的输入格式处理方式。实际考试中,务必仔细阅读题目中的输入描述,它可能是数组长度+数组,也可能是直接一行数组。这里是按“一行空格分隔的数字为数组”来处理的,更具通用性。
- DP数组初始化 :
dp[0][0] = true是动态规划的“起点”,代表空集合的和为0。这个初始化至关重要。 - 填表顺序 :外层循环遍历设备(
i从1到n),内层循环遍历所有可能的功率值(j从0到target)。这是标准的0/1背包填表顺序。 - 答案查找 :填表完成后,
dp[n][j]就代表了考虑所有n个设备时,能否凑出 恰好 为j的功率。我们从target开始向下查找,第一个为true的j就是最接近且不超过目标的最大功率和。 - 空间复杂度优化提示 :上面的代码使用了
O(n*target)的空间。实际上,观察状态转移方程dp[i][j]只依赖于dp[i-1][...],我们可以用一维数组dp[j]来优化空间,将空间复杂度降至O(target)。这在target很大时能节省不少内存。优化后的内层循环需要 从target到power逆序遍历 ,以避免状态被覆盖。这是背包问题的一个经典优化技巧。
4. 空间优化技巧与变种实现
4.1 滚动数组优化(一维DP)
对于机试而言,在确保正确性的前提下,写出空间优化的代码往往能体现更好的功底。下面给出空间优化后的版本。
public static int findClosestCombinationOptimized(int[] powers, int target) {
int n = powers.length;
// 使用一维DP数组,dp[j]表示:能否用已经遍历过的设备,恰好组成功率j
boolean[] dp = new boolean[target + 1];
// 初始化:功率0总是可以达到(不选任何设备)
dp[0] = true;
// 遍历每个设备
for (int i = 0; i < n; i++) {
int power = powers[i];
// 关键:内层循环必须从大到小遍历!
// 如果从小到大遍历,同一个设备可能会被重复使用多次(变成了完全背包问题)。
for (int j = target; j >= power; j--) {
// 状态转移:dp[j] = dp[j] || dp[j - power]
// 含义:当前能组成j,要么是之前就能组成j(不选当前设备),
// 要么是之前能组成j-power(选了当前设备)。
if (dp[j - power]) {
dp[j] = true;
}
// 如果dp[j]原本就是true,这里不需要改动,所以用if判断而非直接赋值
}
}
// 查找结果:从target向下找到第一个为true的j
for (int j = target; j >= 0; j--) {
if (dp[j]) {
return j;
}
}
return 0;
}
注意:一维DP的内层逆序循环是绝对关键点 。如果写成
for (int j = power; j <= target; j++),就变成了完全背包(每个设备无限使用),结果将是错误的。务必理解并记住这个区别。
4.2 处理特殊边界情况与异常输入
一个健壮的程序必须考虑边界情况。在机试中,这些细节可能就是那关键的几分。
- 空数组输入 :如果
powers数组为空,无论target是多少,答案都应该是0。我们的代码中n=0,DP初始化后直接进入查找循环,dp[0]=true,会返回0,结果是正确的。 - 目标功率为0 :如果
target为0,那么任何功率大于0的设备都不能选,答案只能是0。我们的代码中,DP数组大小为1(target+1=1),只有dp[0],初始化即为true,查找时会直接返回0。 - 设备功率超过目标值 :在动态规划过程中,当
j < power时,“选择当前设备”的情况会被跳过(因为j >= power的条件不满足),逻辑是正确的。 - 输入包含非正整数 :题目通常保证输入是正整数。如果存在0或负数,需要根据题意特殊处理。例如,功率为0的设备选不选都不影响总和,可能需要额外逻辑。
5. 实战调试、常见“坑点”与心得
5.1 调试方法与测试用例设计
自己实现代码后,不要只看样例。设计全面的测试用例是保证代码正确的唯一途径。
推荐测试用例集:
// 测试用例1: 基础功能
输入: powers = [1, 2, 3, 4, 5], target = 10
输出: 10 (可以刚好凑满)
// 测试用例2: 无法刚好凑满
输入: powers = [2, 3, 5], target = 7
输出: 6 (选择2和3,无法凑出7)
// 测试用例3: 目标值很小
输入: powers = [10, 20, 30], target = 5
输出: 0 (任何设备都超过目标值)
// 测试用例4: 空数组或目标为0
输入: powers = [], target = 100
输出: 0
输入: powers = [1,2,3], target = 0
输出: 0
// 测试用例5: 大数测试(检查数组越界和性能)
输入: powers = [50, 50, 50, ... 20个], target = 1000
// 应能快速计算出结果
// 测试用例6: 包含重复功率
输入: powers = [5, 5, 5, 8], target = 14
输出: 13 (5+8)
在本地IDE(如IntelliJ IDEA, Eclipse)或在线编程平台运行这些测试,确保全部通过。
5.2 机试中常见错误与避坑指南
根据很多同学的反馈,这道题容易在以下几个地方失分:
- DP数组初始化错误 :忘记设置
dp[0][0] = true,导致整个DP表结果全为false,最终输出0。这是最经典的错误。 - 数组下标越界 :在状态转移
dp[i-1][j-power]时,没有检查j >= power就访问数组,导致当j < power时访问dp[i-1][负数]而崩溃。 - 一维DP遍历顺序错误 :如前所述,使用一维数组优化时,内层循环必须 从大到小 遍历。写成从小到大是高频错误。
- 结果查找逻辑错误 :填完DP表后,不是从
target向下找,而是向上找或乱找。题目要求是“不超过目标的最大值”,所以必须从target开始递减查找。 - 输入格式处理不当 :华为OD的机试系统输入通常是标准的
Scanner或BufferedReader读取。务必看清题目输入说明:是一行数字用空格隔开,还是先读数量再读数组。处理不当会导致后续计算全部错误。 - 时间或内存超限 :如果使用未优化的二维DP且
target很大(比如10^5),可能会导致内存超出限制(O(n*target)的布尔数组可能很大)。这时应考虑使用一维DP优化空间。虽然C卷此题target通常不会过大,但养成优化习惯是好的。
5.3 个人实操心得与技巧
- 先写二维,再优化一维 :在考场上,如果时间紧张,优先保证写出正确清晰的二维DP代码。它逻辑更直观,不易出错。如果时间充裕,再改写成优化的一维版本作为加分项。
- 善用打印调试 :在本地练习时,对于小样例(如
powers=[2,3], target=5),可以把整个DP表打印出来,对照手动推导的结果,这是理解DP过程最快的方式。// 调试打印示例 for (int i = 0; i <= n; i++) { for (int j = 0; j <= target; j++) { System.out.print((dp[i][j] ? "T" : "F") + " "); } System.out.println(); } - 理解大于记忆 :不要死记硬背代码模板。务必理解
dp[i][j]状态的定义,以及“不选”和“选”两种决策如何导致状态转移。理解了本质,即使题目稍有变化(比如求方案数、求具体方案),你也能灵活应对。 - 关于“恰好装满” :本题解法的精髓在于定义“恰好等于 j”。有些背包问题初始化时会将
dp[0][j](j>0) 设为负无穷来表示“不可能恰好装满”,但本题我们只关心布尔状态,且最终通过反向查找来满足“不超过”,所以用“恰好装满”的定义配合查找是最清晰的思路。
这道“查找充电设备组合”题,就像一把钥匙,帮你打开了用动态规划解决组合优化问题的大门。掌握它,不仅是为了通过某一场考试,更是为了在遇到资源调度、成本控制、投资组合等现实问题时,能多一种强大而高效的思维方式。在平时的练习中,不妨尝试一下它的变种,比如“如果每个设备可以选多次(完全背包)怎么办?”或者“要求输出具体选择了哪些设备?”,相信你会对动态规划有更深刻的体会。
更多推荐

所有评论(0)