2 条题解

  • 0
    @ 2025-12-30 17:27:01

    作为教练,我为你准备了一个高效、稳健的数据生成器。它采用 C++14 标准,能够生成 10 组涵盖各种形态(空树、满二叉树、左斜链、右斜链、随机树)的测试数据,并自动生成标准答案。

    为了确保生成器不会因为数据深度过大而爆栈,生成 .out 文件的过程采用了**迭代法(手动模拟栈)**来获取前序遍历序列。

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

    #include <iostream>
    #include <vector>
    #include <fstream>
    #include <stack>
    #include <algorithm>
    #include <random>
    #include <string>
    
    using namespace std;
    
    // 节点结构体
    struct Node {
        int val;
        int l, r;
    };
    
    // ---------------------------------------------------------
    // 核心:使用迭代法(非递归)获取前序遍历并生成答案
    // ---------------------------------------------------------
    void solve_and_write(int n, const vector<Node>& tree, string out_name) {
        ofstream fout(out_name);
        if (n <= 0) {
            fout.close();
            return;
        }
    
        vector<int> preorder_vals;
        stack<int> s;
        s.push(0); // 根节点固定为 0
    
        // 迭代前序遍历:根 -> 左 -> 右
        while (!s.empty()) {
            int u = s.top();
            s.pop();
            preorder_vals.push_back(tree[u].val);
    
            // 先压右子节点,再压左子节点,这样出栈顺序就是左先右后
            if (tree[u].r != -1) s.push(tree[u].r);
            if (tree[u].l != -1) s.push(tree[u].l);
        }
    
        // 写入答案文件
        for (int i = 0; i < (int)preorder_vals.size(); ++i) {
            fout << preorder_vals[i] << (i == (int)preorder_vals.size() - 1 ? "" : " ");
        }
        fout << endl;
        fout.close();
    }
    
    // ---------------------------------------------------------
    // 各种形态的树生成函数
    // ---------------------------------------------------------
    void make_test_case(int id, int n, int type) {
        string in_name = to_string(id) + ".in";
        string out_name = to_string(id) + ".out";
        ofstream fin(in_name);
    
        if (n == 0) {
            fin << 0 << endl;
            fin.close();
            solve_and_write(0, {}, out_name);
            return;
        }
    
        vector<Node> tree(n, {0, -1, -1});
        mt19937 rng(id + 2025);
        uniform_int_distribution<int> val_dist(-100, 100);
    
        for (int i = 0; i < n; ++i) tree[i].val = val_dist(rng);
    
        if (type == 1) { // 极端左斜链 (需要 Morris 遍历或迭代处理)
            for (int i = 0; i < n - 1; ++i) tree[i].l = i + 1;
        } 
        else if (type == 2) { // 极端右斜链 (本身已经是展开状态)
            for (int i = 0; i < n - 1; ++i) tree[i].r = i + 1;
        }
        else if (type == 3) { // 满二叉树结构
            for (int i = 0; i < n; ++i) {
                int left = 2 * i + 1;
                int right = 2 * i + 2;
                if (left < n) tree[i].l = left;
                if (right < n) tree[i].r = right;
            }
        }
        else if (id == 3) { // 手动模拟示例 1
            tree.clear(); tree.resize(6);
            tree[0] = {1, 1, 2}; tree[1] = {2, 3, 4}; tree[2] = {5, -1, 5};
            tree[3] = {3, -1, -1}; tree[4] = {4, -1, -1}; tree[5] = {6, -1, -1};
        }
        else { // 随机二叉树 (保证连通且不超度)
            vector<int> slots; // 记录还有空位的节点
            slots.push_back(0);
            for (int i = 1; i < n; ++i) {
                int idx = uniform_int_distribution<int>(0, (int)slots.size() - 1)(rng);
                int p = slots[idx];
                if (tree[p].l == -1 && tree[p].r == -1) {
                    if (rng() % 2) tree[p].l = i; else tree[p].r = i;
                } else if (tree[p].l == -1) {
                    tree[p].l = i;
                    slots.erase(slots.begin() + idx);
                } else {
                    tree[p].r = i;
                    slots.erase(slots.begin() + idx);
                }
                slots.push_back(i);
            }
        }
    
        // 写入输入文件
        fin << n << "\n";
        for (int i = 0; i < n; ++i) {
            fin << tree[i].val << " " << tree[i].l << " " << tree[i].r << "\n";
        }
        fin.close();
    
        // 生成标准答案
        solve_and_write(n, tree, out_name);
        cout << "Case " << id << " [N=" << n << "] generated." << endl;
    }
    
    int main() {
        // 测试点分布设计
        make_test_case(1, 0, 0);    // 边界:空树
        make_test_case(2, 1, 0);    // 边界:单节点
        make_test_case(3, 6, 0);    // 示例:样例 1 结构
        make_test_case(4, 100, 1);  // 极端:左斜
        make_test_case(5, 100, 2);  // 极端:右斜
        make_test_case(6, 1023, 3); // 满二叉树 (N=2^10-1)
        make_test_case(7, 2000, 0); // 随机:上限规模 1
        make_test_case(8, 2000, 1); // 随机:左斜上限 (测试性能)
        make_test_case(9, 2000, 0); // 随机:上限规模 2
        make_test_case(10, 2000, 0);// 随机:上限规模 3
    
        return 0;
    }
    

    生成器说明及辅导建议

    1. 复杂度与性能

    • 生成时间:对于 N=2000N=2000 的数据规模,整个程序生成 10 个测试点只需要几十毫秒。
    • 非递归安全性:在生成 .out 时,我们使用了 std::stack 进行迭代遍历。这确保了即便生成 N=2000N=2000 层的长链(如 type=1),生成器本身也不会出现内存错误或爆栈。
    • 文件大小:单组数据 .in 约为 30KB30KB,10 组数据总计约 300KB300KB,远小于 2MB,方便上传。

    2. 测试点设计的深度

    • 测试点 4 (左斜长链):这是对原地修改算法最严苛的考验。如果选手的代码逻辑中没有正确处理“寻找左子树最右节点”的边界,或者在挪动左子树时没有清空 L[curr],这个点必错。
    • 测试点 6 (满二叉树):测试算法在处理大量分叉节点时的正确性。

    3. 教练提醒

    • 输入读取:在读入数据时,请提醒学生注意题目中“根节点固定为 0”的条件,直接从 dfs(0)curr = 0 开始即可。
    • Morris 思想:虽然 N=2000N=2000 不大,但如果能写出 O(1)O(1) 空间复杂度的原地修改法,是衡量一名 NOI 选手是否具备底层优化意识的重要标志。

    你可以直接编译运行此程序。它会自动在当前目录下生成 1.in/out10.in/out。祝你的 OJ 建设和辅导工作顺利!

    • 0
      @ 2025-12-30 17:24:54

      作为教练,我将为你展示从“空间换时间”的辅助容器法,过渡到经典的反向后序递归,最后达到竞赛巅峰的 Morris 启发式原地修改法


      版本一:辅助容器法(最稳妥的暴力,空间 O(N)O(N)

      思路分析: 这是考场上最不容易出错的写法。

      1. 先进行一次标准的前序遍历,按访问顺序把节点的编号存入一个 std::vector
      2. 遍历该容器,强行修改每个节点的指针:左孩子设为 -1,右孩子指向容器中的下一个节点。
      • 时间复杂度O(N)O(N)
      • 空间复杂度O(N)O(N),用于存储节点序列。
      #include <iostream>
      #include <vector>
      
      using namespace std;
      
      const int MAXN = 2005;
      int Val[MAXN], L[MAXN], R[MAXN];
      vector<int> nodes;
      
      // 标准前序遍历:根 -> 左 -> 右
      void preorder(int u) {
          if (u == -1) return;
          nodes.push_back(u);
          preorder(L[u]);
          preorder(R[u]);
      }
      
      int main() {
          int n;
          if (!(cin >> n) || n == 0) return 0;
      
          for (int i = 0; i < n; ++i) {
              cin >> Val[i] >> L[i] >> R[i];
          }
      
          preorder(0);
      
          // 根据前序序列重构树结构
          for (int i = 0; i < (int)nodes.size() - 1; ++i) {
              int curr = nodes[i];
              int next = nodes[i + 1];
              L[curr] = -1;   // 左子针始终为 -1
              R[curr] = next; // 右子针指向序列下一个
          }
          // 最后一个节点的左右都要为空
          if (!nodes.empty()) {
              int last = nodes.back();
              L[last] = -1;
              R[last] = -1;
          }
      
          // 按展开后的链表顺序输出
          int curr = 0;
          while (curr != -1) {
              cout << Val[curr] << (R[curr] == -1 ? "" : " ");
              curr = R[curr];
          }
          cout << endl;
      
          return 0;
      }
      

      版本二:反向后序递归(巧妙的原地修改,空间 O(H)O(H)

      思路分析: 如果我们按“右 -> 左 -> 根”的顺序遍历(即前序遍历的完全反向),我们可以维护一个 last 变量,记录上一个处理完的节点。 对于当前节点,直接将其右孩子指向 last,左孩子设为 -1

      • 时间复杂度O(N)O(N)
      • 空间复杂度O(H)O(H),取决于树的高度(递归栈)。
      #include <iostream>
      
      using namespace std;
      
      const int MAXN = 2005;
      int Val[MAXN], L[MAXN], R[MAXN];
      int last_node = -1; // 记录上一个被线性化的节点
      
      // 反向后序遍历:右 -> 左 -> 根
      void rev_postorder(int u) {
          if (u == -1) return;
      
          // 先处理右子树,再处理左子树
          rev_postorder(R[u]);
          rev_postorder(L[u]);
      
          // 【核心操作】修改当前节点的指向
          R[u] = last_node;
          L[u] = -1;
          
          // 更新 last_node 为当前节点,供它的前序前驱节点连接
          last_node = u;
      }
      
      int main() {
          int n;
          if (!(cin >> n) || n == 0) return 0;
      
          for (int i = 0; i < n; ++i) {
              cin >> Val[i] >> L[i] >> R[i];
          }
      
          rev_postorder(0);
      
          // 输出
          int curr = 0;
          while (curr != -1) {
              cout << Val[curr] << (R[curr] == -1 ? "" : " ");
              curr = R[curr];
          }
          return 0;
      }
      

      版本三:最优原地修改法(Morris 思路,空间 O(1)O(1)

      思路分析: 这是面试和竞赛中的最优解。 对于每一个节点 curr

      1. 如果它有左子树,那么其左子树中“最右边”的节点(即左子树前序遍历的最后一个节点),一定是 curr 原本右孩子的前驱。
      2. 我们把 curr 的右子树整体接到左子树最右节点的右边。
      3. curr 的左子树挪到右边,左边置空。
      • 时间复杂度O(N)O(N)。虽然有嵌套循环,但每条边最多被访问两次(找前驱一次,移动一次)。
      • 空间复杂度O(1)O(1)
      #include <cstdio>
      #include <algorithm>
      
      using namespace std;
      
      const int MAXN = 2005;
      int Val[MAXN], L[MAXN], R[MAXN];
      
      int main() {
          int n;
          if (scanf("%d", &n) != 1 || n <= 0) return 0;
      
          for (int i = 0; i < n; ++i) {
              scanf("%d %d %d", &Val[i], &L[i], &R[i]);
          }
      
          int curr = 0; // 从根节点开始
          while (curr != -1) {
              if (L[curr] != -1) {
                  // 1. 找到左子树中最右边的节点
                  int pre = L[curr];
                  while (R[pre] != -1) {
                      pre = R[pre];
                  }
                  
                  // 2. 将 curr 的原右子树接到这个最右节点的右边
                  R[pre] = R[curr];
                  
                  // 3. 将左子树整个移动到右边
                  R[curr] = L[curr];
                  L[curr] = -1; // 【易错点】左子树必须清空
              }
              // 4. 继续处理下一个右节点(可能是原来的左孩子,也可能是接上来的右孩子)
              curr = R[curr];
          }
      
          // 输出展开后的结果
          curr = 0;
          while (curr != -1) {
              printf("%d%c", Val[curr], (R[curr] == -1 ? '\n' : ' '));
              curr = R[curr];
          }
      
          return 0;
      }
      

      复杂度分析思考过程

      1. 时间复杂度思考

        • 在版本三中,外层 while 遍历所有节点。内层寻找“最右节点”的 while 看起来会增加负担。
        • 但从边的角度看:每条边在寻找 pre 时被访问一次,在移动子树时被处理一次。因此总操作次数是 O(N)O(N)
        • 建议:在竞赛中,当看到这种“寻找前驱节点并修改指针”的题目,优先思考 Morris 遍历风格的迭代。
      2. 空间复杂度思考

        • 版本一需要 O(N)O(N)
        • 版本二需要 O(H)O(H)。如果树退化成一条链,H=NH=N,同样可能爆栈。
        • 版本三仅使用 currpre 两个变量。
        • 建议:NOIP 竞赛中,N=2000N=2000 级别使用递归通常安全,但如果 N105N \ge 10^5,请务必使用版本三的迭代写法。

      时间复杂度优化建议

      • 减少数据拷贝:在版本一中,vector 的存储和随后的二次遍历带来了额外的常数开销。
      • 指针式操作:版本三直接在原有的 LR 数组上进行逻辑搬运,缓存命中率最高。
      • I/O 优化:使用 scanf/printf

      读题关键词总结

      • “前序遍历”:确定了链接的逻辑顺序。
      • “原地修改 / O(1)O(1) 额外空间”:核心考点。看到它,就要想到不能开新数组,要利用树本身的指针。
      • “左子指针为 null”:完成操作后的结构约束。不要忘记 L[curr] = -1
      • “单链表”:暗示了结构已经由树退化为链。

      希望这个由浅入深的分析能帮你透彻理解树的线性化变换!加油!

      • 1

      信息

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