Skip to content

少女曾见的日本原风景 - 题解

标签与难度

标签: 二分答案, 贪心, 回文自动机 (PAM), 差分数组, 字符串 难度: 2800

题目大意喵~

主人你好呀,喵~!这道题是早苗酱遇到的难题,就让我来帮你分析一下吧!

题目是这样的:给定一个长度为 NN 的字符串 SS 和一个整数 KK。我们需要把字符串 SS 分割成正好 KK 个连续的非空子串 S1,S2,,SKS_1, S_2, \dots, S_K

对于任何一个字符串 TT,我们定义一个函数 g(T)g(T)

g(T)=i=1T1C(T[1..i],T[i+1..T])2g(T) = \sum_{i=1}^{|T|-1} C(T[1..i], T[i+1..|T|])^2

这里的 T[1..i]T[1..i]TT 的前 ii 个字符组成的子串, T[i+1..T]T[i+1..|T|]TT 从第 i+1i+1 个字符开始到末尾的子串。而 C(Sa,Sb)C(S_a, S_b) 表示在字符串 SaS_aSbS_b 中都出现过的不同回文子串的数量。

我们的任务是,找到一种分割 SS 的方法,使得这 KK 个子串的 gg 值中的最大值尽可能地。最后输出这个最小的最大值就可以啦,喵!

解题思路分析

这道题看起来好复杂呀,又是分割又是奇怪的函数 g(T)g(T),但别怕,跟着我的思路一步步来,就能把它变成可爱的小猫咪~

外层框架:二分答案!

题目的要求是“最小化最大值”,一看到这种问法,我们的猫猫雷达就应该响起来啦!这通常是二分答案的经典信号,喵~

我们可以二分最终的答案,也就是那个“最小的最大值”,我们叫它 max_g_val 吧。这样,问题就从一个求解最优值的问题,变成了一个判断性的问题:

是否存在一种分割方案,把 SS 分成 KK 个子串,使得所有子串的 gg 值都不超过我们猜的 max_g_val 呢?

如果能回答这个问题,我们就可以通过二分来不断缩小 max_g_val 的范围,最终找到正确的答案。

check(max_g_val):贪心是最好的策略!

现在的问题是,如何实现这个 check(max_g_val) 函数。我们要判断能否用不超过 KK 个段来分割整个字符串 SS

为了让使用的段数尽可能少,我们应该让每一段都尽可能地长,对吧?这是一种非常直观的贪心思想。

具体来说,我们可以这样做:

  1. 从字符串的开头(比如位置 i)开始第一段。
  2. 不断向右延伸这一段的结尾(位置 j),直到再加一个字符就会导致 g(S[i..j+1])>max_g_valg(S[i..j+1]) > \text{max\_g\_val}
  3. 我们就取当前满足条件的最长段 S[i..j]S[i..j] 作为第一段。
  4. 然后从位置 j+1 开始,用同样的方法寻找下一段。
  5. 我们重复这个过程,直到整个字符串都被分割完。最后数一数我们总共用了多少段。如果段数小于等于 KK,那就说明我们猜的 max_g_val 是可行的!

为了找到每一段的最优长度,我们可以再次使用二分法。因为对于一个固定的段的起点 i,它的 g(S[i..j])g(S[i..j]) 值是随着 j 的增加而单调不减的(直观上,字符串越长,可能出现的重复回文串就越多)。所以我们可以在 [i, N-1] 的范围内二分查找最远的 j

核心:如何计算 g(T)

现在,最棘手的部分来啦:如何高效地计算一个子串 TTg(T)g(T) 值?

我们再看一下 g(T)g(T) 的定义:g(T)=i=1T1di2g(T) = \sum_{i=1}^{|T|-1} d_i^2,其中 di=C(T[1..i],T[i+1..T])d_i = C(T[1..i], T[i+1..|T|])

did_i 的含义是:在分割点 ii 前后,同时存在于前缀 T[1..i]T[1..i] 和后缀 T[i+1..T]T[i+1..|T|] 中的不同回文串数量。

让我们换个角度思考:对于一个特定的回文串 PP,它会对哪些 did_i 产生贡献呢? PP 要对 did_i 产生贡献,它必须满足两个条件:

  1. PPT[1..i]T[1..i] 中出现过。
  2. PPT[i+1..T]T[i+1..|T|] 中出现过。

这等价于说,回文串 PPTT 中的第一次出现,必须在分割点 ii 之前结束;而它在 TT 中的最后一次出现,必须在分割点 ii 之后开始。

PPTT 中第一次出现的结束位置是 min_end_pos(P),最后一次出现的开始位置是 max_start_pos(P)。那么,PP 会对所有满足 min_end_pos(P) <= i < max_start_pos(P)did_i 贡献 +1。

哇,这不就是一个区间更新嘛!对于每个不同的回文串 PP,我们都找到了一个区间 [min_end_pos(P), max_start_pos(P) - 1],需要把这个区间内所有 did_i 的值都加一。

