Skip to content

F1. Cycling (Easy Version) - 题解

比赛与标签

比赛: Codeforces Round 1023 (Div. 2) 标签: binary search, brute force, dp, greedy 难度: *2300

题目大意喵~

主人 Sama,你好呀!这道题是说,有一个叫 Leo 的程序员小哥,要去见他的恋人,喵~ 但是路上有 n 个骑行者挡住了他的去路,他们从前到后编号为 1n。Leo 最初在第 n 个骑行者的后面。

为了见到心上人,Leo 可以做两种操作:

  1. 超车: 假设 Leo 正跟在第 i 个骑行者后面,他可以花费 a_i 的代价超过这个人,然后他就到了第 i-1 个骑行者的后面。
  2. 交换: Leo 可以使用超能力,花费 j - i 的代价,交换第 i 个和第 j 个骑行者的敏捷度 a_ia_j(这里要求 i < j)。这个操作可以随时、任意次使用。

我们的任务就是,帮助 Leo 计算一下,他从第 n 个骑行者后面出发,一直到超过第 1 个骑行者,所需要花费的最小总代价是多少呢?

简单来说,就是要依次超过从 n1 的所有人,期间可以通过交换来改变超车时的 a_i 值,目标是总花费最小,的说!

解题思路大揭秘!

这道题要求最小花费,而且整个过程是一系列决策(是现在超车还是先交换?),这闻起来就像是动态规划(DP)的味道呢,喵~

让我们来定义一下 DP 状态吧! dp[i] 可以表示:成功超过从第 i 位到第 1 位所有骑行者所需要的最小代价。 我们的最终目标就是求 dp[n]

那么,dp[i] 该如何计算呢?我们可以把它分解成更小的子问题。想象一下,为了超过 i, i-1, ..., 1 这些人,我们可以把他们分成两部分:

  • 先解决后面一部分人,比如 k, k-1, ..., 1。这部分的最小代价就是 dp[k-1]
  • 然后,我们一口气解决剩下的一整块人,也就是 i, i-1, ..., k

所以,状态转移方程的雏形就出来啦: dp[i] = min(dp[k-1] + cost_to_clear_block(k, i)),其中 1 <= k <= i

这里的 dp[0] 显然是 0,因为超过 0 个人不需要任何代价,喵~

现在的关键问题是,如何计算 cost_to_clear_block(k, i) 呢?也就是,当我们正准备超过第 i 个人时,用最优策略连续超过 i, i-1, ..., k 这一整块人的最小代价是多少?

解决一个“块”的贪心策略

对于 [k, i] 这个区间里的 i - k + 1 个人,我们总共需要进行 i - k + 1 次超车。为了让超车代价总和最小,最贪心的想法当然是每次超车都花最小的代价

  1. 找到最小代价: 我们先在原始数组的 a[k]a[i](1-based index)中找到那个敏捷度最小的值,我们叫它 min_val,它最初在 min_idx 的位置上。

  2. 制定交换+超车策略:

    • 第一次超车 (超过第 i 个人): 我们需要让第 i 个位置的敏捷度变成 min_val。怎么办呢?当然是把 min_idx 位置上的 min_vali 位置上的值交换啦!这次交换的代价是 i - min_idx。然后,我们进行超车,代价是 min_val
    • 后续超车 (超过 i-1, ..., k): 现在 min_val 在第 i 个位置。当我们准备超过第 i-1 个人时,我们再把 min_vali 位置换到 i-1 位置,代价是 i - (i-1) = 1。然后超车,代价 min_val。这个过程就像“冒泡”一样,我们把 min_val 一路“冒泡”下去,每次交换代价都是 1
  3. 计算总代价:

    • 超车总代价: 一共 i - k + 1 次超车,每次代价都是 min_val。总共是 (i - k + 1) * min_val
    • 交换总代价:
      • 第一次把 min_val 换到位置 i 的代价是 i - min_idx
      • 之后 min_valii-1,从 i-1i-2,...,一直到从 k+1k,总共需要 i - k 次代价为 1 的交换。总代价是 i - k
      • 所以交换总代价是 (i - min_idx) + (i - k)

把它们加起来,处理一个块 [k, i] 的最小代价就是: cost_to_clear_block(k, i) = (i - k + 1) * min_val + (i - min_idx) + (i - k)

