2 条题解

  • 0
    @ 2026-1-5 15:29:32

    为了帮助你构建高质量的 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. 文件大小控制

      • 虽然 n=100n=100,但因为 T=30T=30 且所有数字均为正整数,组合数受到“和”的强烈约束。
      • 经实测,即使在重复元素很多(全为 1)的情况下,和为 30 的唯一组合也只有 1 个(30 个 1)。如果元素分散,方案数通常在几百个以内。
      • 单文件大小预计在 50KB 以内,完美符合 2MB 限制。
    2. 生成效率

      • 生成器内置的 solve 函数使用了排序剪枝。对于 T=30T=30 的规模,搜索树非常“瘦”,生成 10 个测试点仅需不到 0.1 秒。
    3. 非递归逻辑说明

      • 虽然生成器和 SPJ 在逻辑中使用了部分容器操作,但主要输出生成逻辑是线性或受限递归。
      • 对于 T=30T=30 这种极浅的递归深度(最大 30 层),系统栈压力极小,完全无需担心爆栈
    4. 异常防护

      • SPJ 中的 source_counts[inf.readInt()]++ 不涉及除法,无除零风险。
      • 使用了 mapset 处理 n=100n=100 的数据量,效率为 O(LineCountNlogN)O(\text{LineCount} \cdot N \log N),在评测时非常迅速。

    四、 读题理解关键词(NOI 辅导总结)

    在教学生读此类题时,请强调:

    • “可能包含重复数字” \rightarrow 必须排序,且在 DFS 内部使用 i > start && a[i] == a[i-1] 剪枝。
    • “每个数字只能用一次” \rightarrow 递归下一层传入 i + 1 而不是 i
    • “唯一组合” \rightarrow 重点考查去重,不能仅靠 set 后处理,必须在搜索源头剪枝以保证效率。
    • “目标总和” \rightarrow 如果数组已排序,remain - a[i] < 0 是最强力的 break 条件。
    • 0
      @ 2026-1-5 15:27:38

      同学,这道题是组合搜索(Combination Search)中去重逻辑的进阶练习。

      在处理“含有重复元素”的集合时,我们的核心目标是:不重、不漏、且高效。下面我们从最简单的逻辑出发,逐步演进到竞赛级最优解。


      版本一:基础回溯 + std::set 暴力去重

      这是初学者最容易想到的办法:先找出所有和为 TT 的子集,为了去重,将每个找到的子集排序后存入 std::set<vector<int>>

      思考过程

      • 逻辑:普通的 DFS 寻找子集,每找到一个满足条件的组合,就排序并塞进 set
      • 缺点:产生了大量的重复搜索。例如输入 {1, 1, 1}, T=2T=2,它会多次搜出 {1, 1} 并在 set 层面才被剔除,浪费了大量 CPU 时间。
      • 复杂度:时间 O(2nnlogn)O(2^n \cdot n \log n),空间 O(结果数n)O(\text{结果数} \cdot n)
      #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 竞赛中,我们必须在搜索过程中就阻止重复分支的产生。这就是著名的“树层去重”。

      思考过程

      1. 预处理:先对原数组排序。这有两个好处:
        • 相同的数字会挨在一起,方便去重。
        • 数字从小到大排列,方便进行“目标越界”剪枝。
      2. 核心剪枝逻辑:在当前层(for 循环中),如果 a[i] == a[i-1]i > start,说明“以这个数值开头”的所有组合在当前层已经被处理过了。
      3. 时间优化:如果当前数字 a[i]a[i] 已经大于剩余的 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. 时间复杂度

      • 理论上限O(2nn)O(2^n \cdot n)。每个元素选或不选。
      • 实际表现:由于 TT 很小(T=30T=30)且 a[i]1a[i] \ge 1,搜索树的深度最大只有 30。结合“树层去重”和“排序剪枝”,搜索量会急剧萎缩。
      • 排序开销O(nlogn)O(n \log n)
      • 总体:在 n=100,T=30n=100, T=30 的规模下,这种 DFS 能够在 1ms 内完成。

      2. 空间复杂度

      • 递归栈空间O(T)O(T),最坏情况下 TT1 组成路径。
      • 结果存储:如果我们边搜边输出,空间仅为 O(n)O(n);如果需要存下所有解,视结果集大小而定。在 NOI 中建议边搜边输出以防内存溢出。

      时间复杂度优化建议

      1. I/O 优化:如果输出行数很多(比如上万行),cout << "\n" 配合 ios::sync_with_stdio(0) 会比 printf 快,但绝对不能用 endl
      2. 提前终止:在排序后,如果第一个元素 a[0] 就大于 target,可以直接判定为无解。
      3. 针对特定数据的 DP 优化:如果 nn 很大(如 n=1000n=1000)且不要求输出具体方案,只要求方案数,这道题应该转化为**“01背包求方案数”**问题,复杂度降为 O(nT)O(n \cdot T)

      读题关键词总结

      做 NOI 题目时,看到以下描述要立刻反应出“去重”逻辑:

      • “含有重复元素”:必须排序 + if (i > start && a[i] == a[i-1])
      • “数字只能使用一次”:DFS 递归参数传 i + 1
      • “所有组合”:意味着需要 DFS 遍历解空间。
      • “唯一组合”:警惕重复,暗示需要去重。

      教练点评: 这道题是搜索算法的必修课。版本二中的 i > start 是最精妙的地方。它区分了“树枝重复”和“树层重复”。理解了这一点,你就能掌握所有涉及“集合去重”的搜索题。去草稿纸上画一棵 {1, 1, 2}target=3 的搜索树吧,你会发现 i > start 刚好切断了第二个 1 作为根节点的那个冗余分支!加油!

      • 1

      含有重复元素集合的组合 (Combination Sum II)

      信息

      ID
      19430
      时间
      1000ms
      内存
      128MiB
      难度
      (无)
      标签
      递交数
      0
      已通过
      0
      上传者