2 条题解
-
0
同学,针对 组合总和 (Combination Sum) 的数据生成与评测,我们需要重点考虑“搜索空间爆炸”与“输出文件大小”的平衡。
在 的情况下,如果数组中包含较小的数(如 ),组合方案数会成千上万。为了满足你要求的 2MB 理想文件大小,我们在生成测试数据时会对 和 的比例进行科学微调。
一、 数据生成器 (gen.cpp)
该生成器使用 C++14 标准,内置高效的带剪枝求解器来生成标准答案。
#include <iostream> #include <fstream> #include <vector> #include <string> #include <algorithm> #include <random> #include <set> using namespace std; typedef vector<int> Combination; // 求解器:用于生成 .out 文件 void solve(int target, int start, int n, const vector<int>& a, Combination& curr, ofstream& fout) { if (target == 0) { for (int i = 0; i < curr.size(); ++i) { fout << curr[i] << (i == curr.size() - 1 ? "" : " "); } fout << "\n"; return; } for (int i = start; i < n; ++i) { if (a[i] > target) break; // 强力剪枝 curr.push_back(a[i]); solve(target - a[i], i, n, a, curr, fout); curr.pop_back(); } } int main() { // 测试点配置:{n, target, min_val, max_val} // 为了控制文件大小在 2MB 内,我们根据 target 调整 min_val int cfg[11][4] = { {0, 0, 0, 0}, // 占位 {1, 10, 2, 5}, // T1: 极小规模 {4, 7, 2, 7}, // T2: 样例数据 {10, 50, 20, 100}, // T3: 无解(min_val > target) {5, 40, 5, 20}, // T4: 中等规模 {15, 60, 10, 40}, // T5: 较多方案 {20, 100, 20, 50}, // T6: 深度增加 {30, 200, 50, 150}, // T7: 较大 target {30, 300, 80, 200}, // T8: 强剪枝测试 {30, 500, 100, 200}, // T9: 极限 target, 大元素 {10, 500, 50, 100} // T10: 极限 target, 中元素 }; mt19937 rng(2026); for (int t = 1; t <= 10; ++t) { string in_name = to_string(t) + ".in"; string out_name = to_string(t) + ".out"; ofstream fin(in_name), fout(out_name); int n = cfg[t][0], target = cfg[t][1]; set<int> a_set; if (t == 2) { // 样例 a_set = {2, 3, 6, 7}; } else { uniform_int_distribution<int> dist(cfg[t][2], cfg[t][3]); while (a_set.size() < n) a_set.insert(dist(rng)); } vector<int> a(a_set.begin(), a_set.end()); n = a.size(); // 写 .in 文件 fin << n << " " << target << "\n"; for (int i = 0; i < n; ++i) fin << a[i] << (i == n - 1 ? "" : " "); fin << "\n"; // 写 .out 文件 sort(a.begin(), a.end()); Combination curr; solve(target, 0, n, a, curr, fout); fin.close(); fout.close(); cout << "Test case " << t << " generated.\n"; } return 0; }
二、 特判程序 (checker.cpp)
使用
testlib.h(NOI 常用评测插件库)。由于输出顺序不限且组合内部元素可无序,SPJ 会对每行进行排序后放入set进行比对。#include "testlib.h" #include <vector> #include <string> #include <algorithm> #include <set> #include <sstream> using namespace std; // 将一行字符串解析为排序后的整数向量 vector<int> parseLine(string line) { stringstream ss(line); int val; vector<int> res; while (ss >> val) res.push_back(val); sort(res.begin(), res.end()); return res; } int main(int argc, char* argv[]) { registerTestlibCmd(argc, argv); // 1. 读取输入信息 int n = inf.readInt(); int target = inf.readInt(); set<int> candidates; for (int i = 0; i < n; ++i) candidates.insert(inf.readInt()); // 2. 读取标准答案 set<vector<int>> ans_sets; // 使用 ans.seekEof() 配合 ans.readLine() 鲁棒读取 while (!ans.seekEof()) { string line = ans.readLine(); if (line.empty()) continue; ans_sets.insert(parseLine(line)); } // 3. 读取并校验选手答案 set<vector<int>> ouf_sets; int line_cnt = 0; while (!ouf.seekEof()) { string line = ouf.readLine(); if (line.empty()) continue; line_cnt++; vector<int> curr = parseLine(line); // 校验:元素合法性 long long sum = 0; for (int x : curr) { if (candidates.find(x) == candidates.end()) quitf(_wa, "Line %d: Element %d is not in candidates", line_cnt, x); sum += x; } // 校验:和是否正确 if (sum != target) quitf(_wa, "Line %d: Sum %lld does not match target %d", line_cnt, sum, target); // 校验:是否重复输出 if (ouf_sets.count(curr)) quitf(_wa, "Line %d: Duplicate combination found", line_cnt); // 校验:是否在标准答案集中 if (!ans_sets.count(curr)) quitf(_wa, "Line %d: Combination is valid but not expected (logic error)", line_cnt); ouf_sets.insert(curr); } // 4. 最终校验:找全了吗? if (ouf_sets.size() < ans_sets.size()) quitf(_wa, "Too few combinations: expected %d, found %d", (int)ans_sets.size(), (int)ouf_sets.size()); if (ouf_sets.size() > ans_sets.size()) quitf(_wa, "How? Found more combinations than answer! (logic error in SPJ)"); quitf(_ok, "Correct! Found %d unique combinations.", (int)ouf_sets.size()); return 0; }
三、 复杂度与安全性思考
- 文件大小控制:
- 在生成器中,对于 的大用例,我通过控制
min_val(cfg 数组的第三列)确保搜索树的深度不会过深,从而将解的数量控制在几千个以内。单文件大小约在 。
- 在生成器中,对于 的大用例,我通过控制
- 生成效率:
- 内置求解器使用了
sort+if (a[i] > target) break;的组合。在生成过程中,即使是 的点也几乎是秒出,不会出现超时。
- 内置求解器使用了
- 非递归与爆栈:
- 生成器使用了递归 DFS,但在 且 的情况下,递归深度最大只有 层;即便 ,深度也才 层。对于现代系统 1MB 的默认栈空间,这是极度安全的,不会爆栈。
- SPJ 鲁棒性:
- 使用了
seekEof()。它会跳过多余的空白符(空格、换行、制表符)。如果选手的输出末尾有多余的空行或空格,SPJ 不会报错“Expected EOF”。
- 使用了
四、 读题理解关键词 (NOI 辅导总结)
在做搜索题时,教学生如何通过关键词写出正确的“剪枝逻辑”:
- “无限重复选取”:看到它,脑中要浮现:
dfs(target - a[i], i),而不是i + 1。 - “正整数”:看到它,意味着 ,这是可行性剪枝(Sum只会增加)的前提。
- “唯一组合”:意味着你要控制搜索序(例如只能选下标 当前下标的数)。
- “所有解”:这几乎就是在明示你:本题主要考查DFS + 剪枝,而不是 DP。
教练点评: 这道题是搜索算法的“完全形态”。如果学生能写出排序后的
break剪枝,说明他已经跨过了 NOI 搜索题的第一道门槛。如果他在大 面前能保持 的递归深度而不迷失,他已经具备了省一的潜质。加油! - 文件大小控制:
-
0
同学你好。针对这道经典的搜索题,我将为你展示从“基础回溯”到“考场高分剪枝版”的代码演变过程。
在 NOI 竞赛中,搜索顺序和剪枝是拿到满分(AC)的生命线。
版本一:基础回溯版(逻辑雏形)
这个版本的思路是:对于每一个数字,我们不断尝试把它加入路径,直到和超过目标。为了去重,我们引入
begin变量,规定“不走回头路”。思考过程:
- 时间复杂度:指数级。搜索树的深度最深可达 (当序列中有
1时)。 - 空间复杂度:,主要是递归系统栈的消耗。
#include <iostream> #include <vector> using namespace std; int n, target; int a[35]; vector<int> path; // remain: 当前还需要凑出的差值 // begin: 为了去重,当前层递归只能从数组的第 begin 个元素开始往后选 void dfs(int remain, int begin) { // 递归边界:正好凑齐 if (remain == 0) { for (int i = 0; i < path.size(); ++i) { cout << path[i] << (i == path.size() - 1 ? "" : " "); } cout << "\n"; return; } for (int i = begin; i < n; ++i) { // 如果当前选的数没超过剩余需求,则尝试选取 if (remain >= a[i]) { path.push_back(a[i]); // 关键点:由于可以无限重复选,传给下一层的起始索引依然是 i,而不是 i+1 dfs(remain - a[i], i); path.pop_back(); // 回溯:将刚才选的数弹出,尝试本层的下一个 i } } } int main() { // NOI 竞赛常用:解绑 cin 以提升速度 ios::sync_with_stdio(false); cin.tie(0); if (!(cin >> n >> target)) return 0; for (int i = 0; i < n; ++i) cin >> a[i]; dfs(target, 0); return 0; }
版本二:最优复杂度——排序与结构化剪枝版(NOI 推荐做法)
在版本一中,即使
a[i]已经很大了,循环依然会继续跑。在 NOI 考场上,我们需要通过 排序 结合break来实现强力剪枝。思考过程:
- 剪枝逻辑:如果数组是有序的(升序),一旦发现
remain - a[i] < 0,说明当前的a[i]太大了,那么i之后的所有数字(只会更大)也一定不符合要求。 - 效率提升:直接跳出当前循环(
break),这比版本一的continue逻辑要节省大量的递归分支。
#include <iostream> #include <vector> #include <algorithm> // 必须包含排序算法 using namespace std; int n, target; int a[35]; vector<int> path; void dfs(int remain, int begin) { if (remain == 0) { for (int i = 0; i < path.size(); ++i) { cout << path[i] << (i == path.size() - 1 ? "" : " "); } cout << "\n"; return; } for (int i = begin; i < n; ++i) { // 【最优剪枝点】: // 如果当前数字已经大于剩余需求,因为数组是有序的, // 后面的数字肯定更不符合要求,直接结束本层循环。 if (remain < a[i]) break; path.push_back(a[i]); // 保持 i 以支持“无限选取” dfs(remain - a[i], i); path.pop_back(); // 回溯 } } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); if (!(cin >> n >> target)) return 0; for (int i = 0; i < n; ++i) cin >> a[i]; // 【重要步骤】:必须先排序,否则剪枝逻辑 break 无法成立 sort(a, a + n); dfs(target, 0); return 0; }
复杂度分析思考过程
- 时间复杂度分析:
- 这是一个典型的 NP完全问题 的搜索版。
- 最坏情况:当所有解的长度都接近 时。
- 实际表现:由于 且 的范围较大,搜索空间虽然理论上很大,但“可行解”的数量限制了搜索树的有效节点数。在有剪枝的情况下,复杂度接近于 。
- 空间复杂度分析:
- 递归深度:最大为 。如果序列中有 1,深度为 500 层。
- 栈空间:500 层的递归在 256MB 限制下完全没有压力(NOI 默认栈限制通常较大,或选手可手动开启)。
时间复杂度优化建议
- I/O 优化:本题输出量极大(可能有数万行结果)。使用
endl会导致严重的超时(TLE),因为它会不断刷新缓冲区。务必使用\n。 - 避免对象拷贝:在
dfs参数传递时,绝对不要直接传递vector。使用全局变量或vector的引用进行回溯是标准做法。 - 排序的方向:升序排序最有利于在
for循环中使用break。如果是降序,你只能使用continue,效率低很多。 - 去重策略:使用
begin索引控制搜索方向是最高效的去重方式。不要尝试搜出所有结果(包括 和 )后再用std::set去重,那样会浪费大量时间和空间。
读题关键词总结 (NOIP/NOI 专用)
做搜索题读题时,要像雷达一样扫描这些词汇:
- “无限制重复选取” 递归调用时,下一层的
begin设为i(当前位置),而不是i+1。 - “组合”而非“排列” 规定一个固定的搜索顺序(通常是下标递增),杜绝走回头路。
- “正整数” 确定了和是单调递增的,可以使用
remain < a[i]进行剪枝。 - “唯一组合” 再次提醒你, 和 只能算一个,必须控制搜索序。
教练点评: 这道题是“回溯法”进阶的必练题。掌握了版本二中的 排序 + 索引控制 + 越界 break,你就掌握了 NOI 搜索类题目中 80% 的提分技巧。加油!
- 时间复杂度:指数级。搜索树的深度最深可达 (当序列中有
- 1
信息
- ID
- 19431
- 时间
- 1000ms
- 内存
- 128MiB
- 难度
- 10
- 标签
- (无)
- 递交数
- 2
- 已通过
- 1
- 上传者