2 条题解

  • 0
    @ 2025-12-30 14:58:30

    为了方便你在 OJ(Online Judge)上创建测试数据,我为你编写了一个完整的数据生成器。该程序会自动生成符合 NOI 竞赛要求的 1.in ~ 10.in 以及对应的 1.out ~ 10.out 文件。

    数据生成逻辑设计

    1. 覆盖范围
      • 测试点 1-2:边界情况(n=0,n=1n=0, n=1)。
      • 测试点 3-4:特殊形态(星形树、极深链形树)。
      • 测试点 5-6:完全多叉树(平衡结构)。
      • 测试点 7-10:大规模随机树(n=104n=10^4),严格控制树高 1000\le 1000
    2. 安全性
      • 使用 std::stack 进行非递归遍历生成标准答案,防止生成器自身爆栈。
      • 使用 std::mt19937 提高随机性。
      • 严格遵循 N104N \le 10^4 的限制,确保 .in 文件大小适中。

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

    #include <iostream>
    #include <fstream>
    #include <vector>
    #include <string>
    #include <random>
    #include <algorithm>
    #include <stack>
    #include <ctime>
    
    using namespace std;
    
    // 题目约束
    const int MAXN = 10000;
    const int MAXVAL = 10000;
    const int MAXHEIGHT = 1000;
    
    struct Node {
        int val;
        vector<int> children;
    };
    
    // 使用非递归方式生成前序遍历序列 (标准答案)
    vector<int> get_preorder(int n, const vector<Node>& tree) {
        vector<int> res;
        if (n == 0) return res;
        
        stack<int> s;
        s.push(0); // 根节点固定为0
        
        while (!s.empty()) {
            int u = s.top();
            s.pop();
            res.push_back(tree[u].val);
            
            // 为了前序遍历 (从左到右),栈需要反向压入子节点
            for (int i = (int)tree[u].children.size() - 1; i >= 0; --i) {
                s.push(tree[u].children[i]);
            }
        }
        return res;
    }
    
    // 生成树结构的核心逻辑
    void generate_test_case(int case_num, int n, int type) {
        string in_name = to_string(case_num) + ".in";
        string out_name = to_string(case_num) + ".out";
        ofstream fin(in_name);
        ofstream fout(out_name);
    
        if (n == 0) {
            fin << 0 << endl;
            return;
        }
    
        vector<Node> tree(n);
        vector<int> depth(n, 0);
        mt19937 rng(time(0) + case_num);
    
        // 1. 生成节点值
        for (int i = 0; i < n; ++i) {
            tree[i].val = rng() % (MAXVAL + 1);
        }
    
        // 2. 构造树结构 (确保 0 为根)
        depth[0] = 1;
        for (int i = 1; i < n; ++i) {
            int p;
            if (type == 1) { // 星形:所有人都连根
                p = 0;
            } else if (type == 2) { // 链形:i 连 i-1 (需受 MAXHEIGHT 约束)
                p = (i < MAXHEIGHT) ? i - 1 : rng() % MAXHEIGHT;
            } else { // 随机树
                // 启发式:在深度允许的范围内随机选父节点
                do {
                    p = rng() % i;
                } while (depth[p] >= MAXHEIGHT);
            }
            tree[p].children.push_back(i);
            depth[i] = depth[p] + 1;
        }
    
        // 3. 输出 .in 文件
        fin << n << endl;
        for (int i = 0; i < n; ++i) {
            fin << tree[i].val << " " << tree[i].children.size();
            for (int child : tree[i].children) {
                fin << " " << child;
            }
            fin << "\n";
        }
    
        // 4. 计算并输出 .out 文件 (使用非递归前序遍历)
        vector<int> ans = get_preorder(n, tree);
        for (int i = 0; i < (int)ans.size(); ++i) {
            fout << ans[i] << (i == (int)ans.size() - 1 ? "" : " ");
        }
        fout << endl;
    
        fin.close();
        fout.close();
        cout << "Case " << case_num << " finished. n=" << n << endl;
    }
    
    int main() {
        // 1-2: 边界
        generate_test_case(1, 0, 0);
        generate_test_case(2, 1, 0);
        
        // 3-4: 特殊形态
        generate_test_case(3, 100, 1); // 星形
        generate_test_case(4, 1000, 2); // 链形
    
        // 5-6: 中等随机
        generate_test_case(5, 500, 0);
        generate_test_case(6, 2000, 0);
    
        // 7-10: 大规模 (N=10000)
        for (int i = 7; i <= 10; ++i) {
            generate_test_case(i, 10000, 0);
        }
    
        return 0;
    }
    

    数据说明与分析

    1. 时间复杂度分析 (生成器)

    • 构树过程:对于每个测试点,我们只遍历一次节点 O(N)O(N)。由于加入了 while(depth[p] >= MAXHEIGHT) 检查,在最坏随机情况下,寻找父节点的时间也接近常数(因为合法的父节点占大多数)。
    • 答案生成:使用栈进行非递归遍历,每个节点进出栈各一次,时间复杂度为 O(N)O(N)
    • 总计:生成 10 个测试点的时间复杂度为 O(N)O(\sum N),在 N=104N=10^4 的量级下,生成速度在毫秒级,不会超时。

    2. 空间复杂度分析

    • 内存占用:主要消耗在存储 vector<Node>,每个节点存其子节点编号。总边数为 N1N-1,空间复杂度为 O(N)O(N)。对于 N=104N=10^4,内存占用约几百 KB。
    • 文件大小控制
      • 每个节点在 .in 文件中大约占 10~20 字节(数值+空格)。
      • 10410^4 节点对应的单个文件约 200KB
      • 10 个测试点总大小约 2MB 左右,完全符合 OJ 系统的上传要求。

    3. 读题关键词与易错点提示 (OJ 创建者视角)

    • 节点编号:此生成器严格遵循 0 为根节点,且子节点按插入顺序(即输入顺序)决定前序遍历的“左到右”顺序。
    • 空树处理n=0 是 NOI 常见的“杀手”边界,请确保你的 OJ 判题脚本能正确处理空行。
    • 格式要求:样例输出末尾通常不建议留多余空格。此代码中通过 (i == size-1 ? "" : " ") 逻辑处理了行末空格。

    如何使用

    1. 将上述代码保存为 gen.cpp
    2. 使用 g++ gen.cpp -o gen -std=c++14 编译。
    3. 运行 ./gen,当前目录下即生成 1.in ~ 10.out
    • 0
      @ 2025-12-30 14:55:22

      在 NOI 竞赛中,处理树结构题目通常有两种主流写法:递归(DFS)迭代(手动维护栈)。对于 N104N \le 10^4 的数据规模,两者在时间效率上差异不大,但在极端情况下(如树退化成链),递归可能导致系统栈溢出,而迭代法更为稳健。

      以下是按照从基础到进阶的完整代码实现。


      版本一:基础递归 DFS 版(NOI 赛场最常用)

      思路思考: 这是最直观的解法。利用函数调用栈,先处理当前节点,再递归处理子节点。

      • 优点:代码极其简洁,逻辑清晰。
      • 缺点:如果树的深度很大(如 10510^5 以上且没有系统栈保护),可能触发 Segmentation Fault
      #include <iostream>
      #include <vector>
      #include <algorithm>
      
      using namespace std;
      
      // 常量定义,根据题目规模设定
      const int MAXN = 10005;
      
      // 定义 N 叉树结构
      struct Node {
          int val;
          vector<int> children;
      } tree[MAXN];
      
      vector<int> result;
      
      // 递归函数:前序遍历
      void dfs(int u) {
          // 1. 记录当前节点值 (根)
          result.push_back(tree[u].val);
          
          // 2. 依次递归遍历每一个子节点 (从左到右)
          for (int child_id : tree[u].children) {
              dfs(child_id);
          }
      }
      
      int main() {
          // 加速 I/O
          ios::sync_with_stdio(false);
          cin.tie(0);
      
          int n;
          if (!(cin >> n)) return 0;
          if (n == 0) return 0;
      
          // 读入树结构
          // 假设输入格式:节点编号 0 到 n-1,每行:值 子节点数 子节点1...
          for (int i = 0; i < n; ++i) {
              int id, k, val;
              cin >> val >> k;
              tree[i].val = val;
              for (int j = 0; j < k; ++j) {
                  int child;
                  cin >> child;
                  tree[i].children.push_back(child);
              }
          }
      
          // 从根节点(编号0)开始 DFS
          dfs(0);
      
          // 输出结果
          for (int i = 0; i < result.size(); ++i) {
              cout << result[i] << (i == result.size() - 1 ? "" : " ");
          }
          cout << endl;
      
          return 0;
      }
      

      版本二:迭代优化版(手动模拟栈)

      思路思考: 为了解决递归深度限制问题,我们手动维护一个 std::stack

      • 难点:前序遍历顺序是“根 -> 左子 -> 右子”。但在栈中,先进入的后出来。
      • 策略:为了让左边的子节点先被弹出处理,我们需要将子节点按 从右往左 的顺序压入栈中。
      #include <iostream>
      #include <vector>
      #include <stack>
      #include <algorithm>
      
      using namespace std;
      
      const int MAXN = 10005;
      
      struct Node {
          int val;
          vector<int> children;
      } tree[MAXN];
      
      void iterativePreorder(int root) {
          if (root < 0) return;
      
          stack<int> s;
          s.push(root);
      
          vector<int> result;
          // 预分配内存,减少 vector 扩容开销
          result.reserve(MAXN); 
      
          while (!s.empty()) {
              int u = s.top();
              s.pop();
      
              // 访问当前节点
              result.push_back(tree[u].val);
      
              // 关键点:反向压栈!
              // 因为栈是后进先出,为了让左孩子先出栈,必须先压入右孩子
              for (int i = (int)tree[u].children.size() - 1; i >= 0; --i) {
                  s.push(tree[u].children[i]);
              }
          }
      
          for (int i = 0; i < result.size(); ++i) {
              cout << result[i] << (i == result.size() - 1 ? "" : " ");
          }
          cout << endl;
      }
      
      int main() {
          // 省略与版本一相同的读入代码...
          // 此处直接调用 iterativePreorder(0);
          return 0;
      }
      

      复杂度分析思考过程

      1. 时间复杂度:O(N)O(N)

      • 分析:无论递归还是迭代,每个节点都恰好进栈/出栈一次(或被递归函数访问一次)。
      • 子节点遍历:虽然每个节点有多个子节点,但所有节点的子节点总数加起来等于 N1N-1(树的边数)。
      • 结论:总操作次数与节点数 NN 成线性关系。

      2. 空间复杂度:O(N)O(N)

      • 存储空间:邻接表 vector<int> children 存储了 N1N-1 条边,占用 O(N)O(N)
      • 辅助空间
        • 递归版:系统栈深度最坏为 O(N)O(N)
        • 迭代版:手动维护的栈 stack<int> 在最坏情况下(所有节点都是根的孩子)存储 N1N-1 个节点,占用 O(N)O(N)

      时间复杂度优化建议 (NOI 竞赛技巧)

      1. 快速 I/O: 对于 N>105N > 10^5 的题目,cin/cout 即使加了 tie(0) 也可能比 scanf/printf 慢。在 NOI 中建议使用:

        inline int read() {
            int x=0,f=1;char ch=getchar();
            while(ch<'0'||ch>'9'){if(ch=='-')f=-1;ch=getchar();}
            while(ch>='0'&&ch<='9'){x=x*10+ch-'0';ch=getchar();}
            return x*f;
        }
        
      2. 避免 std::vector 频繁拷贝: 在 for (int child_id : tree[u].children) 中使用引用 & 虽在处理 int 时提升不明显,但如果处理复杂对象,应养成 const auto& 的习惯。

      3. 链式前向星: 如果题目对空间限制极其苛刻(例如只有 16MB),std::vector 的内存扩容机制(通常是 1.5 到 2 倍倍增)可能会浪费空间。此时可以改用链式前向星存图:

        int head[MAXN], nxt[MAXN], to[MAXN], cnt;
        void add_edge(int u, int v) {
            to[++cnt] = v;
            nxt[cnt] = head[u];
            head[u] = cnt;
        }
        // 注意:链式前向星遍历出来的是子节点的倒序,
        // 若要严格“从左到右”,需先将子节点存下来反转或调整加边顺序。
        

      总结:读题关键词与陷阱

      • “前序遍历”:看到它就要想到 根 -> 子 的顺序。
      • “N 叉树”:意味着子节点数量不固定,代码中必须有遍历子节点列表的循环。
      • “从左到右”:这是一个非常关键的细节。如果使用实现,必须反向压栈;如果使用链式前向星,由于其“头插法”特性,遍历出的顺序默认是反向的,需要额外注意。
      • 数据规模N=104N=10^4 很小,但如果 N=106N=10^6,一定要把递归改为迭代,或者使用 std::thread 扩充系统栈,否则极易爆栈。
      • 1

      信息

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