2 条题解
-
0
为了方便你创建 OJ(Online Judge)测试数据,我编写了一个完整的数据生成器。该程序遵循 NOIP C++14 标准,能够自动生成 1-10 号测试点的
.in和.out文件。数据生成器设计思路
-
测试点分布:
- 1:样例数据(验证基本逻辑)。
- 2:边界:,单个极长单词。
- 3:无重叠情况:随机字符串,重叠概率极低。
- 4:完全重叠:后一个单词是前一个单词的重复。
- 5:重叠长度为 1:测试最小重叠情况。
- 6:大字符集随机:混合数字和字母。
- 7:KMP 杀手/哈希杀手:大量重复字符(如
aaaaa...),专门攻击 暴力。 - 8:最大 值: 个短单词。
- 9:最大总长度:少量极长单词,总长 。
- 10:综合极端数据:长短结合,模拟真实竞赛压力。
-
性能优化:使用
std::ios::sync_with_stdio(0)并在解题函数中使用 的 KMP 算法,确保生成.out文件时不会超时。
完整代码 (DataGenerator.cpp)
#include <iostream> #include <fstream> #include <string> #include <vector> #include <random> #include <algorithm> #include <ctime> using namespace std; // ================== 标准答案算法 (KMP $O(L)$) ================== // 用于生成 .out 文件 string solve_std(int n, const vector<string>& words) { string res = ""; for (int i = 0; i < n; ++i) { const string& cur = words[i]; if (i == 0) { res = cur; continue; } int cur_len = cur.length(); int res_len = res.length(); int check_len = min(cur_len, res_len); // 构造匹配串: 新词前缀 + '#' + 结果串后缀 // 复杂度控制:只取 check_len 长度的后缀 string p = cur.substr(0, check_len) + "#" + res.substr(res_len - check_len); int p_len = p.length(); vector<int> nxt(p_len, 0); for (int i_kmp = 1, j = 0; i_kmp < p_len; ++i_kmp) { while (j > 0 && p[i_kmp] != p[j]) j = nxt[j - 1]; if (p[i_kmp] == p[j]) j++; nxt[i_kmp] = j; } int overlap = nxt[p_len - 1]; // 关键:使用 append 避免产生临时对象,保持 O(L) if (overlap < cur_len) { res.append(cur.begin() + overlap, cur.end()); } } return res; } // ================== 数据随机工具 ================== string random_string(int len, int type) { static const char alpha[] = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; static const char only_a[] = "aaaaaaab"; // 构造高重复度 string s = ""; static mt19937 rng(time(0)); for (int i = 0; i < len; ++i) { if (type == 0) s += alpha[rng() % 62]; else if (type == 1) s += only_a[rng() % 8]; else s += 'a'; } return s; } // ================== 主生成逻辑 ================== void generate_test(int test_id) { string in_name = to_string(test_id) + ".in"; string out_name = to_string(test_id) + ".out"; ofstream fout(in_name); int n = 0; vector<string> words; if (test_id == 1) { // 样例 n = 5; words = {"sample", "please", "ease", "in", "out"}; } else if (test_id == 2) { // 边界:n=1 n = 1; words.push_back(random_string(1000000, 0)); } else if (test_id == 3) { // 无重叠 n = 100; for(int i=0; i<n; ++i) words.push_back(random_string(1000, 0)); } else if (test_id == 4) { // 完全重叠 n = 1000; string s = random_string(1000, 0); for(int i=0; i<n; ++i) words.push_back(s); } else if (test_id == 7) { // KMP 杀手: aaaaaaa n = 1000; for(int i=0; i<n; ++i) words.push_back(random_string(1000, 2)); } else if (test_id == 8) { // 最大 n,短单词 n = 100000; for(int i=0; i<n; ++i) words.push_back(random_string(9, 0)); } else if (test_id == 9) { // 极长单词 n = 10; for(int i=0; i<n; ++i) words.push_back(random_string(100000, 1)); } else { // 综合随机 n = 50000; for(int i=0; i<n; ++i) words.push_back(random_string(15, 0)); } // 写入输入文件 fout << n << "\n"; for (int i = 0; i < n; ++i) { fout << words[i] << (i == n - 1 ? "" : " "); } fout.close(); // 计算标准输出 string result = solve_std(n, words); ofstream fans(out_name); fans << result << endl; fans.close(); cout << "Test Case " << test_id << " generated." << endl; } int main() { for (int i = 1; i <= 10; ++i) { generate_test(i); } return 0; }数据说明与 OJ 设置建议
- 文件说明:
- 每个
.in文件包含了单词数量 和对应的单词。 - 总长度在测试点 9 和 10 中达到了约 字符(约 1MB),符合 NOI 竞赛对中大型输入的要求。
- 每个
- 空间限制思考:
- 题目要求 256MB。生成器生成的
res字符串使用std::string,由于使用了reserve或append思想,内存占用极低(仅约 1-2MB)。
- 题目要求 256MB。生成器生成的
- 时间限制思考:
- 暴力 算法在测试点 7, 9, 10 中会超时。
- 单哈希算法在测试点 7 中可能会因为重复字符构造的特殊哈希值而产生碰撞,从而导致错误结果。
- 易错点提示(针对 OJ 裁判):
- 特殊字符:生成器在 KMP 构造时使用了
#作为分隔符。请确保题目描述中告知单词仅包含数字和字母,否则 KMP 分隔符需动态选择。 - 行末换行:
.out文件统一以endl结尾,建议 OJ 评测插件开启 "Ignore Trailing Space/Newline"。
- 特殊字符:生成器在 KMP 构造时使用了
读题关键词提取
在辅导学生时,请强调以下关键词的权重:
- "Order" (顺序):不能改变单词合并的先后。
- "Longest" (最长):必须匹配最大的 ,这是贪心合并的前提。
- "Suffix...Prefix" (后缀等于前缀):KMP 算法的核心定义。
- "Total Length ":决定了你不能使用任何 的子串提取方法。
-
-
0
在 NOI 竞赛中,字符串处理题目的核心往往在于如何通过预处理减少重复的比对。本题的进化过程是从“暴力字符比对”到“数值化比对(哈希)”,再到“利用失配函数(KMP)”的过程。
以下是三个阶段的完整可运行代码及分析。
第一阶段:朴素暴力算法 ()
思路思考: 最直观的想法是,对于每一个新词 ,我们从可能的最大重叠长度(新词长度和已合并串长度的最小值)开始,逐一尝试匹配。
#include <iostream> #include <string> #include <vector> #include <algorithm> using namespace std; int main() { ios::sync_with_stdio(false); // 竞赛必备:加速IO cin.tie(0); int n; cin >> n; string res; for (int i = 0; i < n; ++i) { string t; cin >> t; if (i == 0) { res = t; continue; } int max_overlap = min((int)res.length(), (int)t.length()); int overlap = 0; // 易错点:这里从大到小枚举可以保证找到“最长”匹配,但 substr 本身是 O(L) 的 // 总复杂度 = O(N_words * L_word_average^2) -> 实际最坏 $10^{12}$ for (int len = max_overlap; len >= 1; --len) { if (res.substr(res.length() - len) == t.substr(0, len)) { overlap = len; break; } } res += t.substr(overlap); } cout << res << endl; return 0; }复杂度分析:
- 时间:。在数据全为
aaaa...时,每次都要比对完整长度,总计算量爆炸。 - 空间:,仅存储最终字符串。
第二阶段:双哈希优化 ( - 概率性正确)
思路思考: 既然比对字符串慢,我们就把字符串映射成一个整数(哈希值)。 技巧:
- 后缀哈希动态维护:从左往右扫描新词的前缀,同时从右往左“扩展”旧串的后缀。
- 防卡策略:NOI 竞赛中单哈希(如
unsigned long long自然溢出)极其容易被构造数据卡掉,必须使用双哈希。
#include <iostream> #include <string> #include <vector> using namespace std; typedef long long ll; // 使用两个不同的质数模数,极大降低碰撞概率 const ll MOD1 = 1e9 + 7; const ll MOD2 = 1e9 + 9; const ll base = 131; // 常用字符哈希基数 int main() { ios::sync_with_stdio(false); cin.tie(0); int n; cin >> n; string res; for (int i = 0; i < n; ++i) { string t; cin >> t; if (i == 0) { res = t; continue; } int n_res = res.length(); int n_t = t.length(); int limit = min(n_res, n_t); ll h1_pre = 0, h2_pre = 0; // t 的前缀哈希 ll h1_suf = 0, h2_suf = 0; // res 的后缀哈希 ll p1 = 1, p2 = 1; int best_l = 0; for (int len = 1; len <= limit; ++len) { // t 的前缀哈希计算:新字符在低位 h1_pre = (h1_pre * base + t[len-1]) % MOD1; h2_pre = (h2_pre * base + t[len-1]) % MOD2; // res 的后缀哈希计算:新字符在高位 // 例如:已知 "abc" 后缀,增加 "d" 在前面变成 "dabc" h1_suf = (h1_suf + (ll)res[n_res - len] * p1) % MOD1; h2_suf = (h2_suf + (ll)res[n_res - len] * p2) % MOD2; p1 = (p1 * base) % MOD1; p2 = (p2 * base) % MOD2; if (h1_pre == h1_suf && h2_pre == h2_suf) { best_l = len; } } // 关键:+= 操作在 C++14 中性能良好 for (int j = best_l; j < n_t; ++j) res += t[j]; } cout << res << endl; return 0; }复杂度分析:
- 时间:。每个字符只被计算哈希常数次。
- 空间:。
- 评价:在大多数竞赛场景下此解法可通过,但由于其非确定性,存在极小概率被卡。
第三阶段:KMP 最优算法 ( - 确定性最优)
思路思考: 我们要寻找的是“新词 的前缀”与“旧串 的后缀”的最长匹配。这正是 KMP 算法中
next数组(最长相等前后缀)的变体应用。 关键优化: 为了不让 KMP 的匹配过程退化到 ,我们只取旧串 的最后 位参与比对。#include <iostream> #include <vector> #include <string> #include <algorithm> using namespace std; // 专门处理单词合并的 KMP 逻辑 int get_max_overlap(const string& t, const string& res) { int n = t.length(); int m = res.length(); int len = min(n, m); // 构造临时串:新词 + 分隔符 + res的末尾 // 分隔符 '#' 极其重要,防止前后缀匹配跨越边界 string s_match = t + "#"; // 性能优化:只取后缀,避免处理 res 全串 for (int i = m - len; i < m; ++i) { s_match += res[i]; } int total_len = s_match.length(); vector<int> nxt(total_len, 0); // 标准 KMP next 数组计算 // j 表示当前匹配的最长前缀长度 for (int i = 1, j = 0; i < total_len; ++i) { while (j > 0 && s_match[i] != s_match[j]) { j = nxt[j - 1]; } if (s_match[i] == s_match[j]) { j++; } nxt[i] = j; } // 整个串的最长相等前后缀长度即为 overlap return nxt[total_len - 1]; } int main() { ios::sync_with_stdio(false); cin.tie(NULL); int n; if (!(cin >> n)) return 0; string final_res; string cur; for (int i = 0; i < n; ++i) { cin >> cur; if (i == 0) { final_res = cur; } else { int overlap = get_max_overlap(cur, final_res); // 易错点:append 的起始位置是 overlap for (int j = overlap; j < (int)cur.length(); ++j) { final_res += cur[j]; } } } cout << final_res << endl; return 0; }
时间复杂度优化建议 (NOI 考场笔记)
-
内存预分配: 在最终版本中,
string final_res的长度最大会达到 。如果不断追加字符,底层可能会发生多次内存重分配(Reallocation)。 优化:在循环前使用final_res.reserve(1000005);预留空间,可以减少约 10%-20% 的常数时间。 -
避免
substr拷贝: 在get_max_overlap中,我们使用了s_match += res[i]。在更严苛的题目中,可以直接手动维护一个字符数组,或者利用指针/索引进行 KMP 匹配,减少字符串拷贝开销。 -
KMP 的空间优化: 每次合并都新开了
vector<int> nxt。在 NOI 中,建议定义一个全局数组int nxt[2000005],每次合并时复用它。
总结:读题关键词与套路
- “最长前缀等于后缀” 锁定 KMP。
- “动态维护、快速比较” 锁定 Rolling Hash。
- “总长度 ” 意味着即使是 都要小心,首选 线性算法。
- “合并规则:保留前者,接上后者余部” 典型的前缀匹配后缀模型。
这道题是字符串基础知识的“试金石”。在草稿纸上推演时,一定要画出
res后缀与cur前缀重叠的示意图,明确next数组在此时的物理意义。 - 时间:。在数据全为
- 1
信息
- ID
- 19391
- 时间
- 1000ms
- 内存
- 128MiB
- 难度
- 10
- 标签
- (无)
- 递交数
- 3
- 已通过
- 1
- 上传者