2 条题解
-
0
这是一个非常专业的需求。在搭建OJ(Online Judge)或进行比赛命题时,一套覆盖全面、具有强壮性的测试数据是至关重要的。
由于本题数据范围较小 (),我们不需要担心数据生成的时间复杂度,重点在于测试用例的覆盖面(边界值、空值、全删、全保留等)。
以下是完整的 C++ 数据生成器代码。该程序运行后,会在当前目录下生成
1.in/1.out到10.in/10.out共20个文件。数据生成器代码 (Data Generator)
/** * 题目:数组元素的就地移除 - 数据生成器 * 用途:生成测试点 1.in/out ~ 10.in/out * 标准:C++14 * 策略:覆盖空数组、单元素、全删除、全保留、部分删除、最大数据范围等情况 */ #include <iostream> #include <fstream> #include <vector> #include <string> #include <random> #include <algorithm> #include <chrono> using namespace std; // 全局随机数引擎 mt19937 rng(chrono::steady_clock::now().time_since_epoch().count()); // 生成指定范围内的随机整数 [min, max] int randomInt(int min, int max) { uniform_int_distribution<int> dist(min, max); return dist(rng); } // 核心解题逻辑:用于生成 .out 文件 // 返回 pair<新长度, 新数组> pair<int, vector<int>> solve(vector<int> nums, int val) { int k = 0; int n = nums.size(); // 模拟题目要求的“原地移除”逻辑,这里为了方便输出,我们直接在原副本上操作 for (int i = 0; i < n; ++i) { if (nums[i] != val) { nums[k] = nums[i]; k++; } } // 截断数组,只保留前k个有效元素 nums.resize(k); return {k, nums}; } // 保存测试点文件 void saveTestCase(int caseNum, int n, const vector<int>& nums, int val) { string inName = to_string(caseNum) + ".in"; string outName = to_string(caseNum) + ".out"; // 1. 写入 .in 文件 ofstream fin(inName); fin << n << "\n"; for (int i = 0; i < n; ++i) { fin << nums[i] << (i == n - 1 ? "" : " "); } fin << "\n" << val << "\n"; fin.close(); // 2. 计算答案并写入 .out 文件 pair<int, vector<int>> result = solve(nums, val); int k = result.first; vector<int> resNums = result.second; ofstream fout(outName); fout << k << "\n"; for (int i = 0; i < k; ++i) { fout << resNums[i] << (i == k - 1 ? "" : " "); } fout << "\n"; fout.close(); cout << "Generated Case " << caseNum << ": N=" << n << ", Val=" << val << ", Removed=" << (n - k) << endl; } int main() { // 【测试点 1-2】:小规模随机数据 (N <= 10) for (int i = 1; i <= 2; ++i) { int n = randomInt(5, 10); vector<int> nums(n); for(int& x : nums) x = randomInt(0, 10); int val = randomInt(0, 5); // 较高概率命中 saveTestCase(i, n, nums, val); } // 【测试点 3】:边界情况 - 空数组 (N = 0) { saveTestCase(3, 0, {}, 5); } // 【测试点 4】:边界情况 - 单元素数组,且需要移除 (N = 1, match) { saveTestCase(4, 1, {10}, 10); } // 【测试点 5】:边界情况 - 单元素数组,不需要移除 (N = 1, no match) { saveTestCase(5, 1, {10}, 20); } // 【测试点 6】:特殊情况 - 全部元素都需要移除 (All match) { int n = 100; int val = 7; vector<int> nums(n, val); // 数组全为7 saveTestCase(6, n, nums, val); } // 【测试点 7】:特殊情况 - 没有元素需要移除 (No match) { int n = 100; int val = 50; vector<int> nums(n); for(int& x : nums) x = randomInt(0, 49); // 保证不等于50 saveTestCase(7, n, nums, val); } // 【测试点 8】:特殊情况 - 元素值只有两种 (Dense binary case) // 模拟类似 [0,1,0,1,1,0] 的情况,测试密集删除 { int n = 100; int val = 1; vector<int> nums(n); for(int& x : nums) x = randomInt(0, 1); // 只有0或1 saveTestCase(8, n, nums, val); } // 【测试点 9】:中等规模随机 - val 取值在范围外 (Boundary Val) // 题目数据范围 nums[i] <= 50, val <= 100。测试 val > 50 的情况 { int n = 80; int val = 99; // 数组里最大只有50,所以肯定删不掉任何东西 vector<int> nums(n); for(int& x : nums) x = randomInt(0, 50); saveTestCase(9, n, nums, val); } // 【测试点 10】:大规模随机数据 (N = 100) // 综合强度测试 { int n = 100; int val = randomInt(0, 50); vector<int> nums(n); // 让 val 出现的频率大约在 1/10 左右,稍微稀疏一点 for(int& x : nums) { if (randomInt(0, 9) == 0) x = val; else x = randomInt(0, 50); } saveTestCase(10, n, nums, val); } cout << "All 10 test cases generated successfully!" << endl; return 0; }数据覆盖点说明表
为了确保你的测试数据具有说服力,你可以参考下表来检查覆盖情况:
测试点编号 的大小 数据特征 考察目的 预期结果 1-2 小 () 纯随机 基础功能验证 正常移除 3 空数组 边界健壮性:程序是否会崩溃或越界 4 唯一元素 == val边界健壮性:删成空数组 5 唯一元素 != val边界健壮性:保留唯一元素 6 (Max) 所有元素都等于 val极端情况:能否处理全部删除 7 没有元素等于 val极端情况:能否处理全部保留 8 只有 0 和 1 密集数据:频繁的移除操作 9 val不在数组值域内逻辑检查: val很大时的处理10 (Max) 大规模随机 综合性能测试 随机 核心知识点与生成器设计思路
- 文件流操作 (
ofstream):生成器需要频繁创建文件,使用to_string(i) + ".in"动态生成文件名非常方便。 - 随机数分布 (
uniform_int_distribution):C++11 的<random>库比传统的rand()更均匀,更适合生成竞赛数据,避免伪随机数的周期性问题。 - 解耦设计:将“解题逻辑”封装在
solve函数中。这不仅让代码清晰,而且保证了生成的.out文件绝对是基于你的逻辑算出来的,所见即所得。 - 边界覆盖:
- 往往是许多初学者代码
Segmentation Fault的原因(例如直接访问nums[0]而不检查长度)。 - 全删(结果为空)容易导致输出格式错误(例如输出了多余的空格或换行)。
- 往往是许多初学者代码
将这段代码保存为
gen.cpp,编译运行即可在当前文件夹下得到全套测试数据。 - 文件流操作 (
-
0
你好!我是你的OI竞赛教练。
针对题目《数组元素的就地移除》,这是在NOI/CSP普及组或提高组初赛中考察线性表操作与双指针算法的经典模型。
下面我将给出标准的 AC (Accepted) 代码,采用 NOIP C++14 标准编写,并附带详细的复杂度分析与优化思路。
一、 标准题解代码 (C++14)
这份代码可以直接在本地编译运行,也可以用于提交评测。我在关键逻辑处加入了详细注释,特别是新手容易写错的边界条件。
/* * 题目:数组元素的就地移除 (Remove Element In-Place) * 算法:双指针法 (快慢指针) * 时间复杂度:O(N) * 空间复杂度:O(1) * 编写标准:NOIP/CSP C++14 */ #include <iostream> #include <vector> using namespace std; int main() { // 【关键点1】关闭流同步,加速cin/cout,防止在大数据量下超时 // 这是NOI系列竞赛的必备习惯 ios::sync_with_stdio(false); cin.tie(nullptr); int n; // 检查输入流状态,虽然题目保证输入合法,但这是一个好习惯 if (!(cin >> n)) return 0; // 定义数组 // 这里使用vector动态调整大小,也可以根据数据范围(N<=100)开静态数组 int nums[105]; vector<int> nums(n); for (int i = 0; i < n; ++i) { cin >> nums[i]; } int val; cin >> val; // 【核心算法】双指针 - 快慢指针法 // k (慢指针): 指向当前“新数组”的末尾,即下一个“非val元素”应该存放的位置 // i (快指针): 负责遍历原数组,寻找“非val元素” int k = 0; for (int i = 0; i < n; ++i) { // 【易错点1】判断条件是 != val,我们保留的是“不等于”val的数 if (nums[i] != val) { // 将找到的好数 nums[i] 复制到 nums[k] 的位置 nums[k] = nums[i]; // 【易错点2】赋值后,慢指针才向后移动 // 如果写成 nums[k++] = nums[i]; 也是可以的,更简洁 k++; } // 如果 nums[i] == val,快指针 i 继续跑,慢指针 k 原地不动, // 相当于把这个坏数“逻辑上”跳过了(实际上稍后会被后面的好数覆盖)。 } // 【输出处理】 // 第一行输出新长度 cout << k << endl; // 第二行输出新数组的内容 // 【易错点3】注意遍历范围是 0 到 k-1,不要输出原数组长度 n for (int i = 0; i < k; ++i) { // 控制空格:最后一个元素后面通常不加空格,虽然本题可能不严格,但这是竞赛标准格式 cout << nums[i] << (i == k - 1 ? "" : " "); } cout << endl; // 保持输出完整性 return 0; }
二、 复杂度分析思考过程
在竞赛中,写完代码后的第一件事就是分析复杂度,确保不会 TLE (Time Limit Exceeded) 或 MLE (Memory Limit Exceeded)。
1. 时间复杂度分析
- 思考过程: 我们看代码中的主循环
for (int i = 0; i < n; ++i)。 - 推导: 快指针
i从0走到n-1,数组中的每个元素被访问了一次。循环内部的操作(比较、赋值、自增)都是 的常数级操作。 - 结论: 总时间复杂度为 。其中 是数组的长度。
- 对比: 如果使用暴力法(发现一个删一个,把后面的所有元素往前挪),删除一个元素需要移动后面所有元素,最坏情况是 ,总共有 个元素,总复杂度会退化为 。在 时暴力法会超时,而双指针法依然能在 0.1秒内完成。
2. 空间复杂度分析
- 思考过程: 我们申请了什么新的内存空间?
- 推导: 除了存储输入数据的数组
nums(这是必须的)之外,我们只定义了两个整数变量i和k以及n,val。 - 结论: 额外使用的空间是常数级别的,即 。符合题目“原地 (In-Place)”的要求。
三、 优化建议 (进阶思考)
虽然 已经是该问题的最优时间复杂度量级,但在特定数据分布下,我们还可以通过减少赋值次数来进行常数级优化。
场景假设: 数组很大(例如 ),但要移除的元素
val非常少(例如只有 1 个),且题目允许打乱元素顺序。当前解法不足: 如果数组是
[1, 2, 3, 4, 5],val = 1。 快慢指针法会将 2, 3, 4, 5 全部向前移动一位。操作了 4 次赋值。优化思路:对撞指针(交换移除) 当我们遇到一个
val时,不需要把后面的所有人都往前挪,我们可以直接把数组最后一个元素搬过来,覆盖掉当前的val,然后数组长度减 1。优化代码片段:
// 优化版核心逻辑(注意:这会改变元素相对顺序) int left = 0; int right = n; // 有效长度 while (left < right) { if (nums[left] == val) { // 发现坏数,直接用当前有效数组的最后一个元素覆盖它 nums[left] = nums[right - 1]; // 数组有效长度减小 right--; // 注意:这里 left 不自增,因为新换过来的数(nums[right-1])可能也是 val, // 下一轮循环需要再次检查 nums[left] } else { left++; } } return right;对比总结:
- 快慢指针(标准解法): 保持元素相对顺序,赋值次数等于“好元素”的数量。
- 对撞指针(优化解法): 不保持相对顺序,赋值次数等于“坏元素”的数量。
- 竞赛建议: 除非题目明确要求“最少操作次数”或数据极度特殊,否则优先使用快慢指针。因为它逻辑更顺畅,不易写出死循环,且“保持相对顺序”在大多数后续题目中是一个默认的有利条件。
- 思考过程: 我们看代码中的主循环
- 1
信息
- ID
- 19333
- 时间
- 1000ms
- 内存
- 128MiB
- 难度
- 10
- 标签
- (无)
- 递交数
- 1
- 已通过
- 1
- 上传者