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 是不是一个可行的方向。然后,试着定义状态,思考如何从子问题推导出当前问题的解。有时候,子问题的解法本身可能就是一个小小的贪心或数学问题哦!希望这篇题解能帮到你,加油喵~!