F1. Cycling (Easy Version) - 题解
比赛与标签
比赛: Codeforces Round 1023 (Div. 2) 标签: binary search, brute force, dp, greedy 难度: *2300
题目大意喵~
主人 Sama,你好呀!这道题是说,有一个叫 Leo 的程序员小哥,要去见他的恋人,喵~ 但是路上有 n 个骑行者挡住了他的去路,他们从前到后编号为 1 到 n。Leo 最初在第 n 个骑行者的后面。
为了见到心上人,Leo 可以做两种操作:
- 超车: 假设 Leo 正跟在第
i个骑行者后面,他可以花费a_i的代价超过这个人,然后他就到了第i-1个骑行者的后面。 - 交换: Leo 可以使用超能力,花费
j - i的代价,交换第i个和第j个骑行者的敏捷度a_i和a_j(这里要求i < j)。这个操作可以随时、任意次使用。
我们的任务就是,帮助 Leo 计算一下,他从第 n 个骑行者后面出发,一直到超过第 1 个骑行者,所需要花费的最小总代价是多少呢?
简单来说,就是要依次超过从 n 到 1 的所有人,期间可以通过交换来改变超车时的 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 次超车。为了让超车代价总和最小,最贪心的想法当然是每次超车都花最小的代价!
找到最小代价: 我们先在原始数组的
a[k]到a[i](1-based index)中找到那个敏捷度最小的值,我们叫它min_val,它最初在min_idx的位置上。制定交换+超车策略:
- 第一次超车 (超过第
i个人): 我们需要让第i个位置的敏捷度变成min_val。怎么办呢?当然是把min_idx位置上的min_val和i位置上的值交换啦!这次交换的代价是i - min_idx。然后,我们进行超车,代价是min_val。 - 后续超车 (超过
i-1, ..., k): 现在min_val在第i个位置。当我们准备超过第i-1个人时,我们再把min_val从i位置换到i-1位置,代价是i - (i-1) = 1。然后超车,代价min_val。这个过程就像“冒泡”一样,我们把min_val一路“冒泡”下去,每次交换代价都是1。
- 第一次超车 (超过第
计算总代价:
- 超车总代价: 一共
i - k + 1次超车,每次代价都是min_val。总共是(i - k + 1) * min_val。 - 交换总代价:
- 第一次把
min_val换到位置i的代价是i - min_idx。 - 之后
min_val从i到i-1,从i-1到i-2,...,一直到从k+1到k,总共需要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 过程啦!
- 外层循环
i从1到n:计算dp[i]。 - 内层循环
j从i到1:枚举最后一个块是[j, i]。 - 在内层循环中,我们需要找到
[j, i]区间内的min_val和min_idx,然后套用上面的公式计算block_cost,并更新dp[i]。
这样,dp[n] 就是我们想要的答案啦!
代码实现喵~
#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) 的说。我们有两个嵌套的循环,外层
i从1到n,内层j从i到1。内部的操作都是常数时间,所以总的时间复杂度是 O(n^2)。对于n <= 5000的数据范围,这个复杂度是完全可以接受的,不会超时的! - 空间复杂度: O(n) 的说。我们主要用了一个
dp数组来存储状态,大小为n+1。所以空间复杂度是 O(n)。
知识点与总结
这道题真有趣,融合了好几种思想呢!
动态规划 (Dynamic Programming): 这是解题的整体框架。通过定义状态
dp[i],并将问题分解为“处理完前j-1个”和“处理块[j, i]”两个子问题,我们构建了清晰的状态转移方程。这是解决最优化问题的强大武器!贪心思想 (Greedy Strategy): 在解决子问题“如何以最小代价处理一个块”时,我们用了贪心策略。我们贪心地认为,每次超车都应该用这个块里最小的敏捷值,然后围绕这个目标去设计交换方案。事实证明这个贪心策略是正确的,它构成了我们 DP 转移的核心。
分块/划分思想: DP 的转移过程本质上是在对
1...i这个序列进行划分。dp[i]的值,是由所有可能的“最后一块”[j, i]的情况中取最优值得到的。这种将问题划分为若干个“块”来处理的思路在很多题目中都很有用哦!
总之,遇到这类求最优解的问题,不妨先想想 DP 是不是一个可行的方向。然后,试着定义状态,思考如何从子问题推导出当前问题的解。有时候,子问题的解法本身可能就是一个小小的贪心或数学问题哦!希望这篇题解能帮到你,加油喵~!