3 条题解
-
0
为了方便你创建 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; }
测试点详细说明表
测试点编号 规模 范围 设计目的 1 1 [1, 1] 考察对单节点链表及最小边界的处理 2 2 [1, 2] 考察只有两个节点且需要全交换的情况 3 500 [1, 500] 全区间反转:考察 且 的极端情况 4 [1, 1] 左边界不动:考察 时哨兵节点是否工作 5 [500, 500] 右边界不动:考察 的处理 6 [250, 251] 最小区间:考察中间相邻两节点交换的逻辑 7 [2, 499] 大区间:考察保留首尾节点、只反转中间长段的情况 8 [100, 400] 数值重复:确保程序不受节点数值干扰 9 100 随机 普通随机数据,验证通用性 10 500 满规模随机数据,验证最坏情况下的稳定性
生成器设计要点 (符合 NOI 要求)
-
文件大小控制: 由于 最大仅为 500,即使 10 个测试点全部包含 500 个带负号的整数,总文件大小也仅约为 $500 \times 10 \times 5 \text{ bytes} \approx 25\text{KB}$。远远低于 2MB 的限制,加载极快。
-
避免生成超时: 生成算法使用简单的
std::vector和std::reverse,其时间复杂度为 ,生成全部 10 组数据的时间在毫秒级。 -
安全性与健壮性:
- 非递归生成:生成标准答案(
.out)时使用迭代方式,完全不存在爆栈风险。 - 无除零异常:生成逻辑仅涉及加减法和指针移动。
- 随机种子:使用
chrono提供的纳秒级时间戳作为种子,确保每次生成的随机数据不同。
- 非递归生成:生成标准答案(
-
易用性: 该程序是一个完整的工具,编译运行后直接在当前目录产出所有 OJ 评测所需文件。你只需修改
MAXN即可快速扩展数据规模。
-
-
0
同学你好!在 NOI 竞赛中,递归(Recursion) 是一种极其重要的思维方式。虽然在处理超大规模链表时迭代法更稳健,但递归解法能帮你深刻理解“数学归纳法”和“子问题分解”的精髓。
对于“区间反转”任务,递归法的精妙之处在于将问题拆解为:“反转前 N 个节点” 和 “移动到起始位置” 两个子任务。
递归法思路推演:分治与衔接
1. 子任务:反转链表的前 个节点
假设我们要反转链表的前 个节点。
- 递归基:当 时,说明反转的就是当前节点,我们需要记录下这个节点的下一个节点(即第 个节点,称之为
successor接班人),因为反转后,原本的头节点要指向它。 - 递去:递归处理
head->next,反转它前面的 个节点。 - 归来:将
head接在已经反转好的子链表后面,并让head指向刚才保存的successor。
2. 主任务:反转 到
- 如果 :直接调用“反转前 个节点”的函数(此时 )。
- 如果 :我们不需要反转当前节点,只需要递归地去处理
head->next,并将 和 同时减 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; }
三、 复杂度分析思考过程
-
时间复杂度分析:
- 定位过程:递归深度为 。
- 反转过程:递归深度为 。
- 总计:总递归深度为 ,每个节点仅被处理一次,复杂度为 。
-
空间复杂度分析 (重要!):
- 由于是递归调用,每一次函数调用都会在**系统栈(Stack)**上开辟一层空间来保存局部变量(如
head,n等)。 - 总计:。
- NOI 考场风险:
- 本题 ,递归完全没问题(通常系统默认栈大小为 8MB 或以上,足以支撑数万层简单的递归)。
- 但是,如果 达到 甚至更大,且链表退化为线性结构,递归法极易触发
Runtime Error(栈溢出)。在处理大数据量时,迭代法(版本二)更受竞赛选手青睐。
- 由于是递归调用,每一次函数调用都会在**系统栈(Stack)**上开辟一层空间来保存局部变量(如
四、 递归解法的关键词与技巧
- “状态转移”:注意
left-1, right-1的变化,这实际上是在模拟指针的移动。 - “接班人 (Successor)”:这是递归反转局部链表的核心。全反转时我们让尾部指向
nullptr,但区间反转必须让尾部指向区间后的第一个节点。 - “回溯连接”:在递归“归来”的过程中,通过
head->next = ...这种赋值操作,可以自动处理区间头部与前面节点的连接问题,无需像迭代法那样手动维护pre指针。
教练总结: 递归法代码极简,逻辑优美,适合在考场上快速实现并保证正确性(前提是数据范围允许)。请务必对比迭代法的“头插”与递归法的“归来连接”,这两种思维方式的碰撞是提升算法能力的关键。加油!
- 递归基:当 时,说明反转的就是当前节点,我们需要记录下这个节点的下一个节点(即第 个节点,称之为
-
0
同学你好!区间反转是链表操作中的“期中考试”。相比于全链表反转,它增加了对断点保存和区域连接的考察。在 NOI 竞赛中,能否一次性写对这类指针逻辑,直接决定了你在处理高级数据结构(如平衡树、Splay 的区间翻转)时的效率。
下面我们从“拆解合并”的朴素思路,过渡到“一趟扫描”的最优算法。
版本一:拆解与合并 (逻辑清晰的保底方案)
思路分析: 这是最符合直觉的方法:
- 先找到第
left-1个节点(pre)和第right+1个节点(succ)。 - 将
left到right这一段从原链表中“切下来”。 - 使用上一题(反转全链表)的算法反转这一小段。
- 将反转后的片段接回
pre和succ之间。
- 时间复杂度:,虽然遍历了两两次,但依然是线性复杂度。
- 空间复杂度:。
- 易错点:如果不使用
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刚数完的那个位置。- 时间复杂度:,严格只遍历一次。
- 空间复杂度:。
- 关键点:理解
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; }
三、 复杂度分析与思考过程
-
时间复杂度分析:
- 定位
pre:消耗 。 - 区间变换:循环执行了 次,每次操作都是 的指针交换。
- 总计:,最坏情况下 ,故为 。
- 优化建议:对于 这种极小规模,任何线性算法都能在 1ms 内完成。但在处理百万级数据时,一趟扫描(版本二)比两趟扫描(版本一)能节省约一半的常数时间。
- 定位
-
空间复杂度分析:
- 我们只定义了
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++]; }
- 我们只定义了
四、 考场避坑指南
-
左边界陷阱: 很多同学不写
dummy,导致left=1时无法通过pre = pre->next找到“前驱”。记住:只要链表的头节点可能变,就必须加dummy。 -
指针丢失: 在执行
cur->next = nxt->next之前,必须先保存nxt = cur->next。链表题的本质就是“不要弄丢了还没处理的节点地址”。 -
循环次数计算: 反转长度为 的区间,只需要进行 次“头插”操作。例如反转
[2, 4],长度为 3,只需插 2 次。 -
数据范围: 本题 非常小,如果题目的 达到 ,则严禁使用递归法,因为递归深度过大会导致系统栈溢出(Runtime Error)。版本二的迭代法是真正的全场景通用解。
- 先找到第
- 1
信息
- ID
- 19405
- 时间
- 1000ms
- 内存
- 128MiB
- 难度
- 10
- 标签
- (无)
- 递交数
- 1
- 已通过
- 1
- 上传者