3 条题解

  • 0
    @ 2025-12-30 11:27:15

    为了方便你创建 OJ 测试数据,我为你编写了一个基于 C++14 标准的数据生成器。

    该生成器会生成 10 组测试点,涵盖了区间反转的各种极端情况(如全区间反转、单节点反转、首尾边界反转、最大数据规模等)。生成器内部使用 std::vector 模拟链表逻辑生成标准答案,确保生成过程不会爆栈。

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

    #include <iostream>
    #include <fstream>
    #include <vector>
    #include <string>
    #include <algorithm>
    #include <random>
    #include <chrono>
    
    using namespace std;
    
    // 数据范围定义
    const int MAXN = 500;
    const int MAXVAL = 500;
    const int MINVAL = -500;
    
    /**
     * 标准答案逻辑:用于生成 .out 文件
     * 使用 vector 模拟反转,简单且绝对准确,避免指针逻辑错误
     */
    void solve(const vector<int>& data, int left, int right, string out_name) {
        ofstream fout(out_name);
        if (data.empty()) {
            fout << endl;
            return;
        }
    
        vector<int> res = data;
        // 注意:题目中 left, right 是从 1 开始的编号
        // C++ vector iterator 是从 0 开始的,所以区间是 [left-1, right)
        if (left <= right && left >= 1 && right <= (int)data.size()) {
            reverse(res.begin() + (left - 1), res.begin() + right);
        }
    
        for (int i = 0; i < (int)res.size(); ++i) {
            fout << res[i] << (i == (int)res.size() - 1 ? "" : " ");
        }
        fout << endl;
        fout.close();
    }
    
    /**
     * 写入输入文件 (.in)
     */
    void write_input(const vector<int>& data, int left, int right, string in_name) {
        ofstream fin(in_name);
        // 第一行:节点值
        for (int i = 0; i < (int)data.size(); ++i) {
            fin << data[i] << (i == (int)data.size() - 1 ? "" : " ");
        }
        fin << endl;
        // 第二行:left 和 right
        fin << left << " " << right << endl;
        fin.close();
    }
    
    int main() {
        // 随机数发生器
        mt19937 rng(chrono::steady_clock::now().time_since_epoch().count());
        uniform_int_distribution<int> val_dist(MINVAL, MAXVAL);
    
        for (int t = 1; t <= 10; ++t) {
            string in_name = to_string(t) + ".in";
            string out_name = to_string(t) + ".out";
            vector<int> data;
            int n = 0, L = 0, R = 0;
    
            // 根据测试点编号构造不同场景
            switch(t) {
                case 1: // 最小边界:n=1
                    n = 1; L = 1; R = 1;
                    data.push_back(val_dist(rng));
                    break;
                case 2: // 边界:n=2, 全反转
                    n = 2; L = 1; R = 2;
                    data = {1, 2};
                    break;
                case 3: // 边界:n=max, 全反转 [1, 500]
                    n = MAXN; L = 1; R = MAXN;
                    for(int i=1; i<=n; ++i) data.push_back(i);
                    break;
                case 4: // 边界:反转第一个节点 (实际无变化)
                    n = MAXN; L = 1; R = 1;
                    for(int i=1; i<=n; ++i) data.push_back(val_dist(rng));
                    break;
                case 5: // 边界:反转最后一个节点 (实际无变化)
                    n = MAXN; L = MAXN; R = MAXN;
                    for(int i=1; i<=n; ++i) data.push_back(val_dist(rng));
                    break;
                case 6: // 场景:反转中间很小一段
                    n = MAXN; L = 250; R = 251;
                    for(int i=1; i<=n; ++i) data.push_back(i);
                    break;
                case 7: // 场景:反转除了首尾以外的所有部分
                    n = MAXN; L = 2; R = MAXN - 1;
                    for(int i=1; i<=n; ++i) data.push_back(i);
                    break;
                case 8: // 场景:全相同元素 (考察逻辑鲁棒性)
                    n = MAXN; L = 100; R = 400;
                    { int common = val_dist(rng);
                      for(int i=0; i<n; ++i) data.push_back(common); }
                    break;
                case 9: // 随机:中等规模
                    n = 100;
                    for(int i=0; i<n; ++i) data.push_back(val_dist(rng));
                    L = uniform_int_distribution<int>(1, n)(rng);
                    R = uniform_int_distribution<int>(L, n)(rng);
                    break;
                case 10: // 随机:最大规模
                    n = MAXN;
                    for(int i=0; i<n; ++i) data.push_back(val_dist(rng));
                    L = uniform_int_distribution<int>(1, n)(rng);
                    R = uniform_int_distribution<int>(L, n)(rng);
                    break;
            }
    
            write_input(data, L, R, in_name);
            solve(data, L, R, out_name);
            
            cout << "Test Case " << t << " generated: n=" << n << " [" << L << "," << R << "]" << endl;
        }
    
        return 0;
    }
    

    测试点详细说明表

    测试点编号 nn 规模 范围 [left,right][left, right] 设计目的
    1 1 [1, 1] 考察对单节点链表及最小边界的处理
    2 2 [1, 2] 考察只有两个节点且需要全交换的情况
    3 500 [1, 500] 全区间反转:考察 left=1left=1right=nright=n 的极端情况
    4 [1, 1] 左边界不动:考察 left=right=1left=right=1 时哨兵节点是否工作
    5 [500, 500] 右边界不动:考察 left=right=nleft=right=n 的处理
    6 [250, 251] 最小区间:考察中间相邻两节点交换的逻辑
    7 [2, 499] 大区间:考察保留首尾节点、只反转中间长段的情况
    8 [100, 400] 数值重复:确保程序不受节点数值干扰
    9 100 随机 普通随机数据,验证通用性
    10 500 满规模随机数据,验证最坏情况下的稳定性

    生成器设计要点 (符合 NOI 要求)

    1. 文件大小控制: 由于 nn 最大仅为 500,即使 10 个测试点全部包含 500 个带负号的整数,总文件大小也仅约为 $500 \times 10 \times 5 \text{ bytes} \approx 25\text{KB}$。远远低于 2MB 的限制,加载极快。

    2. 避免生成超时: 生成算法使用简单的 std::vectorstd::reverse,其时间复杂度为 O(n)O(n),生成全部 10 组数据的时间在毫秒级。

    3. 安全性与健壮性

      • 非递归生成:生成标准答案(.out)时使用迭代方式,完全不存在爆栈风险。
      • 无除零异常:生成逻辑仅涉及加减法和指针移动。
      • 随机种子:使用 chrono 提供的纳秒级时间戳作为种子,确保每次生成的随机数据不同。
    4. 易用性: 该程序是一个完整的工具,编译运行后直接在当前目录产出所有 OJ 评测所需文件。你只需修改 MAXN 即可快速扩展数据规模。

    • 0
      @ 2025-12-30 11:25:50

      同学你好!在 NOI 竞赛中,递归(Recursion) 是一种极其重要的思维方式。虽然在处理超大规模链表时迭代法更稳健,但递归解法能帮你深刻理解“数学归纳法”和“子问题分解”的精髓。

      对于“区间反转”任务,递归法的精妙之处在于将问题拆解为:“反转前 N 个节点”“移动到起始位置” 两个子任务。


      递归法思路推演:分治与衔接

      1. 子任务:反转链表的前 nn 个节点

      假设我们要反转链表的前 nn 个节点。

      • 递归基:当 n=1n=1 时,说明反转的就是当前节点,我们需要记录下这个节点的下一个节点(即第 n+1n+1 个节点,称之为 successor 接班人),因为反转后,原本的头节点要指向它。
      • 递去:递归处理 head->next,反转它前面的 n1n-1 个节点。
      • 归来:将 head 接在已经反转好的子链表后面,并让 head 指向刚才保存的 successor

      2. 主任务:反转 leftleftrightright

      • 如果 left=1left = 1:直接调用“反转前 nn 个节点”的函数(此时 n=rightn = right)。
      • 如果 left>1left > 1:我们不需要反转当前节点,只需要递归地去处理 head->next,并将 leftleftrightright 同时减 1(向目标区间靠拢)。

      递归版标准代码 (NOIP C++14)

      #include <iostream>
      #include <string>
      #include <sstream>
      
      using namespace std;
      
      struct ListNode {
          int val;
          ListNode *next;
          ListNode(int x) : val(x), next(nullptr) {}
      };
      
      // 全局变量或通过引用传递,记录反转区间后的第一个节点
      ListNode* successor = nullptr;
      
      /**
       * 子函数:反转以 head 为起点的向前 n 个节点
       * 关键点:保存第 n+1 个节点,防止断链
       */
      ListNode* reverseN(ListNode* head, int n) {
          // 递归边界:只反转 1 个节点,即它本身
          if (n == 1) {
              successor = head->next; // 记录第 n+1 个节点
              return head;
          }
          
          // 递去:反转以 head->next 为起点的 n-1 个节点
          ListNode* last = reverseN(head->next, n - 1);
          
          // 归来:进行指针倒置
          // 易错点:这里是 head->next 的 next 指向自己
          head->next->next = head;
          
          // 关键点:反转后的尾巴(当前的 head)要接上后面的“接班人”
          head->next = successor;
          
          return last;
      }
      
      /**
       * 主递归函数
       */
      ListNode* reverseBetween(ListNode* head, int left, int right) {
          // 基础情况:如果 left == 1,等价于反转前 n 个节点
          if (left == 1) {
              return reverseN(head, right);
          }
          
          // 递去:当前节点不在反转区间内,递归处理下一个
          // 关键点:head->next 重新指向递归返回的结果,完成重新连接
          head->next = reverseBetween(head->next, left - 1, right - 1);
          
          return head;
      }
      
      // 辅助函数:构造链表
      ListNode* createList() {
          string line;
          if (!getline(cin, line)) return nullptr;
          stringstream ss(line);
          ListNode* dummy = new ListNode(0);
          ListNode* tail = dummy;
          int x;
          while (ss >> x) {
              tail->next = new ListNode(x);
              tail = tail->next;
          }
          return dummy->next;
      }
      
      int main() {
          // 优化IO速度
          ios::sync_with_stdio(false);
          cin.tie(0);
      
          ListNode* head = createList();
          int l, r;
          if (!(cin >> l >> r)) return 0;
      
          head = reverseBetween(head, l, r);
      
          // 输出
          while (head) {
              cout << head->val << (head->next ? " " : "");
              head = head->next;
          }
          cout << endl;
      
          return 0;
      }
      

      三、 复杂度分析思考过程

      1. 时间复杂度分析

        • 定位过程:递归深度为 leftleft
        • 反转过程:递归深度为 rightleftright - left
        • 总计:总递归深度为 rightright,每个节点仅被处理一次,复杂度为 O(n)O(n)
      2. 空间复杂度分析 (重要!)

        • 由于是递归调用,每一次函数调用都会在**系统栈(Stack)**上开辟一层空间来保存局部变量(如 head, n 等)。
        • 总计O(n)O(n)
        • NOI 考场风险
          • 本题 n500n \le 500,递归完全没问题(通常系统默认栈大小为 8MB 或以上,足以支撑数万层简单的递归)。
          • 但是,如果 nn 达到 10510^5 甚至更大,且链表退化为线性结构,递归法极易触发 Runtime Error (栈溢出)。在处理大数据量时,迭代法(版本二)更受竞赛选手青睐。

      四、 递归解法的关键词与技巧

      1. “状态转移”:注意 left-1, right-1 的变化,这实际上是在模拟指针的移动。
      2. “接班人 (Successor)”:这是递归反转局部链表的核心。全反转时我们让尾部指向 nullptr,但区间反转必须让尾部指向区间后的第一个节点。
      3. “回溯连接”:在递归“归来”的过程中,通过 head->next = ... 这种赋值操作,可以自动处理区间头部与前面节点的连接问题,无需像迭代法那样手动维护 pre 指针。

      教练总结: 递归法代码极简,逻辑优美,适合在考场上快速实现并保证正确性(前提是数据范围允许)。请务必对比迭代法的“头插”递归法的“归来连接”,这两种思维方式的碰撞是提升算法能力的关键。加油!

      • 0
        @ 2025-12-30 11:20:23

        同学你好!区间反转是链表操作中的“期中考试”。相比于全链表反转,它增加了对断点保存区域连接的考察。在 NOI 竞赛中,能否一次性写对这类指针逻辑,直接决定了你在处理高级数据结构(如平衡树、Splay 的区间翻转)时的效率。

        下面我们从“拆解合并”的朴素思路,过渡到“一趟扫描”的最优算法。


        版本一:拆解与合并 (逻辑清晰的保底方案)

        思路分析: 这是最符合直觉的方法:

        1. 先找到第 left-1 个节点(pre)和第 right+1 个节点(succ)。
        2. leftright 这一段从原链表中“切下来”。
        3. 使用上一题(反转全链表)的算法反转这一小段。
        4. 将反转后的片段接回 presucc 之间。
        • 时间复杂度O(n)O(n),虽然遍历了两两次,但依然是线性复杂度。
        • 空间复杂度O(1)O(1)
        • 易错点:如果不使用 dummy 节点,当 left=1 时,pre 会指向空,逻辑会直接崩溃。
        #include <iostream>
        #include <vector>
        #include <string>
        #include <sstream>
        
        using namespace std;
        
        struct ListNode {
            int val;
            ListNode *next;
            ListNode(int x) : val(x), next(nullptr) {}
        };
        
        // 复用:反转整个链表的函数
        void reverseEntire(ListNode* head) {
            ListNode* prev = nullptr;
            ListNode* curr = head;
            while (curr) {
                ListNode* next = curr->next;
                curr->next = prev;
                prev = curr;
                curr = next;
            }
        }
        
        ListNode* reverseBetween(ListNode* head, int left, int right) {
            // 哨兵节点:处理 left = 1 的万能钥匙
            ListNode* dummy = new ListNode(-1);
            dummy->next = head;
        
            // 1. 找到 pre (第 left-1 个节点)
            ListNode* pre = dummy;
            for (int i = 0; i < left - 1; i++) {
                pre = pre->next;
            }
        
            // 2. 找到 rightNode (第 right 个节点)
            ListNode* rightNode = pre;
            for (int i = 0; i < right - left + 1; i++) {
                rightNode = rightNode->next;
            }
        
            // 3. 切断连接
            ListNode* leftNode = pre->next;
            ListNode* succ = rightNode->next;
        
            pre->next = nullptr;
            rightNode->next = nullptr;
        
            // 4. 反转子链表
            reverseEntire(leftNode);
        
            // 5. 接回原链表
            // 此时子链表的头变成了 rightNode,尾变成了 leftNode
            pre->next = rightNode;
            leftNode->next = succ;
        
            return dummy->next;
        }
        
        int main() {
            // 模拟读入:先读一行节点,再读 left 和 right
            string line;
            getline(cin, line);
            stringstream ss(line);
            int x, left, right;
            
            ListNode* dummy = new ListNode(0);
            ListNode* tail = dummy;
            while (ss >> x) {
                tail->next = new ListNode(x);
                tail = tail->next;
            }
            
            cin >> left >> right;
            
            ListNode* res = reverseBetween(dummy->next, left, right);
            
            while (res) {
                cout << res->val << (res->next ? " " : "");
                res = res->next;
            }
            cout << endl;
            return 0;
        }
        

        版本二:一趟扫描 (最优复杂度、头插法)

        思路分析: 在移动到 left 位置后,我们不切断链表,而是直接进行操作。 核心思想是:left 后面那个节点(nxt)不断挪到 pre 的后面。 这就好比在排队,pre 是固定的位置,我们把后面的同学一个一个插到 pre 刚数完的那个位置。

        • 时间复杂度O(n)O(n),严格只遍历一次。
        • 空间复杂度O(1)O(1)
        • 关键点:理解 cur->next = nxt->next。这一步的作用是让当前区间原本的开头(cur)不断向后“跳跃”,从而腾出位置让后面的节点插到前面去。
        #include <iostream>
        #include <string>
        #include <sstream>
        
        using namespace std;
        
        struct ListNode {
            int val;
            ListNode *next;
            ListNode(int x) : val(x), next(nullptr) {}
        };
        
        ListNode* reverseBetween(ListNode* head, int left, int right) {
            // 使用 dummy node 可以避免对 head 被修改的复杂判断
            ListNode* dummy = new ListNode(-1);
            dummy->next = head;
            
            ListNode* pre = dummy;
            // 第一步:将 pre 移动到 left 的前一个位置
            for (int i = 0; i < left - 1; ++i) {
                pre = pre->next;
            }
            
            // 第二步:开始“头插”
            // cur 始终指向反转区间的第一个节点(反转过程中它会不断后移,最后变成区间的尾部)
            ListNode* cur = pre->next;
            ListNode* nxt;
            
            for (int i = 0; i < right - left; ++i) {
                // 关键四步走:
                nxt = cur->next;            // 1. 找到待挪动的节点 nxt
                cur->next = nxt->next;      // 2. cur 跳过 nxt,指向 nxt 的下一个
                nxt->next = pre->next;      // 3. nxt 反向连接,插到当前区间的最前面
                pre->next = nxt;            // 4. pre 接住新的区间头部
            }
            
            return dummy->next;
        }
        
        int main() {
            ios::sync_with_stdio(false);
            cin.tie(0);
        
            string line;
            if (!getline(cin, line)) return 0;
            stringstream ss(line);
            
            ListNode* dummy = new ListNode(0);
            ListNode* tail = dummy;
            int x;
            while (ss >> x) {
                tail->next = new ListNode(x);
                tail = tail->next;
            }
            
            int l, r;
            if (!(cin >> l >> r)) return 0;
            
            ListNode* res = reverseBetween(dummy->next, l, r);
            
            while (res) {
                cout << res->val << (res->next ? " " : "");
                res = res->next;
            }
            cout << endl;
            return 0;
        }
        

        三、 复杂度分析与思考过程

        1. 时间复杂度分析

          • 定位 pre:消耗 O(left)O(left)
          • 区间变换:循环执行了 rightleftright - left 次,每次操作都是 O(1)O(1) 的指针交换。
          • 总计O(right)O(right),最坏情况下 right=nright=n,故为 O(n)O(n)
          • 优化建议:对于 n=500n=500 这种极小规模,任何线性算法都能在 1ms 内完成。但在处理百万级数据时,一趟扫描(版本二)比两趟扫描(版本一)能节省约一半的常数时间。
        2. 空间复杂度分析

          • 我们只定义了 dummy, pre, cur, nxt 几个指针,属于原地(In-place)修改。
          • 内存池建议:在 NOI 比赛中,如果你担心 new 带来的时间开销或碎片化,可以定义静态数组:
            ListNode pool[505];
            int ptr = 0;
            ListNode* newNode(int x) {
                pool[ptr].val = x;
                pool[ptr].next = nullptr;
                return &pool[ptr++];
            }
            

        四、 考场避坑指南

        1. 左边界陷阱: 很多同学不写 dummy,导致 left=1 时无法通过 pre = pre->next 找到“前驱”。记住:只要链表的头节点可能变,就必须加 dummy

        2. 指针丢失: 在执行 cur->next = nxt->next 之前,必须先保存 nxt = cur->next。链表题的本质就是“不要弄丢了还没处理的节点地址”。

        3. 循环次数计算: 反转长度为 KK 的区间,只需要进行 K1K-1 次“头插”操作。例如反转 [2, 4],长度为 3,只需插 2 次。

        4. 数据范围: 本题 n500n \le 500 非常小,如果题目的 nn 达到 10510^5,则严禁使用递归法,因为递归深度过大会导致系统栈溢出(Runtime Error)。版本二的迭代法是真正的全场景通用解。

        • 1

        信息

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