2 条题解

  • 0
    @ 2026-1-5 16:34:45

    同学,针对 组合总和 (Combination Sum) 的数据生成与评测,我们需要重点考虑“搜索空间爆炸”与“输出文件大小”的平衡。

    T=500T=500 的情况下,如果数组中包含较小的数(如 1,2,31, 2, 3),组合方案数会成千上万。为了满足你要求的 2MB 理想文件大小,我们在生成测试数据时会对 TTmin(A)\min(A) 的比例进行科学微调。


    一、 数据生成器 (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;
    }
    

    三、 复杂度与安全性思考

    1. 文件大小控制
      • 在生成器中,对于 T=500T=500 的大用例,我通过控制 min_val(cfg 数组的第三列)确保搜索树的深度不会过深,从而将解的数量控制在几千个以内。单文件大小约在 200KB1MB200KB \sim 1MB
    2. 生成效率
      • 内置求解器使用了 sort + if (a[i] > target) break; 的组合。在生成过程中,即使是 T=500T=500 的点也几乎是秒出,不会出现超时。
    3. 非递归与爆栈
      • 生成器使用了递归 DFS,但在 T=500T=500min(A)=50\min(A)=50 的情况下,递归深度最大只有 1010 层;即便 min(A)=2\min(A)=2,深度也才 250250 层。对于现代系统 1MB 的默认栈空间,这是极度安全的,不会爆栈
    4. SPJ 鲁棒性
      • 使用了 seekEof()。它会跳过多余的空白符(空格、换行、制表符)。如果选手的输出末尾有多余的空行或空格,SPJ 不会报错“Expected EOF”。

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

    在做搜索题时,教学生如何通过关键词写出正确的“剪枝逻辑”:

    • “无限重复选取”:看到它,脑中要浮现:dfs(target - a[i], i),而不是 i + 1
    • “正整数”:看到它,意味着 A[i]1A[i] \ge 1,这是可行性剪枝(Sum只会增加)的前提。
    • “唯一组合”:意味着你要控制搜索序(例如只能选下标 \ge 当前下标的数)。
    • “所有解”:这几乎就是在明示你:本题主要考查DFS + 剪枝,而不是 DP。

    教练点评: 这道题是搜索算法的“完全形态”。如果学生能写出排序后的 break 剪枝,说明他已经跨过了 NOI 搜索题的第一道门槛。如果他在大 TT 面前能保持 O(T)O(T) 的递归深度而不迷失,他已经具备了省一的潜质。加油!

    • 0
      @ 2026-1-5 16:32:23

      同学你好。针对这道经典的搜索题,我将为你展示从“基础回溯”到“考场高分剪枝版”的代码演变过程。

      在 NOI 竞赛中,搜索顺序剪枝是拿到满分(AC)的生命线。


      版本一:基础回溯版(逻辑雏形)

      这个版本的思路是:对于每一个数字,我们不断尝试把它加入路径,直到和超过目标。为了去重,我们引入 begin 变量,规定“不走回头路”。

      思考过程

      • 时间复杂度:指数级。搜索树的深度最深可达 TT(当序列中有 1 时)。
      • 空间复杂度O(T)O(T),主要是递归系统栈的消耗。
      #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;
      }
      

      复杂度分析思考过程

      1. 时间复杂度分析
        • 这是一个典型的 NP完全问题 的搜索版。
        • 最坏情况:当所有解的长度都接近 TT 时。
        • 实际表现:由于 T=500T=500A[i]A[i] 的范围较大,搜索空间虽然理论上很大,但“可行解”的数量限制了搜索树的有效节点数。在有剪枝的情况下,复杂度接近于 O(所有合法组合的元素总数)O(\text{所有合法组合的元素总数})
      2. 空间复杂度分析
        • 递归深度:最大为 T/min(A)T / \min(A)。如果序列中有 1,深度为 500 层。
        • 栈空间:500 层的递归在 256MB 限制下完全没有压力(NOI 默认栈限制通常较大,或选手可手动开启)。

      时间复杂度优化建议

      1. I/O 优化:本题输出量极大(可能有数万行结果)。使用 endl 会导致严重的超时(TLE),因为它会不断刷新缓冲区。务必使用 \n
      2. 避免对象拷贝:在 dfs 参数传递时,绝对不要直接传递 vector。使用全局变量或 vector 的引用进行回溯是标准做法。
      3. 排序的方向:升序排序最有利于在 for 循环中使用 break。如果是降序,你只能使用 continue,效率低很多。
      4. 去重策略:使用 begin 索引控制搜索方向是最高效的去重方式。不要尝试搜出所有结果(包括 {2,3}\{2,3\}{3,2}\{3,2\})后再用 std::set 去重,那样会浪费大量时间和空间。

      读题关键词总结 (NOIP/NOI 专用)

      做搜索题读题时,要像雷达一样扫描这些词汇:

      • “无限制重复选取” \rightarrow 递归调用时,下一层的 begin 设为 i(当前位置),而不是 i+1
      • “组合”而非“排列” \rightarrow 规定一个固定的搜索顺序(通常是下标递增),杜绝走回头路。
      • “正整数” \rightarrow 确定了和是单调递增的,可以使用 remain < a[i] 进行剪枝。
      • “唯一组合” \rightarrow 再次提醒你, {2,3}\{2, 3\}{3,2}\{3, 2\} 只能算一个,必须控制搜索序。

      教练点评: 这道题是“回溯法”进阶的必练题。掌握了版本二中的 排序 + 索引控制 + 越界 break,你就掌握了 NOI 搜索类题目中 80% 的提分技巧。加油!

      • 1

      信息

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