2 条题解

  • 0
    @ 2025-12-18 17:15:12

    对于建设 OJ(在线判测系统)而言,字符串题目的数据需要格外注意空字符串完全相同字符串以及完全没有公共前缀等极端情况。

    以下是针对“最长公共前缀”题目编写的数据生成器。它包含了逻辑实现(标程)并自动循环生成 10 组测试数据。

    数据生成器 (C++14)

    #include <iostream>
    #include <fstream>
    #include <vector>
    #include <string>
    #include <algorithm>
    #include <random>
    #include <chrono>
    
    using namespace std;
    
    // --- 标程:用于生成 .out 文件 ---
    string getLCP(int n, const vector<string>& strs) {
        if (n == 0) return "";
        if (n == 1) return strs[0];
    
        string first = strs[0];
        for (int j = 0; j < (int)first.size(); ++j) {
            char c = first[j];
            for (int i = 1; i < n; ++i) {
                // 纵向扫描逻辑
                if (j == (int)strs[i].size() || strs[i][j] != c) {
                    return first.substr(0, j);
                }
            }
        }
        return first;
    }
    
    // --- 随机字符串生成辅助函数 ---
    string randString(mt19937& rng, int len) {
        string s = "";
        uniform_int_distribution<int> dist('a', 'z');
        for (int i = 0; i < len; ++i) {
            s += (char)dist(rng);
        }
        return s;
    }
    
    // --- 主生成逻辑 ---
    void make_data() {
        mt19937 rng(chrono::steady_clock::now().time_since_epoch().count());
    
        for (int t = 1; t <= 10; ++t) {
            int n;
            vector<string> strs;
    
            // --- 根据测试点编号构造不同类型的数据 ---
            if (t == 1) { // 边界:只有1个字符串且为空
                n = 1;
                strs.push_back("");
            }
            else if (t == 2) { // 边界:只有1个字符串且长度为1
                n = 1;
                strs.push_back("a");
            }
            else if (t == 3) { // 边界:所有字符串长度为0
                n = 50;
                for(int i=0; i<n; ++i) strs.push_back("");
            }
            else if (t == 4) { // 特殊:完全没有公共前缀(首字母即不同)
                n = 200;
                for(int i=0; i<n; ++i) {
                    string s = randString(rng, 100);
                    s[0] = (char)('a' + (i % 26)); // 确保首字母循环改变
                    strs.push_back(s);
                }
            }
            else if (t == 5) { // 特殊:所有字符串完全相同
                n = 200;
                string target = randString(rng, 200);
                for(int i=0; i<n; ++i) strs.push_back(target);
            }
            else if (t == 6) { // 特殊:其中一个字符串非常短,限制了前缀
                n = 100;
                string common = "prefixtest";
                for(int i=0; i<n; ++i) strs.push_back(common + randString(rng, 50));
                strs[50] = "pre"; // 插入一个短字符串
            }
            else if (t == 7) { // 特殊:其中一个字符串是空的,导致LCP为空
                n = 10;
                for(int i=0; i<n; ++i) strs.push_back("nonempty" + to_string(i));
                strs[5] = "";
            }
            else if (t == 8) { // 随机:较长公共前缀
                n = 150;
                string common = randString(rng, 100);
                for(int i=0; i<n; ++i) strs.push_back(common + randString(rng, 20));
            }
            else if (t == 9) { // 随机:普通规模随机
                n = rng() % 100 + 50;
                for(int i=0; i<n; ++i) strs.push_back(randString(rng, rng() % 200));
            }
            else { // 最大规模随机
                n = 200;
                for(int i=0; i<n; ++i) strs.push_back(randString(rng, 200));
            }
    
            // --- 写入 .in 文件 ---
            string in_name = to_string(t) + ".in";
            ofstream fin(in_name);
            fin << n << "\n";
            for (const string& s : strs) {
                fin << s << "\n";
            }
            fin.close();
    
            // --- 调用标程生成 .out 文件 ---
            string out_name = to_string(t) + ".out";
            ofstream fout(out_name);
            fout << getLCP(n, strs) << endl; // 即使是空串也建议带换行,符合NOI习惯
            fout.close();
    
            cout << "Generated Case " << t << ": n=" << n << endl;
        }
    }
    
    int main() {
        make_data();
        cout << "\nAll test cases (1.in~10.out) generated successfully!" << endl;
        return 0;
    }
    

    测试点详细规划说明

    测试点 NN (数量) LL (长度) 特征描述 考查要点
    1 1 0 只有一个空串 极端边界,考察对 strs[0] 的访问安全。
    2 1 只有一个单字符串 基础边界。
    3 50 0 全为空串 考察循环边界是否会崩溃。
    4 200 100 首字母互不相同 考查“提前退出”逻辑,LCP 为空的情况。
    5 200 全等超长字符串 考查 O(N×L)O(N \times L) 的满负载运行性能。
    6 100 变长 存在极短截断串 考察 j == strs[i].size() 的逻辑判断顺序。
    7 10 数组中间插入空串 考查对空字符串作为干扰项的处理。
    8 150 120 强一致公共前缀 验证 O(S)O(S) 复杂度的稳定性。
    9 随机 普通分布数据 综合逻辑校验。
    10 200 全随机最大规模 压力测试。

    教练的生成器编写建议

    1. 文件输出细节:在 NOI 比赛中,如果答案为空字符串,输出通常是一个换行符(即空行)。生成器中使用 endl 确保了即使结果是 "",也会输出一个 \n,这与大多数 OJ 的 Special Judge 或过滤行末空格的机制兼容。
    2. 随机数种子:使用了 chrono::steady_clock 作为种子,确保每次运行生成器产生的数据序列都不同,有利于在本地进行多组对拍。
    3. 内存与递归:本生成器全部采用迭代逻辑,且字符串总大小约为 200×200=40,000200 \times 200 = 40,000 字节,仅占用约 40KB 内存。在生成 N105N \le 10^5 的超大型字符串数据时,建议改用 char 数组缓冲输出以减少 string 拼接带来的开销。
    4. 数据合法性:生成器严格限制了字符范围为 a-z,确保符合题目描述。在创建 OJ 数据时,一定要再次确认题目是否有大写字母或数字的特殊要求。
    • 0
      @ 2025-12-18 11:35:55

      这是“最长公共前缀”问题的标准题解。在 NOI 竞赛中,字符串模拟题的关键在于对下标越界的严谨检查以及对特殊情况(如空串)的预判

      一、 题解标准代码 (C++14)

      #include <iostream>
      #include <vector>
      #include <string>
      
      using namespace std;
      
      /**
       * 核心逻辑说明:
       * 采用“纵向扫描”法。以第一个字符串为基准,逐位比对所有字符串。
       * 这种方法在发现第一个不匹配时能立即停止,效率极高。
       */
      string solve() {
          int n;
          if (!(cin >> n)) return "";
      
          vector<string> strs(n);
          for (int i = 0; i < n; ++i) {
              cin >> strs[i];
          }
      
          // 易错点 1:处理输入为空或 n=0 的情况(虽然数据约定 n>=1)
          if (n == 0) return "";
          // 易错点 2:如果只有一个字符串,公共前缀就是它自己
          if (n == 1) return strs[0];
      
          // 以第一个字符串作为基准,尝试比对每一位
          string first = strs[0];
          for (int j = 0; j < (int)first.size(); ++j) {
              char c = first[j]; // 当前列需要比对的字符
              
              // 遍历剩余的字符串
              for (int i = 1; i < n; ++i) {
                  // 关键点 1:检查越界。如果当前比对的位数 j 已经达到了 strs[i] 的长度
                  // 说明 strs[i] 是目前找到的最短字符串,公共前缀不能超过它
                  if (j == (int)strs[i].size()) {
                      return first.substr(0, j);
                  }
                  
                  // 关键点 2:检查字符是否匹配
                  if (strs[i][j] != c) {
                      // 一旦发现不匹配,立即截取并返回结果
                      return first.substr(0, j);
                  }
              }
          }
      
          // 如果基准字符串全部跑完都匹配,说明基准字符串本身就是公共前缀
          return first;
      }
      
      int main() {
          // 竞赛优化:加速 I/O
          ios::sync_with_stdio(false);
          cin.tie(0);
      
          // 输出结果,注意题目要求:无公共前缀输出空行
          cout << solve() << endl;
      
          return 0;
      }
      

      二、 复杂度分析思考过程

      1. 时间复杂度

      • 读取输入O(S)O(S),其中 SS 是所有字符串中字符的总数。
      • 纵向扫描
        • 在最坏情况下(例如所有字符串完全相同),我们需要遍历每一个字符。此时复杂度为 O(S)O(S)
        • 在最好情况下(例如第一个字符就不匹配),我们只需要遍历 nn 次(每个字符串看一眼第一个字符),复杂度为 O(n)O(n)
      • 总结:时间复杂度为 O(S)O(S)。在 200×200=40,000200 \times 200 = 40,000 的数据规模下,运算量极小,能瞬间通过。

      2. 空间复杂度

      • 存储空间:我们需要存储所有输入的字符串,空间复杂度为 O(S)O(S)
      • 辅助空间:除了结果字符串外,我们只使用了常数个整型变量(i, j, n)和字符变量(c),额外辅助空间为 O(1)O(1)

      三、 算法优化建议

      虽然纵向扫描已经是 O(S)O(S) 的线性复杂度,但在面对更大规模数据(如 n=106n=10^6)时,可以考虑以下思路:

      1. 二分搜索长度 (Binary Search)

        • 思路:最长公共前缀的长度一定在 0最小字符串长度 之间。我们可以对这个“长度”进行二分搜索。
        • 判定条件:对于长度 midmid,检查所有字符串的前 midmid 个字符是否相同。
        • 适用场景:字符串数量巨大,且平均长度也很长时,配合字符串哈希(String Hash)可以进一步加速。
      2. 分治法 (Divide and Conquer)

        • 思路:$LCP(S_1, S_2, ..., S_n) = LCP(LCP(S_1...S_{n/2}), LCP(S_{n/2+1}...S_n))$。
        • 适用场景:适合并行计算环境。
      3. 排序比较法

        • 思路:先将字符串数组按字典序排序,然后只需比较第一个最后一个字符串。因为排序后,前缀重合度最高和最低的字符串会被分到两头,它们的公共前缀一定是全体的公共前缀。
        • 复杂度O(Slogn)O(S \log n)。虽然多了个 log\log,但代码实现非常简洁。

      四、 易错点总结(读题及实现细节)

      • 空字符串:输入可能包含长度为 0 的字符串 ""。纵向扫描时,j == strs[i].size() 必须优先判断,否则访问 strs[i][0] 会导致越界。
      • substr 的范围first.substr(0, j) 返回的是长度为 j 的子串,下标从 0j-1。在 j=0j=0 时,它会正确返回空串 ""
      • 类型转换strs[i].size() 返回的是无符号整数 (size_t),与 int 型的 j 比较时,建议强制类型转换 (int),避免编译器产生警告或在某些极端情况下出现比对异常。

      这个题解代码哪里体现了双指针的思想?

      这是一个非常深入的问题。在算法竞赛中,“双指针” (Two Pointers) 并不一定非要在同一个数组上左右对冲(快慢指针),它更多地代表了一种坐标定位与同步扫描的思想。

      在上面的解题代码中,双指针思想体现在对二维结构(字符串数组)的行列协同控制。我们可以从以下两个维度来拆解这两个“指针”:

      1. 指针定位:从“一维”到“二维”的映射

      请看代码中的双重循环结构,我们在纸上定义这两个指针:

      • 指针 jj (列指针/进度指针):指向第一个字符串 strs[0] 的当前字符位置。它代表了目前正在尝试匹配的前缀长度
      • 指针 ii (行指针/校验指针):在确定了第 jj 列后,这个指针负责纵向扫描剩余的所有字符串,确保它们在第 jj 位都步调一致。

      草稿纸上的图示:

      假设有 ["flower", "flow", "flight"]

             j=0  j=1  j=2  ...
      i=0 :  [f]  [l]  [o]  [w]  [e]  [r]   <-- 指针 j 在这里移动 (列进度)
      i=1 :  [f]  [l]  [o]  [w]             <-- 指针 i 在这里移动 (行遍历)
      i=2 :  [f]  [l]  [i]  [g]  [h]  [t]
              ^
              i 指针从 1 扫到 n-1
      

      2. 双指针的协作逻辑

      在 NOI 竞赛中,这种双指针协作被称为**“锁定一维,遍历另一维”**:

      1. 锁定进度:指针 jj 锁定一个位置(比如第 2 位,字符 'o')。
      2. 同步校验:指针 ii 带着这个使命,去检查 strs[1][2]strs[2][2]...
      3. 反馈结果
        • 如果 ii 指针顺利走完一轮(遍历完所有字符串),说明第 jj 位是公共的,jj 指针继续向右移一步(j++j++)。
        • 如果 ii 指针在某一行“卡住了”(字符不匹配或字符串到头了),双指针协作立即停止。

      3. 为什么说它是“变种双指针”?

      传统的双指针(如滑动窗口)通常处理一个序列。而本题处理的是多个序列,我们的指针体系实际上是在做一个 “同步前进” 的动作:

      • 隐含的同步指针:虽然代码里只显式写了 strs[i][j],但你可以理解为有 nn 个指针同时指向 nn 个字符串。由于我们要找的是“公共前缀”,所以这 nn 个指针的下标必须永远相等(都是 jj)。
      • 因此,我们只需要维护一个全局的列下标 jj,配合一个行下标 ii 循环,就能模拟出 nn 个指针同步向右移动的效果。

      总结

      这套代码体现了双指针思想中的**“维度解耦”**:

      • 主控指针 (jj) 负责探索可能的最优解(更长的前缀)。
      • 辅助指针 (ii) 负责在当前解下进行合法性验证。

      这种 “一静一动,以点带面” 的控制方式,正是双指针思想在复杂数据结构(字符串数组)中的高级应用。在处理更难的题目(如:在 nn 个字符串中找最长公共子串)时,这种行列指针的控制逻辑会演变成更复杂的多指针同步或二分校验

      • 1

      信息

      ID
      19356
      时间
      1000ms
      内存
      128MiB
      难度
      10
      标签
      (无)
      递交数
      1
      已通过
      1
      上传者