华为OD机试高频题“称砝码”详解:动态规划与多语言实现
1. 项目概述:从一道经典机试真题说起
最近在帮几个准备华为OD机试的朋友做模拟练习,发现“称砝码”这道题出现的频率相当高,几乎成了算法与数据结构能力的一块“试金石”。这道题本身并不复杂,但非常考验解题者对问题本质的抽象能力、对动态规划思想的掌握程度,以及代码实现的严谨性。很多朋友第一次看到题目描述时,觉得无非是排列组合,上手一写却漏洞百出,要么超时,要么结果不对。今天,我就结合自己多年刷题和面试官的经验,把这道题的里里外外彻底拆解一遍,并提供C++、Java、Python三种主流语言的实现与对比分析。无论你是正在备战华为OD,还是想巩固动态规划基础,这篇文章都能给你带来实实在在的收获。
简单来说,“称砝码”问题描述如下:给定一组砝码的重量(例如 [1, 2] ),以及每个砝码对应的数量(例如 [2, 1] ),问利用这些砝码,能够称出多少种不同的重量(包括0)。这里的“称出”指的是将砝码放在天平的一端(通常理解为全部放在同一边),计算其总重量所能形成的所有可能组合。这道题的核心,在于如何高效地枚举所有可能的重量组合,并去重。暴力回溯搜索在砝码种类和数量稍多时就会指数级爆炸,因此,动态规划(DP)中的“多重背包问题”变体,是解决它的不二法门。接下来,我们就从思路到代码,一步步把它吃透。
2. 核心思路与算法设计拆解
2.1 问题本质抽象:从排列组合到集合论
初看题目,最容易想到的思路是:我有若干砝码,每个砝码可以用0次到最大次数,把所有可能的组合总重量算出来,扔进一个集合(Set)里去重,最后集合的大小就是答案。这个思路完全正确,它定义了问题的解空间。假设有m种砝码,第i种砝码重量为 weight[i] ,数量为 num[i] ,那么所有可能的重量组合就是一个多重集合的笛卡尔积的映射: {使用0~num[0]个weight[0]} × {使用0~num[1]个weight[1]} × ... × {使用0~num[m-1]个weight[m-1]} ,然后将每个组合的重量求和,放入结果集。
这个抽象帮助我们明确了目标: 生成所有可能的和 。难点在于如何高效生成,避免重复计算。例如,砝码重量为[1,2],数量为[2,1],可能的组合有:(0个1,0个2)->0, (1个1,0个2)->1, (2个1,0个2)->2, (0个1,1个2)->2, (1个1,1个2)->3, (2个1,1个2)->4。去重后,能得到的不同重量是{0,1,2,3,4}共5种。当砝码种类和数量上升时,这种直观的枚举法复杂度是O(Π(num[i]+1)),是不可接受的。
2.2 动态规划状态定义与转移方程
为了高效求解,我们必须转换视角。不要想着“同时决定所有种类砝码的个数”,而是考虑“逐步增加可用的砝码种类,看能称出哪些重量”。这引出了动态规划的核心思想: 阶段性地扩大决策范围 。
我们定义状态: dp[j] 表示是否可以称出重量 j 。 dp 可以是一个布尔数组( vector<bool> in C++, boolean[] in Java, list[bool] in Python),也可以是一个比特位集合( bitset )来优化空间和速度。初始状态: dp[0] = true ,表示重量0总是可以称出的(不使用任何砝码)。
那么,如何转移呢?考虑我们当前已经处理了前 i-1 种砝码,得到了一个可达重量集合(即 dp 数组为 true 的那些下标)。现在引入第 i 种砝码,其重量为 w ,数量为 n 。对于当前可达的每一个重量 j ,我们可以选择添加 k 个( k 从0到 n )这种新砝码,从而得到新的重量 j + k * w 。这意味着,我们需要用 新的砝码 去更新 所有已有的可达状态 。
这里有一个关键的实现技巧:如果采用最朴素的三重循环(遍历砝码种类 i 、遍历当前所有可能重量 j 、遍历使用数量 k ),效率依然很低。我们可以进一步优化。注意到,对于一种重量为 w ,数量为 n 的砝码,其效果等价于有 n 个重量为 w 的 01物品 (每个物品只能用一次)。但这 n 个物品是完全相同的,我们可以用 多重背包的二进制优化 思想,将其拆分成若干个“物品组”,从而转化为一个01背包问题来进行更新,这通常是最优解。但对于机试场景,砝码种类和总重量通常不会大到离谱,我们可以采用一种更直观、编码更简单的“正向迭代”方法。
核心转移逻辑(正向迭代法) : 我们遍历每一种砝码。对于每一种砝码,我们 从最大可能重量向0遍历当前dp数组 (这是为了确保在更新 dp[j + w] 时, dp[j] 是未被本轮砝码更新过的旧状态,避免同一砝码被重复使用多次,这实质上是01背包的思想)。但是,我们有多 n 个!所以,我们需要一个额外的计数数组 count ,来记录为了得到某个重量 j ,当前种类的砝码已经使用了多少个。具体步骤,我们将在代码实现部分详细展开。另一种更清晰易懂的方法是使用“滚动集合”的思想,在Python中实现起来非常简洁。
2.3 算法复杂度与优化权衡
假设砝码种类为 m ,所有砝码总重量最大可能值为 W_max (即所有砝码都用上的总重量)。我们最终需要维护的 dp 数组长度就是 W_max + 1 。
- 朴素枚举法 :时间复杂度 O(Π(num[i]+1)),空间复杂度 O(结果集大小)。在数量多时不可行。
- 动态规划(集合更新法) :这是我最推荐在机试中使用的方法。外层遍历每种砝码,内层遍历当前可达集合中的每个重量,内层再遍历该砝码的使用个数(0~n)。最坏时间复杂度约为 O(m * W_max * avg_num),在机试数据范围内通常可以接受,且代码直观。
- 动态规划(二进制优化法) :将每种砝码按二进制拆分(1,2,4,...)成若干个“虚拟”的01物品,然后做标准的01背包DP。时间复杂度优化到 O(m * log(avg_num) * W_max),空间复杂度不变。代码稍复杂,但适用于更极端的数据。
对于华为OD机试,数据规模一般会控制得比较友好。因此,追求代码的清晰、正确和快速实现,比极致的优化更重要。下面,我们将采用**动态规划(集合更新法)**作为主线,给出三种语言的实现,并分析其中的细微差别和注意事项。
3. 多语言代码实现与逐行解析
我将提供两种风格的DP实现:第一种是经典的“DP数组+计数辅助数组”方法,逻辑严谨,在C++和Java中很常见;第二种是Python中利用集合(Set)特性实现的“滚动集合”法,非常简洁易懂。我会先给出Python的集合法,因为它最能体现思路的本质,然后再用C++和Java实现数组法,并对比其优劣。
3.1 Python实现(集合滚动法)
def count_weight_types(weights, nums):
"""
计算可以称出的不同重量种数。
:param weights: List[int], 砝码的重量列表
:param nums: List[int], 对应砝码的数量列表
:return: int, 可以称出的不同重量的数量(包括0)
"""
# 初始集合,只包含重量0
possible_weights = {0}
# 遍历每一种砝码
for w, n in zip(weights, nums):
# 当前轮次的新集合,初始化为空
current_new = set()
# 对于当前已能称出的每一种重量
for existing_weight in possible_weights:
# 尝试使用0个到n个当前砝码
for k in range(n + 1):
new_weight = existing_weight + k * w
current_new.add(new_weight)
# 将本轮生成的所有新重量合并到总集合中
# 注意:这里直接赋值即可,因为current_new已经包含了所有旧重量(k=0的情况)
possible_weights = current_new
# 返回集合的大小
return len(possible_weights)
# 示例用法
if __name__ == "__main__":
weights = [1, 2]
nums = [2, 1]
result = count_weight_types(weights, nums)
print(f"可以称出的重量种数: {result}") # 输出 5
代码解析与注意事项 :
- 核心变量 :
possible_weights是一个集合(Set),它动态维护着处理完前若干种砝码后,所有可能称出的重量。初始状态为{0}。 - 三层循环 :
- 外层
for w, n in zip(...): 遍历每种砝码。 - 中层
for existing_weight in possible_weights: 遍历 上一轮结束 时所有可能的重量。注意,这里遍历的是possible_weights的快照(在Python中,遍历一个集合的同时修改它是安全的,因为我们是在遍历旧的集合,将结果添加到current_new这个新集合)。 - 内层
for k in range(n+1): 尝试使用0到n个当前砝码。
- 外层
- 去重 :集合(Set)自动处理了重量重复的问题。例如,1个2g砝码和2个1g砝码都称出2g,但集合中只会保留一个2。
- 合并策略 :每一轮,我们都创建一个全新的集合
current_new。对于上一轮的每个重量existing_weight,我们生成n+1个新重量(包括existing_weight本身,即k=0的情况)并加入current_new。一轮结束后,用current_new完全替换possible_weights。这个方法是正确的,因为新一轮的可能性完全由旧一轮的可能性加上新砝码的所有使用方式生成。 - 复杂度 :假设最终集合大小为S,那么时间复杂度大致为O(m * S * avg_n)。由于S最大为W_max+1,且在实际计算中增长,这个方法是可行的,但可能不是最优。然而,其代码极其清晰,在机试中快速正确解题是第一要务。
注意 :这种写法在砝码总重量很大、种类较多时,
current_new集合可能会变得非常大,导致内存和速度问题。但在OD机试的常规约束下,它通常是够用的。
3.2 C++实现(DP数组法)
C++中,我们通常使用 std::vector<bool> 或 std::bitset 来表示状态数组。这里使用 vector<bool> ,因为它支持动态大小。
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
int countWeightTypes(const vector<int>& weights, const vector<int>& nums) {
if (weights.empty()) return 1; // 只有重量0
// 计算可能的最大重量
int max_weight = 0;
for (int i = 0; i < weights.size(); ++i) {
max_weight += weights[i] * nums[i];
}
// dp[j] 表示重量j是否可达
vector<bool> dp(max_weight + 1, false);
dp[0] = true; // 初始状态
// 遍历每一种砝码
for (int i = 0; i < weights.size(); ++i) {
int w = weights[i];
int n = nums[i];
// 为了正确处理多重砝码,我们需要从后往前更新dp
// 但是需要结合数量限制,标准方法是使用一个计数数组
// 这里采用一种更通用的“滚动数组”思想:对每个w,我们重复n次01背包过程
// 但更高效的方法是使用“计数限制”的更新
vector<bool> new_dp = dp; // 复制当前状态
// 遍历所有可能的目标重量j (从0到max_weight)
// 实际上,我们只需要遍历当前可达的重量,但为了简单,遍历所有
for (int j = 0; j <= max_weight; ++j) {
if (!dp[j]) continue; // 如果当前重量j不可达,跳过
// 对于可达的重量j,尝试添加k个当前砝码 (k从1到n)
for (int k = 1; k <= n; ++k) {
int new_j = j + k * w;
if (new_j > max_weight) break; // 超出范围,停止
new_dp[new_j] = true;
}
}
dp = move(new_dp); // 更新状态到下一轮
}
// 统计可达重量的数量
int count = 0;
for (bool reachable : dp) {
if (reachable) count++;
}
return count;
}
int main() {
vector<int> weights = {1, 2};
vector<int> nums = {2, 1};
int result = countWeightTypes(weights, nums);
cout << "可以称出的重量种数: " << result << endl; // 输出 5
return 0;
}
代码解析与避坑指南 :
- 最大重量计算 :首先需要计算所有砝码都使用时的总重量
max_weight,作为DP数组的大小。这是必要的,因为C++数组需要预先确定大小。 - DP数组更新策略 :代码中采用了
new_dp = dp然后更新new_dp的策略。这是因为在同一个dp数组上原地更新,且顺序遍历j时,会导致“一个砝码被使用多次”的完全背包效果,而不是我们想要的“每个砝码有数量限制”的多重背包效果。复制一份旧的dp,基于旧的dp更新new_dp,可以保证每个砝码在本轮更新中只基于上一轮的状态,不会被重复累加。 - 循环细节 :外层遍历砝码种类
i。中层遍历所有重量j(0到max_weight),但通过if (!dp[j]) continue;只对当前可达的重量进行处理。内层遍历使用该砝码的个数k(从1到n)。这种写法逻辑清晰,但存在优化空间:中层循环遍历了所有j,而不是仅遍历可达的j。 - 复杂度 :时间复杂度为 O(m * W_max * avg_n),空间复杂度为 O(W_max)。由于有
if (!dp[j]) continue;,实际计算量会小于最坏情况。 - 一个常见的错误 :试图用类似完全背包的一维DP写法(
for (int j = w; j <= max_weight; ++j) dp[j] = dp[j] || dp[j - w];)并外加一个数量限制循环。这种写法很容易出错,因为内层的数量循环和重量循环的顺序需要仔细设计,以确保状态转移正确。上面提供的“复制更新”法虽然空间开销稍大(多一个vector<bool>),但正确性更容易保证。
3.3 Java实现(DP数组法 - 优化版)
Java的实现思路与C++类似,但我们可以做一个小优化:在遍历重量 j 时,我们只遍历当前 dp 数组中为 true 的那些索引。我们可以用一个 List<Integer> 来动态记录当前可达的重量,而不是遍历整个数组。
import java.util.*;
public class Main {
public static int countWeightTypes(int[] weights, int[] nums) {
if (weights == null || weights.length == 0) {
return 1;
}
// 计算最大可能重量
int maxWeight = 0;
for (int i = 0; i < weights.length; i++) {
maxWeight += weights[i] * nums[i];
}
// dp数组,dp[j]为true表示重量j可达
boolean[] dp = new boolean[maxWeight + 1];
dp[0] = true;
// 用一个列表记录当前可达的重量,避免遍历整个数组
List<Integer> currentWeights = new ArrayList<>();
currentWeights.add(0);
for (int i = 0; i < weights.length; i++) {
int w = weights[i];
int n = nums[i];
// 临时存储本轮新产生的可达重量,用于更新currentWeights
Set<Integer> newWeightSet = new HashSet<>();
// 我们需要基于上一轮结束时的状态进行更新,所以遍历currentWeights
List<Integer> weightsToProcess = new ArrayList<>(currentWeights);
for (int existingWeight : weightsToProcess) {
// 尝试使用0到n个当前砝码
for (int k = 0; k <= n; k++) {
int newWeight = existingWeight + k * w;
if (newWeight <= maxWeight && !dp[newWeight]) {
dp[newWeight] = true;
newWeightSet.add(newWeight);
}
}
}
// 将本轮新发现的可达重量加入列表
currentWeights.addAll(newWeightSet);
}
// 统计可达重量数量
int count = 0;
for (boolean reachable : dp) {
if (reachable) count++;
}
return count;
}
public static void main(String[] args) {
int[] weights = {1, 2};
int[] nums = {2, 1};
int result = countWeightTypes(weights, nums);
System.out.println("可以称出的重量种数: " + result); // 输出 5
}
}
代码解析与性能考量 :
- 优化点 :引入了
currentWeights列表和newWeightSet集合。currentWeights动态维护当前所有可达的重量值。在每一轮处理新砝码时,我们遍历这个列表(而不是整个dp数组),从而大幅减少了无效的遍历。newWeightSet用于收集本轮新产生的(之前不可达的)重量,避免在currentWeights中重复添加。 - 状态更新 :
dp数组仍然是最终记录状态的权威。当我们通过计算发现一个新的可达重量newWeight时,首先检查dp[newWeight]是否为false(避免重复操作),然后将其设为true并加入newWeightSet。 - 遍历快照 :
List<Integer> weightsToProcess = new ArrayList<>(currentWeights);这一行至关重要。它创建了当前可达重量列表的一个副本。因为我们在循环内部会向currentWeights添加新元素(通过addAll),如果在原列表上直接进行遍历并修改,在某些语言中会抛出ConcurrentModificationException,在逻辑上也容易出错。遍历副本保证了我们基于“本轮开始前”的状态进行扩展。 - 去重 :
newWeightSet使用HashSet,自动确保了加入currentWeights的新重量不重复。 - 优势 :这种方法在可达重量相对稀疏(即
dp数组中true的比例不高)时,效率远高于遍历整个maxWeight范围。它结合了集合法的思路和数组法的快速状态查询,是一种不错的折中。
4. 测试用例设计与边界情况处理
写出代码只是第一步,通过所有测试用例才能拿满分。下面设计一组测试用例,覆盖各种边界和常见错误点。
| 测试用例描述 | 输入 (weights, nums) | 预期输出 | 考察点 |
|---|---|---|---|
| 基础用例1 | [1], [1] | 2 (0, 1) | 最简单情况,只有一种砝码一个。 |
| 基础用例2 | [1, 2], [2, 1] | 5 (0,1,2,3,4) | 题目经典示例,包含重复重量(2)。 |
| 重量重复 | [2, 2], [1, 1] | 3 (0,2,4) | 不同种类的砝码重量相同,测试去重逻辑。 |
| 数量为零 | [1, 3], [2, 0] | 3 (0,1,2) | 某种砝码数量为0,相当于不存在。 |
| 大重量砝码 | [10, 50], [1, 2] | 6 (0,10,50,60,100,110) | 重量间隔大,测试DP数组边界。 |
| 最大数量 | [1], [10] | 11 (0~10) | 单种砝码大量,测试内层k循环。 |
| 空输入 | [], [] | 1 (只有0) | 边界情况,没有砝码。 |
| 重量为0 | [0, 1], [1, 1] | 2 (0, 1) | 重量为0的砝码无意义,但程序应能处理(加0不影响结果)。实际题目应避免此输入。 |
| 总重量较大 | [1,2,5,10,20,50], [10,10,10,10,10,10] | 计算所有组合,结果很大。 | 测试算法性能和整数范围。总重量可达(1+2+5+10+20+50)*10=880,组合数很多。 |
在代码中,务必加入输入校验(虽然机试环境输入通常规范) :
# Python 示例
if not weights or len(weights) != len(nums):
return 1 # 或根据题目要求返回
# 确保nums中的数非负
对于C++/Java,也要注意数组越界、空指针等情况的处理。
5. 常见错误与调试技巧实录
在实现和调试这道题时,我见过也犯过不少错误。这里总结几个高频坑点:
-
重复计数(同一砝码无限使用) :
- 错误现象 :结果比预期大很多,特别是当砝码重量较小时。
- 错误代码 (C++示例):
for (int i=0; i<weights.size(); ++i) { int w = weights[i]; for (int j=w; j<=max_weight; ++j) { // 顺序遍历j if (dp[j-w]) dp[j] = true; // 这会导致w被重复使用多次 } } - 根因 :这是 完全背包 的写法(每个物品无限使用),而本题是 多重背包 (每个物品有限个)。顺序遍历
j使得在更新dp[j]时,dp[j-w]可能已经被 本轮 的砝码更新过了,相当于该砝码被用了不止一次。 - 解决 :使用“基于上一轮状态更新”的策略,如我们代码中的
new_dp复制法,或者使用逆序遍历j(但需要结合数量限制,较复杂)。
-
遗漏重量0 :
- 错误现象 :结果比预期少1。
- 根因 :忘记初始化
dp[0]=true或集合中没有加入0。重量0(不使用任何砝码)也是一种合法的“称重”结果。 - 解决 :牢记初始化。
-
数组越界 :
- 错误现象 :运行时错误(Segment Fault, IndexOutOfBounds)。
- 根因 :计算
new_j = j + k * w时没有检查是否小于等于max_weight。 - 解决 :在内层
k循环中增加条件判断if (new_j > max_weight) break;。
-
使用浮点数 :
- 错误现象 :精度问题导致结果错误(如果题目误传为浮点数重量)。
- 根因 :砝码重量通常是整数,用整数运算即可。如果题目明确是整数,绝对不要用
float或double。 - 解决 :全程使用
int。
-
去重逻辑错误(仅限集合法) :
- 错误代码 (Python):
for w, n in zip(weights, nums): for existing_weight in possible_weights: for k in range(1, n+1): # 错误!从1开始,漏了k=0 possible_weights.add(existing_weight + k*w) - 根因 :内层循环从1开始,那么
existing_weight本身(即k=0的情况)就无法被加入到新的集合中。在下一轮迭代时,possible_weights这个集合在遍历过程中被修改,且丢失了原有的基础重量,会导致严重的遗漏。 - 解决 :要么像示例一样使用
current_new暂存结果,要么确保k从0开始。 更安全的做法是永远不边遍历集合边修改它 。
- 错误代码 (Python):
调试技巧 :
- 小数据模拟 :用纸笔或调试器,跟踪第一个简单用例(如
weights=[1], nums=[1])的整个DP过程,查看dp数组或集合的变化,确保每一步都符合预期。 - 打印中间状态 :在每轮砝码处理完后,打印出当前的可达重量集合,比对是否与手动计算一致。
- 对比输出 :用暴力枚举法(针对小数据)生成一个正确的结果,与你的DP算法结果进行对比,快速定位问题。
6. 算法扩展与变式思考
“称砝码”是多重背包求方案数/可行性问题的一个典型代表。理解它之后,可以轻松应对一系列变体:
- 求具体方案 :如果题目要求输出所有能称出的重量,而不是数量,那么直接输出
dp数组中为true的索引,或者输出possible_weights集合中的所有元素即可。 - 天平左右均可放 :这是另一个经典变体。砝码可以放在天平左右两边。那么重量组合就变成了每个砝码可以取
-n[i]*w[i],-(n[i]-1)*w[i], ...,0,...,n[i]*w[i]。最终能称出的重量是所有可能代数和(可正可负)的绝对值。此时,DP数组的下标需要能表示负数,通常的做法是定义一个偏移量offset,例如dp[j+offset]表示重量j是否可达(j可以为负)。状态转移时,对于每个k,不仅要考虑j + k*w,还要考虑j - k*w。 - 求最接近目标值的重量 :给定一个目标重量
T,问用这些砝码能称出的不超过T的最大重量是多少?这依然是DP可行性问题,最后从T往下找第一个dp[j]为true的j即可。 - 求方案数(多少种方法称出某重量) :将
dp数组从布尔型改为整型(dp[j]表示称出重量j的方案数)。状态转移方程变为dp[j] += dp[j - k*w](需注意遍历顺序,通常需要三重循环或使用二进制优化/单调队列优化)。此时初始化dp[0]=1。
掌握核心的动态规划思想—— 将问题分解为阶段,每个阶段考虑一种物品(砝码),状态表示当前可达的集合 ,就能以不变应万变。
7. 华为OD机试实战建议
结合华为OD机试的环境和特点,给你几点最后的建议:
- 语言选择 :选择你最熟悉的语言。Python写起来快,代码简洁,在时间允许的情况下是利器;C++执行效率高,但对边界条件和细节要求更严格;Java介于两者之间。 没有绝对的好坏,只有熟练度的高低 。
- 输入输出 :务必熟悉牛客网/力扣那种核心代码模式的IO,或者ACM模式的IO。本题通常是给定
weights和nums两个列表,直接调用你写的函数。确保函数名、参数类型和返回类型与题目要求一致。 - 时间与空间 :机试通常有时间和内存限制。本文介绍的集合法(Python)和优化列表法(Java)在常规数据下完全够用。如果担心超时,可以预先估算一下最大重量
W_max。如果W_max超过10^5,或者砝码种类和数量乘积很大,就需要考虑更优的二进制拆分法了。 - 调试 :机试环境可能没有本地IDE那么方便。养成用
print或cout输出关键变量中间值的习惯(提交前记得注释掉),这是最直接的调试手段。 - 心态 :看到题目先花几分钟彻底理解题意,抽象出模型。像“称砝码”这种题,一旦识别出是多重背包/子集和问题,剩下的就是套用清晰的DP框架。如果一时没思路,先想暴力解法,再思考如何用空间换时间进行优化。
这道题就像一把钥匙,打开的是动态规划中“选择与状态”这扇大门。把它练熟,不仅能通过考试,更能加深对很多组合优化问题的理解。我在实际面试中,也经常用类似的题目来考察候选人的基础扎实程度和思维严谨性。希望这篇长文能帮你把这块知识夯得实实的。如果在实际编码中遇到任何问题,欢迎随时交流讨论。
更多推荐

所有评论(0)