喵~ 主人好呀!今天由我,你最可爱的小猫娘,来为你讲解一道有趣的动态规划问题——Block Sequence (CF 1881E) 喵!
这个问题就像是整理一排猫抓板,要让它们排列得整整齐齐、漂漂亮亮的,一起来看看怎么做吧!
题目大意
题目会给我们一个长度为 n
的整数序列 a
。
我们需要把这个序列变成一个“美丽序列”。什么样的序列是“美丽序列”呢?一个序列如果能被分成若干个“块”,并且每个块都满足 “块的第一个元素”等于“这个块的长度”,那么它就是美丽的喵。
举个例子:序列 [3, 3, 4, 5, 2, 6, 1]
就是一个美丽序列。 它可以被分成两个块:
- 第一个块是
[3, 3, 4, 5]
。它的第一个元素是3
,但整个块的长度是4
,这不对喵... 啊,我搞错了,让本喵再看看! 正确的划分应该是:[3, 3, 4, 5]
和[2, 6, 1]
。- 第一个块是
[3, 3, 4, 5]
。它的第一个元素是3
,而这个块恰好由3
个元素(3, 4, 5
)跟在它后面,所以总长度是1 + 3 = 4
。啊,题目里的意思是块的第一个元素是块的长度!所以[3, 3, 4, 5]
的第一个元素是3
,那么这个块就应该是[3, x, y]
这样子,长度为 3。 - 让我们重新理解一下喵~!一个块的形式是
[k, element_1, element_2, ..., element_k]
。所以,整个块的长度是k+1
。 - 那么,
[3, 3, 4, 5, 2, 6, 1]
这个序列,应该是这样划分的:- 第一个块:
[3, 3, 4, 5]
。它的第一个元素是3
,后面跟着3
个元素3, 4, 5
。这个块是合法的。 - 第二个块:
[2, 6, 1]
。它的第一个元素是2
,后面跟着2
个元素6, 1
。这个块也是合法的。
- 第一个块:
- 所以,
[3, 3, 4, 5, 2, 6, 1]
是一个美丽序列!
- 第一个块是
而序列 [1, 4, 3]
就不是,因为第一个块的长度应该是 1
,那么块本身就是 [1]
。多出来的 4, 3
就没法处理了。
我们每次操作可以从序列中 删除任意一个元素。
问题是:最少需要多少次删除操作,才能让原始序列变成一个美丽序列呢?
题解方法
这个问题要求“最少操作次数”,一看到这种最优化问题,本喵的直觉就告诉我,可以用 动态规划 (Dynamic Programming, DP) 来解决!就像猫猫总能找到最舒服的午睡地点一样喵~
我们的思路是从后往前看这个序列。为什么从后往前呢?因为一个块 [k, ...]
的决策会影响到它后面的序列,从后往前处理,可以保证当我们在决定位置 i
的元素何去何从时,它后面所有可能的情况我们都已经算过了,拿到了最优解。
我们来定义一个 dp
数组,就像猫猫的小鱼干仓库一样喵~ dp[i]
表示:将原始序列的后缀 a[i...n-1]
变成本地序列,最少需要删除多少个元素。
我们的最终目标,就是求出 dp[0]
,也就是让整个序列 a[0...n-1]
变美丽的最小删除次数。
接下来,我们来思考 dp[i]
该怎么计算。对于在位置 i
的元素 a[i]
,我们有两种选择,就像猫猫看到一个毛线球,是扑上去玩呢,还是不理它走开呢?
删除
a[i]
(不理它走开喵~) 如果我们决定删除a[i]
,那么我们就付出了1
次删除操作的代价。接下来,问题就变成了“如何让剩下的后缀a[i+1...n-1]
变美丽”,而这个问题的答案,我们已经定义好了,就是dp[i+1]
! 所以,这种选择的总代价是1 + dp[i+1]
。保留
a[i]
(扑上去玩!) 如果我们决定保留a[i]
,那么它必须成为一个新块的开始。设a[i]
的值是k
。根据美丽序列的定义,这个新块的长度应该是k
,它会包含a[i]
自己,以及它后面的k
个元素。 也就是说,这个块会占用序列中从i
到i+k
的所有位置。 这个选择有一个前提条件:数组得有足够多的元素来组成这个块,也就是i + k
不能超过数组的末尾。换句话说,下一个块的起始位置i + k + 1
必须小于等于n
(n
是数组的长度,n
这个下标可以看作是数组末尾之后的位置)。 如果这个条件满足,我们就可以成功地组成一个块[a[i], a[i+1], ..., a[i+k]]
。我们没有删除这个块里的任何元素。接下来,问题就变成了“如何让从i + k + 1
开始的后缀a[i+k+1...n-1]
变美丽”,这个问题的答案就是dp[i+k+1]
。 所以,这种选择的总代价是dp[i+k+1]
。
综合以上两种选择,dp[i]
就应该是这两种选择中,代价更小的那一个! 所以我们的状态转移方程就是: dp[i] = 1 + dp[i+1]
(这是默认选择,即删除 a[i]
) 如果 i + a[i] + 1 <= n
,那么我们还有一个选择:dp[i] = min(dp[i], dp[i + a[i] + 1])
最后,我们需要一个 边界条件。当 i=n
时,dp[n]
表示要让一个空序列(从下标 n
开始的后缀)变美丽需要多少次删除。空序列本身就是美丽的,所以 dp[n] = 0
。
我们从 i = n-1
开始,一步步倒着计算到 i = 0
,最终 dp[0]
就是我们想要的答案啦!
题解
下面是具体的 C++ 代码实现,本喵加了一些注释,方便主人理解喵~
#include <iostream>
#include <vector>
#include <algorithm>
void solve() {
int n;
std::cin >> n;
std::vector<int> a(n);
for (int i = 0; i < n; ++i) {
std::cin >> a[i];
}
// dp[i] 表示将后缀 a[i...n-1] 变美丽所需的最小删除次数。
// 数组大小为 n+1,是为了处理边界情况 dp[n]。
std::vector<int> dp(n + 1);
// 边界条件:一个空后缀是美丽的,不需要删除任何元素。
dp[n] = 0;
// 我们从后往前遍历数组来计算 dp 值。
// 这样可以确保在计算 dp[i] 时,所有它依赖的 dp 值(如 dp[i+1])都已经计算好了。
for (int i = n - 1; i >= 0; --i) {
// 选择 1:删除 a[i]。
// 代价是 1 (删除 a[i]) + dp[i+1] (处理剩余部分的代价)。
dp[i] = 1 + dp[i + 1];
// 选择 2:保留 a[i],并以它为首构建一个块。
// 块的长度由 a[i] 决定。
int k = a[i];
// 下一个块的起始下标。
int next_start_idx = i + k + 1;
// 检查是否能构成一个完整的块(即块不会超出数组边界)。
if (next_start_idx <= n) {
// 如果可以,那么这个选择的代价就是处理从 next_start_idx 开始的后缀的代价。
// 我们在两种选择中取一个最小值。
dp[i] = std::min(dp[i], dp[next_start_idx]);
}
}
// dp[0] 存储了处理整个数组 a[0...n-1] 的最小删除次数。
std::cout << dp[0] << "\n";
}
int main() {
// 加上这个可以让输入输出快一点,喵~
std::ios_base::sync_with_stdio(false);
std::cin.tie(NULL);
int t;
std::cin >> t;
while (t--) {
solve();
}
return 0;
}
知识点介绍
这道题的核心知识点是 动态规划 (Dynamic Programming, DP)。
什么是动态规划? 动态规划是一种通过将复杂问题分解成更小的、重叠的子问题来解决问题的算法思想。它通常用于求解最优化问题(如求最大值、最小值、最长序列等)。
动态规划的两个核心特性:
最优子结构 (Optimal Substructure) 如果一个问题的最优解可以由其子问题的最优解有效地构造出来,那么这个问题就具有最优子结构。 在这道题里,让整个序列
a[0...n-1]
变美丽的最小删除次数,依赖于让其后缀a[1...n-1]
或a[k+1...n-1]
变美丽的最小删除次数。这就是最优子结构的体现。重叠子问题 (Overlapping Subproblems) 在用递归等方式解决问题时,如果相同的子问题被反复计算多次,那么这个问题就具有重叠子问题的性质。 比如,在计算
dp[i]
和dp[j]
时,它们可能都需要dp[k]
的值。动态规划通过“记忆化”或“自底向上”的填表方式,确保每个子问题只被计算一次,然后将结果存储起来,供后续计算使用,从而大大提高了效率。
我们这道题的解法就是典型的“自底向上”填表法。我们从最小的子问题 dp[n]
开始,然后逐步计算更大的子问题 dp[n-1]
, dp[n-2]
, ..., 直到我们要求解的原始问题 dp[0]
。这样就保证了计算的顺序是正确的,并且每个子问题只求解一次。
希望这篇题解能帮到主人哦,如果还有什么不明白的,随时可以再来问本喵!喵~