2 条题解

  • 0
    @ 2026-1-5 9:31:15

    为了方便你创建 OJ(Online Judge)测试数据,我为你编写了一个基于 C++14 标准的数据生成器。该生成器会循环生成 10 组测试点,涵盖了从最小规模(n=1n=1)到最大规模(n=16n=16)以及包含负数、大整数等边界情况。

    数据生成器代码 (gen.cpp)

    该程序会自动在当前目录下生成 1.in, 1.out10.in, 10.out

    #include <iostream>
    #include <fstream>
    #include <vector>
    #include <string>
    #include <set>
    #include <random>
    #include <algorithm>
    
    using namespace std;
    
    // 解决逻辑:用于生成标准输出 (.out)
    void generate_output(int n, const vector<int>& nums, const string& out_name) {
        ofstream fout(out_name);
        // 使用位运算生成子集,确保逻辑稳健
        for (int i = 0; i < (1 << n); ++i) {
            bool first = true;
            for (int j = 0; j < n; ++j) {
                if ((i >> j) & 1) {
                    if (!first) fout << " ";
                    fout << nums[j];
                    first = false;
                }
            }
            fout << "\n"; // 输出子集,空集则输出一个空行
        }
        fout.close();
    }
    
    int main() {
        // 准备不同规模的 n
        int n_list[] = {1, 2, 3, 5, 8, 10, 12, 14, 16, 16};
        
        // 随机数生成器
        mt19937 rng(1337); // 固定种子保证数据可重复
        uniform_int_distribution<int> dist(-1e9, 1e9);
    
        for (int t = 1; t <= 10; ++t) {
            int n = n_list[t-1];
            string in_name = to_string(t) + ".in";
            string out_name = to_string(t) + ".out";
    
            // 1. 生成输入数据
            ofstream fin(in_name);
            fin << n << "\n";
            
            set<int> unique_elements;
            vector<int> nums;
            
            while (unique_elements.size() < n) {
                int val = dist(rng);
                if (unique_elements.find(val) == unique_elements.end()) {
                    unique_elements.insert(val);
                    nums.push_back(val);
                }
            }
    
            // 特殊处理:样例数据放入第3个测试点
            if (t == 3) {
                nums = {1, 2, 3};
            }
    
            for (int i = 0; i < n; ++i) {
                fin << nums[i] << (i == n - 1 ? "" : " ");
            }
            fin << "\n";
            fin.close();
    
            // 2. 生成对应的标准输出
            generate_output(n, nums, out_name);
            
            cout << "Generated Test Case " << t << ": n=" << n << endl;
        }
    
        return 0;
    }
    

    测试点设计说明

    测试点编号 nn 规模 数据特征 目的
    1 n=1n=1 最小规模边界 检查对仅包含一个元素集合的处理
    2 n=2n=2 极小规模 检查基础逻辑
    3 n=3n=3 样例数据 确保通过题目给出的样例
    4-5 n=58n=5 \sim 8 中等规模 验证递归深度和路径保存
    6-8 n=1214n=12 \sim 14 较大规模 验证程序的时间常数
    9 n=16n=16 最大规模 满负荷测试,检查是否超时(2162^{16} 行输出)
    10 包含大负数 确保 int 类型处理正确且数据不重复

    如何使用此生成器

    1. 环境准备:确保你的环境支持 C++11 或 C++14(NOI 现行标准)。
    2. 编译
      g++ gen.cpp -o gen -O2 -std=c++14
      
    3. 运行
      ./gen
      
      执行后,文件夹下会出现 1.in, 1.out ... 10.out 共 20 个文件。

    OJ 配置建议

    1. 比较器 (Checker):由于题目要求“子集顺序不作要求”,如果你的 OJ 支持 Special Judge (SPJ),建议编写一个简单的 SPJ。
      • 如果不使用 SPJ:请告知选手必须按照某种特定顺序输出(例如位运算顺序或 DFS 顺序),或者在后台使用 diff -b -B -w 忽略行末空格和空行差异。
    2. 输出限制:注意 n=16n=16 时输出文件约为几兆字节,请确保 OJ 的 Output Limit 设置在 128MB 以上,避免因输出文件过大导致 OLE (Output Limit Exceeded)
    3. 时间限制:由于主要耗时在 I/O(打印 2162^{16} 行),建议时限设为 1.0s,内存设为 256MB
    • 0
      @ 2026-1-5 9:29:24

      在 NOI 竞赛中,虽然 n=16n=16 的数据范围非常小,但理解从“搜索”到“状态压缩”的演变是极其重要的。下面我将为你演示从最基础的递归回溯,到竞赛中更常用的位运算(状态压缩)的代码实现。


      版本一:基础回溯(选与不选决策树)

      这是最直观的 DFS,每个元素只有“选”或“不选”两种状态。

      思考过程

      • 时间复杂度:每个位置有两种选择,总共有 2n2^n 个状态。每个状态在递归边界需要 O(n)O(n) 的时间进行输出。总时间 O(n2n)O(n \cdot 2^n)
      • 空间复杂度:递归深度为 O(n)O(n),临时存储路径的 path 空间为 O(n)O(n)
      #include <iostream>
      #include <vector>
      
      using namespace std;
      
      int n;
      int nums[20];
      vector<int> path;
      
      // u 表示当前处理到第几个元素
      void dfs(int u) {
          // 易错点:递归边界,当处理完所有元素时输出
          if (u == n) {
              for (int i = 0; i < path.size(); ++i) {
                  cout << path[i] << (i == path.size() - 1 ? "" : " ");
              }
              cout << endl; // 输出空行代表空集或行末换行
              return;
          }
      
          // 分支 1:不选择当前的 nums[u]
          dfs(u + 1);
      
          // 分支 2:选择当前的 nums[u]
          path.push_back(nums[u]);
          dfs(u + 1);
          
          // 关键点:回溯,恢复现场,保证对后续决策不产生影响
          path.pop_back();
      }
      
      int main() {
          // 竞赛建议:优化输入输出效率
          ios::sync_with_stdio(false);
          cin.tie(0);
      
          if (!(cin >> n)) return 0;
          for (int i = 0; i < n; ++i) {
              cin >> nums[i];
          }
      
          dfs(0);
      
          return 0;
      }
      

      版本二:组合式搜索(按起始位置枚举)

      这种写法在处理“组合”问题时更常见,通过限制下一次搜索的起始位置来避免重复。

      思考过程

      • 时间复杂度O(n2n)O(n \cdot 2^n)
      • 空间复杂度O(n)O(n)
      • 差异:这种方式每进入一次 dfs 函数就会直接记录一个结果,而不是等到递归到底部。
      #include <iostream>
      #include <vector>
      
      using namespace std;
      
      int n;
      int nums[20];
      vector<int> path;
      
      void dfs(int start) {
          // 每进入一个节点,当前的 path 都是一个合法的子集
          for (int i = 0; i < path.size(); ++i) {
              cout << path[i] << (i == path.size() - 1 ? "" : " ");
          }
          cout << "\n";
      
          // 关键点:从 start 开始往后找,避免选到重复组合
          for (int i = start; i < n; ++i) {
              path.push_back(nums[i]);
              dfs(i + 1); 
              path.pop_back(); // 回溯
          }
      }
      
      int main() {
          ios::sync_with_stdio(false); cin.tie(0);
          cin >> n;
          for (int i = 0; i < n; ++i) cin >> nums[i];
      
          dfs(0);
          return 0;
      }
      

      版本三:最优解——位运算状态压缩 (Bitmask)

      在 NOI 竞赛中,如果 nn 较小(通常 n20n \le 20),直接使用位运算枚举子集是最简洁且常数最小的方法。

      思考过程

      • 核心逻辑:一个长度为 nn 的集合有 2n2^n 个子集。我们可以用 002n12^n-1 的整数来代表每个子集。
      • 位运算技巧:整数 ii 的二进制表示中,如果第 jj 位是 11,则代表选中 nums[j]
      • 时间复杂度O(n2n)O(n \cdot 2^n)
      • 空间复杂度O(1)O(1)(除去存储输入的数组),没有递归开销。
      #include <iostream>
      
      using namespace std;
      
      int main() {
          int n;
          int nums[20];
          
          if (!(cin >> n)) return 0;
          for (int i = 0; i < n; ++i) cin >> nums[i];
      
          // 1 << n 相当于 2的n次方
          // i 遍历从 0 到 2^n - 1 的每一个状态
          for (int i = 0; i < (1 << n); ++i) {
              bool first = true;
              for (int j = 0; j < n; ++j) {
                  // 易错点:检查第 j 位是否为 1
                  // (i >> j) & 1 是判断 i 的二进制第 j 位是否为 1 的常用写法
                  if ((i >> j) & 1) {
                      if (!first) cout << " ";
                      cout << nums[j];
                      first = false;
                  }
              }
              cout << "\n"; // 每个状态对应一行
          }
      
          return 0;
      }
      

      复杂度分析与优化建议总结

      1. 时间复杂度分析

      • 计算状态数:对于 nn 个元素,每个元素有“在”或“不在”两种状态,总状态数为 2×2××2=2n2 \times 2 \times \dots \times 2 = 2^n
      • 单次操作:无论是递归还是位运算,构造一个子集并输出都需要遍历 nn 个元素。
      • 总复杂度O(n2n)O(n \cdot 2^n)
      • 数据量评估:当 n=16n=16 时,216=655362^{16} = 65536,乘以 1616 约为 10610^6 次操作。在 1.0s 时限内绰绰有余(NOI 机器通常每秒处理 10810^8 次运算)。

      2. 空间复杂度分析

      • 递归版O(n)O(n),主要是系统栈空间和 path 数组。
      • 位运算版O(1)O(1),不需要额外空间。
      • 内存建议:在 NOI 中,如果 nn 很大(如 n=25n=25),不要试图把所有子集存在 vector<vector<int>> 里,否则会造成 MLE (Memory Limit Exceeded)

      3. 性能优化建议

      1. I/O 优化:子集问题的输出量非常大。使用 cout << endl 会频繁刷新缓冲区,导致效率低下。建议使用 \n 或者 printf
      2. 位运算优势:位运算版本由于没有函数调用的压栈开销,常数项极小。在 nn 接近 20-22 的压测环境下,位运算优势明显。
      3. 剪枝思考:本题要求输出所有子集,因此无法剪枝。但如果是“求和为 K 的子集”,则可以在递归中加入 if (current_sum > K) return; 这样的剪枝逻辑。

      教练提示:在考场上,如果 nn 很小,位运算是你的首选,因为它代码量少且不易出错。如果题目要求按特定顺序(如字典序)输出,记得先对原数组 sort 一下。

      • 1

      信息

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