有了这个,我们就可以愉快地写出完整的 DP 过程啦!

  • 外层循环 i1n:计算 dp[i]
  • 内层循环 ji1:枚举最后一个块是 [j, i]
  • 在内层循环中,我们需要找到 [j, i] 区间内的 min_valmin_idx,然后套用上面的公式计算 block_cost,并更新 dp[i]

这样,dp[n] 就是我们想要的答案啦!

代码实现喵~

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

// 用一个很大的数表示无穷大,防止溢出喵
const long long INF = 4e18; 

void solve() {
    int n;
    std::cin >> n;
    std::vector<long long> a(n);
    for (int i = 0; i < n; ++i) {
        std::cin >> a[i];
    }

    // dp[i] 表示成功超过 1...i 号骑行者的最小代价
    std::vector<long long> dp(n + 1, INF);
    dp[0] = 0; // 超过0个人,代价为0,是我们的起点

    // i 从 1 到 n,计算 dp[i]
    for (int i = 1; i <= n; ++i) {
        long long min_val = 2e9 + 7; // 当前块的最小敏捷度
        long long min_idx = -1;      // 最小敏捷度的位置(1-based)

        // j 从 i 到 1,枚举最后一个块的起始位置 j
        for (int j = i; j >= 1; --j) {
            // 当前处理的块是 [j, i] (1-based index)
            // 对应到0-based数组是 a[j-1] 到 a[i-1]
            
            // 随着 j 减小,块向左扩展,我们顺便更新块内的最小值
            if (a[j - 1] < min_val) {
                min_val = a[j - 1];
                min_idx = j;
            }

            // 计算处理块 [j, i] 的总代价
            // 1. 交换代价:
            //    - 把 min_val 从 min_idx 换到 i,代价 i - min_idx
            //    - 之后把 min_val 从 i 冒泡到 j,代价 i - j
            // 2. 超车代价:
            //    - 共 i - j + 1 次超车,每次都用 min_val,总代价 (i - j + 1) * min_val
            long long block_cost = (long long)(i - j) + (long long)(i - min_idx) + (long long)(i - j + 1) * min_val;
            
            // 状态转移:从 dp[j-1] 转移到 dp[i]
            if (dp[j - 1] != INF) {
                dp[i] = std::min(dp[i], dp[j - 1] + block_cost);
            }
        }
    }

    // dp[n] 就是最终答案啦!
    std::cout << dp[n] << std::endl;
}

int main() {
    std::ios_base::sync_with_stdio(false);
    std::cin.tie(NULL);
    int t;
    std::cin >> t;
    while (t--) {
        solve();
    }
    return 0;
}

复杂度分析的说

  • 时间复杂度: O(n^2) 的说。我们有两个嵌套的循环,外层 i1n,内层 ji1。内部的操作都是常数时间,所以总的时间复杂度是 O(n^2)。对于 n <= 5000 的数据范围,这个复杂度是完全可以接受的,不会超时的!
  • 空间复杂度: O(n) 的说。我们主要用了一个 dp 数组来存储状态,大小为 n+1。所以空间复杂度是 O(n)。

知识点与总结

这道题真有趣,融合了好几种思想呢!

  1. 动态规划 (Dynamic Programming): 这是解题的整体框架。通过定义状态 dp[i],并将问题分解为“处理完前 j-1 个”和“处理块 [j, i]”两个子问题,我们构建了清晰的状态转移方程。这是解决最优化问题的强大武器!

  2. 贪心思想 (Greedy Strategy): 在解决子问题“如何以最小代价处理一个块”时,我们用了贪心策略。我们贪心地认为,每次超车都应该用这个块里最小的敏捷值,然后围绕这个目标去设计交换方案。事实证明这个贪心策略是正确的,它构成了我们 DP 转移的核心。

  3. 分块/划分思想: DP 的转移过程本质上是在对 1...i 这个序列进行划分。dp[i] 的值,是由所有可能的“最后一块” [j, i] 的情况中取最优值得到的。这种将问题划分为若干个“块”来处理的思路在很多题目中都很有用哦!

总之,遇到这类求最优解的问题,不妨先想想 DP 是不是一个可行的方向。然后,试着定义状态,思考如何从子问题推导出当前问题的解。有时候,子问题的解法本身可能就是一个小小的贪心或数学问题哦!希望这篇题解能帮到你,加油喵~!

Released under the MIT License.