2 条题解
-
0
太棒了!为这种“计数模式”的题目构造测试数据非常直接,重点是要覆盖所有可能让计数器漏算、多算,或者让暴力算法 超时的极端情况。
由于字符串长度 ,每个输入文件大小最大仅为 KB 左右。10个测试点加起来也就 MB 出头,完美满足你 2MB 以内的要求。
以下是为你编写的 C++ 数据生成器代码及测试点设计说明。
测试点设计说明 (10个测试点)
我们通过精心构造字符串的形态,来全面检测选手代码的健壮性:
- Test 1: 。最短边界。只有一个字符
'b'。验证基础输入输出和不崩溃。 - Test 2: 。最小删除。输入
"ba",必须删除其中一个使其平衡。 - Test 3: 。小数据随机。用于肉眼对账或基础逻辑验证。
- Test 4: 。完全平衡状态。前半部分全是
'a',后半部分全是'b'。答案必须为 0。测试代码是否会错误地多删。 - Test 5: 。完全倒置状态。前半部分全是
'b',后半部分全是'a'。这需要极其巨大的修改量(删掉一半字符)。 - Test 6: 。纯 'a' 序列。没有任何
'b'。验证边界情况(极易引发索引越界)。 - Test 7: 。纯 'b' 序列。没有任何
'a'。同上,测试前缀全空、后缀全满的边界。 - Test 8: 。高频交替 "bababa..."。针对 暴力法量身定做。每次寻找分界点都需要进行大量计数,极大增加常数,确保卡死暴力。
- Test 9: 。带偏好的随机 (90% 'a', 10% 'b')。伪随机大数据,考察答案不在正中间的情况。
- Test 10: 。纯随机大数据 (50% 'a', 50% 'b')。大乱斗综合测试。
C++ 数据生成器代码
你可以将以下代码保存为
data_maker.cpp并编译运行。它会在几百毫秒内飞速生成所有.in和.out文件。#include <iostream> #include <fstream> #include <string> #include <vector> #include <random> #include <algorithm> using namespace std; // 【标准答案程序】使用最优 O(N) 空间 O(1) 的滚动计数算法计算答案,用于生成 .out int solve_optimal(int n, const string& s) { int a_count_right = 0; for (char c : s) { if (c == 'a') a_count_right++; } int b_count_left = 0; int min_del = a_count_right; // 初始假设分界线在最左侧 for (int i = 0; i < n; ++i) { if (s[i] == 'a') a_count_right--; else b_count_left++; min_del = min(min_del, b_count_left + a_count_right); } return min_del; } // 随机数引擎初始化 (固定种子 1337 保证生成数据可复现) mt19937 rng(1337); // 生成 0 到 99 之间的随机数,用于按概率生成字符 int rand_100() { uniform_int_distribution<int> dist(0, 99); return dist(rng); } // 核心生成逻辑 void generate_test_case(int test_id) { int n = 0; string s = ""; switch (test_id) { case 1: // 边界情况 N=1 n = 1; s = "b"; break; case 2: // 最小错误情况 N=2 n = 2; s = "ba"; break; case 3: // 较小随机数据 n = 20; for(int i=0; i<n; ++i) s += (rand_100() < 50 ? 'a' : 'b'); break; case 4: // 完全平衡状态 aaaa...bbbb... (答案应该为 0) n = 100000; s = string(50000, 'a') + string(50000, 'b'); break; case 5: // 完全倒置 bbbb...aaaa... n = 100000; s = string(50000, 'b') + string(50000, 'a'); break; case 6: // 纯粹的 'a' n = 100000; s = string(n, 'a'); break; case 7: // 纯粹的 'b' n = 100000; s = string(n, 'b'); break; case 8: // 高频交替 bababa... n = 100000; for(int i=0; i<n; ++i) s += (i % 2 == 0 ? 'b' : 'a'); break; case 9: // 带偏好随机: 90% a, 10% b n = 100000; for(int i=0; i<n; ++i) s += (rand_100() < 90 ? 'a' : 'b'); break; case 10: // 纯随机大数据 n = 100000; for(int i=0; i<n; ++i) s += (rand_100() < 50 ? 'a' : 'b'); break; } // 1. 生成 .in 文件 string in_filename = to_string(test_id) + ".in"; ofstream fin(in_filename); if (!fin) { cerr << "无法创建文件: " << in_filename << "\n"; return; } fin << n << "\n" << s << "\n"; fin.close(); // 2. 使用标程计算答案并生成 .out 文件 int ans = solve_optimal(n, s); string out_filename = to_string(test_id) + ".out"; ofstream fout(out_filename); fout << ans << "\n"; fout.close(); cout << "成功生成测试点 " << test_id << " (N = " << n << ", 预期答案 = " << ans << ")\n"; } int main() { // 关闭同步,加速控制台输出 ios::sync_with_stdio(false); cin.tie(nullptr); cout << "开始生成字符串平衡测试数据...\n"; for (int t = 1; t <= 10; ++t) { generate_test_case(t); } cout << "全部生成完毕!数据精简且全面,可直接打包至 OJ 平台。\n"; return 0; }质量与安全评估
- 极度轻量与极速生成:我们使用了
std::string(length, char)这种内置的高效构造方法来生成规律字符串,而对于随机字符串则在 单次循环内完成构造,没有任何递归调用(0 爆栈风险)和复杂的内存重新分配,整体瞬间执行完成。 - 没有除以 0 风险:无论是数据生成逻辑还是标程逻辑,均只有最基本的加减和比较运算。
- 精确打击弱点代码:如果在前缀后缀计数时,选手错误地写成了 的暴力统计,或者用前缀数组时把大小误设为 (正确应该是 才能包含最左/最右分割点),Test 4、Test 6 和 Test 7 会精确命中这些常见的 Segment Fault 越界或 TLE 超时。
编译命令建议:
g++ data_maker.cpp -o data_maker -O2 -std=c++14 - Test 1: 。最短边界。只有一个字符
-
0
将刚才草稿纸上的思路转化为严谨的代码,是算法竞赛中最激动人心的时刻。
对于这种“寻找完美分割点”的问题,在NOI(信息学奥赛)的考场上,我们通常也是遵循**“先想暴力 -> 寻找重复计算 -> 预处理优化 -> 空间压缩”**的路径。
下面我将为你提供三个版本的标准 C++14 代码,层层递进。
版本一:基础暴力版 (Brute Force)
思路: 字符串长度为 ,一共有 个可能的“分界线”位置(从下标 的左边,一直到下标 的右边)。我们枚举每一个分界线位置,每次都老老实实地去数左半边有多少个
'b',右半边有多少个'a',加起来就是当前划分的删除代价。#include <iostream> #include <string> #include <algorithm> using namespace std; int main() { // NOI 必备:优化标准输入输出速度 ios::sync_with_stdio(false); cin.tie(nullptr); int n; if (!(cin >> n)) return 0; string s; cin >> s; // 将答案初始化为一个足够大的数,最多把字符串全删了,也就是 n 次 int min_del = n; // 枚举分界线 i,i 表示左半部分包含的字符个数 (0 到 n) // 当 i=0 时,左边没字符;当 i=n 时,右边没字符 for (int i = 0; i <= n; ++i) { int b_count_left = 0; int a_count_right = 0; // 1. 统计左半部分 [0, i-1] 中的 'b' 的数量 for (int j = 0; j < i; ++j) { if (s[j] == 'b') { b_count_left++; } } // 2. 统计右半部分[i, n-1] 中的 'a' 的数量 for (int j = i; j < n; ++j) { if (s[j] == 'a') { a_count_right++; } } // 3. 更新最小删除次数 min_del = min(min_del, b_count_left + a_count_right); } cout << min_del << "\n"; return 0; }- 时间复杂度分析: 外层循环 次。内层有两个并列的循环,它们的执行次数加起来恰好是 。所以总计算次数是 。时间复杂度为 。当 时,一定会 TLE (超时)。
- 空间复杂度分析: 除了存储输入字符串本身,只使用了几个局部整数变量。空间复杂度为 。
版本二:预处理前后缀数组版 (空间换时间)
思路: 我们发现在暴力版中,随着分界线
i一步步向右移动,左边的'b'和右边的'a'被反复重复计算了无数次。利用“左右模式”,我们可以开辟两个数组dpLeft和dpRight,提前把每个位置的前缀'b'数量和后缀'a'数量算好。#include <iostream> #include <string> #include <vector> #include <algorithm> using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; if (!(cin >> n)) return 0; string s; cin >> s; // dpLeft[i] 记录下标 i 左侧 (不含 i) 有多少个 'b' // 数组大小开 n + 1 是为了处理分界线在最右端的情况 vector<int> dpLeft(n + 1, 0); for (int i = 1; i <= n; ++i) { // dpLeft[i] 的值基于前一个状态,如果是 'b' 就额外加 1 dpLeft[i] = dpLeft[i - 1] + (s[i - 1] == 'b' ? 1 : 0); } // dpRight[i] 记录从下标 i 开始到末尾,有多少个 'a' vector<int> dpRight(n + 1, 0); for (int i = n - 1; i >= 0; --i) { // 从右往左推,如果是 'a' 就额外加 1 dpRight[i] = dpRight[i + 1] + (s[i] == 'a' ? 1 : 0); } int min_del = n; // 【关键点】枚举 O(1) 查询:现在枚举分割点 i 只需要查表即可 for (int i = 0; i <= n; ++i) { // 分割点在 i 处,总代价 = 左边的 'b' + 右边及其自身的 'a' min_del = min(min_del, dpLeft[i] + dpRight[i]); } cout << min_del << "\n"; return 0; }- 时间复杂度分析: 我们用了两次独立的 循环来预处理数组,最后用一次 循环来查找最小值。这属于平行的遍历,时间复杂度降维至 。通过 数据毫无压力。
- 空间复杂度分析: 额外开辟了两个长度为 的
vector数组。空间复杂度为 。
版本三:最优状态滚动版 (单次遍历的极致优化)
思路: 顺着上一版的思路继续深挖。既然我们最后计算答案是从左向右顺序枚举分界线
i的,我们完全没必要用数组把所有的dpRight和dpLeft都存下来。我们用两个变量模拟分界线的移动即可!#include <iostream> #include <string> #include <algorithm> using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; if (!(cin >> n)) return 0; string s; cin >> s; // 1. 首先统计出整个字符串里有多少个 'a' int a_count_right = 0; for (int i = 0; i < n; ++i) { if (s[i] == 'a') { a_count_right++; } } int b_count_left = 0; // 【关键点】初始状态:假设分界线在最左侧(下标 0 之前) // 此时左边为空(需要删 0 个 b),右边是整个字符串(需要删所有 a_count_right 个 a) int min_del = a_count_right; // 2. 让分界线从左向右逐个跨越字符 for (int i = 0; i < n; ++i) { // 如果跨过的是 'a',那么它从右半区进入了左半区 // 左半区允许有 'a'(不需要删),但右半区的 'a' 少了一个(右边需要删的代价减小) if (s[i] == 'a') { a_count_right--; } // 【易错点】如果跨过的是 'b',右边的 'a' 数量不受影响 // 但是左边多了一个非法的 'b',需要删掉的 'b' 数量增加 else { b_count_left++; } // 计算当前分界线位置的总代价,并试图更新最小值 min_del = min(min_del, b_count_left + a_count_right); } cout << min_del << "\n"; return 0; }- 时间复杂度分析: 第一次遍历统计字符
'a',花费 ;第二次遍历移动分界线并更新答案,花费 。常数极小,总时间复杂度为卓越的 。 - 空间复杂度分析: 彻底抛弃了
dpLeft和dpRight数组。除了存储原字符串以外,仅引入了a_count_right和b_count_left这几个 的变量。辅助空间复杂度被完美压缩到了 。
教练总结
对比一下我们之前学的 Min-Max 和 Trend-Based 模式,你会发现这个 Counting 模式 最为特殊:
- 求极值和趋势时: 我们往往关注的是“前面某一个特定的值”对现在的影响。
- 计数模式时: 我们关注的是“前面所有符合条件的值的累计总和”对全局的影响。
在NOI竞赛中,版本三这种**“预统计总量 + 滑动更新”**的技巧,本质上就是极其基础的 “滑动窗口/双指针”的退化版 或者 “滚动前缀和”。 学会从 的“每次重新数” -> 空间的“把数过的记下来” -> 空间的“只记当前账本的盈亏”,你对一维动态规划状态优化的理解就已经入门了!
- 1
信息
- ID
- 19478
- 时间
- 1000ms
- 内存
- 128MiB
- 难度
- 10
- 标签
- 递交数
- 2
- 已通过
- 1
- 上传者