1 条题解
-
0
题目重述
给定一个长度为 的数组 。
一个子序列(不要求连续)被称为 “整齐子序列”,如果对于任意一个值 ,它在子序列中出现的次数要么是 ,要么恰好等于 。例如,可以包含 个 、 个 、 个 ,但不能包含 个 或 个 。
求最长的整齐子序列的长度。
动态规划状态定义
设 表示考虑前缀 时,能获得的最长整齐子序列的长度。
显然, 是非递减的:
因为前缀 的解一定是前缀 的解的一个子集,或者可以从前缀 的解继承过来(不选 )。
转移方程推导
当我们计算 时,考虑 是否被选入整齐子序列:
情况 1:不选
此时最优解就是前缀 的最优解,即:
情况 2:选
如果 被选中,那么根据整齐子序列的定义,值 在子序列中必须出现恰好 次。
并且我们假设 是子序列的最后一个元素(因为我们是从左到右递推的,可以这样考虑)。为了最大化长度,我们希望这 个 在数组中 尽可能靠右 地被选取,这样前面的前缀 就能容纳更多的元素(因为 单调递增)。
具体来说,设 是数组 中值 的 第 大的下标,即从右往左数第 个 的位置。
那么我们可以这样构造子序列:- 从 中取一个最优整齐子序列(长度为 )
- 再取上 位置开始的这 个 (即 中所有等于 的 个元素)
这样构造出的子序列中,值 恰好出现 次(因为从 到 正好有 个 ,并且 是第 个 ),并且前面的部分与这些 互不干扰(因为 及之前没有额外的 被选入?等一下,需要小心)
更严格的分析:
如果 是第 大的 的位置,那么从 到 恰好有 个 。
如果我们在子序列中包含这 个 ,并且保证子序列中其他位置的值与它们不冲突,那么这样是合法的。
前面的部分 可以独立地选出一个最优整齐子序列(长度为 ),加上这 个元素,总长度为:因此,情况 2 的候选答案为:
最终转移
综合两种情况:
$$dp(i) = \max \left( dp(i-1),\; dp(x-1) + a_i \right) $$其中 是值 在 中出现的倒数第 个位置(即第 大的下标)。
如果 在 中出现的次数不足 ,则情况 2 不可能发生,此时只有 。
高效实现
我们需要在 时间内找到 。
维护队列
对于每个不同的值 ,我们维护一个队列 ,存储它出现的下标。
当遍历到 时:- 将 入队 。
- 如果 的大小超过 ,则弹出队首(因为队列只保留最近的 个出现位置)。
- 如果 的大小恰好等于 ,则队首元素就是 (即倒数第 个 的位置)。
于是转移可以快速完成:
$$dp(i) = \max(dp(i-1),\; dp(q[a_i].front() - 1) + a_i) $$
代码解析
#include <bits/stdc++.h> using namespace std; typedef long long ll; const int MAXN = 2e5 + 10; int T, n, a[MAXN], dp[MAXN]; deque<int> q[MAXN]; int main() { for (scanf("%d", &T); T--; ) { scanf("%d", &n); for (int i = 1; i <= n; i++) q[i].clear(); // 清空所有队列 for (int i = 1; i <= n; i++) scanf("%d", &a[i]); for (int i = 1; i <= n; i++) { dp[i] = dp[i - 1]; // 不选 a[i] 的情况 q[a[i]].emplace_back(i); // 将当前位置入队 if (q[a[i]].size() > a[i]) q[a[i]].pop_front(); // 保持队列长度 ≤ a[i] if (q[a[i]].size() == a[i]) { // 恰好有 a[i] 个 a[i] 在队列中 int x = q[a[i]].front(); // x 是倒数第 a[i] 个 a[i] 的位置 dp[i] = max(dp[i], dp[x - 1] + a[i]); // 更新 dp[i] } } printf("%d\n", dp[n]); } }
复杂度分析
- 每个位置 只入队一次、出队最多一次,因此所有队列操作的总复杂度为 。
- 每个 的计算是 。
- 总时间复杂度: 每个测试用例。
- 空间复杂度: 用于存储队列和数组。
总结
这道题的关键在于:
- 利用 的单调性,贪心地选择最靠右的 个 。
- 用队列维护每个值最近的出现位置,快速找到转移所需的 。
- 状态转移方程:
其中 是值 的倒数第 次出现位置。
- 1
信息
- ID
- 6219
- 时间
- 1000ms
- 内存
- 256MiB
- 难度
- 4
- 标签
- 递交数
- 2
- 已通过
- 1
- 上传者