2 条题解
-
0
作为金牌教练,在制作 OJ(Online Judge)数据时,我们追求的是数据的严谨性和全面性。
对于《组合总和 III》,由于数字范围固定在 ,其实际状态空间极小(最大组合数 )。因此,数据生成的重点在于覆盖无解情况、单一解情况以及解数最多的情况。
一、 数据生成器 (gen.cpp)
为了保证生成器在任何环境下都绝不爆栈,我使用了位运算(Bitmask)状态压缩的方法来替代递归。这种非递归写法逻辑简单且性能极高。
#include <iostream> #include <fstream> #include <vector> #include <string> #include <algorithm> using namespace std; // 使用位运算非递归求解,生成标准答案 void generate_output(int k, int n, const string& out_name) { ofstream fout(out_name); vector<vector<int>> results; // 1 << 9 遍历 1-9 数字的所有子集状态 (共 512 种) for (int i = 0; i < (1 << 9); ++i) { vector<int> current_path; int current_sum = 0; for (int j = 0; j < 9; ++j) { // 如果第 j 位为 1,代表选取数字 j + 1 if ((i >> j) & 1) { current_path.push_back(j + 1); current_sum += (j + 1); } } // 判定条件:个数为 k 且和为 n if (current_path.size() == k && current_sum == n) { results.push_back(current_path); } } // 为了让 .out 文件有序,进行字典序排序 sort(results.begin(), results.end()); for (const auto& row : results) { for (int i = 0; i < row.size(); ++i) { fout << row[i] << (i == row.size() - 1 ? "" : " "); } fout << "\n"; } fout.close(); } int main() { // 测试点设计 int ks[] = {2, 3, 2, 3, 3, 9, 9, 4, 2, 3}; int ns[] = {3, 1, 20, 7, 9, 45, 46, 20, 17, 24}; for (int t = 1; t <= 10; ++t) { int k = ks[t - 1]; int n = ns[t - 1]; string in_name = to_string(t) + ".in"; string out_name = to_string(t) + ".out"; // 生成输入文件 ofstream fin(in_name); fin << k << " " << n << endl; fin.close(); // 生成输出文件 generate_output(k, n, out_name); cout << "Test Case " << t << " generated: k=" << k << ", n=" << n << endl; } return 0; }
二、 特判程序 (checker.cpp)
使用竞赛标准的
testlib.h风格编写。它会验证:- 每行数字个数是否为 。
- 数字是否都在 之间且不重复。
- 数字之和是否为 。
- 是否找全了所有解,且没有输出重复的解。
#include "testlib.h" #include <vector> #include <set> #include <numeric> #include <algorithm> using namespace std; /** * SPJ 逻辑说明: * 1. 从 ans 读取所有标准解存入 set * 2. 从 ouf 读取选手解,验证合法性并存入 set * 3. 对比两个 set 是否完全一致 */ int main(int argc, char* argv[]) { registerTestlibCmd(argc, argv); // 1. 获取输入数据 int k = inf.readInt(); int n = inf.readInt(); // 2. 读取标准答案 set<set<int>> ans_sets; while (!ans.seekEof()) { set<int> combo; for (int i = 0; i < k; ++i) { combo.insert(ans.readInt()); } ans_sets.insert(combo); } // 3. 读取并校验选手答案 set<set<int>> ouf_sets; int line_count = 0; // 使用 seekEof 跳过末尾空白,防止 Expected EOF 错误 while (!ouf.seekEof()) { line_count++; set<int> combo; long long current_sum = 0; for (int i = 0; i < k; ++i) { // readInt(min, max, name) 自动校验范围 int val = ouf.readInt(1, 9, "element of combination"); if (combo.count(val)) { quitf(_wa, "Line %d: Duplicate number %d in a single combination", line_count, val); } combo.insert(val); current_sum += val; } if (current_sum != n) { quitf(_wa, "Line %d: Sum of elements is %lld, expected %d", line_count, current_sum, n); } if (ouf_sets.count(combo)) { quitf(_wa, "Duplicate combination found at line %d", line_count); } if (ans_sets.find(combo) == ans_sets.end()) { quitf(_wa, "Combination at line %d is valid but not a required solution", line_count); } ouf_sets.insert(combo); } // 4. 检查解是否找全 if (ouf_sets.size() < ans_sets.size()) { quitf(_wa, "Solutions missing: expected %d, found %d", (int)ans_sets.size(), (int)ouf_sets.size()); } // 如果选手输出的每一个都在 ans_sets 里,且数量一致且不重,则必定正确 quitf(_ok, "Correct! Found all %d combinations.", (int)ans_sets.size()); return 0; }
三、 测试点设计理由
编号 设计意图 预期结果 1 2, 3 最小规模边界 1 22 3, 1 和太小,无解 (空) 3 2, 20 和太大(最大 17),无解 4 3, 7 样例 1 1 2 45 3, 9 较多解测试 1 2 6,1 3 5,2 3 46 9, 45 最大 边界 1 2 3 4 5 6 7 8 97 9, 46 最大 无解边界 (空) 8 4, 20 最大组合数附近 较多解 (约 10+ 组) 9 2, 17 包含最大数字 9 的解 8 910 3, 24 大数值组合 7 8 9四、 复杂度分析与生成优化建议
- 时间复杂度:
- 生成器采用 暴力迭代,复杂度 ,单次生成时间 。
- SPJ 采用
set<set<int>>,处理最大 126 行解,性能极高。
- 空间复杂度:
- 完全非递归,不存在爆栈风险。
- 使用
set进行校验,空间复杂度 ,在 规模下内存占用不足 。
- 文件大小:
- 最大解集为 行,每行约 20 字节。单文件大小约 ,10 个测试点总大小约 ,远低于 2MB。
- 鲁棒性:
- SPJ 使用
readInt(1, 9)和seekEof()。这能防止选手输出非数字字符或在文件末尾多打几个换行符而导致的误判。
- SPJ 使用
教练寄语: 同学,这套数据系统虽然简单,但它涵盖了从“无解”到“边界”的思维全景。在 NOI 中,位运算状态压缩通常是处理这种小范围选择题的“杀手锏”。希望你能通过这些数据,把回溯和去重的每一个细节都打磨通透!加油!
-
0
同学你好!这道题在 LeetCode 上的数据范围很小(1-9),但在 NOI 竞赛中,理解搜索空间的修剪(Pruning)以及从子集枚举到组合枚举的逻辑转化是非常关键的。
下面我按算法演进的思路为你提供三个版本的代码。
版本一:基础回溯(子集枚举思路)
最直观的想法是对 1 到 9 这九个数字,每个数字都面临“选”或“不选”两种抉择。当我们决策完 9 个数字后,检查选出的数字个数是否为 ,总和是否为 。
思考过程:
- 状态空间:相当于枚举 的所有子集,共有 种可能。
- 易错点:递归边界的处理,以及回溯时
path的状态恢复。
#include <iostream> #include <vector> using namespace std; int K, N; vector<int> path; // cur: 当前考虑的数字 (1-9) // count: 已选取的数字个数 // sum: 已选取的数字总和 void dfs(int cur, int count, int sum) { // 基础剪枝:如果已经选了超过 K 个数或者总和已经超了,直接返回 if (count > K || sum > N) return; // 到达递归边界:考虑完了 1 到 9 if (cur == 10) { if (count == K && sum == N) { for (int i = 0; i < path.size(); ++i) { cout << path[i] << (i == path.size() - 1 ? "" : " "); } cout << "\n"; } return; } // 决策 1:选取当前的数字 cur path.push_back(cur); dfs(cur + 1, count + 1, sum + cur); path.pop_back(); // 回溯:关键操作,恢复现场 // 决策 2:不选取当前的数字 cur dfs(cur + 1, count, sum); } int main() { ios::sync_with_stdio(false); cin.tie(0); if (!(cin >> K >> N)) return 0; dfs(1, 0, 0); return 0; }
版本二:标准组合搜索(按位置枚举)
在竞赛中,更常用的写法是使用
for循环枚举“下一个要选谁”。通过start变量限制选取的数字必须递增,从而天然地避免了重复组合。思考过程:
- 逻辑:每一层递归决定组合中的“第 个位置”放哪个数。
- 复杂度分析:时间复杂度 。空间复杂度 。
#include <iostream> #include <vector> using namespace std; int K, N; vector<int> path; void dfs(int start, int sum) { // 如果已经选够了 K 个数 if (path.size() == K) { if (sum == N) { for (int i = 0; i < K; ++i) cout << path[i] << (i == K - 1 ? "" : " "); cout << "\n"; } return; } // 从 start 开始尝试,保证选出的序列是递增的,从而去重 for (int i = start; i <= 9; ++i) { // 剪枝:如果当前数字已经让和超过 N,则没必要往后看(因为 i 是递增的) if (sum + i > N) break; path.push_back(i); dfs(i + 1, sum + i); path.pop_back(); // 回溯 } } int main() { ios::sync_with_stdio(false); cin.tie(0); if (!(cin >> K >> N)) return 0; dfs(1, 0); return 0; }
版本三:NOI 最优剪枝版(引入数学边界)
虽然 1-9 的范围极小,但如果是 1-100,我们就需要更强的剪枝。我们可以计算:如果把后面剩下的数字全选最大的,或者全选最小的,是否还能达到目标。
剪枝核心:
- 剩余数量不足:如果还没选够 个,但剩下的可选数字个数已经不够凑齐 个了。
- 剩余和无法达到:即便把后面剩下的最小 个数选上,和也超过 ;或者选上最大的,和也达不到 。
#include <iostream> #include <vector> using namespace std; int K, N; vector<int> path; void dfs(int start, int sum) { int need = K - path.size(); // 递归边界 if (need == 0) { if (sum == N) { for (int i = 0; i < K; ++i) cout << path[i] << (i == K - 1 ? "" : " "); cout << "\n"; } return; } // 强力剪枝 1:剩余可选数字个数不足 if (9 - start + 1 < need) return; // 循环上界剪枝:后面剩下的数如果全选上最大的也达不到目标,则退出 // 这里简单实现:i 的上界可以优化为 9 - need + 1 for (int i = start; i <= 9 - need + 1; ++i) { // 强力剪枝 2:当前和已经超标 if (sum + i > N) break; path.push_back(i); dfs(i + 1, sum + i); path.pop_back(); } } int main() { ios::sync_with_stdio(false); cin.tie(0); if (!(cin >> K >> N)) return 0; // 边界检查:k 个数最小和为 (1+k)*k/2,最大和为 (19-k)*k/2 if (N < (1 + K) * K / 2 || N > (19 - K) * K / 2) return 0; dfs(1, 0); return 0; }
复杂度与优化建议总结
1. 时间复杂度分析思考过程
- 状态数量:本题是在 9 个元素中选 个,总状态数是组合数 。
- 单次操作:构造答案和输出需要 。
- 总复杂度:。
- 常数项:由于 的范围限制和剪枝,实际执行的函数调用次数远少于 。
2. 空间复杂度分析
- 递归深度:,在本题中最大为 9。
- 辅助空间:
path数组 。 - 总体:,在 NOI 256MB 的环境下完全可以忽略。
3. 性能优化建议
- 位运算(Bitmask):由于总数只有 9 个,可以枚举 到 的所有整数,判断二进制位中 1 的个数是否为 ,且对应的数字之和是否为 。这在 较小时代码最简洁。
- I/O 优化:如果输出数据量大,建议使用
"\n"代替std::endl。 - 预处理:如果 很大,可以使用动态规划预处理方案数,但本题要求输出具体方案,DFS 仍是首选。
读题关键词总结
- “ 个数”:暗示搜索深度固定为 。
- “1 到 9”:暗示搜索范围极窄,大概率是暴力搜索或状态压缩。
- “每个数字最多使用一次”:意味着这是组合问题,递归时起始位置要
+1。 - “不包含重复组合”:再次提醒必须按照递增顺序搜索,不能回头选之前的数。
教练寄语: 同学,组合总和 III 是最纯粹的限定深度搜索。虽然 LeetCode 上的 让你感觉不到压力,但在 NOI 中,你要学会利用“剩余可选个数”去优化循环上界。这几行代码的优化,往往就是省一和省二的分水岭!加油。
- 1
信息
- ID
- 19436
- 时间
- 1000ms
- 内存
- 128MiB
- 难度
- 10
- 标签
- (无)
- 递交数
- 1
- 已通过
- 1
- 上传者