1. 问题背景与题目解析
这道蓝桥杯2023年省赛A组的题目《买瓜》看似简单,实则暗藏玄机。题目大意是:小明去买瓜,瓜摊上有n个瓜,每个瓜有确定的重量。小明想要购买总重量恰好为m的瓜,但买整个瓜可能太重,因此允许将瓜切成两半购买(即可以选择买整个瓜或半个瓜)。我们需要计算出有多少种不同的购买组合能满足总重量等于m。
这个问题本质上是一个带有特殊条件的子集和问题(Subset Sum Problem)。与经典子集和问题不同的是,这里的每个元素(瓜)有三种选择状态:不选、选半个、选整个。这使得问题的复杂度比传统0-1背包问题更高。
注意:在实际处理中,"半个瓜"的重量必须是整数,因为题目给定的瓜重量都是整数,所以半个瓜的重量就是原重量除以2(题目保证所有瓜的重量都是偶数,因此半个瓜的重量也是整数)。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 解题思路分析
2.1 暴力回溯法的可行性
最直观的解法是使用回溯法枚举所有可能的组合。对于每个瓜,我们有三种选择:
- 不选这个瓜
- 选半个瓜(重量为w/2)
- 选整个瓜(重量为w)
这种方法的理论时间复杂度是O(3^n),当n=30时,3^30 ≈ 2e14,这显然无法在合理时间内完成计算。
2.2 折半处理的优化思路
为了降低时间复杂度,可以采用"折半处理"(Meet in the Middle)的技巧。具体步骤是:
- 将瓜分成两组,每组大约n/2个
- 分别计算每组所有可能的组合和对应的总重量
- 对于第一组的每个可能和a,在第二组中寻找和为(m - a)的组合
这种方法可以将时间复杂度从O(3^n)降低到O(3^(n/2))。当n=30时,3^15 ≈ 1.4e7,这在现代计算机上是可接受的。
3. 具体实现细节
3.1 数据预处理
首先需要对输入的瓜重量进行处理:
cpp复制vector<int> melons; // 存储所有瓜的重量
int n, m; // n是瓜的数量,m是目标重量
// 输入处理
cin >> n >> m;
melons.resize(n);
for(int i = 0; i < n; i++) {
cin >> melons[i];
// 题目保证所有瓜的重量都是偶数,所以半个瓜的重量是整数
}
3.2 折半分组实现
将瓜分成两组,通常可以简单地对半分割:
cpp复制int half = n / 2;
vector<int> left(melons.begin(), melons.begin() + half);
vector<int> right(melons.begin() + half, melons.end());
3.3 生成所有可能的组合和
我们需要编写一个函数来生成所有可能的组合和。对于每个瓜,有三种选择,可以使用递归或迭代的方式实现:
cpp复制void generateSums(const vector<int>& melons, vector<int>& sums, int index = 0, int current_sum = 0) {
if(index == melons.size()) {
sums.push_back(current_sum);
return;
}
// 不选当前瓜
generateSums(melons, sums, index + 1, current_sum);
// 选半个瓜
generateSums(melons, sums, index + 1, current_sum + melons[index] / 2);
// 选整个瓜
generateSums(melons, sums, index + 1, current_sum + melons[index]);
}
3.4 合并两组结果
生成两组的和后,我们需要统计满足条件的组合数。为了提高效率,可以先对第二组的结果排序,然后对于第一组的每个和,在第二组中使用二分查找来快速统计匹配的数量:
cpp复制vector<int> left_sums, right_sums;
generateSums(left, left_sums);
generateSums(right, right_sums);
sort(right_sums.begin(), right_sums.end());
int count = 0;
for(int sum : left_sums) {
int target = m - sum;
auto range = equal_range(right_sums.begin(), right_sums.end(), target);
count += range.second - range.first;
}
4. 优化与边界处理
4.1 去重与剪枝
在实际实现中,可以进行一些优化:
- 在生成和的过程中,如果当前和已经超过m,可以提前终止这条路径(剪枝)
- 对于相同的和,可以记录出现次数而不是存储多个副本
4.2 处理大数情况
当n较大时,3^(n/2)可能仍然很大。可以使用哈希表来存储和的出现次数,而不是存储所有可能的和:
cpp复制unordered_map<int, int> generateSumCounts(const vector<int>& melons) {
unordered_map<int, int> sum_counts;
sum_counts[0] = 1; // 初始状态:和为0有1种方式
for(int w : melons) {
unordered_map<int, int> new_counts;
for(auto& [sum, cnt] : sum_counts) {
// 不选
new_counts[sum] += cnt;
// 选半个
new_counts[sum + w/2] += cnt;
// 选整个
new_counts[sum + w] += cnt;
}
sum_counts = move(new_counts);
}
return sum_counts;
}
4.3 最终优化版代码
结合上述优化,完整的解决方案如下:
cpp复制#include <iostream>
#include <vector>
#include <algorithm>
#include <unordered_map>
using namespace std;
unordered_map<int, int> generateSumCounts(const vector<int>& melons, int max_sum) {
unordered_map<int, int> sum_counts;
sum_counts[0] = 1;
for(int w : melons) {
unordered_map<int, int> new_counts;
for(auto& [sum, cnt] : sum_counts) {
// 不选
if(sum <= max_sum) new_counts[sum] += cnt;
// 选半个
int half_sum = sum + w/2;
if(half_sum <= max_sum) new_counts[half_sum] += cnt;
// 选整个
int full_sum = sum + w;
if(full_sum <= max_sum) new_counts[full_sum] += cnt;
}
sum_counts = move(new_counts);
}
return sum_counts;
}
int main() {
int n, m;
cin >> n >> m;
vector<int> melons(n);
for(int i = 0; i < n; i++) {
cin >> melons[i];
}
int half = n / 2;
vector<int> left(melons.begin(), melons.begin() + half);
vector<int> right(melons.begin() + half, melons.end());
auto left_counts = generateSumCounts(left, m);
auto right_counts = generateSumCounts(right, m);
int total = 0;
for(auto& [sum, cnt] : left_counts) {
int target = m - sum;
if(right_counts.count(target)) {
total += cnt * right_counts[target];
}
}
cout << total << endl;
return 0;
}
5. 复杂度分析与对比
5.1 时间复杂度比较
- 暴力回溯法:O(3^n)
- 折半处理法:O(3^(n/2)) 生成和 + O(3^(n/2) log 3^(n/2)) 排序和查找 ≈ O(3^(n/2))
当n=30时:
- 暴力法:3^30 ≈ 2e14 次操作
- 折半法:2 * 3^15 ≈ 3e7 次操作
5.2 空间复杂度
折半处理法需要存储两组的所有可能和,空间复杂度为O(3^(n/2))。对于n=30,这大约是1.4e7个元素,在现代计算机上是可以接受的。
6. 实际测试与性能优化
在实际测试中,我们发现当n接近30时,程序运行时间可能会接近时间限制。为了进一步优化:
- 提前终止:在生成和的过程中,如果当前和已经超过m,可以跳过后续的选择
- 并行计算:左右两组的和生成可以并行进行
- 哈希表优化:使用更高效的哈希表实现,如Google的dense_hash_map
提示:在竞赛编程中,如果n≤20,可以直接使用暴力回溯法;当20<n≤30时,折半处理是最佳选择;当n>30时,可能需要更高级的算法或启发式方法。
7. 常见错误与调试技巧
在实现这个算法时,容易犯以下错误:
-
整数溢出:没有检查中间结果是否溢出,特别是当瓜的重量很大时
- 解决方法:使用long long类型存储和
-
重复计数:当瓜的重量有重复时,可能会重复计算相同的组合
- 解决方法:使用哈希表记录每个和的出现次数
-
边界条件:当m=0时,应该返回1(不选任何瓜也是一种选择)
- 解决方法:单独处理m=0的情况
-
半个瓜的处理:忘记题目保证瓜的重量都是偶数,错误地处理半个瓜的重量
- 解决方法:仔细阅读题目条件,确保w/2是整数
调试时可以先用小规模数据测试,例如:
- 输入1:n=3, m=5, 瓜重量为[2,4,6]
- 输入2:n=4, m=10, 瓜重量为[2,2,4,6]
8. 算法扩展与变种
这个问题有几个有趣的变种:
-
限制切瓜次数:最多只能切k个瓜
- 解决方法:在状态中额外记录已切的瓜数
-
瓜重量不全为偶数:允许半个瓜的重量不是整数
- 解决方法:将所有重量乘以2,转化为整数问题
-
求具体方案而非计数:需要输出所有满足条件的组合
- 解决方法:在生成和的同时记录选择的路径
-
动态瓜摊:瓜的重量会随时间变化
- 解决方法:使用动态数据结构维护和的信息
9. 竞赛中的实用技巧
在编程竞赛中处理这类问题时,可以记住以下技巧:
- 折半处理的适用场景:当n在30-40左右,且暴力法复杂度为O(2^n)或O(3^n)时
- 哈希表vs排序+二分:
- 哈希表:实现简单,但可能有较高的常数因子
- 排序+二分:更稳定,适合较大的n
- 预处理输入数据:排序瓜的重量,可以更有效地剪枝
- 对称性利用:如果问题有对称性,可以减少一半的计算量
10. 个人实现心得
在实际编码实现这个算法时,有几点经验值得分享:
-
递归与迭代的选择:对于生成所有组合和,递归实现更直观,但迭代实现(使用队列或栈)通常更快且不易栈溢出
-
哈希表的使用技巧:在C++中,unordered_map比map快,但在处理大量数据时可能会有哈希冲突。如果知道数据范围,可以用vector代替
-
测试用例设计:一定要测试n=0,1,2等小规模情况,以及所有瓜重量相同等特殊情况
-
性能分析工具:使用profiler找出代码中的热点,重点优化最耗时的部分
-
空间与时间的权衡:有时候存储中间结果会占用大量内存,但可以显著减少计算时间,需要根据题目限制做出选择
这道题很好地展示了如何将一个看似无法解决的问题,通过巧妙的算法设计(折半处理)变为可解。在实际编程竞赛中,这类技巧常常是解决难题的关键。
