1 条题解
-
0
一、问题分析与数学建模
1.1 问题本质
这是一道动态规划 + 单调队列优化的经典问题,核心在于理解子任务的得分机制并设计高效的状态转移。
问题形式化
给定:
- 个选手, 组测试数据
- 分数数组 ,其中 表示第 组测试数据的分数
- 结果矩阵 ,其中 表示第 个选手通过了第 组测试数据
子任务定义:
- 将 组测试数据划分成 个连续的子任务()
- 每个子任务包含连续的测试数据区间
得分规则: 对于选手 和子任务 :
$$\text{Score}_i([l,r]) = \begin{cases} \sum_{j=l}^{r} \texttt{Points}[j] & \text{如果选手 } i \text{ 通过了 } [l,r] \text{ 中所有测试数据} \\ 0 & \text{否则} \end{cases} $$目标:对于每个 ,求将测试数据分成恰好 个子任务时,所有选手得分之和的最小值。
1.2 核心数学观察
观察 1:选手通过子任务的充要条件
命题 1:选手 通过子任务 当且仅当对于所有 ,都有 。
推论:如果选手 在区间 中有任意一个测试数据未通过,则该选手在这个子任务中得 0 分。
观察 2:预处理最后出错位置
为了快速判断选手是否通过某个子任务,我们定义:
定义: 表示选手 在位置 或之前最后一次出错的位置。
形式化定义:
$$\texttt{pre}[i][j] = \begin{cases} j & \text{如果 } \texttt{Results}[i][j] = 0 \\ \texttt{pre}[i][j-1] & \text{如果 } \texttt{Results}[i][j] = 1 \end{cases} $$关键性质:选手 通过子任务 当且仅当 。
证明:
- 若 ,则选手 在 区间内没有出错,因此通过该子任务 ✓
- 若 ,则选手 在 区间内至少有一次出错,因此得 0 分 ✓
观察 3:动态规划状态设计
状态定义:
$$\texttt{dp}[i][j] = \text{前 } i \text{ 组测试数据分成 } j \text{ 个子任务时的最小总分} $$初始状态:
$$\texttt{dp}[0][0] = 0, \quad \texttt{dp}[i][j] = +\infty \text{ (其他)} $$目标答案:
观察 4:状态转移方程
考虑最后一个子任务 (),则:
$$\texttt{dp}[j][x+1] = \min_{k=x-1}^{j-1} \left\{ \texttt{dp}[k][x] + \text{cost}(k+1, j) \right\} $$其中 表示子任务 的总得分:
$$\text{cost}(k+1, j) = \text{cnt}(k, j) \times \sum_{i=k+1}^{j} \texttt{Points}[i] $$这里 表示能够通过子任务 的选手数量:
$$\text{cnt}(k, j) = \left| \{ i : \texttt{pre}[i][j] \le k \} \right| $$直观理解:
- 如果有 个选手通过了子任务
- 每个选手获得的分数是
- 因此这个子任务的总得分是两者的乘积
1.3 朴素算法的复杂度瓶颈
朴素动态规划的时间复杂度为 ,对于 的数据规模,会超时。
瓶颈分析:
- 外层循环: 个子任务
- 中层循环: 个结束位置
- 内层循环: 个起始位置
需要优化内层循环。
二、算法优化:单调队列优化 DP
2.1 转移方程的变形
将状态转移方程改写:
$$\texttt{dp}[j][x+1] = \min_{k} \left\{ \texttt{dp}[k][x] + \text{cnt}(k, j) \times \left( \texttt{sum}[j] - \texttt{sum}[k] \right) \right\} $$其中 $\texttt{sum}[i] = \sum_{t=1}^{i} \texttt{Points}[t]$ 是分数的前缀和。
展开:
$$\texttt{dp}[j][x+1] = \min_{k} \left\{ \texttt{dp}[k][x] + \text{cnt}(k, j) \times \texttt{sum}[j] - \text{cnt}(k, j) \times \texttt{sum}[k] \right\} $$由于 对于固定的 和不同的 可能不同(因为 依赖于 ),我们需要重新组织。
2.2 按 值分组
关键观察:对于固定的 ,随着 从小到大变化, 是单调非递减的。
证明:
- $\text{cnt}(k, j) = \left| \{ i : \texttt{pre}[i][j] \le k \} \right|$
- 当 增大时,满足条件的选手数量只会增加或保持不变 ✓
进一步观察: 的取值只会在某些特殊的 值处发生变化,这些特殊值恰好是 (对于所有选手 )。
分组策略: 将所有 $\texttt{pre}[1][j], \texttt{pre}[2][j], \ldots, \texttt{pre}[N][j]$ 收集起来并排序,记为 。
对于 , 的值是固定的,等于 (有 个选手的 值 )。
2.3 单调队列维护最小值
对于每个 值(记为 ),我们需要维护:
$$\min_{k \in [p_t, p_{t+1})} \left\{ \texttt{dp}[k][x] - c \times \texttt{sum}[k] \right\} $$然后加上 得到最终的转移值。
单调队列优化:
- 对于每个 值,维护一个单调队列
- 队列中存储 $(\text{位置} k, \texttt{dp}[k][x] - c \times \texttt{sum}[k])$
- 队列保持单调递增,队首是最小值
- 当 的范围变化时,动态更新队列
2.4 算法流程总结
对于第 个子任务(基于前 个子任务的结果):
- 初始化:为每个可能的 值创建一个单调队列
- 枚举结束位置 :
- 收集所有 的值,加入 和 ,排序得到分界点
- 对于每个区间 , 值为 :
- 更新第 个单调队列,将区间 内的转移点加入
- 从队首取出最小值,加上 ,更新
三、代码模块详解
模块 1:头文件与全局变量
#include <bits/stdc++.h> #define re register #define int long long #define chmin(a,b) (a = min(a,b)) using namespace std; typedef pair<int, int> pii; const int N = 2e4 + 10, M = 60; const int inf = (int)(1e18) + 10; int n, m, S; char s[M][N]; int arr[N], sum[N]; int pre[M][N], dp[N][M];数据结构说明:
n:选手数量m:测试数据数量S:最大子任务数s[i][j]:第 个选手在第 组测试数据的结果('0' 或 '1')arr[i]:第 组测试数据的分数sum[i]:分数的前缀和,pre[i][j]:选手 在位置 或之前最后一次出错的位置dp[i][j]:前 组测试数据分成 个子任务的最小总分
模块 2:单调队列结构
struct { int L, R; deque<pii> q; inline void clear() { L = 0, R = -1; while (!q.empty()) q.pop_back(); } inline void update(int l, int r, int x, int cnt) { // 扩展右边界,加入新的转移点 while (R < r) { R++; int val = dp[R][x] - cnt * sum[R]; // 维护单调性:弹出所有 >= val 的元素 while (!q.empty() && q.back().snd >= val) q.pop_back(); q.push_back({R, val}); } // 收缩左边界,移除不在范围内的元素 while (L < l) { while (!q.empty() && q.front().fst <= L) q.pop_front(); L++; } } } mq[M];单调队列的关键操作:
-
clear():清空队列,重置边界L和R记录当前维护的区间
-
update(l, r, x, cnt):更新队列以维护区间-
扩展右边界:将 内的点加入队列
- 计算转移值:$\texttt{val} = \texttt{dp}[R][x] - \texttt{cnt} \times \texttt{sum}[R]$
- 维护单调性:弹出队尾所有 的元素
- 将新元素加入队尾
-
收缩左边界:移除 内的点
- 如果队首元素的位置 ,则弹出
- 更新
-
单调性质:队列中的元素按照转移值单调递增,队首是最小值。
数学推导: 对于固定的 值,转移方程变为:
$$\texttt{dp}[j][x+1] = \min_{k \in [l, r]} \left\{ \texttt{dp}[k][x] - \text{cnt} \times \texttt{sum}[k] \right\} + \text{cnt} \times \texttt{sum}[j] $$单调队列维护的是 $\texttt{dp}[k][x] - \text{cnt} \times \texttt{sum}[k]$ 的最小值。
模块 3:输入与预处理
signed main() { n = read(), m = read(), S = read(); // 读入分数并计算前缀和 for (re int i = 1; i <= m; i++) sum[i] = sum[i - 1] + (arr[i] = read()); // 读入结果矩阵并预处理 pre 数组 for (re int i = 1; i <= n; i++) { scanf("%s", s[i] + 1); for (re int j = 1; j <= m; j++) { if (s[i][j] == '0') pre[i][j] = j; // 当前位置出错 else pre[i][j] = pre[i][j - 1]; // 继承上一个出错位置 } }预处理逻辑:
-
前缀和计算:
用于快速计算子任务 的分数和:
-
pre数组的递推:- 如果 (出错),则
- 如果 (通过),则
正确性:这样递推后, 始终保存了选手 在 区间内最后一次出错的位置(如果从未出错,则为 0)。
模块 4:动态规划主循环
// 初始化 DP 数组 for (re int i = 0; i <= m; i++) { for (re int j = 0; j <= S; j++) dp[i][j] = inf; } dp[0][0] = 0; // 枚举子任务数 for (re int i = 0; i < S; i++) { // 清空所有单调队列 for (re int j = 0; j <= n; j++) mq[j].clear(); // 枚举当前子任务的结束位置 for (re int j = i + 1; j <= m; j++) { vector<int> pt; // 收集所有分界点 for (re int k = 1; k <= n; k++) { pt.push_back(pre[k][j]); } pt.push_back(0); pt.push_back(j); sort(pt.begin(), pt.end());外层循环:枚举已有的子任务数 ,计算 个子任务的情况。
中层循环:枚举当前子任务的结束位置 。
收集分界点:
- 将所有 ()收集到
pt中 - 加入 (下界)和 (上界)
- 排序后去重
分界点的意义:
- 在区间 内,能通过子任务 的选手数量是固定的,等于
- 因此可以按照选手数量分组处理
模块 5:分组转移与单调队列优化
for (re int p = 0; p < pt.size() - 1; p++) { if (pt[p] == pt[p + 1]) continue; // 更新第 p 个单调队列,维护区间 [pt[p], pt[p+1]-1] mq[p].update(pt[p], pt[p + 1] - 1, i, p); // 从队首取出最小值,更新 dp[j][i+1] chmin(dp[j][i + 1], mq[p].q.front().snd + p * sum[j]); } } }分组转移的核心逻辑:
对于每个分界点区间 :
-
跳过空区间:如果 ,说明没有新的转移点
-
更新单调队列:
- 调用
mq[p].update(pt[p], pt[p+1]-1, i, p) - 将区间 内的转移点加入第 个队列
- 队列中维护的是 的最小值
- 调用
-
计算转移值:
- 从队首取出最小值:$\texttt{minval} = \texttt{dp}[k][i] - p \times \texttt{sum}[k]$
- 加上当前项:
- 更新
数学推导:
$$\begin{aligned} \texttt{dp}[j][i+1] &= \min \left\{ \texttt{dp}[k][i] + p \times (\texttt{sum}[j] - \texttt{sum}[k]) \right\} \\ &= \min \left\{ \texttt{dp}[k][i] - p \times \texttt{sum}[k] \right\} + p \times \texttt{sum}[j] \\ &= \texttt{mq}[p].q.\text{front}().\text{snd} + p \times \texttt{sum}[j] \end{aligned} $$为什么要分组?
- 因为不同的 值对应不同的 值
- 只有 值相同时,才能用统一的公式计算
- 分组后,每组内的转移系数()是相同的
模块 6:输出答案
for (re int i = 1; i <= S; i++) printf("%lld\n", dp[m][i]); return 0; }输出:对于每个 ,输出 ,即将所有 组测试数据分成 个子任务时的最小总分。
四、正确性证明
4.1 状态转移的正确性
定理 1:状态转移方程正确刻画了问题的递推关系。
证明:
- 考虑前 组测试数据分成 个子任务
- 最后一个子任务为 ()
- 前 组数据分成 个子任务,最优值为
- 子任务 的得分为 $\text{cnt}(k, j) \times (\texttt{sum}[j] - \texttt{sum}[k])$
- 总分为两者之和,取最小值即得 ✓
4.2 单调队列优化的正确性
定理 2:单调队列正确维护了区间最小值。
证明:
- 队列中的元素按照转移值单调递增
- 队首元素是最小值
- 当新元素加入时,弹出所有 新值的元素,保持单调性
- 当区间收缩时,弹出不在范围内的元素
- 因此队首始终是当前区间的最小值 ✓
4.3 分组策略的正确性
定理 3:按照 值分组后,每组内的 值是固定的。
证明:
- 对于固定的 ,$\text{cnt}(k, j) = \left| \{ i : \texttt{pre}[i][j] \le k \} \right|$
- 当 时,满足 的选手数量恰好为
- 因此 在该区间内是常数 ✓
五、复杂度分析
5.1 时间复杂度
预处理:
- 读入并计算前缀和:
- 预处理
pre数组:
动态规划:
- 外层循环:
- 中层循环:
- 收集分界点并排序:
- 分组转移:对于每个 ,每个转移点 至多被加入队列一次,至多被弹出一次,因此均摊
总时间复杂度:
简化为:
对于 ,约为 次操作,可以通过。
5.2 空间复杂度
dp数组:pre数组:- 单调队列:(最坏情况)
总空间复杂度:
六、算法总结与启发
6.1 核心思想
本题采用的动态规划 + 单调队列优化算法的核心思想:
- DP 建模:将问题抽象为划分问题,设计 DP 状态和转移方程
- 预处理优化:预处理
pre数组,快速判断选手是否通过子任务 - 分组转移:按照选手数量分组,将不同 值的转移分开处理
- 单调队列优化:对于每个 值,使用单调队列维护区间最小值,降低时间复杂度
6.2 关键技巧
-
预处理最后出错位置:
- 通过递推快速计算
pre数组 - 利用
pre数组快速判断选手是否通过子任务
- 通过递推快速计算
-
分组转移:
- 观察到 只在特殊位置变化
- 按照 值分组,避免重复计算
-
单调队列优化:
- 将区间最小值查询从 优化到均摊
- 关键是维护队列的单调性
6.3 适用场景
- ✅ 区间划分问题
- ✅ DP 转移需要维护区间最小值/最大值
- ✅ 转移系数分段恒定的问题
- ✅ 需要单调队列优化的 DP
七、易错点与注意事项
7.1 易错点 1:
pre数组的初始化错误做法:忘记初始化
pre[i][0] = 0正确做法:
- 如果选手从未出错,
pre[i][j]应该保持为 0 - 递推时正确继承上一个状态
7.2 易错点 2:分界点的处理
错误做法:忘记加入 0 和 作为边界
正确做法:
- 必须加入 (下界),表示没有选手通过的情况
- 必须加入 (上界),确保所有转移点都被覆盖
7.3 易错点 3:单调队列的边界
错误做法:队列的左右边界
L和R初始化错误正确做法:
- 初始时
L = 0, R = -1,表示空区间 - 更新时先扩展右边界,再收缩左边界
7.4 易错点 4:数据类型溢出
问题:$N \times (\texttt{Points}[1] + \ldots + \texttt{Points}[T]) \le 2 \times 10^9$,需要使用
long long解决:代码中使用
#define int long long,确保所有整数运算都是 64 位的。
八、知识点总结
8.1 核心算法技巧
-
✅ 动态规划
- 区间划分 DP
- 状态设计与转移
-
✅ 单调队列
- 维护区间最小值
- 均摊 查询
-
✅ 预处理优化
- 前缀和
- 最后出错位置的递推
-
✅ 分组优化
- 按照转移系数分组
- 避免重复计算
九、总结
本题是一道经典的动态规划优化问题,综合考察了:
- DP 状态设计与转移
- 预处理技巧
- 单调队列优化
- 分组处理技巧
- 1
信息
- ID
- 4077
- 时间
- 3000ms
- 内存
- 128MiB
- 难度
- 8
- 标签
- 递交数
- 1
- 已通过
- 1
- 上传者