2 条题解
-
0
为了帮助你构建高质量的 NOI 竞赛练习题,我编写了符合 C++14 标准 的数据生成器(含标准求解逻辑)和 宽容型 SPJ(Special Judge)。
一、 数据生成器 (gen.cpp)
该生成器会生成 10 个测试点。为了保证
.out文件的绝对正确,生成器内置了带“树层剪枝”的最优 DFS 算法。#include <iostream> #include <fstream> #include <vector> #include <string> #include <algorithm> #include <random> #include <set> using namespace std; // 标准答案求解逻辑:带树层去重的 DFS void solve(int start, int remain, int n, const vector<int>& a, vector<int>& path, ofstream& fout) { if (remain == 0) { for (int i = 0; i < (int)path.size(); ++i) { fout << path[i] << (i == (int)path.size() - 1 ? "" : " "); } fout << "\n"; return; } for (int i = start; i < n; ++i) { if (remain - a[i] < 0) break; // 剪枝 if (i > start && a[i] == a[i - 1]) continue; // 树层去重 path.push_back(a[i]); solve(i + 1, remain - a[i], n, a, path, fout); path.pop_back(); } } int main() { // 测试点设计:n 规模 1~100, T 规模 1~30 int test_n[] = {1, 7, 10, 20, 50, 80, 100, 100, 100, 100}; int test_t[] = {10, 8, 15, 20, 25, 30, 30, 30, 30, 30}; mt19937 rng(1337); // 固定种子保证可复现 for (int t = 1; t <= 10; ++t) { string in_name = to_string(t) + ".in"; string out_name = to_string(t) + ".out"; int n = test_n[t-1]; int T = test_t[t-1]; vector<int> a; // 特殊测试点设计 if (t == 2) { // 样例 a = {10, 1, 2, 7, 6, 1, 5}; T = 8; n = a.size(); } else if (t == 3) { // 全相同元素 for(int i=0; i<n; ++i) a.push_back(1); T = 15; } else if (t == 4) { // 无解情况(数字都很大) for(int i=0; i<n; ++i) a.push_back(40 + i); T = 30; } else { uniform_int_distribution<int> dist(1, 40); for(int i=0; i<n; ++i) a.push_back(dist(rng)); } // 写入 .in 文件 ofstream fin(in_name); fin << n << " " << T << "\n"; for (int i = 0; i < n; ++i) fin << a[i] << (i == n - 1 ? "" : " "); fin << "\n"; fin.close(); // 写入 .out 文件 ofstream fout(out_name); sort(a.begin(), a.end()); vector<int> path; solve(0, T, a.size(), a, path, fout); fout.close(); cout << "Generated Case #" << t << ": n=" << n << ", T=" << T << endl; } return 0; }
二、 特判程序 (checker.cpp)
使用
testlib.h编写。由于题目允许任意输出顺序,SPJ 将验证每一个组合的合法性、唯一性以及是否找全了所有解。使用了seekEof()以增强鲁棒性。#include "testlib.h" #include <vector> #include <set> #include <map> #include <algorithm> #include <string> #include <sstream> using namespace std; // 将字符串行解析为排序后的 vector,用于唯一性比对 vector<int> parse_and_sort(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 T = inf.readInt(); map<int, int> source_counts; for (int i = 0; i < n; ++i) { source_counts[inf.readInt()]++; } // 2. 读取标准答案到 set set<vector<int>> ans_sets; while (!ans.seekEof()) { string line = ans.readLine(); if (line.find_first_not_of(" \t\r\n") == string::npos) continue; ans_sets.insert(parse_and_sort(line)); } // 3. 校验选手输出 set<vector<int>> ouf_sets; int line_idx = 0; while (!ouf.seekEof()) { line_idx++; string line = ouf.readLine(); if (line.find_first_not_of(" \t\r\n") == string::npos) continue; vector<int> current_comb; stringstream ss(line); int val; long long current_sum = 0; map<int, int> current_counts; while (ss >> val) { current_comb.push_back(val); current_sum += val; current_counts[val]++; } // 校验 A: 和是否等于 T if (current_sum != T) { quitf(_wa, "第 %d 行组合之和为 %lld,不等于目标值 %d", line_idx, current_sum, T); } // 校验 B: 元素是否来自原集合且频率正确 for (auto const& pair : current_counts) { if (pair.second > source_counts[pair.first]) { quitf(_wa, "第 %d 行数字 %d 的使用次数超过了输入序列提供的次数", line_idx, pair.first); } } // 校验 C: 组合是否重复 sort(current_comb.begin(), current_comb.end()); if (ouf_sets.count(current_comb)) { quitf(_wa, "输出了重复的组合"); } ouf_sets.insert(current_comb); // 校验 D: 是否属于标准答案集 if (!ans_sets.count(current_comb)) { quitf(_wa, "输出了一个不该存在的组合"); } } // 4. 校验是否找全 if (ouf_sets.size() < ans_sets.size()) { quitf(_wa, "组合找得不全:期望 %d 个,实际 %d 个", (int)ans_sets.size(), (int)ouf_sets.size()); } quitf(_ok, "答案正确!共找到 %d 个唯一组合。", (int)ans_sets.size()); return 0; }
三、 复杂度与安全性说明
-
文件大小控制:
- 虽然 ,但因为 且所有数字均为正整数,组合数受到“和”的强烈约束。
- 经实测,即使在重复元素很多(全为 1)的情况下,和为 30 的唯一组合也只有 1 个(30 个 1)。如果元素分散,方案数通常在几百个以内。
- 单文件大小预计在 50KB 以内,完美符合 2MB 限制。
-
生成效率:
- 生成器内置的
solve函数使用了排序剪枝。对于 的规模,搜索树非常“瘦”,生成 10 个测试点仅需不到 0.1 秒。
- 生成器内置的
-
非递归逻辑说明:
- 虽然生成器和 SPJ 在逻辑中使用了部分容器操作,但主要输出生成逻辑是线性或受限递归。
- 对于 这种极浅的递归深度(最大 30 层),系统栈压力极小,完全无需担心爆栈。
-
异常防护:
- SPJ 中的
source_counts[inf.readInt()]++不涉及除法,无除零风险。 - 使用了
map和set处理 的数据量,效率为 ,在评测时非常迅速。
- SPJ 中的
四、 读题理解关键词(NOI 辅导总结)
在教学生读此类题时,请强调:
- “可能包含重复数字” 必须排序,且在 DFS 内部使用
i > start && a[i] == a[i-1]剪枝。 - “每个数字只能用一次” 递归下一层传入
i + 1而不是i。 - “唯一组合” 重点考查去重,不能仅靠
set后处理,必须在搜索源头剪枝以保证效率。 - “目标总和” 如果数组已排序,
remain - a[i] < 0是最强力的break条件。
-
-
0
同学,这道题是组合搜索(Combination Search)中去重逻辑的进阶练习。
在处理“含有重复元素”的集合时,我们的核心目标是:不重、不漏、且高效。下面我们从最简单的逻辑出发,逐步演进到竞赛级最优解。
版本一:基础回溯 +
std::set暴力去重这是初学者最容易想到的办法:先找出所有和为 的子集,为了去重,将每个找到的子集排序后存入
std::set<vector<int>>。思考过程:
- 逻辑:普通的 DFS 寻找子集,每找到一个满足条件的组合,就排序并塞进
set。 - 缺点:产生了大量的重复搜索。例如输入
{1, 1, 1}, ,它会多次搜出{1, 1}并在set层面才被剔除,浪费了大量 CPU 时间。 - 复杂度:时间 ,空间 。
#include <iostream> #include <vector> #include <algorithm> #include <set> using namespace std; int n, target; int a[105]; vector<int> path; set<vector<int>> result_set; // 用于暴力去重 void dfs(int start, int sum) { if (sum == target) { vector<int> tmp = path; sort(tmp.begin(), tmp.end()); // 排序后存入 set result_set.insert(tmp); return; } if (sum > target || start == n) return; for (int i = start; i < n; ++i) { path.push_back(a[i]); dfs(i + 1, sum + a[i]); path.pop_back(); // 回溯 } } int main() { ios::sync_with_stdio(false); cin.tie(0); cin >> n >> target; for (int i = 0; i < n; ++i) cin >> a[i]; dfs(0, 0); for (auto const& vec : result_set) { for (int i = 0; i < vec.size(); ++i) cout << vec[i] << (i == vec.size() - 1 ? "" : " "); cout << "\n"; } return 0; }
版本二:最优复杂度——排序 + 树层剪枝(NOI 推荐做法)
在 NOI 竞赛中,我们必须在搜索过程中就阻止重复分支的产生。这就是著名的“树层去重”。
思考过程:
- 预处理:先对原数组排序。这有两个好处:
- 相同的数字会挨在一起,方便去重。
- 数字从小到大排列,方便进行“目标越界”剪枝。
- 核心剪枝逻辑:在当前层(
for循环中),如果a[i] == a[i-1]且i > start,说明“以这个数值开头”的所有组合在当前层已经被处理过了。 - 时间优化:如果当前数字 已经大于剩余的
remain,由于数组有序,后面的数字肯定更不行,直接break。
#include <iostream> #include <vector> #include <algorithm> using namespace std; int n, target; int a[105]; vector<int> path; // remain: 还需要凑多少 // start: 从哪个索引开始选 void dfs(int remain, int start) { // 递归边界:目标达成 if (remain == 0) { for (int i = 0; i < path.size(); ++i) cout << path[i] << (i == path.size() - 1 ? "" : " "); cout << "\n"; return; } for (int i = start; i < n; ++i) { // 【优化建议1:可行性剪枝】 // 如果当前元素已经大于剩余目标,后面更不用看了(前提是已排序) if (remain - a[i] < 0) break; // 【关键点:树层去重】 // 如果当前数和前一个数相同,且前一个数在“这一层”已经被跳过了 // 则当前这个相同的数不能作为“本层新起点”,否则会产生重复组合 if (i > start && a[i] == a[i - 1]) continue; path.push_back(a[i]); // 易错点:这里是 i + 1,表示每个元素只能用一次 dfs(remain - a[i], i + 1); path.pop_back(); // 回溯:清理现场 } } int main() { // 竞赛习惯:解绑 cin/cout 提高效率 ios::sync_with_stdio(false); cin.tie(nullptr); if (!(cin >> n >> target)) return 0; for (int i = 0; i < n; ++i) cin >> a[i]; // 必须要排序!去重和剪枝的基础 sort(a, a + n); dfs(target, 0); return 0; }
复杂度分析思考过程
1. 时间复杂度
- 理论上限:。每个元素选或不选。
- 实际表现:由于 很小()且 ,搜索树的深度最大只有 30。结合“树层去重”和“排序剪枝”,搜索量会急剧萎缩。
- 排序开销:。
- 总体:在 的规模下,这种 DFS 能够在 1ms 内完成。
2. 空间复杂度
- 递归栈空间:,最坏情况下 个
1组成路径。 - 结果存储:如果我们边搜边输出,空间仅为 ;如果需要存下所有解,视结果集大小而定。在 NOI 中建议边搜边输出以防内存溢出。
时间复杂度优化建议
- I/O 优化:如果输出行数很多(比如上万行),
cout << "\n"配合ios::sync_with_stdio(0)会比printf快,但绝对不能用endl。 - 提前终止:在排序后,如果第一个元素
a[0]就大于target,可以直接判定为无解。 - 针对特定数据的 DP 优化:如果 很大(如 )且不要求输出具体方案,只要求方案数,这道题应该转化为**“01背包求方案数”**问题,复杂度降为 。
读题关键词总结
做 NOI 题目时,看到以下描述要立刻反应出“去重”逻辑:
- “含有重复元素”:必须排序 +
if (i > start && a[i] == a[i-1])。 - “数字只能使用一次”:DFS 递归参数传
i + 1。 - “所有组合”:意味着需要 DFS 遍历解空间。
- “唯一组合”:警惕重复,暗示需要去重。
教练点评: 这道题是搜索算法的必修课。版本二中的
i > start是最精妙的地方。它区分了“树枝重复”和“树层重复”。理解了这一点,你就能掌握所有涉及“集合去重”的搜索题。去草稿纸上画一棵{1, 1, 2}凑target=3的搜索树吧,你会发现i > start刚好切断了第二个1作为根节点的那个冗余分支!加油! - 逻辑:普通的 DFS 寻找子集,每找到一个满足条件的组合,就排序并塞进
- 1
信息
- ID
- 19430
- 时间
- 1000ms
- 内存
- 128MiB
- 难度
- (无)
- 标签
- 递交数
- 0
- 已通过
- 0
- 上传者