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的状态定义、转移方程和具体实现细节。

3. 动态规划实现详解与Java代码

3.1 DP状态定义与数组设计

我们定义一个二维的布尔数组(或者整型数组,但布尔型更直观) dp[i][j]

  • i 的含义:考虑前 i 个充电设备(即 powers 数组下标从0到 i-1 的设备)。
  • j 的含义:当前背包的容量(即目标功率值)。
  • dp[i][j] 的值:一个布尔值,表示 是否能够 i 个设备 中,选出一些设备,使得它们的总功率 恰好等于 j

这里有两个关键点需要理解:

  1. “恰好等于” vs “不超过” :我们定义的是“恰好等于”。为什么?因为最终我们要找的是最接近 target 且不超过它的值。如果我们能知道所有“恰好等于”某个功率值 j 的可能性( dp[i][j] = true ),那么只要从 target 开始向下遍历 j ,第一个遇到的 dp[n][j] true j ,就是我们要找的答案。这比直接处理“不超过”要更清晰。
  2. 数组大小 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 时,我们有两种选择:

  1. 不选择这个设备 :那么能否凑出功率 j ,就完全取决于前 i-1 个设备了。即 dp[i][j] = dp[i-1][j]
  2. 选择这个设备 :前提是这个设备的功率 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;
    }
}

代码关键点解析:

  1. 输入处理 :代码展示了两种常见的输入格式处理方式。实际考试中,务必仔细阅读题目中的输入描述,它可能是数组长度+数组,也可能是直接一行数组。这里是按“一行空格分隔的数字为数组”来处理的,更具通用性。
  2. DP数组初始化 dp[0][0] = true 是动态规划的“起点”,代表空集合的和为0。这个初始化至关重要。
  3. 填表顺序 :外层循环遍历设备( i 从1到 n ),内层循环遍历所有可能的功率值( j 从0到 target )。这是标准的0/1背包填表顺序。
  4. 答案查找 :填表完成后, dp[n][j] 就代表了考虑所有 n 个设备时,能否凑出 恰好 j 的功率。我们从 target 开始向下查找,第一个为 true j 就是最接近且不超过目标的最大功率和。
  5. 空间复杂度优化提示 :上面的代码使用了 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 机试中常见错误与避坑指南

根据很多同学的反馈,这道题容易在以下几个地方失分:

  1. DP数组初始化错误 :忘记设置 dp[0][0] = true ,导致整个DP表结果全为false,最终输出0。这是最经典的错误。
  2. 数组下标越界 :在状态转移 dp[i-1][j-power] 时,没有检查 j >= power 就访问数组,导致当 j < power 时访问 dp[i-1][负数] 而崩溃。
  3. 一维DP遍历顺序错误 :如前所述,使用一维数组优化时,内层循环必须 从大到小 遍历。写成从小到大是高频错误。
  4. 结果查找逻辑错误 :填完DP表后,不是从 target 向下找,而是向上找或乱找。题目要求是“不超过目标的最大值”,所以必须从 target 开始递减查找。
  5. 输入格式处理不当 :华为OD的机试系统输入通常是标准的 Scanner BufferedReader 读取。务必看清题目输入说明:是一行数字用空格隔开,还是先读数量再读数组。处理不当会导致后续计算全部错误。
  6. 时间或内存超限 :如果使用未优化的二维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) 设为负无穷来表示“不可能恰好装满”,但本题我们只关心布尔状态,且最终通过反向查找来满足“不超过”,所以用“恰好装满”的定义配合查找是最清晰的思路。

这道“查找充电设备组合”题,就像一把钥匙,帮你打开了用动态规划解决组合优化问题的大门。掌握它,不仅是为了通过某一场考试,更是为了在遇到资源调度、成本控制、投资组合等现实问题时,能多一种强大而高效的思维方式。在平时的练习中,不妨尝试一下它的变种,比如“如果每个设备可以选多次(完全背包)怎么办?”或者“要求输出具体选择了哪些设备?”,相信你会对动态规划有更深刻的体会。

Logo

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

更多推荐