2 条题解
-
0
为了方便你创建 OJ(Online Judge)测试数据,我为你编写了一个集成化的数据生成器。它不仅能生成符合 NOI 规范的随机输入数据(
.in),还会自动调用标准 算法生成对应的答案文件(.out)。该生成器涵盖了从极限边界到随机噪声,再到高相似度的各种典型场景,确保能有效区分选手的代码质量。
数据生成器 C++14 代码
#include <iostream> #include <fstream> #include <string> #include <vector> #include <random> #include <algorithm> #include <ctime> using namespace std; /** * 核心逻辑:标准 O(NM) 动态规划 * 用于生成测试点的标准答案 (.out) */ int get_standard_answer(const string& A, const string& B) { int n = A.length(); int m = B.length(); // 使用 vector 动态分配空间,避免大数组在栈上溢出 vector<vector<int>> dp(n + 1, vector<int>(m + 1)); for (int i = 0; i <= n; ++i) dp[i][0] = i; for (int j = 0; j <= m; ++j) dp[0][j] = j; for (int i = 1; i <= n; ++i) { for (int j = 1; j <= m; ++j) { if (A[i - 1] == B[j - 1]) { dp[i][j] = dp[i - 1][j - 1]; } else { dp[i][j] = min({dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]}) + 1; } } } return dp[n][m]; } /** * 随机字符串生成工具 */ string gen_random_string(int len, mt19937& rng) { string s = ""; uniform_int_distribution<int> dist(0, 25); for (int i = 0; i < len; ++i) { s += (char)('a' + dist(rng)); } return s; } /** * 构造特定相似度的数据(用于模拟微小改动的情况) */ string gen_similar_string(string base, int edits, mt19937& rng) { string res = base; uniform_int_distribution<int> pos_dist(0, (int)res.length() - 1); uniform_int_distribution<int> op_dist(0, 2); uniform_int_distribution<int> char_dist(0, 25); for (int i = 0; i < edits; ++i) { int op = op_dist(rng); if (res.empty()) op = 1; // 只能插入 if (op == 0 && !res.empty()) { // 删除 res.erase(pos_dist(rng) % res.length(), 1); } else if (op == 1) { // 插入 string c = ""; c += (char)('a' + char_dist(rng)); res.insert(res.empty() ? 0 : pos_dist(rng) % res.length(), c); } else if (op == 2 && !res.empty()) { // 替换 res[pos_dist(rng) % res.length()] = (char)('a' + char_dist(rng)); } } return res; } void create_test_case(int id, int n, int m, int type) { mt19937 rng(time(0) + id); string A, B; // 根据测试点类型构造数据 if (type == 0) { // 边界情况:极短字符串 A = gen_random_string(n, rng); B = gen_random_string(m, rng); } else if (type == 1) { // 边界情况:完全相等 A = gen_random_string(n, rng); B = A; } else if (type == 2) { // 场景:高相似度(模拟只改了几处) A = gen_random_string(n, rng); B = gen_similar_string(A, m, rng); // 此处 m 代表修改次数 } else if (type == 3) { // 场景:完全不同字符集 for (int i = 0; i < n; i++) A += 'a'; for (int i = 0; i < m; i++) B += 'b'; } else { // 常规随机数据 A = gen_random_string(n, rng); B = gen_random_string(m, rng); } // 写入输入文件 string in_name = to_string(id) + ".in"; ofstream fin(in_name); fin << A << "\n" << B << endl; fin.close(); // 写入输出文件 string out_name = to_string(id) + ".out"; ofstream fout(out_name); fout << get_standard_answer(A, B) << endl; fout.close(); cout << "Generated Test Case " << id << ": A=" << A.length() << " B=" << B.length() << endl; } int main() { // 测试点配置:测试点编号, N范围, M范围, 类型 // 类型 0:微型, 1:相等, 2:相似, 3:极差异, 4:大随机 create_test_case(1, 1, 1, 0); // 最小边界 create_test_case(2, 2000, 1, 0); // 极大与极小对比 create_test_case(3, 100, 100, 1); // 完全相等 create_test_case(4, 500, 500, 3); // 完全不同字符 create_test_case(5, 1000, 50, 2); // 长串微改 create_test_case(6, 1500, 1500, 4); // 中型随机 create_test_case(7, 2000, 1999, 4); // 极限随机 create_test_case(8, 2000, 2000, 2); // 极限相似(最容易错点) create_test_case(9, 2000, 2000, 3); // 极限差异 create_test_case(10, 2000, 2000, 4); // 极限随机 return 0; }
设计说明及优化点分析
1. 覆盖情况设计(覆盖测试点的科学性)
- Case 1 (最小边界):。测试程序是否能处理最小长度。
- Case 3 (完全相等):测试程序在字符相等时是否能正确跳转
+0。 - Case 5 & 8 (高相似度):这是你之前容易错的地方(多加了 1)。通过构造只有少量增删改的数据,强迫程序在“匹配成功”和“不成功”之间频繁切换。
- Case 2 & 9 (极限规模与巨大差异):测试 算法的性能极限。
2. 时间与空间优化建议
- 空间优化:在生成器中,我使用了
vector<vector<int>>动态分配。在实际 OJ 评测中,如果 达到 以上,必须强制使用滚动数组,否则会爆内存(Memory Limit Exceeded)。 - 生成效率:由于采用了迭代(非递归)DP 计算答案,生成 10 个极限测试点的总耗时约在 0.2s - 0.5s 之间,完全符合你的快速创建需求。
- 安全性:
- 无除法操作:避开了 Division by zero。
- 非递归逻辑:
get_standard_answer完全采用双重循环,绝对不会产生爆栈(Stack Overflow)风险。
3. 数据文件大小控制
- 输入数据最大为两个 2000 长度的字符串,单个
.in文件约 4KB。 - 10 个测试点的总大小约为 40KB,远低于你要求的 2MB 理想值。这对于 OJ 系统的上传和加载非常友好。
4. 使用方法
- 将上述代码保存为
gen.cpp。 - 使用
g++ -std=c++14 gen.cpp -o gen编译。 - 运行
./gen。 - 当前目录下会立即生成
1.in到10.out共 20 个文件,你可以直接打包上传到你的 OJ 题目后台。
-
0
你好!作为教练,我非常认同“从暴力到最优”的思考方式。在 NOI 竞赛中,很多复杂的 DP 都是从**搜索(DFS)**原型演变而来的。
下面我将为你演示这道题的三个阶段:从指数级暴力的递归,到消除重复子问题的记忆化搜索,再到最终的递推 DP。
第一阶段:简单暴力版本(纯递归)
思路思考过程: 我们在草稿纸上模拟:对于 和 ,
- 如果它们相等,就看 和 。
- 如果不相等,我们有三条路:
- 修改:强行让它们相等,代价 +1。
- 删除:把 扔了,代价 +1,用 去匹配 。
- 插入:在 前面补一个跟 一样的,代价 +1,用 去匹配 。
#include <iostream> #include <string> #include <algorithm> using namespace std; // 暴力递归函数:计算 A 串从 i 开始和 B 串从 j 开始的编辑距离 int solve(string A, string B, int i, int j) { // 边界情况:其中一个串跑完了 if (i == A.length()) return B.length() - j; // A 完了,B 剩下的全靠插入 if (j == B.length()) return A.length() - i; // B 完了,A 剩下的全删掉 // 情况 1:当前字符相等 if (A[i] == B[j]) { return solve(A, B, i + 1, j + 1); } // 情况 2:字符不等,尝试三种操作取最小值 int res_replace = solve(A, B, i + 1, j + 1) + 1; // 修改 int res_delete = solve(A, B, i + 1, j) + 1; // 删除 int res_insert = solve(A, B, i, j + 1) + 1; // 插入 return min({res_replace, res_delete, res_insert}); } int main() { string A, B; if (!(cin >> A >> B)) return 0; // 暴力版本在长度 > 15 时就会因为复杂度爆炸而超时 (TLE) cout << solve(A, B, 0, 0) << endl; return 0; }- 时间复杂度:。每个状态分裂出 3 个分支,典型的指数级复杂度。
- 空间复杂度:,主要是递归系统的栈空间。
第二阶段:记忆化搜索(加个“记事本”)
思路思考过程: 在暴力递归中,我们会发现很多
solve(i, j)被反复计算。比如:先删 A 再插 A,和先换 A 再保持 A,可能都会走到同一个 。 我们在纸上画一个memo[i][j]表,算过一次就记下来,下次直接取。#include <iostream> #include <string> #include <vector> #include <algorithm> using namespace std; int memo[2005][2005]; int solve(const string& A, const string& B, int i, int j) { if (i == A.length()) return B.length() - j; if (j == B.length()) return A.length() - i; // 如果已经算过了,直接返回,不再递归 if (memo[i][j] != -1) return memo[i][j]; int res; if (A[i] == B[j]) { res = solve(A, B, i + 1, j + 1); } else { res = min({solve(A, B, i + 1, j + 1), solve(A, B, i + 1, j), solve(A, B, i, j + 1)}) + 1; } return memo[i][j] = res; // 记录答案 } int main() { string A, B; cin >> A >> B; // 初始化记事本为 -1 for(int i=0; i<2005; ++i) for(int j=0; j<2005; ++j) memo[i][j] = -1; cout << solve(A, B, 0, 0) << endl; return 0; }- 时间复杂度:。每个状态只算一次,表格填满就结束。
- 空间复杂度:。
第三阶段:最优复杂度版本(递推 DP + 滚动数组)
思路思考过程: 递归会有函数调用的开销(NOI 竞赛中需注意)。我们将其改为迭代(递推)。 进一步观察:求
dp[i][j]只需要dp[i-1][j]、dp[i][j-1]和dp[i-1][j-1]。这意味着我不需要存整张表,只需要当前行和上一行。#include <iostream> #include <string> #include <vector> #include <algorithm> using namespace std; /** * NOI 风格的高效写法 */ int main() { // 提升 I/O 效率 ios::sync_with_stdio(false); cin.tie(0); string A, B; if (!(cin >> A >> B)) return 0; int n = A.length(); int m = B.length(); // 空间优化:只用两行,f[curr] 和 f[prev] vector<int> f(m + 1); vector<int> prev(m + 1); // 1. 初始化第 0 行:从空串 A 变成前 j 个 B,需要 j 次插入 for (int j = 0; j <= m; ++j) prev[j] = j; // 2. 逐行递推 for (int i = 1; i <= n; ++i) { f[0] = i; // 每一行的起点:从前 i 个 A 变成空串 B,需要 i 次删除 for (int j = 1; j <= m; ++j) { // 【关键易错点】字符串下标从 0 开始,状态 i 对应 A[i-1] if (A[i - 1] == B[j - 1]) { f[j] = prev[j - 1]; // 相等则不操作 } else { // 不等则取左(插入)、上(删除)、左上(替换)的最小值 + 1 // f[j-1] 是左方,prev[j] 是上方,prev[j-1] 是左上方 f[j] = min({prev[j - 1], prev[j], f[j - 1]}) + 1; } } prev = f; // 完成一行,交换(或者用 i%2 滚动) } cout << (n == 0 ? m : (m == 0 ? n : prev[m])) << endl; return 0; }
总结分析
1. 复杂度思考过程
- 时间:无论是记忆化搜索还是递推,都是 。对于 的数据规模,总计算次数是 。在 1.0s 的时限下,这个量级非常轻松。
- 空间:
- 二维数组版:$2000 \times 2000 \times 4 \text{ bytes} \approx 16 \text{ MB}$。
- 滚动数组版:$2000 \times 2 \times 4 \text{ bytes} \approx 16 \text{ KB}$。
2. 时间复杂度优化建议
- 常数优化:在内层循环中,
std::min({a, b, c})在某些老旧编译器上比手写两个min(a, min(b, c))慢一点点。 - 特定场景优化:如果字符串非常大但编辑距离很小(比如 ),可以使用 算法。原理是只计算 DP 表中对角线附近宽度为 的区域,因为距离太远的路径不可能产生最优解。
3. 读题关键词与易错点
- 关键词:看到“三种操作”、“最少次数”且涉及“两个序列”,无脑直接联想 二维 DP。
- 易错点 (1):下标偏移。
dp[i][j]通常表示长度,取字符时要用A[i-1]。 - 易错点 (2):相等不加一。很多同学习惯性地给所有转移都
+1,要牢记:DP 的+1代表一次“动作”,如果两个字符本身就相等,我们不需要做任何动作,所以不加 1。
希望这次的完整演进能帮你彻底理解这道经典题!
- 1
信息
- ID
- 6561
- 时间
- 1000ms
- 内存
- 128MiB
- 难度
- 5
- 标签
- 递交数
- 3
- 已通过
- 1
- 上传者