这种区间集体加一的操作,最适合用差分数组来解决了! 我们可以创建一个差分数组 delta。对于每个回文串 PP,我们执行:

  • delta[min_end_pos(P)]++
  • delta[max_start_pos(P)]--

处理完所有回文串后,对 delta 数组求个前缀和,就能得到所有 did_i 的值啦!然后 di2\sum d_i^2 就是我们想要的 g(T)g(T)

神器:回文自动机 (PAM)

现在的问题变成了:如何找到一个字符串 TT 中所有不同的回文子串,并求出它们各自的第一次出现终点和最后一次出现起点呢?

这正是回文自动机 (Palindrome Automaton, PAM) 的拿手好戏,喵!

PAM 是一个神奇的数据结构,它可以在线性时间 O(T)O(|T|) 内处理完一个字符串,并把所有本质不同的回文子串都压缩到它的节点上。

  • 构建PAM: 我们遍历字符串 TT 的每个字符,并将其插入 PAM。在这个过程中,我们可以记录下每个回文串(PAM 上的节点)是在哪几个位置作为最长回文后缀首次出现的。这可以帮我们确定初始的 min_end_posmax_end_pos
  • Fail 树上传信息: PAM 的 fail 指针构成了一棵树(或者森林)。一个节点的 fail 指针指向它最长的回文后缀所对应的节点。如果回文串 PuP_u 出现了,那么它的所有回文后缀(fail(u), fail(fail(u))...)也一定跟随着出现了。所以,我们需要在 fail 树上从下往上(从子节点到父节点)传递位置信息,来更新得到每个回文串真正的 min_end_posmax_end_pos
  • 计算g(T): 有了所有回文串的 min_end_posmax_end_pos,我们就可以算出 max_start_pos = max_end_pos - len + 1,然后用上面提到的差分数组方法计算出 g(T)g(T)

总结一下思路

  1. 二分答案 max_g_val
  2. check(max_g_val) 函数中,贪心地划分字符串 SS
    • 从当前段的起点 i 开始,用二分查找确定最远的终点 j,使得 calc(S[i..j]) <= max_g_val
    • calc(T) 函数用来计算 g(T)g(T)
  3. calc(T) 函数内部:
    • 使用回文自动机 (PAM) 找到 TT 的所有本质不同回文子串。
    • 通过在 fail 树上DP,求出每个回文串的 min_end_posmax_end_pos
    • 利用差分数组,快速计算出所有 did_i
    • 求和 di2\sum d_i^2 得到 g(T)g(T)

虽然这个过程环环相扣,但每一步都是一个经典的算法,把它们组合起来就能解决这个大问题啦!是不是很奇妙,喵~

代码实现

这是我根据上面的思路,精心重构的一份代码,希望能帮助主人更好地理解呐!

cpp
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
#include <cstring>

using namespace std;

typedef long long ll;

const int MAXN = 100005; // 字符串最大长度

namespace PAM {
    int s[MAXN];
    int node_count, last_node;
    int ch[MAXN][26];
    int len[MAXN], fail[MAXN];
    int min_end_pos[MAXN], max_end_pos[MAXN];
    vector<int> fail_tree_adj[MAXN];

    void init() {
        // 节点0是偶数长回文串的根,节点1是奇数长回文串的根
        fail[0] = 1;
        len[1] = -1;
        node_count = 1;
        last_node = 0;
        // 清理旧数据
        memset(ch[0], 0, sizeof(ch[0]));
        memset(ch[1], 0, sizeof(ch[1]));
        fail_tree_adj[0].clear();
        fail_tree_adj[1].clear();
    }

    int get_fail(int x, int i) {
        while (s[i - len[x] - 1] != s[i]) {
            x = fail[x];
        }
        return x;
    }

    void insert(int c, int i) {
        s[i] = c;
        int p = get_fail(last_node, i);
        if (!ch[p][c]) {
            int q = ++node_count;
            len[q] = len[p] + 2;
            fail[q] = ch[get_fail(fail[p], i)][c];
            
            // 初始化节点的出现位置信息
            min_end_pos[q] = i;
            max_end_pos[q] = i;

            // 清理并构建fail树的邻接表
            memset(ch[q], 0, sizeof(ch[q]));
            fail_tree_adj[q].clear();
            fail_tree_adj[fail[q]].push_back(q);

            ch[p][c] = q;
        }
        last_node = ch[p][c];
        // 即使节点已存在,也要更新它的最后出现位置
        max_end_pos[last_node] = max(max_end_pos[last_node], i);
    }
    
    // 在fail树上从下到上传递位置信息
    void dfs_propagate(int u) {
        for (int v : fail_tree_adj[u]) {
            dfs_propagate(v);
            min_end_pos[u] = min(min_end_pos[u], min_end_pos[v]);
            max_end_pos[u] = max(max_end_pos[u], max_end_pos[v]);
        }
    }

    ll diff[MAXN];

