2 条题解
-
0
作为教练,制作带限制的子序列和这类题目的数据时,核心在于构造能够让 DP 决策点在单调队列中频繁“换届”的序列,特别是针对 值的大小变化以及负数的分布进行压力测试。
本题的时间复杂度要求为 ,因此数据生成器中的标程逻辑必须严格使用单调队列优化,以确保在生成 级数据时瞬时完成。
一、 数据生成器代码 (C++14 标准)
该程序会自动循环生成
1.in/out到10.in/out。它包含了各种边界情况:全负数(考察非空子序列)、全正数、大跨度 值等。#include <iostream> #include <fstream> #include <vector> #include <string> #include <deque> #include <algorithm> #include <random> #include <ctime> using namespace std; // ================= 标准标程逻辑 (用于生成 .out) ================= // 采用单调队列优化 DP, 时间复杂度 O(N) long long solve_standard(int n, int k, const vector<int>& nums) { if (n == 0) return 0; vector<long long> dp(n); deque<int> dq; long long global_max = -2e18; // 保证非空子序列 for (int i = 0; i < n; ++i) { // 1. 弹出不在窗口 [i-k, i-1] 内的决策点 while (!dq.empty() && dq.front() < i - k) { dq.pop_front(); } // 2. 状态转移: dp[i] = nums[i] + max(0, max_dp_in_window) long long prev_best = dq.empty() ? 0 : max(0LL, dp[dq.front()]); dp[i] = nums[i] + prev_best; // 更新结果 global_max = max(global_max, dp[i]); // 3. 维护单调队列 (递减) while (!dq.empty() && dp[i] >= dp[dq.back()]) { dq.pop_back(); } dq.push_back(i); } return global_max; } // ================= 数据生成辅助函数 ================= void write_test_case(int id, int n, int k, const vector<int>& nums) { string in_name = to_string(id) + ".in"; string out_name = to_string(id) + ".out"; // 写入输入文件 ofstream fout(in_name); fout << n << " " << k << "\n"; for (int i = 0; i < n; ++i) { fout << nums[i] << (i == n - 1 ? "" : " "); } fout << endl; fout.close(); // 生成标准输出 long long result = solve_standard(n, k, nums); ofstream fans(out_name); fans << result << endl; fans.close(); cout << "Testcase " << id << " generated: N=" << n << ", k=" << k << endl; } // ================= 主生成逻辑 ================= int main() { mt19937 rng(time(0)); uniform_int_distribution<int> val_dist(-10000, 10000); for (int i = 1; i <= 10; ++i) { int n, k; vector<int> nums; if (i == 1) { // 样例 1 n = 5; k = 2; nums = {10, 2, -10, 5, 20}; } else if (i == 2) { // 样例 2 (全负数) n = 3; k = 1; nums = {-1, -2, -3}; } else if (i == 3) { // 样例 3 n = 5; k = 2; nums = {10, -2, -10, -5, 20}; } else if (i == 4) { // 边界:k=1 (只能选相邻或跳过) n = 1000; k = 1; for(int j=0; j<n; ++j) nums.push_back(val_dist(rng)); } else if (i == 5) { // 边界:k=n (可以跳过任何负数,选所有正数) n = 1000; k = n; for(int j=0; j<n; ++j) nums.push_back(val_dist(rng)); } else if (i == 6) { // 全正数极限 n = 10000; k = 500; for(int j=0; j<n; ++j) nums.push_back(rng() % 10000); } else if (i == 7) { // 密集负数,少量极大正数 n = 10000; k = 10; for(int j=0; j<n; ++j) { if (j % 100 == 0) nums.push_back(10000); else nums.push_back(-10000); } } else if (i == 8) { // 锯齿形波动 n = 50000; k = 100; for(int j=0; j<n; ++j) nums.push_back(j % 2 == 0 ? 10000 : -10000); } else if (i <= 10) { // 极限随机数据 n = 100000; k = rng() % n + 1; for(int j=0; j<n; ++j) nums.push_back(val_dist(rng)); } write_test_case(i, n, k, nums); } return 0; }
二、 测试点设计思路(创建 OJ 评测点参考)
作为教练,我们需要通过以下维度确保选手代码的鲁棒性:
测试点 数据特征 考察重点 1-3 官方样例 确保基础逻辑与题目完全一致。 4 限制最严的情况,考察 DP 基础状态转移。 5 限制最松的情况,考察是否能正确识别“只选正数”的最优策略。 6 全正数 考察 long long累加是否溢出(最大值可达 )。7 稀疏正数 考察单调队列在长距离负数区间内维护最大值的能力。 8 剧烈波动 考察单调队列频繁 pop_back和pop_front的性能。9-10 极限规模 性能测试:确保算法是 而非 。
三、 考场避坑建议
-
非空子序列: 题目要求是非空子序列。如果数组全是负数(如
[-5, -2, -10]),结果应该是-2而不是0。在代码实现中,ans的初始值应设为极小值或第一个元素的dp值。 -
数值溢出: ,每个数最大 ,理论最大和为 ,虽然在
int范围内,但为了安全起见,NOI 选手的标配是 全程long long。 -
单调队列的性质:
- 队首弹出:检查决策点是否过期(下标差距 )。
- 队尾弹出:检查新决策点是否比旧决策点更优(
dp[i] >= dp[dq.back()])。 - 取值逻辑:只有当队首的
dp值为正时才累加,否则当前元素自立门户更好。
-
非递归安全: 该 DP 方案本身是迭代实现的,生成器和标程均无递归,在任何系统栈限制下都能稳定运行。
这份生成器生成的
.in和.out格式严谨,可以直接用于创建练习赛。加油! -
-
0
你好,同学。这道题是 单调队列优化动态规划(DP) 的进阶实战。在 NOI 竞赛中,单调队列是解决“区间最值”类转移方程的最优工具。
以下是基于 C++14 标准 的满分题解代码。
一、 标程代码 (C++14 标准)
#include <iostream> #include <vector> #include <deque> #include <algorithm> using namespace std; /** * 题目:带限制的子序列和 * 核心思想:动态规划 + 单调队列优化 * * 状态定义:dp[i] 表示以 nums[i] 结尾的满足条件的子序列最大和。 * 转移方程:dp[i] = nums[i] + max(0, max{dp[j]}) 其中 i-k <= j < i * 优化点:使用单调队列维护窗口 [i-k, i-1] 内 dp 值的最大值。 */ void solve() { int n, k; if (!(cin >> n >> k)) return; vector<int> nums(n); for (int i = 0; i < n; ++i) cin >> nums[i]; // dp[i] 可能达到 10^9 级别(10^5 * 10^4),int 勉强够,但竞赛建议用 long long vector<long long> dp(n); // dq 存储下标,维护 dp 值的单调递减 deque<int> dq; long long ans = -2e18; // 初始化为极小值 for (int i = 0; i < n; ++i) { // 1. 弹出过时的下标:如果队首不在窗口 [i-k, i-1] 内 while (!dq.empty() && dq.front() < i - k) { dq.pop_front(); } // 2. 状态转移 // 队首就是窗口内 dp 值最大的下标。如果最大值比 0 小,我们宁愿不选前面的。 long long prev_max = dq.empty() ? 0 : max(0LL, dp[dq.front()]); dp[i] = nums[i] + prev_max; // 更新全局最大值 ans = max(ans, dp[i]); // 3. 维护单调队列的递减性 // 如果当前 dp[i] 比队尾的 dp 大,说明队尾已经没有存在的意义了(更旧且更弱) while (!dq.empty() && dp[i] >= dp[dq.back()]) { dq.pop_back(); } dq.push_back(i); } cout << ans << endl; } int main() { // NOI 竞赛必备优化:加速 I/O 效率 ios::sync_with_stdio(false); cin.tie(0); solve(); return 0; }
二、 复杂度分析思考过程
1. 时间复杂度:
- 推演过程:
- 外层循环遍历 个元素。
- 内部虽然有
while循环,但观察每个下标 :它最多只会被push_back进队列一次,也最多只会被从队首或队尾pop出去一次。 - 均摊分析:所有队列操作的总次数上限是 。
- 结论:总时间复杂度为线性 。即使 均达到 ,计算量也仅在 级别,1.0s 时限内绰绰有余。
2. 空间复杂度:
- 我们需要一个 的
dp数组来记录状态。 - 单调队列在最坏情况下(数据单调递增)可能存储 个元素,空间为 。
- 结论:总空间复杂度为 。
三、 时间复杂度优化建议 (NOI 提高组技巧)
-
数组模拟双端队列 (Manual Deque):
std::deque内部为了支持复杂的内存块管理,常数较大。在 NOI 考场上,如果时限给得非常紧(如 0.5s 处理 数据),手写数组模拟队列可以快 2-3 倍:int q[100005]; int head = 0, tail = -1; // push_back: q[++tail] = i; // pop_back: tail--; // pop_front: head++; -
原地滚动优化: 如果题目只要求最终的
ans,且内存极其受限,可以发现 只依赖于前 个值,但由于单调队列需要引用历史下标的 值,完全滚动较为复杂。在 256MB 的限制下, 存储是绝对安全的。 -
快读 (Fast I/O): 对于 量级的整数输入,
ios::sync_with_stdio(false)已经足够。如果数据量提升到 ,建议使用getchar()版本的自定义读入函数。
四、 总结:读题关键词与思维锚点
在 NOI 竞赛中,看到以下关键词的组合,应立刻定位到“单调队列优化 DP”:
- “子序列” (Subsequence):考虑 DP 状态 表示以第 个数结尾的结果。
- “相邻下标距离限制 ”:意味着 的来源范围被限定在一个滑动窗口内 。
- “最大和”:转移方程中涉及 操作,且是在滑动窗口内找最大。
- “”:排除 暴力,目标必须是线性 。
教练寄语: 单调队列优化 DP 的本质是决策排除。新产生的决策点如果比旧的决策点更强且更年轻,那么旧的就可以永远被丢弃。掌握了这种“优胜劣汰”的筛选法,你就掌握了线性优化 DP 的精髓。加油!
- 推演过程:
- 1
信息
- ID
- 19383
- 时间
- 1000ms
- 内存
- 128MiB
- 难度
- 10
- 标签
- (无)
- 递交数
- 1
- 已通过
- 1
- 上传者