Skip to content

吟游诗人 - 题解

标签与难度

标签: 二分答案, 动态规划, 树状数组, 置换, 复杂度优化, 喵~ 难度: 2300

题目大意喵~

一位吟游诗人温迪需要将一首长度为 nn 的乐谱(一个整数序列)划分成恰好 kk 个连续的乐章。乐谱的“动听度”由响度最大的那个乐章决定,并且这个最大响度越小,乐谱就越动听。温迪的目标,就是让这个最大响度尽可能地小。

然而,另一位诗人六指乔瑟会来捣乱!在温迪开始演奏前,他可以对初始乐谱 cc 进行至多 TT 次变换。每次变换都是应用一个给定的置换 ff。也就是说,最终的乐谱 dd 可以是 f0(c),f1(c),,fT(c)f^0(c), f^1(c), \dots, f^T(c) 中的任意一个。

六指乔瑟非常聪明,他会选择一个变换次数 t[0,T]t \in [0, T],使得温迪演奏该乐谱 d=ft(c)d=f^t(c) 时,能达到的最小的最大乐章响度是所有可能中最大的。而温DEI迪同样聪明,对于任何给定的乐谱,他总能找到最佳划分方式,使得最大响度最小。

我们的任务,就是预测这场神仙对决的最终结果:那个被六指乔瑟“最大化”了的“最小的最大响度”究竟是多少呢,喵?

解题思路分析

这真是一场精彩的对决喵!整个问题可以看成一个双层博弈:

  1. 外层 (六指乔瑟): 他有 T+1T+1 种选择(应用置换 00TT 次),他会选择一种,使得最终的“动听度”最差(即最小的最大响度最大)。
  2. 内层 (温迪): 对于乔瑟选定的任意一首乐谱,温迪需要将其划分为 kk 个乐章,并让其中响度最大的乐章的响度尽可能小。

我们的目标是求出 max0tT{温迪对 ft(c) 能做到的最小的最大响度}\max_{0 \le t \le T} \{ \text{温迪对 } f^t(c) \text{ 能做到的最小的最大响度} \}

所以,解题的核心思路就是,我们先枚举乔瑟的所有可能选择,也就是从 t=0t=0t=Tt=T 的每一次置换。对于每一个置换后得到的乐谱,我们去解决内层的温迪问题。最后,在所有结果中取一个最大值,就是最终的答案啦!

内层问题:最小化最大乐章响度

对于一个固定的乐谱序列 aa,要将它分成恰好 kk 段,并最小化最大段和。这是一个经典的“最大值最小化”问题,通常都可以用 二分答案 来解决,喵~

我们来二分这个“最小的最大响度”,称之为 max_loudness。现在的问题就转化为一个判定问题:是否存在一种划分方案,把序列 aa 分成恰好 kk 段,并且每一段的和都不超过 max_loudness

这个问题比想象中要复杂一些。如果只是要求划分成 不超过 kk 段,我们可以用贪心解决。但这里要求是 恰好 kk 段。这意味着,对于一个给定的 max_loudness,我们需要知道能划分出的最少段数 min_partitions 和最多段数 max_partitions。只要 kk 在这个区间内,即 min_partitions <= k <= max_partitions,那么 check(max_loudness) 就为真。

如何求 min_partitionsmax_partitions 呢?这可以用动态规划来解决!

S[i]S[i] 为序列 aa 的前缀和,即 S[i]=j=1iajS[i] = \sum_{j=1}^{i} a_j(并且 S[0]=0S[0]=0)。一个从 j+1j+1ii 的乐章的响度就是 S[i]S[j]S[i] - S[j]

我们定义 dpmin[i]dp_{min}[i]dpmax[i]dp_{max}[i] 分别为:将前 ii 个音符划分成若干乐章(每个响度都不超过 max_loudness)所需的最少和最多乐章数。

状态转移方程如下:

dpmin[i]=1+min0j<i,S[i]S[j]max_loudness{dpmin[j]}dp_{min}[i] = 1 + \min_{0 \le j < i, S[i] - S[j] \le \text{max\_loudness}} \{ dp_{min}[j] \}