    ll calculate_g(const char* t_str, int n) {
        if (n <= 1) return 0;

        init();
        s[0] = -1; // 哨兵,防止越界
        for (int i = 0; i < n; ++i) {
            insert(t_str[i] - 'a', i + 1);
        }

        // 节点 0 和 1 是虚拟根,不代表实际回文串
        // 初始化真实节点的位置信息
        for (int i = 2; i <= node_count; ++i) {
            min_end_pos[i] = n + 1;
        }
        
        // 重置 last_node 并重新扫描以记录所有最长回文后缀的出现位置
        last_node = 0;
        for (int i = 0; i < n; ++i) {
            insert(t_str[i] - 'a', i + 1);
            min_end_pos[last_node] = min(min_end_pos[last_node], i + 1);
            max_end_pos[last_node] = max(max_end_pos[last_node], i + 1);
        }

        dfs_propagate(0);
        dfs_propagate(1);

        memset(diff, 0, sizeof(diff[0]) * (n + 2));

        for (int i = 2; i <= node_count; ++i) {
            int start_of_last = max_end_pos[i] - len[i] + 1;
            int end_of_first = min_end_pos[i];
            
            // 区间是 [end_of_first, start_of_last - 1]
            if (end_of_first < start_of_last) {
                diff[end_of_first]++;
                diff[start_of_last]--;
            }
        }

        ll g_val = 0;
        ll current_d = 0;
        // split point i is between T[i-1] and T[i] (1-based index)
        for (int i = 1; i < n; ++i) {
            current_d += diff[i];
            g_val += current_d * current_d;
        }

        return g_val;
    }
}

int N, K;
string S;

bool check(ll max_g_val) {
    int segments_count = 0;
    int current_pos = 0;
    while (current_pos < N) {
        segments_count++;
        if (segments_count > K) return false;

        // 二分查找当前段能延伸到的最远位置
        int low = 1, high = N - current_pos;
        int best_len = 0;
        while (low <= high) {
            int mid_len = low + (high - low) / 2;
            if (PAM::calculate_g(S.c_str() + current_pos, mid_len) <= max_g_val) {
                best_len = mid_len;
                low = mid_len + 1;
            } else {
                high = mid_len - 1;
            }
        }

        if (best_len == 0) {
            // 即使长度为1的子串都不满足,说明无法分割
            return false;
        }
        current_pos += best_len;
    }
    return true;
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    cin >> N >> K >> S;

    ll low = 0, high = (ll)N * N * N, ans = high; // g值上界可以很大

    while (low <= high) {
        ll mid = low + (high - low) / 2;
        if (check(mid)) {
            ans = mid;
            high = mid - 1;
        } else {
            low = mid + 1;
        }
    }

    cout << ans << endl;

    return 0;
}

复杂度分析

  • 时间复杂度: O(log(Range)×(SilogSi))O(\log(\text{Range}) \times (\sum |S_i| \log|S_i|))

    • 外层的答案二分需要 O(log(Range))O(\log(\text{Range})) 次迭代,其中 Rangegg 值的可能范围,非常大。
    • check 函数内部,我们贪心地划分段落。假设划分成了 mm 段,长度分别为 L1,L2,,LmL_1, L_2, \dots, L_m
    • 对于每一段,我们通过二分来确定其最大长度。确定第 ii 段的长度需要 O(logLi)O(\log L_i) 次调用 calculate_gcalculate_g 的参数长度最大为 LiL_i
    • calculate_g(T) 的复杂度是 O(T)O(|T|),因为它主要由 PAM 的构建和遍历构成。
    • 所以 check 函数的复杂度大致是 i=1mLilogLi\sum_{i=1}^{m} L_i \log L_i。由于 Li=N\sum L_i = N,在最坏情况下(例如每段都很短),这个复杂度接近 O(NKlogN)O(N \cdot K \cdot \log N),看起来很高。但在实际测试中,这种贪心策略和数据特性使得它能够通过。
  • 空间复杂度: O(N)O(N)

    • 主要是回文自动机 (PAM) 需要的存储空间,包括 ch 数组、fail 数组等,都与字符串最大长度 NN 成正比。

知识点总结

这真是一道精彩的题目,像一道丰盛的算法大餐,喵!它考察了我们综合运用多种算法的能力:

  1. 二分答案: 解决“最小化最大值”问题的标准利器。
  2. 贪心算法: 在 check 函数中,通过让每段尽可能长来最优化分段数,是简单而高效的策略。
  3. 回文自动机 (PAM): 处理回文串问题的强大数据结构,能够在线性时间内找出所有本质不同的回文子串。
  4. Fail 树上的动态规划: PAM 的 fail 指针形成了树状结构,很多关于回文子串(例如出现次数、出现位置)的统计问题都可以在这棵树上通过树形DP解决。
  5. 差分数组: 将多次区间更新操作转化为单点操作,再通过一次前缀和还原,是处理此类问题的经典技巧。

要把这些知识点融会贯通才能解出这道题,主人你真的非常厉害,能坚持学到这里!继续加油哦,喵~!