[Cnoi2021] 符文破译

GitHub跳转原题关系图返回列表

先用 Z 函数求每个位置和字典串前缀的最长匹配长度,再把这些匹配视作区间覆盖做最少跳数贪心。

OJ: luogu

题目 ID: P8112

难度:提高+/省选-

标签:字符串贪心Z函数区间覆盖

日期: 2026-06-21 12:51

题意

给一个字典串 T 和一个符文串 S

题目保证:

  • T 的所有非空前缀都是合法词缀

现在要把 S 划分成尽量少的若干段,每一段都必须是 T 的某个非空前缀。

如果无法划分,输出 Fake

思路

先看一个可以直接验证想法的朴素解:

cpp
#include <bits/stdc++.h>
using namespace std;

const int INF = 0x3f3f3f3f;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int len_t, len_s;
    string t, s;
    cin >> len_t >> len_s;
    cin >> t >> s;

    int m = (int) t.size();
    int n = (int) s.size();

    vector<int> dp(n + 1, INF);
    dp[0] = 0;

    for (int i = 0; i < n; i++) {
        if (dp[i] == INF) {
            continue;
        }
        for (int len = 1; len <= m && i + len <= n; len++) {
            bool same = true;
            for (int j = 0; j < len; j++) {
                if (s[i + j] != t[j]) {
                    same = false;
                    break;
                }
            }
            if (same) {
                dp[i + len] = min(dp[i + len], dp[i] + 1);
            }
        }
    }

    if (dp[n] == INF) {
        cout << "Fake\n";
    }
    else {
        cout << dp[n] << '\n';
    }
    return 0;
}

因为合法词缀就是 T 的所有非空前缀,所以对 S 的每个起点 i,我们真正关心的只有一件事:

  • 从这里开始,最多能匹配 T 的前多少个字符

设这个最大长度是 reach[i],那么从位置 i 出发,我们就可以任选一段:

  • 长度在 [1, reach[i]] 之间

如果把每个位置 i 看成一个区间:

  • 可以覆盖到 i + reach[i]

那么题目就变成了:

  • 用最少个区间,从位置 0 一路覆盖到位置 n

这就是经典的“最少跳数 / 区间覆盖”贪心。

接下来只剩如何快速求 reach[i]

把字符串拼成:

  • T + '#' + S

对这个新串做一次 Z 函数,就能在线性时间求出:

  • S 每个位置和 T 前缀的最长公共前缀长度

这正好就是 reach[i]

最后做一遍最少跳数贪心:

  1. 当前已经覆盖到 cur_end
  2. 扫描所有 i <= cur_end 的位置,更新最远能到的 far
  3. 一次扩展结束后,把答案加一,并把 cur_end = far
  4. 如果 far 没有继续变大,说明无解

这样总复杂度就是线性的。

代码

cpp
#include <bits/stdc++.h>
using namespace std;

string t, s;

// Z 函数:z[i] 表示 str[i..] 与 str[0..] 的最长公共前缀长度。
vector<int> z_function(const string &str) {
    int len = (int) str.size();
    vector<int> z(len, 0);
    int l = 0, r = 0;
    z[0] = len;
    for (int i = 1; i < len; i++) {
        if (i <= r) {
            z[i] = min(r - i + 1, z[i - l]);
        }
        while (i + z[i] < len && str[z[i]] == str[i + z[i]]) {
            z[i]++;
        }
        if (i + z[i] - 1 > r) {
            l = i;
            r = i + z[i] - 1;
        }
    }
    return z;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int len_t, len_s;
    cin >> len_t >> len_s;
    cin >> t >> s;

    int m = (int) t.size();
    int n = (int) s.size();

    string merged = t + "#" + s;
    vector<int> zf = z_function(merged);

    vector<int> reach(n, 0);
    for (int i = 0; i < n; i++) {
        reach[i] = min(zf[m + 1 + i], m);
    }

    // 把每个位置 i 看成一个区间 [i, i + reach[i]]。
    // 问最少用多少段覆盖到 n,就是经典最少跳数贪心。
    int ans = 0;
    int cur_end = 0;
    int far = 0;
    int i = 0;

    while (cur_end < n) {
        while (i <= cur_end && i < n) {
            far = max(far, i + reach[i]);
            i++;
        }
        if (far <= cur_end) {
            cout << "Fake\n";
            return 0;
        }
        ans++;
        cur_end = far;
    }

    cout << ans << '\n';
    return 0;
}

复杂度

Z 函数是 O(n+m)O(n + m)

后面的最少跳数贪心也是 O(n)O(n)

总复杂度 O(n+m)O(n + m)

总结

这题最关键的转换是:

  1. 先把“每段必须是 T 的前缀”转成“每个位置最多能走多远”
  2. 再把整个问题转成区间覆盖最少段数

前半段用 Z 函数,后半段用贪心,两部分拼起来就是正解。

一图流解析

这张图把本题的建模、关键转移、实现检查和训练方法压缩到一页,适合读完正文后复盘。

一图流解析