dpmax[i]=1+max0j<i,S[i]S[j]max_loudness{dpmax[j]}dp_{max}[i] = 1 + \max_{0 \le j < i, S[i] - S[j] \le \text{max\_loudness}} \{ dp_{max}[j] \}

这个转移方程的复杂度是 O(N2)O(N^2),对于一个 check 函数来说太慢了。整个算法会是 O(TN2log(Sum))O(T \cdot N^2 \cdot \log(\text{Sum})),肯定会超时的说!

DP优化:树状数组来加速!

我们需要优化这个DP过程。注意到转移条件 S[i]S[j]max_loudnessS[i] - S[j] \le \text{max\_loudness} 可以变形为 S[j]S[i]max_loudnessS[j] \ge S[i] - \text{max\_loudness}。这意味着,在计算 dp[i]dp[i] 时,我们需要查询所有满足特定前缀和条件的 j<ij < idp[j]dp[j] 的最值。

这是一个典型的二维偏序问题(偏序为下标 j<ij<i 和前缀和 S[j]ThresholdS[j] \ge \text{Threshold}),但由于我们是按 ii 顺序计算的,下标的限制天然满足。问题就简化为了一维数据结构上的范围查询。

我们可以将所有前缀和 S[0],,S[n]S[0], \dots, S[n] 进行离散化,然后用**树状数组(Fenwick Tree)**或者线段树来维护DP值。

具体做法是:

  1. 计算所有前缀和 S[0],,S[n]S[0], \dots, S[n],并将它们收集起来排序、去重,得到一个离散化后的值域。
  2. 建立两个树状数组,一个维护 dpmindp_{min} 的区间最小值,另一个维护 dpmaxdp_{max} 的区间最大值。
  3. 当我们计算 dp[i]dp[i] 时: a. 找到阈值 S[i]max_loudnessS[i] - \text{max\_loudness} 在离散化值域中的排名 rank_threshold。 b. 我们需要查询所有 S[j]S[j] 排名大于等于 rank_thresholddp[j]dp[j] 的最值。这等价于一个后缀查询。 c. 我们可以用一个特殊构造的树状数组来实现“单点更新,后缀查询”,或者通过坐标变换(例如将排名 r 映射到 max_rank - r + 1)在标准的前缀查询树状数组上实现。 d. 查到最值后,计算出 dpmin[i]dp_{min}[i]dpmax[i]dp_{max}[i]。 e. 将 S[i]S[i] 的排名 rank_i 作为下标,把 dpmin[i]dp_{min}[i]dpmax[i]dp_{max}[i] 更新到树状数组中,供后续的 ii 查询。

这样,一次 check 函数的复杂度就降到了 O(NlogN)O(N \log N)

整体流程与小优化

现在,我们的总算法是:

  1. 外层循环 tt00TT
  2. 在循环中,首先生成第 tt 次置换后的乐谱序列 a=ft(c)a = f^t(c)
  3. 对序列 aa 进行二分答案,寻找最小的 max_loudness
    • 二分的 check 函数内部使用 O(NlogN)O(N \log N) 的DP+树状数组来完成。
  4. 记录下当前乐谱的最小 max_loudness,并在所有 tt 的结果中取最大值。

总复杂度是 O(TNlogNlog(Sum))O(T \cdot N \log N \cdot \log(\text{Sum}))。在 N,T=2000N, T=2000 的情况下,这依然太慢了。但是,我们可以加一个小小的优化,喵~

我们维护一个全局的 global_max_min_loudness,表示到目前为止遇到的最大“最小响度”。对于一个新的乐谱,我们先不急着二分,而是先 check(global_max_min_loudness) 一下。

  • 如果 check 通过,说明这个乐谱的最小最大响度不会超过我们已知的最大值。它不会对最终答案产生贡献,我们可以直接跳过对它的二分,进行下一次循环。
  • 如果 check 失败,说明我们找到了一个更“差”的乐谱!它的最小最大响度一定大于 global_max_min_loudness。这时我们再进行二分,把二分下界设为 global_max_min_loudness + 1,找到它精确的值,并更新全局最大值。

这个优化在很多情况下能显著减少二分的次数,尤其是在一个能产生较大结果的置换被较早遇到时。虽然最坏复杂度不变,但它能帮助我们通过这道题,喵~

代码实现

这是我根据上面的思路,精心重构的一份代码哦~ 希望能帮到你,喵!

cpp
#include <iostream>
#include <vector>
#include <numeric>
#include <algorithm>
#include <functional>

// 使用 long long 防止权值和溢出
using ll = long long;

const ll INF_LOUDNESS = 4e12; // 一个足够大的响度值
const int MAXN = 2005;
const int INF_PARTITIONS = MAXN; // DP值不可能超过N

// --- 全局变量 ---
int n, k, T;
ll initial_c[MAXN];
int perm_f[MAXN];
ll current_a[MAXN];
ll prefix_sum[MAXN];

// --- DP及数据结构相关 ---
std::vector<ll> distinct_sums;
int n_distinct;
int min_dp[MAXN], max_dp[MAXN];

// 树状数组,用于优化DP
// 实现单点更新与后缀查询
namespace FenwickTree {
    int min_tree[MAXN], max_tree[MAXN];

    void init() {
        // n_distinct 是离散化后值的数量,+2是为了边界安全
        std::fill(min_tree, min_tree + n_distinct + 2, INF_PARTITIONS);
        std::fill(max_tree, max_tree + n_distinct + 2, -INF_PARTITIONS);
    }

    // 更新 rank 位置的值
    void update_min(int rank, int val) {
        for (; rank > 0; rank -= rank & -rank) min_tree[rank] = std::min(min_tree[rank], val);
    }
    void update_max(int rank, int val) {
        for (; rank > 0; rank -= rank & -rank) max_tree[rank] = std::max(max_tree[rank], val);
    }

    // 查询 rank 及之后所有位置的最值
    int query_min(int rank) {
        int res = INF_PARTITIONS;
        for (; rank <= n_distinct; rank += rank & -rank) res = std::min(res, min_tree[rank]);
        return res;
    }
    int query_max(int rank) {
        int res = -INF_PARTITIONS;
        for (; rank <= n_distinct; rank += rank & -rank) res = std::max(res, max_tree[rank]);
        return res;
    }
}

// 判定函数:对于当前乐谱 current_a, 是否能以 max_loudness 为上限,划分成 k 段
bool check(ll max_loudness) {
    // 1. 计算前缀和并离散化
    prefix_sum[0] = 0;
    for (int i = 1; i <= n; ++i) {
        prefix_sum[i] = prefix_sum[i - 1] + current_a[i];
    }

    distinct_sums.assign(prefix_sum, prefix_sum + n + 1);
    std::sort(distinct_sums.begin(), distinct_sums.end());
    distinct_sums.erase(std::unique(distinct_sums.begin(), distinct_sums.end()), distinct_sums.end());
    n_distinct = distinct_sums.size();

    // 获取一个值在离散化数组中的排名 (1-based)
    auto get_rank = [&](ll val) {
        return std::lower_bound(distinct_sums.begin(), distinct_sums.end(), val) - distinct_sums.begin() + 1;
    };

    // 2. DP 过程
    FenwickTree::init();

    min_dp[0] = 0;
    max_dp[0] = 0;
    int rank_s0 = get_rank(prefix_sum[0]);
    FenwickTree::update_min(rank_s0, 0);
    FenwickTree::update_max(rank_s0, 0);

    for (int i = 1; i <= n; ++i) {
        // 寻找 S[j] >= S[i] - max_loudness
        ll target_s = prefix_sum[i] - max_loudness;
        int target_rank = get_rank(target_s);

        int min_prev_dp = FenwickTree::query_min(target_rank);
        int max_prev_dp = FenwickTree::query_max(target_rank);

        min_dp[i] = (min_prev_dp == INF_PARTITIONS) ? INF_PARTITIONS : min_prev_dp + 1;
        max_dp[i] = (max_prev_dp == -INF_PARTITIONS) ? -INF_PARTITIONS : max_prev_dp + 1;

        int rank_si = get_rank(prefix_sum[i]);
        FenwickTree::update_min(rank_si, min_dp[i]);
        FenwickTree::update_max(rank_si, max_dp[i]);
    }

    return min_dp[n] <= k && k <= max_dp[n];
}

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

    std::cin >> n >> k >> T;
    for (int i = 1; i <= n; ++i) std::cin >> initial_c[i];
    for (int i = 1; i <= n; ++i) std::cin >> perm_f[i];

    // 初始化当前乐谱为原始乐谱
    for (int i = 1; i <= n; ++i) current_a[i] = initial_c[i];

    ll global_max_min_loudness = -INF_LOUDNESS;

    for (int t = 0; t <= T; ++t) {
        if (t > 0) {
            // 应用置换 f: new_a[i] = old_a[f[i]]
            ll temp_a[MAXN];
            for (int i = 1; i <= n; ++i) {
                temp_a[i] = current_a[perm_f[i]];
            }
            for (int i = 1; i <= n; ++i) {
                current_a[i] = temp_a[i];
            }
        }

        // 优化:如果当前已知的最大值已经可以满足,就不用二分了
        if (check(global_max_min_loudness)) {
            continue;
        }

        // 二分查找当前乐谱的最小最大响度
        ll low = global_max_min_loudness + 1, high = INF_LOUDNESS, current_min_loudness = INF_LOUDNESS;
        while (low <= high) {
            ll mid = low + (high - low) / 2;
            if (check(mid)) {
                current_min_loudness = mid;
                high = mid - 1;
            } else {
                low = mid + 1;
            }
        }
        global_max_min_loudness = current_min_loudness;
    }

    std::cout << global_max_min_loudness << std::endl;

    return 0;
}

复杂度分析

  • 时间复杂度: O(TNlogNlog(Range))O(T \cdot N \log N \cdot \log(\text{Range}))

    • 外层循环最多执行 T+1T+1 次。
    • 内部的 check 函数,主要开销在于排序离散化(O(NlogN)O(N \log N))和 NN 次树状数组操作(每次 O(logN)O(\log N)),总共是 O(NlogN)O(N \log N)
    • 二分答案的次数是 log(Range)\log(\text{Range}),其中 Range 是响度的可能范围。
    • 尽管最坏情况下的理论复杂度很高,但由于我们加入了 check(global_max_min_loudness) 这个剪枝优化,实际运行中可以跳过大量不必要的二分搜索,从而在时限内通过,喵~
  • 空间复杂度: O(N)O(N)

    • 主要空间开销是存储乐谱序列、前缀和、DP数组以及树状数组,它们的大小都与 NN 呈线性关系。

知识点总结

这道题是多种算法思想的巧妙结合,像一首层次丰富的乐曲呢,喵~

  1. 问题建模: 识别出外层的“最大化”和内层的“最小化”博弈结构是解题的第一步。
  2. 二分答案: 内层的“最小化最大值”问题是二分答案的经典应用场景。
  3. 动态规划: check 函数的核心是 DP。设计出正确的状态(最少/最多划分数)和转移方程是关键。
  4. 数据结构优化: 用树状数组(或线段树)将 O(N2)O(N^2) 的 DP 优化到 O(NlogN)O(N \log N),是解决性能瓶颈的核心技巧。
    • 离散化: 配合树状数组使用,处理值域较大的情况。
    • 特殊树状数组: 本题解中的树状数组实现了“单点更新,后缀查询”,是一种不那么常见但非常有用的技巧。
  5. 置换群: 题目中的变换是基于置换的,理解如何正确地应用置换是必要的。
  6. 剪枝优化: 在循环中利用已知最优解进行剪枝,是让高复杂度算法通过时限的重要策略。

希望这篇题解能帮助你更好地理解这道题的每一个细节,加油哦,未来的大算法家!喵~